Refactor: Scope shared screens by tenant instead of VDC

useTenantScope answers "what am I working inside": a VDC on cloud,
a lease on community. The create wizard, volumes, storage and
infrastructure screens use it, so they work on both planes.
This commit is contained in:
2026-08-23 20:33:51 +05:45
parent 2ea9a3bf75
commit 58dc58b9cf
13 changed files with 1161 additions and 455 deletions
@@ -12,6 +12,7 @@ import {
import { toast } from "sonner";
import { xcloudifyApi } from "@/services/xcloudifyApi";
import { usePlane } from "@/plane";
import { useTenantScope } from "@/hooks/useTenantScope";
import { ResourceTypeSelector } from "./ResourceTypeSelector";
import { ResourceConfigurationForm } from "./ResourceConfigurationForm";
import { ResourceReviewStep } from "./ResourceReviewStep";
@@ -43,6 +44,19 @@ const VM_WIZARD_STEPS: VmWizardStepDescriptor[] = [
const getTotalSteps = (resourceType: string) => (resourceType === "virtual-machine" ? 5 : 3);
// Resource types that core stamps a `tenant_id` on, and so need a scope
// resolved before they can be created (a VDC on cloud, a Lease on community
// -- see hooks/useTenantScope). The types absent from this set are the ones
// the wizard only hands back to its caller without calling core itself.
const SCOPED_RESOURCE_TYPES = new Set(["virtual-machine", "container", "network"]);
/** What the API actually said, falling back to a generic line. Keeps core's
* specific validation messages in front of the user instead of "try again". */
const errorMessage = (error: unknown, fallback: string): string => {
const detail = error instanceof Error ? error.message : "";
return detail ? `${fallback} ${detail}` : fallback;
};
export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
isOpen,
onOpenChange,
@@ -54,6 +68,20 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
}) => {
const { zerotier: zerotierSupported, cloudflare: cloudflareSupported, sshKeyVault: sshKeyVaultSupported } = usePlane();
// What a created resource belongs to, whatever this plane calls it: a VDC
// on cloud, a Lease on community. Read from the provider the app already
// mounts rather than fetched here -- this used to call
// getVirtualDataCenters() directly, which is an endpoint community does
// not have, so every create form on that plane sat permanently at "no
// data center selected" and refused to submit. See hooks/useTenantScope.
const {
scopes: virtualDataCenters,
activeScope,
loading: virtualDataCentersLoading,
noun: scopeNoun,
perResourcePicker: showScopePicker,
} = useTenantScope();
const [wizardStep, setWizardStep] = useState(initialStep);
const [resourceType, setResourceType] = useState(initialResourceType);
const [resourceName, setResourceName] = useState("");
@@ -79,11 +107,6 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
const [vmDiskSize, setVmDiskSize] = useState("50"); // Boot disk size in GB
const [vmVolumeType, setVmVolumeType] = useState("local"); // Boot volume type
// Server group selection (optional, shared by VM and container)
const [serverGroupId, setServerGroupId] = useState("");
const [serverGroups, setServerGroups] = useState<any[]>([]);
const [serverGroupsLoading, setServerGroupsLoading] = useState(false);
// Container VDC selection
const [selectedVdc, setSelectedVdc] = useState("");
@@ -92,11 +115,10 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
const [dockerImageSearch, setDockerImageSearch] = useState("");
const [isCreating, setIsCreating] = useState(false);
// Fetch networks, volumes, and virtual data centers
// Networks and volumes for the selected scope (the scope list itself comes
// from useTenantScope above, not from a fetch here).
const [networks, setNetworks] = useState<any[]>([]);
const [networksLoading, setNetworksLoading] = useState(false);
const [virtualDataCenters, setVirtualDataCenters] = useState<any[]>([]);
const [virtualDataCentersLoading, setVirtualDataCentersLoading] = useState(false);
const [volumes, setVolumes] = useState<any[]>([]);
const [volumesLoading, setVolumesLoading] = useState(false);
const [images, setImages] = useState<any[]>([]);
@@ -123,6 +145,15 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
// User-defined custom metadata for VM creation — raw text (e.g. cloud-init cloud-config YAML)
const [customMetadata, setCustomMetadata] = useState("");
// The scope this create belongs to, from whichever of the two pieces of
// state the current flow fills in: the VM flow has its own picker
// (vmVdcId), every other form shares one (selectedVdc). Both are kept in
// step by the defaulting effect below, so the order only matters while a
// user is actively changing the VM picker.
const scopeId = resourceType === "virtual-machine"
? (vmVdcId || selectedVdc)
: (selectedVdc || vmVdcId);
// Random name generator
const generateRandomName = () => {
const adjectives = [
@@ -158,16 +189,10 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
// Random name will be set in the useEffect above when dialog opens
// Only fetch virtual data centers on open - networks and volumes will be fetched when VDC is selected
try {
setVirtualDataCentersLoading(true);
const vdcResponse = await xcloudifyApi.getVirtualDataCenters();
setVirtualDataCenters(vdcResponse);
} catch (error) {
console.error("Error fetching virtual data centers:", error);
} finally {
setVirtualDataCentersLoading(false);
}
// Scopes are not fetched here -- useTenantScope reads whichever
// provider the app mounted (VdcProvider on cloud, LeaseProvider on
// community), which has already loaded them by the time any page can
// open this dialog.
// Fetch available VM images
try {
@@ -188,6 +213,11 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
setSshKeysLoading(true);
const keysResponse = await xcloudifyApi.getSSHKeys();
setSshKeys(keysResponse);
// A key the user marked default in the vault is the one they want on
// every VM, so preselect it and let the common case need no input.
// The picker still shows it and can change or clear it.
const defaultKey = keysResponse.find((k: any) => k?.is_default);
if (defaultKey) setSelectedSshKeyIds([defaultKey.id]);
} catch (error) {
console.error("Error fetching SSH keys:", error);
setSshKeys([]);
@@ -212,21 +242,40 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
fetchData();
}, [isOpen]);
// Default to first VDC when list loads
// Default to the scope the session is already working in, falling back to
// the first available. The active one matters on both planes -- a cloud
// user who switched VDCs in the header expects the wizard to open there,
// and on community it is the *only* one that can be used (the picker is
// hidden; the lease is chosen in the header).
//
// Applied for *every* resource type that carries a scope, not just VM and
// container. Networks need one too -- createNetwork below reads
// `selectedVdc || vmVdcId` for both `tenant_id` and the region -- and when
// this effect skipped them, the id survived only by accident: it was left
// over from the container default set on the first mount, and
// handleDialogOpenChange clears it on close. So opening the network form on
// a freshly-loaded page worked, and opening it after closing the dialog
// once refused to submit with no visible reason. Volumes have their own
// dialog (CreateVolumeDialog) which already defaults this way -- this
// brings the wizard in line with it.
useEffect(() => {
if (!SCOPED_RESOURCE_TYPES.has(resourceType)) return;
if (virtualDataCenters.length === 0) return;
if (!vmVdcId && resourceType === "virtual-machine") {
const vdcId = virtualDataCenters[0].id;
setVmVdcId(vdcId);
handleVdcChange(vdcId);
const preferred = activeScope?.id ?? virtualDataCenters[0].id;
// vmVdcId backs the VM flow's own picker; selectedVdc backs every other
// form's. Both are set so the scope survives a change of resource type
// within one open dialog.
if (!vmVdcId) {
setVmVdcId(preferred);
}
if (!selectedVdc && resourceType === "container") {
const vdcId = virtualDataCenters[0].id;
setSelectedVdc(vdcId);
handleVdcChange(vdcId);
if (!selectedVdc) {
setSelectedVdc(preferred);
}
if (!vmVdcId && !selectedVdc) {
handleVdcChange(preferred);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [virtualDataCenters, resourceType]);
}, [virtualDataCenters, activeScope, resourceType]);
// Default to first network for VMs when list loads
useEffect(() => {
@@ -268,19 +317,6 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
setNetworksLoading(false);
}
// Fetch server groups for the selected VDC
try {
setServerGroupsLoading(true);
const sgData = await xcloudifyApi.getServerGroups(vdcId);
setServerGroups(sgData);
setServerGroupId(""); // Reset selection when VDC changes
} catch (error) {
console.error("Error fetching server groups for VDC:", error);
setServerGroups([]);
} finally {
setServerGroupsLoading(false);
}
// 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).
@@ -348,7 +384,6 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
setVmDiskSize("50");
setVmVolumeType("local");
setSelectedVdc("");
setServerGroupId("");
// Reset SSH key selection
setSelectedSshKeyIds([]);
setRawPublicKeys("");
@@ -363,8 +398,8 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
// Reset fetched data
setNetworks([]);
setVolumes([]);
setVirtualDataCenters([]);
setServerGroups([]);
// Scopes are not reset: they belong to the session, not the dialog
// (useTenantScope reads the app's own provider).
}
};
@@ -377,7 +412,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
return;
}
if (!vmVdcId) {
toast.error("Please select a virtual data center");
toast.error(`Please select a ${scopeNoun.toLowerCase()}`);
return;
}
if (!vmBootImage) {
@@ -416,7 +451,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
}
} else if (wizardStep === 2 && resourceType === "container") {
if (createNewPod && !selectedVdc) {
toast.error("Please select a virtual data center");
toast.error(`Please select a ${scopeNoun.toLowerCase()}`);
return;
}
if (createNewPod && !selectedNetwork) {
@@ -511,13 +546,14 @@ 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).
// Core has no tenancy concept: a new pod needs the scope's region_id,
// and its tenant_id is the scope's own id by convention -- cloud stamps
// tenant_id = vdc.id, community stamps tenant_id = lease.id, and each
// gateway overwrites whatever was sent.
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");
toast.error(`Could not resolve a region for the selected ${scopeNoun.toLowerCase()}`);
return;
}
@@ -527,7 +563,6 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
? { region_id: containerVdc?.region_id || "", tenant_id: containerVdcId }
: { pod: selectedPod }
),
...(serverGroupId && { server_group_id: serverGroupId }),
containers: [{
docker_image: resourceDetails.image || "nginx:latest",
container_name: resourceName || "my-container",
@@ -546,8 +581,11 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
}]
};
await xcloudifyApi.createContainer(containerPayload);
// Through the scope's own gateway, not the flat /workloads/containers
// call -- that one goes to the operator passthrough and never triggers
// Cloudflare tunnel provisioning for a use_dns port.
await xcloudifyApi.createContainerInVdc(containerVdcId, containerPayload);
toast.success("Container created successfully!");
onResourceCreated({ type: "container", name: resourceName });
handleDialogOpenChange(false);
@@ -561,7 +599,7 @@ 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");
toast.error(`Could not resolve a region for the selected ${scopeNoun.toLowerCase()}`);
return;
}
// Parse memory and vCPU from specs selection or use custom values
@@ -632,9 +670,9 @@ 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).
// Core has no tenancy concept: it wants region_id + tenant_id. The
// scope's region supplies the former; its id doubles as tenant_id by
// convention -- a VDC id on cloud, a lease id on community.
const selectedVmVdc = virtualDataCenters.find(v => v.id === vmVdcId);
const vmPayload: any = {
region_id: selectedVmVdc?.region_id || "",
@@ -664,10 +702,6 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
}]
};
if (serverGroupId) {
vmPayload.server_group_id = serverGroupId;
}
await xcloudifyApi.createVirtualMachine(vmPayload);
toast.success("Virtual Machine created successfully!");
@@ -692,23 +726,25 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
const createNetwork = async () => {
try {
const vdcId = selectedVdc || vmVdcId;
const vdcId = scopeId;
if (!vdcId) {
toast.error("Please select a virtual data center");
toast.error(`Please select a ${scopeNoun.toLowerCase()}`);
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).
// Core has no tenancy concept: it wants region_id + tenant_id. The
// scope's region supplies the former; its id doubles as tenant_id by
// convention -- a VDC id on cloud, a lease id on community. Both
// gateways overwrite the pair from the scope in the URL anyway.
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");
toast.error(`Could not resolve a region for the selected ${scopeNoun.toLowerCase()}`);
return;
}
const payload: Record<string, any> = {
name: resourceName,
region_id: networkVdc.region_id,
tenant_id: vdcId,
enable_host_nat: !!resourceDetails.enable_host_nat,
};
if (resourceDetails.ipv4_cidr) payload.ipv4_cidr = resourceDetails.ipv4_cidr;
if (resourceDetails.ipv4_gateway) payload.ipv4_gateway = resourceDetails.ipv4_gateway;
@@ -728,7 +764,11 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
handleDialogOpenChange(false);
} catch (error) {
console.error("Error creating network:", error);
toast.error("Failed to create network. Please try again.");
// The real message, not a generic one: core's rejections are specific
// and actionable ("ipv4_gateway is not inside ipv4_cidr"), and
// replacing them with "please try again" costs the user the only
// information that would let them fix the form.
toast.error(errorMessage(error, "Failed to create network."));
throw error;
}
};
@@ -827,8 +867,8 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
availableNetworks={networks}
availableImages={images}
availableVirtualDataCenters={virtualDataCenters}
serverGroupId={serverGroupId}
serverGroups={serverGroups}
scopeNoun={scopeNoun}
scopeId={scopeId}
gpuModels={gpuModels}
selectedGpuModelId={selectedGpuModelId}
gpuCount={gpuCount}
@@ -866,6 +906,8 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
onVdcChange={handleVdcChange}
availableVirtualDataCenters={virtualDataCenters}
virtualDataCentersLoading={virtualDataCentersLoading}
scopeNoun={scopeNoun}
showScopePicker={showScopePicker}
vmBootImage={vmBootImage}
setVmBootImage={setVmBootImage}
availableImages={images}
@@ -897,6 +939,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
return (
<VmNetworkAccessStep
vmVdcId={vmVdcId}
scopeNoun={scopeNoun}
vmNetworks={vmNetworks}
setVmNetworks={setVmNetworks}
availableNetworks={networks}
@@ -908,17 +951,9 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
sshKeys={sshKeys}
sshKeysLoading={sshKeysLoading}
selectedSshKeyIds={selectedSshKeyIds}
onToggleSshKey={(keyId, checked) => {
setSelectedSshKeyIds(prev =>
checked ? [...prev, keyId] : prev.filter(id => id !== keyId)
);
}}
onSelectSshKey={(keyId) => setSelectedSshKeyIds(keyId ? [keyId] : [])}
rawPublicKeys={rawPublicKeys}
setRawPublicKeys={setRawPublicKeys}
serverGroupId={serverGroupId}
setServerGroupId={setServerGroupId}
serverGroups={serverGroups}
serverGroupsLoading={serverGroupsLoading}
customMetadata={customMetadata}
setCustomMetadata={setCustomMetadata}
/>
@@ -973,14 +1008,11 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
networksLoading={networksLoading}
availableVirtualDataCenters={virtualDataCenters}
virtualDataCentersLoading={virtualDataCentersLoading}
scopeNoun={scopeNoun}
showScopePicker={showScopePicker}
selectedVdc={selectedVdc}
setSelectedVdc={setSelectedVdc}
onVdcChange={handleVdcChange}
// Server group props
serverGroupId={serverGroupId}
setServerGroupId={setServerGroupId}
serverGroups={serverGroups}
serverGroupsLoading={serverGroupsLoading}
/>
);
case 3:
@@ -1009,6 +1041,39 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
return false;
};
/**
* Why the final create cannot go through, or null.
*
* Shown next to the button and used to disable it, rather than being left
* to a toast fired from inside the create handler. A create that refuses
* itself and reports it only through a toast is indistinguishable from a
* dead button the moment that toast does not render -- which is exactly how
* this presented on the community portal, where no sonner Toaster was
* mounted at all (see CommunityPortalChrome). The reason belongs on screen,
* beside the control it blocks.
*/
const createBlockedReason = (): string | null => {
if (!SCOPED_RESOURCE_TYPES.has(resourceType)) return null;
const noun = scopeNoun.toLowerCase();
if (!scopeId) {
return virtualDataCentersLoading
? `Loading your ${noun}s…`
: `No ${noun} is selected, so there is nothing to create this in.`;
}
const scope = virtualDataCenters.find((v) => v.id === scopeId);
if (scope?.usable === false) {
return scope.unusableReason ?? `This ${noun} cannot accept new resources right now.`;
}
// Networks are the one type this wizard sends a region for explicitly
// (workloads get theirs stamped by the gateway), so an unresolved region
// fails the create rather than being merely cosmetic.
if (resourceType === "network" && !scope?.region_id) {
return `Could not resolve a region for the selected ${noun}.`;
}
return null;
};
const blockedReason = createBlockedReason();
return (
<>
<Dialog open={isOpen} onOpenChange={handleDialogOpenChange}>
@@ -1048,7 +1113,10 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
</Button>
)}
</div>
<div>
<div className="flex items-center gap-3">
{wizardStep === totalSteps && blockedReason && (
<span className="text-sm text-destructive">{blockedReason}</span>
)}
{wizardStep < totalSteps ? (
<Button
type="button"
@@ -1061,7 +1129,7 @@ export const CreateResourceWizard: React.FC<CreateResourceWizardProps> = ({
<Button
type="button"
onClick={handleCreateResource}
disabled={isCreating}
disabled={isCreating || Boolean(blockedReason)}
className="bg-blue-600 hover:bg-blue-700"
>
{isCreating ? (
@@ -5,10 +5,12 @@ 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 { Switch } from "@/components/ui/switch";
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";
// Scopes, not VDCs: on community these are Leases. See hooks/useTenantScope.
import type { TenantScope } from "@/hooks/useTenantScope";
interface ResourceConfigurationFormProps {
resourceType: string;
@@ -48,17 +50,17 @@ interface ResourceConfigurationFormProps {
onOpenDockerImageSearch: () => void;
availableNetworks?: Network[];
networksLoading?: boolean;
availableVirtualDataCenters?: VirtualDataCenter[];
availableVirtualDataCenters?: TenantScope[];
virtualDataCentersLoading?: boolean;
/** What this plane calls the thing a resource belongs to. See useTenantScope. */
scopeNoun?: string;
/** False when the scope is fixed for the session (community's lease is
* chosen in the header), so a picker here would show one option. */
showScopePicker?: boolean;
// Container-specific VDC selection
selectedVdc?: string;
setSelectedVdc?: (vdcId: string) => void;
onVdcChange?: (vdcId: string) => void;
// Server group selection (optional)
serverGroupId?: string;
setServerGroupId?: (id: string) => void;
serverGroups?: Array<{ id: string; name: string; policy: string }>;
serverGroupsLoading?: boolean;
}
export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps> = ({
@@ -101,15 +103,12 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
networksLoading = false,
availableVirtualDataCenters = [],
virtualDataCentersLoading = false,
scopeNoun = "Virtual Data Center",
showScopePicker = true,
// Container-specific VDC selection
selectedVdc = "",
setSelectedVdc = () => {},
onVdcChange = () => {},
// Server group selection (optional)
serverGroupId = "",
setServerGroupId = () => {},
serverGroups = [],
serverGroupsLoading = false,
}) => {
const { cloudflare } = usePlane();
@@ -128,11 +127,12 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
{resourceType === "container" && (
<>
{/* Virtual Data Center Selection - Only show when creating new pod */}
{createNewPod && (
<>
{/* Which scope the new pod belongs to. Only asked when creating one,
and only on a plane where there is a choice to make -- community
fixes it to the session's lease (see useTenantScope). */}
{createNewPod && showScopePicker && (
<div>
<Label htmlFor="container-vdc">Virtual Data Center *</Label>
<Label htmlFor="container-vdc">{scopeNoun} *</Label>
<Select
value={selectedVdc}
onValueChange={(value) => {
@@ -143,46 +143,36 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
required
>
<SelectTrigger className="w-full">
<SelectValue placeholder={virtualDataCentersLoading ? "Loading virtual data centers..." : "Select virtual data center"} />
<SelectValue placeholder={
virtualDataCentersLoading
? `Loading ${scopeNoun.toLowerCase()}s...`
: `Select ${scopeNoun.toLowerCase()}`
} />
</SelectTrigger>
<SelectContent className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50">
{availableVirtualDataCenters.map((vdc) => (
<SelectItem key={vdc.id} value={vdc.id}>
<SelectItem key={vdc.id} value={vdc.id} disabled={vdc.usable === false}>
{vdc.name}
{vdc.description && (
<span className="text-gray-500 ml-2">({vdc.description})</span>
)}
{vdc.usable === false && vdc.unusableReason && (
<span className="text-gray-500 ml-2">— {vdc.unusableReason}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Server Group (optional) - container */}
{selectedVdc && (
<div>
<Label htmlFor="container-server-group">Server Group <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Select
value={serverGroupId || "none"}
onValueChange={(value) => setServerGroupId(value === "none" ? "" : value)}
disabled={serverGroupsLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={serverGroupsLoading ? "Loading…" : "No server group"} />
</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>
{serverGroups.map((sg) => (
<SelectItem key={sg.id} value={sg.id}>
{sg.name}
<span className="text-gray-500 ml-2">({sg.policy})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</>
)}
{createNewPod && !showScopePicker && (
<div>
<Label>{scopeNoun}</Label>
<p className="text-sm text-muted-foreground">
{availableVirtualDataCenters.find((v) => v.id === selectedVdc)?.name
?? `No ${scopeNoun.toLowerCase()} selected`}
</p>
</div>
)}
{/* Pod Selection */}
@@ -272,7 +262,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
>
<SelectTrigger className="w-full">
<SelectValue placeholder={
!selectedVdc ? "Select a virtual data center first" :
!selectedVdc ? `Select a ${scopeNoun.toLowerCase()} first` :
networksLoading ? "Loading networks..." :
"Select network"
} />
@@ -419,6 +409,15 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
</Button>
</div>
))}
{/* The tunnel is provisioned from the project's own Cloudflare
account -- there is no platform-wide one -- so a use_dns
port on a project that has none stays private. */}
{cloudflare && portForwarding.some((port) => port.useDns) && (
<p className="text-xs text-muted-foreground">
A "Use DNS" port is published through your project's own Cloudflare account.
Connect one under Data Centers first, or the port stays private.
</p>
)}
</div>
)}
</div>
@@ -486,7 +485,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
))
) : (
<SelectItem value="no-volumes" disabled>
{selectedVdc ? 'No volumes available in this virtual data center' : 'Select a virtual data center first'}
{selectedVdc ? `No volumes available in this ${scopeNoun.toLowerCase()}` : `Select a ${scopeNoun.toLowerCase()} first`}
</SelectItem>
);
})()}
@@ -663,29 +662,44 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
{resourceType === "network" && (
<>
<div>
<Label htmlFor="network-vdc">Virtual Data Center *</Label>
<Select
value={selectedVdc}
onValueChange={(value) => {
setSelectedVdc(value);
onVdcChange(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={virtualDataCentersLoading ? "Loading..." : "Select a VDC"} />
</SelectTrigger>
<SelectContent>
{availableVirtualDataCenters.map((vdc) => (
<SelectItem key={vdc.id} value={vdc.id}>{vdc.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{showScopePicker ? (
<div>
<Label htmlFor="network-vdc">{scopeNoun} *</Label>
<Select
value={selectedVdc}
onValueChange={(value) => {
setSelectedVdc(value);
onVdcChange(value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={
virtualDataCentersLoading ? "Loading..." : `Select a ${scopeNoun.toLowerCase()}`
} />
</SelectTrigger>
<SelectContent>
{availableVirtualDataCenters.map((vdc) => (
<SelectItem key={vdc.id} value={vdc.id} disabled={vdc.usable === false}>
{vdc.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label>{scopeNoun}</Label>
<p className="text-sm text-muted-foreground">
{availableVirtualDataCenters.find((v) => v.id === selectedVdc)?.name
?? `No ${scopeNoun.toLowerCase()} selected`}
</p>
</div>
)}
<div>
<Label htmlFor="network-type">Network Type</Label>
<Input
id="network-type"
value={resourceDetails.network_type ?? ""}
onChange={(e) => setResourceDetails({...resourceDetails, network_type: e.target.value})}
placeholder="e.g. overlay, flat"
/>
@@ -693,6 +707,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
<div>
<Label htmlFor="encapsulation">Encapsulation</Label>
<Select
value={resourceDetails.encapsulation ?? ""}
onValueChange={(value) => setResourceDetails({...resourceDetails, encapsulation: value})}
>
<SelectTrigger className="w-full">
@@ -710,6 +725,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
<Label htmlFor="ovs-bridge">OVS Bridge</Label>
<Input
id="ovs-bridge"
value={resourceDetails.ovs_bridge ?? ""}
onChange={(e) => setResourceDetails({...resourceDetails, ovs_bridge: e.target.value})}
placeholder="e.g. br-int"
/>
@@ -718,6 +734,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
<Label htmlFor="ipv4-cidr">IPv4 CIDR</Label>
<Input
id="ipv4-cidr"
value={resourceDetails.ipv4_cidr ?? ""}
onChange={(e) => setResourceDetails({...resourceDetails, ipv4_cidr: e.target.value})}
placeholder="e.g. 10.0.0.0/24"
/>
@@ -726,6 +743,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
<Label htmlFor="ipv4-gateway">IPv4 Gateway</Label>
<Input
id="ipv4-gateway"
value={resourceDetails.ipv4_gateway ?? ""}
onChange={(e) => setResourceDetails({...resourceDetails, ipv4_gateway: e.target.value})}
placeholder="e.g. 10.0.0.1"
/>
@@ -734,6 +752,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
<Label htmlFor="dhcp-range-start">DHCP Range Start</Label>
<Input
id="dhcp-range-start"
value={resourceDetails.dhcp_range_start ?? ""}
onChange={(e) => setResourceDetails({...resourceDetails, dhcp_range_start: e.target.value})}
placeholder="e.g. 10.0.0.100"
/>
@@ -742,6 +761,7 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
<Label htmlFor="dhcp-range-end">DHCP Range End</Label>
<Input
id="dhcp-range-end"
value={resourceDetails.dhcp_range_end ?? ""}
onChange={(e) => setResourceDetails({...resourceDetails, dhcp_range_end: e.target.value})}
placeholder="e.g. 10.0.0.200"
/>
@@ -750,10 +770,22 @@ export const ResourceConfigurationForm: React.FC<ResourceConfigurationFormProps>
<Label htmlFor="nscontroller-ip">NSController IP</Label>
<Input
id="nscontroller-ip"
value={resourceDetails.nscontroller_ip ?? ""}
onChange={(e) => setResourceDetails({...resourceDetails, nscontroller_ip: e.target.value})}
placeholder="e.g. 10.0.0.2 (ns-controller's IP on this network)"
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<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={!!resourceDetails.enable_host_nat}
onCheckedChange={(checked) => setResourceDetails({...resourceDetails, enable_host_nat: checked})}
/>
</div>
</>
)}
</div>
@@ -1,6 +1,8 @@
import React from "react";
import type { EnvironmentVariable, PortForwarding, ResourceDetails } from "./types";
import type { Network } from "@/hooks/useNetworks";
// Scopes, not VDCs: on community these are Leases. See hooks/useTenantScope.
import type { TenantScope } from "@/hooks/useTenantScope";
interface ResourceReviewStepProps {
resourceType: string;
@@ -24,9 +26,14 @@ interface ResourceReviewStepProps {
vmVolumeType?: string;
availableNetworks?: Network[];
availableImages?: any[];
availableVirtualDataCenters?: any[];
serverGroupId?: string;
serverGroups?: any[];
availableVirtualDataCenters?: TenantScope[];
/** What this plane calls the scope. See useTenantScope. */
scopeNoun?: string;
/** The scope this resource will be created in. Named on the review for the
* types whose form does not show a picker (community hides it -- the lease
* is chosen once in the shell header), so the last screen before a create
* still says what it is being created in. */
scopeId?: string;
gpuModels?: Array<{ id: string; vendor_name: string; device_name: string }>;
selectedGpuModelId?: string;
gpuCount?: string;
@@ -38,6 +45,36 @@ interface ResourceReviewStepProps {
customMetadata?: string;
}
/** Resource types that render their own formatted section above, and so must
* not also be dumped raw by the catch-all at the end. */
const TYPED_REVIEW_SECTIONS = new Set(["network", "container", "virtual-machine"]);
/** The network form's fields, with the labels the form itself uses, in the
* order it asks for them. `enable_host_nat` is deliberately absent -- it is
* a boolean and gets its own Enabled/Disabled line. */
const NETWORK_FIELDS: Array<[string, string]> = [
["network_type", "Network Type"],
["encapsulation", "Encapsulation"],
["ovs_bridge", "OVS Bridge"],
["ipv4_cidr", "IPv4 CIDR"],
["ipv4_gateway", "IPv4 Gateway"],
["ipv4_dns_servers", "IPv4 DNS Servers"],
["dhcp_range_start", "DHCP Range Start"],
["dhcp_range_end", "DHCP Range End"],
["nscontroller_ip", "NSController IP"],
["ipv6_cidr", "IPv6 CIDR"],
["ipv6_gateway", "IPv6 Gateway"],
];
/** "dhcp_range_start" / "customCpu" -> "Dhcp range start" / "Custom Cpu". */
const humanizeKey = (key: string): string =>
key
.replace(/_/g, " ")
.replace(/([A-Z])/g, " $1")
.replace(/\s+/g, " ")
.trim()
.replace(/^./, (c) => c.toUpperCase());
const VOLUME_TYPE_LABELS: Record<string, string> = {
local: "Local",
lvm: "LVM",
@@ -67,8 +104,8 @@ export const ResourceReviewStep: React.FC<ResourceReviewStepProps> = ({
availableNetworks = [],
availableImages = [],
availableVirtualDataCenters = [],
serverGroupId = "",
serverGroups = [],
scopeNoun = "Virtual Data Center",
scopeId = "",
gpuModels = [],
selectedGpuModelId = "",
gpuCount = "1",
@@ -90,11 +127,6 @@ export const ResourceReviewStep: React.FC<ResourceReviewStepProps> = ({
if (!img) return imageId;
return img.name + (img.os_version ? ` (${img.os_version})` : "");
};
// Helper to get server group name by ID
const getServerGroupName = (sgId: string) => {
const sg = serverGroups.find(s => s.id === sgId);
return sg ? `${sg.name} (${sg.policy})` : sgId;
};
// Helper to get VDC name by ID
const getVdcName = (vdcId: string) => {
const vdc = availableVirtualDataCenters.find(v => v.id === vdcId);
@@ -110,6 +142,9 @@ export const ResourceReviewStep: React.FC<ResourceReviewStepProps> = ({
const key = sshKeys.find(k => k.id === keyId);
return key ? key.key_name : keyId;
};
const scopeName = scopeId
? availableVirtualDataCenters.find(v => v.id === scopeId)?.name ?? null
: null;
return (
<div className="space-y-4">
<div className="border border-border rounded-lg p-4 bg-muted/50">
@@ -124,6 +159,40 @@ export const ResourceReviewStep: React.FC<ResourceReviewStepProps> = ({
<span className="text-sm font-medium">{resourceName}</span>
</div>
{/* Network-specific details.
Every field the network form collects is named here, in the
order the form asks for them. It used to name three and let the
catch-all below print the rest, which meant the review repeated
"IPv4 CIDR" as a raw "Ipv4_cidr", and printed "Enable_host_nat:"
with nothing after it -- React renders a boolean as nothing, so
the one field whose whole value is a boolean showed up blank
directly under the line that had already said "Host NAT:
Enabled". */}
{resourceType === "network" && (
<>
{NETWORK_FIELDS.map(([key, label]) => {
const value = resourceDetails[key as keyof ResourceDetails];
if (value === undefined || value === null || value === "") return null;
return (
<div key={key} className="flex justify-between gap-4">
<span className="text-sm text-muted-foreground">{label}:</span>
<span className="text-sm font-medium text-right">{String(value)}</span>
</div>
);
})}
<div className="flex justify-between gap-4">
<span className="text-sm text-muted-foreground">Host NAT:</span>
<span className="text-sm font-medium">{resourceDetails.enable_host_nat ? "Enabled" : "Disabled"}</span>
</div>
{scopeName && (
<div className="flex justify-between gap-4">
<span className="text-sm text-muted-foreground">{scopeNoun}:</span>
<span className="text-sm font-medium text-right">{scopeName}</span>
</div>
)}
</>
)}
{/* Container-specific details */}
{resourceType === "container" && (
<>
@@ -197,7 +266,7 @@ export const ResourceReviewStep: React.FC<ResourceReviewStepProps> = ({
)}
{vmVdcId && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Virtual Data Center:</span>
<span className="text-sm text-muted-foreground">{scopeNoun}:</span>
<span className="text-sm font-medium">{getVdcName(vmVdcId)}</span>
</div>
)}
@@ -231,12 +300,6 @@ export const ResourceReviewStep: React.FC<ResourceReviewStepProps> = ({
</span>
</div>
)}
{serverGroupId && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Server Group:</span>
<span className="text-sm font-medium">{getServerGroupName(serverGroupId)}</span>
</div>
)}
{selectedSshKeyIds.length > 0 && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">SSH Key:</span>
@@ -276,15 +339,24 @@ export const ResourceReviewStep: React.FC<ResourceReviewStepProps> = ({
</>
)}
{/* Other resource type details */}
{Object.entries(resourceDetails).map(([key, value]) =>
key !== "image" && key !== "command" ? (
<div key={key} className="flex justify-between">
<span className="text-sm text-muted-foreground">{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}:</span>
<span className="text-sm font-medium">{value as string}</span>
</div>
) : null
)}
{/* Resource types with no section of their own above still get
their collected fields listed, rather than a review that shows
only a name. Gated on the type: when this ran unconditionally it
printed a second, raw copy of everything the typed sections had
already formatted. */}
{!TYPED_REVIEW_SECTIONS.has(resourceType) &&
Object.entries(resourceDetails).map(([key, value]) => {
if (key === "image" || key === "command") return null;
if (value === undefined || value === null || value === "") return null;
return (
<div key={key} className="flex justify-between gap-4">
<span className="text-sm text-muted-foreground">{humanizeKey(key)}:</span>
<span className="text-sm font-medium text-right">
{typeof value === "boolean" ? (value ? "Yes" : "No") : String(value)}
</span>
</div>
);
})}
</div>
</div>
</div>
@@ -4,7 +4,8 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tag, Building2, MonitorCog } from "lucide-react";
import type { Network } from "@/hooks/useNetworks";
import type { VirtualDataCenter } from "@/hooks/useVirtualDataCenters";
// Scopes, not VDCs: on community these are Leases. See hooks/useTenantScope.
import type { TenantScope } from "@/hooks/useTenantScope";
interface VmBasicsStepProps {
resourceName: string;
@@ -12,8 +13,15 @@ interface VmBasicsStepProps {
vmVdcId: string;
setVmVdcId: (vdcId: string) => void;
onVdcChange: (vdcId: string) => void;
availableVirtualDataCenters: VirtualDataCenter[];
availableVirtualDataCenters: TenantScope[];
virtualDataCentersLoading: boolean;
/** What this plane calls the thing a VM belongs to -- "Virtual Data Center"
* on cloud, "Lease" on community. See hooks/useTenantScope. */
scopeNoun?: string;
/** Whether the user picks one here. False on a plane where the scope is
* chosen once for the session in the header (community), so the control
* would only ever offer the one already in effect. */
showScopePicker?: boolean;
vmBootImage: string;
setVmBootImage: (imageId: string) => void;
availableImages: any[];
@@ -28,6 +36,8 @@ export const VmBasicsStep: React.FC<VmBasicsStepProps> = ({
onVdcChange,
availableVirtualDataCenters,
virtualDataCentersLoading,
scopeNoun = "Virtual Data Center",
showScopePicker = true,
vmBootImage,
setVmBootImage,
availableImages,
@@ -55,35 +65,58 @@ export const VmBasicsStep: React.FC<VmBasicsStepProps> = ({
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
<Label htmlFor="vm-vdc">Virtual Data Center *</Label>
{showScopePicker ? (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
<Label htmlFor="vm-vdc">{scopeNoun} *</Label>
</div>
<Select
value={vmVdcId}
onValueChange={(value) => {
setVmVdcId(value);
onVdcChange(value);
}}
disabled={virtualDataCentersLoading}
required
>
<SelectTrigger className="w-full" id="vm-vdc">
<SelectValue placeholder={
virtualDataCentersLoading
? `Loading ${scopeNoun.toLowerCase()}s...`
: `Select ${scopeNoun.toLowerCase()}`
} />
</SelectTrigger>
<SelectContent className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50">
{availableVirtualDataCenters.map((vdc) => (
<SelectItem key={vdc.id} value={vdc.id} disabled={vdc.usable === false}>
{vdc.name}
{vdc.description && (
<span className="text-gray-500 ml-2">({vdc.description})</span>
)}
{vdc.usable === false && vdc.unusableReason && (
<span className="text-gray-500 ml-2">— {vdc.unusableReason}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select
value={vmVdcId}
onValueChange={(value) => {
setVmVdcId(value);
onVdcChange(value);
}}
disabled={virtualDataCentersLoading}
required
>
<SelectTrigger className="w-full" id="vm-vdc">
<SelectValue placeholder={virtualDataCentersLoading ? "Loading virtual data centers..." : "Select virtual data center"} />
</SelectTrigger>
<SelectContent className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg z-50">
{availableVirtualDataCenters.map((vdc) => (
<SelectItem key={vdc.id} value={vdc.id}>
{vdc.name}
{vdc.description && (
<span className="text-gray-500 ml-2">({vdc.description})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
// The scope is fixed for the session, so state it rather than offering
// a one-option control -- the user still needs to know which machine
// this VM is about to land on.
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
<Label>{scopeNoun}</Label>
</div>
<p className="text-sm text-muted-foreground">
{availableVirtualDataCenters.find((v) => v.id === vmVdcId)?.name
?? `No ${scopeNoun.toLowerCase()} selected`}
</p>
</div>
)}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
@@ -98,7 +131,7 @@ export const VmBasicsStep: React.FC<VmBasicsStepProps> = ({
>
<SelectTrigger className="w-full" id="vm-boot-image">
<SelectValue placeholder={
!vmVdcId ? "Select a virtual data center first" :
!vmVdcId ? `Select a ${scopeNoun.toLowerCase()} first` :
imagesLoading ? "Loading images..." :
"Select OS image"
} />
@@ -6,12 +6,14 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Network as NetworkIcon, KeyRound, Plus, Users, ChevronDown, Info, Settings2 } from "lucide-react";
import { Network as NetworkIcon, KeyRound, Plus, ChevronDown, Info, Settings2 } from "lucide-react";
import type { Network } from "@/hooks/useNetworks";
import { usePlane } from "@/plane";
interface VmNetworkAccessStepProps {
vmVdcId: string;
/** What this plane calls the scope. See useTenantScope. */
scopeNoun?: string;
vmNetworks: string[];
setVmNetworks: (networks: string[]) => void;
availableNetworks: Network[];
@@ -23,20 +25,18 @@ interface VmNetworkAccessStepProps {
sshKeys: Array<{ id: string; key_name: string; key_fingerprint: string | null }>;
sshKeysLoading: boolean;
selectedSshKeyIds: string[];
onToggleSshKey: (keyId: string, checked: boolean) => void;
/** Picks the one saved key to inject, or clears the selection with null. */
onSelectSshKey: (keyId: string | null) => 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 }>;
serverGroupsLoading: boolean;
customMetadata: string;
setCustomMetadata: (value: string) => void;
}
export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
vmVdcId,
scopeNoun = "virtual data center",
vmNetworks,
setVmNetworks,
availableNetworks,
@@ -48,13 +48,9 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
sshKeys,
sshKeysLoading,
selectedSshKeyIds,
onToggleSshKey,
onSelectSshKey,
rawPublicKeys,
setRawPublicKeys,
serverGroupId,
setServerGroupId,
serverGroups,
serverGroupsLoading,
customMetadata,
setCustomMetadata,
}) => {
@@ -75,7 +71,7 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
>
<SelectTrigger className="w-full" id="vm-network">
<SelectValue placeholder={
!vmVdcId ? "Select a virtual data center first" :
!vmVdcId ? `Select a ${scopeNoun.toLowerCase()} first` :
networksLoading ? "Loading networks..." :
"Select network"
} />
@@ -94,7 +90,7 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
))
) : (
<SelectItem value="no-networks" disabled>
No networks available in this virtual data center
No networks available in this {scopeNoun.toLowerCase()}
</SelectItem>
);
})()}
@@ -171,7 +167,7 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
) : (
<Select
value={selectedSshKeyIds[0] ?? "none"}
onValueChange={(value) => onToggleSshKey(value === "none" ? "" : value, value !== "none")}
onValueChange={(value) => onSelectSshKey(value === "none" ? null : value)}
>
<SelectTrigger className="w-full" id="vm-ssh-key">
<SelectValue placeholder="Select a key…" />
@@ -211,36 +207,6 @@ export const VmNetworkAccessStep: React.FC<VmNetworkAccessStepProps> = ({
</div>
)}
{/* ── Server Group ────────────────────────────────────────── */}
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<Label htmlFor="vm-server-group">Server Group <span className="text-muted-foreground text-xs">(optional)</span></Label>
</div>
<Select
value={serverGroupId || "none"}
onValueChange={(value) => setServerGroupId(value === "none" ? "" : value)}
disabled={serverGroupsLoading || !vmVdcId}
>
<SelectTrigger className="w-full" id="vm-server-group">
<SelectValue placeholder={
!vmVdcId ? "Select a VDC first" :
serverGroupsLoading ? "Loading…" :
"No server group"
} />
</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>
{serverGroups.map((sg) => (
<SelectItem key={sg.id} value={sg.id}>
{sg.name}
<span className="text-gray-500 ml-2">({sg.policy})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* ── Advanced (custom metadata) ──────────────────────────── */}
<Collapsible>
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-md border px-3 py-2 text-sm font-medium hover:bg-accent/50 transition-colors [&[data-state=open]>svg]:rotate-180">
@@ -9,7 +9,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import { xcloudifyApi } from "@/services/xcloudifyApi";
import { useVirtualDataCenters } from "@/hooks/useVirtualDataCenters";
// A volume belongs to whatever this plane calls a tenant scope -- a VDC on
// cloud, a Lease on community. See hooks/useTenantScope.
import { useTenantScope } from "@/hooks/useTenantScope";
import { HardDrive, Share, Loader2, Folder, ChevronRight } from "lucide-react";
interface CreateVolumeDialogProps {
@@ -39,7 +41,13 @@ const INITIAL_FORM = {
};
export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: CreateVolumeDialogProps) => {
const { virtualDataCenters, isLoading: vdcLoading } = useVirtualDataCenters();
const {
scopes: virtualDataCenters,
activeScope,
loading: vdcLoading,
noun: scopeNoun,
perResourcePicker: showScopePicker,
} = useTenantScope();
const [category, setCategory] = useState<Category>("");
const [blockType, setBlockType] = useState("");
const [isLoading, setIsLoading] = useState(false);
@@ -52,6 +60,16 @@ export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: Cr
}
}, [isOpen]);
// Default to the scope the session is already in. On community that is the
// only usable one and the picker above is hidden, so without this the form
// would have no vdc_id at all and the create would be rejected.
useEffect(() => {
if (!isOpen || formData.vdc_id) return;
const preferred = activeScope?.id ?? virtualDataCenters[0]?.id;
if (preferred) handleChange("vdc_id", preferred);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, activeScope, virtualDataCenters]);
const apiType = category === "shared" ? "nfs" : category === "local" ? "local" : blockType;
const handleChange = (field: string, value: string | boolean) => {
@@ -77,7 +95,9 @@ export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: Cr
if (!category) { toast.error("Select a storage type"); return; }
if (category === "block" && !blockType) { toast.error("Select a block storage type"); return; }
if (!formData.name || !formData.size_gb || !formData.vdc_id) {
toast.error("Name, size, and VDC are required");
// scopeNoun, not "VDC": on community this form is scoped by a Lease and
// there is no data center anywhere in the product to point at.
toast.error(`Name, size, and ${scopeNoun.toLowerCase()} are required`);
return;
}
if (category === "local" && !formData.path) {
@@ -227,19 +247,32 @@ export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: Cr
</span>
</div>
{/* VDC */}
{/* Which scope the volume belongs to. On a plane where that is
fixed for the session (community's lease), state it instead
of offering a control with one option. */}
<div className="space-y-2">
<Label>Virtual Data Center *</Label>
<Select value={formData.vdc_id} onValueChange={(v) => handleChange("vdc_id", v)}>
<SelectTrigger>
<SelectValue placeholder={vdcLoading ? "Loading..." : "Select a VDC"} />
</SelectTrigger>
<SelectContent>
{virtualDataCenters.map((vdc) => (
<SelectItem key={vdc.id} value={vdc.id}>{vdc.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label>{scopeNoun}{showScopePicker ? " *" : ""}</Label>
{showScopePicker ? (
<Select value={formData.vdc_id} onValueChange={(v) => handleChange("vdc_id", v)}>
<SelectTrigger>
<SelectValue placeholder={
vdcLoading ? "Loading..." : `Select a ${scopeNoun.toLowerCase()}`
} />
</SelectTrigger>
<SelectContent>
{virtualDataCenters.map((vdc) => (
<SelectItem key={vdc.id} value={vdc.id} disabled={vdc.usable === false}>
{vdc.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">
{virtualDataCenters.find((v) => v.id === formData.vdc_id)?.name
?? `No ${scopeNoun.toLowerCase()} selected`}
</p>
)}
</div>
{/* Name + Size */}
@@ -18,7 +18,6 @@ import {
Network,
Loader2,
Trash2,
Search,
Share,
Folder,
X,
@@ -51,13 +50,12 @@ import {
import { CreateResourceWizard } from "./CreateResourceWizard";
import { useXCloudifyPods } from "@/hooks/useXCloudifyPods";
import { useVirtualMachines } from "@/hooks/useVirtualMachines";
import { useVirtualDataCenters } from "@/hooks/useVirtualDataCenters";
import { useNetworks } from "@/hooks/useNetworks";
import { xcloudifyApi } from "@/services/xcloudifyApi";
import { useToast } from "@/hooks/use-toast";
import { ComingSoon } from "@/components/ui/coming-soon";
import { Link } from "react-router-dom";
import { groupNetworksByVDC } from "@/lib/utils";
import { groupNetworksByVDC, resolveWorkloadVdcId, isFailedWorkloadStatus, describeWorkloadError } from "@/lib/utils";
import { useTenantScope } from "@/hooks/useTenantScope";
export const InfrastructurePage = () => {
const { resourceType, resourceId } = useParams();
@@ -86,7 +84,19 @@ export const InfrastructurePage = () => {
});
const { toast } = useToast();
// Core-owned reads are routed through this scope's gateway, so it is the
// authoritative owner of anything they return -- see resolveWorkloadVdcId.
const {
activeScope: activeVdc,
scopes: virtualDataCenters,
loading: vdcLoading,
error: vdcError,
refresh: refetchVDCs,
noun: scopeNoun,
} = useTenantScope();
const activeVdcId = activeVdc?.id ?? null;
// Fetch pods from XCloudify API
const { pods, isLoading, error, refetch } = useXCloudifyPods();
@@ -99,20 +109,12 @@ export const InfrastructurePage = () => {
deleteVirtualMachine,
rebootVirtualMachine,
stopVirtualMachine,
startVirtualMachine,
} = useVirtualMachines();
// Fetch virtual data centers
const {
virtualDataCenters,
isLoading: vdcLoading,
error: vdcError,
refetch: refetchVDCs
} = useVirtualDataCenters();
// Volume state
const [volumes, setVolumes] = useState<any[]>([]);
const [volumesLoading, setVolumesLoading] = useState(false);
const [volumeSearch, setVolumeSearch] = useState("");
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
const [volDeleteOpen, setVolDeleteOpen] = useState(false);
@@ -308,11 +310,6 @@ export const InfrastructurePage = () => {
}
};
const filteredVolumes = volumes.filter(v =>
v.name?.toLowerCase().includes(volumeSearch.toLowerCase()) ||
v.description?.toLowerCase().includes(volumeSearch.toLowerCase())
);
// State for regions and workload hosts
// Fetch networks
@@ -323,22 +320,14 @@ export const InfrastructurePage = () => {
refetch: refetchNetworks,
} = useNetworks();
// State for regions and workload hosts
// State for regions and workload hosts
// State for regions
const [regions, setRegions] = useState<any[]>([]);
const [workloadHosts, setWorkloadHosts] = useState<any[]>([]);
// Fetch regions and workload hosts
// Fetch regions
useEffect(() => {
const fetchLookupData = async () => {
try {
const [regionsData, hostsData] = await Promise.all([
xcloudifyApi.getRegions().catch(() => []),
xcloudifyApi.getWorkloadHosts().catch(() => [])
]);
setRegions(regionsData);
setWorkloadHosts(hostsData);
setRegions(await xcloudifyApi.getRegions().catch(() => []));
} catch (err) {
console.error('Error fetching lookup data:', err);
}
@@ -566,6 +555,22 @@ export const InfrastructurePage = () => {
}
};
const handleStartVM = async (vmId: string, vmName: string) => {
try {
await startVirtualMachine(vmId);
toast({
title: "Start initiated",
description: `VM "${vmName}" is starting.`,
});
} catch (error) {
toast({
title: "Start failed",
description: error instanceof Error ? error.message : "An error occurred while starting the VM.",
variant: "destructive",
});
}
};
// Function to handle network delete
const handleDeleteNetwork = async (networkId: string, networkName: string) => {
try {
@@ -651,7 +656,7 @@ export const InfrastructurePage = () => {
}
virtualMachines.forEach((vm) => {
const vdcId = vm.vdc_id;
const vdcId = resolveWorkloadVdcId(vm, activeVdcId);
if (!grouped.has(vdcId)) {
grouped.set(vdcId, []);
}
@@ -672,7 +677,7 @@ export const InfrastructurePage = () => {
}
pods.forEach((pod) => {
const vdcId = pod.virtual_data_center_id || 'unassigned';
const vdcId = resolveWorkloadVdcId(pod, activeVdcId);
if (!grouped.has(vdcId)) {
grouped.set(vdcId, []);
}
@@ -691,9 +696,11 @@ export const InfrastructurePage = () => {
return grouped;
};
// Get VDC info by ID
// Get VDC info by ID. Falls back to the active VDC's own record, which the
// /virtual_data_centers list can omit when that call is the one that failed.
const getVDCInfo = (vdcId: string) => {
return virtualDataCenters.find(vdc => vdc.id === vdcId);
return virtualDataCenters.find(vdc => vdc.id === vdcId)
?? (vdcId === activeVdcId ? activeVdc ?? undefined : undefined);
};
// Handle container details view
@@ -720,9 +727,11 @@ export const InfrastructurePage = () => {
refetchNetworks();
refetchVDCs();
break;
case 'storage':
fetchVolumes();
refetchVDCs();
break;
default:
// For other tabs (clusters, storage), we'll just refresh containers for now
refetch();
break;
}
};
@@ -735,11 +744,30 @@ export const InfrastructurePage = () => {
return isLoading || vdcLoading;
case 'networks':
return networkLoading || vdcLoading;
case 'storage':
return volumesLoading || vdcLoading;
default:
return isLoading || vdcLoading;
return false;
}
};
const getCreateAction = (): { label: string; onClick: () => void; disabled?: boolean } => {
switch (activeTab) {
case 'virtual-machines':
return { label: 'New Virtual Machine', onClick: () => openWizardWithResourceType('virtual-machine') };
case 'containers':
return { label: 'New Container', onClick: () => openWizardWithResourceType('container') };
case 'networks':
return { label: 'New Network', onClick: () => openWizardWithResourceType('network') };
case 'storage':
return { label: 'New Volume', onClick: () => setCreateVolumeOpen(true) };
default:
return { label: 'New Resource', onClick: () => {}, disabled: true };
}
};
const createAction = getCreateAction();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -756,13 +784,24 @@ export const InfrastructurePage = () => {
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={() => openWizardWithResourceType(activeTab === "virtual-machines" ? "virtual-machine" : "container")}
onClick={createAction.onClick}
disabled={createAction.disabled}
>
<Plus className="h-4 w-4 mr-2" /> New Resource
<Plus className="h-4 w-4 mr-2" /> {createAction.label}
</Button>
</div>
</div>
{vdcError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Failed to load your {scopeNoun.toLowerCase()}s</AlertTitle>
<AlertDescription>
{vdcError} Resources below may show as "Unassigned" or display raw IDs until this is resolved.
</AlertDescription>
</Alert>
)}
{zeroTierReminder && (
<Alert className="border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/40">
<Network className="h-4 w-4" />
@@ -829,26 +868,34 @@ export const InfrastructurePage = () => {
const groupedVMs = groupVirtualMachinesByVDC();
return Array.from(groupedVMs.entries()).map(([vdcId, vms]) => {
const vdcInfo = getVDCInfo(vdcId);
const isUnassigned = vdcId === 'unassigned';
return (
<Card key={vdcId} className="bg-white border-gray-200">
<CardHeader className="pb-2 border-b border-gray-200">
<div className="flex items-center justify-between">
<CardTitle className="text-gray-900">
<Link
to={`/virtual-data-centers/${vdcId}`}
className="hover:underline flex items-center gap-2"
>
<Database className="h-5 w-5" />
{vdcInfo?.name || `VDC ${vdcId}`}
</Link>
{isUnassigned ? (
<span className="flex items-center gap-2">
<Database className="h-5 w-5" />
Unassigned Virtual Machines
</span>
) : (
<span className="flex items-center gap-2">
<Database className="h-5 w-5" />
{vdcInfo?.name || `${scopeNoun} ${vdcId}`}
</span>
)}
</CardTitle>
<Badge variant="secondary" className="text-xs">
{vms.length} VM{vms.length !== 1 ? 's' : ''}
</Badge>
</div>
{vdcInfo?.description && (
{!isUnassigned && vdcInfo?.description && (
<p className="text-sm text-gray-600 mt-1">{vdcInfo.description}</p>
)}
{isUnassigned && (
<p className="text-sm text-gray-600 mt-1">Virtual machines not assigned to any {scopeNoun.toLowerCase()}</p>
)}
</CardHeader>
<CardContent className="p-0">
<Table>
@@ -898,6 +945,12 @@ export const InfrastructurePage = () => {
/>
{vm.status}
</Badge>
{isFailedWorkloadStatus(vm.status) && describeWorkloadError(vm.last_error_reason) && (
<p className="text-xs text-red-600 mt-1 max-w-[18rem] truncate"
title={describeWorkloadError(vm.last_error_reason) ?? undefined}>
{describeWorkloadError(vm.last_error_reason)}
</p>
)}
</TableCell>
<TableCell className="hidden md:table-cell">Virtual Machine</TableCell>
<TableCell className="hidden lg:table-cell">{getVMSpecs(vm)}</TableCell>
@@ -960,6 +1013,15 @@ export const InfrastructurePage = () => {
Stop
</DropdownMenuItem>
)}
{vm.status === "stopped" && (
<DropdownMenuItem
className="cursor-pointer flex items-center"
onClick={() => handleStartVM(vm.id, vm.name)}
>
<Power className="h-4 w-4 mr-2" />
Start
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer text-red-500 flex items-center"
onClick={() => handleDeleteVM(vm.id, vm.name)}
@@ -1038,19 +1100,14 @@ export const InfrastructurePage = () => {
{isUnassigned ? (
<span>Unassigned Pods</span>
) : (
<Link
to={`/virtual-data-centers/${vdcId}`}
className="hover:underline"
>
{vdcInfo?.name || `VDC ${vdcId}`}
</Link>
<span>{vdcInfo?.name || `${scopeNoun} ${vdcId}`}</span>
)}
</h3>
{!isUnassigned && vdcInfo?.description && (
<p className="text-sm text-gray-600 mt-1">{vdcInfo.description}</p>
)}
{isUnassigned && (
<p className="text-sm text-gray-600 mt-1">Pods not assigned to any virtual data center</p>
<p className="text-sm text-gray-600 mt-1">Pods not assigned to any {scopeNoun.toLowerCase()}</p>
)}
</div>
<Badge variant="secondary" className="text-xs">
@@ -1071,7 +1128,6 @@ export const InfrastructurePage = () => {
onPodAction={handlePodAction}
onViewContainer={handleViewContainer}
regions={regions}
workloadHosts={workloadHosts}
virtualDataCenters={virtualDataCenters}
/>
))}
@@ -1203,114 +1259,148 @@ export const InfrastructurePage = () => {
</TabsContent>
<TabsContent value="storage" className="mt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search volumes..."
value={volumeSearch}
onChange={(e) => setVolumeSearch(e.target.value)}
className="pl-8 w-64"
/>
<div className="space-y-6">
{volumesLoading || vdcLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2">Loading volumes...</span>
</div>
<Button onClick={() => setCreateVolumeOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Volume
</Button>
</div>
) : volumes.length === 0 ? (
<Card className="border-2 border-dashed border-gray-300">
<CardContent className="flex flex-col items-center justify-center py-12">
<HardDrive className="h-12 w-12 text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No volumes found</h3>
<p className="text-gray-600 text-center mb-4">
Get started by creating your first storage volume
</p>
<Button onClick={() => setCreateVolumeOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Volume
</Button>
</CardContent>
</Card>
) : (
(() => {
const groupedVolumes = new Map<string, typeof volumes>();
volumes.forEach((vol) => {
const vdcId = resolveWorkloadVdcId(vol, activeVdcId);
if (!groupedVolumes.has(vdcId)) {
groupedVolumes.set(vdcId, []);
}
groupedVolumes.get(vdcId)!.push(vol);
});
<Card className="bg-white border-gray-200">
<CardHeader className="pb-2 border-b border-gray-200">
<CardTitle className="text-gray-900">Storage Volumes</CardTitle>
</CardHeader>
<CardContent className="p-0">
{volumesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading volumes...
</div>
) : filteredVolumes.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Database className="h-10 w-10 mx-auto mb-3" />
<p>{volumes.length === 0 ? "No volumes found" : "No volumes match your search"}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-gray-200 hover:bg-gray-50">
<TableHead className="text-gray-600">Name</TableHead>
<TableHead className="text-gray-600">Type</TableHead>
<TableHead className="text-gray-600">Size</TableHead>
<TableHead className="text-gray-600">Status</TableHead>
<TableHead className="text-gray-600 hidden md:table-cell">Path</TableHead>
<TableHead className="text-gray-600 hidden md:table-cell">Boot</TableHead>
<TableHead className="text-gray-600 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredVolumes.map((vol) => (
<TableRow key={vol.id} className="border-gray-200 hover:bg-gray-50">
<TableCell className="font-medium text-gray-900">
<button
className="hover:underline text-left text-primary font-medium"
onClick={() => navigate(`/storage/${vol.id}/details`)}
>
{vol.name}
</button>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{getVolTypeIcon(vol.type)}
<Badge variant="outline">{vol.type?.toUpperCase() || "—"}</Badge>
</div>
</TableCell>
<TableCell>{vol.size_gb >= 1024 ? `${(vol.size_gb / 1024).toFixed(1)} TB` : `${vol.size_gb} GB`}</TableCell>
<TableCell>{getVolStatusBadge(vol.status)}</TableCell>
<TableCell className="hidden md:table-cell font-mono text-sm">{vol.path || "—"}</TableCell>
<TableCell className="hidden md:table-cell">
{vol.boot ? <Badge variant="default">Boot</Badge> : <Badge variant="secondary">No</Badge>}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-white border-gray-200 text-gray-900">
<DropdownMenuItem className="cursor-pointer" onClick={() => openVolWorkloads(vol)}>
View Attached Workloads
</DropdownMenuItem>
{vol.status === "in-use" ? (
<DropdownMenuItem className="cursor-pointer" onClick={() => openVolDetach(vol)}>
Detach from Workload
</DropdownMenuItem>
) : (
<DropdownMenuItem className="cursor-pointer" onClick={() => openVolAttach(vol)}>
Attach to Workload
</DropdownMenuItem>
)}
<DropdownMenuItem className="cursor-pointer" onClick={() => openVolEdit(vol)}>
Edit / Resize
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500"
onClick={() => { setVolToDelete(vol); setVolDeleteOpen(true); }}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
return Array.from(groupedVolumes.entries()).map(([vdcId, volsInVdc]) => {
const vdcInfo = getVDCInfo(vdcId);
const isUnassigned = vdcId === 'unassigned';
return (
<Card key={vdcId} className="bg-white border-gray-200">
<CardHeader className="pb-2 border-b border-gray-200">
<div className="flex items-center justify-between">
<CardTitle className="text-gray-900">
{isUnassigned ? (
<span className="flex items-center gap-2">
<Database className="h-5 w-5" />
Unassigned Volumes
</span>
) : (
<span className="flex items-center gap-2">
<Database className="h-5 w-5" />
{vdcInfo?.name || `${scopeNoun} ${vdcId}`}
</span>
)}
</CardTitle>
<Badge variant="secondary" className="text-xs">
{volsInVdc.length} Volume{volsInVdc.length !== 1 ? 's' : ''}
</Badge>
</div>
{!isUnassigned && vdcInfo?.description && (
<p className="text-sm text-gray-600 mt-1">{vdcInfo.description}</p>
)}
{isUnassigned && (
<p className="text-sm text-gray-600 mt-1">Volumes not assigned to any {scopeNoun.toLowerCase()}</p>
)}
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow className="border-gray-200 hover:bg-gray-50">
<TableHead className="text-gray-600">Name</TableHead>
<TableHead className="text-gray-600">Type</TableHead>
<TableHead className="text-gray-600">Size</TableHead>
<TableHead className="text-gray-600">Status</TableHead>
<TableHead className="text-gray-600 hidden md:table-cell">Path</TableHead>
<TableHead className="text-gray-600 hidden md:table-cell">Boot</TableHead>
<TableHead className="text-gray-600 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{volsInVdc.map((vol) => (
<TableRow key={vol.id} className="border-gray-200 hover:bg-gray-50">
<TableCell className="font-medium text-gray-900">
<button
className="hover:underline text-left text-primary font-medium"
onClick={() => navigate(`/storage/${vol.id}/details`)}
>
{vol.name}
</button>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{getVolTypeIcon(vol.type)}
<Badge variant="outline">{vol.type?.toUpperCase() || "—"}</Badge>
</div>
</TableCell>
<TableCell>{vol.size_gb >= 1024 ? `${(vol.size_gb / 1024).toFixed(1)} TB` : `${vol.size_gb} GB`}</TableCell>
<TableCell>{getVolStatusBadge(vol.status)}</TableCell>
<TableCell className="hidden md:table-cell font-mono text-sm">{vol.path || "—"}</TableCell>
<TableCell className="hidden md:table-cell">
{vol.boot ? <Badge variant="default">Boot</Badge> : <Badge variant="secondary">No</Badge>}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-white border-gray-200 text-gray-900">
<DropdownMenuItem className="cursor-pointer" onClick={() => openVolWorkloads(vol)}>
View Attached Workloads
</DropdownMenuItem>
{vol.status === "in-use" ? (
<DropdownMenuItem className="cursor-pointer" onClick={() => openVolDetach(vol)}>
Detach from Workload
</DropdownMenuItem>
) : (
<DropdownMenuItem className="cursor-pointer" onClick={() => openVolAttach(vol)}>
Attach to Workload
</DropdownMenuItem>
)}
<DropdownMenuItem className="cursor-pointer" onClick={() => openVolEdit(vol)}>
Edit / Resize
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500"
onClick={() => { setVolToDelete(vol); setVolDeleteOpen(true); }}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
});
})()
)}
</div>
{/* Create Volume */}
@@ -1480,13 +1570,6 @@ export const InfrastructurePage = () => {
<TabsContent value="networks" className="mt-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div />
<Button onClick={() => openWizardWithResourceType("network")}>
<Plus className="h-4 w-4 mr-2" />
Create Network
</Button>
</div>
{networkError && (
<Alert className="mb-4">
<AlertCircle className="h-4 w-4" />
@@ -1520,7 +1603,7 @@ export const InfrastructurePage = () => {
// Group networks by VDC directly
const networksByVDC = new Map<string, typeof networks>();
networks.forEach((network) => {
const vdcId = network.tenant_id || 'unassigned';
const vdcId = resolveWorkloadVdcId(network, activeVdcId);
if (!networksByVDC.has(vdcId)) {
networksByVDC.set(vdcId, []);
}
@@ -1540,19 +1623,14 @@ export const InfrastructurePage = () => {
{isUnassigned ? (
<span>Unassigned Networks</span>
) : (
<Link
to={`/virtual-data-centers/${vdcId}`}
className="hover:underline"
>
{vdcInfo?.name || `VDC ${vdcId}`}
</Link>
<span>{vdcInfo?.name || `${scopeNoun} ${vdcId}`}</span>
)}
</h3>
{!isUnassigned && vdcInfo?.description && (
<p className="text-sm text-gray-600 mt-1">{vdcInfo.description}</p>
)}
{isUnassigned && (
<p className="text-sm text-gray-600 mt-1">Networks not assigned to any virtual data center</p>
<p className="text-sm text-gray-600 mt-1">Networks not assigned to any {scopeNoun.toLowerCase()}</p>
)}
</div>
<Badge variant="secondary" className="text-xs">
@@ -1585,6 +1663,12 @@ export const InfrastructurePage = () => {
</div>
</div>
<div className="flex items-center gap-3">
<Badge
variant={network.enable_host_nat ? "default" : "outline"}
className="text-xs"
>
NAT {network.enable_host_nat ? "On" : "Off"}
</Badge>
<Badge variant="outline" className="font-mono text-xs">
{network.vni}
</Badge>
@@ -12,7 +12,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CreateVolumeDialog } from "@/components/CreateVolumeDialog";
import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog";
import { useVirtualDataCenters } from "@/hooks/useVirtualDataCenters";
// A volume belongs to whatever this plane calls a tenant scope -- a VDC on
// cloud, a Lease on community. See hooks/useTenantScope.
import { useTenantScope } from "@/hooks/useTenantScope";
import { toast } from "sonner";
import { xcloudifyApi } from "@/services/xcloudifyApi";
import {
@@ -31,7 +33,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
export const StoragePage = () => {
const navigate = useNavigate();
const { virtualDataCenters } = useVirtualDataCenters();
const { scopes: virtualDataCenters, noun: scopeNoun } = useTenantScope();
const [searchQuery, setSearchQuery] = useState("");
const [volumes, setVolumes] = useState<any[]>([]);
const [imageMap, setImageMap] = useState<Record<string, any>>({});
@@ -353,9 +355,9 @@ export const StoragePage = () => {
}, {});
const getVdcName = (vdcId: string) => {
if (vdcId === "unknown") return "Unknown VDC";
if (vdcId === "unknown") return `Unknown ${scopeNoun.toLowerCase()}`;
const vdc = virtualDataCenters.find((v) => v.id === vdcId);
return vdc ? vdc.name : `VDC ${vdcId.slice(0, 8)}...`;
return vdc ? vdc.name : `${scopeNoun} ${vdcId.slice(0, 8)}...`;
};
const formatGb = (gb: number) =>
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -13,6 +13,8 @@ import { ArrowLeft, HardDrive, Loader2, Trash2, RefreshCw, Share, Folder, Databa
import { xcloudifyApi } from "@/services/xcloudifyApi";
import { useToast } from "@/hooks/use-toast";
import { DeleteConfirmationDialog } from "./DeleteConfirmationDialog";
// Scope, not VDC: community scopes by Lease. See hooks/useTenantScope.
import { useTenantScope } from "@/hooks/useTenantScope";
interface VolumeDetailsPageProps {
volumeId: string;
@@ -23,7 +25,16 @@ export const VolumeDetailsPage: React.FC<VolumeDetailsPageProps> = ({ volumeId,
const [volume, setVolume] = useState<any>(null);
const [attachedWorkloads, setAttachedWorkloads] = useState<any[]>([]);
const [imageMap, setImageMap] = useState<Record<string, any>>({});
const [vdcMap, setVdcMap] = useState<Record<string, any>>({});
// Scope names come from the session's provider, not a fetch. This used to
// be part of the Promise.all below via getVirtualDataCenters(), which
// community has no endpoint for -- so one 404 rejected the whole batch and
// the page reported "failed to load volume" for a volume that had loaded
// fine. See hooks/useTenantScope.
const { scopes, noun: scopeNoun } = useTenantScope();
const vdcMap = useMemo(
() => Object.fromEntries(scopes.map((s) => [s.id, s])),
[scopes],
);
const [hostMap, setHostMap] = useState<Record<string, any>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -54,11 +65,10 @@ export const VolumeDetailsPage: React.FC<VolumeDetailsPageProps> = ({ volumeId,
setIsLoading(true);
setError(null);
try {
const [volRes, workloadsRes, images, vdcs, hosts] = await Promise.all([
const [volRes, workloadsRes, images, hosts] = await Promise.all([
xcloudifyApi.getVolume(volumeId),
xcloudifyApi.getWorkloadsForVolume(volumeId),
xcloudifyApi.getImages(),
xcloudifyApi.getVirtualDataCenters(),
xcloudifyApi.getWorkloadHosts(),
]);
setVolume(volRes.data || volRes);
@@ -66,9 +76,6 @@ export const VolumeDetailsPage: React.FC<VolumeDetailsPageProps> = ({ volumeId,
const imgMap: Record<string, any> = {};
for (const img of images) imgMap[img.id] = img;
setImageMap(imgMap);
const vMap: Record<string, any> = {};
for (const vdc of (Array.isArray(vdcs) ? vdcs : [])) vMap[vdc.id] = vdc;
setVdcMap(vMap);
const hMap: Record<string, any> = {};
for (const host of (Array.isArray(hosts) ? hosts : [])) hMap[host.id] = host;
setHostMap(hMap);
@@ -231,7 +238,7 @@ export const VolumeDetailsPage: React.FC<VolumeDetailsPageProps> = ({ volumeId,
|| linkedImage?.id
|| null;
const vdcName = volume.vdc_id
? (vdcMap[volume.vdc_id]?.name || `VDC ${volume.vdc_id.slice(0, 8)}...`)
? (vdcMap[volume.vdc_id]?.name || `${scopeNoun} ${volume.vdc_id.slice(0, 8)}...`)
: "—";
return (
+144
View File
@@ -0,0 +1,144 @@
/**
* The lease the community portal is currently working in.
*
* The community-plane counterpart to useVdc.tsx. Community has no VDC --
* a Lease IS the tenant unit: its id is the tenant_id core stamps on every
* resource, and the lease gateway (/leases/<id>/core/*) resolves it from the
* URL rather than trusting the caller (see community/app/routes/
* gateway_routes.py). So every core-owned call needs one selected, the same
* way a cloud call needs an active VDC.
*
* Resolved once the community session is known, from GET /leases -- which
* returns every lease this user is party to, as both customer and node
* owner (lease_routes.py:list_leases). Only the customer side is usable
* here: a lease where this user is the node's owner has no compute for them
* to manage on this screen (that side is their own node's dashboard), and an
* ended lease's gateway 409s on every call, so neither belongs in the picker.
*/
import {
createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode,
} from "react";
import { communityApi, Lease } from "@/services/communityApi";
import { setActiveLeaseId } from "@/services/xcloudifyApi";
import { useCommunityAuth } from "@/hooks/useCommunityAuth";
const STORAGE_KEY = "xcloudify_active_lease";
interface LeaseState {
/** This user's own active leases -- the ones with compute to manage. */
leases: Lease[];
activeLease: Lease | null;
loading: boolean;
/** The lookup has finished (either way). Distinguishes "still loading"
* from "loaded, and this user genuinely has no active lease yet". */
resolved: boolean;
error: string | null;
/** Switch lease; remembered for next visit. */
selectLease: (leaseId: string) => void;
/** Re-read the list, e.g. after leasing a new node or ending one. */
refresh: () => void;
}
const LeaseContext = createContext<LeaseState>({
leases: [], activeLease: null, loading: false, resolved: true, error: null,
selectLease: () => {}, refresh: () => {},
});
function remembered(): string | null {
try {
return localStorage.getItem(STORAGE_KEY);
} catch {
return null; // private window, blocked storage -- fall back to first lease
}
}
function remember(leaseId: string | null) {
try {
if (leaseId) localStorage.setItem(STORAGE_KEY, leaseId);
else localStorage.removeItem(STORAGE_KEY);
} catch {
/* not worth failing a page load over */
}
}
export function LeaseProvider({ children }: { children: ReactNode }) {
const { user, loading: authLoading, error: authError } = useCommunityAuth();
const [leases, setLeases] = useState<Lease[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [resolved, setResolved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
const refresh = useCallback(() => setNonce((n) => n + 1), []);
useEffect(() => {
if (!user) {
setActiveLeaseId(null);
setLeases([]);
setActiveId(null);
setResolved(!authLoading);
return;
}
let cancelled = false;
(async () => {
setLoading(true);
try {
const all = await communityApi.listLeases();
if (cancelled) return;
const usable = all.filter((l) => l.customer_id === user.id && l.active);
setLeases(usable);
// Keep the remembered lease only if it is still usable -- it may
// have ended, or this browser's identity may have switched (the
// community dev-identity switcher reloads the page, but a saved
// lease id from a previous identity could otherwise leak through).
const saved = remembered();
const chosen = usable.find((l) => l.id === saved) ?? usable[0] ?? null;
setActiveId(chosen?.id ?? null);
setActiveLeaseId(chosen?.id ?? null);
remember(chosen?.id ?? null);
setError(null);
} catch (err) {
if (cancelled) return;
setLeases([]);
setActiveId(null);
setActiveLeaseId(null);
setError(err instanceof Error ? err.message : "Could not load your leases");
} finally {
if (!cancelled) {
setLoading(false);
setResolved(true);
}
}
})();
return () => { cancelled = true; };
// user.id, not user: a new object identity on every /auth/me refresh
// would refetch the workspace for no reason.
}, [user?.id, authLoading, nonce]);
const selectLease = useCallback((leaseId: string) => {
setActiveId(leaseId);
setActiveLeaseId(leaseId);
remember(leaseId);
}, []);
const value = useMemo<LeaseState>(() => ({
leases,
activeLease: leases.find((l) => l.id === activeId) ?? null,
loading,
resolved,
error: authError ?? error,
selectLease,
refresh,
}), [leases, activeId, loading, resolved, error, authError, selectLease, refresh]);
return <LeaseContext.Provider value={value}>{children}</LeaseContext.Provider>;
}
export function useLease(): LeaseState {
return useContext(LeaseContext);
}
@@ -0,0 +1,180 @@
/**
* The thing a core-owned resource belongs to, whatever this plane calls it.
*
* Core has no tenancy: it stores a `tenant_id` string on every workload,
* network and volume, and never asks what it means. Each plane above it
* supplies its own answer:
*
* cloud tenant_id = a Virtual Data Center's id
* community tenant_id = a Lease's id (community/README.md)
* core nothing -- the operator console is cross-tenant by design
*
* Before this hook existed, ~half the shared screens asked `useVdc()` and
* `useVirtualDataCenters()` directly. On community that meant an unmounted
* provider returning a null VDC and a 404 from `/api/virtual_data_centers`,
* which the create forms read as "no data center selected" and refused to
* submit -- so the community portal could list compute it was unable to
* create. This is the missing abstraction those screens should have had:
* one question ("what am I working inside?") with a per-plane answer, in the
* same spirit as plane.tsx's capability flags.
*
* Cloud and core behaviour is unchanged -- for them this is a thin pass
* through to the VDC hooks that were already there.
*/
import { createContext, useContext, useMemo, ReactNode } from "react";
import { usePlane } from "@/plane";
import { useVdc } from "@/hooks/useVdc";
import { useLease } from "@/hooks/useLease";
export interface TenantScope {
/** What core will see as `tenant_id`. */
id: string;
/** Human label for a picker: the VDC's name, or the leased machine's. */
name: string;
/** Core region the scope's workloads land in. Empty when not yet resolvable. */
region_id: string;
/** True when this scope can accept new workloads right now. */
usable: boolean;
/** Why not, when `usable` is false -- shown instead of a silent empty picker. */
unusableReason?: string;
/** Free text the plane attaches to a scope; cloud puts the VDC's own
* description here, community has none. Rendered beside the name in a
* picker when present. */
description?: string | null;
}
export interface TenantScopeState {
scopes: TenantScope[];
activeScope: TenantScope | null;
loading: boolean;
/** The lookup finished, either way. Distinguishes "loading" from "none". */
resolved: boolean;
error: string | null;
selectScope: (id: string) => void;
refresh: () => void;
/**
* What to call it on screen. A community customer has never heard of a
* "data center" and a cloud tenant has never heard of a "lease"; the same
* form has to be readable to both. Singular only -- every label that needs
* the plural just appends an "s" ("virtual data centers", "leases"), and a
* second field nothing read would look like a rule it is not.
*/
noun: string;
/**
* Whether the user picks a scope per resource at all. Cloud: yes, a
* project can hold several VDCs. Community: no -- one lease is selected
* for the whole session in the header, and every create belongs to it, so
* a per-form picker would be a control with one option.
*/
perResourcePicker: boolean;
}
const EMPTY: TenantScopeState = {
scopes: [], activeScope: null, loading: false, resolved: true, error: null,
selectScope: () => {}, refresh: () => {},
noun: "Scope", perResourcePicker: false,
};
// Only used to let a plane override the derived value in a test or a
// storybook. Nothing mounts it in production -- the real value is derived
// from whichever of VdcProvider / LeaseProvider the app already mounts.
const TenantScopeContext = createContext<TenantScopeState | null>(null);
export function TenantScopeOverride({
value, children,
}: { value: TenantScopeState; children: ReactNode }) {
return <TenantScopeContext.Provider value={value}>{children}</TenantScopeContext.Provider>;
}
export function useTenantScope(): TenantScopeState {
const override = useContext(TenantScopeContext);
const plane = usePlane();
// Both are called unconditionally -- hooks cannot be called behind an `if`
// -- and both are safe to call on the wrong plane: each returns its
// provider-less default (empty list, null active) rather than fetching.
const vdc = useVdc();
const lease = useLease();
const fromVdc = useMemo<TenantScopeState>(() => ({
scopes: vdc.vdcs.map((v) => ({
id: v.id,
name: v.name,
region_id: v.region_id ?? "",
usable: Boolean(v.region_id),
unusableReason: v.region_id ? undefined : "This data center has no region assigned.",
description: v.description,
})),
activeScope: vdc.activeVdc
? {
id: vdc.activeVdc.id,
name: vdc.activeVdc.name,
region_id: vdc.activeVdc.region_id ?? "",
usable: Boolean(vdc.activeVdc.region_id),
unusableReason: vdc.activeVdc.region_id ? undefined : "This data center has no region assigned.",
description: vdc.activeVdc.description,
}
: null,
loading: vdc.loading,
resolved: vdc.resolved,
error: vdc.error,
selectScope: vdc.selectVdc,
refresh: vdc.refresh,
// The exact words the cloud forms used before this hook existed. Kept
// verbatim so introducing the abstraction changes no cloud copy: the
// labels read "Virtual Data Center *", the placeholders "Select virtual
// data center", exactly as they did.
noun: "Virtual Data Center",
perResourcePicker: true,
}), [vdc]);
const fromLease = useMemo<TenantScopeState>(() => {
const toScope = (l: typeof lease.leases[number]): TenantScope => {
// A lease whose node has gone offline, or has not finished enrolling
// into core, will be refused by the gateway with a 409 (see
// gateway_routes._placement_for). Saying so here turns that into a
// disabled option with a reason instead of a form that submits and
// fails.
const offline = l.node_status && l.node_status !== "online";
// Both halves of what the gateway checks, in the order it checks them.
// `node_enrolled` is not redundant with `region_id`: a provider's core
// region exists from the moment the node is *registered*, so region_id
// is set on a machine the installer has never run on. Leaving that out
// made every such lease look usable, and the customer met the refusal
// as a 409 on each compute call instead of a disabled option here.
const joining = !l.region_id || !l.node_enrolled;
return {
id: l.id,
// The machine's name, not the lease id: "cory-node01" is what the
// customer chose to rent, and the lease id is an implementation
// detail they never typed.
name: l.node_name || `Lease ${l.id.slice(0, 8)}`,
region_id: l.region_id ?? "",
usable: !joining && !offline,
unusableReason: offline
? "The leased machine is offline."
: joining
? "This lease's machine has not finished joining the compute plane yet. It usually takes a minute after the installer runs."
: undefined,
};
};
return {
scopes: lease.leases.map(toScope),
activeScope: lease.activeLease ? toScope(lease.activeLease) : null,
loading: lease.loading,
resolved: lease.resolved,
error: lease.error,
selectScope: lease.selectLease,
refresh: lease.refresh,
noun: "Lease",
// One lease is chosen for the whole session in the header; a per-form
// copy of that control would only ever show the same single answer.
perResourcePicker: false,
};
}, [lease]);
if (override) return override;
if (plane.id === "community") return fromLease;
if (plane.id === "core") return EMPTY;
return fromVdc;
}
@@ -6,7 +6,16 @@ export interface VirtualMachine {
name: string;
status: string;
vdc_id: string;
/** core scopes by tenant_id, which IS the VDC id -- vdc_id is often absent. */
tenant_id?: string;
workload_host_id?: string;
/** Why this workload failed, when status is an error state. */
last_error_reason?: string | null;
extended_status?: string | null;
allocation_attempts?: number;
max_allocation_attempts?: number | null;
last_attempt_at?: string | null;
next_retry_at?: string | null;
launch_params?: string;
created_at?: string;
updated_at?: string;
@@ -74,6 +83,19 @@ export const useVirtualMachines = () => {
}
};
const startVirtualMachine = async (vmId: string) => {
try {
await xcloudifyApi.vmAction(vmId, 'start');
setVirtualMachines(prev =>
prev.map(vm => vm.id === vmId ? { ...vm, status: 'pending-start' } : vm)
);
return true;
} catch (err) {
console.error('Error starting virtual machine:', err);
throw err;
}
};
useEffect(() => {
fetchVirtualMachines();
}, []);
@@ -86,5 +108,6 @@ export const useVirtualMachines = () => {
deleteVirtualMachine,
rebootVirtualMachine,
stopVirtualMachine,
startVirtualMachine,
};
};
+62
View File
@@ -7,6 +7,68 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** Workload statuses that mean "this thing is broken", per core's Workload
* status machine (see models.py). A workload in any of these has a reason
* recorded in `last_error_reason` -- showing the badge without it tells the
* operator something failed but not what. */
const FAILED_WORKLOAD_STATUSES = new Set([
'error', 'failed', 'failed-to-spawn', 'launch_failed', 'host_failed', 'dead',
]);
export function isFailedWorkloadStatus(status?: string): boolean {
return FAILED_WORKLOAD_STATUSES.has((status || '').toLowerCase());
}
/** Reason slugs core writes directly (host_monitoring.py, allocation_retry.py).
* Anything not listed is a raw error string from the spawn attempt and is
* shown verbatim. */
const ERROR_REASON_TEXT: Record<string, string> = {
'host_failure_reschedule': 'The host failed; this workload is being rescheduled onto another one.',
'single_host_failure': 'The only host running this workload failed.',
'relocation_disabled': 'The host failed, and relocation is disabled for this workload.',
'no_online_hosts': 'No online host is available in this region.',
'no_alternate_hosts': 'No alternate host was available to relocate onto.',
'host_offline': 'The assigned host went offline.',
'exceeded-max-retries': 'Allocation was retried the maximum number of times and never succeeded.',
'all-candidate-hosts-failed': 'Every candidate host rejected this workload.',
};
/** Human-readable form of core's `last_error_reason`. */
export function describeWorkloadError(reason?: string | null): string | null {
if (!reason) return null;
const key = reason.trim();
if (ERROR_REASON_TEXT[key]) return ERROR_REASON_TEXT[key];
// host_monitoring.py writes this one with the pause cause appended.
if (key.startsWith('region_paused_')) {
return `The region is paused (${key.slice('region_paused_'.length).replace(/_/g, ' ')}).`;
}
return key;
}
/**
* The VDC a workload belongs to, across both planes' field names.
*
* Core has no VDC concept -- it stamps `tenant_id`, which IS the VDC's id by
* convention (see gateway_routes.py in theapi/cloud). Only cloud's own records
* carry `vdc_id` / `virtual_data_center_id`, so reading those alone leaves
* every core-served workload looking unassigned.
*
* `fallbackVdcId` is the cloud portal's active VDC: there, core reads go
* through /vdcs/<id>/core/*, so a returned workload cannot belong to any other
* VDC even when the payload omits the field. Core console passes nothing --
* its listings are genuinely cross-tenant and may have no owner.
*/
export function resolveWorkloadVdcId(
workload: Record<string, any>,
fallbackVdcId?: string | null,
): string {
return workload?.tenant_id
|| workload?.vdc_id
|| workload?.virtual_data_center_id
|| fallbackVdcId
|| 'unassigned';
}
// Network grouping interface: Region -> VDC -> Networks[]
export interface GroupedNetworks {
regionId: string;