Merge branch 'main' into v1.01/stage-env

This commit is contained in:
2026-06-03 13:22:52 +09:30
committed by GitHub
7 changed files with 1901 additions and 554 deletions
+257 -208
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { toast } from "sonner";
import { xcloudifyApi } from "@/services/xcloudifyApi";
import { useVirtualDataCenters } from "@/hooks/useVirtualDataCenters";
import { HardDrive, Share, Loader2, Folder } from "lucide-react";
import { HardDrive, Share, Loader2, Folder, ChevronRight } from "lucide-react";
interface CreateVolumeDialogProps {
isOpen: boolean;
@@ -18,284 +18,333 @@ interface CreateVolumeDialogProps {
onVolumeCreated?: () => void;
}
// Top-level category the user picks first
type Category = "shared" | "local" | "block" | "";
// Actual API type — only relevant when category === "block"
const BLOCK_TYPES = [
{ value: "local", label: "Local (qcow2)", description: "File-backed QEMU image on the host filesystem" },
{ value: "lvm", label: "LVM", description: "Logical Volume on the host's volume group" },
{ value: "ceph", label: "Ceph", description: "Distributed Ceph RBD block device" },
];
const INITIAL_FORM = {
name: "",
description: "",
size_gb: "",
path: "",
boot: false,
vdc_id: "",
image_id: "",
host_id: "",
};
export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: CreateVolumeDialogProps) => {
const { virtualDataCenters, isLoading: vdcLoading } = useVirtualDataCenters();
const [storageType, setStorageType] = useState<"shared" | "volume" | "local" | "">("");
const [category, setCategory] = useState<Category>("");
const [blockType, setBlockType] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
name: "",
description: "",
size_gb: "",
type: "",
path: "",
boot: false,
vdc_id: "",
});
const [images, setImages] = useState<any[]>([]);
const [hosts, setHosts] = useState<any[]>([]);
const [formData, setFormData] = useState(INITIAL_FORM);
const handleStorageTypeSelect = (type: "shared" | "volume" | "local") => {
setStorageType(type);
setFormData(prev => ({
...prev,
type: type === "shared" ? "nfs" : type === "volume" ? "ceph" : "local"
}));
useEffect(() => {
if (isOpen) {
xcloudifyApi.getImages().then(setImages).catch(() => {});
xcloudifyApi.getWorkloadHosts().then(setHosts).catch(() => {});
}
}, [isOpen]);
const apiType = category === "shared" ? "nfs" : category === "local" ? "local" : blockType;
const handleChange = (field: string, value: string | boolean) => {
setFormData(prev => {
const next = { ...prev, [field]: value };
if (field === "boot" && !value) next.image_id = "";
return next;
});
};
const handleInputChange = (field: string, value: string | boolean) => {
setFormData(prev => ({
...prev,
[field]: value
}));
const reset = () => {
setCategory("");
setBlockType("");
setFormData(INITIAL_FORM);
};
const handleOpenChange = (open: boolean) => {
if (!open) reset();
onOpenChange(open);
};
const handleSubmit = async () => {
if (!storageType) {
toast.error("Please select a storage type");
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");
return;
}
if (!formData.name || !formData.size_gb || !formData.vdc_id || (storageType === "local" && !formData.path)) {
toast.error("Please fill in all required fields");
if (category === "local" && !formData.path) {
toast.error("Host folder path is required for local volumes");
return;
}
if (formData.boot && !formData.image_id) {
toast.error("A boot volume requires an image");
return;
}
setIsLoading(true);
try {
const volumeData = {
...formData,
const payload: Record<string, any> = {
name: formData.name,
size_gb: parseFloat(formData.size_gb),
vdc_id: formData.vdc_id,
type: apiType,
boot: formData.boot,
};
if (formData.description) payload.description = formData.description;
if (formData.path) payload.path = formData.path;
if (formData.image_id) payload.image_id = formData.image_id;
if (formData.host_id) payload.host_id = formData.host_id;
await xcloudifyApi.createVolume(volumeData);
toast.success("Volume created successfully!");
await xcloudifyApi.createVolume(payload);
toast.success("Volume queued for creation — provisioning in background");
onVolumeCreated?.();
onOpenChange(false);
resetForm();
handleOpenChange(false);
} catch (error: any) {
console.error("Error creating volume:", error);
toast.error(error.message || "Failed to create volume");
} finally {
setIsLoading(false);
}
};
const resetForm = () => {
setStorageType("");
setFormData({
name: "",
description: "",
size_gb: "",
type: "",
path: "",
boot: false,
vdc_id: "",
});
};
const handleOpenChange = (open: boolean) => {
if (!open) {
resetForm();
}
onOpenChange(open);
};
const supportsBootable = category === "block" || category === "local";
const showForm = category === "shared" || category === "local" || (category === "block" && blockType);
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Storage Volume</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{!storageType && (
{/* Step 1: category selection */}
{!category && (
<div className="space-y-4">
<h3 className="text-lg font-medium">Select Storage Type</h3>
<p className="text-sm text-muted-foreground">Select a storage type to get started.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card
<Card
className="cursor-pointer border-2 hover:border-primary transition-colors"
onClick={() => handleStorageTypeSelect("shared")}
onClick={() => setCategory("shared")}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Share className="h-5 w-5" />
Shared Filesystem
</CardTitle>
<CardDescription>
A folder mount that can be shared across multiple workloads and between containers and VMs
</CardDescription>
<CardDescription>NFS mount shared across multiple workloads</CardDescription>
</CardHeader>
<CardContent>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Multi-workload access</li>
<li>• Cross-container sharing</li>
<li>• VM compatibility</li>
<li>• NFS-based storage</li>
</ul>
</CardContent>
</Card>
<Card
<Card
className="cursor-pointer border-2 hover:border-primary transition-colors"
onClick={() => handleStorageTypeSelect("volume")}
onClick={() => setCategory("local")}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive className="h-5 w-5" />
Storage Volume
</CardTitle>
<CardDescription>
A Ceph volume that can be attached to a single workload for high-performance storage
</CardDescription>
</CardHeader>
<CardContent>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Single workload attachment</li>
<li>• High performance</li>
<li>• Block storage</li>
<li>• Ceph-based storage</li>
</ul>
</CardContent>
</Card>
<Card
className="cursor-pointer border-2 hover:border-primary transition-colors"
onClick={() => handleStorageTypeSelect("local")}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Folder className="h-5 w-5" />
Host Local Folder
</CardTitle>
<CardDescription>
A folder present on a specific host that must already exist on the target machine
</CardDescription>
<CardDescription>A directory that already exists on a specific host</CardDescription>
</CardHeader>
</Card>
<Card
className="cursor-pointer border-2 hover:border-primary transition-colors"
onClick={() => setCategory("block")}
>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<HardDrive className="h-5 w-5" />
Block Storage
</CardTitle>
<CardDescription>Bootable block device — local qcow2, LVM, or Ceph</CardDescription>
</CardHeader>
<CardContent>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• Host-specific folder</li>
<li>• Must pre-exist</li>
<li>• Direct host access</li>
<li>• Single host binding</li>
</ul>
</CardContent>
</Card>
</div>
</div>
)}
{storageType && (
{/* Step 1b: block sub-type selection */}
{category === "block" && !blockType && (
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
{storageType === "shared" ? <Share className="h-5 w-5" /> :
storageType === "volume" ? <HardDrive className="h-5 w-5" /> :
<Folder className="h-5 w-5" />}
<h3 className="text-lg font-medium">
{storageType === "shared" ? "Shared Filesystem" :
storageType === "volume" ? "Storage Volume" :
"Host Local Folder"}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setStorageType("")}
className="ml-auto"
<div className="flex items-center gap-2">
<button
className="text-sm text-muted-foreground hover:text-foreground"
onClick={() => setCategory("")}
>
Change Type
</Button>
← Storage Type
</button>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium">Block Storage</span>
</div>
<p className="text-sm text-muted-foreground">Choose the backend for this block device.</p>
<div className="grid grid-cols-1 gap-3">
{BLOCK_TYPES.map((bt) => (
<Card
key={bt.value}
className="cursor-pointer border-2 hover:border-primary transition-colors"
onClick={() => setBlockType(bt.value)}
>
<CardHeader className="py-4">
<CardTitle className="text-base">{bt.label}</CardTitle>
<CardDescription>{bt.description}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)}
{/* Step 2: form */}
{showForm && (
<div className="space-y-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button className="hover:text-foreground" onClick={() => { setCategory(""); setBlockType(""); }}>
Storage Type
</button>
{category === "block" && (
<>
<ChevronRight className="h-3 w-3" />
<button className="hover:text-foreground" onClick={() => setBlockType("")}>Block Storage</button>
</>
)}
<ChevronRight className="h-3 w-3" />
<span className="text-foreground font-medium">
{category === "shared" ? "Shared Filesystem" : category === "local" ? "Host Local Folder" : BLOCK_TYPES.find(b => b.value === blockType)?.label}
</span>
</div>
{/* VDC */}
<div className="space-y-2">
<Label htmlFor="vdc">Virtual Data Center *</Label>
<Select value={formData.vdc_id} onValueChange={(value) => handleInputChange("vdc_id", value)}>
<Label>Virtual Data Center *</Label>
<Select value={formData.vdc_id} onValueChange={(v) => handleChange("vdc_id", v)}>
<SelectTrigger>
<SelectValue placeholder={vdcLoading ? "Loading..." : "Select a virtual data center"} />
<SelectValue placeholder={vdcLoading ? "Loading..." : "Select a VDC"} />
</SelectTrigger>
<SelectContent>
{virtualDataCenters.map((vdc) => (
<SelectItem key={vdc.id} value={vdc.id}>
{vdc.name}
<SelectItem key={vdc.id} value={vdc.id}>{vdc.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Name + Size */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name *</Label>
<Input
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
placeholder="my-volume"
/>
</div>
<div className="space-y-2">
<Label>Size (GB) *</Label>
<Input
type="number"
min="1"
value={formData.size_gb}
onChange={(e) => handleChange("size_gb", e.target.value)}
placeholder="20"
/>
</div>
</div>
{/* Path */}
{(category === "shared" || category === "local") && (
<div className="space-y-2">
<Label>{category === "local" ? "Host Folder Path *" : "Mount Path"}</Label>
<Input
value={formData.path}
onChange={(e) => handleChange("path", e.target.value)}
placeholder={category === "local" ? "/var/lib/data" : "/shared/data"}
/>
{category === "local" && (
<p className="text-xs text-muted-foreground">Must already exist on the target host.</p>
)}
</div>
)}
{/* Description */}
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="Optional"
rows={2}
/>
</div>
{/* Bootable + image */}
{supportsBootable && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="boot"
checked={formData.boot}
onCheckedChange={(checked) => handleChange("boot", !!checked)}
/>
<Label htmlFor="boot">Bootable volume</Label>
</div>
{formData.boot && (
<div className="space-y-2 pl-6">
<Label>
Image * <span className="text-xs text-muted-foreground">(required for boot)</span>
</Label>
<Select value={formData.image_id} onValueChange={(v) => handleChange("image_id", v)}>
<SelectTrigger>
<SelectValue placeholder="Select an image" />
</SelectTrigger>
<SelectContent>
{images.map((img) => (
<SelectItem key={img.id} value={img.id}>
{img.name || [img.os_family, img.os_version].filter(Boolean).join(" ") || img.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* Host pin */}
<div className="space-y-2">
<Label>Pin to Host <span className="text-xs text-muted-foreground">(optional)</span></Label>
<Select
value={formData.host_id || "__auto__"}
onValueChange={(v) => handleChange("host_id", v === "__auto__" ? "" : v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__auto__">Auto-schedule (recommended)</SelectItem>
{hosts.map((host) => (
<SelectItem key={host.id} value={host.id}>
{host.name || host.hostname || host.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="Enter volume name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="size">Size (GB) *</Label>
<Input
id="size"
type="number"
value={formData.size_gb}
onChange={(e) => handleInputChange("size_gb", e.target.value)}
placeholder="Enter size in GB"
min="1"
/>
</div>
</div>
{storageType === "shared" && (
<div className="space-y-2">
<Label htmlFor="path">Mount Path</Label>
<Input
id="path"
value={formData.path}
onChange={(e) => handleInputChange("path", e.target.value)}
placeholder="/shared/data"
/>
</div>
)}
{storageType === "local" && (
<div className="space-y-2">
<Label htmlFor="path">Host Folder Path *</Label>
<Input
id="path"
value={formData.path}
onChange={(e) => handleInputChange("path", e.target.value)}
placeholder="/var/lib/data"
/>
<p className="text-sm text-muted-foreground">
This folder must already exist on the target host. Container/VM launch will fail if the folder doesn't exist.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="Enter volume description"
/>
</div>
{storageType === "volume" && (
<div className="flex items-center space-x-2">
<Checkbox
id="boot"
checked={formData.boot}
onCheckedChange={(checked) => handleInputChange("boot", checked as boolean)}
/>
<Label htmlFor="boot">Bootable volume</Label>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => handleOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={isLoading}>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create Volume
@@ -307,4 +356,4 @@ export const CreateVolumeDialog = ({ isOpen, onOpenChange, onVolumeCreated }: Cr
</DialogContent>
</Dialog>
);
};
};
+487 -129
View File
@@ -16,10 +16,19 @@ import {
MoreVertical,
AlertCircle,
Network,
Loader2,
Trash2,
Search,
Share,
Folder,
} from "lucide-react";
import { PodCard } from "./PodCard";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DeleteConfirmationDialog } from "./DeleteConfirmationDialog";
import { CreateVolumeDialog } from "./CreateVolumeDialog";
import { PodCard } from "./PodCard";
import { AuditLogsViewer } from "./shared/AuditLogsViewer";
import { ContainerDetails } from "./ContainerDetails";
import {
@@ -97,6 +106,211 @@ export const InfrastructurePage = () => {
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);
const [volToDelete, setVolToDelete] = useState<any>(null);
const [volDeleting, setVolDeleting] = useState(false);
const [volEditOpen, setVolEditOpen] = useState(false);
const [volToEdit, setVolToEdit] = useState<any>(null);
const [volEditName, setVolEditName] = useState("");
const [volEditDesc, setVolEditDesc] = useState("");
const [volEditSize, setVolEditSize] = useState("");
const [volEditing, setVolEditing] = useState(false);
const [volAttachOpen, setVolAttachOpen] = useState(false);
const [volToAttach, setVolToAttach] = useState<any>(null);
const [attachWorkloads, setAttachWorkloads] = useState<any[]>([]);
const [attachWorkloadsLoading, setAttachWorkloadsLoading] = useState(false);
const [attachWorkloadId, setAttachWorkloadId] = useState("");
const [attaching, setAttaching] = useState(false);
const [volDetachOpen, setVolDetachOpen] = useState(false);
const [volToDetach, setVolToDetach] = useState<any>(null);
const [detachWorkloads, setDetachWorkloads] = useState<any[]>([]);
const [detachWorkloadsLoading, setDetachWorkloadsLoading] = useState(false);
const [selectedDetachWorkloadId, setSelectedDetachWorkloadId] = useState("");
const [isDetaching, setIsDetaching] = useState(false);
const [volWorkloadsOpen, setVolWorkloadsOpen] = useState(false);
const [volWorkloads, setVolWorkloads] = useState<any[]>([]);
const [volWorkloadsLoading, setVolWorkloadsLoading] = useState(false);
const [volWorkloadsName, setVolWorkloadsName] = useState("");
const fetchVolumes = async () => {
setVolumesLoading(true);
try {
const res = await xcloudifyApi.getVolumes();
setVolumes(res.data?.volumes || []);
} catch {
toast({ title: "Failed to fetch volumes", variant: "destructive" });
} finally {
setVolumesLoading(false);
}
};
useEffect(() => {
fetchVolumes();
}, []);
const openVolEdit = (vol: any) => {
setVolToEdit(vol);
setVolEditName(vol.name || "");
setVolEditDesc(vol.description || "");
setVolEditSize(String(vol.size_gb));
setVolEditOpen(true);
};
const handleVolEdit = async () => {
if (!volToEdit) return;
const size = parseFloat(volEditSize);
if (!size || size <= 0) { toast({ title: "Enter a valid size", variant: "destructive" }); return; }
if (size < volToEdit.size_gb) { toast({ title: "Shrinking not supported — new size must be larger", variant: "destructive" }); return; }
if (!volEditName.trim()) { toast({ title: "Name cannot be empty", variant: "destructive" }); return; }
setVolEditing(true);
try {
const payload: Record<string, any> = { size_gb: size };
if (volEditName !== volToEdit.name) payload.name = volEditName;
if (volEditDesc !== (volToEdit.description || "")) payload.description = volEditDesc;
const res = await xcloudifyApi.updateVolume(volToEdit.id, payload);
toast({ title: res?.data?.request_id ? "Volume resize queued" : "Volume updated" });
fetchVolumes();
setVolEditOpen(false);
} catch (e: any) {
toast({ title: e.message || "Failed to update volume", variant: "destructive" });
} finally {
setVolEditing(false);
}
};
const handleVolDelete = async () => {
if (!volToDelete) return;
setVolDeleting(true);
try {
await xcloudifyApi.deleteVolume(volToDelete.id);
toast({ title: "Volume deletion queued" });
fetchVolumes();
} catch (e: any) {
toast({ title: e.message || "Failed to delete volume", variant: "destructive" });
} finally {
setVolDeleting(false);
setVolDeleteOpen(false);
setVolToDelete(null);
}
};
const openVolAttach = async (vol: any) => {
setVolToAttach(vol);
setAttachWorkloadId("");
setAttachWorkloads([]);
setVolAttachOpen(true);
if (vol.vdc_id) {
setAttachWorkloadsLoading(true);
try {
const res = await xcloudifyApi.getWorkloadsForVirtualDataCenter(vol.vdc_id);
const wls = res.data?.workloads || res.data || [];
setAttachWorkloads(Array.isArray(wls) ? wls : []);
} catch {
toast({ title: "Failed to load workloads", variant: "destructive" });
} finally {
setAttachWorkloadsLoading(false);
}
}
};
const handleVolAttach = async () => {
if (!volToAttach || !attachWorkloadId) { toast({ title: "Select a workload", variant: "destructive" }); return; }
setAttaching(true);
try {
await xcloudifyApi.attachVolumeToWorkload(volToAttach.id, attachWorkloadId);
toast({ title: "Volume attach queued — hot-plug in progress" });
setVolAttachOpen(false);
} catch (e: any) {
toast({ title: e.message || "Failed to attach volume", variant: "destructive" });
} finally {
setAttaching(false);
}
};
const openVolDetach = async (vol: any) => {
setVolToDetach(vol);
setSelectedDetachWorkloadId("");
setDetachWorkloads([]);
setVolDetachOpen(true);
setDetachWorkloadsLoading(true);
try {
const res = await xcloudifyApi.getWorkloadsForVolume(vol.id);
setDetachWorkloads(res.data?.workloads || []);
} catch {
toast({ title: "Failed to load attached workloads", variant: "destructive" });
} finally {
setDetachWorkloadsLoading(false);
}
};
const handleVolDetach = async () => {
if (!volToDetach || !selectedDetachWorkloadId) {
toast({ title: "Select a workload to detach from", variant: "destructive" });
return;
}
setIsDetaching(true);
try {
await xcloudifyApi.detachVolumeFromWorkload(volToDetach.id, selectedDetachWorkloadId);
toast({ title: "Volume detach queued — hot-unplug in progress" });
setVolDetachOpen(false);
fetchVolumes();
} catch (e: any) {
toast({ title: e.message || "Failed to detach volume", variant: "destructive" });
} finally {
setIsDetaching(false);
}
};
const openVolWorkloads = async (vol: any) => {
setVolWorkloadsName(vol.name);
setVolWorkloads([]);
setVolWorkloadsOpen(true);
setVolWorkloadsLoading(true);
try {
const res = await xcloudifyApi.getWorkloadsForVolume(vol.id);
setVolWorkloads(res.data?.workloads || []);
} catch {
toast({ title: "Failed to load attached workloads", variant: "destructive" });
} finally {
setVolWorkloadsLoading(false);
}
};
const getVolStatusBadge = (status: string) => {
switch (status) {
case "available": return <Badge className="bg-green-100 text-green-800 border-green-200">Available</Badge>;
case "in-use": return <Badge className="bg-blue-100 text-blue-800 border-blue-200">In Use</Badge>;
case "creating": return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">Creating</Badge>;
case "pending-scheduling": return <Badge className="bg-orange-100 text-orange-800 border-orange-200"><Loader2 className="h-3 w-3 mr-1 animate-spin inline" />Scheduling</Badge>;
case "pending-resize": return <Badge className="bg-purple-100 text-purple-800 border-purple-200"><Loader2 className="h-3 w-3 mr-1 animate-spin inline" />Resizing</Badge>;
case "error": return <Badge variant="destructive">Error</Badge>;
default: return <Badge variant="secondary">{status || "Unknown"}</Badge>;
}
};
const getVolTypeIcon = (type: string) => {
switch (type) {
case "nfs": return <Share className="h-4 w-4" />;
case "local": return <Folder className="h-4 w-4" />;
default: return <HardDrive className="h-4 w-4" />;
}
};
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
@@ -367,44 +581,6 @@ export const InfrastructurePage = () => {
// Pods are now fetched from the API hook
const parseStorageToGB = (value: string): number => {
const num = parseFloat(value);
if (isNaN(num)) return 0;
const upper = value.toUpperCase();
if (upper.includes("TB")) return num * 1024;
if (upper.includes("PB")) return num * 1024 * 1024;
return num; // assume GB
};
const storage = [
{
id: "vol-data-1",
name: "ml-datasets",
type: "SSD",
size: "500GB",
used: "312GB",
mountPoint: "/data",
status: "Healthy",
},
{
id: "vol-models",
name: "ml-models",
type: "SSD",
size: "1TB",
used: "750GB",
mountPoint: "/models",
status: "Healthy",
},
{
id: "vol-backup",
name: "backup-storage",
type: "HDD",
size: "2TB",
used: "1.2TB",
mountPoint: "/backups",
status: "Healthy",
},
];
const clusters = [
{
@@ -1001,97 +1177,279 @@ export const InfrastructurePage = () => {
</TabsContent>
<TabsContent value="storage" className="mt-6">
<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">
<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">Used</TableHead>
<TableHead className="text-gray-600 hidden md:table-cell">Mount Point</TableHead>
<TableHead className="text-gray-600">Status</TableHead>
<TableHead className="text-gray-600 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storage.map((volume) => (
<TableRow
key={volume.id}
className="border-gray-200 hover:bg-gray-50"
>
<TableCell className="font-medium text-gray-900">
{volume.name}
</TableCell>
<TableCell>{volume.type}</TableCell>
<TableCell>{volume.size}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-gray-200 rounded-full flex-shrink-0 overflow-hidden">
<div
className="h-full rounded-full bg-blue-500"
style={{
width: `${Math.min(
(parseStorageToGB(volume.used) /
parseStorageToGB(volume.size)) *
100,
100
)}%`,
}}
/>
</div>
<span className="text-xs whitespace-nowrap">{volume.used}</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell">{volume.mountPoint}</TableCell>
<TableCell>
<Badge
variant="outline"
className="border-green-500 text-green-500"
>
<span className="h-1.5 w-1.5 rounded-full mr-1 bg-green-500" />
{volume.status}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
<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>
<Button onClick={() => setCreateVolumeOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Volume
</Button>
</div>
<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`)}
>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-white border-gray-200 text-gray-900"
>
<DropdownMenuItem className="cursor-pointer">
Details
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
Snapshots
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer">
Expand
</DropdownMenuItem>
<DropdownMenuItem className="cursor-pointer text-red-500">
Unmount
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{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 */}
<CreateVolumeDialog
isOpen={createVolumeOpen}
onOpenChange={setCreateVolumeOpen}
onVolumeCreated={fetchVolumes}
/>
{/* Delete */}
<DeleteConfirmationDialog
open={volDeleteOpen}
onOpenChange={setVolDeleteOpen}
onConfirm={handleVolDelete}
title="Delete Volume"
description={`Are you sure you want to delete "${volToDelete?.name}"? The backend volume will be removed from its host worker. This action cannot be undone.`}
itemName={volToDelete?.name || ""}
itemType="volume"
/>
{/* Edit / Resize */}
<Dialog open={volEditOpen} onOpenChange={setVolEditOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>Edit Volume</DialogTitle></DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Name</Label>
<Input value={volEditName} onChange={(e) => setVolEditName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={volEditDesc} onChange={(e) => setVolEditDesc(e.target.value)} placeholder="Optional" />
</div>
<div className="space-y-2">
<Label>Size (GB)</Label>
<Input type="number" min="1" value={volEditSize} onChange={(e) => setVolEditSize(e.target.value)} />
{volToEdit && (
<p className="text-xs text-muted-foreground">Current: {volToEdit.size_gb} GB — grow only</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setVolEditOpen(false)}>Cancel</Button>
<Button onClick={handleVolEdit} disabled={volEditing}>
{volEditing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Detach from Workload */}
<Dialog open={volDetachOpen} onOpenChange={setVolDetachOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>Detach from Workload</DialogTitle></DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">Volume: <span className="font-medium text-foreground">{volToDetach?.name}</span></p>
<div className="space-y-2">
<Label>Select Workload to Detach From</Label>
{detachWorkloadsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />Loading workloads...
</div>
) : detachWorkloads.length === 0 ? (
<p className="text-sm text-muted-foreground">No attached workloads found.</p>
) : (
<Select value={selectedDetachWorkloadId} onValueChange={setSelectedDetachWorkloadId}>
<SelectTrigger><SelectValue placeholder="Select a workload" /></SelectTrigger>
<SelectContent>
{detachWorkloads.map((wl) => (
<SelectItem key={wl.id} value={wl.id}>
{wl.name}{wl.workload_type ? ` (${wl.workload_type})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setVolDetachOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={handleVolDetach} disabled={isDetaching || !selectedDetachWorkloadId || detachWorkloadsLoading}>
{isDetaching && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Detach
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Attach to Workload */}
<Dialog open={volAttachOpen} onOpenChange={setVolAttachOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>Attach to Workload</DialogTitle></DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">Volume: <span className="font-medium text-foreground">{volToAttach?.name}</span></p>
<p className="text-xs text-muted-foreground">Only VirtualMachine workloads are supported. For local/LVM volumes, the VM must be on the same host.</p>
<div className="space-y-2">
<Label>Select VM</Label>
{attachWorkloadsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />Loading workloads...
</div>
) : attachWorkloads.length === 0 ? (
<p className="text-sm text-muted-foreground">No workloads found in this VDC.</p>
) : (
<Select value={attachWorkloadId} onValueChange={setAttachWorkloadId}>
<SelectTrigger><SelectValue placeholder="Select a workload" /></SelectTrigger>
<SelectContent>
{attachWorkloads.map((wl) => (
<SelectItem key={wl.id} value={wl.id}>
{wl.name}{wl.workload_type ? ` (${wl.workload_type})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setVolAttachOpen(false)}>Cancel</Button>
<Button onClick={handleVolAttach} disabled={attaching || !attachWorkloadId || attachWorkloadsLoading}>
{attaching && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Attach
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Attached Workloads */}
<Dialog open={volWorkloadsOpen} onOpenChange={setVolWorkloadsOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>Attached Workloads — {volWorkloadsName}</DialogTitle></DialogHeader>
<div className="py-2">
{volWorkloadsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
<Loader2 className="h-4 w-4 animate-spin" />Loading...
</div>
) : volWorkloads.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No workloads attached to this volume.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{volWorkloads.map((wl) => (
<TableRow key={wl.id}>
<TableCell className="font-medium">{wl.name}</TableCell>
<TableCell><Badge variant="outline">{wl.workload_type || "—"}</Badge></TableCell>
<TableCell><Badge variant="secondary">{wl.status || "—"}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setVolWorkloadsOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
<TabsContent value="networks" className="mt-6">
+582 -216
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from "react";
import { 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";
import { Badge } from "@/components/ui/badge";
@@ -6,49 +7,83 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
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";
import { toast } from "sonner";
import { xcloudifyApi } from "@/services/xcloudifyApi";
import {
HardDrive,
FolderPlus,
Upload,
Download,
Trash2,
import {
HardDrive,
FolderPlus,
Trash2,
Search,
MoreHorizontal,
Database,
Archive,
Cloud,
Share,
Loader2
Loader2,
Folder,
} from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
export const StoragePage = () => {
const navigate = useNavigate();
const { virtualDataCenters } = useVirtualDataCenters();
const [searchQuery, setSearchQuery] = useState("");
const [volumes, setVolumes] = useState<any[]>([]);
const [imageMap, setImageMap] = useState<Record<string, any>>({});
const [isLoading, setIsLoading] = useState(true);
const [createVolumeOpen, setCreateVolumeOpen] = useState(false);
// Delete state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [volumeToDelete, setVolumeToDelete] = useState<any>(null);
const [isDeleting, setIsDeleting] = useState(false);
const storageStats = {
total: 5000, // GB
used: 2150,
free: 2850,
volumes: volumes.length,
buckets: 8
};
// Resize/edit state
const [resizeDialogOpen, setResizeDialogOpen] = useState(false);
const [volumeToResize, setVolumeToResize] = useState<any>(null);
const [newSizeGb, setNewSizeGb] = useState("");
const [editName, setEditName] = useState("");
const [editDescription, setEditDescription] = useState("");
const [isResizing, setIsResizing] = useState(false);
// Attached workloads state
const [workloadsDialogOpen, setWorkloadsDialogOpen] = useState(false);
const [workloadsForVolume, setWorkloadsForVolume] = useState<any[]>([]);
const [workloadsForVolumeLoading, setWorkloadsForVolumeLoading] = useState(false);
const [workloadsVolumeName, setWorkloadsVolumeName] = useState("");
// Attach state
const [attachDialogOpen, setAttachDialogOpen] = useState(false);
const [volumeToAttach, setVolumeToAttach] = useState<any>(null);
const [availableWorkloads, setAvailableWorkloads] = useState<any[]>([]);
const [workloadsLoading, setWorkloadsLoading] = useState(false);
const [selectedWorkloadId, setSelectedWorkloadId] = useState("");
const [isAttaching, setIsAttaching] = useState(false);
// Detach state
const [detachDialogOpen, setDetachDialogOpen] = useState(false);
const [volumeToDetach, setVolumeToDetach] = useState<any>(null);
const [detachWorkloads, setDetachWorkloads] = useState<any[]>([]);
const [detachWorkloadsLoading, setDetachWorkloadsLoading] = useState(false);
const [selectedDetachWorkloadId, setSelectedDetachWorkloadId] = useState("");
const [isDetaching, setIsDetaching] = useState(false);
const fetchVolumes = async () => {
try {
setIsLoading(true);
const response = await xcloudifyApi.getVolumes();
setVolumes(response.data?.volumes || []);
const [volumeResponse, images] = await Promise.all([
xcloudifyApi.getVolumes(),
xcloudifyApi.getImages(),
]);
setVolumes(volumeResponse.data?.volumes || []);
const map: Record<string, any> = {};
for (const img of images) map[img.id] = img;
setImageMap(map);
} catch (error: any) {
console.error("Error fetching volumes:", error);
toast.error("Failed to fetch volumes");
@@ -61,28 +96,197 @@ export const StoragePage = () => {
fetchVolumes();
}, []);
// Compute stats from real data
const totalAllocatedGb = useMemo(
() => volumes.reduce((sum, v) => sum + (v.size_gb || 0), 0),
[volumes]
);
const bootVolumeCount = useMemo(() => volumes.filter((v) => v.boot).length, [volumes]);
const typeBreakdown = useMemo(() => {
const breakdown: Record<string, number> = {};
for (const v of volumes) {
const t = v.type || "unknown";
breakdown[t] = (breakdown[t] || 0) + (v.size_gb || 0);
}
return breakdown;
}, [volumes]);
// Delete
const handleDeleteVolume = async () => {
if (!volumeToDelete) return;
setIsDeleting(true);
try {
await xcloudifyApi.deleteVolume(volumeToDelete.id);
toast.success("Volume deleted successfully!");
toast.success("Volume deletion queued — removing from backend worker");
fetchVolumes();
} catch (error: any) {
console.error("Error deleting volume:", error);
toast.error(error.message || "Failed to delete volume");
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
setVolumeToDelete(null);
}
};
// View attached workloads
const openWorkloadsDialog = async (volume: any) => {
setWorkloadsVolumeName(volume.name);
setWorkloadsForVolume([]);
setWorkloadsDialogOpen(true);
setWorkloadsForVolumeLoading(true);
try {
const response = await xcloudifyApi.getWorkloadsForVolume(volume.id);
setWorkloadsForVolume(response.data?.workloads || []);
} catch (error: any) {
toast.error("Failed to load attached workloads");
} finally {
setWorkloadsForVolumeLoading(false);
}
};
// Resize
const openResizeDialog = (volume: any) => {
setVolumeToResize(volume);
setNewSizeGb(String(volume.size_gb));
setEditName(volume.name || "");
setEditDescription(volume.description || "");
setResizeDialogOpen(true);
};
const handleResizeVolume = async () => {
if (!volumeToResize) return;
const size = parseFloat(newSizeGb);
if (!size || size <= 0) {
toast.error("Enter a valid size greater than 0");
return;
}
if (size < volumeToResize.size_gb) {
toast.error("Shrinking volumes is not supported — new size must be larger than current");
return;
}
if (!editName.trim()) {
toast.error("Name cannot be empty");
return;
}
setIsResizing(true);
try {
const payload: Record<string, any> = { size_gb: size };
if (editName !== volumeToResize.name) payload.name = editName;
if (editDescription !== (volumeToResize.description || "")) payload.description = editDescription;
const response = await xcloudifyApi.updateVolume(volumeToResize.id, payload);
if (response?.data?.request_id) {
toast.success("Volume resize queued — provisioning in background");
} else {
toast.success("Volume updated successfully");
}
fetchVolumes();
setResizeDialogOpen(false);
} catch (error: any) {
toast.error(error.message || "Failed to update volume");
} finally {
setIsResizing(false);
}
};
// Attach to workload
const openAttachDialog = async (volume: any) => {
setVolumeToAttach(volume);
setSelectedWorkloadId("");
setAvailableWorkloads([]);
setAttachDialogOpen(true);
if (volume.vdc_id) {
setWorkloadsLoading(true);
try {
const response = await xcloudifyApi.getWorkloadsForVirtualDataCenter(volume.vdc_id);
const workloads = response.data?.workloads || response.data || [];
setAvailableWorkloads(Array.isArray(workloads) ? workloads : []);
} catch (error: any) {
toast.error("Failed to load workloads");
} finally {
setWorkloadsLoading(false);
}
}
};
const handleAttachVolume = async () => {
if (!volumeToAttach || !selectedWorkloadId) {
toast.error("Select a workload to attach to");
return;
}
setIsAttaching(true);
try {
await xcloudifyApi.attachVolumeToWorkload(volumeToAttach.id, selectedWorkloadId);
toast.success("Volume attach queued — hot-plug in progress");
setAttachDialogOpen(false);
} catch (error: any) {
toast.error(error.message || "Failed to attach volume");
} finally {
setIsAttaching(false);
}
};
const openDetachDialog = async (volume: any) => {
setVolumeToDetach(volume);
setSelectedDetachWorkloadId("");
setDetachWorkloads([]);
setDetachDialogOpen(true);
setDetachWorkloadsLoading(true);
try {
const res = await xcloudifyApi.getWorkloadsForVolume(volume.id);
setDetachWorkloads(res.data?.workloads || []);
} catch {
toast.error("Failed to load attached workloads");
} finally {
setDetachWorkloadsLoading(false);
}
};
const handleDetachVolume = async () => {
if (!volumeToDetach || !selectedDetachWorkloadId) {
toast.error("Select a workload to detach from");
return;
}
setIsDetaching(true);
try {
await xcloudifyApi.detachVolumeFromWorkload(volumeToDetach.id, selectedDetachWorkloadId);
toast.success("Volume detach queued — hot-unplug in progress");
setDetachDialogOpen(false);
fetchVolumes();
} catch (error: any) {
toast.error(error.message || "Failed to detach volume");
} finally {
setIsDetaching(false);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case "available":
return <Badge className="bg-green-100 text-green-800 border-green-200">Available</Badge>;
case "in-use":
return <Badge className="bg-blue-100 text-blue-800 border-blue-200">In Use</Badge>;
case "creating":
return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">Creating</Badge>;
case "pending-scheduling":
return <Badge className="bg-orange-100 text-orange-800 border-orange-200"><Loader2 className="h-3 w-3 mr-1 animate-spin inline" />Scheduling</Badge>;
case "pending-resize":
return <Badge className="bg-purple-100 text-purple-800 border-purple-200"><Loader2 className="h-3 w-3 mr-1 animate-spin inline" />Resizing</Badge>;
case "error":
return <Badge variant="destructive">Error</Badge>;
default:
return <Badge variant="secondary">{status || "Unknown"}</Badge>;
}
};
const getStorageTypeIcon = (type: string) => {
switch (type) {
case 'nfs':
case "nfs":
return <Share className="h-4 w-4" />;
case 'ceph':
case "ceph":
return <HardDrive className="h-4 w-4" />;
case "local":
return <Folder className="h-4 w-4" />;
default:
return <Database className="h-4 w-4" />;
}
@@ -90,43 +294,38 @@ export const StoragePage = () => {
const getStorageTypeLabel = (type: string) => {
switch (type) {
case 'nfs':
return 'Shared Filesystem';
case 'ceph':
return 'Storage Volume';
case "nfs":
return "Shared Filesystem";
case "ceph":
return "Storage Volume";
case "local":
return "Host Local";
default:
return type?.toUpperCase() || 'Unknown';
return type?.toUpperCase() || "Unknown";
}
};
const filteredVolumes = volumes.filter(volume =>
volume.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
volume.description?.toLowerCase().includes(searchQuery.toLowerCase())
const filteredVolumes = volumes.filter(
(volume) =>
volume.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
volume.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
// Group volumes by VDC
const groupedVolumes = filteredVolumes.reduce((groups: { [key: string]: any[] }, volume) => {
const vdcId = volume.vdc_id || 'unknown';
if (!groups[vdcId]) {
groups[vdcId] = [];
}
const vdcId = volume.vdc_id || "unknown";
if (!groups[vdcId]) groups[vdcId] = [];
groups[vdcId].push(volume);
return groups;
}, {});
// Get VDC name by ID
const getVdcName = (vdcId: string) => {
if (vdcId === 'unknown') return 'Unknown VDC';
const vdc = virtualDataCenters.find(v => v.id === vdcId);
if (vdcId === "unknown") return "Unknown VDC";
const vdc = virtualDataCenters.find((v) => v.id === vdcId);
return vdc ? vdc.name : `VDC ${vdcId.slice(0, 8)}...`;
};
const files = [
{ name: "training-data.tar.gz", size: "45.2 GB", modified: "2 hours ago", type: "archive" },
{ name: "model-v2.pth", size: "2.8 GB", modified: "5 hours ago", type: "model" },
{ name: "inference-logs/", size: "156 MB", modified: "1 day ago", type: "folder" },
{ name: "config.yaml", size: "2.4 KB", modified: "3 days ago", type: "config" }
];
const formatGb = (gb: number) =>
gb >= 1024 ? `${(gb / 1024).toFixed(1)} TB` : `${gb.toFixed(0)} GB`;
return (
<div className="space-y-6">
@@ -141,16 +340,18 @@ export const StoragePage = () => {
</Button>
</div>
{/* Storage Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Stats - computed from real volume data */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
<CardTitle className="text-sm font-medium">Total Allocated</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">2.1 TB</div>
<p className="text-xs text-muted-foreground">of 5 TB used</p>
<div className="text-2xl font-bold">
{isLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : formatGb(totalAllocatedGb)}
</div>
<p className="text-xs text-muted-foreground">across {volumes.length} volumes</p>
</CardContent>
</Card>
@@ -160,82 +361,57 @@ export const StoragePage = () => {
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{storageStats.volumes}</div>
<p className="text-xs text-muted-foreground">8 mounted</p>
<div className="text-2xl font-bold">
{isLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : volumes.length}
</div>
<p className="text-xs text-muted-foreground">{bootVolumeCount} bootable</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Object Buckets</CardTitle>
<CardTitle className="text-sm font-medium">Object Storage</CardTitle>
<Cloud className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{storageStats.buckets}</div>
<p className="text-xs text-muted-foreground">Public & private</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Backup Size</CardTitle>
<Archive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">850 GB</div>
<p className="text-xs text-muted-foreground">Last backup: 2h ago</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Monthly Cost</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">$156</div>
<p className="text-xs text-muted-foreground">$0.073/GB</p>
<div className="text-2xl font-bold text-muted-foreground">—</div>
<p className="text-xs text-muted-foreground">Not available</p>
</CardContent>
</Card>
</div>
{/* Storage Usage */}
<Card>
<CardHeader>
<CardTitle>Storage Usage</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between">
<span>Used Storage</span>
<span>{storageStats.used} GB / {storageStats.total} GB</span>
</div>
<Progress value={(storageStats.used / storageStats.total) * 100} className="h-3" />
{!isLoading && volumes.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Storage Breakdown by Type</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{Object.entries(typeBreakdown).map(([type, gb]) => (
<div key={type} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="flex items-center gap-2">
{getStorageTypeIcon(type)}
{getStorageTypeLabel(type)}
</span>
<span>{formatGb(gb)}</span>
</div>
<Progress
value={totalAllocatedGb > 0 ? (gb / totalAllocatedGb) * 100 : 0}
className="h-2"
/>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div className="text-center">
<div className="text-lg font-semibold text-blue-600">1.2 TB</div>
<div className="text-muted-foreground">Datasets</div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-green-600">650 GB</div>
<div className="text-muted-foreground">Models</div>
</div>
<div className="text-center">
<div className="text-lg font-semibold text-orange-600">250 GB</div>
<div className="text-muted-foreground">Logs & Cache</div>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
)}
<Tabs defaultValue="volumes" className="space-y-4">
<TabsList>
<TabsTrigger value="volumes">Persistent Volumes</TabsTrigger>
<TabsTrigger value="buckets">Object Storage</TabsTrigger>
<TabsTrigger value="files">File Browser</TabsTrigger>
</TabsList>
<TabsContent value="volumes" className="space-y-4">
@@ -279,68 +455,107 @@ export const StoragePage = () => {
{groupedVolumes[vdcId].length} volumes
</Badge>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Size</TableHead>
<TableHead>Status</TableHead>
<TableHead>Boot / Image</TableHead>
<TableHead>Description</TableHead>
<TableHead>Path</TableHead>
<TableHead>Boot</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupedVolumes[vdcId].map((volume) => (
<TableRow key={volume.id}>
<TableCell className="font-medium">{volume.name}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getStorageTypeIcon(volume.type)}
<Badge variant="outline">
{getStorageTypeLabel(volume.type)}
</Badge>
</div>
</TableCell>
<TableCell>{volume.size_gb} GB</TableCell>
<TableCell className="max-w-[200px] truncate">
{volume.description || "-"}
</TableCell>
<TableCell className="font-mono text-sm">
{volume.path || "-"}
</TableCell>
<TableCell>
<Badge variant={volume.boot ? 'default' : 'secondary'}>
{volume.boot ? 'Yes' : 'No'}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Attach to Workload</DropdownMenuItem>
<DropdownMenuItem>Resize</DropdownMenuItem>
<DropdownMenuItem>Clone</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => {
setVolumeToDelete(volume);
setDeleteDialogOpen(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{groupedVolumes[vdcId].map((volume) => {
const linkedImage = volume.image_id ? imageMap[volume.image_id] : null;
const imageName =
linkedImage?.name ||
[linkedImage?.os_family, linkedImage?.os_version]
.filter(Boolean)
.join(" ") ||
linkedImage?.format ||
null;
return (
<TableRow key={volume.id}>
<TableCell className="font-medium">
<button
className="hover:underline text-left text-primary font-medium"
onClick={() => navigate(`/storage/${volume.id}/details`)}
>
{volume.name}
</button>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{getStorageTypeIcon(volume.type)}
<Badge variant="outline">{getStorageTypeLabel(volume.type)}</Badge>
</div>
</TableCell>
<TableCell>{formatGb(volume.size_gb)}</TableCell>
<TableCell>{getStatusBadge(volume.status)}</TableCell>
<TableCell>
{volume.boot ? (
<div className="space-y-1">
<Badge variant="default">Boot</Badge>
{imageName && (
<div className="text-xs text-muted-foreground truncate max-w-[140px]">
{imageName}
</div>
)}
</div>
) : (
<Badge variant="secondary">No</Badge>
)}
</TableCell>
<TableCell className="max-w-[160px] truncate">
{volume.description || "—"}
</TableCell>
<TableCell className="font-mono text-sm">
{volume.path || "—"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => openWorkloadsDialog(volume)}>
View Attached Workloads
</DropdownMenuItem>
{volume.status === "in-use" ? (
<DropdownMenuItem onClick={() => openDetachDialog(volume)}>
Detach from Workload
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => openAttachDialog(volume)}>
Attach to Workload
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => openResizeDialog(volume)}>
Edit / Resize
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => {
setVolumeToDelete(volume);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
@@ -359,83 +574,234 @@ export const StoragePage = () => {
<CardContent>
<div className="text-center py-8 text-muted-foreground">
<Cloud className="h-12 w-12 mx-auto mb-4" />
<p>No object storage buckets found</p>
<Button className="mt-4">Create Bucket</Button>
<p className="font-medium">Object storage is not available</p>
<p className="text-sm mt-1">This feature is not yet supported in your environment.</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="files" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>File Browser</CardTitle>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
<Button variant="outline">
<Upload className="h-4 w-4 mr-2" />
Upload
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Size</TableHead>
<TableHead>Modified</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file, index) => (
<TableRow key={index}>
<TableCell className="font-medium">{file.name}</TableCell>
<TableCell>{file.size}</TableCell>
<TableCell>{file.modified}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm">
<Download className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Create Volume Dialog */}
<CreateVolumeDialog
isOpen={createVolumeOpen}
onOpenChange={setCreateVolumeOpen}
onVolumeCreated={fetchVolumes}
/>
{/* Delete Confirmation */}
<DeleteConfirmationDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteVolume}
title="Delete Volume"
description={`Are you sure you want to delete the volume "${volumeToDelete?.name}"? This action cannot be undone.`}
description={`Are you sure you want to delete "${volumeToDelete?.name}"? The backend volume will be removed from its host worker. This action cannot be undone.`}
itemName={volumeToDelete?.name || ""}
itemType="container"
itemType="volume"
/>
{/* Attached Workloads Dialog */}
<Dialog open={workloadsDialogOpen} onOpenChange={setWorkloadsDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Attached Workloads — {workloadsVolumeName}</DialogTitle>
</DialogHeader>
<div className="py-2">
{workloadsForVolumeLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4">
<Loader2 className="h-4 w-4 animate-spin" />
Loading workloads...
</div>
) : workloadsForVolume.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No workloads are attached to this volume.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workloadsForVolume.map((wl) => (
<TableRow key={wl.id}>
<TableCell className="font-medium">{wl.name}</TableCell>
<TableCell>
<Badge variant="outline">{wl.workload_type || "—"}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">{wl.status || "—"}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWorkloadsDialogOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit / Resize Dialog */}
<Dialog open={resizeDialogOpen} onOpenChange={setResizeDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Edit Volume</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="edit-name">Name</Label>
<Input
id="edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Volume name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Input
id="edit-description"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="space-y-2">
<Label htmlFor="resize-size">Size (GB)</Label>
<Input
id="resize-size"
type="number"
min="1"
value={newSizeGb}
onChange={(e) => setNewSizeGb(e.target.value)}
placeholder="Enter size in GB"
/>
{volumeToResize && (
<p className="text-xs text-muted-foreground">
Current: {volumeToResize.size_gb} GB — grow only, shrink not supported
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResizeDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleResizeVolume} disabled={isResizing}>
{isResizing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Attach to Workload Dialog */}
<Dialog open={attachDialogOpen} onOpenChange={setAttachDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Attach to Workload</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">
Volume: <span className="font-medium text-foreground">{volumeToAttach?.name}</span>
</p>
<p className="text-xs text-muted-foreground">
Only VirtualMachine workloads are supported. For local/LVM volumes, the VM must be on the same host.
</p>
<div className="space-y-2">
<Label>Select VM</Label>
{workloadsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading workloads...
</div>
) : availableWorkloads.length === 0 ? (
<p className="text-sm text-muted-foreground">No workloads found in this VDC.</p>
) : (
<Select value={selectedWorkloadId} onValueChange={setSelectedWorkloadId}>
<SelectTrigger>
<SelectValue placeholder="Select a workload" />
</SelectTrigger>
<SelectContent>
{availableWorkloads.map((wl) => (
<SelectItem key={wl.id} value={wl.id}>
{wl.name}{wl.workload_type ? ` (${wl.workload_type})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAttachDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleAttachVolume}
disabled={isAttaching || !selectedWorkloadId || workloadsLoading}
>
{isAttaching && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Attach
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Detach from Workload Dialog */}
<Dialog open={detachDialogOpen} onOpenChange={setDetachDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Detach from Workload</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">
Volume: <span className="font-medium text-foreground">{volumeToDetach?.name}</span>
</p>
<div className="space-y-2">
<Label>Select Workload to Detach From</Label>
{detachWorkloadsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading workloads...
</div>
) : detachWorkloads.length === 0 ? (
<p className="text-sm text-muted-foreground">No attached workloads found.</p>
) : (
<Select value={selectedDetachWorkloadId} onValueChange={setSelectedDetachWorkloadId}>
<SelectTrigger>
<SelectValue placeholder="Select a workload" />
</SelectTrigger>
<SelectContent>
{detachWorkloads.map((wl) => (
<SelectItem key={wl.id} value={wl.id}>
{wl.name}{wl.workload_type ? ` (${wl.workload_type})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDetachDialogOpen(false)}>Cancel</Button>
<Button
variant="destructive"
onClick={handleDetachVolume}
disabled={isDetaching || !selectedDetachWorkloadId || detachWorkloadsLoading}
>
{isDetaching && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Detach
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
};
+8 -1
View File
@@ -696,7 +696,14 @@ export const VirtualMachineDetailsPage: React.FC<VirtualMachineDetailsPageProps>
<TableBody>
{volumes.map((vol: any) => (
<TableRow key={vol.id}>
<TableCell className="font-medium">{vol.name}</TableCell>
<TableCell className="font-medium">
<button
className="hover:underline text-left text-primary"
onClick={() => navigate(`/storage/${vol.id}/details`)}
>
{vol.name}
</button>
</TableCell>
<TableCell>
{vol.size_gb != null ? `${vol.size_gb} GB` : '-'}
</TableCell>
+550
View File
@@ -0,0 +1,550 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { ArrowLeft, HardDrive, Loader2, Trash2, RefreshCw, Share, Folder, Database, Link2, Link2Off, Plus, Expand } from "lucide-react";
import { xcloudifyApi } from "@/services/xcloudifyApi";
import { useToast } from "@/hooks/use-toast";
import { DeleteConfirmationDialog } from "./DeleteConfirmationDialog";
interface VolumeDetailsPageProps {
volumeId: string;
onBack: () => void;
}
export const VolumeDetailsPage: React.FC<VolumeDetailsPageProps> = ({ volumeId, onBack }) => {
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>>({});
const [hostMap, setHostMap] = useState<Record<string, any>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Delete
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Resize
const [resizeOpen, setResizeOpen] = useState(false);
const [resizeValue, setResizeValue] = useState("");
const [isResizing, setIsResizing] = useState(false);
// Detach
const [detachingId, setDetachingId] = useState<string | null>(null);
// Attach
const [attachOpen, setAttachOpen] = useState(false);
const [vdcWorkloads, setVdcWorkloads] = useState<any[]>([]);
const [vdcWorkloadsLoading, setVdcWorkloadsLoading] = useState(false);
const [selectedWorkloadId, setSelectedWorkloadId] = useState("");
const [isAttaching, setIsAttaching] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const fetchAll = async () => {
setIsLoading(true);
setError(null);
try {
const [volRes, workloadsRes, images, vdcs, hosts] = await Promise.all([
xcloudifyApi.getVolume(volumeId),
xcloudifyApi.getWorkloadsForVolume(volumeId),
xcloudifyApi.getImages(),
xcloudifyApi.getVirtualDataCenters(),
xcloudifyApi.getWorkloadHosts(),
]);
setVolume(volRes.data || volRes);
setAttachedWorkloads(workloadsRes.data?.workloads || []);
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);
} catch (err: any) {
setError(err.message || "Failed to load volume");
} finally {
setIsLoading(false);
}
};
useEffect(() => { fetchAll(); }, [volumeId]);
const handleDelete = async () => {
setIsDeleting(true);
try {
await xcloudifyApi.deleteVolume(volumeId);
toast({ title: "Volume deletion queued — removing from backend worker" });
navigate("/storage");
} catch (err: any) {
toast({ title: err.message || "Failed to delete volume", variant: "destructive" });
} finally {
setIsDeleting(false);
setDeleteOpen(false);
}
};
const openResize = () => {
setResizeValue(String(volume?.size_gb ?? ""));
setResizeOpen(true);
};
const handleResize = async () => {
const newSize = parseFloat(resizeValue);
if (!newSize || newSize <= 0) {
toast({ title: "Enter a valid size greater than 0", variant: "destructive" });
return;
}
if (newSize < volume.size_gb) {
toast({ title: "Shrinking is not supported — new size must be larger", variant: "destructive" });
return;
}
if (newSize === volume.size_gb) {
toast({ title: "New size is the same as current size", variant: "destructive" });
return;
}
setIsResizing(true);
try {
const res = await xcloudifyApi.updateVolume(volumeId, { size_gb: newSize });
if (res?.data?.request_id) {
toast({ title: `Resize to ${newSize} GB queued — provisioning in background` });
} else {
toast({ title: "Volume resized successfully" });
}
setResizeOpen(false);
fetchAll();
} catch (err: any) {
toast({ title: err.message || "Failed to resize volume", variant: "destructive" });
} finally {
setIsResizing(false);
}
};
const handleDetach = async (workloadId: string) => {
setDetachingId(workloadId);
try {
await xcloudifyApi.detachVolumeFromWorkload(volumeId, workloadId);
toast({ title: "Volume detach queued — hot-unplug in progress" });
fetchAll();
} catch (err: any) {
toast({ title: err.message || "Failed to detach volume", variant: "destructive" });
} finally {
setDetachingId(null);
}
};
const openAttach = async () => {
setSelectedWorkloadId("");
setVdcWorkloads([]);
setAttachOpen(true);
if (volume?.vdc_id) {
setVdcWorkloadsLoading(true);
try {
const res = await xcloudifyApi.getWorkloadsForVirtualDataCenter(volume.vdc_id);
// API only allows VMs — filter to VirtualMachine type
const all = res.data?.workloads || res.data || [];
setVdcWorkloads(
(Array.isArray(all) ? all : []).filter((wl: any) => wl.workload_type === "VirtualMachine")
);
} catch {
toast({ title: "Failed to load workloads", variant: "destructive" });
} finally {
setVdcWorkloadsLoading(false);
}
}
};
const handleAttach = async () => {
if (!selectedWorkloadId) { toast({ title: "Select a VM", variant: "destructive" }); return; }
setIsAttaching(true);
try {
await xcloudifyApi.attachVolumeToWorkload(volumeId, selectedWorkloadId);
toast({ title: "Volume attach queued — hot-plug in progress" });
setAttachOpen(false);
fetchAll();
} catch (err: any) {
toast({ title: err.message || "Failed to attach volume", variant: "destructive" });
} finally {
setIsAttaching(false);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case "available": return <Badge className="bg-green-100 text-green-800 border-green-200">Available</Badge>;
case "in-use": return <Badge className="bg-blue-100 text-blue-800 border-blue-200">In Use</Badge>;
case "creating": return <Badge className="bg-yellow-100 text-yellow-800 border-yellow-200">Creating</Badge>;
case "pending-scheduling": return <Badge className="bg-orange-100 text-orange-800 border-orange-200"><Loader2 className="h-3 w-3 mr-1 animate-spin inline" />Scheduling</Badge>;
case "pending-resize": return <Badge className="bg-purple-100 text-purple-800 border-purple-200"><Loader2 className="h-3 w-3 mr-1 animate-spin inline" />Resizing</Badge>;
case "error": return <Badge variant="destructive">Error</Badge>;
default: return <Badge variant="secondary">{status || "Unknown"}</Badge>;
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case "nfs": return <Share className="h-5 w-5" />;
case "local": return <Folder className="h-5 w-5" />;
default: return <HardDrive className="h-5 w-5" />;
}
};
const formatGb = (gb: number) =>
gb >= 1024 ? `${(gb / 1024).toFixed(1)} TB` : `${gb} GB`;
const formatDate = (iso: string) => {
if (!iso) return "—";
return new Date(iso).toLocaleString();
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Loading volume details...
</div>
);
}
if (error || !volume) {
return (
<div className="space-y-4">
<Button variant="ghost" onClick={onBack}><ArrowLeft className="h-4 w-4 mr-2" />Back</Button>
<p className="text-destructive">{error || "Volume not found"}</p>
</div>
);
}
const linkedImage = volume.image_id ? imageMap[volume.image_id] : null;
const imageName = linkedImage?.name
|| [linkedImage?.os_family, linkedImage?.os_version].filter(Boolean).join(" ")
|| linkedImage?.id
|| null;
const vdcName = volume.vdc_id
? (vdcMap[volume.vdc_id]?.name || `VDC ${volume.vdc_id.slice(0, 8)}...`)
: "—";
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Separator orientation="vertical" className="h-6" />
<div className="flex items-center gap-2">
{getTypeIcon(volume.type)}
<h1 className="text-2xl font-bold">{volume.name}</h1>
{getStatusBadge(volume.status)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchAll} disabled={isLoading}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button variant="outline" size="sm" onClick={openResize}>
<Expand className="h-4 w-4 mr-2" />
Resize
</Button>
<Button variant="destructive" size="sm" onClick={() => setDeleteOpen(true)} disabled={isDeleting}>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Size</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatGb(volume.size_gb)}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Type</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{volume.type?.toUpperCase() || "—"}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Attached Workloads</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{attachedWorkloads.length}</p>
</CardContent>
</Card>
</div>
{/* Details */}
<Card>
<CardHeader>
<CardTitle>Volume Details</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
<div>
<dt className="text-sm text-muted-foreground">ID</dt>
<dd className="font-mono text-sm mt-0.5 break-all">{volume.id}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Name</dt>
<dd className="font-medium mt-0.5">{volume.name}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Description</dt>
<dd className="mt-0.5">{volume.description || "—"}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Status</dt>
<dd className="mt-0.5">{getStatusBadge(volume.status)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Type</dt>
<dd className="mt-0.5"><Badge variant="outline">{volume.type?.toUpperCase() || "—"}</Badge></dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Size</dt>
<dd className="mt-0.5">{formatGb(volume.size_gb)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Path</dt>
<dd className="font-mono text-sm mt-0.5">{volume.path || "—"}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Source</dt>
<dd className="font-mono text-sm mt-0.5 break-all">{volume.source || "—"}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Boot Volume</dt>
<dd className="mt-0.5">
{volume.boot ? <Badge variant="default">Yes</Badge> : <Badge variant="secondary">No</Badge>}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Image</dt>
<dd className="mt-0.5">{imageName || "—"}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Virtual Data Center</dt>
<dd className="mt-0.5">{vdcName}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Host</dt>
<dd className="mt-0.5">
{volume.workload_host_id
? (hostMap[volume.workload_host_id]?.name || hostMap[volume.workload_host_id]?.hostname || <span className="font-mono text-sm">{volume.workload_host_id}</span>)
: "—"}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Created</dt>
<dd className="mt-0.5">{formatDate(volume.created_at)}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Updated</dt>
<dd className="mt-0.5">{formatDate(volume.updated_at)}</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Attached Workloads */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
Attached Workloads
</CardTitle>
<Button size="sm" variant="outline" onClick={openAttach}>
<Plus className="h-4 w-4 mr-2" />
Attach to VM
</Button>
</CardHeader>
<CardContent>
{attachedWorkloads.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No workloads attached to this volume.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>ID</TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attachedWorkloads.map((wl) => (
<TableRow key={wl.id}>
<TableCell className="font-medium">
{wl.workload_type === "VirtualMachine" ? (
<button
className="hover:underline text-left text-primary"
onClick={() => navigate(`/infrastructure/virtual-machines/${wl.id}/details`)}
>
{wl.name}
</button>
) : (
wl.name
)}
</TableCell>
<TableCell><Badge variant="outline">{wl.workload_type || "—"}</Badge></TableCell>
<TableCell><Badge variant="secondary">{wl.status || "—"}</Badge></TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{wl.id}</TableCell>
<TableCell>
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={detachingId === wl.id}
onClick={() => handleDetach(wl.id)}
>
{detachingId === wl.id
? <Loader2 className="h-4 w-4 animate-spin" />
: <><Link2Off className="h-4 w-4 mr-1" />Detach</>
}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Delete confirmation */}
<DeleteConfirmationDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
onConfirm={handleDelete}
title="Delete Volume"
description={`Are you sure you want to delete "${volume.name}"? The backend volume will be removed from its host worker. This action cannot be undone.`}
itemName={volume.name}
itemType="volume"
/>
{/* Attach dialog */}
<Dialog open={attachOpen} onOpenChange={setAttachOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
Attach to VM
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-xs text-muted-foreground">
Only VirtualMachine workloads are supported. For local/LVM volumes, the VM must be on the same host.
</p>
<div className="space-y-2">
<Label>Select VM</Label>
{vdcWorkloadsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />Loading VMs...
</div>
) : vdcWorkloads.length === 0 ? (
<p className="text-sm text-muted-foreground">No VMs found in this VDC.</p>
) : (
<div className="border rounded-md divide-y max-h-56 overflow-y-auto">
{vdcWorkloads.map((wl) => {
const needsAffinity = volume?.type === "local" || volume?.type === "lvm";
const sameHost = wl.workload_host_id === volume?.workload_host_id;
const disabled = needsAffinity && !sameHost;
const hostName = wl.workload_host_id
? (hostMap[wl.workload_host_id]?.name || hostMap[wl.workload_host_id]?.hostname || wl.workload_host_id.slice(0, 8) + "…")
: "unplaced";
const selected = selectedWorkloadId === wl.id;
return (
<button
key={wl.id}
disabled={disabled}
onClick={() => !disabled && setSelectedWorkloadId(wl.id)}
className={[
"w-full text-left px-3 py-2.5 flex items-center justify-between gap-2 transition-colors",
disabled ? "opacity-40 cursor-not-allowed bg-muted/30" : "hover:bg-accent cursor-pointer",
selected ? "bg-accent" : "",
].join(" ")}
>
<div>
<p className="text-sm font-medium">{wl.name}</p>
<p className="text-xs text-muted-foreground">
Host: {hostName}
{disabled && " — different host, attach not allowed"}
</p>
</div>
{selected && <span className="text-xs text-primary font-medium">Selected</span>}
</button>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAttachOpen(false)}>Cancel</Button>
<Button onClick={handleAttach} disabled={isAttaching || !selectedWorkloadId || vdcWorkloadsLoading}>
{isAttaching && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Attach
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Resize dialog */}
<Dialog open={resizeOpen} onOpenChange={setResizeOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Expand className="h-4 w-4" />
Resize Volume
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>New Size (GB)</Label>
<Input
type="number"
min={volume?.size_gb ?? 1}
value={resizeValue}
onChange={(e) => setResizeValue(e.target.value)}
placeholder="Enter new size in GB"
/>
<p className="text-xs text-muted-foreground">
Current: {volume?.size_gb} GB — grow only, shrink not supported
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResizeOpen(false)}>Cancel</Button>
<Button onClick={handleResize} disabled={isResizing}>
{isResizing && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Resize
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import { useParams, useNavigate } from "react-router-dom";
import { VolumeDetailsPage } from "./VolumeDetailsPage";
export const VolumeDetailsWrapper: React.FC = () => {
const { volumeId } = useParams<{ volumeId: string }>();
const navigate = useNavigate();
return (
<VolumeDetailsPage
volumeId={volumeId || ""}
onBack={() => navigate("/storage")}
/>
);
};
+2
View File
@@ -32,6 +32,7 @@ import { CertificatesPage } from "@/components/CertificatesPage";
import { SSHKeysPage } from "@/components/SSHKeysPage";
import { NetworkDetailsPage } from "@/components/NetworkDetailsPage";
import { ServerGroupsPage } from "@/components/ServerGroupsPage";
import { VolumeDetailsWrapper } from "@/components/VolumeDetailsWrapper";
import NotFound from "@/pages/NotFound";
import { useNavigate } from "react-router-dom";
@@ -78,6 +79,7 @@ const Index = () => {
<Route path="/my-instances" element={<div className="p-4">My Instances page coming soon...</div>} />
<Route path="/my-templates" element={<div className="p-4">My Templates page coming soon...</div>} />
<Route path="/storage" element={<StoragePage />} />
<Route path="/storage/:volumeId/details" element={<VolumeDetailsWrapper />} />
<Route path="/ssh-keys" element={<SSHKeysPage />} />
<Route path="/certificates" element={<CertificatesPage />} />
<Route path="/billing" element={<BillingPage />} />