Chore: Move UI into core/cloud apps & extract shared package
This commit is contained in:
@@ -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
|
||||
@@ -24,3 +24,6 @@ dist-ssr
|
||||
*.sw?
|
||||
|
||||
|
||||
|
||||
.env
|
||||
.env.local
|
||||
|
||||
+15
-19
@@ -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}'
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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": "*" }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../../packages/shared/postcss.config.js";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "@/index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "../../packages/shared/src"]
|
||||
}
|
||||
@@ -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") } },
|
||||
});
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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": "*" }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../../packages/shared/postcss.config.js";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "@/index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src", "../../packages/shared/src"]
|
||||
}
|
||||
@@ -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") } },
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
Generated
+2228
-2711
File diff suppressed because it is too large
Load Diff
+13
-7
@@ -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/*"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
+85
-61
@@ -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:
|
||||
+10
-65
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
+10
@@ -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>
|
||||
+80
-48
@@ -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">
|
||||
+4
-26
@@ -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}>
|
||||
+3
-1
@@ -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">
|
||||
+2
-3
@@ -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, []);
|
||||
}
|
||||
+6
-4
@@ -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">
|
||||
+5
-4
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-22
@@ -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>
|
||||
+2
-38
@@ -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">
|
||||
+13
-5
@@ -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>
|
||||
+1
-1
@@ -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")
|
||||
);
|
||||
+2
-2
@@ -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
Reference in New Issue
Block a user