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 60942a1  Implement the cluster management UI for Kvrocks controller 
(#197)
60942a1 is described below

commit 60942a18e7fc2cf9edd4ae2391bc05b9e3433f2b
Author: Vinayak Sharma <[email protected]>
AuthorDate: Sun Aug 4 13:22:06 2024 +0530

    Implement the cluster management UI for Kvrocks controller (#197)
---
 webui/next.config.mjs                              |  25 ++-
 webui/package.json                                 |   4 +
 webui/src/app/lib/api.ts                           | 124 +++++++++++----
 .../[namespace]/clusters/[cluster]}/layout.tsx     |   0
 .../[namespace]/clusters/[cluster]/page.tsx}       |  46 +++---
 .../{cluster => namespaces/[namespace]}/layout.tsx |   0
 webui/src/app/namespaces/[namespace]/page.tsx      | 122 +++++++++++++++
 webui/src/app/{cluster => namespaces}/layout.tsx   |   0
 webui/src/app/{cluster => namespaces}/page.tsx     |  30 ++--
 webui/src/app/ui/banner.tsx                        |   4 +-
 webui/src/app/ui/createCard.tsx                    |  77 ++++++++++
 webui/src/app/ui/formCreation.tsx                  | 141 +++++++++++++++++
 webui/src/app/ui/formDialog.tsx                    | 168 +++++++++++++++++++++
 webui/src/app/ui/namespace/namespaceCreation.tsx   |  89 -----------
 webui/src/app/ui/namespace/namespaceItem.tsx       |  81 ----------
 webui/src/app/ui/sidebar.tsx                       |  99 +++++++++---
 webui/src/app/ui/sidebarItem.tsx                   | 124 +++++++++++++++
 17 files changed, 878 insertions(+), 256 deletions(-)

diff --git a/webui/next.config.mjs b/webui/next.config.mjs
index d3b5b41..d40e4fb 100644
--- a/webui/next.config.mjs
+++ b/webui/next.config.mjs
@@ -17,7 +17,26 @@
  * under the License. 
  */
  
-/** @type {import('next').NextConfig} */
-const nextConfig = {};
+import { PHASE_DEVELOPMENT_SERVER } from 'next/constants.js';
 
-export default nextConfig;
+const apiPrefix = "/api/v1";
+const devHost = "127.0.0.1:9379";
+const prodHost = "production-api.yourdomain.com";
+
+const nextConfig = (phase, { defaultConfig }) => {
+  const isDev = phase === PHASE_DEVELOPMENT_SERVER;
+  const host = isDev ? devHost : prodHost;
+
+  return {
+    async rewrites() {
+      return [
+        {
+          source: `${apiPrefix}/:slug*`,
+          destination: `http://${host}${apiPrefix}/:slug*`,
+        },
+      ];
+    },
+  };
+};
+
+export default nextConfig;
\ No newline at end of file
diff --git a/webui/package.json b/webui/package.json
index 4ae3d9b..d2c9523 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -11,6 +11,10 @@
     "dependencies": {
         "@emotion/react": "^11.11.3",
         "@emotion/styled": "^11.11.0",
+        "@fortawesome/fontawesome-svg-core": "^6.6.0",
+        "@fortawesome/free-brands-svg-icons": "^6.6.0",
+        "@fortawesome/free-solid-svg-icons": "^6.6.0",
+        "@fortawesome/react-fontawesome": "^0.2.2",
         "@mui/icons-material": "^5.15.7",
         "@mui/material": "^5.15.5",
         "@types/js-yaml": "^4.0.9",
diff --git a/webui/src/app/lib/api.ts b/webui/src/app/lib/api.ts
index 723d0bf..b063d7d 100644
--- a/webui/src/app/lib/api.ts
+++ b/webui/src/app/lib/api.ts
@@ -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,33 +6,27 @@
  * 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.
  */
 
-import yaml from 'js-yaml';
-import fs from 'fs';
-import path from 'path';
-import axios, { AxiosError } from 'axios';
+import axios, { AxiosError } from "axios";
 
-const configFile = './config/config.yaml';
-const apiPrefix = '/api/v1';
-let host;
-try {
-    const wholeFilePath = path.join(process.cwd(), '..', configFile);
-    const doc = yaml.load(fs.readFileSync(wholeFilePath, 'utf8'));
-    host = (doc as any)['addr'];
-} catch (error) {
-    host = '127.0.0.1:9379';
+const apiPrefix = "/api/v1";
+const apiHost = `${apiPrefix}`;
+
+export interface Cluster {
+  name: string;
+  version: number;
+  shards: {};
 }
-const apiHost = `http://${host}${apiPrefix}`;
 
 export async function fetchNamespaces(): Promise<string[]> {
     try {
@@ -43,11 +37,14 @@ export async function fetchNamespaces(): Promise<string[]> {
         return [];
     }
 }
+
 export async function createNamespace(name: string): Promise<string> {
     try {
-        const { data: responseData } = await 
axios.post(`${apiHost}/namespaces`, {namespace: name});
-        if(responseData?.data == 'created') {
-            return '';
+        const { data: responseData } = await 
axios.post(`${apiHost}/namespaces`, {
+            namespace: name,
+        });
+        if (responseData?.data != undefined) {
+            return "";
         } else {
             return handleError(responseData);
         }
@@ -58,9 +55,78 @@ export async function createNamespace(name: string): 
Promise<string> {
 
 export async function deleteNamespace(name: string): Promise<string> {
     try {
-        const { data: responseData } = await 
axios.delete(`${apiHost}/namespaces/${name}`);
-        if(responseData?.data == 'ok') {
-            return '';
+        const { data: responseData } = await axios.delete(
+            `${apiHost}/namespaces/${name}`
+        );
+        if (responseData?.data == "ok") {
+            return "";
+        } else {
+            return handleError(responseData);
+        }
+    } catch (error) {
+        return handleError(error);
+    }
+}
+
+export async function createCluster(
+    name: string,
+    nodes: string[],
+    replicas: number,
+    password: string,
+    namespace: string
+): Promise<string> {
+    try {
+        const { data: responseData } = await axios.post(
+            `${apiHost}/namespaces/${namespace}/clusters`,
+            { name, nodes, replicas, password }
+        );
+        if (responseData?.data != undefined) {
+            return "";
+        } else {
+            return handleError(responseData);
+        }
+    } catch (error) {
+        return handleError(error);
+    }
+}
+
+export async function fetchClusters(namespace: string): Promise<string[]> {
+    try {
+        const { data: responseData } = await axios.get(
+            `${apiHost}/namespaces/${namespace}/clusters`
+        );
+        return responseData.data.clusters || [];
+    } catch (error) {
+        handleError(error);
+        return [];
+    }
+}
+
+export async function fetchCluster(
+    namespace: string,
+    cluster: string
+): Promise<Object> {
+    try {
+        const { data: responseData } = await axios.get(
+            `${apiHost}/namespaces/${namespace}/clusters/${cluster}`
+        );
+        return responseData.data.cluster;
+    } catch (error) {
+        handleError(error);
+        return {};
+    }
+}
+
+export async function deleteCluster(
+    namespace: string,
+    cluster: string
+): Promise<string> {
+    try {
+        const { data: responseData } = await axios.delete(
+            `${apiHost}/namespaces/${namespace}/clusters/${cluster}`
+        );
+        if (responseData?.data == "ok") {
+            return "";
         } else {
             return handleError(responseData);
         }
@@ -70,13 +136,13 @@ export async function deleteNamespace(name: string): 
Promise<string> {
 }
 
 function handleError(error: any): string {
-    let message: string = '';
-    if(error instanceof AxiosError) {
+    let message: string = "";
+    if (error instanceof AxiosError) {
         message = error.response?.data?.error?.message || error.message;
     } else if (error instanceof Error) {
         message = error.message;
-    } else if (typeof error === 'object') {
+    } else if (typeof error === "object") {
         message = error?.error?.message || error?.message;
     }
-    return message || 'Unknown error';
-}
\ No newline at end of file
+    return message || "Unknown error";
+}
diff --git a/webui/src/app/cluster/layout.tsx 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/layout.tsx
similarity index 100%
copy from webui/src/app/cluster/layout.tsx
copy to webui/src/app/namespaces/[namespace]/clusters/[cluster]/layout.tsx
diff --git a/webui/src/app/lib/actions.ts 
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
similarity index 51%
rename from webui/src/app/lib/actions.ts
rename to webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
index 55ff8d0..d8014d8 100644
--- a/webui/src/app/lib/actions.ts
+++ b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.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,32 +6,38 @@
  * 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 server';
 
-import { redirect } from "next/navigation";
-import { createNamespace, deleteNamespace } from "./api";
-import { revalidatePath } from "next/cache";
+'use client';
 
-export async function createNamespaceAction(name: string): Promise<string> {
-    const errMsg = await createNamespace(name);
-    if(!errMsg) {
-        revalidatePath('/cluster');
-    }
-    return errMsg;
-}
+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 async function deleteNamespaceAction(name: string): Promise<string> {
-    const result = deleteNamespace(name);
-    revalidatePath('/cluster');
-    return result;
-}
\ No newline at end of file
+    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>
+    );
+}
diff --git a/webui/src/app/cluster/layout.tsx 
b/webui/src/app/namespaces/[namespace]/layout.tsx
similarity index 100%
copy from webui/src/app/cluster/layout.tsx
copy to webui/src/app/namespaces/[namespace]/layout.tsx
diff --git a/webui/src/app/namespaces/[namespace]/page.tsx 
b/webui/src/app/namespaces/[namespace]/page.tsx
new file mode 100644
index 0000000..4ee1aaa
--- /dev/null
+++ b/webui/src/app/namespaces/[namespace]/page.tsx
@@ -0,0 +1,122 @@
+/*
+ * 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 { Box, Container, Card, 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 Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useState, useEffect } from "react";
+
+export default function Namespace({ params }: { params: { namespace: string } 
}) {
+    const [namespaces, setNamespaces] = useState<string[]>([]);
+    const [namespace, setNamespace] = useState<string>("");
+    const [clusterData, setClusterData] = useState<any[]>([]);
+    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');
+                    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);
+                            return null;
+                        }
+                    })
+                );
+                setClusterData(data.filter(Boolean)); // Filter out null values
+            } catch (error) {
+                console.error("Error fetching data:", error);
+            }
+        };
+
+        fetchData();
+    }, [namespaces, params.namespace, router]);
+
+    return (
+        <div className="flex h-full">
+            <NamespaceSidebar />
+            <Container
+                maxWidth={false}
+                disableGutters
+                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}
+                </div>
+            </Container>
+        </div>
+    );
+}
diff --git a/webui/src/app/cluster/layout.tsx 
b/webui/src/app/namespaces/layout.tsx
similarity index 100%
rename from webui/src/app/cluster/layout.tsx
rename to webui/src/app/namespaces/layout.tsx
diff --git a/webui/src/app/cluster/page.tsx b/webui/src/app/namespaces/page.tsx
similarity index 63%
rename from webui/src/app/cluster/page.tsx
rename to webui/src/app/namespaces/page.tsx
index 62a9847..2b2547b 100644
--- a/webui/src/app/cluster/page.tsx
+++ b/webui/src/app/namespaces/page.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,29 +6,35 @@
  * 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.
  */
 
-import { Box, Container } from "@mui/material";
-import Sidebar from "../ui/sidebar";
+import { Box, Container, Card } from "@mui/material";
+import { NamespaceSidebar } from "../ui/sidebar";
 
-export default function Cluster() {
+export default function Namespace() {
     return (
         <div className="flex h-full">
-            <Sidebar />
-            <Container maxWidth={false} disableGutters sx={{height: '100%', 
overflowY: 'auto', marginLeft: '16px'}}>
+            <NamespaceSidebar />
+            <Container
+                maxWidth={false}
+                disableGutters
+                sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
+            >
                 <div>
-                    todo: show all clusters in selected namespace here
+                    <Box className="flex">
+                        This is the namespace page.
+                    </Box>
                 </div>
             </Container>
         </div>
-    )
-}
\ No newline at end of file
+    );
+}
diff --git a/webui/src/app/ui/banner.tsx b/webui/src/app/ui/banner.tsx
index 8839817..b9b4d1c 100644
--- a/webui/src/app/ui/banner.tsx
+++ b/webui/src/app/ui/banner.tsx
@@ -26,8 +26,8 @@ const links = [
         url: '/',
         title: 'Home'
     },{
-        url: '/cluster',
-        title: 'cluster'
+        url: '/namespaces',
+        title: 'Namespaces'
     },{
         url: 'https://kvrocks.apache.org',
         title: 'community',
diff --git a/webui/src/app/ui/createCard.tsx b/webui/src/app/ui/createCard.tsx
new file mode 100644
index 0000000..3e1a87e
--- /dev/null
+++ b/webui/src/app/ui/createCard.tsx
@@ -0,0 +1,77 @@
+/*
+ * 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 { Card } from "@mui/material";
+import React, { ReactNode } from "react";
+import { ClusterCreation } from "./formCreation";
+import { faCirclePlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+interface CreateCardProps {
+  children: ReactNode;
+}
+
+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>
+    );
+};
+
+export const AddClusterCardProps = ({ namespace }: { namespace: string }) => {
+    return (
+        <>
+            <FontAwesomeIcon
+                icon={faCirclePlus}
+                size="4x"
+                style={{
+                    color: "#e0e0e0",
+                    marginBottom: "8px",
+                    transition: "color 0.2s",
+                }}
+            />
+            <div className="mt-4">
+                <ClusterCreation position="card" namespace={namespace} />
+            </div>
+        </>
+    );
+};
diff --git a/webui/src/app/ui/formCreation.tsx 
b/webui/src/app/ui/formCreation.tsx
new file mode 100644
index 0000000..5304e91
--- /dev/null
+++ b/webui/src/app/ui/formCreation.tsx
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+"use client";
+import React from "react";
+import FormDialog from "./formDialog";
+import { createCluster, createNamespace } from "../lib/api";
+import { useRouter } from "next/navigation";
+
+type NamespaceFormProps = {
+  position: string;
+};
+
+type ClusterFormProps = {
+  position: string;
+  namespace: string;
+};
+
+const containsWhitespace = (value: string): boolean => /\s/.test(value);
+
+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 null;
+};
+
+export const NamespaceCreation: React.FC<NamespaceFormProps> = ({
+    position,
+}) => {
+    const router = useRouter();
+    const handleSubmit = async (formData: FormData) => {
+        const fieldsToValidate = ["name"];
+        const errorMessage = validateFormData(formData, fieldsToValidate);
+        if (errorMessage) {
+            return errorMessage;
+        }
+
+        const formObj = Object.fromEntries(formData.entries());
+        const response = await createNamespace(formObj["name"] as string);
+        if (response === "") {
+            router.push(`/namespaces/${formObj["name"]}`);
+        }
+        return "Invalid form data";
+    };
+
+    return (
+        <FormDialog
+            position={position}
+            title="Create Namespace"
+            submitButtonLabel="Create"
+            formFields={[
+                { name: "name", label: "Input Name", type: "text", required: 
true },
+            ]}
+            onSubmit={handleSubmit}
+        />
+    );
+};
+
+export const ClusterCreation: React.FC<ClusterFormProps> = ({
+    position,
+    namespace,
+}) => {
+    const router = useRouter();
+    const handleSubmit = async (formData: FormData) => {
+        
+        const fieldsToValidate = ["name", "replicas", "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[];
+        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 createCluster(
+            formObj["name"] as string,
+            nodes,
+            parseInt(formObj["replicas"] as string),
+            formObj["password"] as string,
+            namespace
+        );
+        if (response === "") {
+            
router.push(`/namespaces/${namespace}/clusters/${formObj["name"]}`);
+            return;
+        }
+        return "Invalid form data";
+    };
+
+    return (
+        <FormDialog
+            position={position}
+            title="Create Cluster"
+            submitButtonLabel="Create"
+            formFields={[
+                { name: "name", label: "Input Name", type: "text", required: 
true },
+                { name: "nodes", label: "Input Nodes", type: "array", 
required: true },
+                {
+                    name: "replicas",
+                    label: "Input Replicas",
+                    type: "text",
+                    required: true,
+                },
+                {
+                    name: "password",
+                    label: "Input Password",
+                    type: "text",
+                    required: true,
+                },
+            ]}
+            onSubmit={handleSubmit}
+        />
+    );
+};
diff --git a/webui/src/app/ui/formDialog.tsx b/webui/src/app/ui/formDialog.tsx
new file mode 100644
index 0000000..9bf7e61
--- /dev/null
+++ b/webui/src/app/ui/formDialog.tsx
@@ -0,0 +1,168 @@
+/*
+ * 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. 
+ */
+
+import {
+    Alert,
+    Button,
+    Dialog,
+    DialogActions,
+    DialogContent,
+    DialogTitle,
+    Snackbar,
+    TextField,
+    Box,
+    Chip,
+    Typography,
+    Autocomplete
+} from "@mui/material";
+import React, { useCallback, useState, FormEvent } from "react";
+
+interface FormDialogProps {
+    position: string;
+    title: string;
+    submitButtonLabel: string;
+    formFields: {
+        name: string;
+        label: string;
+        type: string;
+        required?: boolean;
+    }[];
+    onSubmit: (formData: FormData) => Promise<string | undefined>;
+}
+
+const FormDialog: React.FC<FormDialogProps> = ({
+    position,
+    title,
+    submitButtonLabel,
+    formFields,
+    onSubmit,
+}) => {
+    const [showDialog, setShowDialog] = useState(false);
+    const openDialog = useCallback(() => setShowDialog(true), []);
+    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 => {
+            formData.append(name, JSON.stringify(arrayValues[name]));
+        });
+
+        const error = await onSubmit(formData);
+        if (error) {
+            setErrorMessage(error);
+        } else {
+            closeDialog();
+        }
+    };
+
+    return (
+        <>
+            {position === "card" ? (
+                <Button variant="contained" onClick={openDialog}>
+                    {title}
+                </Button>
+            ) : (
+                <Button variant="outlined" onClick={openDialog}>
+                    {title}
+                </Button>
+            )}
+
+            <Dialog open={showDialog} onClose={closeDialog}>
+                <form onSubmit={handleSubmit}>
+                    <DialogTitle>{title}</DialogTitle>
+                    <DialogContent sx={{ width: "500px" }}>
+                        {formFields.map((field, index) => (
+                            field.type === 'array' ? (
+                                <Box key={index} mb={2}>
+                                    <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)}
+                                        renderTags={(value, getTagProps) =>
+                                            value.map((option, index) => (
+                                                <Chip
+                                                    {...getTagProps({ index })}
+                                                    key={index} 
+                                                    label={option}
+                                                />
+                                            ))
+                                        }
+                                        renderInput={(params) => (
+                                            <TextField
+                                                {...params}
+                                                variant="outlined"
+                                                label={`Add ${field.label}*`}
+                                                placeholder="Type and press 
enter"
+                                            />
+                                        )}
+                                    />
+                                </Box>
+                            ) : (
+                                <TextField
+                                    key={index}
+                                    autoFocus={index === 0}
+                                    required={field.required}
+                                    name={field.name}
+                                    label={field.label}
+                                    type={field.type}
+                                    fullWidth
+                                    variant="standard"
+                                    margin="normal"
+                                    sx={{ mb: 2 }}
+                                />
+                            )
+                        ))}
+                    </DialogContent>
+                    <DialogActions>
+                        <Button onClick={closeDialog}>Cancel</Button>
+                        <Button type="submit">{submitButtonLabel}</Button>
+                    </DialogActions>
+                </form>
+            </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>
+        </>
+    );
+};
+
+export default FormDialog;
diff --git a/webui/src/app/ui/namespace/namespaceCreation.tsx 
b/webui/src/app/ui/namespace/namespaceCreation.tsx
deleted file mode 100644
index a0bcf23..0000000
--- a/webui/src/app/ui/namespace/namespaceCreation.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-/* 
- * 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 { createNamespaceAction } from "@/app/lib/actions";
-import { Alert, Button, Dialog, DialogActions, DialogContent, 
DialogContentText, DialogTitle, Snackbar, TextField } from "@mui/material";
-import { useCallback, useState } from "react";
-
-export default function NamespaceCreation() {
-    const [showDialog, setShowDialog] = useState(false);
-    const openDialog = useCallback(() => setShowDialog(true), []);
-    const closeDialog = useCallback(() => setShowDialog(false), []);
-    const [errorMessage, setErrorMessage] = useState('');
-
-    return (
-        <>
-            <Button variant="outlined" onClick={openDialog}>Create 
Namespace</Button>
-            <Dialog
-                open={showDialog}
-                PaperProps={{
-                    component: 'form',
-                    action: async (formData: FormData) => {
-                        const formObj = Object.fromEntries(formData.entries());
-                        if(typeof formObj['name'] === 'string') {
-                            const errMsg = await 
createNamespaceAction(formObj['name']);
-                            if (errMsg) {
-                                setErrorMessage(errMsg);
-                            }
-                            closeDialog();
-                        }
-                    },
-                }}
-                onClose={closeDialog}
-            >
-                <DialogTitle>Create Namespace</DialogTitle>
-                <DialogContent
-                    sx={{
-                        width: '500px'
-                    }}
-                >
-                    <TextField
-                        autoFocus
-                        required
-                        name="name"
-                        label="Input Name"
-                        type="name"
-                        fullWidth
-                        variant="standard"
-                    />
-                </DialogContent>
-                <DialogActions>
-                    <Button onClick={closeDialog}>Cancel</Button>
-                    <Button type="submit">Create</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>
-        </>
-    )
-}
diff --git a/webui/src/app/ui/namespace/namespaceItem.tsx 
b/webui/src/app/ui/namespace/namespaceItem.tsx
deleted file mode 100644
index 21145e0..0000000
--- a/webui/src/app/ui/namespace/namespaceItem.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-/* 
- * 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 { Button, Dialog, DialogActions, DialogContent, DialogContentText, 
DialogTitle, IconButton, ListItem, ListItemButton, ListItemText, Menu, 
MenuItem, Tooltip } from "@mui/material";
-import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
-import { useCallback, useRef, useState } from "react";
-import { deleteNamespaceAction } from "@/app/lib/actions";
-
-export default function NamespaceItem({ item }: {item: string}) {
-    const [hover, setHover] = useState<boolean>(false);
-    const [showMenu, setShowMenu] = useState<boolean>(false);
-    const listItemTextRef = useRef(null);
-    const openMenu = useCallback(() => setShowMenu(true), []);
-    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 confirmDelete = useCallback(async () => {
-        await deleteNamespaceAction(item);
-        closeMenu();
-    },[item, closeMenu])
-    return (<ListItem
-        disablePadding
-        secondaryAction={
-            hover && <IconButton onClick={openMenu} ref={listItemTextRef} >
-                <MoreHorizIcon />
-            </IconButton>
-        }
-        onMouseEnter={() => setHover(true)}
-        onMouseLeave={() => !showMenu && setHover(false)}
-    >
-        <ListItemButton sx={{paddingRight: '10px'}}>
-            <Tooltip title={item} arrow>
-                <ListItemText classes={{primary: 'overflow-hidden 
text-ellipsis text-nowrap'}} primary={`${item}`} />
-            </Tooltip>
-        </ListItemButton>
-        <Menu
-            id={item}
-            open={showMenu}
-            onClose={closeMenu}
-            anchorEl={listItemTextRef.current}
-            anchorOrigin={{
-                vertical: 'center',
-                horizontal: 'center',
-            }}
-        >
-            <MenuItem color="red" 
onClick={openDeleteConfirmDialog}>Delete</MenuItem>
-        </Menu>
-        <Dialog
-            open={showDeleteConfirm}
-        >
-            <DialogTitle>Confirm</DialogTitle>
-            <DialogContent>
-                <DialogContentText>
-                    Please confirm you want to delete namespace {item}
-                </DialogContentText>
-            </DialogContent>
-            <DialogActions>
-                <Button onClick={closeDeleteConfirmDialog}>Cancel</Button>
-                <Button onClick={confirmDelete} color="error">Delete</Button>
-            </DialogActions>
-        </Dialog>
-    </ListItem>)
-}
diff --git a/webui/src/app/ui/sidebar.tsx b/webui/src/app/ui/sidebar.tsx
index 73c976a..4a0ef31 100644
--- a/webui/src/app/ui/sidebar.tsx
+++ b/webui/src/app/ui/sidebar.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,39 +6,98 @@
  * 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 { Divider, List } from "@mui/material";
-import { fetchNamespaces } from "@/app/lib/api";
-import NamespaceItem from "./namespace/namespaceItem";
-import NamespaceCreation from "./namespace/namespaceCreation";
+import { fetchClusters, fetchNamespaces } from "@/app/lib/api";
+import Item from "./sidebarItem";
+import { ClusterCreation, NamespaceCreation } from "./formCreation";
+import Link from "next/link";
+import { useState, useEffect } from "react";
+
+export function NamespaceSidebar() {
+    const [namespaces, setNamespaces] = useState<string[]>([]);
+    const [error, setError] = useState<string | null>(null);
+
+    useEffect(() => {
+        const fetchData = async () => {
+            try {
+                const fetchedNamespaces = await fetchNamespaces();
+                setNamespaces(fetchedNamespaces);
+            } catch (err) {
+                setError("Failed to fetch namespaces");
+            }
+        };
+        fetchData();
+    }, []);
+
+    return (
+        <div className="w-60 h-full flex">
+            <List className="w-full overflow-y-auto">
+                <div className="mt-2 mb-4 text-center">
+                    <NamespaceCreation position="sidebar" />
+                </div>
+                {error && <p style={{ color: "red" }}>{error}</p>}
+                {namespaces.map((namespace) => (
+                    <div key={namespace}>
+                        <Divider variant="fullWidth" />
+                        <Link href={`/namespaces/${namespace}`} passHref>
+                            <Item type="namespace" item={namespace} />
+                        </Link>
+                    </div>
+                ))}
+                <Divider variant="fullWidth" />
+            </List>
+            <Divider orientation="vertical" variant="fullWidth" flexItem />
+        </div>
+    );
+}
+
+export function ClusterSidebar({ namespace }: { namespace: string }) {
+    const [clusters, setClusters] = useState<string[]>([]);
+    const [error, setError] = useState<string | null>(null);
+
+    useEffect(() => {
+        const fetchData = async () => {
+            try {
+                const fetchedClusters = await fetchClusters(namespace);
+                setClusters(fetchedClusters);
+            } catch (err) {
+                setError("Failed to fetch clusters");
+            }
+        };
+        fetchData();
+    }, [namespace]);
 
-export default async function Sidebar() {
-    const namespaces = await fetchNamespaces();
     return (
         <div className="w-60 h-full flex">
             <List className="w-full overflow-y-auto">
                 <div className="mt-2 mb-4 text-center">
-                    <NamespaceCreation />
+                    <ClusterCreation namespace={namespace} position="sidebar" 
/>
                 </div>
-                {namespaces.map((namespace, index) => (<>
-                    {index === 0 && (
-                        <Divider variant="middle"/>
-                    )}
-                    <NamespaceItem key={namespace} item={namespace} />
-                    <Divider variant="middle"/>
-                </>))}
+                {error && <p style={{ color: "red" }}>{error}</p>}
+                {clusters.map((cluster) => (
+                    <div key={cluster}>
+                        <Divider variant="fullWidth" />
+                        <Link 
href={`/namespaces/${namespace}/clusters/${cluster}`} passHref>
+                            <Item type="cluster" item={cluster} 
namespace={namespace}/>
+                        </Link>
+                    </div>
+                ))}
+                <Divider variant="fullWidth" />
             </List>
-            <Divider orientation="vertical" flexItem/>
+            <Divider orientation="vertical" variant="fullWidth" flexItem />
         </div>
-    )
-}
\ No newline at end of file
+    );
+}
diff --git a/webui/src/app/ui/sidebarItem.tsx b/webui/src/app/ui/sidebarItem.tsx
new file mode 100644
index 0000000..f0656dc
--- /dev/null
+++ b/webui/src/app/ui/sidebarItem.tsx
@@ -0,0 +1,124 @@
+/*
+ * 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 { Button, Dialog, DialogActions, DialogContent, DialogContentText, 
DialogTitle, IconButton, ListItem, ListItemButton, ListItemText, Menu, 
MenuItem, 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 { faTrash } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+interface NamespaceItemProps {
+    item: string;
+    type: 'namespace';
+}
+
+interface ClusterItemProps {
+    item: string;
+    type: 'cluster';
+    namespace: string;
+}
+
+type ItemProps = NamespaceItemProps | ClusterItemProps;
+
+export default function Item(props: ItemProps) {
+    const { item, type } = props;
+    const [hover, setHover] = useState<boolean>(false);
+    const [showMenu, setShowMenu] = useState<boolean>(false);
+    const listItemTextRef = useRef(null);
+    const openMenu = useCallback(() => setShowMenu(true), []);
+    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 router = useRouter();
+
+    const confirmDelete = useCallback(async () => {
+        if (type === 'namespace') {
+            await deleteNamespace(item);
+            router.push('/namespaces');
+            router.refresh();
+        } else if (type === 'cluster') {
+            const { namespace } = props as ClusterItemProps;
+            await deleteCluster(namespace, item);
+            router.push(`/namespaces/${namespace}`);
+            router.refresh();
+        }
+        closeMenu();
+    }, [item, type, props, closeMenu, router]);
+
+    const activeItem = usePathname().split('/')[type === 'namespace' ? 2 : 4];
+    const isActive = item === activeItem;
+
+    return (
+        <ListItem
+            disablePadding
+            secondaryAction={
+                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)',
+                }
+            }}
+        >
+            <ListItemButton sx={{ paddingRight: '10px' }}>
+                <Tooltip title={item} arrow>
+                    <ListItemText classes={{ primary: 'overflow-hidden 
text-ellipsis text-nowrap' }} primary={`${item}`} />
+                </Tooltip>
+            </ListItemButton>
+            <Menu
+                id={item}
+                open={showMenu}
+                onClose={closeMenu}
+                anchorEl={listItemTextRef.current}
+                anchorOrigin={{
+                    vertical: 'center',
+                    horizontal: 'center',
+                }}
+            >
+                <MenuItem onClick={openDeleteConfirmDialog}>
+                    <FontAwesomeIcon icon={faTrash} color="red" />
+                </MenuItem>
+            </Menu>
+            <Dialog
+                open={showDeleteConfirm}
+            >
+                <DialogTitle>Confirm</DialogTitle>
+                <DialogContent>
+                    <DialogContentText>
+                        Please confirm you want to delete {type} {item}
+                    </DialogContentText>
+                </DialogContent>
+                <DialogActions>
+                    <Button onClick={closeDeleteConfirmDialog}>Cancel</Button>
+                    <Button onClick={confirmDelete} 
color="error">Delete</Button>
+                </DialogActions>
+            </Dialog>
+        </ListItem>
+    );
+}

Reply via email to