From 874153a6803dfb82e85370f784e33b961ad5f8f1 Mon Sep 17 00:00:00 2001 From: Agnik Misra Date: Tue, 18 Mar 2025 14:46:57 +0530 Subject: [PATCH 01/13] Fix edge case handling in Mock engine List method --- store/engine/engine_inmemory.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/store/engine/engine_inmemory.go b/store/engine/engine_inmemory.go index 00c17dd7..0de030b4 100644 --- a/store/engine/engine_inmemory.go +++ b/store/engine/engine_inmemory.go @@ -75,7 +75,7 @@ func (m *Mock) List(_ context.Context, prefix string) ([]Entry, error) { m.mu.Lock() defer m.mu.Unlock() - exists := make(map[string]bool, 0) + exists := map[string]bool{} var entries []Entry for k, v := range m.values { if strings.HasPrefix(k, prefix) { From 775863e0a4de8bc57cfa861600449387d53e05c0 Mon Sep 17 00:00:00 2001 From: Agnik Misra Date: Tue, 18 Mar 2025 14:56:37 +0530 Subject: [PATCH 02/13] There's a typo in the main UI heading - Controler should be Controller --- webui/src/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webui/src/app/page.tsx b/webui/src/app/page.tsx index 78456a69..4a40fe5c 100644 --- a/webui/src/app/page.tsx +++ b/webui/src/app/page.tsx @@ -25,7 +25,7 @@ export default function Home() { style={{minHeight: 'calc(100vh - 64px)', height: 'calc(100vh - 64px)'}} className={'flex flex-col items-center justify-center space-y-2 h-full'} > - Kvrocks Controler UI + Kvrocks Controller UI Work in progress... + ); +}; 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 a6f453a6..aad16022 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 @@ -19,15 +19,21 @@ "use client"; -import { Container, Typography, Tooltip } from "@mui/material"; +import { Box, Typography, Chip, Badge } 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 { AddNodeCard, AddShardCard, CreateCard } from "@/app/ui/createCard"; +import { AddNodeCard, ResourceCard } from "@/app/ui/createCard"; import Link from "next/link"; import { LoadingSpinner } from "@/app/ui/loadingSpinner"; import { truncateText } from "@/app/utils"; +import DeviceHubIcon from '@mui/icons-material/DeviceHub'; +import DnsIcon from '@mui/icons-material/Dns'; +import EmptyState from "@/app/ui/emptyState"; +import AlarmIcon from '@mui/icons-material/Alarm'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import RemoveCircleIcon from '@mui/icons-material/RemoveCircle'; export default function Shard({ params, @@ -63,54 +69,113 @@ export default function Shard({ return ; } + // Calculate uptime from creation timestamp + const calculateUptime = (timestamp: number) => { + const now = Math.floor(Date.now() / 1000); + const uptimeSeconds = now - timestamp; + + if (uptimeSeconds < 60) return `${uptimeSeconds} seconds`; + if (uptimeSeconds < 3600) return `${Math.floor(uptimeSeconds / 60)} minutes`; + if (uptimeSeconds < 86400) return `${Math.floor(uptimeSeconds / 3600)} hours`; + return `${Math.floor(uptimeSeconds / 86400)} days`; + }; + + // Get role color and icon + const getRoleInfo = (role: string) => { + if (role === 'master') { + return { + color: 'success', + icon: + }; + } + return { + color: 'info', + icon: + }; + }; + return (
- -
- - {nodesData.nodes.map( - (node: any, index: number) => ( - - - - Node {index + 1} - - - + + +
+ + + Shard {parseInt(shard) + 1} + {nodesData?.nodes && ( + + )} + + + {cluster} cluster in namespace {namespace} + +
+
+ +
+ + + + + {nodesData?.nodes && nodesData.nodes.length > 0 ? ( + nodesData.nodes.map((node: any, index: number) => { + const roleInfo = getRoleInfo(node.role); + return ( + + - ID: {truncateText(node.id, 20)} - - - - Address: {node.addr} - - - Role: {node.role} - - - Created At: {new Date(node.created_at * 1000).toLocaleString()} - - - - ) - )} -
- +
+
+ ID: + + {truncateText(node.id, 10)} + +
+ +
+ Address: + {node.addr} +
+ +
+ Uptime: + + + {calculateUptime(node.created_at)} + +
+
+ + + ); + }) + ) : ( + + } + /> + + )} +
+ +
); } diff --git a/webui/src/app/namespaces/[namespace]/page.tsx b/webui/src/app/namespaces/[namespace]/page.tsx index 6f06b9f5..71bbc77a 100644 --- a/webui/src/app/namespaces/[namespace]/page.tsx +++ b/webui/src/app/namespaces/[namespace]/page.tsx @@ -19,14 +19,18 @@ "use client"; -import { Container, Typography } from "@mui/material"; +import { Box, Typography, Chip } from "@mui/material"; import { NamespaceSidebar } from "../../ui/sidebar"; -import { AddClusterCard, CreateCard } from "../../ui/createCard"; +import { AddClusterCard, ResourceCard } from "../../ui/createCard"; import { fetchCluster, fetchClusters, fetchNamespaces } from "@/app/lib/api"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { useRouter, notFound } from "next/navigation"; import { useState, useEffect } from "react"; import { LoadingSpinner } from "@/app/ui/loadingSpinner"; +import StorageIcon from '@mui/icons-material/Storage'; +import FolderIcon from '@mui/icons-material/Folder'; +import EmptyState from "@/app/ui/emptyState"; +import GridViewIcon from '@mui/icons-material/GridView'; export default function Namespace({ params, @@ -44,7 +48,7 @@ export default function Namespace({ if (!fetchedNamespaces.includes(params.namespace)) { console.error(`Namespace ${params.namespace} not found`); - router.push("/404"); + notFound(); return; } @@ -78,45 +82,99 @@ export default function Namespace({ return (
- -
- - {clusterData.map( - (data, index) => - data && ( - - - - {data.name} - - - Version: {data.version} - - - Shards: {data.shards.length} - - - Slots: {data.shards[0].slot_ranges.join(", ")} - - - Target Shard Index: {data.shards[0].target_shard_index} - - - Migrating Slot: {data.shards[0].migrating_slot} - - - - ) - )} -
-
+
+ + +
+ + + {params.namespace} + + + + Namespace + +
+
+ +
+ + + + + {clusterData.length > 0 ? ( + clusterData.map((data, index) => ( + data && ( + + s.migrating_slot >= 0) + ? [{ label: "Migrating", color: "warning" }] + : []) + ]} + > +
+
+ Slots: + + {data.shards[0]?.slot_ranges.length > 0 ? + (data.shards[0].slot_ranges.length > 2 ? + `${data.shards[0].slot_ranges[0]}, ${data.shards[0].slot_ranges[1]}, ...` : + data.shards[0].slot_ranges.join(', ')) : + 'None'} + +
+ + {data.shards[0]?.target_shard_index >= 0 && ( +
+ Target Shard: + {data.shards[0].target_shard_index + 1} +
+ )} + + {data.shards[0]?.migrating_slot >= 0 && ( +
+ Migrating: + +
+ )} +
+ +
+ +
+
+ + ) + )) + ) : ( + + } + /> + + )} +
+
+
); } diff --git a/webui/src/app/namespaces/page.tsx b/webui/src/app/namespaces/page.tsx index ae0dfbf5..2f5daf54 100644 --- a/webui/src/app/namespaces/page.tsx +++ b/webui/src/app/namespaces/page.tsx @@ -19,13 +19,16 @@ "use client"; -import { Box, Container, Card, Link, Typography } from "@mui/material"; +import { Container, Typography, Paper, Box } 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"; +import { CreateCard, ResourceCard } from "../ui/createCard"; +import Link from "next/link"; +import FolderIcon from '@mui/icons-material/Folder'; +import EmptyState from "../ui/emptyState"; export default function Namespace() { const [namespaces, setNamespaces] = useState([]); @@ -36,7 +39,6 @@ export default function Namespace() { const fetchData = async () => { try { const fetchedNamespaces = await fetchNamespaces(); - setNamespaces(fetchedNamespaces); } catch (error) { console.error("Error fetching namespaces:", error); @@ -55,34 +57,42 @@ export default function Namespace() { return (
- -
- {namespaces.length !== 0 ? ( - namespaces.map( - (namespace, index) => - namespace && ( - - - - {namespace} Namespace - - - - ) - ) +
+ + + + Namespaces + + + + {namespaces.length > 0 ? ( +
+ {namespaces.map((namespace) => ( + + +
+ +
+
+ + ))} +
) : ( - - - No namespaces found, create one to get started - - + } + /> )} -
- + +
); } diff --git a/webui/src/app/not-found.tsx b/webui/src/app/not-found.tsx new file mode 100644 index 00000000..25d957d0 --- /dev/null +++ b/webui/src/app/not-found.tsx @@ -0,0 +1,67 @@ +/* + * 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, Typography, Box } from "@mui/material"; +import Link from "next/link"; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import HomeIcon from '@mui/icons-material/Home'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { useRouter } from "next/navigation"; + +export default function NotFound() { + const router = useRouter(); + + return ( +
+ + + + + Page Not Found + + + + We couldn't find the page you're looking for. It might have been moved, deleted, or never existed. + + +
+ + + +
+
+
+ ); +} diff --git a/webui/src/app/page.tsx b/webui/src/app/page.tsx index 4a40fe5c..115e8e8e 100644 --- a/webui/src/app/page.tsx +++ b/webui/src/app/page.tsx @@ -17,19 +17,185 @@ * under the License. */ -import { Button, Container, Typography } from "@mui/material"; +"use client"; + +import { Button, Typography, Box, Paper, Grid, Card, CardContent } from "@mui/material"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useTheme } from "./theme-provider"; +import StorageIcon from '@mui/icons-material/Storage'; +import DnsIcon from '@mui/icons-material/Dns'; +import DeviceHubIcon from '@mui/icons-material/DeviceHub'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import GitHubIcon from '@mui/icons-material/GitHub'; +import LaunchIcon from '@mui/icons-material/Launch'; +import MenuBookIcon from '@mui/icons-material/MenuBook'; +import Link from "next/link"; export default function Home() { + const router = useRouter(); + const { isDarkMode } = useTheme(); + + const features = [ + { + title: "Cluster Management", + description: "Create, modify, and monitor Redis clusters with an intuitive interface", + icon: + }, + { + title: "Shard Distribution", + description: "Efficiently distribute data across multiple shards for optimal performance", + icon: + }, + { + title: "Node Monitoring", + description: "Monitor node health, performance, and connectivity in real-time", + icon: + }, + { + title: "Advanced Metrics", + description: "View detailed performance metrics to optimize your infrastructure", + icon: + } + ]; + + const resources = [ + { + title: "Documentation", + description: "Learn how to use Kvrocks Controller", + icon: , + url: "https://kvrocks.apache.org/docs/" + }, + { + title: "GitHub Repository", + description: "View the source code on GitHub", + icon: , + url: "https://github.com/apache/kvrocks-controller" + } + ]; + return ( -
- Kvrocks Controller UI - Work in progress... - +
+ {/* Hero Section */} +
+
+
+ Kvrocks Logo +
+ + + Kvrocks Controller + + + + A powerful management interface for Apache Kvrocks clusters, enabling efficient distribution, monitoring, and maintenance of your Redis compatible database infrastructure. + + +
+ + + +
+
+
+ + {/* Features Section */} +
+
+ + Key Features + + + + {features.map((feature, index) => ( + + +
+ {feature.icon} +
+ + {feature.title} + + + {feature.description} + +
+
+ ))} +
+
+
+ + {/* Resources Section */} +
+
+ + Resources + + + + {resources.map((resource, index) => ( + + + +
+
+ {resource.icon} +
+
+ + {resource.title} + + + + {resource.description} + +
+
+
+ +
+ ))} +
+
+
+ + {/* Footer */} +
+
+ + © {new Date().getFullYear()} Apache Software Foundation. Licensed under Apache License, Version 2.0. + +
+
); } diff --git a/webui/src/app/theme-provider.tsx b/webui/src/app/theme-provider.tsx new file mode 100644 index 00000000..1b7e18fa --- /dev/null +++ b/webui/src/app/theme-provider.tsx @@ -0,0 +1,109 @@ +/* + * 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 { createContext, useContext, useEffect, useState } from "react"; +import { ThemeProvider as MuiThemeProvider, createTheme } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; + +type ThemeContextType = { + isDarkMode: boolean; + toggleTheme: () => void; +}; + +const ThemeContext = createContext({ + isDarkMode: false, + toggleTheme: () => {} +}); + +export const useTheme = () => useContext(ThemeContext); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [isDarkMode, setIsDarkMode] = useState(false); + + useEffect(() => { + // Check if user has already set a preference + const storedTheme = localStorage.getItem("theme"); + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + if (storedTheme === "dark" || (!storedTheme && prefersDark)) { + setIsDarkMode(true); + document.documentElement.classList.add("dark"); + } else { + setIsDarkMode(false); + document.documentElement.classList.remove("dark"); + } + }, []); + + const toggleTheme = () => { + setIsDarkMode((prev) => { + const newMode = !prev; + if (newMode) { + document.documentElement.classList.add("dark"); + localStorage.setItem("theme", "dark"); + } else { + document.documentElement.classList.remove("dark"); + localStorage.setItem("theme", "light"); + } + return newMode; + }); + }; + + // Create MUI theme based on current mode + const theme = createTheme({ + palette: { + mode: isDarkMode ? "dark" : "light", + primary: { + main: "#1976d2", + light: "#42a5f5", + dark: "#1565c0", + contrastText: "#fff", + }, + secondary: { + main: "#9c27b0", + light: "#ba68c8", + dark: "#7b1fa2", + contrastText: "#fff", + }, + background: { + default: isDarkMode ? "#121212" : "#fafafa", + paper: isDarkMode ? "#1e1e1e" : "#ffffff", + }, + }, + components: { + MuiPaper: { + styleOverrides: { + root: { + transition: "background-color 0.3s ease", + }, + }, + }, + }, + }); + + return ( + + + + {children} + + + ); +} diff --git a/webui/src/app/ui/banner.tsx b/webui/src/app/ui/banner.tsx index b9b4d1c7..8a73f0b2 100644 --- a/webui/src/app/ui/banner.tsx +++ b/webui/src/app/ui/banner.tsx @@ -17,9 +17,16 @@ * under the License. */ -import { AppBar, Container, Toolbar } from "@mui/material"; +"use client"; + +import { AppBar, Container, Toolbar, IconButton, Box, Tooltip, Typography } from "@mui/material"; import Image from "next/image"; import NavLinks from "./nav-links"; +import { useTheme } from "../theme-provider"; +import Brightness4Icon from "@mui/icons-material/Brightness4"; +import Brightness7Icon from "@mui/icons-material/Brightness7"; +import GitHubIcon from "@mui/icons-material/GitHub"; +import { usePathname } from "next/navigation"; const links = [ { @@ -30,18 +37,69 @@ const links = [ title: 'Namespaces' },{ url: 'https://kvrocks.apache.org', - title: 'community', + title: 'Documentation', _blank: true }, -] +]; export default function Banner() { - return ( - - - logo - - - - ) + const { isDarkMode, toggleTheme } = useTheme(); + const pathname = usePathname(); + + // Generate breadcrumb from pathname + const breadcrumbs = pathname.split('/').filter(Boolean); + + return ( + + + +
+ logo + + Kvrocks Controller + +
+ + + + + + + {breadcrumbs.length > 0 && ( + + {breadcrumbs.map((breadcrumb, i) => ( + + {i > 0 && " / "} + {breadcrumb} + + ))} + + )} + + + + {isDarkMode ? : } + + + + + + + + + +
+
+
+ ); } \ No newline at end of file diff --git a/webui/src/app/ui/createCard.tsx b/webui/src/app/ui/createCard.tsx index d09f498d..d3e6b322 100644 --- a/webui/src/app/ui/createCard.tsx +++ b/webui/src/app/ui/createCard.tsx @@ -19,7 +19,7 @@ "use client"; -import { Card, Box } from "@mui/material"; +import { Box, Paper, Chip, Tooltip } from "@mui/material"; import React, { ReactNode } from "react"; import { ClusterCreation, @@ -33,56 +33,38 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; interface CreateCardProps { children: ReactNode; + className?: string; } -export const CreateCard: React.FC = ({ children }) => { +export const CreateCard: React.FC = ({ children, className = "" }) => { return ( - - + {children} - + ); }; export const AddClusterCard = ({ namespace }: { namespace: string }) => { return ( - - -
-
- -
-
- + +
+ +
+
+ +
+
+ +
@@ -97,25 +79,19 @@ export const AddShardCard = ({ cluster: string; }) => { return ( - - -
-
+ +
+ +
-
-
{ return ( - - -
- +
+ +
+ +
+
+ + ); +}; + +export const ResourceCard = ({ + title, + description, + tags, + children +}: { + title: string; + description?: string; + tags?: Array<{label: string, color?: string}>; + children: ReactNode; +}) => { + return ( + +
+
{title}
+ {description && ( +
+ {description} +
+ )} +
+ {children} +
+ {tags && tags.length > 0 && ( +
+ {tags.map((tag, i) => ( + + ))} +
+ )}
); diff --git a/webui/src/app/ui/emptyState.tsx b/webui/src/app/ui/emptyState.tsx new file mode 100644 index 00000000..a329c871 --- /dev/null +++ b/webui/src/app/ui/emptyState.tsx @@ -0,0 +1,63 @@ +/* + * 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 React, { ReactNode } from 'react'; +import { Box, Paper, Typography, Button } from '@mui/material'; + +interface EmptyStateProps { + title: string; + description: string; + icon?: ReactNode; + action?: { + label: string; + onClick: () => void; + }; +} + +const EmptyState: React.FC = ({ title, description, icon, action }) => { + return ( + + {icon && ( + + {icon} + + )} + + {title} + + + {description} + + {action && ( + + )} + + ); +}; + +export default EmptyState; diff --git a/webui/src/app/ui/formDialog.tsx b/webui/src/app/ui/formDialog.tsx index 2e26df0c..157ae86a 100644 --- a/webui/src/app/ui/formDialog.tsx +++ b/webui/src/app/ui/formDialog.tsx @@ -23,6 +23,7 @@ import { Dialog, DialogActions, DialogContent, + DialogContentText, DialogTitle, Snackbar, TextField, @@ -34,22 +35,25 @@ import { Select, InputLabel, FormControl, + Paper, + CircularProgress, } from "@mui/material"; import React, { useCallback, useState, FormEvent } from "react"; - - interface FormDialogProps { +import AddIcon from '@mui/icons-material/Add'; + +interface FormDialogProps { position: string; title: string; submitButtonLabel: string; formFields: { - name: string; - label: string; - type: string; - required?: boolean; - values?: string[]; + name: string; + label: string; + type: string; + required?: boolean; + values?: string[]; }[]; onSubmit: (formData: FormData) => Promise; - } +} const FormDialog: React.FC = ({ position, @@ -63,6 +67,7 @@ const FormDialog: React.FC = ({ const closeDialog = useCallback(() => setShowDialog(false), []); const [errorMessage, setErrorMessage] = useState(""); const [arrayValues, setArrayValues] = useState<{ [key: string]: string[] }>({}); + const [submitting, setSubmitting] = useState(false); const handleArrayChange = (name: string, value: string[]) => { setArrayValues({ ...arrayValues, [name]: value }); @@ -70,40 +75,70 @@ const FormDialog: React.FC = ({ const handleSubmit = async (event: FormEvent) => { event.preventDefault(); + setSubmitting(true); 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(); + try { + const error = await onSubmit(formData); + if (error) { + setErrorMessage(error); + } else { + closeDialog(); + } + } catch (error) { + setErrorMessage("An unexpected error occurred"); + } finally { + setSubmitting(false); } }; return ( <> {position === "card" ? ( - ) : ( - )} - +
- {title} - + + + {title} + + + {formFields.map((field, index) => field.type === "array" ? ( - - + + {field.label} = ({ {...getTagProps({ index })} key={index} label={option} + size="small" + className="bg-primary-light/20 dark:bg-primary-dark/20" /> )) } @@ -129,19 +166,23 @@ const FormDialog: React.FC = ({ variant="outlined" label={`Add ${field.label}*`} placeholder="Type and press enter" + size="small" + className="bg-white dark:bg-dark-paper rounded-md" /> )} /> ) : field.type === "enum" ? ( - - {field.label} + + {field.label}