diff --git a/webui/src/app/lib/api.ts b/webui/src/app/lib/api.ts index 03a0f0e4..8503bccb 100644 --- a/webui/src/app/lib/api.ts +++ b/webui/src/app/lib/api.ts @@ -155,7 +155,7 @@ export async function migrateSlot( namespace: string, cluster: string, target: number, - slot: number, + slot: string, slotOnly: boolean ): Promise { try { @@ -163,7 +163,7 @@ export async function migrateSlot( `${apiHost}/namespaces/${namespace}/clusters/${cluster}/migrate`, { target: target, - slot: slot.toString(), // SlotRange expects string representation like "123" + slot: slot, slot_only: slotOnly, } ); diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx index 3b6bc7b0..5132a1cb 100644 --- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx +++ b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx @@ -61,8 +61,10 @@ import InfoIcon from "@mui/icons-material/Info"; import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; -import { ShardCreation, MigrateSlot } from "@/app/ui/formCreation"; +import { ShardCreation } from "@/app/ui/formCreation"; import DeleteIcon from "@mui/icons-material/Delete"; +import MoveUpIcon from "@mui/icons-material/MoveUp"; +import { MigrationDialog } from "@/app/ui/migrationDialog"; interface ResourceCounts { shards: number; @@ -75,8 +77,8 @@ interface ShardData { index: number; nodes: any[]; slotRanges: string[]; - migratingSlot: number; - importingSlot: number; + migratingSlot: string; + importingSlot: string; targetShardIndex: number; nodeCount: number; hasSlots: boolean; @@ -110,8 +112,27 @@ export default function Cluster({ params }: { params: { namespace: string; clust const [filterOption, setFilterOption] = useState("all"); const [sortOption, setSortOption] = useState("index-asc"); const [expandedSlots, setExpandedSlots] = useState>(new Set()); + const [migrationDialogOpen, setMigrationDialogOpen] = useState(false); const router = useRouter(); + const isActiveMigration = (migratingSlot: string | null | undefined): boolean => { + return ( + migratingSlot !== null && + migratingSlot !== undefined && + migratingSlot !== "" && + migratingSlot !== "-1" + ); + }; + + const isActiveImport = (importingSlot: string | null | undefined): boolean => { + return ( + importingSlot !== null && + importingSlot !== undefined && + importingSlot !== "" && + importingSlot !== "-1" + ); + }; + useEffect(() => { const fetchData = async () => { try { @@ -135,19 +156,11 @@ export default function Cluster({ params }: { params: { namespace: string; clust const hasSlots = shard.slot_ranges && shard.slot_ranges.length > 0; if (hasSlots) withSlots++; - // Ensure we're using the correct field names from the API - // Handle null values properly - null means no migration/import - // Also handle missing fields (import_slot might not be present in all responses) - const migratingSlot = - shard.migrating_slot !== null && shard.migrating_slot !== undefined - ? shard.migrating_slot - : -1; - const importingSlot = - shard.import_slot !== null && shard.import_slot !== undefined - ? shard.import_slot - : -1; - - const hasMigration = migratingSlot >= 0; + // Handle string values from API as per documentation + const migratingSlot = shard.migrating_slot || ""; + const importingSlot = shard.import_slot || ""; + + const hasMigration = isActiveMigration(migratingSlot); if (hasMigration) migrating++; return { @@ -160,7 +173,7 @@ export default function Cluster({ params }: { params: { namespace: string; clust nodeCount, hasSlots, hasMigration, - hasImporting: importingSlot >= 0, + hasImporting: isActiveImport(importingSlot), }; }) ); @@ -181,6 +194,63 @@ export default function Cluster({ params }: { params: { namespace: string; clust fetchData(); }, [namespace, cluster, router]); + const refreshShardData = async () => { + setLoading(true); + try { + const fetchedShards = await listShards(namespace, cluster); + if (!fetchedShards) { + console.error(`Shards not found`); + router.push("/404"); + return; + } + + let totalNodes = 0; + let withSlots = 0; + let migrating = 0; + + const processedShards = await Promise.all( + fetchedShards.map(async (shard: any, index: number) => { + const nodeCount = shard.nodes?.length || 0; + totalNodes += nodeCount; + + const hasSlots = shard.slot_ranges && shard.slot_ranges.length > 0; + if (hasSlots) withSlots++; + + const migratingSlot = shard.migrating_slot || ""; + const importingSlot = shard.import_slot || ""; + + const hasMigration = isActiveMigration(migratingSlot); + if (hasMigration) migrating++; + + return { + index, + nodes: shard.nodes || [], + slotRanges: shard.slot_ranges || [], + migratingSlot, + importingSlot, + targetShardIndex: shard.target_shard_index || -1, + nodeCount, + hasSlots, + hasMigration, + hasImporting: isActiveImport(importingSlot), + }; + }) + ); + + setShardsData(processedShards); + setResourceCounts({ + shards: processedShards.length, + nodes: totalNodes, + withSlots, + migrating, + }); + } catch (error) { + console.error("Error fetching shards:", error); + } finally { + setLoading(false); + } + }; + const handleDeleteShard = async (index: number) => { if ( !confirm( @@ -420,23 +490,21 @@ export default function Cluster({ params }: { params: { namespace: string; clust Create Shard - } + disableElevation + size="medium" + style={{ borderRadius: "16px" }} + onClick={() => setMigrationDialogOpen(true)} + disabled={ + shardsData.filter((shard) => shard.hasSlots).length < 2 + } > - - + Migrate Slot + @@ -1089,24 +1157,20 @@ export default function Cluster({ params }: { params: { namespace: string; clust Shard {shard.index + 1} - {shard.hasMigration && - shard.migratingSlot >= 0 && ( -
-
- - Migrating{" "} - { - shard.migratingSlot - } - -
- )} + {shard.hasMigration && ( +
+
+ + Migrating{" "} + {shard.migratingSlot} + +
+ )} {!shard.hasMigration && !shard.hasImporting && ( @@ -1124,24 +1188,20 @@ export default function Cluster({ params }: { params: { namespace: string; clust )} - {shard.hasImporting && - shard.importingSlot >= 0 && ( -
-
- - Importing{" "} - { - shard.importingSlot - } - -
- )} + {shard.hasImporting && ( +
+
+ + Importing{" "} + {shard.importingSlot} + +
+ )}
@@ -1471,6 +1531,15 @@ export default function Cluster({ params }: { params: { namespace: string; clust
+ + setMigrationDialogOpen(false)} + namespace={namespace} + cluster={cluster} + shards={shardsData} + onSuccess={refreshShardData} + /> ); } diff --git a/webui/src/app/ui/formCreation.tsx b/webui/src/app/ui/formCreation.tsx index b4d7e89a..ad0d9dee 100644 --- a/webui/src/app/ui/formCreation.tsx +++ b/webui/src/app/ui/formCreation.tsx @@ -369,7 +369,7 @@ export const MigrateSlot: React.FC = ({ position, namespace, clu console.error("Error validating migration:", error); } - const response = await migrateSlot(namespace, cluster, target, slot, slotOnly); + const response = await migrateSlot(namespace, cluster, target, slot.toString(), slotOnly); if (response === "") { window.location.reload(); } else { diff --git a/webui/src/app/ui/migrationDialog.tsx b/webui/src/app/ui/migrationDialog.tsx new file mode 100644 index 00000000..b823c3c4 --- /dev/null +++ b/webui/src/app/ui/migrationDialog.tsx @@ -0,0 +1,484 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +"use client"; + +import React, { useState } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + FormControl, + FormLabel, + RadioGroup, + FormControlLabel, + Radio, + Box, + Chip, + CircularProgress, + Alert, + Snackbar, + TextField, + Switch, + alpha, + useTheme, +} from "@mui/material"; +import MoveUpIcon from "@mui/icons-material/MoveUp"; +import StorageIcon from "@mui/icons-material/Storage"; +import DeviceHubIcon from "@mui/icons-material/DeviceHub"; +import { migrateSlot } from "@/app/lib/api"; + +interface Shard { + index: number; + nodes: any[]; + slotRanges: string[]; + migratingSlot: string; + importingSlot: string; + targetShardIndex: number; + nodeCount: number; + hasSlots: boolean; + hasMigration: boolean; + hasImporting: boolean; +} + +interface MigrationDialogProps { + open: boolean; + onClose: () => void; + namespace: string; + cluster: string; + shards: Shard[]; + onSuccess: () => void; +} + +export const MigrationDialog: React.FC = ({ + open, + onClose, + namespace, + cluster, + shards, + onSuccess, +}) => { + const [targetShardIndex, setTargetShardIndex] = useState(-1); + const [slotNumber, setSlotNumber] = useState(""); + const [slotOnly, setSlotOnly] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const theme = useTheme(); + + const availableTargetShards = shards.filter((shard) => shard.hasSlots); + + const validateSlotInput = (input: string): { isValid: boolean; error?: string } => { + if (!input.trim()) { + return { isValid: false, error: "Please enter a slot number or range" }; + } + + // Check if it's a range (contains dash) + if (input.includes("-")) { + const parts = input.split("-"); + if (parts.length !== 2) { + return { + isValid: false, + error: "Invalid range format. Use format: start-end (e.g., 100-200)", + }; + } + + const start = parseInt(parts[0].trim()); + const end = parseInt(parts[1].trim()); + + if (isNaN(start) || isNaN(end)) { + return { + isValid: false, + error: "Both start and end of range must be valid numbers", + }; + } + + if (start < 0 || end > 16383 || start > 16383 || end < 0) { + return { isValid: false, error: "Slot numbers must be between 0 and 16383" }; + } + + if (start > end) { + return { + isValid: false, + error: "Start slot must be less than or equal to end slot", + }; + } + + return { isValid: true }; + } else { + const slot = parseInt(input.trim()); + if (isNaN(slot) || slot < 0 || slot > 16383) { + return { isValid: false, error: "Slot number must be between 0 and 16383" }; + } + return { isValid: true }; + } + }; + + const handleMigration = async () => { + if (targetShardIndex === -1 || !slotNumber.trim()) { + setError("Please select a target shard and enter a slot number or range"); + return; + } + + // Validate slot input + const validation = validateSlotInput(slotNumber); + if (!validation.isValid) { + setError(validation.error || "Invalid slot input"); + return; + } + + setLoading(true); + setError(""); + + try { + const result = await migrateSlot( + namespace, + cluster, + targetShardIndex, + slotNumber.trim(), + slotOnly + ); + + if (result) { + setError(result); + } else { + onSuccess(); + onClose(); + resetForm(); + } + } catch (err) { + setError("An unexpected error occurred during migration"); + } finally { + setLoading(false); + } + }; + + const resetForm = () => { + setTargetShardIndex(-1); + setSlotNumber(""); + setSlotOnly(false); + setError(""); + }; + + const handleClose = () => { + if (!loading) { + onClose(); + resetForm(); + } + }; + + const getSlotRangeDisplay = (slotRanges: string[]) => { + if (!slotRanges || slotRanges.length === 0) return "No slots"; + return slotRanges.join(", "); + }; + + return ( + <> + + + + + + + Migrate Slot + + + Move a slot to a different shard + + + + + + + + setSlotNumber(e.target.value)} + fullWidth + variant="outlined" + placeholder="e.g., 123 or 100-200" + helperText="Enter a single slot (123) or slot range (100-200). Slots must be between 0 and 16383" + sx={{ + "& .MuiOutlinedInput-root": { + borderRadius: "16px", + "&.Mui-focused": { + boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`, + }, + }, + }} + /> + + + + setSlotOnly(e.target.checked)} + sx={{ + "& .MuiSwitch-switchBase.Mui-checked": { + color: theme.palette.primary.main, + }, + "& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": { + backgroundColor: theme.palette.primary.main, + }, + }} + /> + } + label={ + + + Slot-only migration + + + Migrate only the slot without data + + + } + /> + + + {availableTargetShards.length > 0 ? ( + + + Select Target Shard + + + setTargetShardIndex(parseInt(e.target.value))} + > + {availableTargetShards.map((shard) => ( + + } + label={ + + + + + Shard {shard.index} + + + Slots:{" "} + {getSlotRangeDisplay(shard.slotRanges)} + + + Nodes: {shard.nodeCount} + + + + + {shard.hasMigration && ( + + )} + {shard.hasImporting && ( + + )} + + + } + sx={{ + p: 2, + m: 0, + mb: 2, + border: `1px solid ${ + targetShardIndex === shard.index + ? theme.palette.primary.main + : theme.palette.grey[300] + }`, + borderRadius: "16px", + backgroundColor: + targetShardIndex === shard.index + ? alpha(theme.palette.primary.main, 0.1) + : "transparent", + transition: "all 0.2s ease", + "&:hover": { + backgroundColor: alpha( + theme.palette.primary.main, + 0.05 + ), + }, + }} + /> + ))} + + + + ) : ( + + No target shards available for migration. At least one shard with slots + is required. + + )} + + + + + + + + + setError("")} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + > + setError("")} + severity="error" + variant="filled" + sx={{ borderRadius: "16px" }} + > + {error} + + + + ); +};