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