Chore: Move UI into core/cloud apps & extract shared package

This commit is contained in:
2026-08-17 18:52:28 +05:45
parent 37b3de7fd9
commit 505d9a55d7
186 changed files with 7757 additions and 4306 deletions
-9
View File
@@ -1,9 +0,0 @@
# Development environment variables
# Change this to your local API URL if needed (e.g., http://localhost:3001/api)
PORT=8080
API_TARGET=http://127.0.0.1:5000
ALLOWED_HOST=127.0.0.1
VITE_API_BASE_URL=http://127.0.0.1:5000/api
VITE_WS_BASE_URL=http://127.0.0.1:6001
VITE_WS_PATH=/ws
VITE_XCLOUDIFY_API_KEY=5acf3975172647190cd5bd239b7ce2c0a37b3cf31559499482b78c514f09879d
+3
View File
@@ -24,3 +24,6 @@ dist-ssr
*.sw?
.env
.env.local
+15 -19
View File
@@ -1,21 +1,17 @@
# Use official Node.js v23.10.0 base image
FROM node:23.10.0
# Set working directory
WORKDIR /code
# Copy only package files first (for better caching)
COPY package*.json ./
# Install dependencies
# 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
RUN npm install
RUN npm install vite
# Copy the rest of the code
FROM deps AS runtime
WORKDIR /w
COPY . .
# Expose port 8080
EXPOSE 8080
# Default command
CMD ["sh", "-c", "/code/start_webserver.sh"]
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}'
+4
View File
@@ -0,0 +1,4 @@
# Cloud plane. The VITE_ prefix is required -- Vite only exposes prefixed
# vars to the browser.
VITE_API_BASE_URL=http://localhost:5001/api
VITE_WS_BASE_URL=http://localhost:6001
+14
View File
@@ -0,0 +1,14 @@
# Public cloud portal.
services:
portal:
build:
context: ../..
dockerfile: Dockerfile
container_name: xcloudify-portal
environment:
APP: cloud
PORT: "8080"
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5001/api}
VITE_WS_BASE_URL: ${VITE_WS_BASE_URL:-http://localhost:6001}
ports:
- "8080:8080"
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xcloudify Cloud</title>
<meta name="description" content="Public cloud portal: projects, VDCs, VMs, containers, storage" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@xcloudify/cloud",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": { "@xcloudify/shared": "*" }
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../packages/shared/postcss.config.js";
+107
View File
@@ -0,0 +1,107 @@
/**
* Public cloud portal: what a tenant uses, plus the tenancy administration
* for whoever runs the product.
*
* Talks to the cloud plane (:5001). Tenant routes go through cloud's VDC
* gateway, which force-stamps tenant_id; /admin/* routes go through cloud's
* operator gateway and are gated on is_platform_admin.
*
* Deliberately has no underlay: hosts, regions, images, networks, volumes,
* GPUs, DNS and cross-tenant workloads are core's, and are administered from
* the core console (apps/core) instead. Cloud's operator scope stops at the
* tenancy that lives in cloud's own database -- universes, projects, VDCs.
*/
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AppShell } from "@/AppShell";
import {
cloudNavItems, cloudAdminNavItems, generalNavItems,
} from "@/components/AppSidebar";
import { RequireAdmin } from "@/components/RequireAdmin";
import { Dashboard } from "@/components/Dashboard";
import { ExplorePage } from "@/components/ExplorePage";
import { InfrastructurePage } from "@/components/InfrastructurePage";
import { StoragePage } from "@/components/StoragePage";
import { ServerGroupsPage } from "@/components/ServerGroupsPage";
import { SSHKeysPage } from "@/components/SSHKeysPage";
import { AIServicesPage } from "@/components/AIServicesPage";
import { ServerlessPage } from "@/components/ServerlessPage";
import { GitDeployPage } from "@/components/GitDeployPage";
import { BillingPage } from "@/components/BillingPage";
import { ReferralsPage } from "@/components/ReferralsPage";
import { CertificatesPage } from "@/components/CertificatesPage";
import { VirtualMachineDetailsWrapper } from "@/components/VirtualMachineDetailsWrapper";
import { VolumeDetailsWrapper } from "@/components/VolumeDetailsWrapper";
import { NetworkDetailsPage } from "@/components/NetworkDetailsPage";
import { CreateFunctionWizard } from "@/components/CreateFunctionWizard";
import { CreateDeploymentWizard } from "@/components/CreateDeploymentWizard";
import { FunctionDetailsWrapper } from "@/components/FunctionDetailsPage";
import { DeploymentDetailsPage } from "@/components/DeploymentDetailsPage";
import { CloudAdminDashboard } from "@/components/CloudAdminDashboard";
import { UniversesPage } from "@/components/UniversesPage";
import { ProjectsPage } from "@/components/ProjectsPage";
import { ProjectDetailsWrapper } from "@/components/ProjectDetailsWrapper";
import { VirtualDataCentersPage } from "@/components/VirtualDataCentersPage";
import { VirtualDataCenterDetailsWrapper } from "@/components/VirtualDataCenterDetailsWrapper";
import NotFound from "@/pages/NotFound";
const navGroups = [
{ label: "Main", items: cloudNavItems },
{ label: "Administration", items: cloudAdminNavItems },
{ label: "General", items: generalNavItems },
];
const admin = (el: JSX.Element) => <RequireAdmin>{el}</RequireAdmin>;
const Stub = ({ title }: { title: string }) => (
<div className="p-4">{title} page coming soon...</div>
);
export default function App() {
return (
<BrowserRouter>
<AppShell plane="cloud" title="xCloudify Dashboard" navGroups={navGroups}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/explore" element={<ExplorePage />} />
<Route path="/infrastructure" element={<InfrastructurePage />} />
<Route path="/infrastructure/:resourceType" element={<InfrastructurePage />} />
<Route path="/infrastructure/:resourceType/:resourceId" element={<InfrastructurePage />} />
<Route path="/infrastructure/virtual-machines/:vmId/details" element={<VirtualMachineDetailsWrapper />} />
<Route path="/infrastructure/networks/:networkId/details" element={<NetworkDetailsPage />} />
<Route path="/storage" element={<StoragePage />} />
<Route path="/storage/:volumeId/details" element={<VolumeDetailsWrapper />} />
<Route path="/server-groups" element={<ServerGroupsPage />} />
<Route path="/ssh-keys" element={<SSHKeysPage />} />
<Route path="/certificates" element={<CertificatesPage />} />
<Route path="/ai-services" element={<AIServicesPage />} />
<Route path="/serverless" element={<ServerlessPage />} />
<Route path="/serverless/create" element={<CreateFunctionWizard />} />
<Route path="/serverless/functions/:functionId/details" element={<FunctionDetailsWrapper />} />
<Route path="/git-deploy" element={<GitDeployPage />} />
<Route path="/git-deploy/create" element={<CreateDeploymentWizard />} />
<Route path="/git-deploy/:deploymentId/details" element={<DeploymentDetailsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/referrals" element={<ReferralsPage />} />
{/* Administration: cloud's own tenancy. Nothing here touches core
directly -- every underlay screen lives in the core console. */}
<Route path="/admin" element={admin(<CloudAdminDashboard />)} />
<Route path="/admin/universes" element={admin(<UniversesPage />)} />
<Route path="/admin/universes/:universeId" element={admin(<UniversesPage />)} />
<Route path="/admin/projects" element={admin(<ProjectsPage />)} />
<Route path="/admin/projects/:projectId" element={admin(<ProjectDetailsWrapper />)} />
<Route path="/admin/virtual-data-centers" element={admin(<VirtualDataCentersPage />)} />
<Route path="/admin/virtual-data-centers/:vdcId" element={admin(<VirtualDataCenterDetailsWrapper />)} />
{/* Nav entries that are styled as disabled but still routable, as
before the split -- landing on a stub beats landing on a 404. */}
<Route path="/my-templates" element={<Stub title="My Templates" />} />
<Route path="/changelog" element={<Stub title="Changelog" />} />
<Route path="/vote" element={<Stub title="Vote feature" />} />
<Route path="*" element={<NotFound />} />
</Routes>
</AppShell>
</BrowserRouter>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "@/index.css";
createRoot(document.getElementById("root")!).render(<App />);
+5
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "../../packages/shared/src"]
}
+16
View File
@@ -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 ?? "8080", 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") } },
});
+11
View File
@@ -0,0 +1,11 @@
# Bare theapi/core backend, no plane in front of it. Must match
# XCLOUDIFY_API_KEY in theapi/core/.env exactly -- it's the shared secret
# core checks on every request in this mode (see docker-compose.yml).
# localhost:5000 assumes core is reachable there -- locally, or via an SSH
# tunnel to wherever core actually runs.
#
# The VITE_ prefix is required: Vite only exposes prefixed vars to the
# browser. Without it these are silently ignored and the app falls back to
# its hardcoded defaults, which point at the cloud plane.
VITE_API_BASE_URL=http://localhost:5000/api
VITE_XCLOUDIFY_API_KEY=change-me-generate-a-real-key
+16
View File
@@ -0,0 +1,16 @@
# Operator console against a bare core backend (no plane in front of it).
# VITE_* names are passed straight through -- they must keep the prefix all
# the way to the build, since that is what Vite exposes to the browser.
services:
core-console:
build:
context: ../..
dockerfile: Dockerfile
container_name: xcloudify-core-console
environment:
APP: core
PORT: "8083"
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:5000/api}
VITE_XCLOUDIFY_API_KEY: ${VITE_XCLOUDIFY_API_KEY:?set this to core's XCLOUDIFY_API_KEY (theapi/core/.env on the box behind the tunnel) so the console can authenticate}
ports:
- "8083:8083"
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xcloudify — Operator Console</title>
<meta name="description" content="Underlay operator console: hosts, regions, images, capacity" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@xcloudify/core",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": { "@xcloudify/shared": "*" }
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../packages/shared/postcss.config.js";
+77
View File
@@ -0,0 +1,77 @@
/**
* Operator console for the core underlay: hosts, workloads, networks,
* storage, regions, images, capacity.
*
* Core itself is identity-free and holds no user accounts, so normally this
* app never talks to core directly -- it points at whichever identity plane
* is deployed (cloud or community) and uses that plane's /api/admin/core/*
* gateway. Both planes serve that surface identically from the shared
* allowlist, which is what lets one console work against either. Set
* VITE_API_BASE_URL to the plane in front of it.
*
* Exception: a bare `--core` deploy (deploy-dev.sh, no plane at all) instead
* builds this app with VITE_XCLOUDIFY_API_KEY set, which points it straight
* at core with the service key attached (see xcloudifyApi.ts). Deliberate,
* non-internet-facing-demo-only tradeoff -- that key is unrestricted admin
* over every tenant with no scoping, so this must never be set for a build
* that could reach a real user.
*
* There is no tenant-facing half and no tenancy administration: universes,
* projects, VDCs, certificates and billing are cloud's, and core has no
* tables for them. Shared screens that would otherwise offer cloud-only
* controls -- ZeroTier network joins, Cloudflare-backed public ports, the
* saved SSH key vault -- are told to drop them by plane="core"; see plane.tsx.
*/
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { AppShell } from "@/AppShell";
import { underlayNavItems } from "@/components/AppSidebar";
import { RequireAdmin } from "@/components/RequireAdmin";
import { CoreAdminDashboard } from "@/components/CoreAdminDashboard";
import { WorkloadHostsPage } from "@/components/WorkloadHostsPage";
import { WorkloadHostDetailsWrapper } from "@/components/WorkloadHostDetailsWrapper";
import { WorkloadsPage } from "@/components/WorkloadsPage";
import { AdminVirtualMachineDetailsWrapper } from "@/components/AdminVirtualMachineDetailsWrapper";
import { NetworksPage } from "@/components/NetworksPage";
import { AdminDnsPage } from "@/components/AdminDnsPage";
import { AdminVolumesPage } from "@/components/AdminVolumesPage";
import { AdminServerGroupsPage } from "@/components/AdminServerGroupsPage";
import { AdminGpuPage } from "@/components/AdminGpuPage";
import { RegionsPage } from "@/components/RegionsPage";
import { RegionDetailsWrapper } from "@/components/RegionDetailsWrapper";
import { ImagesPage } from "@/components/ImagesPage";
import { AdminAuditPage } from "@/components/AdminAuditPage";
import NotFound from "@/pages/NotFound";
const navGroups = [{ label: "Underlay", items: underlayNavItems }];
export default function App() {
return (
<BrowserRouter>
<AppShell plane="core" title="xCloudify Core Console" navGroups={navGroups}>
<RequireAdmin>
<Routes>
<Route path="/" element={<Navigate to="/admin" replace />} />
<Route path="/admin" element={<CoreAdminDashboard />} />
<Route path="/admin/workload-hosts" element={<WorkloadHostsPage />} />
<Route path="/admin/workload-hosts/:hostId" element={<WorkloadHostDetailsWrapper />} />
<Route path="/admin/workloads" element={<WorkloadsPage />} />
<Route path="/admin/workloads/virtual-machines/:vmId" element={<AdminVirtualMachineDetailsWrapper />} />
<Route path="/admin/networks" element={<NetworksPage allowCreate />} />
<Route path="/admin/networks/:networkId" element={<NetworksPage allowCreate />} />
<Route path="/admin/dns" element={<AdminDnsPage />} />
<Route path="/admin/volumes" element={<AdminVolumesPage />} />
<Route path="/admin/volumes/:volumeId" element={<AdminVolumesPage />} />
<Route path="/admin/server-groups" element={<AdminServerGroupsPage />} />
<Route path="/admin/gpus" element={<AdminGpuPage />} />
{/* VDCs are a cloud concept; a region on core has hosts, not VDCs. */}
<Route path="/admin/regions" element={<RegionsPage showVdcs={false} />} />
<Route path="/admin/regions/:regionId" element={<RegionDetailsWrapper showVdcs={false} />} />
<Route path="/admin/images" element={<ImagesPage />} />
<Route path="/admin/audit" element={<AdminAuditPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</RequireAdmin>
</AppShell>
</BrowserRouter>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "@/index.css";
createRoot(document.getElementById("root")!).render(<App />);
+5
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "../../packages/shared/src"]
}
+16
View File
@@ -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 ?? "8083", 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") } },
});
-21
View File
@@ -1,21 +0,0 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "${PORT:-8080}:${PORT:-8080}"
environment:
- PORT=${PORT:-8080}
- API_TARGET=${API_TARGET:-https://xcloudify-staging.hawkvelt.tech}
- ALLOWED_HOST=${ALLOWED_HOST:-xcloudify-staging.hawkvelt.tech}
- VITE_XCLOUDIFY_API_KEY=${VITE_XCLOUDIFY_API_KEY:-}
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-https://xcloudify-staging.hawkvelt.tech/api}
- VITE_WS_BASE_URL=${VITE_WS_BASE_URL:-https://xcloudify-staging.hawkvelt.tech}
- VITE_WS_PATH=${VITE_WS_PATH:-/ws}
volumes:
- ./src:/app/src
- ./public:/app/public
- ./index.html:/app/index.html
- /app/node_modules
restart: unless-stopped
-27
View File
@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GPU Cloud - IaaS Portal</title>
<meta name="description" content="IaaS Portal for GPU and Docker containers management" />
<meta name="author" content="Lovable" />
<meta property="og:title" content="GPU Cloud - IaaS Portal" />
<meta property="og:description" content="IaaS Portal for GPU and Docker containers management" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@lovable_dev" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
</head>
<body>
<div id="root"></div>
<!-- IMPORTANT: DO NOT REMOVE THIS SCRIPT TAG OR THIS VERY COMMENT! -->
<script src="https://cdn.gpteng.co/gptengineer.js" type="module"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2228 -2711
View File
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -1,14 +1,16 @@
{
"name": "vite_react_shadcn_ts",
"name": "xcloudify-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
"dev:core": "npm run dev -w @xcloudify/core",
"dev:cloud": "npm run dev -w @xcloudify/cloud",
"build": "npm run build --workspaces --if-present",
"build:core": "npm run build -w @xcloudify/core",
"build:cloud": "npm run build -w @xcloudify/cloud",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint ."
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
@@ -80,5 +82,9 @@
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
},
"workspaces": [
"packages/*",
"apps/*"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@xcloudify/shared",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Shared component library and plane-capability layer for the core and cloud portals."
}
+91
View File
@@ -0,0 +1,91 @@
/**
* The chrome every authenticated portal shares: providers, sidebar, header.
*
* Each app supplies its own plane, title, nav groups, and routes; nothing
* plane-specific lives here. The PlaneProvider is what lets shared components
* further down (the create wizard, PodCard) render the right surface for core
* or cloud without either app forking a copy -- see plane.tsx.
*
* Layout is the pre-split shell verbatim. Page components assume it: most of
* them render a bare `space-y-6` root and get their gutter from the `p-6`
* here, and `pb-20` is what keeps the last row clear of the developer
* console, which is `fixed` to the bottom of the viewport.
*/
import { ReactNode, useEffect, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { AppSidebar, NavGroup } from "@/components/AppSidebar";
import { UserProfile } from "@/components/UserProfile";
import { DeveloperWindow } from "@/components/DeveloperWindow";
import { useDeveloperConsole } from "@/hooks/useDeveloperConsole";
import { setApiMonitorCallback } from "@/services/xcloudifyApi";
import { PlaneProvider, PlaneId, PlaneCapabilities } from "@/plane";
const queryClient = new QueryClient();
export function AppShell({
plane,
title,
capabilityOverrides,
navGroups,
children,
}: {
plane: PlaneId;
/** Shown next to the sidebar trigger. */
title: string;
capabilityOverrides?: Partial<PlaneCapabilities>;
navGroups: NavGroup[];
children: ReactNode;
}) {
const { apiCalls, addApiCall, clearApiCalls } = useDeveloperConsole();
// On by default, as before the split. The `developer_console_enabled`
// cookie overrides it, and the toggle in user settings writes that cookie.
const [devConsoleVisible, setDevConsoleVisible] = useState(true);
useEffect(() => {
setApiMonitorCallback(addApiCall);
const cookie = document.cookie
.split("; ")
.find((row) => row.startsWith("developer_console_enabled="))
?.split("=")[1];
if (cookie !== undefined) setDevConsoleVisible(cookie === "true");
const onToggle = (e: Event) =>
setDevConsoleVisible((e as CustomEvent).detail.enabled);
window.addEventListener("developer-console-toggle", onToggle);
return () => window.removeEventListener("developer-console-toggle", onToggle);
}, [addApiCall]);
return (
<PlaneProvider plane={plane} overrides={capabilityOverrides}>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<SidebarProvider>
<div className="min-h-screen flex w-full bg-background">
<AppSidebar groups={navGroups} />
<div className="flex-1 flex flex-col overflow-hidden">
<header className="h-14 border-b border-border bg-background flex items-center justify-between px-4">
<div className="flex items-center">
<SidebarTrigger className="mr-4" />
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
</div>
<UserProfile />
</header>
<main className="flex-1 overflow-auto p-6 pb-20">{children}</main>
</div>
</div>
</SidebarProvider>
{devConsoleVisible && (
<DeveloperWindow apiCalls={apiCalls} onClear={clearApiCalls} />
)}
</TooltipProvider>
</QueryClientProvider>
</PlaneProvider>
);
}
@@ -0,0 +1,205 @@
import { useState, useEffect, useCallback } from 'react';
import { ScrollText, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { xcloudifyApi } from '@/services/xcloudifyApi';
interface AuditLogEntry {
id: string;
action: string;
description: string;
timestamp: string;
object_type: string;
object_id?: string;
user_id?: string;
is_error?: boolean;
}
const PAGE_SIZE = 50;
function formatDate(dateString?: string) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString();
} catch {
return dateString;
}
}
export function AdminAuditPage() {
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionTypes, setActionTypes] = useState<string[]>([]);
const [objectTypeFilter, setObjectTypeFilter] = useState('');
const [actionFilter, setActionFilter] = useState('');
useEffect(() => {
xcloudifyApi.getAuditActionTypes()
.then((res: any) => setActionTypes(res?.data?.actions || []))
.catch(() => setActionTypes([]));
}, []);
const fetchLogs = useCallback(async (currentOffset: number, action: string, objectType: string) => {
try {
setIsLoading(true);
setError(null);
const result = await xcloudifyApi.getAuditLog({
limit: PAGE_SIZE,
offset: currentOffset,
...(action ? { action } : {}),
...(objectType ? { object_type: objectType } : {}),
});
setLogs(result.logs || []);
setTotal(result.pagination?.total || 0);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch audit log');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchLogs(offset, actionFilter, objectTypeFilter);
}, [offset, actionFilter, objectTypeFilter, fetchLogs]);
const clearFilters = () => {
setActionFilter('');
setObjectTypeFilter('');
setOffset(0);
};
const hasActiveFilters = actionFilter || objectTypeFilter;
return (
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<ScrollText className="h-8 w-8" />
Audit Log
</h1>
<p className="text-muted-foreground">Every logged action across every tenant, newest first</p>
</div>
<Button onClick={() => fetchLogs(offset, actionFilter, objectTypeFilter)} disabled={isLoading} variant="outline">
{isLoading ? 'Loading...' : 'Refresh'}
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Select value={actionFilter || '__all__'} onValueChange={(v) => { setActionFilter(v === '__all__' ? '' : v); setOffset(0); }}>
<SelectTrigger className="w-64">
<SelectValue placeholder="Filter by action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All actions</SelectItem>
{actionTypes.map((a) => (
<SelectItem key={a} value={a}>{a}</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="max-w-xs"
placeholder="Filter by object_type (e.g. Workload)..."
value={objectTypeFilter}
onChange={(e) => setObjectTypeFilter(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') setOffset(0); }}
/>
{hasActiveFilters && (
<Button onClick={clearFilters} variant="ghost" size="sm">
<X className="h-4 w-4 mr-1" /> Clear
</Button>
)}
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Log Entries</CardTitle>
<CardDescription>{total} total entries{hasActiveFilters ? ' matching these filters' : ''}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<p>Loading audit log...</p>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Timestamp</TableHead>
<TableHead>Action</TableHead>
<TableHead>Object Type</TableHead>
<TableHead>Object ID</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="whitespace-nowrap text-sm">{formatDate(log.timestamp)}</TableCell>
<TableCell>
<Badge variant="outline">{log.action}</Badge>
</TableCell>
<TableCell className="text-sm">{log.object_type}</TableCell>
<TableCell className="font-mono text-xs">{log.object_id || '-'}</TableCell>
<TableCell className="text-sm max-w-md truncate" title={log.description}>{log.description}</TableCell>
<TableCell>
{log.is_error ? <Badge variant="destructive">Error</Badge> : <Badge variant="outline">OK</Badge>}
</TableCell>
</TableRow>
))}
{logs.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No audit entries found{hasActiveFilters && ' matching these filters'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex items-center justify-between pt-4">
<p className="text-sm text-muted-foreground">
{total > 0 ? `Showing ${offset + 1}-${Math.min(offset + PAGE_SIZE, total)} of ${total}` : ''}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
>
<ChevronLeft className="h-4 w-4 mr-1" /> Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Next <ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,316 @@
import { useState, useCallback } from 'react';
import { Globe2, Plus, Trash2, Pencil, Search } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { CopyButton } from '@/components/ui/copy-button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { DeleteConfirmationDialog } from '@/components/DeleteConfirmationDialog';
import { useToast } from '@/hooks/use-toast';
import { xcloudifyApi } from '@/services/xcloudifyApi';
interface DnsRecordItem {
id: string;
tenant_id: string;
custom_fqdn: string;
record_type: string;
target_value: string;
target_pod_id?: string | null;
ttl: number;
description?: string;
created_at?: string;
[key: string]: any;
}
const RECORD_TYPES = ['A', 'CNAME'];
const emptyForm = { custom_fqdn: '', target_value: '', record_type: 'A', ttl: '300', target_pod_id: '', description: '' };
function formatDate(dateString?: string) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString();
} catch {
return dateString;
}
}
/**
* Operator console for custom DNS aliases. Unlike the other admin listings,
* core has no cross-tenant "list all" for DNS records -- they only exist
* scoped under a tenant -- so a tenant_id must be entered before anything
* loads, rather than treated as an optional narrowing filter.
*/
export function AdminDnsPage() {
const [tenantId, setTenantId] = useState('');
const [loadedTenantId, setLoadedTenantId] = useState('');
const [records, setRecords] = useState<DnsRecordItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editing, setEditing] = useState<DnsRecordItem | null>(null);
const [form, setForm] = useState(emptyForm);
const [isSaving, setIsSaving] = useState(false);
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; record: DnsRecordItem | null; isLoading: boolean }>({
open: false,
record: null,
isLoading: false,
});
const { toast } = useToast();
const fetchRecords = useCallback(async (id: string) => {
if (!id) return;
try {
setIsLoading(true);
setError(null);
const data = await xcloudifyApi.getAdminDnsRecords(id);
setRecords(data);
setLoadedTenantId(id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch DNS records');
setRecords([]);
} finally {
setIsLoading(false);
}
}, []);
const openCreate = () => {
setEditing(null);
setForm(emptyForm);
setIsFormOpen(true);
};
const openEdit = (record: DnsRecordItem) => {
setEditing(record);
setForm({
custom_fqdn: record.custom_fqdn,
target_value: record.target_value,
record_type: record.record_type,
ttl: String(record.ttl),
target_pod_id: record.target_pod_id || '',
description: record.description || '',
});
setIsFormOpen(true);
};
const handleSave = async () => {
if (!form.custom_fqdn.trim() || !form.target_value.trim()) {
toast({ title: 'Validation Error', description: 'FQDN and target value are required', variant: 'destructive' });
return;
}
const payload = {
custom_fqdn: form.custom_fqdn.trim(),
target_value: form.target_value.trim(),
record_type: form.record_type as 'A' | 'CNAME',
ttl: parseInt(form.ttl, 10) || 300,
...(form.target_pod_id.trim() ? { target_pod_id: form.target_pod_id.trim() } : {}),
description: form.description.trim(),
};
setIsSaving(true);
try {
if (editing) {
await xcloudifyApi.updateAdminDnsRecord(editing.id, payload);
toast({ title: 'Success', description: 'DNS record updated successfully' });
} else {
await xcloudifyApi.createAdminDnsRecord(loadedTenantId, payload);
toast({ title: 'Success', description: 'DNS record created successfully' });
}
setIsFormOpen(false);
fetchRecords(loadedTenantId);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to save DNS record', variant: 'destructive' });
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!deleteDialog.record) return;
setDeleteDialog((prev) => ({ ...prev, isLoading: true }));
try {
await xcloudifyApi.deleteAdminDnsRecord(deleteDialog.record.id);
toast({ title: 'Success', description: 'DNS record deleted successfully' });
setDeleteDialog({ open: false, record: null, isLoading: false });
fetchRecords(loadedTenantId);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to delete DNS record', variant: 'destructive' });
setDeleteDialog((prev) => ({ ...prev, isLoading: false }));
}
};
return (
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Globe2 className="h-8 w-8" />
DNS Records
</h1>
<p className="text-muted-foreground">Custom DNS aliases for a tenant's pods</p>
</div>
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2" disabled={!loadedTenantId} onClick={openCreate}>
<Plus className="h-4 w-4" /> Create DNS Record
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? 'Edit DNS Record' : 'Create DNS Record'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="dnsFqdn">Custom FQDN *</Label>
<Input id="dnsFqdn" value={form.custom_fqdn} onChange={(e) => setForm({ ...form, custom_fqdn: e.target.value })} placeholder="api.myapp.mycloud.local" />
</div>
<div>
<Label htmlFor="dnsType">Record Type</Label>
<Select value={form.record_type} onValueChange={(value) => setForm({ ...form, record_type: value })}>
<SelectTrigger id="dnsType">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{RECORD_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="dnsTarget">Target Value *</Label>
<Input id="dnsTarget" value={form.target_value} onChange={(e) => setForm({ ...form, target_value: e.target.value })} placeholder="IP address or target FQDN" />
</div>
<div>
<Label htmlFor="dnsTtl">TTL (seconds)</Label>
<Input id="dnsTtl" type="number" value={form.ttl} onChange={(e) => setForm({ ...form, ttl: e.target.value })} />
</div>
<div>
<Label htmlFor="dnsPod">Target Pod ID (optional)</Label>
<Input id="dnsPod" value={form.target_pod_id} onChange={(e) => setForm({ ...form, target_pod_id: e.target.value })} placeholder="UUID of a pod in this tenant" />
</div>
<div>
<Label htmlFor="dnsDescription">Description</Label>
<Textarea id="dnsDescription" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<Button onClick={handleSave} className="w-full" disabled={!form.custom_fqdn.trim() || !form.target_value.trim() || isSaving}>
{isSaving ? 'Saving...' : editing ? 'Save Changes' : 'Create DNS Record'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Tenant ID..."
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchRecords(tenantId.trim())}
/>
</div>
<Button onClick={() => fetchRecords(tenantId.trim())} disabled={!tenantId.trim() || isLoading} variant="secondary">
{isLoading ? 'Loading...' : 'Load Records'}
</Button>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Records{loadedTenantId ? ` for tenant ${loadedTenantId}` : ''}</CardTitle>
<CardDescription>
{loadedTenantId ? 'Custom aliases for this tenant\'s pods' : 'Enter a tenant ID above and load its records'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<p>Loading...</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>FQDN</TableHead>
<TableHead>Type</TableHead>
<TableHead>Target</TableHead>
<TableHead>TTL</TableHead>
<TableHead>Target Pod</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{r.custom_fqdn}
<CopyButton text={r.custom_fqdn} />
</div>
</TableCell>
<TableCell><Badge variant="outline">{r.record_type}</Badge></TableCell>
<TableCell className="font-mono text-xs">{r.target_value}</TableCell>
<TableCell>{r.ttl}s</TableCell>
<TableCell className="font-mono text-xs">{r.target_pod_id || '-'}</TableCell>
<TableCell>{formatDate(r.created_at)}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => openEdit(r)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteDialog({ open: true, record: r, isLoading: false })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{records.length === 0 && !isLoading && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
{loadedTenantId ? 'No DNS records found for this tenant' : 'No records loaded yet'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<DeleteConfirmationDialog
open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog((prev) => ({ ...prev, open }))}
onConfirm={handleDelete}
isLoading={deleteDialog.isLoading}
title="Delete DNS Record"
description="Traffic to this alias will stop resolving. This cannot be undone."
itemName={deleteDialog.record?.custom_fqdn || deleteDialog.record?.id || ''}
itemType="DNS record"
/>
</div>
);
}
@@ -0,0 +1,224 @@
import { useState, useEffect, useCallback } from 'react';
import { Cpu, Search, X } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { CopyButton } from '@/components/ui/copy-button';
import { xcloudifyApi } from '@/services/xcloudifyApi';
interface GpuModelItem {
id: string;
name?: string;
vendor_name?: string;
device_name?: string;
vram_mb?: number;
architecture?: string;
[key: string]: any;
}
interface GpuHostItem {
id: string;
name?: string;
pci_address: string;
workload_host_id: string;
gpu_status: string;
assigned_workload_id?: string | null;
gpu_model?: GpuModelItem | null;
[key: string]: any;
}
function statusVariant(status?: string): 'default' | 'secondary' | 'destructive' | 'outline' {
const s = (status || '').toLowerCase();
if (s === 'available') return 'default';
if (s === 'in-use') return 'secondary';
if (s === 'error' || s === 'failed') return 'destructive';
return 'outline';
}
/**
* GPU inventory for the operator console. Read-only: GPUs are declared by
* workers on startup (POST /gpus) and released when a VM is deleted -- there
* is no browser-driven mutation path, by design, so this only ever lists.
*/
export function AdminGpuPage() {
const [tab, setTab] = useState<'hosts' | 'models'>('hosts');
const [models, setModels] = useState<GpuModelItem[]>([]);
const [hosts, setHosts] = useState<GpuHostItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hostFilter, setHostFilter] = useState('');
const [appliedHostFilter, setAppliedHostFilter] = useState('');
const fetchData = useCallback(async (workloadHostId: string) => {
try {
setIsLoading(true);
setError(null);
const [modelData, hostData] = await Promise.all([
xcloudifyApi.getAdminGpuModels(),
xcloudifyApi.getAdminGpuHosts(workloadHostId ? { workload_host_id: workloadHostId } : undefined),
]);
setModels(modelData);
setHosts(hostData);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch GPU inventory');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchData(appliedHostFilter);
}, [appliedHostFilter, fetchData]);
const applyFilter = () => setAppliedHostFilter(hostFilter.trim());
const clearFilter = () => {
setHostFilter('');
setAppliedHostFilter('');
};
return (
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Cpu className="h-8 w-8" />
GPUs
</h1>
<p className="text-muted-foreground">
Every GPU slot and model registered across the underlay. Read-only -- GPUs are declared by workers, not created here.
</p>
</div>
<Button onClick={() => fetchData(appliedHostFilter)} disabled={isLoading} variant="outline">
{isLoading ? 'Loading...' : 'Refresh'}
</Button>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
</CardContent>
</Card>
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as 'hosts' | 'models')}>
<TabsList>
<TabsTrigger value="hosts">GPU Hosts</TabsTrigger>
<TabsTrigger value="models">GPU Models</TabsTrigger>
</TabsList>
<TabsContent value="hosts">
<div className="flex items-center gap-2 my-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Filter by workload_host_id..."
value={hostFilter}
onChange={(e) => setHostFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilter()}
/>
</div>
<Button onClick={applyFilter} variant="secondary">Apply</Button>
{appliedHostFilter && (
<Button onClick={clearFilter} variant="ghost" size="sm">
<X className="h-4 w-4 mr-1" /> Clear
</Button>
)}
</div>
<Card>
<CardHeader>
<CardTitle>GPU Hosts</CardTitle>
<CardDescription>Physical GPU slots and their current allocation status</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-32"><p>Loading...</p></div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Model</TableHead>
<TableHead>Status</TableHead>
<TableHead>PCI Address</TableHead>
<TableHead>Workload Host</TableHead>
<TableHead>Assigned Workload</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hosts.map((h) => (
<TableRow key={h.id}>
<TableCell className="font-medium">{h.name || h.id}</TableCell>
<TableCell>{h.gpu_model?.device_name || h.gpu_model?.name || '-'}</TableCell>
<TableCell><Badge variant={statusVariant(h.gpu_status)}>{h.gpu_status}</Badge></TableCell>
<TableCell className="font-mono text-xs">{h.pci_address}</TableCell>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-2">
{h.workload_host_id}
<CopyButton text={h.workload_host_id} />
</div>
</TableCell>
<TableCell className="font-mono text-xs">{h.assigned_workload_id || '-'}</TableCell>
</TableRow>
))}
{hosts.length === 0 && !isLoading && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No GPU hosts found{appliedHostFilter && ' for this host'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="models">
<Card className="mt-4">
<CardHeader>
<CardTitle>GPU Models</CardTitle>
<CardDescription>Known GPU model types, keyed by vendor/device ID</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-32"><p>Loading...</p></div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Device Name</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Architecture</TableHead>
<TableHead>VRAM</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.device_name || m.name || m.id}</TableCell>
<TableCell>{m.vendor_name || '-'}</TableCell>
<TableCell>{m.architecture || '-'}</TableCell>
<TableCell>{m.vram_mb ? `${(m.vram_mb / 1024).toFixed(0)} GB` : '-'}</TableCell>
</TableRow>
))}
{models.length === 0 && !isLoading && (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">No GPU models registered yet</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,371 @@
import { useState, useEffect, useCallback } from 'react';
import { Layers, Plus, Trash2, Search, X } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { BackToListButton } from '@/components/ui/back-to-list-button';
import { CopyButton } from '@/components/ui/copy-button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { DeleteConfirmationDialog } from '@/components/DeleteConfirmationDialog';
import { useToast } from '@/hooks/use-toast';
import { xcloudifyApi } from '@/services/xcloudifyApi';
interface ServerGroupItem {
id: string;
name: string;
description?: string;
tenant_id?: string;
policy: string;
member_count?: number;
members?: { id: string; name: string; workload_host_id?: string }[];
created_at?: string;
[key: string]: any;
}
const POLICIES = ['soft-affinity', 'hard-affinity', 'soft-anti-affinity', 'hard-anti-affinity'];
function formatDate(dateString?: string) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString();
} catch {
return dateString;
}
}
export function AdminServerGroupsPage() {
const [groups, setGroups] = useState<ServerGroupItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tenantFilter, setTenantFilter] = useState('');
const [appliedTenantFilter, setAppliedTenantFilter] = useState('');
const [selected, setSelected] = useState<ServerGroupItem | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [newGroup, setNewGroup] = useState({ name: '', tenant_id: '', policy: 'soft-affinity', description: '' });
const [isCreating, setIsCreating] = useState(false);
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; group: ServerGroupItem | null; isLoading: boolean }>({
open: false,
group: null,
isLoading: false,
});
const { toast } = useToast();
const fetchGroups = useCallback(async (tenantId: string) => {
try {
setIsLoading(true);
setError(null);
const data = await xcloudifyApi.getAdminServerGroups(tenantId ? { tenant_id: tenantId } : undefined);
setGroups(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch server groups');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchGroups(appliedTenantFilter);
}, [appliedTenantFilter, fetchGroups]);
const applyFilter = () => setAppliedTenantFilter(tenantFilter.trim());
const clearFilter = () => {
setTenantFilter('');
setAppliedTenantFilter('');
};
const handleCreate = async () => {
if (!newGroup.name.trim() || !newGroup.tenant_id.trim()) {
toast({ title: 'Validation Error', description: 'Name and tenant ID are required', variant: 'destructive' });
return;
}
setIsCreating(true);
try {
await xcloudifyApi.createAdminServerGroup({
name: newGroup.name.trim(),
tenant_id: newGroup.tenant_id.trim(),
policy: newGroup.policy,
...(newGroup.description.trim() ? { description: newGroup.description.trim() } : {}),
});
toast({ title: 'Success', description: 'Server group created successfully' });
setIsCreateOpen(false);
setNewGroup({ name: '', tenant_id: '', policy: 'soft-affinity', description: '' });
fetchGroups(appliedTenantFilter);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to create server group', variant: 'destructive' });
} finally {
setIsCreating(false);
}
};
const handleDelete = async () => {
if (!deleteDialog.group) return;
setDeleteDialog((prev) => ({ ...prev, isLoading: true }));
try {
await xcloudifyApi.deleteServerGroup(deleteDialog.group.id);
toast({ title: 'Success', description: 'Server group deleted successfully' });
setDeleteDialog({ open: false, group: null, isLoading: false });
if (selected?.id === deleteDialog.group.id) setSelected(null);
fetchGroups(appliedTenantFilter);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to delete server group', variant: 'destructive' });
setDeleteDialog((prev) => ({ ...prev, isLoading: false }));
}
};
if (selected) {
return (
<div className="flex-1 space-y-6">
<BackToListButton label="Back to server groups list" onClick={() => setSelected(null)} />
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Layers className="h-8 w-8" />
{selected.name || selected.id}
</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Server Group Details</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">ID:</label>
<div className="flex items-center gap-2">
<p className="font-mono text-sm text-blue-600">{selected.id}</p>
<CopyButton text={selected.id} />
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Tenant ID:</label>
<div className="flex items-center gap-2">
<p className="font-mono text-sm">{selected.tenant_id || '-'}</p>
{selected.tenant_id && <CopyButton text={selected.tenant_id} />}
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Policy:</label>
<Badge variant="outline">{selected.policy}</Badge>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Description:</label>
<p className="text-sm">{selected.description || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Created:</label>
<p className="text-sm">{formatDate(selected.created_at)}</p>
</div>
</div>
</div>
<div className="pt-6">
<Button variant="destructive" onClick={() => setDeleteDialog({ open: true, group: selected, isLoading: false })}>
<Trash2 className="h-4 w-4 mr-2" /> Delete Server Group
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Members ({selected.member_count ?? selected.members?.length ?? 0})</CardTitle>
<CardDescription>Workloads placed under this group's affinity policy</CardDescription>
</CardHeader>
<CardContent>
{selected.members && selected.members.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>ID</TableHead>
<TableHead>Workload Host ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selected.members.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name || m.id}</TableCell>
<TableCell className="font-mono text-xs">{m.id}</TableCell>
<TableCell className="font-mono text-xs">{m.workload_host_id || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">No members in this group</div>
)}
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Layers className="h-8 w-8" />
Server Groups
</h1>
<p className="text-muted-foreground">Every affinity/anti-affinity group across every tenant</p>
</div>
<div className="flex gap-2">
<Button onClick={() => fetchGroups(appliedTenantFilter)} disabled={isLoading} variant="outline">
{isLoading ? 'Loading...' : 'Refresh'}
</Button>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Create Server Group
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create New Server Group</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="sgName">Name *</Label>
<Input id="sgName" value={newGroup.name} onChange={(e) => setNewGroup({ ...newGroup, name: e.target.value })} placeholder="e.g., web-tier-ha" />
</div>
<div>
<Label htmlFor="sgTenant">Tenant ID *</Label>
<Input id="sgTenant" value={newGroup.tenant_id} onChange={(e) => setNewGroup({ ...newGroup, tenant_id: e.target.value })} placeholder="Owning tenant's ID" />
</div>
<div>
<Label htmlFor="sgPolicy">Policy</Label>
<Select value={newGroup.policy} onValueChange={(value) => setNewGroup({ ...newGroup, policy: value })}>
<SelectTrigger id="sgPolicy">
<SelectValue placeholder="Select policy" />
</SelectTrigger>
<SelectContent>
{POLICIES.map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="sgDescription">Description</Label>
<Textarea id="sgDescription" value={newGroup.description} onChange={(e) => setNewGroup({ ...newGroup, description: e.target.value })} />
</div>
<Button onClick={handleCreate} className="w-full" disabled={!newGroup.name.trim() || !newGroup.tenant_id.trim() || isCreating}>
{isCreating ? 'Creating...' : 'Create Server Group'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Filter by tenant_id..."
value={tenantFilter}
onChange={(e) => setTenantFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilter()}
/>
</div>
<Button onClick={applyFilter} variant="secondary">Apply</Button>
{appliedTenantFilter && (
<Button onClick={clearFilter} variant="ghost" size="sm">
<X className="h-4 w-4 mr-1" /> Clear
</Button>
)}
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>All Server Groups</CardTitle>
<CardDescription>Click a row to view details</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<p>Loading server groups...</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Policy</TableHead>
<TableHead>Tenant ID</TableHead>
<TableHead>Members</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((g) => (
<TableRow key={g.id} className="cursor-pointer hover:bg-muted/50" onClick={() => setSelected(g)}>
<TableCell className="font-medium">{g.name || g.id}</TableCell>
<TableCell>
<Badge variant="outline">{g.policy}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{g.tenant_id || '-'}</TableCell>
<TableCell>{g.member_count ?? g.members?.length ?? 0}</TableCell>
<TableCell>{formatDate(g.created_at)}</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteDialog({ open: true, group: g, isLoading: false });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
{groups.length === 0 && !isLoading && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No server groups found{appliedTenantFilter && ' matching this filter'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<DeleteConfirmationDialog
open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog((prev) => ({ ...prev, open }))}
onConfirm={handleDelete}
isLoading={deleteDialog.isLoading}
title="Delete Server Group"
description="Members will be detached, not deleted. This cannot be undone."
itemName={deleteDialog.group?.name || deleteDialog.group?.id || ''}
itemType="server group"
/>
</div>
);
}
@@ -0,0 +1,23 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { VirtualMachineDetailsPage } from './VirtualMachineDetailsPage';
/**
* Same VirtualMachineDetailsPage the tenant portal uses (VirtualMachineDetailsWrapper),
* just returning to the admin Workloads list instead of the tenant Infrastructure
* page -- for links reached from operator-console context (e.g. RegionsPage's GPU
* dialog), which exists identically in cloud, community, and core.
*/
export const AdminVirtualMachineDetailsWrapper: React.FC = () => {
const { vmId } = useParams<{ vmId: string }>();
const navigate = useNavigate();
return (
<VirtualMachineDetailsPage
vmId={vmId || ''}
onBack={() => navigate('/admin/workloads')}
networkDetailsPath={(networkId) => `/admin/networks/${networkId}`}
volumeDetailsPath={(volumeId) => `/admin/volumes/${volumeId}`}
/>
);
};
@@ -0,0 +1,596 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { HardDrive, Plus, Trash2, Search, X, Link2, Unlink } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { BackToListButton } from '@/components/ui/back-to-list-button';
import { CopyButton } from '@/components/ui/copy-button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from '@/components/ui/dialog';
import { DeleteConfirmationDialog } from '@/components/DeleteConfirmationDialog';
import { useToast } from '@/hooks/use-toast';
import { xcloudifyApi } from '@/services/xcloudifyApi';
interface VolumeItem {
id: string;
name: string;
description?: string;
status?: string;
tenant_id?: string;
size_gb: number;
type?: string;
path?: string;
source?: string;
boot?: boolean;
image_id?: string;
created_at?: string;
updated_at?: string;
[key: string]: any;
}
const VOLUME_TYPES = ['local', 'nfs', 'ceph', 'iscsi', 'lvm'];
const emptyNewVolume = {
name: '',
tenant_id: '',
size_gb: '',
type: '',
description: '',
path: '',
source: '',
boot: false,
image_id: '',
};
function formatDate(dateString?: string) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString();
} catch {
return dateString;
}
}
interface AdminVolumesPageProps {
volumeId?: string;
onBack?: () => void;
}
export function AdminVolumesPage({ volumeId: propVolumeId, onBack }: AdminVolumesPageProps = {}) {
const [volumes, setVolumes] = useState<VolumeItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tenantFilter, setTenantFilter] = useState('');
const [appliedTenantFilter, setAppliedTenantFilter] = useState('');
const [selected, setSelected] = useState<VolumeItem | null>(null);
const { volumeId: urlVolumeId } = useParams<{ volumeId: string }>();
const volumeId = propVolumeId || urlVolumeId;
const navigate = useNavigate();
const backToList = onBack || (() => navigate('/admin/volumes'));
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [newVolume, setNewVolume] = useState(emptyNewVolume);
const [isCreating, setIsCreating] = useState(false);
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; volume: VolumeItem | null; isLoading: boolean }>({
open: false,
volume: null,
isLoading: false,
});
const [attachedWorkloads, setAttachedWorkloads] = useState<any[]>([]);
const [isLoadingWorkloads, setIsLoadingWorkloads] = useState(false);
const [isAttachOpen, setIsAttachOpen] = useState(false);
const [attachWorkloadId, setAttachWorkloadId] = useState('');
const [isAttaching, setIsAttaching] = useState(false);
const [isDetaching, setIsDetaching] = useState<string | null>(null);
const { toast } = useToast();
const fetchVolumes = useCallback(async (tenantId: string) => {
try {
setIsLoading(true);
setError(null);
const response = await xcloudifyApi.getVolumes(tenantId ? { tenant_id: tenantId } : undefined);
setVolumes(response?.data?.volumes || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch volumes');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchVolumes(appliedTenantFilter);
}, [appliedTenantFilter, fetchVolumes]);
// URL-based volume selection (e.g. a deep link from a VM's storage tab)
useEffect(() => {
if (!volumeId) {
setSelected(null);
return;
}
if (volumes.length === 0) return;
const match = volumes.find((v) => v.id === volumeId);
if (match) {
setSelected(match);
} else if (urlVolumeId) {
navigate('/admin/volumes');
}
}, [volumeId, volumes, urlVolumeId, navigate]);
const fetchAttachedWorkloads = useCallback(async (volumeId: string) => {
try {
setIsLoadingWorkloads(true);
const data = await xcloudifyApi.getWorkloadsForVolume(volumeId);
setAttachedWorkloads(data?.data?.workloads || []);
} catch (err) {
setAttachedWorkloads([]);
} finally {
setIsLoadingWorkloads(false);
}
}, []);
useEffect(() => {
if (selected) {
fetchAttachedWorkloads(selected.id);
} else {
setAttachedWorkloads([]);
}
}, [selected, fetchAttachedWorkloads]);
const applyFilter = () => setAppliedTenantFilter(tenantFilter.trim());
const clearFilter = () => {
setTenantFilter('');
setAppliedTenantFilter('');
};
const handleCreate = async () => {
if (!newVolume.name.trim() || !newVolume.tenant_id.trim() || !newVolume.size_gb) {
toast({ title: 'Validation Error', description: 'Name, tenant ID, and size are required', variant: 'destructive' });
return;
}
const size = parseFloat(newVolume.size_gb);
if (!(size > 0)) {
toast({ title: 'Validation Error', description: 'Size must be greater than 0', variant: 'destructive' });
return;
}
setIsCreating(true);
try {
const payload: Record<string, any> = {
name: newVolume.name.trim(),
tenant_id: newVolume.tenant_id.trim(),
size_gb: size,
boot: newVolume.boot,
};
if (newVolume.type) payload.type = newVolume.type;
if (newVolume.description.trim()) payload.description = newVolume.description.trim();
if (newVolume.path.trim()) payload.path = newVolume.path.trim();
if (newVolume.source.trim()) payload.source = newVolume.source.trim();
if (newVolume.image_id.trim()) payload.image_id = newVolume.image_id.trim();
await xcloudifyApi.createVolume(payload);
toast({ title: 'Success', description: 'Volume created successfully' });
setIsCreateOpen(false);
setNewVolume(emptyNewVolume);
fetchVolumes(appliedTenantFilter);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to create volume', variant: 'destructive' });
} finally {
setIsCreating(false);
}
};
const handleDelete = async () => {
if (!deleteDialog.volume) return;
setDeleteDialog((prev) => ({ ...prev, isLoading: true }));
try {
await xcloudifyApi.deleteVolume(deleteDialog.volume.id);
toast({ title: 'Success', description: 'Volume deleted successfully' });
setDeleteDialog({ open: false, volume: null, isLoading: false });
if (selected?.id === deleteDialog.volume.id) setSelected(null);
fetchVolumes(appliedTenantFilter);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to delete volume', variant: 'destructive' });
setDeleteDialog((prev) => ({ ...prev, isLoading: false }));
}
};
const handleAttach = async () => {
if (!selected || !attachWorkloadId.trim()) return;
setIsAttaching(true);
try {
await xcloudifyApi.attachVolumeToWorkload(selected.id, attachWorkloadId.trim());
toast({ title: 'Success', description: 'Volume attached' });
setIsAttachOpen(false);
setAttachWorkloadId('');
fetchAttachedWorkloads(selected.id);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to attach volume', variant: 'destructive' });
} finally {
setIsAttaching(false);
}
};
const handleDetach = async (workloadId: string) => {
if (!selected) return;
setIsDetaching(workloadId);
try {
await xcloudifyApi.detachVolumeFromWorkload(selected.id, workloadId);
toast({ title: 'Success', description: 'Volume detached' });
fetchAttachedWorkloads(selected.id);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to detach volume', variant: 'destructive' });
} finally {
setIsDetaching(null);
}
};
if (selected) {
return (
<div className="flex-1 space-y-6">
<BackToListButton label="Back to volumes list" onClick={backToList} />
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<HardDrive className="h-8 w-8" />
{selected.name || selected.id}
</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Volume Details</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">ID:</label>
<div className="flex items-center gap-2">
<p className="font-mono text-sm text-blue-600">{selected.id}</p>
<CopyButton text={selected.id} />
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Tenant ID:</label>
<div className="flex items-center gap-2">
<p className="font-mono text-sm">{selected.tenant_id || '-'}</p>
{selected.tenant_id && <CopyButton text={selected.tenant_id} />}
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Status:</label>
<Badge variant={selected.status === 'failed' ? 'destructive' : 'outline'}>{selected.status || 'unknown'}</Badge>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Size:</label>
<p className="text-sm">{selected.size_gb} GB</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Type:</label>
<p className="text-sm font-mono">{selected.type || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Boot Volume:</label>
<Badge variant={selected.boot ? 'default' : 'outline'}>{selected.boot ? 'Yes' : 'No'}</Badge>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Path:</label>
<p className="text-sm font-mono">{selected.path || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Source:</label>
<p className="text-sm font-mono">{selected.source || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Image ID:</label>
<p className="text-sm font-mono">{selected.image_id || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Description:</label>
<p className="text-sm">{selected.description || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Created:</label>
<p className="text-sm">{formatDate(selected.created_at)}</p>
</div>
</div>
</div>
<div className="pt-6">
<Button variant="destructive" onClick={() => setDeleteDialog({ open: true, volume: selected, isLoading: false })}>
<Trash2 className="h-4 w-4 mr-2" /> Delete Volume
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Attached Workloads</CardTitle>
<CardDescription>Workloads this volume is currently attached to</CardDescription>
</div>
<Dialog open={isAttachOpen} onOpenChange={setIsAttachOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Link2 className="h-4 w-4 mr-2" /> Attach
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Attach to Workload</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="workloadId">Workload ID *</Label>
<Input
id="workloadId"
value={attachWorkloadId}
onChange={(e) => setAttachWorkloadId(e.target.value)}
placeholder="Workload UUID"
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleAttach} disabled={!attachWorkloadId.trim() || isAttaching}>
{isAttaching ? 'Attaching...' : 'Attach'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{isLoadingWorkloads ? (
<div className="flex items-center justify-center h-24">
<p>Loading...</p>
</div>
) : attachedWorkloads.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>ID</TableHead>
<TableHead>Type</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attachedWorkloads.map((w) => (
<TableRow key={w.id}>
<TableCell className="font-medium">{w.name || w.id}</TableCell>
<TableCell className="font-mono text-xs">{w.id}</TableCell>
<TableCell>{w.workload_type || '-'}</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
disabled={isDetaching === w.id}
onClick={() => handleDetach(w.id)}
>
<Unlink className="h-4 w-4 mr-1" /> {isDetaching === w.id ? 'Detaching...' : 'Detach'}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">Not attached to any workload</div>
)}
</CardContent>
</Card>
<DeleteConfirmationDialog
open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog((prev) => ({ ...prev, open }))}
onConfirm={handleDelete}
isLoading={deleteDialog.isLoading}
title="Delete Volume"
description="This cannot be undone."
itemName={deleteDialog.volume?.name || deleteDialog.volume?.id || ''}
itemType="volume"
/>
</div>
);
}
return (
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<HardDrive className="h-8 w-8" />
Volumes
</h1>
<p className="text-muted-foreground">Every volume across every tenant on this underlay</p>
</div>
<div className="flex gap-2">
<Button onClick={() => fetchVolumes(appliedTenantFilter)} disabled={isLoading} variant="outline">
{isLoading ? 'Loading...' : 'Refresh'}
</Button>
<Dialog open={isCreateOpen} onOpenChange={(open) => { setIsCreateOpen(open); if (!open) setNewVolume(emptyNewVolume); }}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Create Volume
</Button>
</DialogTrigger>
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Volume</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="volName">Name *</Label>
<Input id="volName" value={newVolume.name} onChange={(e) => setNewVolume({ ...newVolume, name: e.target.value })} placeholder="e.g., data-disk-1" />
</div>
<div>
<Label htmlFor="volTenant">Tenant ID *</Label>
<Input id="volTenant" value={newVolume.tenant_id} onChange={(e) => setNewVolume({ ...newVolume, tenant_id: e.target.value })} placeholder="Owning tenant's ID" />
</div>
<div>
<Label htmlFor="volSize">Size (GB) *</Label>
<Input id="volSize" type="number" min="0.1" step="0.1" value={newVolume.size_gb} onChange={(e) => setNewVolume({ ...newVolume, size_gb: e.target.value })} placeholder="e.g., 20" />
</div>
<div>
<Label htmlFor="volType">Type</Label>
<Select value={newVolume.type} onValueChange={(value) => setNewVolume({ ...newVolume, type: value })}>
<SelectTrigger id="volType">
<SelectValue placeholder="Select volume type" />
</SelectTrigger>
<SelectContent>
{VOLUME_TYPES.map((t) => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="volImageId">Image ID</Label>
<Input id="volImageId" value={newVolume.image_id} onChange={(e) => setNewVolume({ ...newVolume, image_id: e.target.value })} placeholder="Optional source image UUID" />
</div>
<div>
<Label htmlFor="volPath">Path</Label>
<Input id="volPath" value={newVolume.path} onChange={(e) => setNewVolume({ ...newVolume, path: e.target.value })} placeholder="Optional mount path" />
</div>
<div>
<Label htmlFor="volSource">Source</Label>
<Input id="volSource" value={newVolume.source} onChange={(e) => setNewVolume({ ...newVolume, source: e.target.value })} placeholder="Optional source" />
</div>
<div>
<Label htmlFor="volDescription">Description</Label>
<Textarea id="volDescription" value={newVolume.description} onChange={(e) => setNewVolume({ ...newVolume, description: e.target.value })} />
</div>
<div className="flex items-center gap-2">
<Checkbox id="volBoot" checked={newVolume.boot} onCheckedChange={(checked) => setNewVolume({ ...newVolume, boot: checked === true })} />
<Label htmlFor="volBoot">Boot volume</Label>
</div>
<Button
onClick={handleCreate}
className="w-full"
disabled={!newVolume.name.trim() || !newVolume.tenant_id.trim() || !newVolume.size_gb || isCreating}
>
{isCreating ? 'Creating...' : 'Create Volume'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Filter by tenant_id..."
value={tenantFilter}
onChange={(e) => setTenantFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilter()}
/>
</div>
<Button onClick={applyFilter} variant="secondary">Apply</Button>
{appliedTenantFilter && (
<Button onClick={clearFilter} variant="ghost" size="sm">
<X className="h-4 w-4 mr-1" /> Clear
</Button>
)}
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>All Volumes</CardTitle>
<CardDescription>Click a row to view details</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<p>Loading volumes...</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Size</TableHead>
<TableHead>Type</TableHead>
<TableHead>Tenant ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Boot</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{volumes.map((vol) => (
<TableRow key={vol.id} className="cursor-pointer hover:bg-muted/50" onClick={() => navigate(`/admin/volumes/${vol.id}`)}>
<TableCell className="font-medium">{vol.name || vol.id}</TableCell>
<TableCell>{vol.size_gb} GB</TableCell>
<TableCell className="font-mono text-sm">{vol.type || '-'}</TableCell>
<TableCell className="font-mono text-xs">{vol.tenant_id || '-'}</TableCell>
<TableCell>
<Badge variant={vol.status === 'failed' ? 'destructive' : 'outline'}>{vol.status || 'unknown'}</Badge>
</TableCell>
<TableCell>
<Badge variant={vol.boot ? 'default' : 'outline'}>{vol.boot ? 'Yes' : 'No'}</Badge>
</TableCell>
<TableCell>{formatDate(vol.created_at)}</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteDialog({ open: true, volume: vol, isLoading: false });
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
{volumes.length === 0 && !isLoading && (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
No volumes found{appliedTenantFilter && ' matching this filter'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<DeleteConfirmationDialog
open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog((prev) => ({ ...prev, open }))}
onConfirm={handleDelete}
isLoading={deleteDialog.isLoading}
title="Delete Volume"
description="This cannot be undone."
itemName={deleteDialog.volume?.name || deleteDialog.volume?.id || ''}
itemType="volume"
/>
</div>
);
}
@@ -0,0 +1,160 @@
import {
Layout,
Search,
Server,
Database,
HardDrive,
CreditCard,
Users,
Settings,
FileText,
Vote,
Globe,
Globe2,
Network,
Boxes,
Building,
FolderOpen,
Zap,
GitBranch,
Shield,
Users2,
KeyRound,
Layers,
ScrollText,
Cpu
} from "lucide-react"
import { NavLink } from "react-router-dom"
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarHeader,
useSidebar,
} from "@/components/ui/sidebar"
import { Logo } from "./Logo"
export type NavItem = {
title: string;
url: string;
icon: React.ComponentType<any>;
/** Rendered greyed out and unclickable. For surfaces that exist in the
* design but have no backend yet -- honest about not working. */
disabled?: boolean;
}
export type NavGroup = {
label: string;
items: NavItem[];
}
// ─── Nav groups, one per plane ────────────────────────────────────────────
// Exported individually so each app composes only the ones it deploys.
// The split follows the backend: cloud owns the tenanted product and the
// tenancy it is administered through; core owns the underlay. Neither app
// mounts the other's routes, so a nav item is only ever listed by the app
// that can actually route to it.
export const cloudNavItems: NavItem[] = [
{ title: "Dashboard", url: "/", icon: Layout },
{ title: "Explore", url: "/explore", icon: Search },
{ title: "Infrastructure", url: "/infrastructure", icon: Server },
{ title: "Server Groups", url: "/server-groups", icon: Users2 },
{ title: "Serverless Compute", url: "/serverless", icon: Zap, disabled: true },
{ title: "Git Deploy", url: "/git-deploy", icon: GitBranch, disabled: true },
{ title: "AI Services", url: "/ai-services", icon: Database, disabled: true },
{ title: "My Templates", url: "/my-templates", icon: FileText, disabled: true },
{ title: "Storage", url: "/storage", icon: HardDrive },
// No plane serves /ssh-keys -- core dropped the table, cloud never added
// the route. Keys go on a VM by pasting them into the create wizard.
{ title: "SSH Keys", url: "/ssh-keys", icon: KeyRound, disabled: true },
{ title: "Certificates", url: "/certificates", icon: Shield },
{ title: "Billing", url: "/billing", icon: CreditCard, disabled: true },
{ title: "Referrals", url: "/referrals", icon: Users, disabled: true },
]
// Cloud's Administration group: tenancy only. Universes, projects and VDCs
// are cloud's own tables. Everything a cloud operator might also want to see
// about the hardware underneath -- hosts, regions, images, capacity -- is
// core's, and is administered from the core console instead.
export const cloudAdminNavItems: NavItem[] = [
{ title: "Admin Dashboard", url: "/admin", icon: Settings },
{ title: "Universes", url: "/admin/universes", icon: Globe2 },
{ title: "Virtual Data Centers", url: "/admin/virtual-data-centers", icon: Building },
{ title: "Projects", url: "/admin/projects", icon: FolderOpen },
]
// The core console: every underlay surface, cross-tenant. Core holds no user
// accounts, so this is the entire app -- there is no tenant-facing half.
export const underlayNavItems: NavItem[] = [
{ title: "Admin Dashboard", url: "/admin", icon: Settings },
{ title: "Workload Hosts", url: "/admin/workload-hosts", icon: Server },
{ title: "Workloads", url: "/admin/workloads", icon: Boxes },
{ title: "Networks", url: "/admin/networks", icon: Network },
{ title: "DNS Records", url: "/admin/dns", icon: Globe2 },
{ title: "Volumes", url: "/admin/volumes", icon: HardDrive },
{ title: "Server Groups", url: "/admin/server-groups", icon: Layers },
{ title: "GPUs", url: "/admin/gpus", icon: Cpu },
{ title: "Regions", url: "/admin/regions", icon: Globe },
{ title: "Images", url: "/admin/images", icon: HardDrive },
{ title: "Audit Log", url: "/admin/audit", icon: ScrollText },
]
export const generalNavItems: NavItem[] = [
{ title: "Changelog", url: "/changelog", icon: FileText, disabled: true },
{ title: "Vote feature", url: "/vote", icon: Vote, disabled: true },
]
export function AppSidebar({ groups }: { groups: NavGroup[] }) {
const { state } = useSidebar()
const collapsed = state === "collapsed"
const getNavCls = ({ isActive }: { isActive: boolean }) =>
isActive ? "bg-sidebar-accent text-sidebar-accent-foreground font-medium" : "hover:bg-sidebar-accent/50"
return (
<Sidebar className={collapsed ? "w-14" : "w-64"} collapsible="icon">
<SidebarHeader className="border-b border-sidebar-border p-4">
<Logo className={collapsed ? "justify-center" : ""} />
</SidebarHeader>
<SidebarContent className="px-3 py-4">
{groups.map((group, gi) => (
<SidebarGroup key={group.label} className={gi === 0 ? undefined : "mt-6"}>
<SidebarGroupLabel className="text-xs font-medium text-sidebar-muted-foreground uppercase tracking-wider mb-2">
{group.label}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild className="h-9">
<NavLink
to={item.url}
end={item.url === "/" || item.url === "/admin"}
className={item.disabled
? "opacity-50 cursor-not-allowed text-muted-foreground hover:text-muted-foreground"
: getNavCls
}
>
<item.icon className="h-4 w-4" />
{!collapsed && <span className="text-sm">{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
</Sidebar>
)
}
@@ -0,0 +1,91 @@
/**
* Cloud's /admin landing: the tenancy surfaces a cloud operator administers.
*
* Universes, projects and VDCs live in cloud's own database, so this is the
* whole of cloud's operator scope. Hosts, regions, images, networks, volumes
* and capacity are underlay -- they belong to core and are administered from
* the core console (CoreAdminDashboard), not from here.
*
* Restored from the pre-split dashboard, minus the underlay sections that
* moved to core and minus the never-built placeholders.
*/
import { Building, FolderOpen, Globe2, Settings } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
interface AdminSection {
id: string;
title: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
path: string;
}
const adminSections: AdminSection[] = [
{
id: 'universes',
title: 'Universes',
description: 'Manage top-level universe namespaces',
icon: Globe2,
path: '/admin/universes',
},
{
id: 'projects',
title: 'Projects',
description: 'Manage projects and their associated resources',
icon: FolderOpen,
path: '/admin/projects',
},
{
id: 'virtual-data-centers',
title: 'Virtual Data Centers',
description: 'Manage VDCs across regions and projects',
icon: Building,
path: '/admin/virtual-data-centers',
},
];
export function CloudAdminDashboard() {
const navigate = useNavigate();
return (
<div className="flex-1 space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Settings className="h-8 w-8" />
Admin Dashboard
</h1>
<p className="text-muted-foreground">
Tenancy administration for xCloudify
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{adminSections.map((section) => {
const Icon = section.icon;
return (
<Card
key={section.id}
className="cursor-pointer transition-all hover:shadow-md hover:border-primary/50"
onClick={() => navigate(section.path)}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" />
{section.title}
</CardTitle>
<CardDescription>
{section.description}
</CardDescription>
</CardHeader>
<CardContent>
<Button className="w-full">Manage</Button>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
@@ -416,7 +416,7 @@ export const ContainerDetails: React.FC<ContainerDetailsProps> = ({
<CardTitle>Launch Parameters</CardTitle>
</CardHeader>
<CardContent>
<pre className="bg-muted/50 p-4 rounded-lg text-sm font-mono overflow-x-auto">
<pre className="bg-muted/50 p-4 rounded-lg text-sm font-mono overflow-y-auto max-h-96 whitespace-pre-wrap break-all">
{formatLaunchParams(containerData.launch_params)}
</pre>
</CardContent>
@@ -0,0 +1,495 @@
/**
* Operator overview of the underlay: capacity, VM/host status, and which
* tenants are consuming what. Core console only.
*
* Cloud's /admin is a different screen (CloudAdminDashboard) because cloud
* operators administer tenancy -- universes, projects, VDCs -- not hardware.
* Everything here reads through core, whether that is core directly or the
* plane's /admin/core/* passthrough.
*/
import { ReactNode, useEffect, useMemo, useState } from 'react';
import { Settings, Server, HardDrive, Cpu, Users, Network, Globe } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ChartContainer, ChartTooltip } from '@/components/ui/chart';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts';
import { xcloudifyApi } from '@/services/xcloudifyApi';
interface RankedItem {
key: string;
label: string;
value: number;
tooltip: string;
}
interface StatusSegment {
key: string;
label: string;
count: number;
color: string;
}
interface DashboardStats {
totalVms: number;
vmStatus: { running: number; stopped: number; pending: number; error: number };
totalStorageGb: number;
volumeCount: number;
totalHosts: number;
activeHosts: number;
hostStatus: { online: number; offline: number; maintenance: number; other: number };
totalTenants: number;
totalNetworks: number;
totalRegions: number;
topTenantsByResource: RankedItem[];
topTenantsByStorage: RankedItem[];
vmsByRegion: RankedItem[];
vmsByHost: RankedItem[];
}
function classifyVmStatus(status?: string): 'running' | 'stopped' | 'error' | 'pending' {
const s = (status || '').toLowerCase();
if (['running', 'active', 'up', 'ready'].includes(s)) return 'running';
if (['error', 'failed'].includes(s)) return 'error';
if (['stopped', 'stopping', 'shutoff'].includes(s)) return 'stopped';
return 'pending';
}
function classifyHostStatus(status?: string): 'online' | 'offline' | 'maintenance' | 'other' {
const s = (status || '').toLowerCase();
if (s === 'online') return 'online';
if (s === 'offline') return 'offline';
if (s === 'maintenance') return 'maintenance';
return 'other';
}
function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1)} TB`;
return `${Math.round(gb)} GB`;
}
function truncateLabel(id: string, fallback = 'Unassigned'): string {
if (!id) return fallback;
return id.length > 12 ? `${id.slice(0, 10)}…` : id;
}
function topN(counts: Map<string, number>, labelFor: (key: string) => string, unit: string, n = 6): RankedItem[] {
return Array.from(counts.entries())
.map(([key, value]) => ({
key,
label: labelFor(key),
value,
tooltip: `${value} ${unit}${value === 1 ? '' : 's'}`,
}))
.sort((a, b) => b.value - a.value)
.slice(0, n);
}
async function loadDashboardStats(): Promise<DashboardStats> {
const [vms, volumeResponse, hosts, networks, regions] = await Promise.all([
xcloudifyApi.getAdminVirtualMachines(),
xcloudifyApi.getVolumes(),
xcloudifyApi.getWorkloadHosts(),
xcloudifyApi.getAdminNetworks(),
xcloudifyApi.getRegions(),
]);
const volumes: any[] = Array.isArray(volumeResponse)
? volumeResponse
: volumeResponse?.data?.volumes || volumeResponse?.data || volumeResponse?.volumes || [];
const hostList = hosts as any[];
const hostNameById = new Map(hostList.map((h) => [h.id, h.name || h.hostname || h.id]));
const regionNameById = new Map((regions as any[]).map((r) => [r.id, r.name || r.id]));
const tenantVmCount = new Map<string, number>();
const tenantVcpu = new Map<string, number>();
const tenantStorage = new Map<string, number>();
const vmsByRegion = new Map<string, number>();
const vmsByHost = new Map<string, number>();
let running = 0, stopped = 0, error = 0, pending = 0;
for (const vm of vms as any[]) {
const bucket = classifyVmStatus(vm.status);
if (bucket === 'running') running++;
else if (bucket === 'stopped') stopped++;
else if (bucket === 'error') error++;
else pending++;
const tenantKey = vm.tenant_id || '';
tenantVmCount.set(tenantKey, (tenantVmCount.get(tenantKey) || 0) + 1);
try {
const params = vm.launch_params ? JSON.parse(vm.launch_params) : null;
const vcpu = Number(params?.vcpu) || 0;
if (vcpu > 0) tenantVcpu.set(tenantKey, (tenantVcpu.get(tenantKey) || 0) + vcpu);
} catch {
// launch_params not JSON-parseable; skip vCPU contribution
}
if (vm.region_id) vmsByRegion.set(vm.region_id, (vmsByRegion.get(vm.region_id) || 0) + 1);
if (vm.workload_host_id) vmsByHost.set(vm.workload_host_id, (vmsByHost.get(vm.workload_host_id) || 0) + 1);
}
let totalStorageGb = 0;
for (const vol of volumes) {
const gb = Number(vol.size_gb) || 0;
totalStorageGb += gb;
const tenantKey = vol.tenant_id || '';
tenantStorage.set(tenantKey, (tenantStorage.get(tenantKey) || 0) + gb);
}
const tenantKeys = new Set([...tenantVmCount.keys(), ...tenantVcpu.keys(), ...tenantStorage.keys()]);
const topTenantsByResource = Array.from(tenantKeys)
.map((tenantId) => {
const vcpu = tenantVcpu.get(tenantId) || 0;
const vmCount = tenantVmCount.get(tenantId) || 0;
const value = vcpu > 0 ? vcpu : vmCount;
return {
key: tenantId,
label: truncateLabel(tenantId),
value,
tooltip: vcpu > 0 ? `${vcpu} vCPU · ${vmCount} VM${vmCount === 1 ? '' : 's'}` : `${vmCount} VM${vmCount === 1 ? '' : 's'}`,
};
})
.filter((t) => t.value > 0)
.sort((a, b) => b.value - a.value)
.slice(0, 6);
const topTenantsByStorage = topN(tenantStorage, (id) => truncateLabel(id), 'GB used').map((item) => ({
...item,
tooltip: formatStorage(item.value),
}));
const hostStatus = { online: 0, offline: 0, maintenance: 0, other: 0 };
for (const host of hostList) {
hostStatus[classifyHostStatus(host.status)]++;
}
return {
totalVms: vms.length,
vmStatus: { running, stopped, pending, error },
totalStorageGb,
volumeCount: volumes.length,
totalHosts: hostList.length,
activeHosts: hostStatus.online,
hostStatus,
totalTenants: tenantKeys.size,
totalNetworks: (networks as any[]).length,
totalRegions: (regions as any[]).length,
topTenantsByResource,
topTenantsByStorage,
vmsByRegion: topN(vmsByRegion, (id) => regionNameById.get(id) || truncateLabel(id), 'VM'),
vmsByHost: topN(vmsByHost, (id) => hostNameById.get(id) || truncateLabel(id), 'VM'),
};
}
function StatusBar({ segments }: { segments: StatusSegment[] }) {
const total = segments.reduce((sum, s) => sum + s.count, 0) || 1;
return (
<div className="space-y-4">
<div className="flex h-3 w-full overflow-hidden rounded-full gap-0.5 bg-muted">
{segments.map((s) => (
<div
key={s.key}
style={{ width: `${(s.count / total) * 100}%`, backgroundColor: s.color }}
title={`${s.label}: ${s.count}`}
/>
))}
</div>
<div className="grid grid-cols-2 gap-3">
{segments.map((s) => (
<div key={s.key} className="flex items-center gap-2 text-sm">
<span className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<span className="text-muted-foreground">{s.label}</span>
<span className="ml-auto font-medium text-foreground">{s.count}</span>
</div>
))}
</div>
</div>
);
}
function RankedBarChart({ data, barColor = 'hsl(var(--primary))' }: { data: RankedItem[]; barColor?: string }) {
return (
<ChartContainer config={{ value: { label: 'Value', color: barColor } }} className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ left: 8, right: 16, top: 4, bottom: 4 }}>
<CartesianGrid horizontal={false} strokeDasharray="3 3" />
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="label"
width={90}
tickLine={false}
axisLine={false}
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
/>
<ChartTooltip
cursor={{ fill: 'hsl(var(--muted))' }}
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as RankedItem;
return (
<div className="rounded-lg border bg-background p-2 shadow-sm text-xs space-y-1">
<div className="font-medium text-foreground">{d.label}</div>
<div className="text-muted-foreground">{d.tooltip}</div>
</div>
);
}}
/>
<Bar dataKey="value" fill={barColor} radius={[0, 4, 4, 0]} barSize={16} />
</BarChart>
</ResponsiveContainer>
</ChartContainer>
);
}
function ChartCard({
title,
description,
isLoading,
isEmpty,
emptyLabel,
children,
}: {
title: string;
description: string;
isLoading: boolean;
isEmpty: boolean;
emptyLabel: string;
children: ReactNode;
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-56 w-full" />
) : isEmpty ? (
<p className="text-sm text-muted-foreground">{emptyLabel}</p>
) : (
children
)}
</CardContent>
</Card>
);
}
export function CoreAdminDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [statsError, setStatsError] = useState<string | null>(null);
const [isLoadingStats, setIsLoadingStats] = useState(true);
useEffect(() => {
let cancelled = false;
loadDashboardStats()
.then((result) => {
if (!cancelled) setStats(result);
})
.catch((err) => {
if (!cancelled) setStatsError(err instanceof Error ? err.message : 'Failed to load infrastructure stats');
})
.finally(() => {
if (!cancelled) setIsLoadingStats(false);
});
return () => {
cancelled = true;
};
}, []);
const vmStatusSegments = useMemo<StatusSegment[]>(() => {
if (!stats) return [];
return [
{ key: 'running', label: 'Running', count: stats.vmStatus.running, color: 'hsl(var(--primary))' },
{ key: 'pending', label: 'Pending', count: stats.vmStatus.pending, color: '#eda100' },
{ key: 'stopped', label: 'Stopped', count: stats.vmStatus.stopped, color: 'hsl(var(--muted-foreground))' },
{ key: 'error', label: 'Error', count: stats.vmStatus.error, color: 'hsl(var(--destructive))' },
].filter((s) => s.count > 0);
}, [stats]);
const hostStatusSegments = useMemo<StatusSegment[]>(() => {
if (!stats) return [];
return [
{ key: 'online', label: 'Online', count: stats.hostStatus.online, color: 'hsl(var(--primary))' },
{ key: 'maintenance', label: 'Maintenance', count: stats.hostStatus.maintenance, color: '#eda100' },
{ key: 'offline', label: 'Offline', count: stats.hostStatus.offline, color: 'hsl(var(--destructive))' },
{ key: 'other', label: 'Other', count: stats.hostStatus.other, color: 'hsl(var(--muted-foreground))' },
].filter((s) => s.count > 0);
}, [stats]);
return (
<div className="flex-1 space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Settings className="h-8 w-8" />
Admin Dashboard
</h1>
<p className="text-muted-foreground">
Central administration for xCloudify infrastructure
</p>
</div>
{statsError && (
<p className="text-sm text-destructive">{statsError}</p>
)}
<div>
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3">Overview</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{isLoadingStats ? (
Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-4 w-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-[60px] mb-2" />
<Skeleton className="h-3 w-[120px]" />
</CardContent>
</Card>
))
) : (
<>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Virtual Machines</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">{stats?.totalVms ?? 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.vmStatus.running ?? 0} running · {(stats?.vmStatus.stopped ?? 0) + (stats?.vmStatus.error ?? 0)} stopped/error
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Storage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">{formatStorage(stats?.totalStorageGb ?? 0)}</div>
<p className="text-xs text-muted-foreground">{stats?.volumeCount ?? 0} volumes</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Workload Hosts</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">
{stats?.activeHosts ?? 0}/{stats?.totalHosts ?? 0}
</div>
<p className="text-xs text-muted-foreground">online</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Tenants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">{stats?.totalTenants ?? 0}</div>
<p className="text-xs text-muted-foreground">
{stats?.topTenantsByResource[0] ? `Top: ${stats.topTenantsByResource[0].label}` : 'with active resources'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Networks</CardTitle>
<Network className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">{stats?.totalNetworks ?? 0}</div>
<p className="text-xs text-muted-foreground">across all regions</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Regions</CardTitle>
<Globe className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-foreground">{stats?.totalRegions ?? 0}</div>
<p className="text-xs text-muted-foreground">deployment regions</p>
</CardContent>
</Card>
</>
)}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<ChartCard
title="VM Status"
description="Distribution across all virtual machines"
isLoading={isLoadingStats}
isEmpty={vmStatusSegments.length === 0}
emptyLabel="No virtual machines yet."
>
<StatusBar segments={vmStatusSegments} />
</ChartCard>
<ChartCard
title="Workload Host Status"
description="Availability across all workload hosts"
isLoading={isLoadingStats}
isEmpty={hostStatusSegments.length === 0}
emptyLabel="No workload hosts registered yet."
>
<StatusBar segments={hostStatusSegments} />
</ChartCard>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<ChartCard
title="Top Tenants by Resource Usage"
description="Ranked by allocated vCPU, falling back to VM count"
isLoading={isLoadingStats}
isEmpty={!stats || stats.topTenantsByResource.length === 0}
emptyLabel="No tenant workloads yet."
>
<RankedBarChart data={stats?.topTenantsByResource ?? []} />
</ChartCard>
<ChartCard
title="Top Tenants by Storage"
description="Ranked by total provisioned volume size"
isLoading={isLoadingStats}
isEmpty={!stats || stats.topTenantsByStorage.length === 0}
emptyLabel="No volumes provisioned yet."
>
<RankedBarChart data={stats?.topTenantsByStorage ?? []} />
</ChartCard>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<ChartCard
title="VMs by Region"
description="Where workloads are physically deployed"
isLoading={isLoadingStats}
isEmpty={!stats || stats.vmsByRegion.length === 0}
emptyLabel="No virtual machines yet."
>
<RankedBarChart data={stats?.vmsByRegion ?? []} />
</ChartCard>
<ChartCard
title="Busiest Workload Hosts"
description="Hosts carrying the most virtual machines"
isLoading={isLoadingStats}
isEmpty={!stats || stats.vmsByHost.length === 0}
emptyLabel="No virtual machines yet."
>
<RankedBarChart data={stats?.vmsByHost ?? []} />
</ChartCard>
</div>
</div>
);
}
@@ -0,0 +1,414 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useToast } from '@/hooks/use-toast';
import {
xcloudifyApi,
type CoreNetwork,
type CreateNetworkInput,
} from '@/services/xcloudifyApi';
interface RegionOption {
id: string;
name?: string;
abbreviation?: string;
}
interface CreateNetworkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (network: CoreNetwork) => void | Promise<void>;
defaultTenantId?: string;
defaultRegionId?: string;
}
interface NetworkFormState {
name: string;
tenant_id: string;
region_id: string;
vni: string;
ipv4_cidr: string;
ipv4_gateway: string;
ipv4_dns_servers: string;
dhcp_range_start: string;
dhcp_range_end: string;
nscontroller_ip: string;
ipv6_cidr: string;
ipv6_gateway: string;
encapsulation: string;
ovs_bridge: string;
network_type: string;
enable_host_nat: boolean;
}
const EMPTY_FORM: NetworkFormState = {
name: '',
tenant_id: '',
region_id: '',
vni: '',
ipv4_cidr: '',
ipv4_gateway: '',
ipv4_dns_servers: '',
dhcp_range_start: '',
dhcp_range_end: '',
nscontroller_ip: '',
ipv6_cidr: '',
ipv6_gateway: '',
encapsulation: 'VXLAN',
ovs_bridge: '',
network_type: '',
enable_host_nat: false,
};
function parseIpv4(value: string): number | null {
const parts = value.split('.');
if (parts.length !== 4) return null;
let result = 0;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const octet = Number(part);
if (octet > 255) return null;
result = result * 256 + octet;
}
return result;
}
function parseIpv4Cidr(value: string): { address: number; prefix: number; blockSize: number } | null {
const [addressText, prefixText, extra] = value.split('/');
if (extra !== undefined || !/^\d{1,2}$/.test(prefixText || '')) return null;
const address = parseIpv4(addressText);
const prefix = Number(prefixText);
if (address === null || prefix < 0 || prefix > 32) return null;
const blockSize = 2 ** (32 - prefix);
return { address, prefix, blockSize };
}
function validateForm(form: NetworkFormState): string | null {
if (!form.name.trim() || !form.tenant_id.trim() || !form.region_id) {
return 'Name, tenant ID, and region are required.';
}
if (form.vni.trim()) {
if (!/^\d+$/.test(form.vni.trim())) return 'VNI must be a whole number.';
const vni = Number(form.vni);
if (vni < 1 || vni > 16_777_215) return 'VNI must be between 1 and 16777215.';
}
const cidrText = form.ipv4_cidr.trim();
const cidr = cidrText ? parseIpv4Cidr(cidrText) : null;
if (cidrText && !cidr) return 'IPv4 CIDR must look like 10.0.0.0/24.';
if (cidr && cidr.address % cidr.blockSize !== 0) {
return 'IPv4 CIDR must start at the network address (for example, 10.0.0.0/24).';
}
const addresses: Array<[string, string]> = [
['IPv4 gateway', form.ipv4_gateway],
['DHCP range start', form.dhcp_range_start],
['DHCP range end', form.dhcp_range_end],
['NSController IP', form.nscontroller_ip],
];
for (const [label, rawValue] of addresses) {
const value = rawValue.trim();
if (!value) continue;
const parsed = parseIpv4(value);
if (parsed === null) return `${label} must be a valid IPv4 address.`;
if (cidr && Math.floor(parsed / cidr.blockSize) !== Math.floor(cidr.address / cidr.blockSize)) {
return `${label} must be inside the IPv4 CIDR.`;
}
}
const dhcpStart = form.dhcp_range_start.trim();
const dhcpEnd = form.dhcp_range_end.trim();
if (!!dhcpStart !== !!dhcpEnd) return 'Provide both DHCP range endpoints or leave both blank.';
if (dhcpStart && dhcpEnd && parseIpv4(dhcpStart)! > parseIpv4(dhcpEnd)!) {
return 'DHCP range start must not be after its end.';
}
const dnsServers = form.ipv4_dns_servers.split(',').map((value) => value.trim()).filter(Boolean);
if (
form.ipv4_dns_servers.trim()
&& (dnsServers.length === 0 || dnsServers.some((value) => parseIpv4(value) === null))
) {
return 'IPv4 DNS servers must be valid, comma-separated IPv4 addresses.';
}
return null;
}
function buildPayload(form: NetworkFormState): CreateNetworkInput {
const payload: CreateNetworkInput = {
name: form.name.trim(),
tenant_id: form.tenant_id.trim(),
region_id: form.region_id,
encapsulation: form.encapsulation,
enable_host_nat: form.enable_host_nat,
};
if (form.vni.trim()) payload.vni = Number(form.vni);
if (form.ipv4_cidr.trim()) payload.ipv4_cidr = form.ipv4_cidr.trim();
if (form.ipv4_gateway.trim()) payload.ipv4_gateway = form.ipv4_gateway.trim();
if (form.ipv4_dns_servers.trim()) {
payload.ipv4_dns_servers = form.ipv4_dns_servers
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.join(',');
}
if (form.dhcp_range_start.trim()) payload.dhcp_range_start = form.dhcp_range_start.trim();
if (form.dhcp_range_end.trim()) payload.dhcp_range_end = form.dhcp_range_end.trim();
if (form.nscontroller_ip.trim()) payload.nscontroller_ip = form.nscontroller_ip.trim();
if (form.ipv6_cidr.trim()) payload.ipv6_cidr = form.ipv6_cidr.trim();
if (form.ipv6_gateway.trim()) payload.ipv6_gateway = form.ipv6_gateway.trim();
if (form.ovs_bridge.trim()) payload.ovs_bridge = form.ovs_bridge.trim();
if (form.network_type.trim()) payload.network_type = form.network_type.trim();
return payload;
}
export function CreateNetworkDialog({
open,
onOpenChange,
onCreated,
defaultTenantId = '',
defaultRegionId = '',
}: CreateNetworkDialogProps) {
const [form, setForm] = useState<NetworkFormState>(EMPTY_FORM);
const [regions, setRegions] = useState<RegionOption[]>([]);
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
const [regionsError, setRegionsError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const { toast } = useToast();
const loadRegions = useCallback(async () => {
try {
setIsLoadingRegions(true);
setRegionsError(null);
setRegions(await xcloudifyApi.getRegions());
} catch (error) {
setRegionsError(error instanceof Error ? error.message : 'Failed to load regions');
} finally {
setIsLoadingRegions(false);
}
}, []);
useEffect(() => {
if (!open) return;
setForm({
...EMPTY_FORM,
tenant_id: defaultTenantId,
region_id: defaultRegionId,
});
}, [open, defaultTenantId, defaultRegionId]);
useEffect(() => {
if (open) void loadRegions();
}, [open, loadRegions]);
const update = <K extends keyof NetworkFormState>(key: K, value: NetworkFormState[K]) => {
setForm((current) => ({ ...current, [key]: value }));
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const validationError = validateForm(form);
if (validationError) {
toast({ title: 'Validation Error', description: validationError, variant: 'destructive' });
return;
}
setIsCreating(true);
try {
const network = await xcloudifyApi.createAdminNetwork(buildPayload(form));
await onCreated(network);
toast({ title: 'Network created', description: `${network.name} was created successfully.` });
onOpenChange(false);
} catch (error) {
toast({
title: 'Failed to create network',
description: error instanceof Error ? error.message : 'The network could not be created.',
variant: 'destructive',
});
} finally {
setIsCreating(false);
}
};
const selectedRegionMissing = form.region_id && !regions.some((region) => region.id === form.region_id);
return (
<Dialog open={open} onOpenChange={(nextOpen) => !isCreating && onOpenChange(nextOpen)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Network</DialogTitle>
<DialogDescription>
Create a tenant network on the core underlay. Leave VNI blank to let core allocate one.
</DialogDescription>
</DialogHeader>
<form id="create-network-form" onSubmit={handleSubmit} className="space-y-6">
<section className="space-y-4">
<h3 className="text-sm font-semibold">Placement and ownership</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="network-name">Name *</Label>
<Input
id="network-name"
value={form.name}
onChange={(event) => update('name', event.target.value)}
placeholder="e.g., production-overlay"
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="network-tenant">Tenant ID *</Label>
<Input
id="network-tenant"
value={form.tenant_id}
onChange={(event) => update('tenant_id', event.target.value)}
placeholder="Owning tenant ID"
required
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="network-region">Region *</Label>
<Select value={form.region_id} onValueChange={(value) => update('region_id', value)}>
<SelectTrigger id="network-region" disabled={isLoadingRegions || (!!regionsError && regions.length === 0)}>
<SelectValue placeholder={isLoadingRegions ? 'Loading regions...' : 'Select a region'} />
</SelectTrigger>
<SelectContent>
{selectedRegionMissing && (
<SelectItem value={form.region_id}>{form.region_id}</SelectItem>
)}
{regions.map((region) => (
<SelectItem key={region.id} value={region.id}>
{region.name || region.id}{region.abbreviation ? ` (${region.abbreviation})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{regionsError && (
<div className="flex items-center justify-between gap-3 text-sm text-destructive">
<span>{regionsError}</span>
<Button type="button" variant="outline" size="sm" onClick={loadRegions} disabled={isLoadingRegions}>
Retry
</Button>
</div>
)}
{!isLoadingRegions && !regionsError && regions.length === 0 && (
<p className="text-sm text-muted-foreground">No regions exist yet. Create a region before creating a network.</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="network-vni">VNI</Label>
<Input
id="network-vni"
type="number"
min={1}
max={16_777_215}
step={1}
value={form.vni}
onChange={(event) => update('vni', event.target.value)}
placeholder="Allocated automatically"
/>
</div>
<div className="space-y-2">
<Label htmlFor="network-encapsulation">Encapsulation</Label>
<Select value={form.encapsulation} onValueChange={(value) => update('encapsulation', value)}>
<SelectTrigger id="network-encapsulation"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="VXLAN">VXLAN</SelectItem>
<SelectItem value="flat">Flat</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</section>
<section className="space-y-4">
<h3 className="text-sm font-semibold">IPv4 addressing</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="network-ipv4-cidr">CIDR</Label>
<Input id="network-ipv4-cidr" value={form.ipv4_cidr} onChange={(event) => update('ipv4_cidr', event.target.value)} placeholder="10.0.0.0/24" />
</div>
<div className="space-y-2">
<Label htmlFor="network-ipv4-gateway">Gateway</Label>
<Input id="network-ipv4-gateway" value={form.ipv4_gateway} onChange={(event) => update('ipv4_gateway', event.target.value)} placeholder="10.0.0.1" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="network-ipv4-dns">DNS servers</Label>
<Input id="network-ipv4-dns" value={form.ipv4_dns_servers} onChange={(event) => update('ipv4_dns_servers', event.target.value)} placeholder="1.1.1.1, 8.8.8.8" />
</div>
<div className="space-y-2">
<Label htmlFor="network-dhcp-start">DHCP range start</Label>
<Input id="network-dhcp-start" value={form.dhcp_range_start} onChange={(event) => update('dhcp_range_start', event.target.value)} placeholder="10.0.0.100" />
</div>
<div className="space-y-2">
<Label htmlFor="network-dhcp-end">DHCP range end</Label>
<Input id="network-dhcp-end" value={form.dhcp_range_end} onChange={(event) => update('dhcp_range_end', event.target.value)} placeholder="10.0.0.200" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="network-nscontroller-ip">NSController IP</Label>
<Input id="network-nscontroller-ip" value={form.nscontroller_ip} onChange={(event) => update('nscontroller_ip', event.target.value)} placeholder="10.0.0.2" />
</div>
</div>
</section>
<section className="space-y-4">
<h3 className="text-sm font-semibold">Advanced</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="network-ipv6-cidr">IPv6 CIDR</Label>
<Input id="network-ipv6-cidr" value={form.ipv6_cidr} onChange={(event) => update('ipv6_cidr', event.target.value)} placeholder="2001:db8::/64" />
</div>
<div className="space-y-2">
<Label htmlFor="network-ipv6-gateway">IPv6 gateway</Label>
<Input id="network-ipv6-gateway" value={form.ipv6_gateway} onChange={(event) => update('ipv6_gateway', event.target.value)} placeholder="2001:db8::1" />
</div>
<div className="space-y-2">
<Label htmlFor="network-ovs-bridge">OVS bridge</Label>
<Input id="network-ovs-bridge" value={form.ovs_bridge} onChange={(event) => update('ovs_bridge', event.target.value)} placeholder="e.g., br-overlay" />
</div>
<div className="space-y-2">
<Label htmlFor="network-type">Network type</Label>
<Input id="network-type" value={form.network_type} onChange={(event) => update('network_type', event.target.value)} placeholder="Optional provider-specific type" />
</div>
<div className="flex items-center justify-between gap-4 rounded-md border p-4 sm:col-span-2">
<div className="space-y-1">
<Label htmlFor="network-host-nat">Host NAT</Label>
<p className="text-sm text-muted-foreground">Enable internet egress through the NSController host.</p>
</div>
<Switch id="network-host-nat" checked={form.enable_host_nat} onCheckedChange={(checked) => update('enable_host_nat', checked)} />
</div>
</div>
</section>
</form>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
Cancel
</Button>
<Button
type="submit"
form="create-network-form"
disabled={isCreating || !form.name.trim() || !form.tenant_id.trim() || !form.region_id || isLoadingRegions}
>
{isCreating ? 'Creating...' : 'Create Network'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -11,6 +11,7 @@ import {
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { xcloudifyApi } from "@/services/xcloudifyApi";
import { usePlane } from "@/plane";
import { ResourceTypeSelector } from "./ResourceTypeSelector";
import { ResourceConfigurationForm } from "./ResourceConfigurationForm";
import { ResourceReviewStep } from "./ResourceReviewStep";
@@ -51,6 +52,8 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
availablePods = [],
initialPodId = ""
}) => {
const { zerotier: zerotierSupported, cloudflare: cloudflareSupported, sshKeyVault: sshKeyVaultSupported } = usePlane();
const [wizardStep, setWizardStep] = useState(initialStep);
const [resourceType, setResourceType] = useState(initialResourceType);
const [resourceName, setResourceName] = useState("");
@@ -104,6 +107,8 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
const [sshKeys, setSshKeys] = useState<any[]>([]);
const [sshKeysLoading, setSshKeysLoading] = useState(false);
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
// Raw-paste path for planes with no key vault (see plane.tsx sshKeyVault)
const [rawPublicKeys, setRawPublicKeys] = useState("");
// GPU state for VM creation
const [gpuModels, setGpuModels] = useState<any[]>([]);
@@ -176,16 +181,19 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
setImagesLoading(false);
}
// Fetch SSH keys for VM creation
try {
setSshKeysLoading(true);
const keysResponse = await xcloudifyApi.getSSHKeys();
setSshKeys(keysResponse);
} catch (error) {
console.error("Error fetching SSH keys:", error);
setSshKeys([]);
} finally {
setSshKeysLoading(false);
// Fetch SSH keys for VM creation -- only planes with a key vault have
// this endpoint (see plane.tsx sshKeyVault); others use a raw-paste field.
if (sshKeyVaultSupported) {
try {
setSshKeysLoading(true);
const keysResponse = await xcloudifyApi.getSSHKeys();
setSshKeys(keysResponse);
} catch (error) {
console.error("Error fetching SSH keys:", error);
setSshKeys([]);
} finally {
setSshKeysLoading(false);
}
}
// Fetch GPU models for VM creation
@@ -223,7 +231,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
// Default to first network for VMs when list loads
useEffect(() => {
if (resourceType !== "virtual-machine" || !vmVdcId) return;
const filtered = networks.filter(n => n.vdc_id === vmVdcId);
const filtered = networks.filter(n => n.tenant_id === vmVdcId);
if (filtered.length > 0 && vmNetworks.length === 0) {
setVmNetworks([filtered[0].id]);
}
@@ -273,35 +281,17 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
setServerGroupsLoading(false);
}
// Fetch volumes for the selected VDC
// Fetch volumes for the selected VDC. Core scopes volumes by tenant_id,
// not vdc_id -- the VDC's own id doubles as tenant_id by convention (see
// gateway_routes.py in theapi/cloud).
if (resourceType === "container") {
try {
setVolumesLoading(true);
const response = await xcloudifyApi.getVolumesForVirtualDataCenter(vdcId);
// Handle different possible response formats
let volumesData = [];
if (response.data?.volumes) {
volumesData = response.data.volumes;
} else if (response.volumes) {
volumesData = response.volumes;
} else if (Array.isArray(response.data)) {
volumesData = response.data;
} else if (Array.isArray(response)) {
volumesData = response;
}
setVolumes(volumesData);
const response = await xcloudifyApi.getVolumes({ tenant_id: vdcId });
setVolumes(response.data?.volumes || []);
} catch (error) {
console.error("Error fetching volumes for VDC:", error);
// Fallback to filtered volumes from general API
try {
const response = await xcloudifyApi.getVolumes({ vdc_id: vdcId });
setVolumes(response.data?.volumes || []);
} catch (fallbackError) {
console.error("Error fetching fallback volumes:", fallbackError);
setVolumes([]);
}
setVolumes([]);
} finally {
setVolumesLoading(false);
}
@@ -361,6 +351,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
setServerGroupId("");
// Reset SSH key selection
setSelectedSshKeyIds([]);
setRawPublicKeys("");
// Reset GPU selection
setSelectedGpuModelId("");
setGpuCount("1");
@@ -411,7 +402,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
toast.error("Please select at least one network");
return;
}
if (zeroTierEnabled) {
if (zerotierSupported && zeroTierEnabled) {
const ztId = zeroTierNetworkId.trim();
if (!ztId) {
toast.error("Please enter a ZeroTier Network ID");
@@ -497,7 +488,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
internal: parseInt(port.internalPort) || 80,
external: parseInt(port.externalPort) || 80,
protocol: port.protocol?.toLowerCase() || "tcp",
...(port.useDns && { use_dns: true })
...(cloudflareSupported && port.useDns && { use_dns: true })
}));
// Prepare storage mounts
@@ -520,10 +511,20 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
memoryValue = memory.replace('mb', '').replace('gb', memory.includes('gb') ? (parseInt(memory.replace('gb', '')) * 1024).toString() : memory.replace('mb', ''));
}
// Core has no VDC concept: a new pod needs the VDC's region_id, and its
// tenant_id is the VDC's own id by convention (see gateway_routes.py in
// theapi/cloud, which stamps tenant_id = vdc.id on every core call).
const containerVdcId = selectedVdc || vmVdcId || (virtualDataCenters.length > 0 ? virtualDataCenters[0].id : "");
const containerVdc = virtualDataCenters.find(v => v.id === containerVdcId);
if (createNewPod && !containerVdc?.region_id) {
toast.error("Could not resolve a region for the selected virtual data center");
return;
}
const containerPayload = {
// Use pod ID if adding to existing pod, otherwise use VDC ID
...(createNewPod
? { virtual_data_center: selectedVdc || vmVdcId || (virtualDataCenters.length > 0 ? virtualDataCenters[0].id : "") }
// Use pod ID if adding to existing pod, otherwise use the VDC's region + tenant
...(createNewPod
? { region_id: containerVdc?.region_id || "", tenant_id: containerVdcId }
: { pod: selectedPod }
),
...(serverGroupId && { server_group_id: serverGroupId }),
@@ -536,9 +537,12 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
...(resourceDetails.command && { command: resourceDetails.command }),
// Only include networks if not using host-bridge
...(selectedNetwork && selectedNetwork !== "host-bridge" && { networks: [selectedNetwork] }),
...(cpuValue && { cpu_shares: parseInt(cpuValue) || 1 }),
// Core's container sanitizer reads 'cpu', not 'cpu_shares' -- see
// core/app/controller/api/workload_container_routes.py.
...(cpuValue && { cpu: parseInt(cpuValue) || 1 }),
...(memoryValue && { mem_limit: parseInt(memoryValue) || 512 }),
...(selectedGpuModelId && { gpu: [{ id: selectedGpuModelId, count: parseInt(gpuCount) || 1 }] })
// No GPU field here: core has no GPU support for containers (only
// VMs) -- see workload_container_routes.py's explicit rejection.
}]
};
@@ -556,6 +560,10 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
const createVirtualMachine = async () => {
try {
if (!virtualDataCenters.find(v => v.id === vmVdcId)?.region_id) {
toast.error("Could not resolve a region for the selected virtual data center");
return;
}
// Parse memory and vCPU from specs selection or use custom values
let memory = parseInt(vmMemory) || 0;
let vcpu = parseInt(vmVcpu) || 0;
@@ -609,9 +617,11 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
});
}
// Validate ZeroTier config (compulsory network ID once enabled)
const zeroTierId = zeroTierNetworkId.trim();
if (zeroTierEnabled) {
// Validate ZeroTier config (compulsory network ID once enabled).
// On a plane without ZeroTier the toggle never renders, so treat it as off.
const zeroTierId = zerotierSupported ? zeroTierNetworkId.trim() : "";
const zeroTierOn = zerotierSupported && zeroTierEnabled;
if (zeroTierOn) {
if (!zeroTierId) {
toast.error("Please enter a ZeroTier Network ID");
return;
@@ -622,8 +632,13 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
}
}
// Core has no VDC concept: it wants region_id + tenant_id. The VDC's
// own region_id supplies the former; its id doubles as tenant_id by
// convention (see gateway_routes.py in theapi/cloud).
const selectedVmVdc = virtualDataCenters.find(v => v.id === vmVdcId);
const vmPayload: any = {
virtual_data_center: vmVdcId,
region_id: selectedVmVdc?.region_id || "",
tenant_id: vmVdcId,
"virtual-machines": [{
name: resourceName,
memory: memory,
@@ -634,14 +649,18 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
...(selectedGpuModelId
? { gpu: [{ id: selectedGpuModelId, count: parseInt(gpuCount) || 1 }] }
: gpu > 0 ? { gpu } : {}),
// Send SSH key IDs (not full keys) - backend validates & metadata resolves them
...(selectedSshKeyIds.length > 0 && {
public_keys: selectedSshKeyIds
}),
// Vault planes send saved key IDs; planes without a vault (see
// plane.tsx sshKeyVault) send the raw OpenSSH strings pasted in.
...(sshKeyVaultSupported
? selectedSshKeyIds.length > 0 && { public_keys: selectedSshKeyIds }
: (() => {
const keys = rawPublicKeys.split(/\r?\n/).map(k => k.trim()).filter(Boolean);
return keys.length > 0 ? { public_keys: keys } : {};
})()),
// User-defined raw metadata (e.g. cloud-init cloud-config YAML)
...(customMetadata.trim() && { metadata: customMetadata }),
// ZeroTier network to join this VM to
...(zeroTierEnabled && zeroTierId && { zerotier_network_id: zeroTierId }),
...(zeroTierOn && zeroTierId && { zerotier_network_id: zeroTierId }),
}]
};
@@ -652,7 +671,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
await xcloudifyApi.createVirtualMachine(vmPayload);
toast.success("Virtual Machine created successfully!");
if (zeroTierEnabled && zeroTierId) {
if (zeroTierOn && zeroTierId) {
toast("Don't forget to authorize this VM in ZeroTier", {
description: `Approve the new member in your ZeroTier network (${zeroTierId}) admin menu so the VM can connect.`,
duration: 15000,
@@ -661,7 +680,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
onResourceCreated({
type: "virtual-machine",
name: resourceName,
zerotierNetworkId: zeroTierEnabled && zeroTierId ? zeroTierId : undefined,
zerotierNetworkId: zeroTierOn && zeroTierId ? zeroTierId : undefined,
});
handleDialogOpenChange(false);
} catch (error) {
@@ -678,9 +697,18 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
toast.error("Please select a virtual data center");
return;
}
// Core has no VDC concept: it wants region_id + tenant_id. The VDC's
// own region_id supplies the former; its id doubles as tenant_id by
// convention (see gateway_routes.py in theapi/cloud).
const networkVdc = virtualDataCenters.find(v => v.id === vdcId);
if (!networkVdc?.region_id) {
toast.error("Could not resolve a region for the selected virtual data center");
return;
}
const payload: Record<string, any> = {
name: resourceName,
vdc_id: vdcId,
region_id: networkVdc.region_id,
tenant_id: vdcId,
};
if (resourceDetails.ipv4_cidr) payload.ipv4_cidr = resourceDetails.ipv4_cidr;
if (resourceDetails.ipv4_gateway) payload.ipv4_gateway = resourceDetails.ipv4_gateway;
@@ -806,6 +834,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
gpuCount={gpuCount}
sshKeys={sshKeys}
selectedSshKeyIds={selectedSshKeyIds}
rawPublicKeys={rawPublicKeys}
zeroTierEnabled={zeroTierEnabled}
zeroTierNetworkId={zeroTierNetworkId}
customMetadata={customMetadata}
@@ -884,6 +913,8 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
checked ? [...prev, keyId] : prev.filter(id => id !== keyId)
);
}}
rawPublicKeys={rawPublicKeys}
setRawPublicKeys={setRawPublicKeys}
serverGroupId={serverGroupId}
setServerGroupId={setServerGroupId}
serverGroups={serverGroups}
@@ -950,13 +981,6 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
setServerGroupId={setServerGroupId}
serverGroups={serverGroups}
serverGroupsLoading={serverGroupsLoading}
// GPU allocation props
gpuModels={gpuModels}
gpuModelsLoading={gpuModelsLoading}
selectedGpuModelId={selectedGpuModelId}
setSelectedGpuModelId={setSelectedGpuModelId}
gpuCount={gpuCount}
setGpuCount={setGpuCount}
/>
);
case 3:
@@ -1,10 +1,11 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { usePlane } from "@/plane";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, X, Search, HardDrive, Share, Sparkles } from "lucide-react";
import { Plus, X, Search, HardDrive, Share } from "lucide-react";
import type { EnvironmentVariable, PortForwarding, StorageMount, Pod, ResourceDetails } from "./types";
import type { Network } from "@/hooks/useNetworks";
import type { VirtualDataCenter } from "@/hooks/useVirtualDataCenters";
@@ -58,13 +59,6 @@ interface ResourceConfigurationFormProps {
setServerGroupId?: (id: string) => void;
serverGroups?: Array<{ id: string; name: string; policy: string }>;
serverGroupsLoading?: boolean;
// GPU allocation (optional)
gpuModels?: Array<{ id: string; vendor_name: string; device_name: string; vram_mb: number; architecture?: string }>;
gpuModelsLoading?: boolean;
selectedGpuModelId?: string;
setSelectedGpuModelId?: (id: string) => void;
gpuCount?: string;
setGpuCount?: (count: string) => void;
}
export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps> = ({
@@ -116,14 +110,9 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
setServerGroupId = () => {},
serverGroups = [],
serverGroupsLoading = false,
// GPU allocation (optional)
gpuModels = [],
gpuModelsLoading = false,
selectedGpuModelId = "",
setSelectedGpuModelId = () => {},
gpuCount = "1",
setGpuCount = () => {},
}) => {
const { cloudflare } = usePlane();
return (
<div className="space-y-4">
<div>
@@ -293,7 +282,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
<SelectItem value="host-bridge">Host bridge</SelectItem>
{selectedVdc && (() => {
const filteredNetworks = availableNetworks.filter(network => network.vdc_id === selectedVdc);
const filteredNetworks = availableNetworks.filter(network => network.tenant_id === selectedVdc);
return filteredNetworks.map((network) => (
<SelectItem key={network.id} value={network.id}>
{network.name}
@@ -407,6 +396,9 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
}}
className="flex-1"
/>
{/* "Use DNS" is the Cloudflare tunnel intent flag. Core stores it
but provisions nothing, so it only renders where a provider exists. */}
{cloudflare && (
<div className="flex items-center space-x-2">
<Checkbox
id={`dns-${index}`}
@@ -415,6 +407,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
/>
<Label htmlFor={`dns-${index}`} className="text-sm whitespace-nowrap">Use DNS</Label>
</div>
)}
<Button
type="button"
variant="ghost"
@@ -474,7 +467,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
</SelectTrigger>
<SelectContent className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50">
{(() => {
const filteredVolumes = availableVolumes.filter(volume => !selectedVdc || volume.vdc_id === selectedVdc);
const filteredVolumes = availableVolumes.filter(volume => !selectedVdc || volume.tenant_id === selectedVdc);
return filteredVolumes.length > 0 ? (
filteredVolumes.map((volume) => (
<SelectItem key={volume.id} value={volume.id}>
@@ -570,54 +563,6 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
)}
</div>
{/* GPU Allocation Section */}
<div className="rounded-md border p-3 space-y-3">
<div className="flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-muted-foreground" />
<Label htmlFor="container-gpu-model">GPU Acceleration <span className="text-muted-foreground text-xs">(optional)</span></Label>
</div>
{gpuModelsLoading ? (
<div className="h-10 rounded-md border bg-muted/30 animate-pulse" />
) : (
<div className="flex gap-2">
<Select
value={selectedGpuModelId || "none"}
onValueChange={(value) => setSelectedGpuModelId(value === "none" ? "" : value)}
>
<SelectTrigger className="w-full" id="container-gpu-model">
<SelectValue placeholder="No GPU" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No GPU</SelectItem>
{gpuModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.vendor_name} {model.device_name}
{model.vram_mb && (
<span className="ml-2 text-xs text-muted-foreground">
{Math.round(model.vram_mb / 1024)}GB VRAM
</span>
)}
{model.architecture && (
<span className="ml-1 text-xs text-muted-foreground">· {model.architecture}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedGpuModelId && (
<Input
type="number"
min="1"
max="8"
value={gpuCount}
onChange={(e) => setGpuCount(e.target.value)}
className="w-20"
placeholder="Count"
/>
)}
</div>
)}
</div>
</>
)}
@@ -32,6 +32,7 @@ interface ResourceReviewStepProps {
gpuCount?: string;
sshKeys?: Array<{ id: string; key_name: string }>;
selectedSshKeyIds?: string[];
rawPublicKeys?: string;
zeroTierEnabled?: boolean;
zeroTierNetworkId?: string;
customMetadata?: string;
@@ -73,6 +74,7 @@ export const ResourceReviewStep: React.FC<ResourceReviewStepProps> = ({
gpuCount = "1",
sshKeys = [],
selectedSshKeyIds = [],
rawPublicKeys = "",
zeroTierEnabled = false,
zeroTierNetworkId = "",
customMetadata = "",
@@ -243,6 +245,14 @@ export const ResourceReviewStep: React.FC<ResourceReviewStepProps> = ({
</span>
</div>
)}
{rawPublicKeys.trim() && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">SSH Public Key:</span>
<span className="text-sm font-medium font-mono truncate max-w-[240px]" title={rawPublicKeys.trim()}>
{rawPublicKeys.trim().split(/\r?\n/).filter(Boolean).length} key(s)
</span>
</div>
)}
{zeroTierEnabled && zeroTierNetworkId && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">ZeroTier Network:</span>
@@ -8,6 +8,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Network as NetworkIcon, KeyRound, Plus, Users, ChevronDown, Info, Settings2 } from "lucide-react";
import type { Network } from "@/hooks/useNetworks";
import { usePlane } from "@/plane";
interface VmNetworkAccessStepProps {
vmVdcId: string;
@@ -23,6 +24,9 @@ interface VmNetworkAccessStepProps {
sshKeysLoading: boolean;
selectedSshKeyIds: string[];
onToggleSshKey: (keyId: string, checked: boolean) => void;
/** Raw OpenSSH public key text, one per line. Used when the plane has no key vault. */
rawPublicKeys: string;
setRawPublicKeys: (value: string) => void;
serverGroupId: string;
setServerGroupId: (id: string) => void;
serverGroups: Array<{ id: string; name: string; policy: string }>;
@@ -45,6 +49,8 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
sshKeysLoading,
selectedSshKeyIds,
onToggleSshKey,
rawPublicKeys,
setRawPublicKeys,
serverGroupId,
setServerGroupId,
serverGroups,
@@ -52,6 +58,8 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
customMetadata,
setCustomMetadata,
}) => {
const { zerotier, sshKeyVault } = usePlane();
return (
<div className="space-y-6">
<div className="space-y-1.5">
@@ -74,7 +82,7 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
</SelectTrigger>
<SelectContent className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50">
{vmVdcId && (() => {
const filteredNetworks = availableNetworks.filter(network => network.vdc_id === vmVdcId);
const filteredNetworks = availableNetworks.filter(network => network.tenant_id === vmVdcId);
return filteredNetworks.length > 0 ? (
filteredNetworks.map((network) => (
<SelectItem key={network.id} value={network.id}>
@@ -95,6 +103,8 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
</div>
{/* ── ZeroTier ─────────────────────────────────────────────── */}
{/* Cloud-only: core stores a zerotier_network_id but provisions nothing. */}
{zerotier && (
<div className="rounded-md border p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -127,57 +137,79 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
</div>
)}
</div>
)}
{/* ── SSH Key ─────────────────────────────────────────────── */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
<Label htmlFor="vm-ssh-key">SSH Key <span className="text-muted-foreground text-xs">(optional)</span></Label>
</div>
<a
href="/ssh-keys"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<Plus className="h-3 w-3" />
Add new key
</a>
</div>
{sshKeysLoading ? (
<div className="h-10 rounded-md border bg-muted/30 animate-pulse" />
) : sshKeys.length === 0 ? (
<div className="flex items-center justify-between rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground">
<span>No SSH keys saved yet.</span>
<a href="/ssh-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline text-xs">
Create one →
{/* Cloud has a saved key vault; core validates a raw public key inline
on VM create and keeps no record of it afterward -- see plane.tsx. */}
{sshKeyVault ? (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
<Label htmlFor="vm-ssh-key">SSH Key <span className="text-muted-foreground text-xs">(optional)</span></Label>
</div>
<a
href="/ssh-keys"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<Plus className="h-3 w-3" />
Add new key
</a>
</div>
) : (
<Select
value={selectedSshKeyIds[0] ?? "none"}
onValueChange={(value) => onToggleSshKey(value === "none" ? "" : value, value !== "none")}
>
<SelectTrigger className="w-full" id="vm-ssh-key">
<SelectValue placeholder="Select a key…" />
</SelectTrigger>
<SelectContent className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50">
<SelectItem value="none">None</SelectItem>
{sshKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
<span className="font-medium">{key.key_name}</span>
{key.key_fingerprint && (
<span className="ml-2 text-xs text-muted-foreground font-mono">
{key.key_fingerprint.slice(0, 24)}…
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{sshKeysLoading ? (
<div className="h-10 rounded-md border bg-muted/30 animate-pulse" />
) : sshKeys.length === 0 ? (
<div className="flex items-center justify-between rounded-md border border-dashed px-3 py-2 text-sm text-muted-foreground">
<span>No SSH keys saved yet.</span>
<a href="/ssh-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline text-xs">
Create one →
</a>
</div>
) : (
<Select
value={selectedSshKeyIds[0] ?? "none"}
onValueChange={(value) => onToggleSshKey(value === "none" ? "" : value, value !== "none")}
>
<SelectTrigger className="w-full" id="vm-ssh-key">
<SelectValue placeholder="Select a key…" />
</SelectTrigger>
<SelectContent className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50">
<SelectItem value="none">None</SelectItem>
{sshKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
<span className="font-medium">{key.key_name}</span>
{key.key_fingerprint && (
<span className="ml-2 text-xs text-muted-foreground font-mono">
{key.key_fingerprint.slice(0, 24)}…
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
) : (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
<Label htmlFor="vm-ssh-key-raw">SSH Public Key <span className="text-muted-foreground text-xs">(optional)</span></Label>
</div>
<Textarea
id="vm-ssh-key-raw"
value={rawPublicKeys}
onChange={(e) => setRawPublicKeys(e.target.value)}
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBc... user@host"
className="font-mono text-xs min-h-[72px]"
/>
<p className="text-xs text-muted-foreground">
One or more OpenSSH public keys, one per line. Injected into the VM via cloud-init at boot.
</p>
</div>
)}
{/* ── Server Group ────────────────────────────────────────── */}
<div className="space-y-1.5">
@@ -36,7 +36,6 @@ const INITIAL_FORM = {
boot: false,
vdc_id: "",
image_id: "",
host_id: "",
};
export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: CreateVolumeDialogProps) => {
@@ -45,13 +44,11 @@ export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: Cr
const [blockType, setBlockType] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [images, setImages] = useState<any[]>([]);
const [hosts, setHosts] = useState<any[]>([]);
const [formData, setFormData] = useState(INITIAL_FORM);
useEffect(() => {
if (isOpen) {
xcloudifyApi.getImages().then(setImages).catch(() => {});
xcloudifyApi.getWorkloadHosts().then(setHosts).catch(() => {});
}
}, [isOpen]);
@@ -97,14 +94,16 @@ export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: Cr
const payload: Record<string, any> = {
name: formData.name,
size_gb: parseFloat(formData.size_gb),
vdc_id: formData.vdc_id,
// Core has no VDC concept -- it scopes volumes by tenant_id. The VDC
// id doubles as the tenant_id by convention (see gateway_routes.py
// in theapi/cloud, which stamps tenant_id = vdc.id on every core call).
tenant_id: formData.vdc_id,
type: apiType,
boot: formData.boot,
};
if (formData.description) payload.description = formData.description;
if (formData.path) payload.path = formData.path;
if (formData.image_id) payload.image_id = formData.image_id;
if (formData.host_id) payload.host_id = formData.host_id;
await xcloudifyApi.createVolume(payload);
toast.success("Volume queued for creation — provisioning in background");
@@ -324,27 +323,6 @@ export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: Cr
</div>
)}
{/* Host pin */}
<div className="space-y-2">
<Label>Pin to Host <span className="text-xs text-muted-foreground">(optional)</span></Label>
<Select
value={formData.host_id || "__auto__"}
onValueChange={(v) => handleChange("host_id", v === "__auto__" ? "" : v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__auto__">Auto-schedule (recommended)</SelectItem>
{hosts.map((host) => (
<SelectItem key={host.id} value={host.id}>
{host.name || host.hostname || host.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => handleOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={isLoading}>
@@ -18,7 +18,9 @@ interface DeleteConfirmationDialogProps {
title: string;
description: string;
itemName: string;
itemType: "pod" | "container" | "network" | "server group" | "virtual machine" | "volume";
itemType:
| "pod" | "container" | "network" | "server group" | "virtual machine"
| "volume" | "universe" | "DNS record";
isLoading?: boolean;
}
@@ -165,7 +165,7 @@ export function ImagesPage() {
if (selectedImage) {
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<BackToListButton
label="Back to images list"
@@ -273,7 +273,7 @@ export function ImagesPage() {
}
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
@@ -216,8 +216,7 @@ export const InfrastructurePage = () => {
setAttachWorkloadsLoading(true);
try {
const res = await xcloudifyApi.getWorkloadsForVirtualDataCenter(vol.vdc_id);
const wls = res.data?.workloads || res.data || [];
setAttachWorkloads(Array.isArray(wls) ? wls : []);
setAttachWorkloads(Array.isArray(res.data) ? res.data : []);
} catch {
toast({ title: "Failed to load workloads", variant: "destructive" });
} finally {
@@ -1521,7 +1520,7 @@ export const InfrastructurePage = () => {
// Group networks by VDC directly
const networksByVDC = new Map<string, typeof networks>();
networks.forEach((network) => {
const vdcId = network.vdc_id || 'unassigned';
const vdcId = network.tenant_id || 'unassigned';
if (!networksByVDC.has(vdcId)) {
networksByVDC.set(vdcId, []);
}
@@ -67,7 +67,9 @@ interface NetworkDetailsPageProps {
interface Network {
id: string;
name: string;
vdc_id: string;
// Core scopes networks by tenant_id, not vdc_id -- the VDC's own id
// doubles as tenant_id by convention (see gateway_routes.py in theapi/cloud).
tenant_id: string;
vni: number;
ipv4_cidr?: string;
ipv4_gateway?: string;
@@ -426,7 +428,7 @@ export const NetworkDetailsPage: React.FC<NetworkDetailsPageProps> = ({
</h1>
<p className="text-muted-foreground text-sm mt-1">
VNI {network.vni}
{network.vdc_id && <> · VDC <span className="font-mono">{network.vdc_id.substring(0, 8)}…</span></>}
{network.tenant_id && <> · VDC <span className="font-mono">{network.tenant_id.substring(0, 8)}…</span></>}
{network.encapsulation && <> · {network.encapsulation}</>}
{network.network_type && <> · {network.network_type}</>}
</p>
@@ -518,7 +520,7 @@ export const NetworkDetailsPage: React.FC<NetworkDetailsPageProps> = ({
{[
{ label: 'Network ID', value: network.id, mono: true, copyable: true },
{ label: 'Name', value: network.name },
{ label: 'VDC ID', value: network.vdc_id, mono: true, copyable: true },
{ label: 'VDC ID', value: network.tenant_id, mono: true, copyable: true },
{ label: 'VNI', value: String(network.vni) },
{ label: 'Network Type', value: network.network_type || '—' },
{ label: 'Encapsulation', value: network.encapsulation || '—' },
@@ -1050,7 +1052,7 @@ export const NetworkDetailsPage: React.FC<NetworkDetailsPageProps> = ({
{[
{ label: 'Network ID', value: network.id },
{ label: 'VNI', value: String(network.vni) },
{ label: 'VDC ID', value: network.vdc_id },
{ label: 'VDC ID', value: network.tenant_id },
{ label: 'Created', value: formatDate(network.created_at) },
].map(({ label, value }) => (
<div key={label} className="space-y-1">
@@ -0,0 +1,302 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Network as NetworkIcon, Plus, Search, X } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { BackToListButton } from '@/components/ui/back-to-list-button';
import { CopyButton } from '@/components/ui/copy-button';
import { CreateNetworkDialog } from '@/components/CreateNetworkDialog';
import { xcloudifyApi, type CoreNetwork } from '@/services/xcloudifyApi';
type NetworkItem = CoreNetwork;
function formatDate(dateString?: string) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString();
} catch {
return dateString;
}
}
interface NetworksPageProps {
networkId?: string;
onBack?: () => void;
/** Core operator console only: expose cross-tenant network creation. */
allowCreate?: boolean;
}
/**
* Underlay-wide network visibility for the operator console: every network
* across every tenant/region, filterable by either. Creation is opt-in because
* this shared page is also mounted by the cloud operator console.
*/
export function NetworksPage({ networkId: propNetworkId, onBack, allowCreate = false }: NetworksPageProps = {}) {
const [networks, setNetworks] = useState<NetworkItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tenantFilter, setTenantFilter] = useState('');
const [regionFilter, setRegionFilter] = useState('');
const [appliedFilters, setAppliedFilters] = useState({ tenant_id: '', region_id: '' });
const [selected, setSelected] = useState<NetworkItem | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const { networkId: urlNetworkId } = useParams<{ networkId: string }>();
const networkId = propNetworkId || urlNetworkId;
const navigate = useNavigate();
const backToList = onBack || (() => navigate('/admin/networks'));
const fetchData = useCallback(async (filters: { tenant_id: string; region_id: string }) => {
try {
setIsLoading(true);
setError(null);
const params: { tenant_id?: string; region_id?: string } = {};
if (filters.tenant_id) params.tenant_id = filters.tenant_id;
if (filters.region_id) params.region_id = filters.region_id;
setNetworks(await xcloudifyApi.getAdminNetworks(params));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch networks');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchData(appliedFilters);
}, [appliedFilters, fetchData]);
// URL-based network selection (e.g. a deep link from a VM's network port)
useEffect(() => {
if (!networkId) {
setSelected(null);
return;
}
if (networks.length === 0) return;
const match = networks.find((n) => n.id === networkId);
if (match) {
setSelected(match);
} else if (urlNetworkId) {
navigate('/admin/networks');
}
}, [networkId, networks, urlNetworkId, navigate]);
const applyFilters = () =>
setAppliedFilters({ tenant_id: tenantFilter.trim(), region_id: regionFilter.trim() });
const clearFilters = () => {
setTenantFilter('');
setRegionFilter('');
setAppliedFilters({ tenant_id: '', region_id: '' });
};
const hasActiveFilters = appliedFilters.tenant_id || appliedFilters.region_id;
if (selected) {
return (
<div className="flex-1 space-y-6">
<BackToListButton label="Back to networks list" onClick={backToList} />
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<NetworkIcon className="h-8 w-8" />
{selected.name || selected.id}
</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Network Details</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">ID:</label>
<div className="flex items-center gap-2">
<p className="font-mono text-sm text-blue-600">{selected.id}</p>
<CopyButton text={selected.id} />
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Tenant ID:</label>
<div className="flex items-center gap-2">
<p className="font-mono text-sm">{selected.tenant_id || '-'}</p>
{selected.tenant_id && <CopyButton text={selected.tenant_id} />}
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Region ID:</label>
<p className="font-mono text-sm">{selected.region_id || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">VNI:</label>
<p className="text-sm">{selected.vni ?? '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Encapsulation:</label>
<Badge variant="outline">{selected.encapsulation || 'VXLAN'}</Badge>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">IPv4 CIDR:</label>
<p className="text-sm font-mono bg-muted/50 p-2 rounded">{selected.ipv4_cidr || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">IPv4 Gateway:</label>
<p className="text-sm font-mono">{selected.ipv4_gateway || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">OVS Bridge:</label>
<p className="text-sm font-mono">{selected.ovs_bridge || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Host NAT:</label>
<Badge variant={selected.enable_host_nat ? 'default' : 'outline'}>
{selected.enable_host_nat ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Created:</label>
<p className="text-sm">{formatDate(selected.created_at)}</p>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Raw</CardTitle>
<CardDescription>Everything the API returned for this network</CardDescription>
</CardHeader>
<CardContent>
<pre className="text-xs bg-muted/50 p-4 rounded overflow-y-auto max-h-96 whitespace-pre-wrap break-all">
{JSON.stringify(selected, null, 2)}
</pre>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<NetworkIcon className="h-8 w-8" />
Networks
</h1>
<p className="text-muted-foreground">
Every network across every tenant and region on this underlay
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => fetchData(appliedFilters)} disabled={isLoading} variant="outline">
{isLoading ? 'Loading...' : 'Refresh'}
</Button>
{allowCreate && (
<Button onClick={() => setIsCreateOpen(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" /> Create Network
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Filter by tenant_id..."
value={tenantFilter}
onChange={(e) => setTenantFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
/>
</div>
<Input
className="max-w-xs"
placeholder="Filter by region_id..."
value={regionFilter}
onChange={(e) => setRegionFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
/>
<Button onClick={applyFilters} variant="secondary">Apply</Button>
{hasActiveFilters && (
<Button onClick={clearFilters} variant="ghost" size="sm">
<X className="h-4 w-4 mr-1" /> Clear
</Button>
)}
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>All Networks</CardTitle>
<CardDescription>Click a row to view details</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<p>Loading networks...</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>CIDR</TableHead>
<TableHead>VNI</TableHead>
<TableHead>Tenant ID</TableHead>
<TableHead>Region ID</TableHead>
<TableHead>Host NAT</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{networks.map((net) => (
<TableRow key={net.id} className="cursor-pointer hover:bg-muted/50" onClick={() => navigate(`/admin/networks/${net.id}`)}>
<TableCell className="font-medium">{net.name || net.id}</TableCell>
<TableCell className="font-mono text-sm">{net.ipv4_cidr || '-'}</TableCell>
<TableCell>{net.vni ?? '-'}</TableCell>
<TableCell className="font-mono text-xs">{net.tenant_id || '-'}</TableCell>
<TableCell className="font-mono text-xs">{net.region_id || '-'}</TableCell>
<TableCell>
<Badge variant={net.enable_host_nat ? 'default' : 'outline'}>
{net.enable_host_nat ? 'Enabled' : 'Disabled'}
</Badge>
</TableCell>
<TableCell>{formatDate(net.created_at)}</TableCell>
</TableRow>
))}
{networks.length === 0 && !isLoading && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No networks found{hasActiveFilters && ' matching these filters'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{allowCreate && (
<CreateNetworkDialog
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
defaultTenantId={appliedFilters.tenant_id}
defaultRegionId={appliedFilters.region_id}
onCreated={() => fetchData(appliedFilters)}
/>
)}
</div>
);
}
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { usePlane } from "@/plane";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
@@ -121,6 +122,7 @@ export const PodCard: React.FC<PodCardProps> = ({
}) => {
const { toast } = useToast();
const { networks } = useNetworks();
const { cloudflare } = usePlane();
// DEBUG: Log network_ports data
useEffect(() => {
@@ -630,7 +632,7 @@ export const PodCard: React.FC<PodCardProps> = ({
<p className="mt-0.5">{workloadHosts.find(h => h.id === pod.workload_host_id)?.name || pod.workload_host_id}</p>
</div>
)}
{pod.cloudflare_tunnel && (
{cloudflare && pod.cloudflare_tunnel && (
<div>
<span className="text-muted-foreground">Cloudflare Tunnel:</span>
<p className="font-mono text-xs mt-0.5 break-all">
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { ArrowLeft, FolderOpen, Plus, Trash2, Copy, ExternalLink, Building, Edit } from 'lucide-react';
import { ArrowLeft, FolderOpen, Plus, Trash2, Copy, ExternalLink, Building, Edit, Cloud, ShieldCheck } from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { BackToListButton } from '@/components/ui/back-to-list-button';
@@ -14,6 +14,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { useToast } from '@/hooks/use-toast';
import { xcloudifyApi } from '@/services/xcloudifyApi';
import { CopyButton } from '@/components/ui/copy-button';
import { useCapability } from '@/plane';
interface Project {
@@ -25,6 +26,14 @@ interface Project {
created_at: string;
updated_at?: string;
visible: boolean;
// Per-project Cloudflare account -- see xcloudifyApi's *ProjectCloudflare
// methods. api_token itself is never returned; cloudflare_configured is
// the only signal of whether one is set.
cloudflare_configured?: boolean;
cloudflare_account_id?: string | null;
cloudflare_zone_id?: string | null;
cloudflare_domain?: string | null;
cloudflare_verified_at?: string | null;
}
interface Universe {
@@ -70,10 +79,17 @@ export function ProjectsPage({ projectId: propProjectId, onBack }: ProjectsPageP
description: '',
status: '',
});
const [cloudflareForm, setCloudflareForm] = useState({ api_token: '', account_id: '', zone_id: '' });
const [isSavingCloudflare, setIsSavingCloudflare] = useState(false);
const [isRemovingCloudflare, setIsRemovingCloudflare] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const { projectId: urlProjectId } = useParams();
const projectId = propProjectId || urlProjectId;
// Cloud-only: core has no Project table, let alone a Cloudflare account on
// one. Nothing routes this component to core today, but gate the section
// anyway rather than rely on that never changing -- see plane.tsx.
const cloudflareEnabled = useCapability('cloudflare');
useEffect(() => {
fetchData();
@@ -242,11 +258,59 @@ export function ProjectsPage({ projectId: propProjectId, onBack }: ProjectsPageP
navigate(`/admin/virtual-data-centers/${vdcId}`);
};
// Merge the routes' response into both the selected project and the list
// it came from, rather than refetching -- setProjectCloudflare/delete both
// return the full updated project already.
const applyProjectUpdate = (updated: Project) => {
setProjects(prev => prev.map(p => (p.id === updated.id ? { ...p, ...updated } : p)));
setSelectedProject(prev => (prev ? { ...prev, ...updated } : prev));
};
const handleSaveCloudflare = async () => {
if (!selectedProject) return;
const { api_token, account_id, zone_id } = cloudflareForm;
if (!api_token.trim() || !account_id.trim() || !zone_id.trim()) {
toast({ title: 'Missing fields', description: 'API token, account ID, and zone ID are all required', variant: 'destructive' });
return;
}
setIsSavingCloudflare(true);
try {
const updated = await xcloudifyApi.setProjectCloudflare(selectedProject.id, {
api_token: api_token.trim(),
account_id: account_id.trim(),
zone_id: zone_id.trim(),
});
applyProjectUpdate(updated);
setCloudflareForm({ api_token: '', account_id: '', zone_id: '' });
toast({ title: 'Cloudflare connected', description: updated.cloudflare_domain ? `Verified against ${updated.cloudflare_domain}` : 'Credentials verified and saved' });
} catch (err) {
// The backend's message here is the useful part -- e.g. "Could not
// verify Cloudflare credentials: <what Cloudflare's API said>".
toast({ title: 'Could not connect Cloudflare', description: err instanceof Error ? err.message : 'Failed to save credentials', variant: 'destructive' });
} finally {
setIsSavingCloudflare(false);
}
};
const handleRemoveCloudflare = async () => {
if (!selectedProject) return;
setIsRemovingCloudflare(true);
try {
const updated = await xcloudifyApi.deleteProjectCloudflare(selectedProject.id);
applyProjectUpdate(updated);
toast({ title: 'Cloudflare disconnected', description: 'Containers in this project can no longer get a public Cloudflare-backed port.' });
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to remove Cloudflare credentials', variant: 'destructive' });
} finally {
setIsRemovingCloudflare(false);
}
};
if (selectedProject) {
const universe = universes.find(u => u.id === selectedProject.universe_id);
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<BackToListButton
label="Back to projects list"
@@ -321,6 +385,102 @@ export function ProjectsPage({ projectId: propProjectId, onBack }: ProjectsPageP
</CardContent>
</Card>
{cloudflareEnabled && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cloud className="h-5 w-5" />
Cloudflare
</CardTitle>
<CardDescription>
Public, DNS-backed ports for this project's containers go through its own Cloudflare account -- there is no platform-wide one.
</CardDescription>
</CardHeader>
<CardContent>
{selectedProject.cloudflare_configured ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Badge variant="default" className="gap-1">
<ShieldCheck className="h-3 w-3" /> Connected
</Badge>
{selectedProject.cloudflare_domain && (
<span className="text-sm font-medium">{selectedProject.cloudflare_domain}</span>
)}
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Account ID:</label>
<p className="font-mono text-xs">{selectedProject.cloudflare_account_id}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Zone ID:</label>
<p className="font-mono text-xs">{selectedProject.cloudflare_zone_id}</p>
</div>
{selectedProject.cloudflare_verified_at && (
<div>
<label className="text-sm font-medium text-muted-foreground">Verified:</label>
<p className="text-sm">{formatDate(selectedProject.cloudflare_verified_at)}</p>
</div>
)}
</div>
<Button
variant="destructive"
size="sm"
onClick={handleRemoveCloudflare}
disabled={isRemovingCloudflare}
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
{isRemovingCloudflare ? 'Disconnecting...' : 'Disconnect'}
</Button>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Not connected. Containers created in this project's VDCs won't get a public Cloudflare-backed port until an account is added here.
</p>
<div className="grid md:grid-cols-2 gap-3">
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="cf-token">API Token *</Label>
<Input
id="cf-token"
type="password"
value={cloudflareForm.api_token}
onChange={(e) => setCloudflareForm({ ...cloudflareForm, api_token: e.target.value })}
placeholder="Token with Zone:DNS Edit + Cloudflare Tunnel Edit permissions"
className="font-mono text-xs"
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cf-account">Account ID *</Label>
<Input
id="cf-account"
value={cloudflareForm.account_id}
onChange={(e) => setCloudflareForm({ ...cloudflareForm, account_id: e.target.value })}
className="font-mono text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cf-zone">Zone ID *</Label>
<Input
id="cf-zone"
value={cloudflareForm.zone_id}
onChange={(e) => setCloudflareForm({ ...cloudflareForm, zone_id: e.target.value })}
className="font-mono text-xs"
/>
</div>
</div>
<Button onClick={handleSaveCloudflare} disabled={isSavingCloudflare} className="flex items-center gap-2">
<Cloud className="h-4 w-4" />
{isSavingCloudflare ? 'Verifying...' : 'Connect Cloudflare'}
</Button>
</div>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -444,7 +604,7 @@ export function ProjectsPage({ projectId: propProjectId, onBack }: ProjectsPageP
}
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
@@ -2,7 +2,7 @@ import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { RegionsPage } from './RegionsPage';
export const RegionDetailsWrapper: React.FC = () => {
export const RegionDetailsWrapper: React.FC<{ showVdcs?: boolean }> = ({ showVdcs = true }) => {
const { regionId } = useParams<{ regionId: string }>();
const navigate = useNavigate();
@@ -11,9 +11,10 @@ export const RegionDetailsWrapper: React.FC = () => {
};
return (
<RegionsPage
regionId={regionId}
onBack={handleBack}
<RegionsPage
regionId={regionId}
onBack={handleBack}
showVdcs={showVdcs}
/>
);
};
@@ -60,9 +60,11 @@ interface WorkloadHost {
interface RegionsPageProps {
regionId?: string;
onBack?: () => void;
/** VDCs are a cloud-plane concept; core (and community) have no VDCs to show. */
showVdcs?: boolean;
}
export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps = {}) {
export function RegionsPage({ regionId: propRegionId, onBack, showVdcs = true }: RegionsPageProps = {}) {
const [regions, setRegions] = useState<Region[]>([]);
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
const [regionVDCs, setRegionVDCs] = useState<VirtualDataCenter[]>([]);
@@ -116,7 +118,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
const region = regions.find(r => r.id === regionId);
if (region) {
setSelectedRegion(region);
fetchRegionVDCs(regionId);
if (showVdcs) fetchRegionVDCs(regionId);
fetchRegionWorkloadHosts(regionId);
} else {
// Region not found, navigate back to list
@@ -127,7 +129,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
setRegionVDCs([]);
setRegionWorkloadHosts([]);
}
}, [regionId, regions, navigate]);
}, [regionId, regions, navigate, showVdcs]);
const fetchRegions = async () => {
try {
@@ -389,7 +391,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
};
if (selectedRegion) {
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<BackToListButton
label="Back to regions list"
@@ -539,6 +541,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
</Card>
)}
{showVdcs && (
<Card>
<CardHeader>
<CardTitle>Virtual Data Centers</CardTitle>
@@ -583,6 +586,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
@@ -865,7 +869,8 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
)}
</div>
{/* Scrollable view + filter bar */}
{/* Scrollable view + filter bar -- nothing to filter with zero GPUs */}
{(isLoadingGpus || total > 0) && (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 mt-3 -mx-1 px-1" style={{ scrollbarWidth: 'none' }}>
{views.map(v => {
const count = v.key === 'all' ? regionGpus.length
@@ -917,6 +922,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
);
})}
</div>
)}
</CardHeader>
<CardContent>
@@ -950,7 +956,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
{selectedGpu.assigned_workload_id ? (
<div
className="p-3 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => { navigate(`/infrastructure/virtual-machines/${selectedGpu.assigned_workload_id}/details`); setIsGpuDialogOpen(false); }}
onClick={() => { navigate(`/admin/workloads/virtual-machines/${selectedGpu.assigned_workload_id}`); setIsGpuDialogOpen(false); }}
>
<div className="flex items-center justify-between">
<div>
@@ -1039,7 +1045,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
}
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
@@ -0,0 +1,53 @@
import { ReactNode } from "react";
import { useAuth } from "@/hooks/useAuth";
/**
* Gate for operator-only screens.
*
* This is a usability guard, not the security boundary -- anyone can edit
* client-side state. The real check lives in the cloud plane, which rejects
* unauthorized calls regardless of what the UI renders. This just stops
* non-operators being shown controls that will only fail.
*/
export function RequireAdmin({ children }: { children: ReactNode }) {
const { user, loading, error } = useAuth();
if (loading) {
return (
<div className="flex items-center justify-center p-12 text-muted-foreground">
Checking permissions…
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center gap-2 p-12">
<p className="text-destructive font-medium">Could not verify your session</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
);
}
if (!user) {
return (
<div className="flex flex-col items-center justify-center gap-2 p-12">
<p className="font-medium">You are signed out</p>
<p className="text-sm text-muted-foreground">Sign in to continue.</p>
</div>
);
}
if (!user.is_platform_admin) {
return (
<div className="flex flex-col items-center justify-center gap-2 p-12">
<p className="font-medium">Operator access required</p>
<p className="text-sm text-muted-foreground">
This area manages platform infrastructure. Your account does not have operator access.
</p>
</div>
);
}
return <>{children}</>;
}
@@ -213,8 +213,7 @@ export const StoragePage = () => {
setWorkloadsLoading(true);
try {
const response = await xcloudifyApi.getWorkloadsForVirtualDataCenter(volume.vdc_id);
const workloads = response.data?.workloads || response.data || [];
setAvailableWorkloads(Array.isArray(workloads) ? workloads : []);
setAvailableWorkloads(Array.isArray(response.data) ? response.data : []);
} catch (error: any) {
toast.error("Failed to load workloads");
} finally {
@@ -282,7 +282,7 @@ export function UniversesPage({ universeId: propUniverseId, onBack }: UniversesP
// ─── DETAIL VIEW ───────────────────────────────────────────────────────────
if (selectedUniverse) {
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<BackToListButton
label="Back to universes list"
@@ -740,6 +740,8 @@ export function UniversesPage({ universeId: propUniverseId, onBack }: UniversesP
onOpenChange={open => setDeleteDialog(prev => ({ ...prev, open }))}
onConfirm={handleDeleteUniverseFromListing}
isLoading={deleteDialog.isLoading}
itemName={deleteDialog.universe?.name ?? ""}
itemType="universe"
title="Delete Universe"
description={`Are you sure you want to delete "${deleteDialog.universe?.name}"? This action cannot be undone. All linked projects and VDCs may be affected.`}
/>
@@ -749,7 +751,7 @@ export function UniversesPage({ universeId: propUniverseId, onBack }: UniversesP
// ─── LIST VIEW ────────────────────────────────────────────────────────────
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
@@ -927,6 +929,8 @@ export function UniversesPage({ universeId: propUniverseId, onBack }: UniversesP
onOpenChange={open => setDeleteDialog(prev => ({ ...prev, open }))}
onConfirm={handleDeleteUniverseFromListing}
isLoading={deleteDialog.isLoading}
itemName={deleteDialog.universe?.name ?? ""}
itemType="universe"
title="Delete Universe"
description={`Are you sure you want to delete "${deleteDialog.universe?.name}"? This action cannot be undone. All linked projects and VDCs may be affected.`}
/>
@@ -1,5 +1,17 @@
/**
* The signed-in user, in the header.
*
* Reads the real session from the plane's /auth/me (useAuth) rather than the
* hardcoded "Admin" this showed before the split. In direct-to-core mode
* there is no /auth/me to call and useAuth reports the service-key operator
* instead -- see the note in useAuth.ts.
*
* There is no Log out here yet: the browser holds no credential of its own,
* so there is nothing for this component to clear. It comes back with the
* real session, as a redirect to the identity provider's sign-out.
*/
import { useState } from "react";
import { User, Settings, LogOut, ChevronDown } from "lucide-react";
import { User, Settings, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -8,31 +20,39 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { UserSettingsDialog } from "./UserSettingsDialog";
import { useAuth } from "@/hooks/useAuth";
function initialsOf(name: string): string {
const letters = name
.split(/[\s@._-]+/)
.filter(Boolean)
.map((part) => part[0])
.join("");
return (letters.slice(0, 2) || "?").toUpperCase();
}
export function UserProfile() {
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const userName = "Cory Hawkvelt";
const userInitials = userName.split(' ').map(n => n[0]).join('');
const { user, loading } = useAuth();
const handleLogout = () => {
localStorage.removeItem('xcloudify_auth');
// Dispatch auth state change event
window.dispatchEvent(new CustomEvent('auth-state-changed', {
detail: { authenticated: false }
}));
};
// Until /auth/me answers there is no name to show, and flashing a
// placeholder that then changes reads worse than showing nothing.
const userName = user?.name ?? (loading ? "" : "Signed out");
const userEmail = user?.email ?? "";
const userInitials = initialsOf(userName);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
<Button
variant="ghost"
className="flex items-center gap-3 px-3 py-2 h-auto"
>
<Avatar className="h-8 w-8">
{user?.avatar_url && <AvatarImage src={user.avatar_url} alt={userName} />}
<AvatarFallback className="bg-primary text-primary-foreground text-sm">
{userInitials}
</AvatarFallback>
@@ -41,57 +61,50 @@ export function UserProfile() {
<ChevronDown className="h-4 w-4 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
<DropdownMenuContent
align="end"
className="w-56 bg-background border border-border shadow-lg z-50"
>
<div className="flex items-center gap-2 p-2">
<Avatar className="h-9 w-9">
{user?.avatar_url && <AvatarImage src={user.avatar_url} alt={userName} />}
<AvatarFallback className="bg-primary text-primary-foreground">
{userInitials}
</AvatarFallback>
</Avatar>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{userName}</p>
<p className="text-xs leading-none text-muted-foreground">
cory@hawkvelt.com
</p>
{userEmail && (
<p className="text-xs leading-none text-muted-foreground">
{userEmail}
</p>
)}
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
<DropdownMenuItem
onClick={() => setIsSettingsOpen(true)}
className="cursor-pointer"
>
<Settings className="mr-2 h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
Profile
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<UserSettingsDialog
open={isSettingsOpen}
<UserSettingsDialog
open={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
userName={userName}
/>
</>
);
}
}
@@ -42,6 +42,8 @@ interface Workload {
pod_id?: string;
created_at?: string;
updated_at?: string;
/** VM console link, generated per-request by core (workload_vm_routes.py). Absent for containers. */
vnc_token?: string;
}
interface VirtualMachine {
@@ -350,10 +352,6 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
});
};
const navigateToRegion = (regionId: string) => {
navigate(`/admin/regions/${regionId}`);
};
const navigateToProject = (projectId: string) => {
navigate(`/admin/projects/${projectId}`);
};
@@ -426,7 +424,7 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
if (isLoading) {
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<BackToListButton label="Back to VDCs list" onClick={handleBack} />
</div>
@@ -443,7 +441,7 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
if (error || !vdc) {
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<BackToListButton label="Back to VDCs list" onClick={handleBack} />
</div>
@@ -457,7 +455,7 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
}
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<BackToListButton label="Back to VDCs list" onClick={handleBack} />
</div>
@@ -534,11 +532,6 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
<span className="text-sm">
{region ? `${region.name} (${region.abbreviation})` : vdc.region_id}
</span>
{region && (
<Button size="sm" variant="ghost" onClick={() => navigateToRegion(vdc.region_id)}>
<ExternalLink className="h-3 w-3" />
</Button>
)}
</div>
</div>
@@ -967,7 +960,6 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
<TableHead>North-South IP</TableHead>
<TableHead>East-West IP</TableHead>
<TableHead>Last Heartbeat</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -999,15 +991,6 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
<TableCell className="text-sm">
{host.last_heartbeat_time ? formatDate(host.last_heartbeat_time) : 'Never'}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(`/admin/workload-hosts/${host.id}`)}
>
<ExternalLink className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -432,10 +432,6 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
return project ? project.name : projectId;
};
const navigateToRegion = (regionId: string) => {
navigate(`/admin/regions/${regionId}`);
};
const navigateToProject = (projectId: string) => {
navigate(`/admin/projects/${projectId}`);
};
@@ -445,7 +441,7 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
const project = projects.find(p => p.id === selectedVdc.project_id);
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<BackToListButton
label="Back to VDCs list"
@@ -515,16 +511,6 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
<label className="text-sm font-medium text-muted-foreground">Region:</label>
<div className="flex items-center gap-2">
<p className="text-sm">{region ? `${region.name} (${region.abbreviation})` : selectedVdc.region_id}</p>
{region && (
<Button
size="sm"
variant="ghost"
onClick={() => navigateToRegion(selectedVdc.region_id)}
className="flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
</Button>
)}
</div>
</div>
<div>
@@ -577,17 +563,6 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
<label className="text-sm font-medium text-muted-foreground">Abbreviation:</label>
<p className="text-sm font-mono">{region.abbreviation}</p>
</div>
<div>
<Button
variant="outline"
size="sm"
onClick={() => navigateToRegion(selectedVdc.region_id)}
className="flex items-center gap-2"
>
<ExternalLink className="h-4 w-4" />
View Region Details
</Button>
</div>
</div>
</CardContent>
</Card>
@@ -766,7 +741,6 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
<TableHead>North-South IP</TableHead>
<TableHead>East-West IP</TableHead>
<TableHead>Heartbeat</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -794,16 +768,6 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
? formatDate(host.last_heartbeat_time)
: 'Never'}
</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
onClick={() => navigate(`/admin/workload-hosts/${host.id}`)}
className="flex items-center gap-1"
>
<ExternalLink className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -976,7 +940,7 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
}
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
@@ -41,6 +41,11 @@ import { useNavigate } from 'react-router-dom';
interface VirtualMachineDetailsPageProps {
vmId: string;
onBack: () => void;
/** Where a network port's network link goes. Defaults to the tenant portal's
* infrastructure view; the admin console passes its own underlay route. */
networkDetailsPath?: (networkId: string) => string;
/** Where a volume link goes. Same default/override split as networkDetailsPath. */
volumeDetailsPath?: (volumeId: string) => string;
}
const performanceData = Array.from({ length: 24 }, (_, i) => ({
@@ -51,7 +56,12 @@ const performanceData = Array.from({ length: 24 }, (_, i) => ({
network: Math.random() * 1000,
}));
export const VirtualMachineDetailsPage: React.FC<VirtualMachineDetailsPageProps> = ({ vmId, onBack }) => {
export const VirtualMachineDetailsPage: React.FC<VirtualMachineDetailsPageProps> = ({
vmId,
onBack,
networkDetailsPath = (networkId) => `/infrastructure/networks/${networkId}/details`,
volumeDetailsPath = (volumeId) => `/storage/${volumeId}/details`,
}) => {
const [selectedTimeRange, setSelectedTimeRange] = useState('24h');
const [vmData, setVmData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -630,9 +640,7 @@ export const VirtualMachineDetailsPage: React.FC<VirtualMachineDetailsPageProps>
<Button
variant="link"
className="p-0 h-auto text-xs font-mono text-blue-500 hover:text-blue-600"
onClick={() =>
navigate(`/infrastructure/networks/${port.network_id}/details`)
}
onClick={() => navigate(networkDetailsPath(port.network_id))}
>
{port.network?.name || port.network_id}
<ExternalLink className="h-3 w-3 ml-1" />
@@ -699,7 +707,7 @@ export const VirtualMachineDetailsPage: React.FC<VirtualMachineDetailsPageProps>
<TableCell className="font-medium">
<button
className="hover:underline text-left text-primary"
onClick={() => navigate(`/storage/${vol.id}/details`)}
onClick={() => navigate(volumeDetailsPath(vol.id))}
>
{vol.name}
</button>
@@ -153,7 +153,7 @@ export const VolumeDetailsPage: React.FC<VolumeDetailsPageProps> = ({ volumeId,
try {
const res = await xcloudifyApi.getWorkloadsForVirtualDataCenter(volume.vdc_id);
// API only allows VMs — filter to VirtualMachine type
const all = res.data?.workloads || res.data || [];
const all = res.data || [];
setVdcWorkloads(
(Array.isArray(all) ? all : []).filter((wl: any) => wl.workload_type === "VirtualMachine")
);
@@ -687,7 +687,7 @@ export function WorkloadHostsPage({ hostId, onBack }: WorkloadHostsPageProps = {
const ipAddresses = parseIPAddresses(selectedHost.ip_address_all || '[]');
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center gap-4">
<BackToListButton
label="Back to list view"
@@ -1503,7 +1503,7 @@ export function WorkloadHostsPage({ hostId, onBack }: WorkloadHostsPageProps = {
}
return (
<div className="flex-1 space-y-6 p-6">
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
@@ -0,0 +1,802 @@
import { useState, useEffect, useCallback } from 'react';
import { Boxes, Search, X, Plus, Trash2, Play, Square, RotateCw } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { BackToListButton } from '@/components/ui/back-to-list-button';
import { CopyButton } from '@/components/ui/copy-button';
import { DeleteConfirmationDialog } from '@/components/DeleteConfirmationDialog';
import { VirtualMachineDetailsPage } from '@/components/VirtualMachineDetailsPage';
import { ContainerDetails } from '@/components/ContainerDetails';
import { useToast } from '@/hooks/use-toast';
import { xcloudifyApi } from '@/services/xcloudifyApi';
// Flat shape shared by VM and container workloads (Workload.to_json() on the
// backend). Pods are structurally different -- see PodItem below -- since
// get_pods() returns an aggregate of pod + its containers, not a bare row.
interface WorkloadItem {
id: string;
name: string;
workload_type?: string;
status?: string;
tenant_id?: string;
region_id?: string;
workload_host_id?: string;
created_at?: string;
[key: string]: any;
}
interface PodItem {
pod_id: string;
pod_name: string;
status?: string;
tenant_id?: string;
region_id?: string;
workload_host_id?: string;
created_at?: string;
containers?: any[];
[key: string]: any;
}
type Kind = 'virtual_machines' | 'containers' | 'pods';
type LifecycleAction = 'start' | 'stop' | 'restart';
const KIND_LABEL: Record<Kind, string> = {
virtual_machines: 'Virtual Machines',
containers: 'Containers',
pods: 'Pods',
};
// core's workload-creation path enforces a narrower whitelist than the
// standalone volume API (volume_routes.py's VOLUME_TYPES also allows nfs/
// ceph/iscsi) -- see create_workload_virtual_machine.py's _supported_volume_types.
const BOOT_VOLUME_TYPES = ['local', 'lvm'] as const;
// Pods have no standalone creation route -- they only come into existence as
// a side effect of creating a container, so there is nothing to build a
// "Create Pod" form against. VMs have no lifecycle route on core at all
// (only create/delete), so no start/stop/restart controls for that tab.
const CREATABLE: Record<Kind, boolean> = { virtual_machines: true, containers: true, pods: false };
const HAS_LIFECYCLE: Record<Kind, boolean> = { virtual_machines: false, containers: true, pods: true };
interface CreateVmForm {
region_id: string;
tenant_id: string;
name: string;
vcpu: string;
memory: string;
disk_size_gb: string;
disk_type: string;
image_id: string;
network_id: string;
server_group_id: string;
gpu_model_id: string;
gpu_count: string;
public_key: string;
metadata: string;
}
interface CreateContainerForm {
region_id: string;
tenant_id: string;
container_name: string;
docker_image: string;
cpu: string;
mem_limit: string;
network_id: string;
}
const DEFAULT_VM_FORM: CreateVmForm = {
region_id: '', tenant_id: '', name: '', vcpu: '2', memory: '2048', disk_size_gb: '20', disk_type: 'local', image_id: '',
network_id: '', server_group_id: '', gpu_model_id: '', gpu_count: '1', public_key: '', metadata: '',
};
const DEFAULT_CONTAINER_FORM: CreateContainerForm = {
region_id: '', tenant_id: '', container_name: '', docker_image: '', cpu: '1', mem_limit: '512', network_id: '',
};
function formatDate(dateString?: string) {
if (!dateString) return '-';
try {
return new Date(dateString).toLocaleString();
} catch {
return dateString;
}
}
function statusVariant(status?: string): 'default' | 'secondary' | 'destructive' | 'outline' {
const s = (status || '').toLowerCase();
if (['running', 'active', 'up', 'ready'].includes(s)) return 'default';
if (['error', 'failed', 'stopped'].includes(s)) return 'destructive';
if (['pending', 'provisioning', 'starting'].includes(s)) return 'secondary';
return 'outline';
}
/**
* Underlay-wide workload management for the operator console: every VM,
* container, and pod across every tenant, filterable by tenant_id, with full
* create/delete/lifecycle control. Distinct from the tenant portal's own
* workload views, which scope to the signed-in tenant instead of crossing
* tenants -- this is the operator's own path to the same actions.
*/
export function WorkloadsPage() {
const [kind, setKind] = useState<Kind>('virtual_machines');
const [vms, setVms] = useState<WorkloadItem[]>([]);
const [containers, setContainers] = useState<WorkloadItem[]>([]);
const [pods, setPods] = useState<PodItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tenantFilter, setTenantFilter] = useState('');
const [appliedTenantFilter, setAppliedTenantFilter] = useState('');
const [selected, setSelected] = useState<WorkloadItem | PodItem | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [vmForm, setVmForm] = useState<CreateVmForm>(DEFAULT_VM_FORM);
const [containerForm, setContainerForm] = useState<CreateContainerForm>(DEFAULT_CONTAINER_FORM);
const [isCreating, setIsCreating] = useState(false);
const [regions, setRegions] = useState<Array<{ id: string; name: string }>>([]);
const [images, setImages] = useState<Array<{ id: string; name: string }>>([]);
const [gpuModels, setGpuModels] = useState<Array<{ id: string; vendor_name?: string; device_name?: string; name?: string }>>([]);
const [vmNetworkOptions, setVmNetworkOptions] = useState<Array<{ id: string; name: string }>>([]);
const [containerNetworkOptions, setContainerNetworkOptions] = useState<Array<{ id: string; name: string }>>([]);
const [serverGroupOptions, setServerGroupOptions] = useState<Array<{ id: string; name: string; policy: string }>>([]);
const [actioningId, setActioningId] = useState<string | null>(null);
const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; kind: Kind; id: string; name: string; isLoading: boolean }>({
open: false,
kind: 'virtual_machines',
id: '',
name: '',
isLoading: false,
});
const { toast } = useToast();
const fetchData = useCallback(async (targetKind: Kind, tenantId: string) => {
try {
setIsLoading(true);
setError(null);
const params = tenantId ? { tenant_id: tenantId } : undefined;
if (targetKind === 'virtual_machines') {
setVms(await xcloudifyApi.getAdminVirtualMachines(params));
} else if (targetKind === 'containers') {
setContainers(await xcloudifyApi.getAdminContainers(params));
} else {
setPods(await xcloudifyApi.getAdminPods(params));
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch workloads');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchData(kind, appliedTenantFilter);
}, [kind, appliedTenantFilter, fetchData]);
// Regions, images, and GPU models back the Create dialog's selects --
// fetched once, not per-open, since all three lists are small and change rarely.
useEffect(() => {
xcloudifyApi.getRegions().then(setRegions).catch(() => setRegions([]));
xcloudifyApi.getImages().then(setImages).catch(() => setImages([]));
xcloudifyApi.getAdminGpuModels().then(setGpuModels).catch(() => setGpuModels([]));
}, []);
// Networks and server groups are scoped to region/tenant, so they're
// re-fetched as the operator fills in the create form -- core has no VDC
// to key off, only region_id (networks) and tenant_id (server groups).
useEffect(() => {
if (!vmForm.region_id) { setVmNetworkOptions([]); return; }
xcloudifyApi.getAdminNetworks({ region_id: vmForm.region_id }).then(setVmNetworkOptions).catch(() => setVmNetworkOptions([]));
}, [vmForm.region_id]);
useEffect(() => {
if (!containerForm.region_id) { setContainerNetworkOptions([]); return; }
xcloudifyApi.getAdminNetworks({ region_id: containerForm.region_id }).then(setContainerNetworkOptions).catch(() => setContainerNetworkOptions([]));
}, [containerForm.region_id]);
useEffect(() => {
if (!vmForm.tenant_id) { setServerGroupOptions([]); return; }
xcloudifyApi.getAdminServerGroups({ tenant_id: vmForm.tenant_id }).then(setServerGroupOptions).catch(() => setServerGroupOptions([]));
}, [vmForm.tenant_id]);
const applyFilter = () => setAppliedTenantFilter(tenantFilter.trim());
const clearFilter = () => {
setTenantFilter('');
setAppliedTenantFilter('');
};
const rows: Array<WorkloadItem | PodItem> = kind === 'virtual_machines' ? vms : kind === 'containers' ? containers : pods;
const openCreate = () => {
setVmForm(DEFAULT_VM_FORM);
setContainerForm(DEFAULT_CONTAINER_FORM);
setIsCreateOpen(true);
};
const handleCreate = async () => {
setIsCreating(true);
try {
if (kind === 'virtual_machines') {
const f = vmForm;
if (!f.region_id || !f.tenant_id || !f.name || !f.image_id || !f.network_id) {
toast({ title: 'Missing fields', description: 'Region, tenant, name, boot image, and network are required', variant: 'destructive' });
setIsCreating(false);
return;
}
const publicKeys = f.public_key.split(/\r?\n/).map(k => k.trim()).filter(Boolean);
await xcloudifyApi.createVirtualMachine({
region_id: f.region_id,
tenant_id: f.tenant_id,
...(f.server_group_id && { server_group_id: f.server_group_id }),
'virtual-machines': [{
name: f.name,
vcpu: parseInt(f.vcpu, 10) || 1,
memory: parseInt(f.memory, 10) || 1024,
volumes: [{ name: 'root', size_gb: parseInt(f.disk_size_gb, 10) || 20, type: f.disk_type, image_id: f.image_id, boot: true }],
networks: [{ id: f.network_id }],
...(publicKeys.length > 0 && { public_keys: publicKeys }),
...(f.gpu_model_id && { gpu: [{ id: f.gpu_model_id, count: parseInt(f.gpu_count, 10) || 1 }] }),
...(f.metadata.trim() && { metadata: f.metadata }),
}],
});
} else {
const f = containerForm;
if (!f.region_id || !f.tenant_id || !f.docker_image || !f.container_name.trim()) {
toast({ title: 'Missing fields', description: 'Region, tenant, Docker image, and container name are required', variant: 'destructive' });
setIsCreating(false);
return;
}
await xcloudifyApi.createContainer({
region_id: f.region_id,
tenant_id: f.tenant_id,
containers: [{
docker_image: f.docker_image,
container_name: f.container_name.trim(),
...(f.network_id && { networks: [f.network_id] }),
cpu: parseFloat(f.cpu) || 1,
mem_limit: parseInt(f.mem_limit, 10) || 512,
}],
});
}
toast({ title: 'Queued', description: `${KIND_LABEL[kind]} creation request queued for scheduling` });
setIsCreateOpen(false);
fetchData(kind, appliedTenantFilter);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to queue creation', variant: 'destructive' });
} finally {
setIsCreating(false);
}
};
const askDelete = (id: string, name: string) => {
setDeleteDialog({ open: true, kind, id, name: name || id, isLoading: false });
};
const handleDelete = async () => {
setDeleteDialog((prev) => ({ ...prev, isLoading: true }));
try {
if (deleteDialog.kind === 'virtual_machines') {
await xcloudifyApi.deleteVirtualMachine(deleteDialog.id);
} else if (deleteDialog.kind === 'containers') {
await xcloudifyApi.deleteContainer(deleteDialog.id);
} else {
await xcloudifyApi.deletePod(deleteDialog.id);
}
toast({ title: 'Success', description: `${KIND_LABEL[deleteDialog.kind].slice(0, -1)} deleted successfully` });
setDeleteDialog((prev) => ({ ...prev, open: false, isLoading: false }));
if (selected) setSelected(null);
fetchData(deleteDialog.kind, appliedTenantFilter);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : 'Failed to delete', variant: 'destructive' });
setDeleteDialog((prev) => ({ ...prev, isLoading: false }));
}
};
const handleLifecycle = async (id: string, action: LifecycleAction) => {
setActioningId(`${id}:${action}`);
try {
if (kind === 'containers') {
await xcloudifyApi.containerLifecycleAction(id, action);
} else {
await xcloudifyApi.podLifecycleAction(id, action);
}
toast({ title: 'Queued', description: `${action} operation queued` });
fetchData(kind, appliedTenantFilter);
} catch (err) {
toast({ title: 'Error', description: err instanceof Error ? err.message : `Failed to queue ${action}`, variant: 'destructive' });
} finally {
setActioningId(null);
}
};
const itemTypeFor = (k: Kind): 'pod' | 'container' | 'virtual machine' =>
k === 'virtual_machines' ? 'virtual machine' : k === 'containers' ? 'container' : 'pod';
const LifecycleButtons = ({ id }: { id: string }) => (
<div className="flex gap-1">
{(['start', 'stop', 'restart'] as LifecycleAction[]).map((action) => {
const Icon = action === 'start' ? Play : action === 'stop' ? Square : RotateCw;
return (
<Button
key={action}
size="sm"
variant="ghost"
title={action}
disabled={actioningId === `${id}:${action}`}
onClick={(e) => {
e.stopPropagation();
handleLifecycle(id, action);
}}
>
<Icon className="h-4 w-4" />
</Button>
);
})}
</div>
);
if (selected) {
const isPod = kind === 'pods';
const id = isPod ? (selected as PodItem).pod_id : (selected as WorkloadItem).id;
const name = isPod ? (selected as PodItem).pod_name : (selected as WorkloadItem).name;
// VMs and containers already have full detail views -- console log,
// networking/storage tabs, lifecycle actions -- built for the tenant
// portal. Reuse them here rather than the flat card+raw-JSON fallback
// below, which only pods still need (no dedicated pod details view yet).
const backToList = () => {
setSelected(null);
fetchData(kind, appliedTenantFilter);
};
if (kind === 'virtual_machines') {
return (
<VirtualMachineDetailsPage
vmId={id}
onBack={backToList}
networkDetailsPath={(networkId) => `/admin/networks/${networkId}`}
volumeDetailsPath={(volumeId) => `/admin/volumes/${volumeId}`}
/>
);
}
if (kind === 'containers') {
return <ContainerDetails containerId={id} containerName={name} onBack={backToList} />;
}
return (
<div className="flex-1 space-y-6">
<BackToListButton label="Back to workloads list" onClick={() => setSelected(null)} />
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Boxes className="h-8 w-8" />
{name || id}
</h1>
<div className="flex items-center gap-2">
{HAS_LIFECYCLE[kind] && <LifecycleButtons id={id} />}
<Button variant="destructive" onClick={() => askDelete(id, name)}>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Workload Details</CardTitle>
</CardHeader>
<CardContent>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">ID:</label>
<div className="flex items-center gap-2">
<p className="font-mono text-sm text-blue-600">{id}</p>
<CopyButton text={id} />
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Status:</label>
<div>
<Badge variant={statusVariant(selected.status)}>{selected.status || 'unknown'}</Badge>
</div>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Tenant ID:</label>
<div className="flex items-center gap-2">
<p className="font-mono text-sm">{selected.tenant_id || '-'}</p>
{selected.tenant_id && <CopyButton text={selected.tenant_id} />}
</div>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-muted-foreground">Region ID:</label>
<p className="font-mono text-sm">{selected.region_id || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Host ID:</label>
<p className="font-mono text-sm">{selected.workload_host_id || '-'}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Created:</label>
<p className="text-sm">{formatDate(selected.created_at)}</p>
</div>
</div>
</div>
</CardContent>
</Card>
{isPod && (selected as PodItem).containers && (selected as PodItem).containers!.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Containers in this pod</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Container ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(selected as PodItem).containers!.map((c: any) => (
<TableRow key={c.container_id}>
<TableCell className="font-medium">{c.container_name}</TableCell>
<TableCell>
<Badge variant={statusVariant(c.status)}>{c.status || 'unknown'}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{c.container_id}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Raw</CardTitle>
<CardDescription>Everything the API returned for this workload</CardDescription>
</CardHeader>
<CardContent>
<pre className="text-xs bg-muted/50 p-4 rounded overflow-y-auto max-h-96 whitespace-pre-wrap break-all">
{JSON.stringify(selected, null, 2)}
</pre>
</CardContent>
</Card>
<DeleteConfirmationDialog
open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog((prev) => ({ ...prev, open }))}
onConfirm={handleDelete}
isLoading={deleteDialog.isLoading}
title={`Delete ${itemTypeFor(deleteDialog.kind)}`}
description="This cannot be undone."
itemName={deleteDialog.name}
itemType={itemTypeFor(deleteDialog.kind)}
/>
</div>
);
}
return (
<div className="flex-1 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<Boxes className="h-8 w-8" />
Workloads
</h1>
<p className="text-muted-foreground">
Every virtual machine, container, and pod across every tenant on this underlay
</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={() => fetchData(kind, appliedTenantFilter)} disabled={isLoading} variant="outline">
{isLoading ? 'Loading...' : 'Refresh'}
</Button>
{CREATABLE[kind] && (
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button className="flex items-center gap-2" onClick={openCreate}>
<Plus className="h-4 w-4" /> Create {kind === 'virtual_machines' ? 'VM' : 'Container'}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create {kind === 'virtual_machines' ? 'Virtual Machine' : 'Container'}</DialogTitle>
</DialogHeader>
{kind === 'virtual_machines' ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Region *</Label>
<Select value={vmForm.region_id} onValueChange={(v) => setVmForm({ ...vmForm, region_id: v })}>
<SelectTrigger><SelectValue placeholder="Select region" /></SelectTrigger>
<SelectContent>
{regions.map((r) => <SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="vm-tenant-id">Tenant ID *</Label>
<Input id="vm-tenant-id" value={vmForm.tenant_id} onChange={(e) => setVmForm({ ...vmForm, tenant_id: e.target.value.trim() })} placeholder="tenant UUID" className="font-mono text-xs" />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="vm-name">Name *</Label>
<Input id="vm-name" value={vmForm.name} onChange={(e) => setVmForm({ ...vmForm, name: e.target.value })} placeholder="my-vm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="vm-vcpu">vCPU</Label>
<Input id="vm-vcpu" type="number" min="1" value={vmForm.vcpu} onChange={(e) => setVmForm({ ...vmForm, vcpu: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor="vm-memory">Memory (MB)</Label>
<Input id="vm-memory" type="number" min="256" step="256" value={vmForm.memory} onChange={(e) => setVmForm({ ...vmForm, memory: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="vm-disk">Boot disk (GB)</Label>
<Input id="vm-disk" type="number" min="1" value={vmForm.disk_size_gb} onChange={(e) => setVmForm({ ...vmForm, disk_size_gb: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Storage type</Label>
<Select value={vmForm.disk_type} onValueChange={(v) => setVmForm({ ...vmForm, disk_type: v })}>
<SelectTrigger><SelectValue placeholder="Select storage type" /></SelectTrigger>
<SelectContent>
{BOOT_VOLUME_TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Boot image *</Label>
<Select value={vmForm.image_id} onValueChange={(v) => setVmForm({ ...vmForm, image_id: v })}>
<SelectTrigger><SelectValue placeholder="Select image" /></SelectTrigger>
<SelectContent>
{images.map((img) => <SelectItem key={img.id} value={img.id}>{img.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Network *</Label>
<Select value={vmForm.network_id} onValueChange={(v) => setVmForm({ ...vmForm, network_id: v })} disabled={!vmForm.region_id}>
<SelectTrigger><SelectValue placeholder={vmForm.region_id ? "Select network" : "Select a region first"} /></SelectTrigger>
<SelectContent>
{vmNetworkOptions.map((n) => <SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Server Group <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Select value={vmForm.server_group_id || "none"} onValueChange={(v) => setVmForm({ ...vmForm, server_group_id: v === "none" ? "" : v })} disabled={!vmForm.tenant_id}>
<SelectTrigger><SelectValue placeholder={vmForm.tenant_id ? "No server group" : "Enter a tenant ID first"} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{serverGroupOptions.map((sg) => <SelectItem key={sg.id} value={sg.id}>{sg.name} ({sg.policy})</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>GPU <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Select value={vmForm.gpu_model_id || "none"} onValueChange={(v) => setVmForm({ ...vmForm, gpu_model_id: v === "none" ? "" : v })}>
<SelectTrigger><SelectValue placeholder="No GPU" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{gpuModels.map((m) => <SelectItem key={m.id} value={m.id}>{m.vendor_name ? `${m.vendor_name} ${m.device_name}` : m.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="vm-public-key">SSH Public Key <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Textarea
id="vm-public-key"
value={vmForm.public_key}
onChange={(e) => setVmForm({ ...vmForm, public_key: e.target.value })}
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBc... user@host"
className="font-mono text-xs min-h-[60px]"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="vm-metadata">Custom Metadata <span className="text-muted-foreground text-xs">(optional, cloud-init)</span></Label>
<Textarea
id="vm-metadata"
value={vmForm.metadata}
onChange={(e) => setVmForm({ ...vmForm, metadata: e.target.value })}
placeholder={"apt:\n primary:\n - arches: [default]\n uri: http://us.archive.ubuntu.com/ubuntu/"}
className="font-mono text-xs min-h-[80px]"
/>
</div>
<Button onClick={handleCreate} className="w-full" disabled={isCreating}>
{isCreating ? 'Queuing...' : 'Queue Creation'}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Region *</Label>
<Select value={containerForm.region_id} onValueChange={(v) => setContainerForm({ ...containerForm, region_id: v })}>
<SelectTrigger><SelectValue placeholder="Select region" /></SelectTrigger>
<SelectContent>
{regions.map((r) => <SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ctr-tenant-id">Tenant ID *</Label>
<Input id="ctr-tenant-id" value={containerForm.tenant_id} onChange={(e) => setContainerForm({ ...containerForm, tenant_id: e.target.value.trim() })} placeholder="tenant UUID" className="font-mono text-xs" />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="ctr-image">Docker image *</Label>
<Input id="ctr-image" value={containerForm.docker_image} onChange={(e) => setContainerForm({ ...containerForm, docker_image: e.target.value })} placeholder="nginx:latest" />
</div>
<div className="space-y-1.5">
<Label htmlFor="ctr-name">Container name *</Label>
<Input id="ctr-name" value={containerForm.container_name} onChange={(e) => setContainerForm({ ...containerForm, container_name: e.target.value })} placeholder="my-container" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ctr-cpu">CPU</Label>
<Input id="ctr-cpu" type="number" min="0.1" step="0.1" value={containerForm.cpu} onChange={(e) => setContainerForm({ ...containerForm, cpu: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ctr-mem">Memory limit (MB)</Label>
<Input id="ctr-mem" type="number" min="64" step="64" value={containerForm.mem_limit} onChange={(e) => setContainerForm({ ...containerForm, mem_limit: e.target.value })} />
</div>
</div>
<div className="space-y-1.5">
<Label>Network <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Select value={containerForm.network_id || "none"} onValueChange={(v) => setContainerForm({ ...containerForm, network_id: v === "none" ? "" : v })} disabled={!containerForm.region_id}>
<SelectTrigger><SelectValue placeholder={containerForm.region_id ? "No network" : "Select a region first"} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{containerNetworkOptions.map((n) => <SelectItem key={n.id} value={n.id}>{n.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button onClick={handleCreate} className="w-full" disabled={isCreating}>
{isCreating ? 'Queuing...' : 'Queue Creation'}
</Button>
</div>
)}
</DialogContent>
</Dialog>
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Filter by tenant_id..."
value={tenantFilter}
onChange={(e) => setTenantFilter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilter()}
/>
</div>
<Button onClick={applyFilter} variant="secondary">Apply</Button>
{appliedTenantFilter && (
<Button onClick={clearFilter} variant="ghost" size="sm">
<X className="h-4 w-4 mr-1" /> Clear
</Button>
)}
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<p className="text-destructive">{error}</p>
</CardContent>
</Card>
)}
<Tabs value={kind} onValueChange={(v) => { setKind(v as Kind); setSelected(null); }}>
<TabsList>
<TabsTrigger value="virtual_machines">Virtual Machines</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
<TabsTrigger value="pods">Pods</TabsTrigger>
</TabsList>
<TabsContent value={kind}>
<Card>
<CardHeader>
<CardTitle>{KIND_LABEL[kind]}</CardTitle>
<CardDescription>Click a row to view details{appliedTenantFilter && ` — filtered to tenant_id "${appliedTenantFilter}"`}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center h-32">
<p>Loading...</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Tenant ID</TableHead>
<TableHead>Region ID</TableHead>
<TableHead>Host ID</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const id = kind === 'pods' ? (row as PodItem).pod_id : (row as WorkloadItem).id;
const name = kind === 'pods' ? (row as PodItem).pod_name : (row as WorkloadItem).name;
return (
<TableRow key={id} className="cursor-pointer hover:bg-muted/50" onClick={() => setSelected(row)}>
<TableCell className="font-medium">{name || id}</TableCell>
<TableCell>
<Badge variant={statusVariant(row.status)}>{row.status || 'unknown'}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{row.tenant_id || '-'}</TableCell>
<TableCell className="font-mono text-xs">{row.region_id || '-'}</TableCell>
<TableCell className="font-mono text-xs">{row.workload_host_id || '-'}</TableCell>
<TableCell>{formatDate(row.created_at)}</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1">
{HAS_LIFECYCLE[kind] && <LifecycleButtons id={id} />}
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => askDelete(id, name)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
{rows.length === 0 && !isLoading && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
No {KIND_LABEL[kind].toLowerCase()} found{appliedTenantFilter && ' for this tenant'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<DeleteConfirmationDialog
open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog((prev) => ({ ...prev, open }))}
onConfirm={handleDelete}
isLoading={deleteDialog.isLoading}
title={`Delete ${itemTypeFor(deleteDialog.kind)}`}
description="This cannot be undone."
itemName={deleteDialog.name}
itemType={itemTypeFor(deleteDialog.kind)}
/>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More