This is an automated email from the ASF dual-hosted git repository.
twice pushed a commit to branch unstable
in repository https://gitbox.apache.org/repos/asf/kvrocks-controller.git
The following commit(s) were added to refs/heads/unstable by this push:
new 0cffae9 feat(webui): implemented cluster slot migration (#341)
0cffae9 is described below
commit 0cffae9a12cf2a2b1e5d54e55437b93ed3ba50db
Author: Agnik Misra <[email protected]>
AuthorDate: Tue Aug 19 19:26:10 2025 +0530
feat(webui): implemented cluster slot migration (#341)
* feat(webui): implemented cluster slot migration
* support of Slot range
* TypeScript error fix
---
webui/src/app/lib/api.ts | 4 +-
.../[namespace]/clusters/[cluster]/page.tsx | 207 ++++++---
webui/src/app/ui/formCreation.tsx | 2 +-
webui/src/app/ui/migrationDialog.tsx | 484 +++++++++++++++++++++
4 files changed, 625 insertions(+), 72 deletions(-)
diff --git a/webui/src/app/lib/api.ts b/webui/src/app/lib/api.ts
index 03a0f0e..8503bcc 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<string> {
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 3b6bc7b..5132a1c 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<FilterOption>("all");
const [sortOption, setSortOption] = useState<SortOption>("index-asc");
const [expandedSlots, setExpandedSlots] = useState<Set<number>>(new Set());
+ const [migrationDialogOpen, setMigrationDialogOpen] =
useState<boolean>(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
</Button>
</ShardCreation>
- <MigrateSlot
- position="page"
- namespace={namespace}
- cluster={cluster}
+ <Button
+ variant="outlined"
+ color="warning"
+ className="whitespace-nowrap px-5 py-2.5
font-medium shadow-sm transition-all hover:shadow-md"
+ startIcon={<MoveUpIcon />}
+ disableElevation
+ size="medium"
+ style={{ borderRadius: "16px" }}
+ onClick={() =>
setMigrationDialogOpen(true)}
+ disabled={
+ shardsData.filter((shard) =>
shard.hasSlots).length < 2
+ }
>
- <Button
- variant="outlined"
- color="warning"
- className="whitespace-nowrap px-5
py-2.5 font-medium shadow-sm transition-all hover:shadow-md"
- startIcon={<SwapHorizIcon />}
- disableElevation
- size="medium"
- style={{ borderRadius: "16px" }}
- >
- Migrate Slot
- </Button>
- </MigrateSlot>
+ Migrate Slot
+ </Button>
</div>
</div>
</div>
@@ -1089,24 +1157,20 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
Shard
{shard.index + 1}
</Typography>
-
{shard.hasMigration &&
-
shard.migratingSlot >= 0 && (
- <div
-
className="flex items-center gap-1 border border-orange-200 bg-orange-50 px-2.5
py-1 dark:border-orange-800 dark:bg-orange-900/30"
-
style={{
-
borderRadius:
-
"12px",
- }}
- >
-
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-orange-500"></div>
-
<span className="text-xs font-medium text-orange-700 dark:text-orange-300">
-
Migrating{" "}
-
{
-
shard.migratingSlot
-
}
-
</span>
- </div>
- )}
+
{shard.hasMigration && (
+ <div
+
className="flex items-center gap-1 border border-orange-200 bg-orange-50 px-2.5
py-1 dark:border-orange-800 dark:bg-orange-900/30"
+
style={{
+
borderRadius: "12px",
+ }}
+ >
+ <div
className="h-1.5 w-1.5 animate-pulse rounded-full bg-orange-500"></div>
+ <span
className="text-xs font-medium text-orange-700 dark:text-orange-300">
+
Migrating{" "}
+
{shard.migratingSlot}
+ </span>
+ </div>
+ )}
{!shard.hasMigration &&
!shard.hasImporting && (
@@ -1124,24 +1188,20 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
</div>
)}
-
{shard.hasImporting &&
-
shard.importingSlot >= 0 && (
- <div
-
className="flex items-center gap-1 border border-blue-200 bg-blue-50 px-2.5
py-1 dark:border-blue-800 dark:bg-blue-900/30"
-
style={{
-
borderRadius:
-
"12px",
- }}
- >
-
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500"></div>
-
<span className="text-xs font-medium text-blue-700 dark:text-blue-300">
-
Importing{" "}
-
{
-
shard.importingSlot
-
}
-
</span>
- </div>
- )}
+
{shard.hasImporting && (
+ <div
+
className="flex items-center gap-1 border border-blue-200 bg-blue-50 px-2.5
py-1 dark:border-blue-800 dark:bg-blue-900/30"
+
style={{
+
borderRadius: "12px",
+ }}
+ >
+ <div
className="h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500"></div>
+ <span
className="text-xs font-medium text-blue-700 dark:text-blue-300">
+
Importing{" "}
+
{shard.importingSlot}
+ </span>
+ </div>
+ )}
</div>
<div
className="mt-2 space-y-1">
@@ -1471,6 +1531,15 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
</Paper>
</Box>
</div>
+
+ <MigrationDialog
+ open={migrationDialogOpen}
+ onClose={() => setMigrationDialogOpen(false)}
+ namespace={namespace}
+ cluster={cluster}
+ shards={shardsData}
+ onSuccess={refreshShardData}
+ />
</div>
);
}
diff --git a/webui/src/app/ui/formCreation.tsx
b/webui/src/app/ui/formCreation.tsx
index b4d7e89..ad0d9de 100644
--- a/webui/src/app/ui/formCreation.tsx
+++ b/webui/src/app/ui/formCreation.tsx
@@ -369,7 +369,7 @@ export const MigrateSlot: React.FC<ShardFormProps> = ({
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 0000000..b823c3c
--- /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<MigrationDialogProps> = ({
+ open,
+ onClose,
+ namespace,
+ cluster,
+ shards,
+ onSuccess,
+}) => {
+ const [targetShardIndex, setTargetShardIndex] = useState<number>(-1);
+ const [slotNumber, setSlotNumber] = useState<string>("");
+ const [slotOnly, setSlotOnly] = useState<boolean>(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string>("");
+ 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 (
+ <>
+ <Dialog
+ open={open}
+ onClose={handleClose}
+ maxWidth="sm"
+ fullWidth
+ PaperProps={{
+ sx: {
+ borderRadius: "24px",
+ boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)",
+ backgroundImage:
+ theme.palette.mode === "dark"
+ ? "linear-gradient(to bottom, rgba(66, 66, 66,
0.8), rgba(33, 33, 33, 0.9))"
+ : "linear-gradient(to bottom, #ffffff,
#f9fafb)",
+ backdropFilter: "blur(20px)",
+ overflow: "hidden",
+ },
+ }}
+ >
+ <DialogTitle
+ sx={{
+ background:
+ theme.palette.mode === "dark"
+ ? alpha(theme.palette.background.paper, 0.5)
+ : alpha(theme.palette.primary.light, 0.1),
+ borderBottom: `1px solid ${
+ theme.palette.mode === "dark"
+ ? theme.palette.grey[800]
+ : theme.palette.grey[200]
+ }`,
+ padding: "24px",
+ }}
+ >
+ <Box display="flex" alignItems="center" gap={2}>
+ <MoveUpIcon className="text-primary" sx={{ fontSize:
28 }} />
+ <Box>
+ <Typography
+ variant="h6"
+ className="font-semibold text-gray-800
dark:text-gray-100"
+ >
+ Migrate Slot
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500 dark:text-gray-400"
+ >
+ Move a slot to a different shard
+ </Typography>
+ </Box>
+ </Box>
+ </DialogTitle>
+
+ <DialogContent sx={{ padding: "24px" }}>
+ <Box mb={1} mt={1}>
+ <TextField
+ label="Slot or Slot Range"
+ value={slotNumber}
+ onChange={(e) => 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)}`,
+ },
+ },
+ }}
+ />
+ </Box>
+
+ <Box mb={1}>
+ <FormControlLabel
+ control={
+ <Switch
+ checked={slotOnly}
+ onChange={(e) =>
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={
+ <Box>
+ <Typography variant="body1"
className="font-medium">
+ Slot-only migration
+ </Typography>
+ <Typography variant="body2"
className="text-gray-500">
+ Migrate only the slot without data
+ </Typography>
+ </Box>
+ }
+ />
+ </Box>
+
+ {availableTargetShards.length > 0 ? (
+ <Box>
+ <Typography
+ variant="subtitle1"
+ className="mb-3 font-medium text-gray-700
dark:text-gray-300"
+ >
+ Select Target Shard
+ </Typography>
+ <FormControl component="fieldset" fullWidth>
+ <RadioGroup
+ value={targetShardIndex.toString()}
+ onChange={(e) =>
setTargetShardIndex(parseInt(e.target.value))}
+ >
+ {availableTargetShards.map((shard) => (
+ <FormControlLabel
+ key={shard.index}
+ value={shard.index.toString()}
+ control={
+ <Radio
+ sx={{
+ color:
theme.palette.primary.main,
+ "&.Mui-checked": {
+ color:
theme.palette.primary.main,
+ },
+ }}
+ />
+ }
+ label={
+ <Box
+ display="flex"
+ alignItems="center"
+ gap={2}
+ flex={1}
+ >
+ <StorageIcon
className="text-info" />
+ <Box flex={1}>
+ <Typography
+ variant="body1"
+
className="font-medium"
+ >
+ Shard {shard.index}
+ </Typography>
+ <Typography
+ variant="body2"
+
className="text-gray-500"
+ >
+ Slots:{" "}
+
{getSlotRangeDisplay(shard.slotRanges)}
+ </Typography>
+ <Typography
+ variant="body2"
+
className="text-gray-500"
+ >
+ Nodes:
{shard.nodeCount}
+ </Typography>
+ </Box>
+ <Box display="flex"
gap={1} flexWrap="wrap">
+ <Chip
+
label={`${shard.nodeCount} nodes`}
+ size="small"
+
className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
+ />
+ {shard.hasMigration &&
(
+ <Chip
+
label="Migrating"
+ size="small"
+
className="bg-orange-100 text-orange-800 dark:bg-orange-900
dark:text-orange-200"
+ />
+ )}
+ {shard.hasImporting &&
(
+ <Chip
+
label="Importing"
+ size="small"
+
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
+ />
+ )}
+ </Box>
+ </Box>
+ }
+ 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
+ ),
+ },
+ }}
+ />
+ ))}
+ </RadioGroup>
+ </FormControl>
+ </Box>
+ ) : (
+ <Alert severity="warning" sx={{ borderRadius: "16px"
}}>
+ No target shards available for migration. At least
one shard with slots
+ is required.
+ </Alert>
+ )}
+ </DialogContent>
+
+ <DialogActions
+ sx={{
+ background:
+ theme.palette.mode === "dark"
+ ? alpha(theme.palette.background.paper, 0.5)
+ : alpha(theme.palette.primary.light, 0.05),
+ borderTop: `1px solid ${
+ theme.palette.mode === "dark"
+ ? theme.palette.grey[800]
+ : theme.palette.grey[200]
+ }`,
+ padding: "24px",
+ justifyContent: "space-between",
+ }}
+ >
+ <Button
+ onClick={handleClose}
+ disabled={loading}
+ sx={{
+ textTransform: "none",
+ fontWeight: 500,
+ borderRadius: "16px",
+ px: 3,
+ py: 1,
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={handleMigration}
+ variant="contained"
+ disabled={
+ loading ||
+ availableTargetShards.length === 0 ||
+ targetShardIndex === -1 ||
+ !slotNumber.trim()
+ }
+ startIcon={loading ? <CircularProgress size={16} /> :
<MoveUpIcon />}
+ sx={{
+ textTransform: "none",
+ fontWeight: 600,
+ borderRadius: "16px",
+ px: 4,
+ py: 1,
+ backgroundColor: theme.palette.primary.main,
+ "&:hover": {
+ backgroundColor: theme.palette.primary.dark,
+ transform: "translateY(-1px)",
+ boxShadow: "0 6px 15px rgba(0, 0, 0, 0.1)",
+ },
+ }}
+ >
+ {loading ? "Processing..." : "Start Migration"}
+ </Button>
+ </DialogActions>
+ </Dialog>
+
+ <Snackbar
+ open={!!error}
+ autoHideDuration={6000}
+ onClose={() => setError("")}
+ anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
+ >
+ <Alert
+ onClose={() => setError("")}
+ severity="error"
+ variant="filled"
+ sx={{ borderRadius: "16px" }}
+ >
+ {error}
+ </Alert>
+ </Snackbar>
+ </>
+ );
+};