Merge branch 'main' into v1.01/stage-env
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user