diff --git a/webui/src/app/globals.css b/webui/src/app/globals.css index 7c7084d2..6578a51d 100644 --- a/webui/src/app/globals.css +++ b/webui/src/app/globals.css @@ -20,3 +20,70 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer components { + .card { + @apply bg-white dark:bg-dark-paper border border-light-border dark:border-dark-border rounded-lg shadow-card hover:shadow-card-hover transition-all p-5 flex flex-col; + } + + .sidebar-item { + @apply flex items-center px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-paper rounded-lg my-1 transition-colors; + } + + .sidebar-item-active { + @apply bg-primary-light/10 text-primary dark:text-primary-light; + } + + .btn { + @apply px-4 py-2 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2; + } + + .btn-primary { + @apply bg-primary text-white hover:bg-primary-dark focus:ring-primary; + } + + .btn-outline { + @apply border border-primary text-primary hover:bg-primary hover:text-white focus:ring-primary; + } + + .container-inner { + @apply p-6 max-w-screen-2xl mx-auto; + } +} + +/* custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #a1a1a1; +} + +.dark ::-webkit-scrollbar-thumb { + background: #555; +} + +.dark ::-webkit-scrollbar-thumb:hover { + background: #777; +} + +/* smooth transitions for dark mode */ +body { + transition: background-color 0.3s ease; +} + +.dark body { + background-color: #121212; + color: #e0e0e0; +} diff --git a/webui/src/app/layout.tsx b/webui/src/app/layout.tsx index a1e1a0ac..20a34d73 100644 --- a/webui/src/app/layout.tsx +++ b/webui/src/app/layout.tsx @@ -22,12 +22,13 @@ import { Inter } from "next/font/google"; import "./globals.css"; import Banner from "./ui/banner"; import { Container } from "@mui/material"; +import { ThemeProvider } from "./theme-provider"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Kvrocks Controller", - description: "Kvrocks Controller", + title: "Apache Kvrocks Controller", + description: "Management UI for Apache Kvrocks clusters", }; export default function RootLayout({ @@ -36,12 +37,14 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - - - {children} - + + + + + + {children} + + ); diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx index 16f8151e..9fdd96c1 100644 --- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx +++ b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx @@ -22,15 +22,20 @@ import { Box, Container, - Typography + Typography, + Chip, + Badge, } 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 { AddShardCard, ResourceCard } from "@/app/ui/createCard"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { LoadingSpinner } from "@/app/ui/loadingSpinner"; +import DnsIcon from '@mui/icons-material/Dns'; +import StorageIcon from '@mui/icons-material/Storage'; +import EmptyState from "@/app/ui/emptyState"; export default function Cluster({ params, @@ -67,42 +72,91 @@ export default function Cluster({ return ; } + const formatSlotRanges = (ranges: string[]) => { + if (!ranges || ranges.length === 0) return "None"; + if (ranges.length <= 2) return ranges.join(", "); + return `${ranges[0]}, ${ranges[1]}, ... (+${ranges.length - 2} more)`; + }; + return (
- -
- - {shardsData.map((shard, index) => ( - - - - Shard {index + 1} - - - Nodes : {shard.nodes.length} - - - Slots: {shard.slot_ranges.join(", ")} - - - Target Shard Index: {shard.target_shard_index} - - - Migrating Slot: {shard.migrating_slot} - - - - ))} -
-
+
+ + +
+ + + {cluster} + + + + Cluster in namespace: {namespace} + +
+
+ +
+ + + + + {shardsData.length > 0 ? ( + shardsData.map((shard, index) => ( + + = 0 ? { label: "Migrating", color: "warning" } : undefined + ].filter(Boolean)} + > +
+
+ Slots: + {formatSlotRanges(shard.slot_ranges)} +
+ + {shard.target_shard_index >= 0 && ( +
+ Target Shard: + {shard.target_shard_index + 1} +
+ )} + + {shard.migrating_slot >= 0 && ( +
+ Migrating Slot: + + {shard.migrating_slot} + +
+ )} +
+
+ + )) + ) : ( + + } + /> + + )} +
+
+
); } 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 9ac6ea17..d65e4286 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 @@ -23,18 +23,24 @@ import { listNodes } from "@/app/lib/api"; import { NodeSidebar } from "@/app/ui/sidebar"; import { Box, - Container, - Card, - Alert, - Snackbar, Typography, - Tooltip, + Chip, + Paper, + Divider, + Grid, + Alert, } 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"; +import DeviceHubIcon from '@mui/icons-material/DeviceHub'; +import LockIcon from '@mui/icons-material/Lock'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import StorageIcon from '@mui/icons-material/Storage'; +import DnsIcon from '@mui/icons-material/Dns'; export default function Node({ params, @@ -43,8 +49,9 @@ export default function Node({ }) { const { namespace, cluster, shard, node } = params; const router = useRouter(); - const [nodeData, setNodeData] = useState(null); + const [nodeData, setNodeData] = useState([]); const [loading, setLoading] = useState(true); + const [copied, setCopied] = useState(null); useEffect(() => { const fetchData = async () => { @@ -70,51 +77,229 @@ export default function Node({ return ; } + const currentNode = nodeData[parseInt(node)]; + if (!currentNode) { + return ( +
+ + + + Node not found + + +
+ ); + } + + // Get role color and text style + const getRoleStyles = (role: string) => { + if (role === 'master') { + return { + color: 'success', + textClass: 'text-success font-medium', + icon: + }; + } + return { + color: 'info', + textClass: 'text-info font-medium', + icon: + }; + }; + + const copyToClipboard = (text: string, type: string) => { + navigator.clipboard.writeText(text); + setCopied(type); + setTimeout(() => setCopied(null), 2000); + }; + + const formattedDate = new Date(currentNode.created_at * 1000).toLocaleString(); + return (
- -
- {nodeData.map((nodeObj: any, index: number) => - index === Number(node) ? ( - <> - - - Node {index + 1} - - - - ID: {truncateText(nodeObj.id, 20)} +
+ + +
+ + + Node {parseInt(node) + 1} + + + + Shard {parseInt(shard) + 1}, {cluster} cluster, {namespace} namespace + +
+
+ + + + + Node Details + + + + + +
+
+ + ID + +
+ + {currentNode.id} + + copyToClipboard(currentNode.id, 'id')} + className="ml-2 text-gray-500 hover:text-primary" + title="Copy ID" + > + {copied === 'id' ? + : + + } + +
+
+ +
+ + Address - - - Address: {nodeObj.addr} - - - Role: {nodeObj.role} - - - Created At:{" "} - {new Date(nodeObj.created_at * 1000).toLocaleString()} - - - - ) : null - )} -
- +
+ + {currentNode.addr} + + copyToClipboard(currentNode.addr, 'addr')} + className="ml-2 text-gray-500 hover:text-primary" + title="Copy Address" + > + {copied === 'addr' ? + : + + } + +
+
+
+ + + +
+
+ + Role + + + {getRoleStyles(currentNode.role).icon} {currentNode.role} + +
+ +
+ + Created At + + + + {formattedDate} + +
+ + {currentNode.password && ( +
+ + Authentication + +
+ + {currentNode.password ? '••••••••' : 'No password set'} + + copyToClipboard(currentNode.password, 'pwd')} + className="ml-2 text-gray-500 hover:text-primary" + title="Copy Password" + disabled={!currentNode.password} + > + {copied === 'pwd' ? + : + + } + +
+
+ )} +
+
+ + + + + + + Shard Information + + + + + + + Shard + + + Shard {parseInt(shard) + 1} + + + + + Cluster + + + {cluster} + + + + + Namespace + + + {namespace} + + + + + +
); } + +interface IconButtonProps { + onClick: () => void; + className?: string; + title?: string; + disabled?: boolean; + children: React.ReactNode; +} + +// Custom IconButton component +const IconButton: React.FC = ({ onClick, className = "", title, disabled = false, children }) => { + return ( + + ); +}; 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..9c46b5ec 100644 --- a/webui/src/app/page.tsx +++ b/webui/src/app/page.tsx @@ -17,19 +17,186 @@ * under the License. */ -import { Button, Container, Typography } from "@mui/material"; +"use client"; + +import { Button, Typography, Box, Paper, Grid } 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 currentYear = new Date().getFullYear(); // minor change: compute current year once + + 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 +
+ + + Apache Kvrocks Controller + + + + A web 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 */} +
+
+ + Copyright © {currentYear} The Apache Software Foundation. Apache Kvrocks, Kvrocks, and its feather logo are trademarks of The Apache Software Foundation. Redis and its cube logo are registered trademarks of Redis Ltd. Apache Kvrocks Controller is released under Apache License, Version 2.0. + +
+
); -} +} \ No newline at end of file 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..69850e48 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 + + Apache 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}