This is an automated email from the ASF dual-hosted git repository.

jihuayu 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 fe7bc35  Implement the shard management UI for Kvrocks controller 
(#202)
fe7bc35 is described below

commit fe7bc358ed3ed18817118b37902929974811a06b
Author: Vinayak Sharma <[email protected]>
AuthorDate: Tue Sep 3 06:09:10 2024 +0530

    Implement the shard management UI for Kvrocks controller (#202)
---
 webui/src/app/lib/api.ts                           |  75 ++++++++++-
 .../[namespace]/clusters/[cluster]/page.tsx        |  77 ++++++++++-
 .../{page.tsx => shards/[shard]/layout.tsx}        |  37 ++---
 .../[shard]/nodes/[node]/layout.tsx}               |  37 ++---
 .../{ => shards/[shard]/nodes/[node]}/page.tsx     |  18 +--
 .../clusters/[cluster]/shards/[shard]/page.tsx     | 119 ++++++++++++++++
 webui/src/app/namespaces/[namespace]/page.tsx      | 120 ++++++++---------
 webui/src/app/ui/createCard.tsx                    |  83 ++++++++----
 webui/src/app/ui/formCreation.tsx                  |  65 ++++++++-
 .../[cluster]/page.tsx => ui/loadingSpinner.tsx}   |  41 +++---
 webui/src/app/ui/sidebar.tsx                       |  93 +++++++++++--
 webui/src/app/ui/sidebarItem.tsx                   | 150 +++++++++++++++------
 12 files changed, 675 insertions(+), 240 deletions(-)

diff --git a/webui/src/app/lib/api.ts b/webui/src/app/lib/api.ts
index b063d7d..63eb607 100644
--- a/webui/src/app/lib/api.ts
+++ b/webui/src/app/lib/api.ts
@@ -58,7 +58,7 @@ export async function deleteNamespace(name: string): 
Promise<string> {
         const { data: responseData } = await axios.delete(
             `${apiHost}/namespaces/${name}`
         );
-        if (responseData?.data == "ok") {
+        if (responseData.data == null) {
             return "";
         } else {
             return handleError(responseData);
@@ -125,7 +125,78 @@ export async function deleteCluster(
         const { data: responseData } = await axios.delete(
             `${apiHost}/namespaces/${namespace}/clusters/${cluster}`
         );
-        if (responseData?.data == "ok") {
+        if (responseData.data == null) {
+            return "";
+        } else {
+            return handleError(responseData);
+        }
+    } catch (error) {
+        return handleError(error);
+    }
+}
+
+export async function createShard(
+    namespace: string,
+    cluster: string,
+    nodes: string[],
+    password: string
+): Promise<string> {
+    try {
+        const { data: responseData } = await axios.post(
+            `${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards`,
+            { nodes, password }
+        );
+        if (responseData?.data != undefined) {
+            return "";
+        } else {
+            return handleError(responseData);
+        }
+    } catch (error) {
+        return handleError(error);
+    }
+}
+
+export async function fetchShard(
+    namespace: string,
+    cluster: string,
+    shard: string
+): Promise<Object> {
+    try {
+        const { data: responseData } = await axios.get(
+            
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards/${shard}`
+        );
+        return responseData.data.shard;
+    } catch (error) {
+        handleError(error);
+        return {};
+    }
+}
+
+export async function listShards(
+    namespace: string,
+    cluster: string
+): Promise<Object[]> {
+    try {
+        const { data: responseData } = await axios.get(
+            `${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards`
+        );
+        return responseData.data.shards || [];
+    } catch (error) {
+        handleError(error);
+        return [];
+    }
+}
+
+export async function deleteShard(
+    namespace: string,
+    cluster: string,
+    shard: string
+): Promise<string> {
+    try {
+        const { data: responseData } = await axios.delete(
+            
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards/${shard}`
+        );
+        if (responseData.data == null) {
             return "";
         } else {
             return handleError(responseData);
diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
index d8014d8..16f8151 100644
--- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
+++ b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
@@ -17,14 +17,55 @@
  * under the License.
  */
 
-'use client';
+"use client";
 
-import { Box, Container, Card, Typography } from "@mui/material";
+import {
+    Box,
+    Container,
+    Typography
+} from "@mui/material";
 import { ClusterSidebar } from "../../../../ui/sidebar";
+import { useState, useEffect } from "react";
+import { listShards } from "@/app/lib/api";
+import { AddShardCard, CreateCard } from "@/app/ui/createCard";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { LoadingSpinner } from "@/app/ui/loadingSpinner";
 
-export default function Cluster() {
-    const url=window.location.href;
-    const namespace = url.split("/", 5)[4];
+export default function Cluster({
+    params,
+}: {
+  params: { namespace: string; cluster: string };
+}) {
+    const { namespace, cluster } = params;
+    const [shardsData, setShardsData] = useState<any[]>([]);
+    const [loading, setLoading] = useState<boolean>(true);
+    const router = useRouter();
+
+    useEffect(() => {
+        const fetchData = async () => {
+            try {
+                const fetchedShards = await listShards(namespace, cluster);
+
+                if (!fetchedShards) {
+                    console.error(`Shards not found`);
+                    router.push("/404");
+                    return;
+                }
+
+                setShardsData(fetchedShards);
+            } catch (error) {
+                console.error("Error fetching shards:", error);
+            } finally {
+                setLoading(false);
+            }
+        };
+        fetchData();
+    }, [namespace, cluster, router]);
+
+    if (loading) {
+        return <LoadingSpinner />;
+    }
 
     return (
         <div className="flex h-full">
@@ -35,7 +76,31 @@ export default function Cluster() {
                 sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
             >
                 <div className="flex flex-row flex-wrap">
-                    This is the cluster page
+                    <AddShardCard namespace={namespace} cluster={cluster} />
+                    {shardsData.map((shard, index) => (
+                        <Link
+                            key={index}
+                            
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${index}`}
+                        >
+                            <CreateCard>
+                                <Typography variant="h6" gutterBottom noWrap>
+                  Shard {index + 1}
+                                </Typography>
+                                <Typography variant="body2" gutterBottom>
+                  Nodes : {shard.nodes.length}
+                                </Typography>
+                                <Typography variant="body2" gutterBottom>
+                  Slots: {shard.slot_ranges.join(", ")}
+                                </Typography>
+                                <Typography variant="body2" gutterBottom>
+                  Target Shard Index: {shard.target_shard_index}
+                                </Typography>
+                                <Typography variant="body2" gutterBottom>
+                  Migrating Slot: {shard.migrating_slot}
+                                </Typography>
+                            </CreateCard>
+                        </Link>
+                    ))}
                 </div>
             </Container>
         </div>
diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/layout.tsx
similarity index 51%
copy from webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
copy to 
webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/layout.tsx
index d8014d8..0c4c376 100644
--- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
+++ 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/layout.tsx
@@ -1,4 +1,4 @@
-/*
+/* 
  * 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
@@ -6,38 +6,21 @@
  * 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.
+ * under the License. 
  */
 
-'use client';
-
-import { Box, Container, Card, Typography } from "@mui/material";
-import { ClusterSidebar } from "../../../../ui/sidebar";
-
-export default function Cluster() {
-    const url=window.location.href;
-    const namespace = url.split("/", 5)[4];
-
+export default function Layout({children}: {children: React.ReactNode}) {
     return (
-        <div className="flex h-full">
-            <ClusterSidebar namespace={namespace} />
-            <Container
-                maxWidth={false}
-                disableGutters
-                sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
-            >
-                <div className="flex flex-row flex-wrap">
-                    This is the cluster page
-                </div>
-            </Container>
-        </div>
-    );
-}
+        <>
+            {children}
+        </>
+    )
+}
\ No newline at end of file
diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/layout.tsx
similarity index 51%
copy from webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
copy to 
webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/layout.tsx
index d8014d8..0c4c376 100644
--- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
+++ 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/layout.tsx
@@ -1,4 +1,4 @@
-/*
+/* 
  * 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
@@ -6,38 +6,21 @@
  * 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.
+ * under the License. 
  */
 
-'use client';
-
-import { Box, Container, Card, Typography } from "@mui/material";
-import { ClusterSidebar } from "../../../../ui/sidebar";
-
-export default function Cluster() {
-    const url=window.location.href;
-    const namespace = url.split("/", 5)[4];
-
+export default function Layout({children}: {children: React.ReactNode}) {
     return (
-        <div className="flex h-full">
-            <ClusterSidebar namespace={namespace} />
-            <Container
-                maxWidth={false}
-                disableGutters
-                sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
-            >
-                <div className="flex flex-row flex-wrap">
-                    This is the cluster page
-                </div>
-            </Container>
-        </div>
-    );
-}
+        <>
+            {children}
+        </>
+    )
+}
\ No newline at end of file
diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
similarity index 73%
copy from webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
copy to 
webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
index d8014d8..b9e69b1 100644
--- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
+++ 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
@@ -17,25 +17,21 @@
  * under the License.
  */
 
-'use client';
-
-import { Box, Container, Card, Typography } from "@mui/material";
-import { ClusterSidebar } from "../../../../ui/sidebar";
-
-export default function Cluster() {
-    const url=window.location.href;
-    const namespace = url.split("/", 5)[4];
+import { Box, Container, Card, Alert, Snackbar } from "@mui/material";
 
+export default function Node() {
+    
     return (
         <div className="flex h-full">
-            <ClusterSidebar namespace={namespace} />
             <Container
                 maxWidth={false}
                 disableGutters
                 sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
             >
-                <div className="flex flex-row flex-wrap">
-                    This is the cluster page
+                <div>
+                    <Box className="flex">
+                        This is the nodes page.
+                    </Box>
                 </div>
             </Container>
         </div>
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
new file mode 100644
index 0000000..bfc517c
--- /dev/null
+++ 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/page.tsx
@@ -0,0 +1,119 @@
+/*
+ * 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 { Container, Typography, Tooltip } from "@mui/material";
+import { ShardSidebar } from "@/app/ui/sidebar";
+import { fetchShard } from "@/app/lib/api";
+import { useRouter } from "next/navigation";
+import { useState, useEffect } from "react";
+import { AddShardCard, CreateCard } from "@/app/ui/createCard";
+import Link from "next/link";
+import { LoadingSpinner } from "@/app/ui/loadingSpinner";
+
+const truncateText = (text: string, limit: number) => {
+    return text.length > limit ? `${text.slice(0, limit)}...` : text;
+};
+
+export default function Shard({
+    params,
+}: {
+    params: { namespace: string; cluster: string; shard: string };
+}) {
+    const { namespace, cluster, shard } = params;
+    const [nodesData, setNodesData] = useState<any>(null);
+    const [loading, setLoading] = useState<boolean>(true);
+    const router = useRouter();
+
+    useEffect(() => {
+        const fetchData = async () => {
+            try {
+                const fetchedNodes = await fetchShard(namespace, cluster, 
shard);
+                if (!fetchedNodes) {
+                    console.error(`Shard ${shard} not found`);
+                    router.push("/404");
+                    return;
+                }
+                setNodesData(fetchedNodes);
+            } catch (error) {
+                console.error("Error fetching shard data:", error);
+            } finally {
+                setLoading(false);
+            }
+        };
+
+        fetchData();
+    }, [namespace, cluster, shard, router]);
+
+    if (loading) {
+        return <LoadingSpinner />;
+    }
+
+    return (
+        <div className="flex h-full">
+            <ShardSidebar namespace={namespace} cluster={cluster} />
+            <Container
+                maxWidth={false}
+                disableGutters
+                sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
+            >
+                <div className="flex flex-row flex-wrap">
+                    <AddShardCard namespace={namespace} cluster={cluster} />
+                    {nodesData.nodes.map(
+                        (node: any, index: number) => (
+                            <Link
+                                
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes/${index}`}
+                                key={index}
+                            >
+                                <CreateCard>
+                                    <Typography variant="h6" gutterBottom>
+                                        Node {index + 1}
+                                    </Typography>
+                                    <Tooltip title={node.id}>
+                                        <Typography
+                                            variant="body2"
+                                            gutterBottom
+                                            sx={{
+                                                whiteSpace: "nowrap",
+                                                overflow: "hidden",
+                                                textOverflow: "ellipsis",
+                                            }}
+                                        >
+                                            ID: {truncateText(node.id, 20)}
+                                        </Typography>
+                                    </Tooltip>
+                                    <Typography variant="body2" gutterBottom>
+                                        Address: {node.addr}
+                                    </Typography>
+                                    <Typography variant="body2" gutterBottom>
+                                        Role: {node.role}
+                                    </Typography>
+                                    <Typography variant="body2" gutterBottom>
+                                        Created At: {new Date(node.created_at 
* 1000).toLocaleString()}
+                                    </Typography>
+                                </CreateCard>
+                            </Link>
+                        )
+                    )}
+                </div>
+            </Container>
+        </div>
+    );
+}
diff --git a/webui/src/app/namespaces/[namespace]/page.tsx 
b/webui/src/app/namespaces/[namespace]/page.tsx
index 4ee1aaa..6f06b9f 100644
--- a/webui/src/app/namespaces/[namespace]/page.tsx
+++ b/webui/src/app/namespaces/[namespace]/page.tsx
@@ -19,56 +19,61 @@
 
 "use client";
 
-import { Box, Container, Card, Typography } from "@mui/material";
+import { Container, Typography } from "@mui/material";
 import { NamespaceSidebar } from "../../ui/sidebar";
-import { AddClusterCardProps, CreateCard } from "../../ui/createCard";
-import {
-    Cluster,
-    fetchCluster,
-    fetchClusters,
-    fetchNamespaces,
-} from "@/app/lib/api";
+import { AddClusterCard, CreateCard } from "../../ui/createCard";
+import { fetchCluster, fetchClusters, fetchNamespaces } from "@/app/lib/api";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
 import { useState, useEffect } from "react";
+import { LoadingSpinner } from "@/app/ui/loadingSpinner";
 
-export default function Namespace({ params }: { params: { namespace: string } 
}) {
-    const [namespaces, setNamespaces] = useState<string[]>([]);
-    const [namespace, setNamespace] = useState<string>("");
+export default function Namespace({
+    params,
+}: {
+  params: { namespace: string };
+}) {
     const [clusterData, setClusterData] = useState<any[]>([]);
+    const [loading, setLoading] = useState<boolean>(true);
     const router = useRouter();
 
     useEffect(() => {
         const fetchData = async () => {
             try {
                 const fetchedNamespaces = await fetchNamespaces();
-                setNamespaces(fetchedNamespaces);
 
-                setNamespace(params.namespace);
-                if (!namespaces.includes(params.namespace)) {
-                    router.push('/404');
+                if (!fetchedNamespaces.includes(params.namespace)) {
+                    console.error(`Namespace ${params.namespace} not found`);
+                    router.push("/404");
                     return;
                 }
 
                 const clusters = await fetchClusters(params.namespace);
                 const data = await Promise.all(
-                    clusters.map(async (cluster) => {
-                        try {
-                            return await fetchCluster(params.namespace, 
cluster);
-                        } catch (error) {
-                            console.error(`Failed to fetch data for cluster 
${cluster}:`, error);
+                    clusters.map((cluster) =>
+                        fetchCluster(params.namespace, cluster).catch((error) 
=> {
+                            console.error(
+                                `Failed to fetch data for cluster ${cluster}:`,
+                                error
+                            );
                             return null;
-                        }
-                    })
+                        })
+                    )
                 );
-                setClusterData(data.filter(Boolean)); // Filter out null values
+                setClusterData(data.filter(Boolean));
             } catch (error) {
                 console.error("Error fetching data:", error);
+            } finally {
+                setLoading(false);
             }
         };
 
         fetchData();
-    }, [namespaces, params.namespace, router]);
+    }, [params.namespace, router]);
+
+    if (loading) {
+        return <LoadingSpinner />;
+    }
 
     return (
         <div className="flex h-full">
@@ -79,42 +84,37 @@ export default function Namespace({ params }: { params: { 
namespace: string } })
                 sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
             >
                 <div className="flex flex-row flex-wrap">
-                    <CreateCard>
-                        <AddClusterCardProps namespace={params.namespace} />
-                    </CreateCard>
-                    {clusterData.length !== 0
-                        ? clusterData.map(
-                            (data: any, index) =>
-                                data && (
-                                    <Link
-                                        
href={`/namespaces/${namespace}/clusters/${data.name}`}
-                                        key={index}
-                                    >
-                                        <CreateCard>
-                                            <Typography variant="h6" 
gutterBottom>
-                                                {data.name}
-                                            </Typography>
-                                            <Typography variant="body2" 
gutterBottom>
-                                                Version: {data.version}
-                                            </Typography>
-                                            <Typography variant="body2" 
gutterBottom>
-                                                Nodes: 
{data.shards[0].nodes.length}
-                                            </Typography>
-                                            <Typography variant="body2" 
gutterBottom>
-                                                Slots: 
{data.shards[0].slot_ranges.join(", ")}
-                                            </Typography>
-                                            <Typography variant="body2" 
gutterBottom>
-                                                Target Shard Index:{" "}
-                                                
{data.shards[0].target_shard_index}
-                                            </Typography>
-                                            <Typography variant="body2" 
gutterBottom>
-                                                Migrating Slot: 
{data.shards[0].migrating_slot}
-                                            </Typography>
-                                        </CreateCard>
-                                    </Link>
-                                )
-                        )
-                        : null}
+                    <AddClusterCard namespace={params.namespace} />
+                    {clusterData.map(
+                        (data, index) =>
+                            data && (
+                                <Link
+                                    
href={`/namespaces/${params.namespace}/clusters/${data.name}`}
+                                    key={index}
+                                >
+                                    <CreateCard>
+                                        <Typography variant="h6" gutterBottom>
+                                            {data.name}
+                                        </Typography>
+                                        <Typography variant="body2" 
gutterBottom>
+                      Version: {data.version}
+                                        </Typography>
+                                        <Typography variant="body2" 
gutterBottom>
+                      Shards: {data.shards.length}
+                                        </Typography>
+                                        <Typography variant="body2" 
gutterBottom>
+                      Slots: {data.shards[0].slot_ranges.join(", ")}
+                                        </Typography>
+                                        <Typography variant="body2" 
gutterBottom>
+                      Target Shard Index: {data.shards[0].target_shard_index}
+                                        </Typography>
+                                        <Typography variant="body2" 
gutterBottom>
+                      Migrating Slot: {data.shards[0].migrating_slot}
+                                        </Typography>
+                                    </CreateCard>
+                                </Link>
+                            )
+                    )}
                 </div>
             </Container>
         </div>
diff --git a/webui/src/app/ui/createCard.tsx b/webui/src/app/ui/createCard.tsx
index 3e1a87e..0534a14 100644
--- a/webui/src/app/ui/createCard.tsx
+++ b/webui/src/app/ui/createCard.tsx
@@ -19,9 +19,9 @@
 
 "use client";
 
-import { Card } from "@mui/material";
+import { Card, Box } from "@mui/material";
 import React, { ReactNode } from "react";
-import { ClusterCreation } from "./formCreation";
+import { ClusterCreation, ShardCreation } from "./formCreation";
 import { faCirclePlus } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 
@@ -31,35 +31,37 @@ interface CreateCardProps {
 
 export const CreateCard: React.FC<CreateCardProps> = ({ children }) => {
     return (
-        <Card
-            variant="outlined"
-            sx={{
-                width: "300px",
-                height: "200px",
-                padding: "16px",
-                margin: "16px",
-                borderRadius: "16px",
-                transition: "transform 0.1s, box-shadow 0.3s",
-                boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
-                "&:hover": {
-                    transform: "scale(1.01)",
-                    boxShadow: "0 4px 8px rgba(0, 0, 0, 0.2)",
-                },
-                cursor: "pointer",
-                display: "flex",
-                flexDirection: "column",
-                alignItems: "center",
-                justifyContent: "center",
-            }}
-        >
-            {children}
-        </Card>
+        <Box sx={{ position: "relative", display: "inline-block" }}>
+            <Card
+                variant="outlined"
+                sx={{
+                    width: "300px",
+                    height: "200px",
+                    padding: "16px",
+                    margin: "16px",
+                    borderRadius: "16px",
+                    transition: "transform 0.1s, box-shadow 0.3s",
+                    boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
+                    "&:hover": {
+                        transform: "scale(1.01)",
+                        boxShadow: "0 4px 8px rgba(0, 0, 0, 0.2)",
+                    },
+                    cursor: "pointer",
+                    display: "flex",
+                    flexDirection: "column",
+                    alignItems: "center",
+                    justifyContent: "center",
+                }}
+            >
+                {children}
+            </Card>
+        </Box>
     );
 };
 
-export const AddClusterCardProps = ({ namespace }: { namespace: string }) => {
+export const AddClusterCard = ({ namespace }: { namespace: string }) => {
     return (
-        <>
+        <CreateCard>
             <FontAwesomeIcon
                 icon={faCirclePlus}
                 size="4x"
@@ -72,6 +74,31 @@ export const AddClusterCardProps = ({ namespace }: { 
namespace: string }) => {
             <div className="mt-4">
                 <ClusterCreation position="card" namespace={namespace} />
             </div>
-        </>
+        </CreateCard>
+    );
+};
+
+export const AddShardCard = ({
+    namespace,
+    cluster,
+}: {
+  namespace: string;
+  cluster: string;
+}) => {
+    return (
+        <CreateCard>
+            <FontAwesomeIcon
+                icon={faCirclePlus}
+                size="4x"
+                style={{
+                    color: "#e0e0e0",
+                    marginBottom: "8px",
+                    transition: "color 0.2s",
+                }}
+            />
+            <div className="mt-4">
+                <ShardCreation position="card" namespace={namespace} 
cluster={cluster} />
+            </div>
+        </CreateCard>
     );
 };
diff --git a/webui/src/app/ui/formCreation.tsx 
b/webui/src/app/ui/formCreation.tsx
index 5304e91..3dc7d00 100644
--- a/webui/src/app/ui/formCreation.tsx
+++ b/webui/src/app/ui/formCreation.tsx
@@ -20,7 +20,7 @@
 "use client";
 import React from "react";
 import FormDialog from "./formDialog";
-import { createCluster, createNamespace } from "../lib/api";
+import { createCluster, createNamespace, createShard } from "../lib/api";
 import { useRouter } from "next/navigation";
 
 type NamespaceFormProps = {
@@ -32,6 +32,12 @@ type ClusterFormProps = {
   namespace: string;
 };
 
+type ShardFormProps = {
+    position: string;
+    namespace: string;
+    cluster: string;
+    };
+
 const containsWhitespace = (value: string): boolean => /\s/.test(value);
 
 const validateFormData = (formData: FormData, fields: string[]): string | null 
=> {
@@ -59,8 +65,9 @@ export const NamespaceCreation: React.FC<NamespaceFormProps> 
= ({
         const response = await createNamespace(formObj["name"] as string);
         if (response === "") {
             router.push(`/namespaces/${formObj["name"]}`);
+        }else{
+            return "Invalid form data";
         }
-        return "Invalid form data";
     };
 
     return (
@@ -109,9 +116,9 @@ export const ClusterCreation: React.FC<ClusterFormProps> = 
({
         );
         if (response === "") {
             
router.push(`/namespaces/${namespace}/clusters/${formObj["name"]}`);
-            return;
+        }else{
+            return "Invalid form data";
         }
-        return "Invalid form data";
     };
 
     return (
@@ -139,3 +146,53 @@ export const ClusterCreation: React.FC<ClusterFormProps> = 
({
         />
     );
 };
+
+
+export const ShardCreation: React.FC<ShardFormProps> = ({
+    position,
+    namespace,
+    cluster,
+}) => {
+    const router = useRouter();
+    const handleSubmit = async (formData: FormData) => {
+        const fieldsToValidate = ["nodes", "password"];
+        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;
+
+        if (nodes.length === 0) {
+            return "Nodes cannot be empty.";
+        }
+
+        for (const node of nodes) {
+            if (containsWhitespace(node)) {
+                return "Nodes cannot contain any whitespace characters.";
+            }
+        }
+
+        const response = await createShard(namespace, cluster, nodes, 
password);
+        if (response === "") {
+            router.push(`/namespaces/${namespace}/clusters/${cluster}`);
+        }else{
+            return "Invalid form data";
+        }
+    };
+
+    return (
+        <FormDialog
+            position={position}
+            title="Create Shard"
+            submitButtonLabel="Create"
+            formFields={[
+                { name: "nodes", label: "Input Nodes", type: "array", 
required: true },
+                { name: "password", label: "Input Password", type: "text", 
required: true },
+            ]}
+            onSubmit={handleSubmit}
+        />
+    );
+};
\ No newline at end of file
diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx 
b/webui/src/app/ui/loadingSpinner.tsx
similarity index 54%
copy from webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
copy to webui/src/app/ui/loadingSpinner.tsx
index d8014d8..51f7e11 100644
--- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
+++ b/webui/src/app/ui/loadingSpinner.tsx
@@ -6,9 +6,9 @@
  * 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
@@ -17,27 +17,24 @@
  * under the License.
  */
 
-'use client';
+"use client";
 
-import { Box, Container, Card, Typography } from "@mui/material";
-import { ClusterSidebar } from "../../../../ui/sidebar";
-
-export default function Cluster() {
-    const url=window.location.href;
-    const namespace = url.split("/", 5)[4];
+import React from 'react';
+import { Box, CircularProgress } from '@mui/material';
 
+export const LoadingSpinner: React.FC = () => {
     return (
-        <div className="flex h-full">
-            <ClusterSidebar namespace={namespace} />
-            <Container
-                maxWidth={false}
-                disableGutters
-                sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
-            >
-                <div className="flex flex-row flex-wrap">
-                    This is the cluster page
-                </div>
-            </Container>
-        </div>
+        <Box
+            sx={{
+                display: 'flex',
+                alignItems: 'center',
+                justifyContent: 'center',
+                height: '100%',
+                width: '100%',
+                minHeight: '200px',
+            }}
+        >
+            <CircularProgress />
+        </Box>
     );
-}
+};
\ No newline at end of file
diff --git a/webui/src/app/ui/sidebar.tsx b/webui/src/app/ui/sidebar.tsx
index 4a0ef31..c9133b4 100644
--- a/webui/src/app/ui/sidebar.tsx
+++ b/webui/src/app/ui/sidebar.tsx
@@ -17,12 +17,12 @@
  * under the License.
  */
 
-'use client';
+"use client";
 
-import { Divider, List } from "@mui/material";
-import { fetchClusters, fetchNamespaces } from "@/app/lib/api";
+import { Divider, List, Typography } from "@mui/material";
+import { fetchClusters, fetchNamespaces, listShards } from "@/app/lib/api";
 import Item from "./sidebarItem";
-import { ClusterCreation, NamespaceCreation } from "./formCreation";
+import { ClusterCreation, NamespaceCreation, ShardCreation } from 
"./formCreation";
 import Link from "next/link";
 import { useState, useEffect } from "react";
 
@@ -48,18 +48,22 @@ export function NamespaceSidebar() {
                 <div className="mt-2 mb-4 text-center">
                     <NamespaceCreation position="sidebar" />
                 </div>
-                {error && <p style={{ color: "red" }}>{error}</p>}
+                {error && (
+                    <Typography color="error" align="center">
+                        {error}
+                    </Typography>
+                )}
                 {namespaces.map((namespace) => (
                     <div key={namespace}>
-                        <Divider variant="fullWidth" />
+                        <Divider />
                         <Link href={`/namespaces/${namespace}`} passHref>
                             <Item type="namespace" item={namespace} />
                         </Link>
                     </div>
                 ))}
-                <Divider variant="fullWidth" />
+                <Divider />
             </List>
-            <Divider orientation="vertical" variant="fullWidth" flexItem />
+            <Divider orientation="vertical" flexItem />
         </div>
     );
 }
@@ -86,18 +90,79 @@ export function ClusterSidebar({ namespace }: { namespace: 
string }) {
                 <div className="mt-2 mb-4 text-center">
                     <ClusterCreation namespace={namespace} position="sidebar" 
/>
                 </div>
-                {error && <p style={{ color: "red" }}>{error}</p>}
+                {error && (
+                    <Typography color="error" align="center">
+                        {error}
+                    </Typography>
+                )}
                 {clusters.map((cluster) => (
                     <div key={cluster}>
-                        <Divider variant="fullWidth" />
-                        <Link 
href={`/namespaces/${namespace}/clusters/${cluster}`} passHref>
-                            <Item type="cluster" item={cluster} 
namespace={namespace}/>
+                        <Divider />
+                        <Link
+                            
href={`/namespaces/${namespace}/clusters/${cluster}`}
+                            passHref
+                        >
+                            <Item type="cluster" item={cluster} 
namespace={namespace} />
                         </Link>
                     </div>
                 ))}
-                <Divider variant="fullWidth" />
+                <Divider />
             </List>
-            <Divider orientation="vertical" variant="fullWidth" flexItem />
+            <Divider orientation="vertical" flexItem />
         </div>
     );
 }
+
+export function ShardSidebar({
+    namespace,
+    cluster,
+}: {
+  namespace: string;
+  cluster: string;
+}) {
+    const [shards, setShards] = useState<string[]>([]);
+    const [error, setError] = useState<string | null>(null);
+
+    useEffect(() => {
+        const fetchData = async () => {
+            try {
+                const fetchedShards = await listShards(namespace, cluster);
+                const shardsIndex = fetchedShards.map(
+                    (shard, index) => "Shard\t" + (index+1).toString()
+                );
+                setShards(shardsIndex);
+            } catch (err) {
+                setError("Failed to fetch shards");
+            }
+        };
+        fetchData();
+    }, [namespace, cluster]);
+
+    return (
+        <div className="w-60 h-full flex">
+            <List className="w-full overflow-y-auto">
+                <div className="mt-2 mb-4 text-center">
+                    <ShardCreation namespace={namespace} cluster={cluster} 
position="sidebar" />
+                </div>
+                {error && (
+                    <Typography color="error" align="center">
+                        {error}
+                    </Typography>
+                )}
+                {shards.map((shard,index) => (
+                    <div key={shard}>
+                        <Divider />
+                        <Link
+                            
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${index}`}
+                            passHref
+                        >
+                            <Item type="shard" item={shard} 
namespace={namespace} cluster={cluster} />
+                        </Link>
+                    </div>
+                ))}
+                <Divider />
+            </List>
+            <Divider orientation="vertical" flexItem />
+        </div>
+    );
+}
\ No newline at end of file
diff --git a/webui/src/app/ui/sidebarItem.tsx b/webui/src/app/ui/sidebarItem.tsx
index f0656dc..c85fc1c 100644
--- a/webui/src/app/ui/sidebarItem.tsx
+++ b/webui/src/app/ui/sidebarItem.tsx
@@ -6,39 +6,62 @@
  * 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. 
+ * under the License.
  */
 
-'use client';
-import { Button, Dialog, DialogActions, DialogContent, DialogContentText, 
DialogTitle, IconButton, ListItem, ListItemButton, ListItemText, Menu, 
MenuItem, Tooltip } from "@mui/material";
-import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
+"use client";
+import {
+    Alert,
+    Button,
+    Dialog,
+    DialogActions,
+    DialogContent,
+    DialogContentText,
+    DialogTitle,
+    IconButton,
+    ListItem,
+    ListItemButton,
+    ListItemText,
+    Menu,
+    MenuItem,
+    Snackbar,
+    Tooltip,
+} from "@mui/material";
+import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
 import { useCallback, useRef, useState } from "react";
 import { usePathname } from "next/navigation";
 import { useRouter } from "next/navigation";
-import { deleteCluster, deleteNamespace } from "../lib/api";
+import { deleteCluster, deleteNamespace, deleteShard } from "../lib/api";
 import { faTrash } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 
 interface NamespaceItemProps {
-    item: string;
-    type: 'namespace';
+  item: string;
+  type: "namespace";
 }
 
 interface ClusterItemProps {
-    item: string;
-    type: 'cluster';
-    namespace: string;
+  item: string;
+  type: "cluster";
+  namespace: string;
 }
 
-type ItemProps = NamespaceItemProps | ClusterItemProps;
+interface ShardItemProps {
+  item: string;
+  type: "shard";
+  namespace: string;
+  cluster: string;
+}
+
+type ItemProps = NamespaceItemProps | ClusterItemProps | ShardItemProps;
 
 export default function Item(props: ItemProps) {
     const { item, type } = props;
@@ -46,49 +69,83 @@ export default function Item(props: ItemProps) {
     const [showMenu, setShowMenu] = useState<boolean>(false);
     const listItemTextRef = useRef(null);
     const openMenu = useCallback(() => setShowMenu(true), []);
-    const closeMenu = useCallback(() => (setShowMenu(false), setHover(false)), 
[]);
+    const closeMenu = useCallback(
+        () => (setShowMenu(false), setHover(false)),
+        []
+    );
     const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean>(false);
-    const openDeleteConfirmDialog = useCallback(() => 
(setShowDeleteConfirm(true), closeMenu()), [closeMenu]);
-    const closeDeleteConfirmDialog = useCallback(() => 
setShowDeleteConfirm(false), []);
+    const openDeleteConfirmDialog = useCallback(
+        () => (setShowDeleteConfirm(true), closeMenu()),
+        [closeMenu]
+    );
+    const closeDeleteConfirmDialog = useCallback(
+        () => setShowDeleteConfirm(false),
+        []
+    );
+    const [errorMessage, setErrorMessage] = useState<string>("");
+
     const router = useRouter();
+    let activeItem = usePathname().split("/").pop() || "";
 
     const confirmDelete = useCallback(async () => {
-        if (type === 'namespace') {
-            await deleteNamespace(item);
-            router.push('/namespaces');
+        let response="";
+        if (type === "namespace") {
+            response = await deleteNamespace(item);
+            if (response ===""){
+                router.push("/namespaces");
+            }
+            setErrorMessage(response);
             router.refresh();
-        } else if (type === 'cluster') {
+        } else if (type === "cluster") {
             const { namespace } = props as ClusterItemProps;
-            await deleteCluster(namespace, item);
-            router.push(`/namespaces/${namespace}`);
+            response = await deleteCluster(namespace, item);
+            if (response ===""){
+                router.push(`/namespaces/${namespace}`);
+            }
+            setErrorMessage(response);
+            router.refresh();
+        } else if (type === "shard") {
+            const { namespace, cluster } = props as ShardItemProps;
+            response = await deleteShard(namespace, cluster, 
(parseInt(item.split("\t")[1])-1).toString());
+            if (response ===""){
+                router.push(`/namespaces/${namespace}/clusters/${cluster}`);
+            }
+            setErrorMessage(response);
             router.refresh();
         }
         closeMenu();
     }, [item, type, props, closeMenu, router]);
 
-    const activeItem = usePathname().split('/')[type === 'namespace' ? 2 : 4];
+    if (type === "shard") {
+        activeItem = "Shard\t" + (parseInt(activeItem) + 1);
+    }
     const isActive = item === activeItem;
 
     return (
         <ListItem
             disablePadding
             secondaryAction={
-                hover && <IconButton onClick={openMenu} ref={listItemTextRef}>
-                    <MoreHorizIcon />
-                </IconButton>
+                hover && (
+                    <IconButton onClick={openMenu} ref={listItemTextRef}>
+                        <MoreHorizIcon />
+                    </IconButton>
+                )
             }
             onMouseEnter={() => setHover(true)}
             onMouseLeave={() => !showMenu && setHover(false)}
             sx={{
-                backgroundColor: isActive ? 'rgba(0, 0, 0, 0.1)' : 
'transparent',
-                '&:hover': {
-                    backgroundColor: 'rgba(0, 0, 0, 0.05)',
-                }
+                backgroundColor: isActive ? "rgba(0, 0, 0, 0.1)" : 
"transparent",
+                "&:hover": {
+                    backgroundColor: "rgba(0, 0, 0, 0.05)",
+                },
             }}
         >
-            <ListItemButton sx={{ paddingRight: '10px' }}>
+            <ListItemButton sx={{ paddingRight: "10px" }}>
                 <Tooltip title={item} arrow>
-                    <ListItemText classes={{ primary: 'overflow-hidden 
text-ellipsis text-nowrap' }} primary={`${item}`} />
+                    <ListItemText
+                        classes={{ primary: "overflow-hidden text-ellipsis 
text-nowrap" }}
+                        primary={`${item}`}
+                    />
                 </Tooltip>
             </ListItemButton>
             <Menu
@@ -97,28 +154,43 @@ export default function Item(props: ItemProps) {
                 onClose={closeMenu}
                 anchorEl={listItemTextRef.current}
                 anchorOrigin={{
-                    vertical: 'center',
-                    horizontal: 'center',
+                    vertical: "center",
+                    horizontal: "center",
                 }}
             >
                 <MenuItem onClick={openDeleteConfirmDialog}>
                     <FontAwesomeIcon icon={faTrash} color="red" />
                 </MenuItem>
             </Menu>
-            <Dialog
-                open={showDeleteConfirm}
-            >
+            <Dialog open={showDeleteConfirm}>
                 <DialogTitle>Confirm</DialogTitle>
                 <DialogContent>
                     <DialogContentText>
-                        Please confirm you want to delete {type} {item}
+            Please confirm you want to delete {type} {item}
                     </DialogContentText>
                 </DialogContent>
                 <DialogActions>
                     <Button onClick={closeDeleteConfirmDialog}>Cancel</Button>
-                    <Button onClick={confirmDelete} 
color="error">Delete</Button>
+                    <Button onClick={confirmDelete} color="error">
+            Delete
+                    </Button>
                 </DialogActions>
             </Dialog>
+            <Snackbar
+                open={!!errorMessage}
+                autoHideDuration={5000}
+                onClose={() => setErrorMessage("")}
+                anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
+            >
+                <Alert
+                    onClose={() => setErrorMessage("")}
+                    severity="error"
+                    variant="filled"
+                    sx={{ width: "100%" }}
+                >
+                    {errorMessage}
+                </Alert>
+            </Snackbar>
         </ListItem>
     );
 }

Reply via email to