This is an automated email from the ASF dual-hosted git repository.
hulk 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 7fe3a2d feat(webui): implemented shard master failover, added delete
icon in list view (#337)
7fe3a2d is described below
commit 7fe3a2de5bb300cbe08efa938549b398e0cf1866
Author: Agnik Misra <[email protected]>
AuthorDate: Fri Aug 15 21:39:25 2025 +0530
feat(webui): implemented shard master failover, added delete icon in list
view (#337)
---
webui/src/app/lib/api.ts | 106 +++---
.../[namespace]/clusters/[cluster]/page.tsx | 233 ++++++++++--
.../clusters/[cluster]/shards/[shard]/page.tsx | 92 ++++-
webui/src/app/namespaces/[namespace]/page.tsx | 129 ++++++-
webui/src/app/ui/failoverDialog.tsx | 419 +++++++++++++++++++++
webui/src/app/ui/formCreation.tsx | 148 ++++++--
6 files changed, 1019 insertions(+), 108 deletions(-)
diff --git a/webui/src/app/lib/api.ts b/webui/src/app/lib/api.ts
index 7351cbe..03a0f0e 100644
--- a/webui/src/app/lib/api.ts
+++ b/webui/src/app/lib/api.ts
@@ -28,6 +28,23 @@ export interface Cluster {
shards: {};
}
+// Helper methods to handle common response patterns
+function handleCreateResponse(responseData: any): string {
+ if (responseData?.data != undefined) {
+ return "";
+ } else {
+ return handleError(responseData);
+ }
+}
+
+function handleDeleteResponse(responseData: any): string {
+ if (responseData == null || responseData.data == null || responseData.data
=== "ok") {
+ return "";
+ } else {
+ return handleError(responseData);
+ }
+}
+
export async function fetchNamespaces(): Promise<string[]> {
try {
const { data: responseData } = await
axios.get(`${apiHost}/namespaces`);
@@ -43,11 +60,7 @@ export async function createNamespace(name: string):
Promise<string> {
const { data: responseData } = await
axios.post(`${apiHost}/namespaces`, {
namespace: name,
});
- if (responseData?.data != undefined) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleCreateResponse(responseData);
} catch (error) {
return handleError(error);
}
@@ -56,11 +69,7 @@ export async function createNamespace(name: string):
Promise<string> {
export async function deleteNamespace(name: string): Promise<string> {
try {
const { data: responseData } = await
axios.delete(`${apiHost}/namespaces/${name}`);
- if (responseData.data == null) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleDeleteResponse(responseData);
} catch (error) {
return handleError(error);
}
@@ -83,11 +92,7 @@ export async function createCluster(
password,
}
);
- if (responseData?.data != undefined) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleCreateResponse(responseData);
} catch (error) {
return handleError(error);
}
@@ -122,11 +127,7 @@ export async function deleteCluster(namespace: string,
cluster: string): Promise
const { data: responseData } = await axios.delete(
`${apiHost}/namespaces/${namespace}/clusters/${cluster}`
);
- if (responseData.data == null) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleDeleteResponse(responseData);
} catch (error) {
return handleError(error);
}
@@ -144,11 +145,7 @@ export async function importCluster(
{ nodes, password }
);
console.log("importCluster response", responseData);
- if (responseData?.data != undefined) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleCreateResponse(responseData);
} catch (error) {
return handleError(error);
}
@@ -166,15 +163,11 @@ export async function migrateSlot(
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/migrate`,
{
target: target,
- slot: slot,
+ slot: slot.toString(), // SlotRange expects string
representation like "123"
slot_only: slotOnly,
}
);
- if (responseData?.data != undefined) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleCreateResponse(responseData);
} catch (error) {
return handleError(error);
}
@@ -191,11 +184,7 @@ export async function createShard(
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards`,
{ nodes, password }
);
- if (responseData?.data != undefined) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleCreateResponse(responseData);
} catch (error) {
return handleError(error);
}
@@ -238,11 +227,7 @@ export async function deleteShard(
const { data: responseData } = await axios.delete(
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards/${shard}`
);
- if (responseData.data == null) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleDeleteResponse(responseData);
} catch (error) {
return handleError(error);
}
@@ -261,11 +246,7 @@ export async function createNode(
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes`,
{ addr, role, password }
);
- if (responseData?.data == null) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleCreateResponse(responseData);
} catch (error) {
return handleError(error);
}
@@ -297,17 +278,40 @@ export async function deleteNode(
const { data: responseData } = await axios.delete(
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes/${nodeId}`
);
- if (responseData.data == null) {
- return "";
- } else {
- return handleError(responseData);
- }
+ return handleDeleteResponse(responseData);
} catch (error) {
console.log(error);
return handleError(error);
}
}
+export async function failoverShard(
+ namespace: string,
+ cluster: string,
+ shard: string,
+ preferredNodeId?: string
+): Promise<{ newMasterId?: string; error?: string }> {
+ try {
+ const requestBody: { preferred_node_id?: string } = {};
+ if (preferredNodeId) {
+ requestBody.preferred_node_id = preferredNodeId;
+ }
+
+ const { data: responseData } = await axios.post(
+
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/failover`,
+ requestBody
+ );
+
+ if (responseData?.data?.new_master_id) {
+ return { newMasterId: responseData.data.new_master_id };
+ } else {
+ return { error: handleError(responseData) };
+ }
+ } catch (error) {
+ return { error: handleError(error) };
+ }
+}
+
function handleError(error: any): string {
let message: string = "";
if (error instanceof AxiosError) {
diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
index 2231ff3..3b6bc7b 100644
--- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
+++ b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
@@ -34,10 +34,12 @@ import {
Radio,
Fade,
Badge,
+ Collapse,
+ Divider,
} from "@mui/material";
import { ClusterSidebar } from "../../../../ui/sidebar";
import { useState, useEffect } from "react";
-import { listShards, listNodes, fetchCluster } from "@/app/lib/api";
+import { listShards, listNodes, fetchCluster, deleteShard } from
"@/app/lib/api";
import { AddShardCard, ResourceCard } from "@/app/ui/createCard";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -57,7 +59,10 @@ import AddIcon from "@mui/icons-material/Add";
import WarningIcon from "@mui/icons-material/Warning";
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 DeleteIcon from "@mui/icons-material/Delete";
interface ResourceCounts {
shards: number;
@@ -98,11 +103,13 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
migrating: 0,
});
const [loading, setLoading] = useState<boolean>(true);
+ const [deletingShardIndex, setDeletingShardIndex] = useState<number |
null>(null);
const [searchTerm, setSearchTerm] = useState<string>("");
const [filterAnchorEl, setFilterAnchorEl] = useState<null |
HTMLElement>(null);
const [sortAnchorEl, setSortAnchorEl] = useState<null | HTMLElement>(null);
const [filterOption, setFilterOption] = useState<FilterOption>("all");
const [sortOption, setSortOption] = useState<SortOption>("index-asc");
+ const [expandedSlots, setExpandedSlots] = useState<Set<number>>(new Set());
const router = useRouter();
useEffect(() => {
@@ -174,6 +181,74 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
fetchData();
}, [namespace, cluster, router]);
+ const handleDeleteShard = async (index: number) => {
+ if (
+ !confirm(
+ `Are you sure you want to delete Shard ${index + 1}? This
action cannot be undone.`
+ )
+ )
+ return;
+
+ try {
+ setDeletingShardIndex(index);
+ const responseMessage = await deleteShard(namespace, cluster,
index.toString());
+ if (responseMessage && responseMessage !== "") {
+ alert(`Failed to delete shard: ${responseMessage}`);
+ return;
+ }
+
+ const fetchedShards = await listShards(namespace, cluster);
+ let totalNodes = 0;
+ let withSlots = 0;
+ let migrating = 0;
+
+ const processedShards = (fetchedShards || []).map((shard: any,
idx: 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 !== 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;
+ if (hasMigration) migrating++;
+
+ return {
+ index: idx,
+ nodes: shard.nodes || [],
+ slotRanges: shard.slot_ranges || [],
+ migratingSlot,
+ importingSlot,
+ targetShardIndex: shard.target_shard_index || -1,
+ nodeCount,
+ hasSlots,
+ hasMigration,
+ hasImporting: importingSlot >= 0,
+ } as ShardData;
+ });
+
+ setShardsData(processedShards);
+ setResourceCounts({
+ shards: processedShards.length,
+ nodes: totalNodes,
+ withSlots,
+ migrating,
+ });
+ } catch (error) {
+ alert(`Failed to delete shard: ${error}`);
+ } finally {
+ setDeletingShardIndex(null);
+ }
+ };
+
const handleFilterClick = (event: React.MouseEvent<HTMLElement>) => {
setFilterAnchorEl(event.currentTarget);
};
@@ -235,12 +310,43 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
return <LoadingSpinner />;
}
- const formatSlotRanges = (ranges: string[]) => {
+ const formatSlotRanges = (ranges: string[], showAll: boolean = false) => {
if (!ranges || ranges.length === 0) return "None";
- if (ranges.length <= 2) return ranges.join(", ");
+ if (showAll || ranges.length <= 2) return ranges.join(", ");
return `${ranges[0]}, ${ranges[1]}, ... (+${ranges.length - 2} more)`;
};
+ const expandSlotRanges = (ranges: string[]) => {
+ if (!ranges || ranges.length === 0) return [];
+ const slots: number[] = [];
+ for (const range of ranges) {
+ if (range.includes("-")) {
+ const [start, end] = range.split("-").map(Number);
+ if (!isNaN(start) && !isNaN(end)) {
+ for (let i = start; i <= end; i++) {
+ slots.push(i);
+ }
+ }
+ } else {
+ const slot = Number(range);
+ if (!isNaN(slot)) {
+ slots.push(slot);
+ }
+ }
+ }
+ return slots.sort((a, b) => a - b);
+ };
+
+ const toggleSlotExpansion = (shardIndex: number) => {
+ const newExpanded = new Set(expandedSlots);
+ if (newExpanded.has(shardIndex)) {
+ newExpanded.delete(shardIndex);
+ } else {
+ newExpanded.add(shardIndex);
+ }
+ setExpandedSlots(newExpanded);
+ };
+
return (
<div className="flex h-full">
<div className="relative h-full">
@@ -1051,33 +1157,96 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
</Typography>
{shard.hasSlots ? (
- <div
className="flex items-center">
-
<Typography
-
variant="body2"
-
className="flex items-center text-gray-500 dark:text-gray-400"
- >
-
<StorageIcon
+ <div>
+ <div
className="flex items-center">
+
<Typography
+
variant="body2"
+
className="flex items-center text-gray-500 dark:text-gray-400"
+ >
+
<StorageIcon
+
sx={{
+
fontSize: 14,
+
}}
+
className="mr-1"
+
/>
+
Slots:{" "}
+
{formatSlotRanges(
+
shard.slotRanges
+
)}
+
</Typography>
+
<Chip
+
size="small"
+
label={`${shard.slotRanges.length} range${shard.slotRanges.length !== 1 ? "s"
: ""}`}
+
color="primary"
+
variant="outlined"
+
className="ml-2"
sx={{
-
fontSize: 14,
+
height: 20,
+
fontSize:
+
"0.7rem",
}}
-
className="mr-1"
/>
-
Slots:{" "}
-
{formatSlotRanges(
-
shard.slotRanges
+
{shard.slotRanges
+
.length > 2 && (
+
<IconButton
+
size="small"
+
onClick={(
+
e
+
) => {
+
e.preventDefault();
+
e.stopPropagation();
+
toggleSlotExpansion(
+
shard.index
+
);
+
}}
+
className="ml-1 p-1"
+
sx={{
+
fontSize: 16,
+
}}
+
>
+
{expandedSlots.has(
+
shard.index
+
) ? (
+
<ExpandLessIcon fontSize="small" />
+
) : (
+
<ExpandMoreIcon fontSize="small" />
+
)}
+
</IconButton>
)}
-
</Typography>
- <Chip
-
size="small"
-
label={`${shard.slotRanges.length} range${shard.slotRanges.length !== 1 ? "s" :
""}`}
-
color="primary"
-
variant="outlined"
-
className="ml-2"
-
sx={{
-
height: 20,
-
fontSize: "0.7rem",
- }}
- />
+ </div>
+
<Collapse
+
in={expandedSlots.has(
+
shard.index
+ )}
+ >
+
<div className="mt-2 rounded-lg bg-gray-50 p-2 dark:bg-gray-800/50">
+
<Typography
+
variant="caption"
+
className="mb-1 block font-medium text-gray-600 dark:text-gray-300"
+
>
+
All Slot Ranges
+
(
+
{
+
expandSlotRanges(
+
shard.slotRanges
+
).length
+
}{" "}
+
total slots):
+
</Typography>
+
<Typography
+
variant="body2"
+
className="text-gray-700 dark:text-gray-200"
+
sx={{
+
fontFamily:
+
"monospace",
+
}}
+
>
+
{shard.slotRanges.join(
+
", "
+
)}
+
</Typography>
+
</div>
+
</Collapse>
</div>
) : (
<Typography
@@ -1232,6 +1401,18 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
</div>
<div className="ml-2 mt-3
flex items-center space-x-2 sm:mt-0">
+ <button
+ onClick={() =>
+
handleDeleteShard(shard.index)
+ }
+ disabled={
+
deletingShardIndex === shard.index
+ }
+
className="bg-gray-100 p-2 text-gray-600 transition-colors hover:bg-red-100
hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50
dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-red-900/30
dark:hover:text-red-400"
+ style={{
borderRadius: "16px" }}
+ >
+ <DeleteIcon />
+ </button>
<Link
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard.index}`}
className="rounded-full bg-primary/10 p-2 text-primary transition-colors
hover:bg-primary/20 dark:bg-primary-dark/20 dark:text-primary-light
dark:hover:bg-primary-dark/30"
diff --git
a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/page.tsx
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/page.tsx
index 6b7c642..e0d5b80 100644
---
a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/page.tsx
+++
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/page.tsx
@@ -35,7 +35,7 @@ import {
Fade,
} from "@mui/material";
import { ShardSidebar } from "@/app/ui/sidebar";
-import { fetchShard } from "@/app/lib/api";
+import { fetchShard, deleteNode } from "@/app/lib/api";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { AddNodeCard } from "@/app/ui/createCard";
@@ -56,6 +56,9 @@ import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import { NodeCreation } from "@/app/ui/formCreation";
import AddIcon from "@mui/icons-material/Add";
+import DeleteIcon from "@mui/icons-material/Delete";
+import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
+import { FailoverDialog } from "@/app/ui/failoverDialog";
export default function Shard({
params,
@@ -65,11 +68,13 @@ export default function Shard({
const { namespace, cluster, shard } = params;
const [nodesData, setNodesData] = useState<any>(null);
const [loading, setLoading] = useState<boolean>(true);
+ const [deletingNodeIndex, setDeletingNodeIndex] = useState<number |
null>(null);
const [searchTerm, setSearchTerm] = useState<string>("");
const [filterAnchorEl, setFilterAnchorEl] = useState<null |
HTMLElement>(null);
const [sortAnchorEl, setSortAnchorEl] = useState<null | HTMLElement>(null);
const [filterOption, setFilterOption] = useState<string>("all");
const [sortOption, setSortOption] = useState<string>("index-asc");
+ const [failoverDialogOpen, setFailoverDialogOpen] =
useState<boolean>(false);
const router = useRouter();
useEffect(() => {
@@ -91,6 +96,20 @@ export default function Shard({
fetchData();
}, [namespace, cluster, shard, router]);
+ const refreshShardData = async () => {
+ setLoading(true);
+ try {
+ const fetchedNodes = await fetchShard(namespace, cluster, shard);
+ setNodesData(fetchedNodes);
+ } catch (error) {
+ console.error("Error refreshing shard data:", error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const hasReplicaNodes = nodesData?.nodes?.some((node: any) => node.role
=== "slave") || false;
+
if (loading) {
return <LoadingSpinner />;
}
@@ -206,6 +225,19 @@ export default function Shard({
</div>
</div>
<div className="flex flex-shrink-0 gap-3">
+ <Button
+ variant="outlined"
+ color="primary"
+ className="whitespace-nowrap px-5 py-2.5
font-medium shadow-sm transition-all hover:shadow-md"
+ style={{ borderRadius: "16px" }}
+ startIcon={<SwapHorizIcon />}
+ disableElevation
+ size="medium"
+ disabled={!hasReplicaNodes}
+ onClick={() => setFailoverDialogOpen(true)}
+ >
+ Failover
+ </Button>
<NodeCreation
position="card"
namespace={namespace}
@@ -636,6 +668,54 @@ export default function Shard({
</Link>
</div>
</div>
+ <div className="ml-2 mt-3
flex items-center space-x-2 sm:mt-0">
+ <button
+ onClick={async ()
=> {
+ if (
+ !confirm(
+ `Are
you sure you want to delete Node ${index + 1}? This action cannot be undone.`
+ )
+ )
+ return;
+ try {
+
setDeletingNodeIndex(index);
+ const res
= await deleteNode(
+
namespace,
+
cluster,
+ shard,
+ node.id
+ );
+ if (res) {
+ alert(
+
`Failed to delete node: ${res}`
+ );
+ return;
+ }
+ // Refetch
shard
+
setLoading(true);
+ const
fetchedNodes =
+ await
fetchShard(
+
namespace,
+
cluster,
+
shard
+ );
+
setNodesData(fetchedNodes);
+ } catch (e) {
+ alert(
+
`Failed to delete node: ${e}`
+ );
+ } finally {
+
setDeletingNodeIndex(null);
+
setLoading(false);
+ }
+ }}
+
disabled={deletingNodeIndex === index}
+
className="bg-gray-100 p-2 text-gray-600 transition-colors hover:bg-red-100
hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50
dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-red-900/30
dark:hover:text-red-400"
+ style={{
borderRadius: "16px" }}
+ >
+ <DeleteIcon />
+ </button>
+ </div>
</div>
</Paper>
</div>
@@ -674,6 +754,16 @@ export default function Shard({
)}
</Paper>
</Box>
+
+ <FailoverDialog
+ open={failoverDialogOpen}
+ onClose={() => setFailoverDialogOpen(false)}
+ namespace={namespace}
+ cluster={cluster}
+ shard={shard}
+ nodes={nodesData?.nodes || []}
+ onSuccess={refreshShardData}
+ />
</div>
</div>
);
diff --git a/webui/src/app/namespaces/[namespace]/page.tsx
b/webui/src/app/namespaces/[namespace]/page.tsx
index f9b1de0..e1ede3d 100644
--- a/webui/src/app/namespaces/[namespace]/page.tsx
+++ b/webui/src/app/namespaces/[namespace]/page.tsx
@@ -35,7 +35,14 @@ import {
Fade,
} from "@mui/material";
import { NamespaceSidebar } from "../../ui/sidebar";
-import { fetchCluster, fetchClusters, fetchNamespaces, listShards, listNodes }
from "@/app/lib/api";
+import {
+ fetchCluster,
+ fetchClusters,
+ fetchNamespaces,
+ listShards,
+ listNodes,
+ deleteCluster,
+} from "@/app/lib/api";
import Link from "next/link";
import { useRouter, notFound } from "next/navigation";
import { useState, useEffect } from "react";
@@ -60,6 +67,7 @@ import FileUploadIcon from "@mui/icons-material/FileUpload";
import WarningIcon from "@mui/icons-material/Warning";
import InfoIcon from "@mui/icons-material/Info";
import { ClusterCreation, ImportCluster } from "@/app/ui/formCreation";
+import DeleteIcon from "@mui/icons-material/Delete";
interface ResourceCounts {
clusters: number;
@@ -112,6 +120,7 @@ export default function Namespace({ params }: { params: {
namespace: string } })
const [filterOption, setFilterOption] = useState<FilterOption>("all");
const [sortOption, setSortOption] = useState<SortOption>("name-asc");
const router = useRouter();
+ const [deletingCluster, setDeletingCluster] = useState<string |
null>(null);
useEffect(() => {
const fetchData = async () => {
@@ -224,6 +233,112 @@ export default function Namespace({ params }: { params: {
namespace: string } })
fetchData();
}, [params.namespace, router]);
+ const handleDeleteCluster = async (clusterName: string) => {
+ if (!confirm(`Are you sure you want to delete cluster
"${clusterName}"?`)) return;
+
+ try {
+ setDeletingCluster(clusterName);
+ const res = await deleteCluster(params.namespace, clusterName);
+ if (res) {
+ alert(`Failed to delete cluster: ${res}`);
+ return;
+ }
+
+ // Re-fetch clusters and rebuild data
+ setLoading(true);
+ const clusters = await fetchClusters(params.namespace);
+ let totalShards = 0;
+ let totalNodes = 0;
+
+ const data = await Promise.all(
+ clusters.map(async (cluster) => {
+ try {
+ const clusterInfo = await
fetchCluster(params.namespace, cluster);
+ if (
+ clusterInfo &&
+ typeof clusterInfo === "object" &&
+ "shards" in clusterInfo
+ ) {
+ const shards = (clusterInfo as any).shards || [];
+
+ let clusterNodeCount = 0;
+ for (let i = 0; i < shards.length; i++) {
+ try {
+ const nodes = await listNodes(
+ params.namespace,
+ cluster,
+ i.toString()
+ );
+ if (Array.isArray(nodes)) {
+ clusterNodeCount += nodes.length;
+ }
+ } catch (error) {
+ console.error(`Failed to fetch nodes for
shard ${i}:`, error);
+ }
+ }
+
+ totalShards += shards.length;
+ totalNodes += clusterNodeCount;
+
+ const hasSlots = shards.some(
+ (s: any) => s.slot_ranges &&
s.slot_ranges.length > 0
+ );
+ const hasMigration = shards.some((s: any) =>
s.migrating_slot >= 0);
+ const hasNoMigration = shards.every(
+ (s: any) => s.migrating_slot === -1
+ );
+ const hasImporting = shards.some((s: any) =>
s.import_slot >= 0);
+
+ const slotRanges =
+ shards.find((s: any) => s.slot_ranges &&
s.slot_ranges.length > 0)
+ ?.slot_ranges || [];
+ const migratingSlot =
+ shards.find((s: any) => s.migrating_slot >=
0)?.migrating_slot ||
+ -1;
+ const importingSlot =
+ shards.find((s: any) => s.import_slot >=
0)?.import_slot || -1;
+ const targetShardIndex =
+ shards.find((s: any) => s.target_shard_index
>= 0)
+ ?.target_shard_index || -1;
+
+ return {
+ ...clusterInfo,
+ shards,
+ shardCount: shards.length,
+ nodeCount: clusterNodeCount,
+ hasSlots,
+ hasMigration,
+ hasNoMigration,
+ hasImporting,
+ slotRanges,
+ migratingSlot,
+ importingSlot,
+ targetShardIndex,
+ } as ClusterData;
+ }
+ return null;
+ } catch (error) {
+ console.error(`Failed to fetch data for cluster
${cluster}:`, error);
+ return null;
+ }
+ })
+ );
+
+ const validData = data.filter(Boolean) as ClusterData[];
+ setClusterData(validData);
+ setResourceCounts({
+ clusters: validData.length,
+ shards: totalShards,
+ nodes: totalNodes,
+ });
+ } catch (e) {
+ alert(`Failed to delete cluster: ${e}`);
+ } finally {
+ setDeletingCluster(null);
+ setLoading(false);
+ }
+ };
+
const handleFilterClick = (event: React.MouseEvent<HTMLElement>) => {
setFilterAnchorEl(event.currentTarget);
};
@@ -1221,6 +1336,18 @@ export default function Namespace({ params }: { params:
{ namespace: string } })
</div>
<div className="ml-2 mt-3
flex items-center space-x-2 sm:mt-0">
+ <button
+ onClick={() =>
+
handleDeleteCluster(cluster.name)
+ }
+ disabled={
+
deletingCluster === cluster.name
+ }
+
className="bg-gray-100 p-2 text-gray-600 transition-colors hover:bg-red-100
hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-50
dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-red-900/30
dark:hover:text-red-400"
+ style={{
borderRadius: "16px" }}
+ >
+ <DeleteIcon />
+ </button>
<Link
href={`/namespaces/${params.namespace}/clusters/${cluster.name}`}
className="bg-primary/10 p-2 text-primary transition-colors hover:bg-primary/20
dark:bg-primary-dark/20 dark:text-primary-light dark:hover:bg-primary-dark/30"
diff --git a/webui/src/app/ui/failoverDialog.tsx
b/webui/src/app/ui/failoverDialog.tsx
new file mode 100644
index 0000000..9958704
--- /dev/null
+++ b/webui/src/app/ui/failoverDialog.tsx
@@ -0,0 +1,419 @@
+/*
+ * 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,
+ alpha,
+ useTheme,
+} from "@mui/material";
+import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
+import CheckCircleIcon from "@mui/icons-material/CheckCircle";
+import DeviceHubIcon from "@mui/icons-material/DeviceHub";
+import { failoverShard } from "@/app/lib/api";
+
+interface Node {
+ id: string;
+ addr: string;
+ role: string;
+ created_at: number;
+}
+
+interface FailoverDialogProps {
+ open: boolean;
+ onClose: () => void;
+ namespace: string;
+ cluster: string;
+ shard: string;
+ nodes: Node[];
+ onSuccess: () => void;
+}
+
+export const FailoverDialog: React.FC<FailoverDialogProps> = ({
+ open,
+ onClose,
+ namespace,
+ cluster,
+ shard,
+ nodes,
+ onSuccess,
+}) => {
+ const [selectedNodeId, setSelectedNodeId] = useState<string>("auto");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string>("");
+ const theme = useTheme();
+
+ const masterNode = nodes.find((node) => node.role === "master");
+ const slaveNodes = nodes.filter((node) => node.role === "slave");
+
+ const handleFailover = async () => {
+ setLoading(true);
+ setError("");
+
+ try {
+ const result = await failoverShard(
+ namespace,
+ cluster,
+ shard,
+ selectedNodeId === "auto" ? undefined : selectedNodeId
+ );
+
+ if (result.error) {
+ setError(result.error);
+ } else {
+ onSuccess();
+ onClose();
+ setSelectedNodeId("auto");
+ }
+ } catch (err) {
+ setError("An unexpected error occurred during failover");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (!loading) {
+ onClose();
+ setSelectedNodeId("auto");
+ setError("");
+ }
+ };
+
+ const truncateId = (id: string, length: number = 8) => {
+ return id.length > length ? `${id.substring(0, length)}...` : id;
+ };
+
+ 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}>
+ <SwapHorizIcon className="text-primary" sx={{
fontSize: 28 }} />
+ <Box>
+ <Typography
+ variant="h6"
+ className="font-semibold text-gray-800
dark:text-gray-100"
+ >
+ Failover Shard Master
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500 dark:text-gray-400"
+ >
+ Promote a replica node to master
+ </Typography>
+ </Box>
+ </Box>
+ </DialogTitle>
+
+ <DialogContent sx={{ padding: "24px" }}>
+ <Box mb={2}>
+ <Typography
+ variant="subtitle1"
+ className="mb-2 font-medium text-gray-700
dark:text-gray-300"
+ >
+ Current Master
+ </Typography>
+ {masterNode && (
+ <Box
+ sx={{
+ p: 2,
+ border: `1px solid
${theme.palette.success.light}`,
+ borderRadius: "16px",
+ backgroundColor:
alpha(theme.palette.success.light, 0.1),
+ }}
+ >
+ <Box display="flex" alignItems="center"
gap={2}>
+ <CheckCircleIcon className="text-success"
/>
+ <Box flex={1}>
+ <Typography variant="body1"
className="font-medium">
+ {masterNode.addr}
+ </Typography>
+ <Typography variant="body2"
className="text-gray-500">
+ ID: {truncateId(masterNode.id)}
+ </Typography>
+ </Box>
+ <Chip
+ label="Master"
+ size="small"
+ className="bg-green-100 text-green-800
dark:bg-green-900 dark:text-green-200"
+ />
+ </Box>
+ </Box>
+ )}
+ </Box>
+
+ {slaveNodes.length > 0 ? (
+ <Box>
+ <Typography
+ variant="subtitle1"
+ className="mb-3 font-medium text-gray-700
dark:text-gray-300"
+ >
+ Select New Master
+ </Typography>
+ <FormControl component="fieldset" fullWidth>
+ <RadioGroup
+ value={selectedNodeId}
+ onChange={(e) =>
setSelectedNodeId(e.target.value)}
+ >
+ <FormControlLabel
+ value="auto"
+ control={
+ <Radio
+ sx={{
+ color:
theme.palette.primary.main,
+ "&.Mui-checked": {
+ color:
theme.palette.primary.main,
+ },
+ }}
+ />
+ }
+ label={
+ <Box>
+ <Typography variant="body1"
className="font-medium">
+ Automatic Selection
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500"
+ >
+ Let the controller choose
the best replica
+ </Typography>
+ </Box>
+ }
+ sx={{
+ p: 2,
+ m: 0,
+ mb: 2,
+ border: `1px solid ${
+ selectedNodeId === "auto"
+ ?
theme.palette.primary.main
+ : theme.palette.grey[300]
+ }`,
+ borderRadius: "16px",
+ backgroundColor:
+ selectedNodeId === "auto"
+ ?
alpha(theme.palette.primary.main, 0.1)
+ : "transparent",
+ transition: "all 0.2s ease",
+ "&:hover": {
+ backgroundColor: alpha(
+ theme.palette.primary.main,
+ 0.05
+ ),
+ },
+ }}
+ />
+
+ {slaveNodes.map((node) => (
+ <FormControlLabel
+ key={node.id}
+ value={node.id}
+ 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}
+ >
+ <DeviceHubIcon
className="text-info" />
+ <Box flex={1}>
+ <Typography
+ variant="body1"
+
className="font-medium"
+ >
+ {node.addr}
+ </Typography>
+ <Typography
+ variant="body2"
+
className="text-gray-500"
+ >
+ ID:
{truncateId(node.id)}
+ </Typography>
+ </Box>
+ <Chip
+ label="Replica"
+ size="small"
+ className="bg-blue-100
text-blue-800 dark:bg-blue-900 dark:text-blue-200"
+ />
+ </Box>
+ }
+ sx={{
+ p: 2,
+ m: 0,
+ mb: 2,
+ border: `1px solid ${
+ selectedNodeId === node.id
+ ?
theme.palette.primary.main
+ :
theme.palette.grey[300]
+ }`,
+ borderRadius: "16px",
+ backgroundColor:
+ selectedNodeId === node.id
+ ?
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 replica nodes available for failover. At least
one replica node 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={handleFailover}
+ variant="contained"
+ disabled={loading || slaveNodes.length === 0}
+ startIcon={loading ? <CircularProgress size={16} /> :
<SwapHorizIcon />}
+ 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 Failover"}
+ </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>
+ </>
+ );
+};
diff --git a/webui/src/app/ui/formCreation.tsx
b/webui/src/app/ui/formCreation.tsx
index ff8cdc4..b4d7e89 100644
--- a/webui/src/app/ui/formCreation.tsx
+++ b/webui/src/app/ui/formCreation.tsx
@@ -27,6 +27,7 @@ import {
createShard,
importCluster,
migrateSlot,
+ listShards,
} from "../lib/api";
import { useRouter } from "next/navigation";
@@ -84,7 +85,7 @@ export const NamespaceCreation: React.FC<NamespaceFormProps>
= ({ position, chil
if (response === "") {
router.push(`/namespaces/${formObj["name"]}`);
} else {
- return "Invalid form data";
+ return response || "Failed to create namespace";
}
};
@@ -129,7 +130,7 @@ export const ClusterCreation: React.FC<ClusterFormProps> =
({ position, namespac
if (response === "") {
router.push(`/namespaces/${namespace}/clusters/${formObj["name"]}`);
} else {
- return "Invalid form data";
+ return response || "Failed to create cluster";
}
};
@@ -168,31 +169,51 @@ export const ShardCreation: React.FC<ShardFormProps> = ({
}) => {
const router = useRouter();
const handleSubmit = async (formData: FormData) => {
- const fieldsToValidate = ["nodes"];
- const errorMessage = validateFormData(formData, fieldsToValidate);
- if (errorMessage) {
- return errorMessage;
- }
+ try {
+ const fieldsToValidate = ["nodes"];
+ const errorMessage = validateFormData(formData, fieldsToValidate);
+ if (errorMessage) {
+ return errorMessage;
+ }
- const formObj = Object.fromEntries(formData.entries());
- const nodes = JSON.parse(formObj["nodes"] as string) as string[];
- const password = formObj["password"] as string;
+ const formObj = Object.fromEntries(formData.entries());
+
+ let nodes: string[];
+ try {
+ const nodesString = formObj["nodes"] as string;
+ if (!nodesString) {
+ return "Nodes field is required.";
+ }
+ nodes = JSON.parse(nodesString) as string[];
+ } catch (parseError) {
+ return "Invalid nodes format. Please check your input.";
+ }
- if (nodes.length === 0) {
- return "Nodes cannot be empty.";
- }
+ const password = (formObj["password"] as string) || "";
- for (const node of nodes) {
- if (containsWhitespace(node)) {
- return "Nodes cannot contain any whitespace characters.";
+ if (!Array.isArray(nodes) || nodes.length === 0) {
+ return "At least one node is required.";
}
- }
- const response = await createShard(namespace, cluster, nodes,
password);
- if (response === "") {
- router.push(`/namespaces/${namespace}/clusters/${cluster}`);
- } else {
- return "Invalid form data";
+ for (const node of nodes) {
+ if (!node || typeof node !== "string") {
+ return "All nodes must be valid address strings.";
+ }
+ if (containsWhitespace(node)) {
+ return "Node addresses cannot contain whitespace
characters.";
+ }
+ }
+
+ const response = await createShard(namespace, cluster, nodes,
password);
+ if (response === "") {
+ // Refresh the page to show the new shard
+ window.location.reload();
+ } else {
+ return response || "Failed to create shard";
+ }
+ } catch (error) {
+ console.error("Error in shard creation:", error);
+ return `Failed to create shard: ${error instanceof Error ?
error.message : "Unknown error"}`;
}
};
@@ -244,7 +265,7 @@ export const ImportCluster: React.FC<ClusterFormProps> = ({
position, namespace,
if (response === "") {
router.push(`/namespaces/${namespace}/clusters/${cluster}`);
} else {
- return "Invalid form data";
+ return response || "Failed to import cluster";
}
};
@@ -288,11 +309,80 @@ export const MigrateSlot: React.FC<ShardFormProps> = ({
position, namespace, clu
const slot = parseInt(formObj["slot"] as string);
const slotOnly = formObj["slot_only"] === "true";
+ // Basic Validation for numeric inputs
+ if (isNaN(target) || target < 0) {
+ return "Target shard index must be a valid non-negative number.";
+ }
+ if (isNaN(slot) || slot < 0 || slot > 16383) {
+ return "Slot must be a valid number between 0 and 16383.";
+ }
+
+ try {
+ const shards = await listShards(namespace, cluster);
+ if (!shards || !Array.isArray(shards)) {
+ return "Failed to fetch shard information. Please check if the
cluster exists.";
+ }
+
+ if (target >= shards.length) {
+ return `Target shard index ${target} does not exist. Available
shards: 0-${shards.length - 1}.`;
+ }
+
+ let sourceShardIndex = -1;
+ for (let i = 0; i < shards.length; i++) {
+ const shard = shards[i] as any;
+ if (shard.slot_ranges && Array.isArray(shard.slot_ranges)) {
+ for (const range of shard.slot_ranges) {
+ let start: number, end: number;
+ if (range.includes("-")) {
+ [start, end] = range.split("-").map(Number);
+ } else {
+ // Single slot, not a range
+ start = end = Number(range);
+ }
+ if (slot >= start && slot <= end) {
+ sourceShardIndex = i;
+ break;
+ }
+ }
+ if (sourceShardIndex !== -1) break;
+ }
+ }
+
+ if (sourceShardIndex === target) {
+ return `Cannot migrate slot ${slot} to the same shard
(${target}). The slot is already in shard ${sourceShardIndex}.`;
+ }
+
+ if (sourceShardIndex === -1) {
+ return `Slot ${slot} is not currently assigned to any shard
and cannot be migrated.`;
+ }
+
+ const sourceShard = shards[sourceShardIndex] as any;
+ if (sourceShard.migrating_slot === slot) {
+ return `Slot ${slot} is already being migrated from shard
${sourceShardIndex}.`;
+ }
+
+ const targetShard = shards[target] as any;
+ if (targetShard.import_slot === slot) {
+ return `Slot ${slot} is already being imported to shard
${target}.`;
+ }
+ } catch (error) {
+ console.error("Error validating migration:", error);
+ }
+
const response = await migrateSlot(namespace, cluster, target, slot,
slotOnly);
if (response === "") {
window.location.reload();
} else {
- return "Invalid form data";
+ // Handle specific error messages from the API
+ if (response.includes("source and target shard is same")) {
+ return "Migration failed: The source and target shards are the
same. Please select a different target shard.";
+ } else if (response.includes("the entry does not exist")) {
+ return "Migration failed: The specified cluster, shard, or
slot does not exist.";
+ } else if (response.includes("already existed")) {
+ return "Migration failed: The slot is already being migrated
or exists in the target shard.";
+ } else {
+ return `Migration failed: ${response}`;
+ }
}
};
@@ -302,13 +392,13 @@ export const MigrateSlot: React.FC<ShardFormProps> = ({
position, namespace, clu
title="Migrate Slot"
submitButtonLabel="Migrate"
formFields={[
- { name: "target", label: "Input Target", type: "text",
required: true },
- { name: "slot", label: "Input Slot", type: "text", required:
true },
+ { name: "target", label: "Target Shard Index", type: "text",
required: true },
+ { name: "slot", label: "Slot Number (0-16383)", type: "text",
required: true },
{
name: "slot_only",
- label: "Slot Only",
+ label: "Slot Only Migration",
type: "enum",
- values: ["true", "false"],
+ values: ["false", "true"],
required: true,
},
]}
@@ -346,7 +436,7 @@ export const NodeCreation: React.FC<NodeFormProps> = ({
if (response === "") {
window.location.reload();
} else {
- return "Invalid form data";
+ return response || "Failed to create node";
}
};