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