Cloudflare Tunnel:
diff --git a/src/components/PortForwardingLinks.tsx b/packages/shared/src/components/PortForwardingLinks.tsx
similarity index 100%
rename from src/components/PortForwardingLinks.tsx
rename to packages/shared/src/components/PortForwardingLinks.tsx
diff --git a/src/components/ProjectDetailsWrapper.tsx b/packages/shared/src/components/ProjectDetailsWrapper.tsx
similarity index 100%
rename from src/components/ProjectDetailsWrapper.tsx
rename to packages/shared/src/components/ProjectDetailsWrapper.tsx
diff --git a/src/components/ProjectsPage.tsx b/packages/shared/src/components/ProjectsPage.tsx
similarity index 73%
rename from src/components/ProjectsPage.tsx
rename to packages/shared/src/components/ProjectsPage.tsx
index 9ec99d4..52d7cdd 100644
--- a/src/components/ProjectsPage.tsx
+++ b/packages/shared/src/components/ProjectsPage.tsx
@@ -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: ".
+ 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 (
-
+
+ {cloudflareEnabled && (
+
+
+
+
+ Cloudflare
+
+
+ Public, DNS-backed ports for this project's containers go through its own Cloudflare account -- there is no platform-wide one.
+
+
+
+ {selectedProject.cloudflare_configured ? (
+
+
+
+ Connected
+
+ {selectedProject.cloudflare_domain && (
+ {selectedProject.cloudflare_domain}
+ )}
+
+
+
+
+
{selectedProject.cloudflare_account_id}
+
+
+
+
{selectedProject.cloudflare_zone_id}
+
+ {selectedProject.cloudflare_verified_at && (
+
+
+
{formatDate(selectedProject.cloudflare_verified_at)}
+
+ )}
+
+
+
+ ) : (
+
+
+ Not connected. Containers created in this project's VDCs won't get a public Cloudflare-backed port until an account is added here.
+
+
+
+
+ )}
+
+
+ )}
+
@@ -444,7 +604,7 @@ export function ProjectsPage({ projectId: propProjectId, onBack }: ProjectsPageP
}
return (
-
+
diff --git a/src/components/RecentActivity.tsx b/packages/shared/src/components/RecentActivity.tsx
similarity index 100%
rename from src/components/RecentActivity.tsx
rename to packages/shared/src/components/RecentActivity.tsx
diff --git a/src/components/ReferralsPage.tsx b/packages/shared/src/components/ReferralsPage.tsx
similarity index 100%
rename from src/components/ReferralsPage.tsx
rename to packages/shared/src/components/ReferralsPage.tsx
diff --git a/src/components/RegionDetailsWrapper.tsx b/packages/shared/src/components/RegionDetailsWrapper.tsx
similarity index 62%
rename from src/components/RegionDetailsWrapper.tsx
rename to packages/shared/src/components/RegionDetailsWrapper.tsx
index c6c36d5..d301db5 100644
--- a/src/components/RegionDetailsWrapper.tsx
+++ b/packages/shared/src/components/RegionDetailsWrapper.tsx
@@ -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 (
-
);
};
diff --git a/src/components/RegionsPage.tsx b/packages/shared/src/components/RegionsPage.tsx
similarity index 98%
rename from src/components/RegionsPage.tsx
rename to packages/shared/src/components/RegionsPage.tsx
index c15d632..95aafc6 100644
--- a/src/components/RegionsPage.tsx
+++ b/packages/shared/src/components/RegionsPage.tsx
@@ -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([]);
const [selectedRegion, setSelectedRegion] = useState(null);
const [regionVDCs, setRegionVDCs] = useState([]);
@@ -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 (
-
+
)}
+ {showVdcs && (
Virtual Data Centers
@@ -583,6 +586,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
)}
+ )}
@@ -865,7 +869,8 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
)}
- {/* Scrollable view + filter bar */}
+ {/* Scrollable view + filter bar -- nothing to filter with zero GPUs */}
+ {(isLoadingGpus || total > 0) && (
{views.map(v => {
const count = v.key === 'all' ? regionGpus.length
@@ -917,6 +922,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
);
})}
+ )}
@@ -950,7 +956,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
{selectedGpu.assigned_workload_id ? (
{ navigate(`/infrastructure/virtual-machines/${selectedGpu.assigned_workload_id}/details`); setIsGpuDialogOpen(false); }}
+ onClick={() => { navigate(`/admin/workloads/virtual-machines/${selectedGpu.assigned_workload_id}`); setIsGpuDialogOpen(false); }}
>
@@ -1039,7 +1045,7 @@ export function RegionsPage({ regionId: propRegionId, onBack }: RegionsPageProps
}
return (
-
+
diff --git a/packages/shared/src/components/RequireAdmin.tsx b/packages/shared/src/components/RequireAdmin.tsx
new file mode 100644
index 0000000..765d578
--- /dev/null
+++ b/packages/shared/src/components/RequireAdmin.tsx
@@ -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 (
+
+ Checking permissions…
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Could not verify your session
+
{error}
+
+ );
+ }
+
+ if (!user) {
+ return (
+
+
You are signed out
+
Sign in to continue.
+
+ );
+ }
+
+ if (!user.is_platform_admin) {
+ return (
+
+
Operator access required
+
+ This area manages platform infrastructure. Your account does not have operator access.
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/src/components/ResourceAllocation.tsx b/packages/shared/src/components/ResourceAllocation.tsx
similarity index 100%
rename from src/components/ResourceAllocation.tsx
rename to packages/shared/src/components/ResourceAllocation.tsx
diff --git a/src/components/ResourceUsageChart.tsx b/packages/shared/src/components/ResourceUsageChart.tsx
similarity index 100%
rename from src/components/ResourceUsageChart.tsx
rename to packages/shared/src/components/ResourceUsageChart.tsx
diff --git a/src/components/SSHKeysPage.tsx b/packages/shared/src/components/SSHKeysPage.tsx
similarity index 100%
rename from src/components/SSHKeysPage.tsx
rename to packages/shared/src/components/SSHKeysPage.tsx
diff --git a/src/components/ServerGroupsPage.tsx b/packages/shared/src/components/ServerGroupsPage.tsx
similarity index 100%
rename from src/components/ServerGroupsPage.tsx
rename to packages/shared/src/components/ServerGroupsPage.tsx
diff --git a/src/components/ServerlessPage.tsx b/packages/shared/src/components/ServerlessPage.tsx
similarity index 100%
rename from src/components/ServerlessPage.tsx
rename to packages/shared/src/components/ServerlessPage.tsx
diff --git a/src/components/StoragePage.tsx b/packages/shared/src/components/StoragePage.tsx
similarity index 99%
rename from src/components/StoragePage.tsx
rename to packages/shared/src/components/StoragePage.tsx
index f338315..68c91d8 100644
--- a/src/components/StoragePage.tsx
+++ b/packages/shared/src/components/StoragePage.tsx
@@ -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 {
diff --git a/src/components/UniversesPage.tsx b/packages/shared/src/components/UniversesPage.tsx
similarity index 99%
rename from src/components/UniversesPage.tsx
rename to packages/shared/src/components/UniversesPage.tsx
index daea934..938efd9 100644
--- a/src/components/UniversesPage.tsx
+++ b/packages/shared/src/components/UniversesPage.tsx
@@ -282,7 +282,7 @@ export function UniversesPage({ universeId: propUniverseId, onBack }: UniversesP
// ─── DETAIL VIEW ───────────────────────────────────────────────────────────
if (selectedUniverse) {
return (
-
+
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 (
-
+
@@ -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.`}
/>
diff --git a/src/components/UserProfile.tsx b/packages/shared/src/components/UserProfile.tsx
similarity index 53%
rename from src/components/UserProfile.tsx
rename to packages/shared/src/components/UserProfile.tsx
index a7956cc..079ba5e 100644
--- a/src/components/UserProfile.tsx
+++ b/packages/shared/src/components/UserProfile.tsx
@@ -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 (
<>
-
-
-
+ {user?.avatar_url && }
{userInitials}
{userName}
-
- cory@hawkvelt.com
-
+ {userEmail && (
+
+ {userEmail}
+
+ )}
-
+
-
- setIsSettingsOpen(true)}
className="cursor-pointer"
>
Settings
-
+
Profile
-
-
-
-
-
- Log out
-
-
>
);
-}
\ No newline at end of file
+}
diff --git a/src/components/UserSettingsDialog.tsx b/packages/shared/src/components/UserSettingsDialog.tsx
similarity index 100%
rename from src/components/UserSettingsDialog.tsx
rename to packages/shared/src/components/UserSettingsDialog.tsx
diff --git a/src/components/VirtualDataCenterDetailsWrapper.tsx b/packages/shared/src/components/VirtualDataCenterDetailsWrapper.tsx
similarity index 97%
rename from src/components/VirtualDataCenterDetailsWrapper.tsx
rename to packages/shared/src/components/VirtualDataCenterDetailsWrapper.tsx
index 87231dd..04d6f2d 100644
--- a/src/components/VirtualDataCenterDetailsWrapper.tsx
+++ b/packages/shared/src/components/VirtualDataCenterDetailsWrapper.tsx
@@ -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 (
-
+
@@ -443,7 +441,7 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
if (error || !vdc) {
return (
-
+
@@ -457,7 +455,7 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
}
return (
-
+
@@ -534,11 +532,6 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
{region ? `${region.name} (${region.abbreviation})` : vdc.region_id}
- {region && (
-
- )}
@@ -967,7 +960,6 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
North-South IP
East-West IP
Last Heartbeat
-
Actions
@@ -999,15 +991,6 @@ export const VirtualDataCenterDetailsWrapper: React.FC = () => {
{host.last_heartbeat_time ? formatDate(host.last_heartbeat_time) : 'Never'}
-
-
-
))}
diff --git a/src/components/VirtualDataCentersPage.tsx b/packages/shared/src/components/VirtualDataCentersPage.tsx
similarity index 96%
rename from src/components/VirtualDataCentersPage.tsx
rename to packages/shared/src/components/VirtualDataCentersPage.tsx
index d6c7908..792ead0 100644
--- a/src/components/VirtualDataCentersPage.tsx
+++ b/packages/shared/src/components/VirtualDataCentersPage.tsx
@@ -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 (
-
+
Region:
{region ? `${region.name} (${region.abbreviation})` : selectedVdc.region_id}
- {region && (
-
- )}
@@ -577,17 +563,6 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
{region.abbreviation}
-
-
-
@@ -766,7 +741,6 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
North-South IP
East-West IP
Heartbeat
-
Actions
@@ -794,16 +768,6 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
? formatDate(host.last_heartbeat_time)
: 'Never'}
-
-
-
))}
@@ -976,7 +940,7 @@ export function VirtualDataCentersPage({ vdcId: propVdcId, onBack }: VirtualData
}
return (
-
+
diff --git a/src/components/VirtualMachineDetailsPage.tsx b/packages/shared/src/components/VirtualMachineDetailsPage.tsx
similarity index 98%
rename from src/components/VirtualMachineDetailsPage.tsx
rename to packages/shared/src/components/VirtualMachineDetailsPage.tsx
index fa0dbbe..8bce1e1 100644
--- a/src/components/VirtualMachineDetailsPage.tsx
+++ b/packages/shared/src/components/VirtualMachineDetailsPage.tsx
@@ -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 = ({ vmId, onBack }) => {
+export const VirtualMachineDetailsPage: React.FC = ({
+ vmId,
+ onBack,
+ networkDetailsPath = (networkId) => `/infrastructure/networks/${networkId}/details`,
+ volumeDetailsPath = (volumeId) => `/storage/${volumeId}/details`,
+}) => {
const [selectedTimeRange, setSelectedTimeRange] = useState('24h');
const [vmData, setVmData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -630,9 +640,7 @@ export const VirtualMachineDetailsPage: React.FC