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 a033316  Implement the node management, import cluster, migrate slot 
UI for Kvrocks controller (#204)
a033316 is described below

commit a033316a6aad76c69d29f9e2c3fa75b5d5e93dbd
Author: Vinayak Sharma <[email protected]>
AuthorDate: Sat Oct 5 13:04:38 2024 +0530

    Implement the node management, import cluster, migrate slot UI for Kvrocks 
controller (#204)
---
 webui/src/app/lib/api.ts                           | 108 +++++++++
 .../[cluster]/shards/[shard]/nodes/[node]/page.tsx |  95 +++++++-
 .../clusters/[cluster]/shards/[shard]/page.tsx     |   9 +-
 webui/src/app/namespaces/page.tsx                  |  58 ++++-
 webui/src/app/ui/createCard.tsx                    |  72 +++++-
 webui/src/app/ui/formCreation.tsx                  | 247 +++++++++++++++++++--
 webui/src/app/ui/formDialog.tsx                    |  73 ++++--
 webui/src/app/ui/sidebar.tsx                       | 110 ++++++++-
 webui/src/app/ui/sidebarItem.tsx                   |  64 +++++-
 .../[shard]/nodes/[node]/page.tsx => utils.ts}     |  23 +-
 10 files changed, 750 insertions(+), 109 deletions(-)

diff --git a/webui/src/app/lib/api.ts b/webui/src/app/lib/api.ts
index 63eb607..81be73a 100644
--- a/webui/src/app/lib/api.ts
+++ b/webui/src/app/lib/api.ts
@@ -135,6 +135,54 @@ export async function deleteCluster(
     }
 }
 
+export async function importCluster(
+    namespace: string,
+    cluster: string,
+    nodes: string[],
+    password: string
+): Promise<string> {
+    try {
+        const { data: responseData } = await axios.post(
+            `${apiHost}/namespaces/${namespace}/clusters/${cluster}/import`,
+            { nodes, password }
+        );
+        console.log("importCluster response", responseData);
+        if (responseData?.data != undefined) {
+            return "";
+        } else {
+            return handleError(responseData);
+        }
+    } catch (error) {
+        return handleError(error);
+    }
+}
+
+export async function migrateSlot(
+    namespace: string,
+    cluster: string,
+    target: number,
+    slot: number,
+    slotOnly: boolean
+): Promise<string> {
+    try {
+        const { data: responseData } = await axios.post(
+            `${apiHost}/namespaces/${namespace}/clusters/${cluster}/migrate`,
+            {
+                target: target,
+                slot: slot,
+                slot_only: slotOnly,
+            }
+        );
+        if (responseData?.data != undefined) {
+            return "";
+        } else {
+            return handleError(responseData);
+        }
+    } catch (error) {
+        return handleError(error);
+    }
+}
+
 export async function createShard(
     namespace: string,
     cluster: string,
@@ -206,6 +254,66 @@ export async function deleteShard(
     }
 }
 
+export async function createNode(
+    namespace: string,
+    cluster: string,
+    shard: string,
+    addr: string,
+    role: string,
+    password: string
+): Promise<string> {
+    try {
+        const { data: responseData } = await axios.post(
+            
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes`,
+            { addr, role, password }
+        );
+        if (responseData?.data == null) {
+            return "";
+        } else {
+            return handleError(responseData);
+        }
+    } catch (error) {
+        return handleError(error);
+    }
+}
+
+export async function listNodes(
+    namespace: string,
+    cluster: string,
+    shard: string
+): Promise<Object[]> {
+    try {
+        const { data: responseData } = await axios.get(
+            
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes`
+        );
+        return responseData.data.nodes || [];
+    } catch (error) {
+        handleError(error);
+        return [];
+    }
+}
+
+export async function deleteNode(
+    namespace: string,
+    cluster: string,
+    shard: string,
+    nodeId: string
+): Promise<string> {
+    try {
+        const { data: responseData } = await axios.delete(
+            
`${apiHost}/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes/${nodeId}`
+        );
+        if (responseData.data == null) {
+            return "";
+        } else {
+            return handleError(responseData);
+        }
+    } catch (error) {
+        console.log(error);
+        return handleError(error);
+    }
+}
+
 function handleError(error: any): string {
     let message: string = "";
     if (error instanceof AxiosError) {
diff --git 
a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
index b9e69b1..9ac6ea1 100644
--- 
a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
+++ 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
@@ -17,21 +17,102 @@
  * under the License.
  */
 
-import { Box, Container, Card, Alert, Snackbar } from "@mui/material";
+"use client";
+
+import { listNodes } from "@/app/lib/api";
+import { NodeSidebar } from "@/app/ui/sidebar";
+import {
+    Box,
+    Container,
+    Card,
+    Alert,
+    Snackbar,
+    Typography,
+    Tooltip,
+} from "@mui/material";
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { LoadingSpinner } from "@/app/ui/loadingSpinner";
+import { AddNodeCard, CreateCard } from "@/app/ui/createCard";
+import { truncateText } from "@/app/utils";
+
+export default function Node({
+    params,
+}: {
+  params: { namespace: string; cluster: string; shard: string; node: string };
+}) {
+    const { namespace, cluster, shard, node } = params;
+    const router = useRouter();
+    const [nodeData, setNodeData] = useState<any>(null);
+    const [loading, setLoading] = useState<boolean>(true);
+
+    useEffect(() => {
+        const fetchData = async () => {
+            try {
+                const fetchedNodes = await listNodes(namespace, cluster, 
shard);
+                if (!fetchedNodes) {
+                    console.error(`Shard ${shard} not found`);
+                    router.push("/404");
+                    return;
+                }
+                setNodeData(fetchedNodes);
+            } catch (error) {
+                console.error("Error fetching shard data:", error);
+            } finally {
+                setLoading(false);
+            }
+        };
+
+        fetchData();
+    }, [namespace, cluster, shard, router]);
+
+    if (loading) {
+        return <LoadingSpinner />;
+    }
 
-export default function Node() {
-    
     return (
         <div className="flex h-full">
+            <NodeSidebar namespace={namespace} cluster={cluster} shard={shard} 
/>
             <Container
                 maxWidth={false}
                 disableGutters
                 sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
             >
-                <div>
-                    <Box className="flex">
-                        This is the nodes page.
-                    </Box>
+                <div className="flex flex-row flex-wrap">
+                    {nodeData.map((nodeObj: any, index: number) =>
+                        index === Number(node) ? (
+                            <>
+                                <CreateCard>
+                                    <Typography variant="h6" gutterBottom>
+                    Node {index + 1}
+                                    </Typography>
+                                    <Tooltip title={nodeObj.id}>
+                                        <Typography
+                                            variant="body2"
+                                            gutterBottom
+                                            sx={{
+                                                whiteSpace: "nowrap",
+                                                overflow: "hidden",
+                                                textOverflow: "ellipsis",
+                                            }}
+                                        >
+                      ID: {truncateText(nodeObj.id, 20)}
+                                        </Typography>
+                                    </Tooltip>
+                                    <Typography variant="body2" gutterBottom>
+                    Address: {nodeObj.addr}
+                                    </Typography>
+                                    <Typography variant="body2" gutterBottom>
+                    Role: {nodeObj.role}
+                                    </Typography>
+                                    <Typography variant="body2" gutterBottom>
+                    Created At:{" "}
+                                        {new Date(nodeObj.created_at * 
1000).toLocaleString()}
+                                    </Typography>
+                                </CreateCard>
+                            </>
+                        ) : null
+                    )}
                 </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
index bfc517c..a6f453a 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
@@ -24,13 +24,10 @@ 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 { AddNodeCard, 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;
-};
+import { truncateText } from "@/app/utils";
 
 export default function Shard({
     params,
@@ -75,7 +72,7 @@ export default function Shard({
                 sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
             >
                 <div className="flex flex-row flex-wrap">
-                    <AddShardCard namespace={namespace} cluster={cluster} />
+                    <AddNodeCard namespace={namespace} cluster={cluster} 
shard={shard} />
                     {nodesData.nodes.map(
                         (node: any, index: number) => (
                             <Link
diff --git a/webui/src/app/namespaces/page.tsx 
b/webui/src/app/namespaces/page.tsx
index 2b2547b..ae0dfbf 100644
--- a/webui/src/app/namespaces/page.tsx
+++ b/webui/src/app/namespaces/page.tsx
@@ -17,10 +17,41 @@
  * under the License.
  */
 
-import { Box, Container, Card } from "@mui/material";
+"use client";
+
+import { Box, Container, Card, Link, Typography } from "@mui/material";
 import { NamespaceSidebar } from "../ui/sidebar";
+import { useRouter } from "next/navigation";
+import { useState, useEffect } from "react";
+import { fetchNamespaces } from "../lib/api";
+import { LoadingSpinner } from "../ui/loadingSpinner";
+import { CreateCard } from "../ui/createCard";
 
 export default function Namespace() {
+    const [namespaces, setNamespaces] = useState<string[]>([]);
+    const [loading, setLoading] = useState<boolean>(true);
+    const router = useRouter();
+
+    useEffect(() => {
+        const fetchData = async () => {
+            try {
+                const fetchedNamespaces = await fetchNamespaces();
+
+                setNamespaces(fetchedNamespaces);
+            } catch (error) {
+                console.error("Error fetching namespaces:", error);
+            } finally {
+                setLoading(false);
+            }
+        };
+
+        fetchData();
+    }, [router]);
+
+    if (loading) {
+        return <LoadingSpinner />;
+    }
+
     return (
         <div className="flex h-full">
             <NamespaceSidebar />
@@ -29,10 +60,27 @@ export default function Namespace() {
                 disableGutters
                 sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
             >
-                <div>
-                    <Box className="flex">
-                        This is the namespace page.
-                    </Box>
+                <div className="flex flex-row flex-wrap">
+                    {namespaces.length !== 0 ? (
+                        namespaces.map(
+                            (namespace, index) =>
+                                namespace && (
+                                    <Link key={namespace} 
href={`/namespaces/${namespace}`}>
+                                        <CreateCard>
+                                            <Typography variant="h6">
+                                                {namespace} Namespace
+                                            </Typography>
+                                        </CreateCard>
+                                    </Link>
+                                )
+                        )
+                    ) : (
+                        <Box>
+                            <Typography variant="h6">
+                No namespaces found, create one to get started
+                            </Typography>
+                        </Box>
+                    )}
                 </div>
             </Container>
         </div>
diff --git a/webui/src/app/ui/createCard.tsx b/webui/src/app/ui/createCard.tsx
index 0534a14..d09f498 100644
--- a/webui/src/app/ui/createCard.tsx
+++ b/webui/src/app/ui/createCard.tsx
@@ -6,22 +6,28 @@
  * 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 { Card, Box } from "@mui/material";
 import React, { ReactNode } from "react";
-import { ClusterCreation, ShardCreation } from "./formCreation";
+import {
+    ClusterCreation,
+    ImportCluster,
+    MigrateSlot,
+    NodeCreation,
+    ShardCreation,
+} from "./formCreation";
 import { faCirclePlus } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 
@@ -35,7 +41,7 @@ export const CreateCard: React.FC<CreateCardProps> = ({ 
children }) => {
             <Card
                 variant="outlined"
                 sx={{
-                    width: "300px",
+                    width: "370px",
                     height: "200px",
                     padding: "16px",
                     margin: "16px",
@@ -71,8 +77,13 @@ export const AddClusterCard = ({ namespace }: { namespace: 
string }) => {
                     transition: "color 0.2s",
                 }}
             />
-            <div className="mt-4">
-                <ClusterCreation position="card" namespace={namespace} />
+            <div className="mt-4 flex flex-row items-end ">
+                <div className="mr-0.5">
+                    <ClusterCreation position="card" namespace={namespace} />
+                </div>
+                <div className="ml-.5">
+                    <ImportCluster position="card" namespace={namespace} />
+                </div>
             </div>
         </CreateCard>
     );
@@ -84,6 +95,46 @@ export const AddShardCard = ({
 }: {
   namespace: string;
   cluster: string;
+}) => {
+    return (
+        <CreateCard>
+            <FontAwesomeIcon
+                icon={faCirclePlus}
+                size="4x"
+                style={{
+                    color: "#e0e0e0",
+                    marginBottom: "8px",
+                    transition: "color 0.2s",
+                }}
+            />
+            <div className="mt-4  flex flex-row items-end">
+                <div className="mr-0.5">
+                    <ShardCreation
+                        position="card"
+                        namespace={namespace}
+                        cluster={cluster}
+                    />
+                </div>
+                <div className="ml-.5">
+                    <MigrateSlot
+                        position="card"
+                        namespace={namespace}
+                        cluster={cluster}
+                    />
+                </div>
+            </div>
+        </CreateCard>
+    );
+};
+
+export const AddNodeCard = ({
+    namespace,
+    cluster,
+    shard,
+}: {
+  namespace: string;
+  cluster: string;
+  shard: string;
 }) => {
     return (
         <CreateCard>
@@ -97,7 +148,12 @@ export const AddShardCard = ({
                 }}
             />
             <div className="mt-4">
-                <ShardCreation position="card" namespace={namespace} 
cluster={cluster} />
+                <NodeCreation
+                    position="card"
+                    namespace={namespace}
+                    cluster={cluster}
+                    shard={shard}
+                />
             </div>
         </CreateCard>
     );
diff --git a/webui/src/app/ui/formCreation.tsx 
b/webui/src/app/ui/formCreation.tsx
index 3dc7d00..75228ec 100644
--- a/webui/src/app/ui/formCreation.tsx
+++ b/webui/src/app/ui/formCreation.tsx
@@ -20,7 +20,14 @@
 "use client";
 import React from "react";
 import FormDialog from "./formDialog";
-import { createCluster, createNamespace, createShard } from "../lib/api";
+import {
+    createCluster,
+    createNamespace,
+    createNode,
+    createShard,
+    importCluster,
+    migrateSlot,
+} from "../lib/api";
 import { useRouter } from "next/navigation";
 
 type NamespaceFormProps = {
@@ -33,18 +40,30 @@ type ClusterFormProps = {
 };
 
 type ShardFormProps = {
-    position: string;
-    namespace: string;
-    cluster: string;
-    };
+  position: string;
+  namespace: string;
+  cluster: string;
+};
+
+type NodeFormProps = {
+  position: string;
+  namespace: string;
+  cluster: string;
+  shard: string;
+};
 
 const containsWhitespace = (value: string): boolean => /\s/.test(value);
 
-const validateFormData = (formData: FormData, fields: string[]): string | null 
=> {
+const validateFormData = (
+    formData: FormData,
+    fields: string[]
+): string | null => {
     for (const field of fields) {
         const value = formData.get(field);
         if (typeof value === "string" && containsWhitespace(value)) {
-            return `${field.charAt(0).toUpperCase() + field.slice(1)} cannot 
contain any whitespace characters.`;
+            return `${
+                field.charAt(0).toUpperCase() + field.slice(1)
+            } cannot contain any whitespace characters.`;
         }
     }
     return null;
@@ -65,7 +84,7 @@ export const NamespaceCreation: React.FC<NamespaceFormProps> 
= ({
         const response = await createNamespace(formObj["name"] as string);
         if (response === "") {
             router.push(`/namespaces/${formObj["name"]}`);
-        }else{
+        } else {
             return "Invalid form data";
         }
     };
@@ -89,8 +108,7 @@ export const ClusterCreation: React.FC<ClusterFormProps> = ({
 }) => {
     const router = useRouter();
     const handleSubmit = async (formData: FormData) => {
-        
-        const fieldsToValidate = ["name", "replicas", "password"];
+        const fieldsToValidate = ["name", "replicas"];
         const errorMessage = validateFormData(formData, fieldsToValidate);
         if (errorMessage) {
             return errorMessage;
@@ -108,15 +126,15 @@ export const ClusterCreation: React.FC<ClusterFormProps> 
= ({
         }
 
         const response = await createCluster(
-            formObj["name"] as string,
-            nodes,
-            parseInt(formObj["replicas"] as string),
-            formObj["password"] as string,
-            namespace
+      formObj["name"] as string,
+      nodes,
+      parseInt(formObj["replicas"] as string),
+      formObj["password"] as string,
+      namespace
         );
         if (response === "") {
             
router.push(`/namespaces/${namespace}/clusters/${formObj["name"]}`);
-        }else{
+        } else {
             return "Invalid form data";
         }
     };
@@ -138,8 +156,7 @@ export const ClusterCreation: React.FC<ClusterFormProps> = 
({
                 {
                     name: "password",
                     label: "Input Password",
-                    type: "text",
-                    required: true,
+                    type: "password",
                 },
             ]}
             onSubmit={handleSubmit}
@@ -147,7 +164,6 @@ export const ClusterCreation: React.FC<ClusterFormProps> = 
({
     );
 };
 
-
 export const ShardCreation: React.FC<ShardFormProps> = ({
     position,
     namespace,
@@ -155,7 +171,7 @@ export const ShardCreation: React.FC<ShardFormProps> = ({
 }) => {
     const router = useRouter();
     const handleSubmit = async (formData: FormData) => {
-        const fieldsToValidate = ["nodes", "password"];
+        const fieldsToValidate = ["nodes"];
         const errorMessage = validateFormData(formData, fieldsToValidate);
         if (errorMessage) {
             return errorMessage;
@@ -178,7 +194,7 @@ export const ShardCreation: React.FC<ShardFormProps> = ({
         const response = await createShard(namespace, cluster, nodes, 
password);
         if (response === "") {
             router.push(`/namespaces/${namespace}/clusters/${cluster}`);
-        }else{
+        } else {
             return "Invalid form data";
         }
     };
@@ -190,9 +206,194 @@ export const ShardCreation: React.FC<ShardFormProps> = ({
             submitButtonLabel="Create"
             formFields={[
                 { name: "nodes", label: "Input Nodes", type: "array", 
required: true },
-                { name: "password", label: "Input Password", type: "text", 
required: true },
+                {
+                    name: "password",
+                    label: "Input Password",
+                    type: "password",
+                },
+            ]}
+            onSubmit={handleSubmit}
+        />
+    );
+};
+
+export const ImportCluster: React.FC<ClusterFormProps> = ({
+    position,
+    namespace,
+}) => {
+    const router = useRouter();
+    const handleSubmit = async (formData: FormData) => {
+        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 cluster = formObj["cluster"] 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 importCluster(namespace, cluster, nodes, 
password);
+        if (response === "") {
+            router.push(`/namespaces/${namespace}/clusters/${cluster}`);
+        } else {
+            return "Invalid form data";
+        }
+    };
+
+    return (
+        <FormDialog
+            position={position}
+            title="Import Cluster"
+            submitButtonLabel="Import"
+            formFields={[
+                {
+                    name: "cluster",
+                    label: "Input Cluster",
+                    type: "text",
+                    required: true,
+                },
+                { name: "nodes", label: "Input Nodes", type: "array", 
required: true },
+                {
+                    name: "password",
+                    label: "Input Password",
+                    type: "password",
+                },
+            ]}
+            onSubmit={handleSubmit}
+        />
+    );
+};
+
+export const MigrateSlot: React.FC<ShardFormProps> = ({
+    position,
+    namespace,
+    cluster,
+}) => {
+    const router = useRouter();
+    const handleSubmit = async (formData: FormData) => {
+        const fieldsToValidate = ["target", "slot", "slot_only"];
+        const errorMessage = validateFormData(formData, fieldsToValidate);
+        if (errorMessage) {
+            return errorMessage;
+        }
+
+        const formObj = Object.fromEntries(formData.entries());
+        const target = parseInt(formObj["target"] as string);
+        const slot = parseInt(formObj["slot"] as string);
+        const slotOnly = formObj["slot_only"] === "true";
+
+        const response = await migrateSlot(
+            namespace,
+            cluster,
+            target,
+            slot,
+            slotOnly
+        );
+        if (response === "") {
+            window.location.reload();
+        } else {
+            return "Invalid form data";
+        }
+    };
+
+    return (
+        <FormDialog
+            position={position}
+            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: "slot_only",
+                    label: "Slot Only",
+                    type: "enum",
+                    values: ["true", "false"],
+                    required: true,
+                },
+            ]}
+            onSubmit={handleSubmit}
+        />
+    );
+};
+
+export const NodeCreation: React.FC<NodeFormProps> = ({
+    position,
+    namespace,
+    cluster,
+    shard,
+}) => {
+    const router = useRouter();
+
+    const handleSubmit = async (formData: FormData) => {
+        const fieldsToValidate = ["Address", "Role"];
+        const errorMessage = validateFormData(formData, fieldsToValidate);
+        if (errorMessage) {
+            return errorMessage;
+        }
+
+        const formObj = Object.fromEntries(formData.entries());
+        const address = formObj["Address"] as string;
+        const role = formObj["Role"] as string;
+        const password = formObj["Password"] as string;
+
+        if (containsWhitespace(address)) {
+            return "Address cannot contain any whitespace characters.";
+        }
+
+        const response = await createNode(
+            namespace,
+            cluster,
+            shard,
+            address,
+            role,
+            password
+        );
+        if (response === "") {
+            window.location.reload();
+        } else {
+            return "Invalid form data";
+        }
+    };
+
+    return (
+        <FormDialog
+            position={position}
+            title="Create Node"
+            submitButtonLabel="Create"
+            formFields={[
+                {
+                    name: "Address",
+                    label: "Input Address",
+                    type: "text",
+                    required: true,
+                },
+                {
+                    name: "Role",
+                    label: "Select Role",
+                    type: "enum",
+                    required: true,
+                    values: ["master", "slave"],
+                },
+                {
+                    name: "Password",
+                    label: "Input Password",
+                    type: "password",
+                },
             ]}
             onSubmit={handleSubmit}
         />
     );
-};
\ No newline at end of file
+};
diff --git a/webui/src/app/ui/formDialog.tsx b/webui/src/app/ui/formDialog.tsx
index 9bf7e61..2e26df0 100644
--- a/webui/src/app/ui/formDialog.tsx
+++ b/webui/src/app/ui/formDialog.tsx
@@ -29,23 +29,28 @@ import {
     Box,
     Chip,
     Typography,
-    Autocomplete
+    Autocomplete,
+    MenuItem,
+    Select,
+    InputLabel,
+    FormControl,
 } from "@mui/material";
 import React, { useCallback, useState, FormEvent } from "react";
-
-interface FormDialogProps {
+  
+  interface FormDialogProps {
     position: string;
     title: string;
     submitButtonLabel: string;
     formFields: {
-        name: string;
-        label: string;
-        type: string;
-        required?: boolean;
+      name: string;
+      label: string;
+      type: string;
+      required?: boolean;
+      values?: string[];
     }[];
     onSubmit: (formData: FormData) => Promise<string | undefined>;
-}
-
+  }
+  
 const FormDialog: React.FC<FormDialogProps> = ({
     position,
     title,
@@ -58,19 +63,19 @@ const FormDialog: React.FC<FormDialogProps> = ({
     const closeDialog = useCallback(() => setShowDialog(false), []);
     const [errorMessage, setErrorMessage] = useState("");
     const [arrayValues, setArrayValues] = useState<{ [key: string]: string[] 
}>({});
-
+  
     const handleArrayChange = (name: string, value: string[]) => {
         setArrayValues({ ...arrayValues, [name]: value });
     };
-
+  
     const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
         event.preventDefault();
         const formData = new FormData(event.currentTarget);
-
-        Object.keys(arrayValues).forEach(name => {
+  
+        Object.keys(arrayValues).forEach((name) => {
             formData.append(name, JSON.stringify(arrayValues[name]));
         });
-
+  
         const error = await onSubmit(formData);
         if (error) {
             setErrorMessage(error);
@@ -78,7 +83,7 @@ const FormDialog: React.FC<FormDialogProps> = ({
             closeDialog();
         }
     };
-
+  
     return (
         <>
             {position === "card" ? (
@@ -90,26 +95,30 @@ const FormDialog: React.FC<FormDialogProps> = ({
                     {title}
                 </Button>
             )}
-
+  
             <Dialog open={showDialog} onClose={closeDialog}>
                 <form onSubmit={handleSubmit}>
                     <DialogTitle>{title}</DialogTitle>
                     <DialogContent sx={{ width: "500px" }}>
-                        {formFields.map((field, index) => (
-                            field.type === 'array' ? (
+                        {formFields.map((field, index) =>
+                            field.type === "array" ? (
                                 <Box key={index} mb={2}>
-                                    <Typography variant="subtitle1" 
className="mt-2 mb-2">{field.label}</Typography>
+                                    <Typography variant="subtitle1" 
className="mt-2 mb-2">
+                                        {field.label}
+                                    </Typography>
                                     <Autocomplete
                                         multiple
                                         freeSolo
                                         value={arrayValues[field.name] || []}
                                         options={[]}
-                                        onChange={(event, newValue) => 
handleArrayChange(field.name, newValue)}
+                                        onChange={(event, newValue) =>
+                                            handleArrayChange(field.name, 
newValue)
+                                        }
                                         renderTags={(value, getTagProps) =>
                                             value.map((option, index) => (
                                                 <Chip
                                                     {...getTagProps({ index })}
-                                                    key={index} 
+                                                    key={index}
                                                     label={option}
                                                 />
                                             ))
@@ -124,6 +133,23 @@ const FormDialog: React.FC<FormDialogProps> = ({
                                         )}
                                     />
                                 </Box>
+                            ) : field.type === "enum" ? (
+                                <FormControl key={index} fullWidth sx={{ mt:3 
}}>
+                                    <InputLabel>{field.label}</InputLabel>
+                                    <Select
+                                        name={field.name}
+                                        label={field.label}
+                                        required={field.required}
+                                        defaultValue=""
+                                        multiple={false}
+                                    >
+                                        {field.values?.map((value, index) => (
+                                            <MenuItem key={index} 
value={value}>
+                                                {value}
+                                            </MenuItem>
+                                        ))}
+                                    </Select>
+                                </FormControl>
                             ) : (
                                 <TextField
                                     key={index}
@@ -138,7 +164,7 @@ const FormDialog: React.FC<FormDialogProps> = ({
                                     sx={{ mb: 2 }}
                                 />
                             )
-                        ))}
+                        )}
                     </DialogContent>
                     <DialogActions>
                         <Button onClick={closeDialog}>Cancel</Button>
@@ -164,5 +190,6 @@ const FormDialog: React.FC<FormDialogProps> = ({
         </>
     );
 };
-
+  
 export default FormDialog;
+  
\ No newline at end of file
diff --git a/webui/src/app/ui/sidebar.tsx b/webui/src/app/ui/sidebar.tsx
index c9133b4..63bbd38 100644
--- a/webui/src/app/ui/sidebar.tsx
+++ b/webui/src/app/ui/sidebar.tsx
@@ -20,9 +20,19 @@
 "use client";
 
 import { Divider, List, Typography } from "@mui/material";
-import { fetchClusters, fetchNamespaces, listShards } from "@/app/lib/api";
+import {
+    fetchClusters,
+    fetchNamespaces,
+    listNodes,
+    listShards,
+} from "@/app/lib/api";
 import Item from "./sidebarItem";
-import { ClusterCreation, NamespaceCreation, ShardCreation } from 
"./formCreation";
+import {
+    ClusterCreation,
+    NamespaceCreation,
+    NodeCreation,
+    ShardCreation,
+} from "./formCreation";
 import Link from "next/link";
 import { useState, useEffect } from "react";
 
@@ -128,7 +138,7 @@ export function ShardSidebar({
             try {
                 const fetchedShards = await listShards(namespace, cluster);
                 const shardsIndex = fetchedShards.map(
-                    (shard, index) => "Shard\t" + (index+1).toString()
+                    (shard, index) => "Shard\t" + (index + 1).toString()
                 );
                 setShards(shardsIndex);
             } catch (err) {
@@ -142,21 +152,30 @@ export function ShardSidebar({
         <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" />
+                    <ShardCreation
+                        namespace={namespace}
+                        cluster={cluster}
+                        position="sidebar"
+                    />
                 </div>
                 {error && (
                     <Typography color="error" align="center">
                         {error}
                     </Typography>
                 )}
-                {shards.map((shard,index) => (
+                {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} />
+                            <Item
+                                type="shard"
+                                item={shard}
+                                namespace={namespace}
+                                cluster={cluster}
+                            />
                         </Link>
                     </div>
                 ))}
@@ -165,4 +184,81 @@ export function ShardSidebar({
             <Divider orientation="vertical" flexItem />
         </div>
     );
-}
\ No newline at end of file
+}
+interface NodeItem {
+  addr: string;
+  created_at: number;
+  id: string;
+  password: string;
+  role: string;
+}
+
+export function NodeSidebar({
+    namespace,
+    cluster,
+    shard,
+}: {
+  namespace: string;
+  cluster: string;
+  shard: string;
+}) {
+    const [nodes, setNodes] = useState<NodeItem[]>([]);
+    const [error, setError] = useState<string | null>(null);
+
+    useEffect(() => {
+        const fetchData = async () => {
+            try {
+                const fetchedNodes = (await listNodes(
+                    namespace,
+                    cluster,
+                    shard
+                )) as NodeItem[];
+                setNodes(fetchedNodes);
+            } catch (err) {
+                setError("Failed to fetch nodes");
+            }
+        };
+        fetchData();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [namespace, cluster, shard]);
+
+    return (
+        <div className="w-60 h-full flex">
+            <List className="w-full overflow-y-auto">
+                <div className="mt-2 mb-4 text-center">
+                    <NodeCreation
+                        namespace={namespace}
+                        cluster={cluster}
+                        shard={shard}
+                        position="sidebar"
+                    />
+                </div>
+                {error && (
+                    <Typography color="error" align="center">
+                        {error}
+                    </Typography>
+                )}
+                {nodes.map((node, index) => (
+                    <div key={index}>
+                        <Divider />
+                        <Link
+                            
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes/${index}`}
+                            passHref
+                        >
+                            <Item
+                                type="node"
+                                item={"Node\t" + (index + 1).toString()}
+                                id={node.id}
+                                namespace={namespace}
+                                cluster={cluster}
+                                shard={shard}
+                            />
+                        </Link>
+                    </div>
+                ))}
+                <Divider />
+            </List>
+            <Divider orientation="vertical" flexItem />
+        </div>
+    );
+}
diff --git a/webui/src/app/ui/sidebarItem.tsx b/webui/src/app/ui/sidebarItem.tsx
index c85fc1c..2342d8a 100644
--- a/webui/src/app/ui/sidebarItem.tsx
+++ b/webui/src/app/ui/sidebarItem.tsx
@@ -39,7 +39,12 @@ 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, deleteShard } from "../lib/api";
+import {
+    deleteCluster,
+    deleteNamespace,
+    deleteNode,
+    deleteShard,
+} from "../lib/api";
 import { faTrash } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 
@@ -61,7 +66,20 @@ interface ShardItemProps {
   cluster: string;
 }
 
-type ItemProps = NamespaceItemProps | ClusterItemProps | ShardItemProps;
+interface NodeItemProps {
+  item: string;
+  type: "node";
+  namespace: string;
+  cluster: string;
+  shard: string;
+  id: string;
+}
+
+type ItemProps =
+  | NamespaceItemProps
+  | ClusterItemProps
+  | ShardItemProps
+  | NodeItemProps;
 
 export default function Item(props: ItemProps) {
     const { item, type } = props;
@@ -88,10 +106,10 @@ export default function Item(props: ItemProps) {
     let activeItem = usePathname().split("/").pop() || "";
 
     const confirmDelete = useCallback(async () => {
-        let response="";
+        let response = "";
         if (type === "namespace") {
             response = await deleteNamespace(item);
-            if (response ===""){
+            if (response === "") {
                 router.push("/namespaces");
             }
             setErrorMessage(response);
@@ -99,25 +117,41 @@ export default function Item(props: ItemProps) {
         } else if (type === "cluster") {
             const { namespace } = props as ClusterItemProps;
             response = await deleteCluster(namespace, item);
-            if (response ===""){
+            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 ===""){
+            response = await deleteShard(
+                namespace,
+                cluster,
+                (parseInt(item.split("\t")[1]) - 1).toString()
+            );
+            if (response === "") {
                 router.push(`/namespaces/${namespace}/clusters/${cluster}`);
             }
             setErrorMessage(response);
             router.refresh();
+        } else if (type === "node") {
+            const { namespace, cluster, shard, id } = props as NodeItemProps;
+            response = await deleteNode(namespace, cluster, shard, id);
+            if (response === "") {
+                router.push(
+                    
`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}`
+                );
+            }
+            setErrorMessage(response);
+            router.refresh();
         }
         closeMenu();
     }, [item, type, props, closeMenu, router]);
 
     if (type === "shard") {
         activeItem = "Shard\t" + (parseInt(activeItem) + 1);
+    }else if (type === "node") {
+        activeItem = "Node\t" + (parseInt(activeItem) + 1);
     }
     const isActive = item === activeItem;
 
@@ -165,9 +199,19 @@ export default function Item(props: ItemProps) {
             <Dialog open={showDeleteConfirm}>
                 <DialogTitle>Confirm</DialogTitle>
                 <DialogContent>
-                    <DialogContentText>
-            Please confirm you want to delete {type} {item}
-                    </DialogContentText>
+                    {type === "node" ? (
+                        <DialogContentText>
+              Please confirm you want to delete {item}
+                        </DialogContentText>
+                    ) : type === "shard" ? (
+                        <DialogContentText>
+              Please confirm you want to delete {item}
+                        </DialogContentText>
+                    ) : (
+                        <DialogContentText>
+              Please confirm you want to delete {type} {item}
+                        </DialogContentText>
+                    )}
                 </DialogContent>
                 <DialogActions>
                     <Button onClick={closeDeleteConfirmDialog}>Cancel</Button>
diff --git 
a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
 b/webui/src/app/utils.ts
similarity index 59%
copy from 
webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
copy to webui/src/app/utils.ts
index b9e69b1..4c79b17 100644
--- 
a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/shards/[shard]/nodes/[node]/page.tsx
+++ b/webui/src/app/utils.ts
@@ -17,23 +17,6 @@
  * under the License.
  */
 
-import { Box, Container, Card, Alert, Snackbar } from "@mui/material";
-
-export default function Node() {
-    
-    return (
-        <div className="flex h-full">
-            <Container
-                maxWidth={false}
-                disableGutters
-                sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
-            >
-                <div>
-                    <Box className="flex">
-                        This is the nodes page.
-                    </Box>
-                </div>
-            </Container>
-        </div>
-    );
-}
+export const truncateText = (text: string, limit: number) => {
+    return text.length > limit ? `${text.slice(0, limit)}...` : text;
+};


Reply via email to