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>
+ );
+}