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:
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user