This is an automated email from the ASF dual-hosted git repository.
twice 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 bf1ba47 feat(webui): redesign the controller web management UI (#281)
bf1ba47 is described below
commit bf1ba4759640bf192292392e93c6288b21af9455
Author: Agnik Misra <[email protected]>
AuthorDate: Thu Mar 20 08:21:45 2025 +0530
feat(webui): redesign the controller web management UI (#281)
* Fix edge case handling in Mock engine List method
* There's a typo in the main UI heading - Controler should be Controller
* This code launches a new goroutine for each node in the cluster without
any concurrency limits.
* redesign the webpage
* Update cluster.go
* fix conflict
* conflict
* Update webui/src/app/page.tsx
* Update webui/src/app/page.tsx
* Update webui/src/app/layout.tsx
* Update webui/src/app/ui/banner.tsx
* Update webui/src/app/page.tsx
* Update webui/src/app/page.tsx
---------
Co-authored-by: Twice <[email protected]>
---
webui/src/app/globals.css | 67 +++++
webui/src/app/layout.tsx | 19 +-
.../[namespace]/clusters/[cluster]/page.tsx | 124 ++++++---
.../[cluster]/shards/[shard]/nodes/[node]/page.tsx | 281 +++++++++++++++++----
.../clusters/[cluster]/shards/[shard]/page.tsx | 157 ++++++++----
webui/src/app/namespaces/[namespace]/page.tsx | 144 +++++++----
webui/src/app/namespaces/page.tsx | 68 ++---
webui/src/app/not-found.tsx | 67 +++++
webui/src/app/page.tsx | 189 +++++++++++++-
webui/src/app/theme-provider.tsx | 109 ++++++++
webui/src/app/ui/banner.tsx | 80 +++++-
webui/src/app/ui/createCard.tsx | 155 +++++++-----
webui/src/app/ui/emptyState.tsx | 63 +++++
webui/src/app/ui/formDialog.tsx | 120 ++++++---
webui/src/app/ui/loadingSpinner.tsx | 56 +++-
webui/src/app/ui/nav-links.tsx | 44 +++-
webui/src/app/ui/sidebar.tsx | 272 +++++++++++++-------
webui/src/app/ui/sidebarItem.tsx | 143 +++++++----
webui/src/app/utils.ts | 52 +++-
webui/tailwind.config.ts | 57 ++++-
20 files changed, 1766 insertions(+), 501 deletions(-)
diff --git a/webui/src/app/globals.css b/webui/src/app/globals.css
index 7c7084d..6578a51 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 a1e1a0a..20a34d7 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 (
- <html lang="en">
- <body className={inter.className}>
- <Banner />
- <Container sx={{marginTop: '64px', height: 'calc(100vh -
64px)'}} maxWidth={false} disableGutters>
- {children}
- </Container>
+ <html lang="en" suppressHydrationWarning>
+ <body className={`${inter.className} bg-light dark:bg-dark
min-h-screen`}>
+ <ThemeProvider>
+ <Banner />
+ <Container sx={{marginTop: '64px', height: 'calc(100vh -
64px)'}} maxWidth={false} disableGutters>
+ {children}
+ </Container>
+ </ThemeProvider>
</body>
</html>
);
diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
index 16f8151..9fdd96c 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 <LoadingSpinner />;
}
+ 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 (
<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">
- <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 className="flex-1 overflow-auto">
+ <Box className="container-inner">
+ <Box className="flex items-center justify-between mb-6">
+ <div>
+ <Typography variant="h5" className="font-medium
text-gray-800 dark:text-gray-100 flex items-center">
+ <StorageIcon className="mr-2 text-primary
dark:text-primary-light" />
+ {cluster}
+ <Chip
+ label={`${shardsData.length} shards`}
+ size="small"
+ color="primary"
+ className="ml-3"
+ />
+ </Typography>
+ <Typography variant="body2"
className="text-gray-500 dark:text-gray-400 mt-1">
+ Cluster in namespace: {namespace}
+ </Typography>
+ </div>
+ </Box>
+
+ <div className="grid grid-cols-1 sm:grid-cols-2
lg:grid-cols-3 xl:grid-cols-4 gap-4">
+ <Box className="col-span-1">
+ <AddShardCard namespace={namespace}
cluster={cluster} />
+ </Box>
+
+ {shardsData.length > 0 ? (
+ shardsData.map((shard, index) => (
+ <Link
+ key={index}
+
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${index}`}
+ className="col-span-1"
+ >
+ <ResourceCard
+ title={`Shard ${index + 1}`}
+ tags={[
+ { label: `${shard.nodes.length}
nodes`, color: "secondary" },
+ shard.migrating_slot >= 0 ? {
label: "Migrating", color: "warning" } : undefined
+ ].filter(Boolean)}
+ >
+ <div className="space-y-2 text-sm">
+ <div className="flex
justify-between">
+ <span className="text-gray-500
dark:text-gray-400">Slots:</span>
+ <span
className="font-medium">{formatSlotRanges(shard.slot_ranges)}</span>
+ </div>
+
+ {shard.target_shard_index >= 0 && (
+ <div className="flex
justify-between">
+ <span
className="text-gray-500 dark:text-gray-400">Target Shard:</span>
+ <span
className="font-medium">{shard.target_shard_index + 1}</span>
+ </div>
+ )}
+
+ {shard.migrating_slot >= 0 && (
+ <div className="flex
justify-between">
+ <span
className="text-gray-500 dark:text-gray-400">Migrating Slot:</span>
+ <Badge color="warning"
variant="dot">
+ <span
className="font-medium">{shard.migrating_slot}</span>
+ </Badge>
+ </div>
+ )}
+ </div>
+ </ResourceCard>
+ </Link>
+ ))
+ ) : (
+ <Box className="col-span-full">
+ <EmptyState
+ title="No shards found"
+ description="Create a shard to get started"
+ icon={<DnsIcon sx={{ fontSize: 60 }} />}
+ />
+ </Box>
+ )}
+ </div>
+ </Box>
+ </div>
</div>
);
}
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 9ac6ea1..d65e428 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<any>(null);
+ const [nodeData, setNodeData] = useState<any[]>([]);
const [loading, setLoading] = useState<boolean>(true);
+ const [copied, setCopied] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
@@ -70,51 +77,229 @@ export default function Node({
return <LoadingSpinner />;
}
+ const currentNode = nodeData[parseInt(node)];
+ if (!currentNode) {
+ return (
+ <div className="flex h-full">
+ <NodeSidebar namespace={namespace} cluster={cluster}
shard={shard} />
+ <Box className="flex-1 container-inner flex items-center
justify-center">
+ <Alert severity="error" variant="filled"
className="shadow-lg">
+ Node not found
+ </Alert>
+ </Box>
+ </div>
+ );
+ }
+
+ // Get role color and text style
+ const getRoleStyles = (role: string) => {
+ if (role === 'master') {
+ return {
+ color: 'success',
+ textClass: 'text-success font-medium',
+ icon: <CheckCircleIcon fontSize="small" className="mr-1" />
+ };
+ }
+ return {
+ color: 'info',
+ textClass: 'text-info font-medium',
+ icon: <DeviceHubIcon fontSize="small" className="mr-1" />
+ };
+ };
+
+ 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 (
<div className="flex h-full">
<NodeSidebar namespace={namespace} cluster={cluster} shard={shard}
/>
- <Container
- maxWidth={false}
- disableGutters
- sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
- >
- <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)}
+ <div className="flex-1 overflow-auto">
+ <Box className="container-inner">
+ <Box className="flex items-center justify-between mb-6">
+ <div>
+ <Typography variant="h5" className="font-medium
text-gray-800 dark:text-gray-100 flex items-center">
+ <DeviceHubIcon className="mr-2 text-primary
dark:text-primary-light" />
+ Node {parseInt(node) + 1}
+ <Chip
+ label={currentNode.role}
+ size="small"
+
color={getRoleStyles(currentNode.role).color as any}
+ className="ml-3"
+ icon={getRoleStyles(currentNode.role).icon}
+ />
+ </Typography>
+ <Typography variant="body2"
className="text-gray-500 dark:text-gray-400 mt-1">
+ Shard {parseInt(shard) + 1}, {cluster}
cluster, {namespace} namespace
+ </Typography>
+ </div>
+ </Box>
+
+ <Paper className="bg-white dark:bg-dark-paper border
border-light-border dark:border-dark-border rounded-lg shadow-card p-6 mb-6">
+ <Typography variant="h6" className="mb-4 font-medium
flex items-center">
+ <StorageIcon fontSize="small" className="mr-2" />
+ Node Details
+ </Typography>
+ <Divider className="mb-4" />
+
+ <Grid container spacing={3}>
+ <Grid item xs={12} md={6}>
+ <div className="space-y-4">
+ <div>
+ <Typography variant="subtitle2"
className="text-gray-500 dark:text-gray-400 mb-1">
+ ID
+ </Typography>
+ <div className="flex items-center">
+ <Typography variant="body1"
className="font-mono bg-gray-50 dark:bg-dark-border px-3 py-2 rounded flex-1
overflow-hidden text-ellipsis">
+ {currentNode.id}
+ </Typography>
+ <IconButton
+ onClick={() =>
copyToClipboard(currentNode.id, 'id')}
+ className="ml-2 text-gray-500
hover:text-primary"
+ title="Copy ID"
+ >
+ {copied === 'id' ?
+ <CheckCircleIcon
fontSize="small" className="text-success" /> :
+ <ContentCopyIcon
fontSize="small" />
+ }
+ </IconButton>
+ </div>
+ </div>
+
+ <div>
+ <Typography variant="subtitle2"
className="text-gray-500 dark:text-gray-400 mb-1">
+ Address
</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 className="flex items-center">
+ <Typography variant="body1"
className="bg-gray-50 dark:bg-dark-border px-3 py-2 rounded flex-1">
+ {currentNode.addr}
+ </Typography>
+ <IconButton
+ onClick={() =>
copyToClipboard(currentNode.addr, 'addr')}
+ className="ml-2 text-gray-500
hover:text-primary"
+ title="Copy Address"
+ >
+ {copied === 'addr' ?
+ <CheckCircleIcon
fontSize="small" className="text-success" /> :
+ <ContentCopyIcon
fontSize="small" />
+ }
+ </IconButton>
+ </div>
+ </div>
+ </div>
+ </Grid>
+
+ <Grid item xs={12} md={6}>
+ <div className="space-y-4">
+ <div>
+ <Typography variant="subtitle2"
className="text-gray-500 dark:text-gray-400 mb-1">
+ Role
+ </Typography>
+ <Typography variant="body1"
className={`${getRoleStyles(currentNode.role).textClass} flex items-center`}>
+
{getRoleStyles(currentNode.role).icon} {currentNode.role}
+ </Typography>
+ </div>
+
+ <div>
+ <Typography variant="subtitle2"
className="text-gray-500 dark:text-gray-400 mb-1">
+ Created At
+ </Typography>
+ <Typography variant="body1"
className="flex items-center">
+ <AccessTimeIcon fontSize="small"
className="mr-1 text-gray-500" />
+ {formattedDate}
+ </Typography>
+ </div>
+
+ {currentNode.password && (
+ <div>
+ <Typography variant="subtitle2"
className="text-gray-500 dark:text-gray-400 mb-1">
+ Authentication
+ </Typography>
+ <div className="flex items-center">
+ <Typography variant="body2"
className="bg-gray-50 dark:bg-dark-border px-3 py-2 rounded flex-1 font-mono">
+ {currentNode.password ?
'••••••••' : 'No password set'}
+ </Typography>
+ <IconButton
+ onClick={() =>
copyToClipboard(currentNode.password, 'pwd')}
+ className="ml-2
text-gray-500 hover:text-primary"
+ title="Copy Password"
+
disabled={!currentNode.password}
+ >
+ {copied === 'pwd' ?
+ <CheckCircleIcon
fontSize="small" className="text-success" /> :
+ <LockIcon
fontSize="small" />
+ }
+ </IconButton>
+ </div>
+ </div>
+ )}
+ </div>
+ </Grid>
+ </Grid>
+ </Paper>
+
+ <Paper className="bg-white dark:bg-dark-paper border
border-light-border dark:border-dark-border rounded-lg shadow-card p-6">
+ <Typography variant="h6" className="mb-4 font-medium
flex items-center">
+ <DnsIcon fontSize="small" className="mr-2" />
+ Shard Information
+ </Typography>
+ <Divider className="mb-4" />
+
+ <Grid container spacing={3}>
+ <Grid item xs={12} md={4}>
+ <Typography variant="subtitle2"
className="text-gray-500 dark:text-gray-400 mb-1">
+ Shard
+ </Typography>
+ <Typography variant="body1">
+ Shard {parseInt(shard) + 1}
+ </Typography>
+ </Grid>
+ <Grid item xs={12} md={4}>
+ <Typography variant="subtitle2"
className="text-gray-500 dark:text-gray-400 mb-1">
+ Cluster
+ </Typography>
+ <Typography variant="body1">
+ {cluster}
+ </Typography>
+ </Grid>
+ <Grid item xs={12} md={4}>
+ <Typography variant="subtitle2"
className="text-gray-500 dark:text-gray-400 mb-1">
+ Namespace
+ </Typography>
+ <Typography variant="body1">
+ {namespace}
+ </Typography>
+ </Grid>
+ </Grid>
+ </Paper>
+ </Box>
+ </div>
</div>
);
}
+
+interface IconButtonProps {
+ onClick: () => void;
+ className?: string;
+ title?: string;
+ disabled?: boolean;
+ children: React.ReactNode;
+}
+
+// Custom IconButton component
+const IconButton: React.FC<IconButtonProps> = ({ onClick, className = "",
title, disabled = false, children }) => {
+ return (
+ <button
+ onClick={onClick}
+ disabled={disabled}
+ className={`w-8 h-8 rounded-full flex items-center justify-center
hover:bg-gray-100 dark:hover:bg-dark-border ${disabled ? 'opacity-50
cursor-not-allowed' : ''} ${className}`}
+ title={title}
+ >
+ {children}
+ </button>
+ );
+};
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 a6f453a..aad1602 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 <LoadingSpinner />;
}
+ // 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: <CheckCircleIcon fontSize="small"
className="text-success" />
+ };
+ }
+ return {
+ color: 'info',
+ icon: <DeviceHubIcon fontSize="small" className="text-info" />
+ };
+ };
+
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">
- <AddNodeCard namespace={namespace} cluster={cluster}
shard={shard} />
- {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",
- }}
+ <div className="flex-1 overflow-auto">
+ <Box className="container-inner">
+ <Box className="flex items-center justify-between mb-6">
+ <div>
+ <Typography variant="h5" className="font-medium
text-gray-800 dark:text-gray-100 flex items-center">
+ <DnsIcon className="mr-2 text-primary
dark:text-primary-light" />
+ Shard {parseInt(shard) + 1}
+ {nodesData?.nodes && (
+ <Chip
+ label={`${nodesData.nodes.length}
nodes`}
+ size="small"
+ color="secondary"
+ className="ml-3"
+ />
+ )}
+ </Typography>
+ <Typography variant="body2"
className="text-gray-500 dark:text-gray-400 mt-1">
+ {cluster} cluster in namespace {namespace}
+ </Typography>
+ </div>
+ </Box>
+
+ <div className="grid grid-cols-1 sm:grid-cols-2
lg:grid-cols-3 xl:grid-cols-4 gap-4">
+ <Box className="col-span-1">
+ <AddNodeCard namespace={namespace}
cluster={cluster} shard={shard} />
+ </Box>
+
+ {nodesData?.nodes && nodesData.nodes.length > 0 ? (
+ nodesData.nodes.map((node: any, index: number) => {
+ const roleInfo = getRoleInfo(node.role);
+ return (
+ <Link
+
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes/${index}`}
+ key={index}
+ className="col-span-1"
+ >
+ <ResourceCard
+ title={`Node ${index + 1}`}
+ tags={[
+ { label: node.role, color:
roleInfo.color as any },
+ ]}
>
- 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 className="space-y-2 text-sm
mt-2">
+ <div className="flex
justify-between items-center">
+ <span
className="text-gray-500 dark:text-gray-400">ID:</span>
+ <span className="font-mono
bg-gray-100 dark:bg-dark-border px-2 py-0.5 rounded text-xs overflow-hidden
text-ellipsis max-w-[120px]" title={node.id}>
+ {truncateText(node.id,
10)}
+ </span>
+ </div>
+
+ <div className="flex
justify-between">
+ <span
className="text-gray-500 dark:text-gray-400">Address:</span>
+ <span
className="font-medium">{node.addr}</span>
+ </div>
+
+ <div className="flex
justify-between items-center">
+ <span
className="text-gray-500 dark:text-gray-400">Uptime:</span>
+ <span className="flex
items-center">
+ <AlarmIcon
fontSize="small" className="mr-1 text-gray-400 dark:text-gray-500" />
+
{calculateUptime(node.created_at)}
+ </span>
+ </div>
+ </div>
+ </ResourceCard>
+ </Link>
+ );
+ })
+ ) : (
+ <Box className="col-span-full">
+ <EmptyState
+ title="No nodes found"
+ description="Create a node to get started"
+ icon={<DeviceHubIcon sx={{ fontSize: 60 }}
/>}
+ />
+ </Box>
+ )}
+ </div>
+ </Box>
+ </div>
</div>
);
}
diff --git a/webui/src/app/namespaces/[namespace]/page.tsx
b/webui/src/app/namespaces/[namespace]/page.tsx
index 6f06b9f..71bbc77 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 (
<div className="flex h-full">
<NamespaceSidebar />
- <Container
- maxWidth={false}
- disableGutters
- sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
- >
- <div className="flex flex-row flex-wrap">
- <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 className="flex-1 overflow-auto">
+ <Box className="container-inner">
+ <Box className="flex items-center justify-between mb-6">
+ <div>
+ <Typography variant="h5" className="font-medium
text-gray-800 dark:text-gray-100 flex items-center">
+ <FolderIcon className="mr-2 text-primary
dark:text-primary-light" />
+ {params.namespace}
+ <Chip
+ label={`${clusterData.length} clusters`}
+ size="small"
+ color="primary"
+ className="ml-3"
+ />
+ </Typography>
+ <Typography variant="body2"
className="text-gray-500 dark:text-gray-400 mt-1">
+ Namespace
+ </Typography>
+ </div>
+ </Box>
+
+ <div className="grid grid-cols-1 sm:grid-cols-2
lg:grid-cols-3 xl:grid-cols-4 gap-4">
+ <Box className="col-span-1">
+ <AddClusterCard namespace={params.namespace} />
+ </Box>
+
+ {clusterData.length > 0 ? (
+ clusterData.map((data, index) => (
+ data && (
+ <Link
+
href={`/namespaces/${params.namespace}/clusters/${data.name}`}
+ key={index}
+ className="col-span-1"
+ >
+ <ResourceCard
+ title={data.name}
+ description={`Version:
${data.version}`}
+ tags={[
+ { label:
`${data.shards.length} shards`, color: "secondary" },
+ ...(data.shards.some((s: any)
=> s.migrating_slot >= 0)
+ ? [{ label: "Migrating",
color: "warning" }]
+ : [])
+ ]}
+ >
+ <div className="space-y-2 text-sm
my-2">
+ <div className="flex
justify-between">
+ <span
className="text-gray-500 dark:text-gray-400">Slots:</span>
+ <span
className="font-medium">
+
{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'}
+ </span>
+ </div>
+
+
{data.shards[0]?.target_shard_index >= 0 && (
+ <div className="flex
justify-between">
+ <span
className="text-gray-500 dark:text-gray-400">Target Shard:</span>
+ <span
className="font-medium">{data.shards[0].target_shard_index + 1}</span>
+ </div>
+ )}
+
+
{data.shards[0]?.migrating_slot >= 0 && (
+ <div className="flex
justify-between">
+ <span
className="text-gray-500 dark:text-gray-400">Migrating:</span>
+ <Chip
+ label={`Slot
${data.shards[0].migrating_slot}`}
+ size="small"
+ color="warning"
+ />
+ </div>
+ )}
+ </div>
+
+ <div className="mt-3 flex
justify-center">
+ <GridViewIcon sx={{ fontSize:
40 }} className="text-primary/20 dark:text-primary-light/30" />
+ </div>
+ </ResourceCard>
+ </Link>
+ )
+ ))
+ ) : (
+ <Box className="col-span-full">
+ <EmptyState
+ title="No clusters found"
+ description="Create a cluster to get
started"
+ icon={<StorageIcon sx={{ fontSize: 60 }}
/>}
+ />
+ </Box>
+ )}
+ </div>
+ </Box>
+ </div>
</div>
);
}
diff --git a/webui/src/app/namespaces/page.tsx
b/webui/src/app/namespaces/page.tsx
index ae0dfbf..2f5daf5 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<string[]>([]);
@@ -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 (
<div className="flex h-full">
<NamespaceSidebar />
- <Container
- maxWidth={false}
- disableGutters
- sx={{ height: "100%", overflowY: "auto", marginLeft: "16px" }}
- >
- <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>
- )
- )
+ <div className="flex-1 overflow-auto">
+ <Box className="container-inner">
+ <Box className="flex items-center justify-between mb-6">
+ <Typography variant="h5" className="font-medium
text-gray-800 dark:text-gray-100">
+ Namespaces
+ </Typography>
+ </Box>
+
+ {namespaces.length > 0 ? (
+ <div className="grid grid-cols-1 sm:grid-cols-2
lg:grid-cols-3 xl:grid-cols-4 gap-4">
+ {namespaces.map((namespace) => (
+ <Link key={namespace}
href={`/namespaces/${namespace}`} passHref>
+ <ResourceCard
+ title={namespace}
+ description="Namespace"
+ tags={[{ label: "namespace", color:
"primary" }]}
+ >
+ <div className="flex items-center
justify-center h-20 mt-4">
+ <FolderIcon
+ sx={{ fontSize: 60 }}
+ className="text-primary/20
dark:text-primary-light/30"
+ />
+ </div>
+ </ResourceCard>
+ </Link>
+ ))}
+ </div>
) : (
- <Box>
- <Typography variant="h6">
- No namespaces found, create one to get started
- </Typography>
- </Box>
+ <EmptyState
+ title="No namespaces found"
+ description="Create a namespace to get started"
+ icon={<FolderIcon sx={{ fontSize: 60 }} />}
+ />
)}
- </div>
- </Container>
+ </Box>
+ </div>
</div>
);
}
diff --git a/webui/src/app/not-found.tsx b/webui/src/app/not-found.tsx
new file mode 100644
index 0000000..25d957d
--- /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 (
+ <div className="flex items-center justify-center min-h-[calc(100vh-64px)]">
+ <Box className="text-center p-8 max-w-lg">
+ <ErrorOutlineIcon sx={{ fontSize: 80 }} className="text-error mb-4" />
+
+ <Typography variant="h3" className="mb-2 font-bold text-gray-900
dark:text-gray-100">
+ Page Not Found
+ </Typography>
+
+ <Typography variant="body1" className="mb-8 text-gray-600
dark:text-gray-300">
+ We couldn't find the page you're looking for. It might have been
moved, deleted, or never existed.
+ </Typography>
+
+ <div className="flex flex-wrap justify-center gap-4">
+ <Button
+ variant="contained"
+ color="primary"
+ startIcon={<HomeIcon />}
+ component={Link}
+ href="/"
+ >
+ Go to Home
+ </Button>
+
+ <Button
+ variant="outlined"
+ startIcon={<ArrowBackIcon />}
+ onClick={() => router.back()}
+ >
+ Go Back
+ </Button>
+ </div>
+ </Box>
+ </div>
+ );
+}
diff --git a/webui/src/app/page.tsx b/webui/src/app/page.tsx
index 4a40fe5..9c46b5e 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: <StorageIcon sx={{ fontSize: 40 }} className="text-primary
dark:text-primary-light" />
+ },
+ {
+ title: "Shard Distribution",
+ description: "Efficiently distribute data across multiple shards
for optimal performance",
+ icon: <DnsIcon sx={{ fontSize: 40 }} className="text-primary
dark:text-primary-light" />
+ },
+ {
+ title: "Node Monitoring",
+ description: "Monitor node health, performance, and connectivity
in real-time",
+ icon: <DeviceHubIcon sx={{ fontSize: 40 }} className="text-primary
dark:text-primary-light" />
+ },
+ {
+ title: "Advanced Metrics",
+ description: "View detailed performance metrics to optimize your
infrastructure",
+ icon: <BarChartIcon sx={{ fontSize: 40 }} className="text-primary
dark:text-primary-light" />
+ }
+ ];
+
+ const resources = [
+ {
+ title: "Documentation",
+ description: "Learn how to use Kvrocks Controller",
+ icon: <MenuBookIcon sx={{ fontSize: 30 }} />,
+ url: "https://kvrocks.apache.org/docs/"
+ },
+ {
+ title: "GitHub Repository",
+ description: "View the source code on GitHub",
+ icon: <GitHubIcon sx={{ fontSize: 30 }} />,
+ url: "https://github.com/apache/kvrocks-controller"
+ }
+ ];
+
return (
- <div
- style={{minHeight: 'calc(100vh - 64px)', height: 'calc(100vh -
64px)'}}
- className={'flex flex-col items-center justify-center space-y-2
h-full'}
- >
- <Typography variant="h3">Kvrocks Controller UI</Typography>
- <Typography variant="body1">Work in progress...</Typography>
- <Button size="large" variant="outlined" sx={{ textTransform:
'none' }} href="https://github.com/apache/kvrocks-controller/issues/135">
- Click here to submit your suggestions
- </Button>
+ <div className="flex flex-col min-h-[calc(100vh-64px)]
bg-gradient-to-b from-white to-gray-50 dark:from-dark dark:to-dark-paper">
+ {/* Hero Section */}
+ <section className="flex-grow flex flex-col items-center
justify-center px-6 py-12 text-center">
+ <div className="max-w-3xl mx-auto">
+ <div className="mb-8 mx-auto relative w-40 h-40">
+ <Image
+ src="/logo.svg"
+ alt="Kvrocks Logo"
+ layout="fill"
+ objectFit="contain"
+ priority
+ className="animate-[pulse_4s_ease-in-out_infinite]"
+ />
+ </div>
+
+ <Typography variant="h2" component="h1"
className="font-bold mb-4 text-gray-900 dark:text-gray-100">
+ Apache Kvrocks <span className="text-primary
dark:text-primary-light">Controller</span>
+ </Typography>
+
+ <Typography variant="h6" className="mb-8 text-gray-600
dark:text-gray-300 max-w-2xl mx-auto">
+ A web management interface for Apache Kvrocks
clusters, enabling efficient distribution, monitoring, and maintenance of your
Redis compatible database infrastructure.
+ </Typography>
+
+ <div className="flex flex-wrap justify-center gap-4">
+ <Button
+ variant="contained"
+ size="large"
+ className="bg-primary hover:bg-primary-dark px-8
py-3 text-lg"
+ onClick={() => router.push('/namespaces')}
+ >
+ Get Started
+ </Button>
+
+ <Button
+ variant="outlined"
+ size="large"
+ className="border-primary text-primary
hover:bg-primary hover:text-white dark:border-primary-light
dark:text-primary-light px-8 py-3 text-lg"
+
href="https://github.com/apache/kvrocks-controller/issues"
+ target="_blank"
+ >
+ Submit Feedback
+ </Button>
+ </div>
+ </div>
+ </section>
+
+ {/* Features Section */}
+ <section className="py-16 px-6 bg-gray-50 dark:bg-dark-paper">
+ <div className="max-w-6xl mx-auto">
+ <Typography variant="h4" component="h2"
className="text-center font-bold mb-12 text-gray-900 dark:text-gray-100">
+ Key Features
+ </Typography>
+
+ <Grid container spacing={4}>
+ {features.map((feature, index) => (
+ <Grid item xs={12} sm={6} md={3} key={index}>
+ <Paper
+ elevation={0}
+ className="card h-full p-6 flex flex-col
items-center text-center"
+ >
+ <div className="mb-4">
+ {feature.icon}
+ </div>
+ <Typography variant="h6" className="mb-2
font-medium">
+ {feature.title}
+ </Typography>
+ <Typography variant="body2"
className="text-gray-600 dark:text-gray-300">
+ {feature.description}
+ </Typography>
+ </Paper>
+ </Grid>
+ ))}
+ </Grid>
+ </div>
+ </section>
+
+ {/* Resources Section */}
+ <section className="py-16 px-6">
+ <div className="max-w-4xl mx-auto">
+ <Typography variant="h4" component="h2"
className="text-center font-bold mb-12 text-gray-900 dark:text-gray-100">
+ Resources
+ </Typography>
+
+ <Grid container spacing={4} justifyContent="center">
+ {resources.map((resource, index) => (
+ <Grid item xs={12} sm={6} key={index}>
+ <Link href={resource.url} target="_blank"
rel="noopener noreferrer">
+ <Paper
+ elevation={0}
+ className="card h-full p-6 flex
flex-col hover:border-primary dark:hover:border-primary-light transition-all"
+ >
+ <div className="flex items-center
mb-4">
+ <div className="p-2 rounded-full
bg-primary/10 dark:bg-primary-dark/20 mr-4">
+ {resource.icon}
+ </div>
+ <div>
+ <Typography variant="h6"
className="flex items-center">
+ {resource.title}
+ <LaunchIcon
fontSize="small" className="ml-2 text-gray-400" />
+ </Typography>
+ <Typography variant="body2"
className="text-gray-600 dark:text-gray-300">
+ {resource.description}
+ </Typography>
+ </div>
+ </div>
+ </Paper>
+ </Link>
+ </Grid>
+ ))}
+ </Grid>
+ </div>
+ </section>
+
+ {/* Footer */}
+ <footer className="py-6 px-6 border-t border-light-border
dark:border-dark-border">
+ <div className="max-w-6xl mx-auto text-center">
+ <Typography variant="body2" className="text-gray-500
dark:text-gray-400">
+ 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.
+ </Typography>
+ </div>
+ </footer>
</div>
);
-}
+}
\ 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 0000000..1b7e18f
--- /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<ThemeContextType>({
+ 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 (
+ <ThemeContext.Provider value={{ isDarkMode, toggleTheme }}>
+ <MuiThemeProvider theme={theme}>
+ <CssBaseline />
+ {children}
+ </MuiThemeProvider>
+ </ThemeContext.Provider>
+ );
+}
diff --git a/webui/src/app/ui/banner.tsx b/webui/src/app/ui/banner.tsx
index b9b4d1c..69850e4 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 (<AppBar>
- <Container maxWidth={false}>
- <Toolbar className="space-x-4">
- <Image src="/logo.svg" width={40} height={40}
alt='logo'></Image>
- <NavLinks links={links}/>
- </Toolbar>
- </Container>
- </AppBar>)
+ const { isDarkMode, toggleTheme } = useTheme();
+ const pathname = usePathname();
+
+ // Generate breadcrumb from pathname
+ const breadcrumbs = pathname.split('/').filter(Boolean);
+
+ return (
+ <AppBar position="fixed" elevation={1} className="bg-white
dark:bg-dark-paper text-gray-800 dark:text-gray-100">
+ <Container maxWidth={false}>
+ <Toolbar className="flex justify-between">
+ <div className="flex items-center">
+ <Image src="/logo.svg" width={40} height={40}
alt='logo' className="mr-4" />
+ <Typography variant="h6" component="div"
className="hidden sm:block font-medium text-primary dark:text-primary-light">
+ Apache Kvrocks Controller
+ </Typography>
+ </div>
+
+ <Box className="hidden md:flex items-center space-x-1">
+ <NavLinks links={links} />
+ </Box>
+
+ <Box className="flex items-center">
+ {breadcrumbs.length > 0 && (
+ <Box className="hidden md:flex items-center
text-sm px-4 py-1 bg-gray-100 dark:bg-dark-border rounded-md mr-4">
+ {breadcrumbs.map((breadcrumb, i) => (
+ <Typography
+ key={i}
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ {i > 0 && " / "}
+ {breadcrumb}
+ </Typography>
+ ))}
+ </Box>
+ )}
+
+ <Tooltip title="Toggle dark mode">
+ <IconButton onClick={toggleTheme} color="inherit"
size="small">
+ {isDarkMode ? <Brightness7Icon /> :
<Brightness4Icon />}
+ </IconButton>
+ </Tooltip>
+
+ <Tooltip title="GitHub Repository">
+ <IconButton
+ color="inherit"
+
href="https://github.com/apache/kvrocks-controller"
+ target="_blank"
+ size="small"
+ className="ml-2"
+ >
+ <GitHubIcon />
+ </IconButton>
+ </Tooltip>
+ </Box>
+ </Toolbar>
+ </Container>
+ </AppBar>
+ );
}
\ No newline at end of file
diff --git a/webui/src/app/ui/createCard.tsx b/webui/src/app/ui/createCard.tsx
index d09f498..d3e6b32 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<CreateCardProps> = ({ children }) => {
+export const CreateCard: React.FC<CreateCardProps> = ({ children, className =
"" }) => {
return (
- <Box sx={{ position: "relative", display: "inline-block" }}>
- <Card
- variant="outlined"
- sx={{
- width: "370px",
- 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",
- }}
+ <Box className="p-3">
+ <Paper
+ elevation={0}
+ className={`card w-72 h-52 transition-all ${className}`}
>
{children}
- </Card>
+ </Paper>
</Box>
);
};
export const AddClusterCard = ({ namespace }: { namespace: 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">
- <ClusterCreation position="card" namespace={namespace} />
- </div>
- <div className="ml-.5">
- <ImportCluster position="card" namespace={namespace} />
+ <CreateCard className="bg-gradient-to-br from-primary-light/5
to-primary/10 dark:from-primary-dark/10 dark:to-primary/20 flex items-center
justify-center">
+ <div className="text-center">
+ <FontAwesomeIcon
+ icon={faCirclePlus}
+ size="4x"
+ className="text-primary/40 dark:text-primary-light/40 mb-4"
+ />
+ <div className="flex flex-row items-center justify-center
space-x-2 mt-2">
+ <div className="text-sm leading-tight">
+ <ClusterCreation position="card" namespace={namespace}
/>
+ </div>
+ <div className="text-sm leading-tight">
+ <ImportCluster position="card" namespace={namespace} />
+ </div>
</div>
</div>
</CreateCard>
@@ -97,25 +79,19 @@ export const AddShardCard = ({
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">
+ <CreateCard className="bg-gradient-to-br from-primary-light/5
to-primary/10 dark:from-primary-dark/10 dark:to-primary/20 flex items-center
justify-center">
+ <div className="text-center">
+ <FontAwesomeIcon
+ icon={faCirclePlus}
+ size="4x"
+ className="text-primary/40 dark:text-primary-light/40 mb-6"
+ />
+ <div className="flex flex-row items-center justify-center
space-x-3 mt-4">
<ShardCreation
position="card"
namespace={namespace}
cluster={cluster}
/>
- </div>
- <div className="ml-.5">
<MigrateSlot
position="card"
namespace={namespace}
@@ -137,23 +113,62 @@ export const AddNodeCard = ({
shard: string;
}) => {
return (
- <CreateCard>
- <FontAwesomeIcon
- icon={faCirclePlus}
- size="4x"
- style={{
- color: "#e0e0e0",
- marginBottom: "8px",
- transition: "color 0.2s",
- }}
- />
- <div className="mt-4">
- <NodeCreation
- position="card"
- namespace={namespace}
- cluster={cluster}
- shard={shard}
+ <CreateCard className="bg-gradient-to-br from-primary-light/5
to-primary/10 dark:from-primary-dark/10 dark:to-primary/20 flex items-center
justify-center">
+ <div className="text-center">
+ <FontAwesomeIcon
+ icon={faCirclePlus}
+ size="4x"
+ className="text-primary/40 dark:text-primary-light/40 mb-6"
/>
+ <div className="mt-4">
+ <NodeCreation
+ position="card"
+ namespace={namespace}
+ cluster={cluster}
+ shard={shard}
+ />
+ </div>
+ </div>
+ </CreateCard>
+ );
+};
+
+export const ResourceCard = ({
+ title,
+ description,
+ tags,
+ children
+}: {
+ title: string;
+ description?: string;
+ tags?: Array<{label: string, color?: string}>;
+ children: ReactNode;
+}) => {
+ return (
+ <CreateCard>
+ <div className="flex flex-col h-full">
+ <div className="font-medium text-lg mb-1">{title}</div>
+ {description && (
+ <div className="text-sm text-gray-500 dark:text-gray-400
mb-3">
+ {description}
+ </div>
+ )}
+ <div className="flex-grow">
+ {children}
+ </div>
+ {tags && tags.length > 0 && (
+ <div className="flex flex-wrap gap-1 mt-3">
+ {tags.map((tag, i) => (
+ <Chip
+ key={i}
+ label={tag.label}
+ size="small"
+ color={tag.color as any || "default"}
+ className="text-xs"
+ />
+ ))}
+ </div>
+ )}
</div>
</CreateCard>
);
diff --git a/webui/src/app/ui/emptyState.tsx b/webui/src/app/ui/emptyState.tsx
new file mode 100644
index 0000000..a329c87
--- /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<EmptyStateProps> = ({ title, description, icon,
action }) => {
+ return (
+ <Paper
+ elevation={0}
+ className="border border-light-border dark:border-dark-border
rounded-lg bg-white dark:bg-dark-paper p-10 text-center max-w-md mx-auto"
+ >
+ {icon && (
+ <Box className="flex justify-center mb-4 text-gray-400
dark:text-gray-500">
+ {icon}
+ </Box>
+ )}
+ <Typography variant="h6" className="font-medium mb-2 text-gray-800
dark:text-gray-100">
+ {title}
+ </Typography>
+ <Typography variant="body2" className="text-gray-500
dark:text-gray-400 mb-6">
+ {description}
+ </Typography>
+ {action && (
+ <Button
+ variant="contained"
+ className="btn btn-primary"
+ onClick={action.onClick}
+ >
+ {action.label}
+ </Button>
+ )}
+ </Paper>
+ );
+};
+
+export default EmptyState;
diff --git a/webui/src/app/ui/formDialog.tsx b/webui/src/app/ui/formDialog.tsx
index 2e26df0..157ae86 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<string | undefined>;
- }
+}
const FormDialog: React.FC<FormDialogProps> = ({
position,
@@ -63,6 +67,7 @@ const FormDialog: React.FC<FormDialogProps> = ({
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<FormDialogProps> = ({
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
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" ? (
- <Button variant="contained" onClick={openDialog}>
+ <Button
+ variant="contained"
+ onClick={openDialog}
+ className="btn btn-primary py-1 px-3 text-xs"
+ startIcon={<AddIcon sx={{ fontSize: 16 }} />}
+ size="small"
+ >
{title}
</Button>
) : (
- <Button variant="outlined" onClick={openDialog}>
+ <Button
+ variant="outlined"
+ onClick={openDialog}
+ className="btn btn-outline w-full"
+ startIcon={<AddIcon />}
+ >
{title}
</Button>
)}
- <Dialog open={showDialog} onClose={closeDialog}>
+ <Dialog
+ open={showDialog}
+ onClose={closeDialog}
+ PaperProps={{
+ className: "rounded-lg shadow-xl"
+ }}
+ maxWidth="sm"
+ fullWidth
+ >
<form onSubmit={handleSubmit}>
- <DialogTitle>{title}</DialogTitle>
- <DialogContent sx={{ width: "500px" }}>
+ <DialogTitle className="bg-gray-50 dark:bg-dark-paper
border-b border-light-border dark:border-dark-border px-6 py-4">
+ <Typography variant="h6" className="font-medium">
+ {title}
+ </Typography>
+ </DialogTitle>
+ <DialogContent className="p-6">
{formFields.map((field, index) =>
field.type === "array" ? (
- <Box key={index} mb={2}>
- <Typography variant="subtitle1"
className="mt-2 mb-2">
+ <Box key={index} mb={3} mt={index === 0 ? 3 :
2}>
+ <Typography variant="subtitle2"
className="mb-2 font-medium">
{field.label}
</Typography>
<Autocomplete
@@ -120,6 +155,8 @@ const FormDialog: React.FC<FormDialogProps> = ({
{...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<FormDialogProps> = ({
variant="outlined"
label={`Add ${field.label}*`}
placeholder="Type and press
enter"
+ size="small"
+ className="bg-white
dark:bg-dark-paper rounded-md"
/>
)}
/>
</Box>
) : field.type === "enum" ? (
- <FormControl key={index} fullWidth sx={{ mt:3
}}>
- <InputLabel>{field.label}</InputLabel>
+ <FormControl key={index} fullWidth sx={{ mt:
index === 0 ? 3 : 3, mb: 2 }}>
+ <InputLabel
id={`${field.name}-label`}>{field.label}</InputLabel>
<Select
+ labelId={`${field.name}-label`}
name={field.name}
label={field.label}
required={field.required}
defaultValue=""
- multiple={false}
+ size="small"
+ className="bg-white dark:bg-dark-paper
rounded-md"
>
{field.values?.map((value, index) => (
<MenuItem key={index}
value={value}>
@@ -159,16 +200,35 @@ const FormDialog: React.FC<FormDialogProps> = ({
label={field.label}
type={field.type}
fullWidth
- variant="standard"
+ variant="outlined"
margin="normal"
- sx={{ mb: 2 }}
+ size="small"
+ className="bg-white dark:bg-dark-paper
rounded-md"
+ sx={{
+ mt: index === 0 ? 3 : 3,
+ mb: 1.5
+ }}
/>
)
)}
</DialogContent>
- <DialogActions>
- <Button onClick={closeDialog}>Cancel</Button>
- <Button type="submit">{submitButtonLabel}</Button>
+ <DialogActions className="p-4 border-t border-light-border
dark:border-dark-border bg-gray-50 dark:bg-dark-paper">
+ <Button
+ onClick={closeDialog}
+ disabled={submitting}
+ className="text-gray-600 dark:text-gray-300
hover:bg-gray-100 dark:hover:bg-dark-border"
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ variant="contained"
+ disabled={submitting}
+ className="btn-primary"
+ startIcon={submitting ? <CircularProgress
size={16} color="inherit" /> : null}
+ >
+ {submitting ? 'Processing...' : submitButtonLabel}
+ </Button>
</DialogActions>
</form>
</Dialog>
@@ -177,12 +237,13 @@ const FormDialog: React.FC<FormDialogProps> = ({
autoHideDuration={5000}
onClose={() => setErrorMessage("")}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
+ className="mb-4"
>
<Alert
onClose={() => setErrorMessage("")}
severity="error"
variant="filled"
- sx={{ width: "100%" }}
+ className="shadow-lg"
>
{errorMessage}
</Alert>
@@ -192,4 +253,3 @@ const FormDialog: React.FC<FormDialogProps> = ({
};
export default FormDialog;
-
\ No newline at end of file
diff --git a/webui/src/app/ui/loadingSpinner.tsx
b/webui/src/app/ui/loadingSpinner.tsx
index 51f7e11..3b314c9 100644
--- a/webui/src/app/ui/loadingSpinner.tsx
+++ b/webui/src/app/ui/loadingSpinner.tsx
@@ -20,21 +20,49 @@
"use client";
import React from 'react';
-import { Box, CircularProgress } from '@mui/material';
+import { Box, CircularProgress, Typography, Fade } from '@mui/material';
+
+interface LoadingSpinnerProps {
+ message?: string;
+ size?: 'small' | 'medium' | 'large';
+ fullScreen?: boolean;
+}
+
+export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
+ message = 'Loading...',
+ size = 'medium',
+ fullScreen = false
+}) => {
+ const spinnerSize = {
+ small: 24,
+ medium: 40,
+ large: 60
+ }[size];
-export const LoadingSpinner: React.FC = () => {
return (
- <Box
- sx={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- height: '100%',
- width: '100%',
- minHeight: '200px',
- }}
- >
- <CircularProgress />
- </Box>
+ <Fade in={true} timeout={300}>
+ <Box
+ sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: fullScreen ? '100vh' : '100%',
+ width: '100%',
+ minHeight: fullScreen ? '100vh' : '300px',
+ }}
+ className="text-primary dark:text-primary-light"
+ >
+ <CircularProgress size={spinnerSize} thickness={4}
className="text-primary dark:text-primary-light" />
+ {message && (
+ <Typography
+ variant="body2"
+ className="mt-4 text-gray-600 dark:text-gray-300
animate-pulse"
+ >
+ {message}
+ </Typography>
+ )}
+ </Box>
+ </Fade>
);
};
\ No newline at end of file
diff --git a/webui/src/app/ui/nav-links.tsx b/webui/src/app/ui/nav-links.tsx
index 66312d6..ad4a075 100644
--- a/webui/src/app/ui/nav-links.tsx
+++ b/webui/src/app/ui/nav-links.tsx
@@ -17,8 +17,11 @@
* under the License.
*/
-import { Button } from "@mui/material"
-import Link from "next/link"
+"use client";
+
+import { Button } from "@mui/material";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
export default function NavLinks({links}: {
links: Array<{
@@ -27,15 +30,32 @@ export default function NavLinks({links}: {
_blank?: boolean,
}>
}) {
+ const pathname = usePathname();
+
return <>
- {links.map(link => <Link
- key={link.url}
- href={link.url}
- {...(link._blank ? {target: '_blank'} : {})}
- >
- <Button color="inherit">
- {link.title}
- </Button>
- </Link>)}
- </>
+ {links.map(link => {
+ const isActive = pathname === link.url ||
+ (link.url !== '/' && pathname.startsWith(link.url));
+
+ return (
+ <Link
+ key={link.url}
+ href={link.url}
+ passHref
+ {...(link._blank ? {target: '_blank', rel: "noopener
noreferrer"} : {})}
+ >
+ <Button
+ color="inherit"
+ className={`px-3 py-1 mx-1 rounded-md
transition-colors ${
+ isActive
+ ? 'bg-primary-light/10 text-primary
dark:text-primary-light'
+ : 'hover:bg-gray-100 dark:hover:bg-dark-border'
+ }`}
+ >
+ {link.title}
+ </Button>
+ </Link>
+ );
+ })}
+ </>;
}
\ No newline at end of file
diff --git a/webui/src/app/ui/sidebar.tsx b/webui/src/app/ui/sidebar.tsx
index 63bbd38..9ff1b65 100644
--- a/webui/src/app/ui/sidebar.tsx
+++ b/webui/src/app/ui/sidebar.tsx
@@ -19,7 +19,7 @@
"use client";
-import { Divider, List, Typography } from "@mui/material";
+import { Divider, List, Typography, Paper, Box, Collapse } from
"@mui/material";
import {
fetchClusters,
fetchNamespaces,
@@ -35,10 +35,50 @@ import {
} from "./formCreation";
import Link from "next/link";
import { useState, useEffect } from "react";
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import FolderIcon from '@mui/icons-material/Folder';
+import StorageIcon from '@mui/icons-material/Storage';
+import DnsIcon from '@mui/icons-material/Dns';
+import DeviceHubIcon from '@mui/icons-material/DeviceHub';
+
+// Sidebar section header component
+const SidebarHeader = ({
+ title,
+ count,
+ isOpen,
+ toggleOpen,
+ icon
+}: {
+ title: string;
+ count: number;
+ isOpen: boolean;
+ toggleOpen: () => void;
+ icon: React.ReactNode;
+}) => (
+ <div
+ className="flex items-center justify-between px-4 py-3 bg-gray-50
dark:bg-dark-paper rounded-md mb-2 cursor-pointer hover:bg-gray-100
dark:hover:bg-dark-border transition-colors"
+ onClick={toggleOpen}
+ >
+ <div className="flex items-center space-x-2">
+ {icon}
+ <Typography variant="subtitle1" className="font-medium">
+ {title}
+ </Typography>
+ {count > 0 && (
+ <span className="bg-primary text-white dark:bg-primary-dark
px-2 py-0.5 rounded-full text-xs">
+ {count}
+ </span>
+ )}
+ </div>
+ {isOpen ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon
fontSize="small" />}
+ </div>
+);
export function NamespaceSidebar() {
const [namespaces, setNamespaces] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
+ const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
const fetchData = async () => {
@@ -53,34 +93,45 @@ export function NamespaceSidebar() {
}, []);
return (
- <div className="w-60 h-full flex">
- <List className="w-full overflow-y-auto">
- <div className="mt-2 mb-4 text-center">
- <NamespaceCreation position="sidebar" />
- </div>
- {error && (
- <Typography color="error" align="center">
- {error}
- </Typography>
- )}
- {namespaces.map((namespace) => (
- <div key={namespace}>
- <Divider />
- <Link href={`/namespaces/${namespace}`} passHref>
+ <Paper
+ className="w-64 h-full flex flex-col overflow-hidden
shadow-sidebar border-r border-light-border dark:border-dark-border"
+ elevation={0}
+ square
+ >
+ <Box className="p-4">
+ <NamespaceCreation position="sidebar" />
+ </Box>
+
+ <SidebarHeader
+ title="Namespaces"
+ count={namespaces.length}
+ isOpen={isOpen}
+ toggleOpen={() => setIsOpen(!isOpen)}
+ icon={<FolderIcon className="text-primary
dark:text-primary-light" />}
+ />
+
+ <Collapse in={isOpen}>
+ <List className="overflow-y-auto max-h-[calc(100vh-180px)]
px-2">
+ {error && (
+ <Typography color="error" align="center"
className="text-sm py-2">
+ {error}
+ </Typography>
+ )}
+ {namespaces.map((namespace) => (
+ <Link href={`/namespaces/${namespace}`} passHref
key={namespace}>
<Item type="namespace" item={namespace} />
</Link>
- </div>
- ))}
- <Divider />
- </List>
- <Divider orientation="vertical" flexItem />
- </div>
+ ))}
+ </List>
+ </Collapse>
+ </Paper>
);
}
export function ClusterSidebar({ namespace }: { namespace: string }) {
const [clusters, setClusters] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
+ const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
const fetchData = async () => {
@@ -95,31 +146,38 @@ export function ClusterSidebar({ namespace }: { namespace:
string }) {
}, [namespace]);
return (
- <div className="w-60 h-full flex">
- <List className="w-full overflow-y-auto">
- <div className="mt-2 mb-4 text-center">
- <ClusterCreation namespace={namespace} position="sidebar"
/>
- </div>
- {error && (
- <Typography color="error" align="center">
- {error}
- </Typography>
- )}
- {clusters.map((cluster) => (
- <div key={cluster}>
- <Divider />
- <Link
-
href={`/namespaces/${namespace}/clusters/${cluster}`}
- passHref
- >
+ <Paper
+ className="w-64 h-full flex flex-col overflow-hidden
shadow-sidebar border-r border-light-border dark:border-dark-border"
+ elevation={0}
+ square
+ >
+ <Box className="p-4">
+ <ClusterCreation namespace={namespace} position="sidebar" />
+ </Box>
+
+ <SidebarHeader
+ title="Clusters"
+ count={clusters.length}
+ isOpen={isOpen}
+ toggleOpen={() => setIsOpen(!isOpen)}
+ icon={<StorageIcon className="text-primary
dark:text-primary-light" />}
+ />
+
+ <Collapse in={isOpen}>
+ <List className="overflow-y-auto max-h-[calc(100vh-180px)]
px-2">
+ {error && (
+ <Typography color="error" align="center"
className="text-sm py-2">
+ {error}
+ </Typography>
+ )}
+ {clusters.map((cluster) => (
+ <Link
href={`/namespaces/${namespace}/clusters/${cluster}`} passHref key={cluster}>
<Item type="cluster" item={cluster}
namespace={namespace} />
</Link>
- </div>
- ))}
- <Divider />
- </List>
- <Divider orientation="vertical" flexItem />
- </div>
+ ))}
+ </List>
+ </Collapse>
+ </Paper>
);
}
@@ -132,6 +190,7 @@ export function ShardSidebar({
}) {
const [shards, setShards] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
+ const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
const fetchData = async () => {
@@ -149,27 +208,36 @@ export function ShardSidebar({
}, [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
- >
+ <Paper
+ className="w-64 h-full flex flex-col overflow-hidden
shadow-sidebar border-r border-light-border dark:border-dark-border"
+ elevation={0}
+ square
+ >
+ <Box className="p-4">
+ <ShardCreation
+ namespace={namespace}
+ cluster={cluster}
+ position="sidebar"
+ />
+ </Box>
+
+ <SidebarHeader
+ title="Shards"
+ count={shards.length}
+ isOpen={isOpen}
+ toggleOpen={() => setIsOpen(!isOpen)}
+ icon={<DnsIcon className="text-primary
dark:text-primary-light" />}
+ />
+
+ <Collapse in={isOpen}>
+ <List className="overflow-y-auto max-h-[calc(100vh-180px)]
px-2">
+ {error && (
+ <Typography color="error" align="center"
className="text-sm py-2">
+ {error}
+ </Typography>
+ )}
+ {shards.map((shard, index) => (
+ <Link
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${index}`} passHref
key={index}>
<Item
type="shard"
item={shard}
@@ -177,14 +245,13 @@ export function ShardSidebar({
cluster={cluster}
/>
</Link>
- </div>
- ))}
- <Divider />
- </List>
- <Divider orientation="vertical" flexItem />
- </div>
+ ))}
+ </List>
+ </Collapse>
+ </Paper>
);
}
+
interface NodeItem {
addr: string;
created_at: number;
@@ -204,6 +271,7 @@ export function NodeSidebar({
}) {
const [nodes, setNodes] = useState<NodeItem[]>([]);
const [error, setError] = useState<string | null>(null);
+ const [isOpen, setIsOpen] = useState(true);
useEffect(() => {
const fetchData = async () => {
@@ -219,46 +287,56 @@ export function NodeSidebar({
}
};
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 />
+ <Paper
+ className="w-64 h-full flex flex-col overflow-hidden
shadow-sidebar border-r border-light-border dark:border-dark-border"
+ elevation={0}
+ square
+ >
+ <Box className="p-4">
+ <NodeCreation
+ namespace={namespace}
+ cluster={cluster}
+ shard={shard}
+ position="sidebar"
+ />
+ </Box>
+
+ <SidebarHeader
+ title="Nodes"
+ count={nodes.length}
+ isOpen={isOpen}
+ toggleOpen={() => setIsOpen(!isOpen)}
+ icon={<DeviceHubIcon className="text-primary
dark:text-primary-light" />}
+ />
+
+ <Collapse in={isOpen}>
+ <List className="overflow-y-auto max-h-[calc(100vh-180px)]
px-2">
+ {error && (
+ <Typography color="error" align="center"
className="text-sm py-2">
+ {error}
+ </Typography>
+ )}
+ {nodes.map((node, index) => (
<Link
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes/${index}`}
passHref
+ key={index}
>
<Item
type="node"
- item={"Node\t" + (index + 1).toString()}
+ item={`Node\t${index + 1}`}
id={node.id}
namespace={namespace}
cluster={cluster}
shard={shard}
/>
</Link>
- </div>
- ))}
- <Divider />
- </List>
- <Divider orientation="vertical" flexItem />
- </div>
+ ))}
+ </List>
+ </Collapse>
+ </Paper>
);
}
diff --git a/webui/src/app/ui/sidebarItem.tsx b/webui/src/app/ui/sidebarItem.tsx
index 2342d8a..32b2d79 100644
--- a/webui/src/app/ui/sidebarItem.tsx
+++ b/webui/src/app/ui/sidebarItem.tsx
@@ -29,13 +29,15 @@ import {
IconButton,
ListItem,
ListItemButton,
+ ListItemIcon,
ListItemText,
Menu,
MenuItem,
Snackbar,
Tooltip,
+ Badge,
} from "@mui/material";
-import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
+import MoreVertIcon from "@mui/icons-material/MoreVert";
import { useCallback, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
@@ -45,8 +47,12 @@ import {
deleteNode,
deleteShard,
} from "../lib/api";
-import { faTrash } from "@fortawesome/free-solid-svg-icons";
+import { faTrashCan } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import FolderIcon from '@mui/icons-material/Folder';
+import StorageIcon from '@mui/icons-material/Storage';
+import DnsIcon from '@mui/icons-material/Dns';
+import DeviceHubIcon from '@mui/icons-material/DeviceHub';
interface NamespaceItemProps {
item: string;
@@ -85,7 +91,7 @@ export default function Item(props: ItemProps) {
const { item, type } = props;
const [hover, setHover] = useState<boolean>(false);
const [showMenu, setShowMenu] = useState<boolean>(false);
- const listItemTextRef = useRef(null);
+ const listItemRef = useRef(null);
const openMenu = useCallback(() => setShowMenu(true), []);
const closeMenu = useCallback(
() => (setShowMenu(false), setHover(false)),
@@ -105,6 +111,22 @@ export default function Item(props: ItemProps) {
const router = useRouter();
let activeItem = usePathname().split("/").pop() || "";
+
+ const getItemIcon = () => {
+ switch (type) {
+ case "namespace":
+ return <FolderIcon fontSize="small" className="text-primary
dark:text-primary-light" />;
+ case "cluster":
+ return <StorageIcon fontSize="small" className="text-primary
dark:text-primary-light" />;
+ case "shard":
+ return <DnsIcon fontSize="small" className="text-primary
dark:text-primary-light" />;
+ case "node":
+ return <DeviceHubIcon fontSize="small" className="text-primary
dark:text-primary-light" />;
+ default:
+ return null;
+ }
+ };
+
const confirmDelete = useCallback(async () => {
let response = "";
if (type === "namespace") {
@@ -150,76 +172,111 @@ export default function Item(props: ItemProps) {
if (type === "shard") {
activeItem = "Shard\t" + (parseInt(activeItem) + 1);
- }else if (type === "node") {
+ } else if (type === "node") {
activeItem = "Node\t" + (parseInt(activeItem) + 1);
}
const isActive = item === activeItem;
+
+ const displayName = item.includes("\t") ? item.split("\t")[0] + " " +
item.split("\t")[1] : item;
+
return (
<ListItem
disablePadding
- secondaryAction={
- hover && (
- <IconButton onClick={openMenu} ref={listItemTextRef}>
- <MoreHorizIcon />
- </IconButton>
- )
- }
+ className="mb-1"
+ ref={listItemRef}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => !showMenu && setHover(false)}
- sx={{
- backgroundColor: isActive ? "rgba(0, 0, 0, 0.1)" :
"transparent",
- "&:hover": {
- backgroundColor: "rgba(0, 0, 0, 0.05)",
- },
- }}
>
- <ListItemButton sx={{ paddingRight: "10px" }}>
- <Tooltip title={item} arrow>
- <ListItemText
- classes={{ primary: "overflow-hidden text-ellipsis
text-nowrap" }}
- primary={`${item}`}
- />
- </Tooltip>
+ <ListItemButton
+ className={`rounded-md group transition-colors ${
+ isActive
+ ? 'bg-primary-light/10 text-primary
dark:text-primary-light'
+ : 'hover:bg-gray-100 dark:hover:bg-dark-border'
+ }`}
+ dense
+ >
+ <ListItemIcon sx={{ minWidth: 36 }}>
+ {getItemIcon()}
+ </ListItemIcon>
+ <ListItemText
+ primary={displayName}
+ className="overflow-hidden text-ellipsis"
+ primaryTypographyProps={{
+ className: "text-sm font-medium",
+ noWrap: true
+ }}
+ />
+ {hover && (
+ <IconButton
+ size="small"
+ edge="end"
+ onClick={openMenu}
+ className="opacity-0 group-hover:opacity-100
transition-opacity"
+ >
+ <MoreVertIcon fontSize="small" />
+ </IconButton>
+ )}
</ListItemButton>
+
<Menu
- id={item}
+ id={`menu-${item}`}
open={showMenu}
onClose={closeMenu}
- anchorEl={listItemTextRef.current}
+ anchorEl={listItemRef.current}
anchorOrigin={{
- vertical: "center",
- horizontal: "center",
+ vertical: "bottom",
+ horizontal: "right",
+ }}
+ transformOrigin={{
+ vertical: "top",
+ horizontal: "right",
+ }}
+ PaperProps={{
+ className: "shadow-lg"
}}
>
- <MenuItem onClick={openDeleteConfirmDialog}>
- <FontAwesomeIcon icon={faTrash} color="red" />
+ <MenuItem onClick={openDeleteConfirmDialog}
className="text-error hover:bg-error-light/10">
+ <FontAwesomeIcon icon={faTrashCan} className="mr-2" />
+ Delete
</MenuItem>
</Menu>
- <Dialog open={showDeleteConfirm}>
- <DialogTitle>Confirm</DialogTitle>
+
+ <Dialog
+ open={showDeleteConfirm}
+ onClose={closeDeleteConfirmDialog}
+ className="backdrop-blur-sm"
+ PaperProps={{
+ className: "rounded-lg shadow-xl"
+ }}
+ >
+ <DialogTitle className="font-medium">Confirm
Delete</DialogTitle>
<DialogContent>
- {type === "node" ? (
+ {type === "node" || type === "shard" ? (
<DialogContentText>
- Please confirm you want to delete {item}
- </DialogContentText>
- ) : type === "shard" ? (
- <DialogContentText>
- Please confirm you want to delete {item}
+ Are you sure you want to delete {displayName}?
</DialogContentText>
) : (
<DialogContentText>
- Please confirm you want to delete {type} {item}
+ Are you sure you want to delete {type} <span
className="font-semibold">{item}</span>?
</DialogContentText>
)}
</DialogContent>
- <DialogActions>
- <Button onClick={closeDeleteConfirmDialog}>Cancel</Button>
- <Button onClick={confirmDelete} color="error">
- Delete
+ <DialogActions className="p-4">
+ <Button onClick={closeDeleteConfirmDialog}
variant="outlined">
+ Cancel
+ </Button>
+ <Button
+ onClick={confirmDelete}
+ variant="contained"
+ color="error"
+ className="bg-error hover:bg-error-dark"
+ >
+ Delete
</Button>
</DialogActions>
</Dialog>
+
<Snackbar
open={!!errorMessage}
autoHideDuration={5000}
diff --git a/webui/src/app/utils.ts b/webui/src/app/utils.ts
index 4c79b17..9ddc6db 100644
--- a/webui/src/app/utils.ts
+++ b/webui/src/app/utils.ts
@@ -17,6 +17,54 @@
* under the License.
*/
-export const truncateText = (text: string, limit: number) => {
- return text.length > limit ? `${text.slice(0, limit)}...` : text;
+/**
+ * Truncates text to a specific length and adds an ellipsis
+ */
+export const truncateText = (text: string, maxLength: number): string => {
+ if (!text || text.length <= maxLength) return text;
+ return `${text.substring(0, maxLength)}...`;
+};
+
+/**
+ * Format a timestamp to a human-readable date
+ */
+export const formatTimestamp = (timestamp: number): string => {
+ return new Date(timestamp * 1000).toLocaleString();
+};
+
+/**
+ * Format bytes into a human-readable format
+ */
+export const formatBytes = (bytes: number, decimals: number = 2): string => {
+ if (bytes === 0) return '0 Bytes';
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
+};
+
+/**
+ * Calculate uptime from creation timestamp
+ */
+export const calculateUptime = (timestamp: number): string => {
+ 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`;
+};
+
+/**
+ * Format slot ranges for better display
+ */
+export const formatSlotRanges = (ranges: string[]): string => {
+ if (!ranges || ranges.length === 0) return "None";
+ if (ranges.length <= 2) return ranges.join(", ");
+ return `${ranges[0]}, ${ranges[1]}, ... (+${ranges.length - 2} more)`;
};
diff --git a/webui/tailwind.config.ts b/webui/tailwind.config.ts
index 0305e16..7b220bc 100644
--- a/webui/tailwind.config.ts
+++ b/webui/tailwind.config.ts
@@ -25,12 +25,65 @@ const config: Config = {
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
+ darkMode: 'class',
theme: {
extend: {
+ colors: {
+ primary: {
+ DEFAULT: '#1976d2',
+ light: '#42a5f5',
+ dark: '#1565c0',
+ contrastText: '#fff',
+ },
+ secondary: {
+ DEFAULT: '#9c27b0',
+ light: '#ba68c8',
+ dark: '#7b1fa2',
+ contrastText: '#fff',
+ },
+ success: {
+ DEFAULT: '#2e7d32',
+ light: '#4caf50',
+ dark: '#1b5e20',
+ },
+ error: {
+ DEFAULT: '#d32f2f',
+ light: '#ef5350',
+ dark: '#c62828',
+ },
+ warning: {
+ DEFAULT: '#ed6c02',
+ light: '#ff9800',
+ dark: '#e65100',
+ },
+ info: {
+ DEFAULT: '#0288d1',
+ light: '#03a9f4',
+ dark: '#01579b',
+ },
+ dark: {
+ DEFAULT: '#121212',
+ paper: '#1e1e1e',
+ border: '#333333',
+ },
+ light: {
+ DEFAULT: '#fafafa',
+ paper: '#ffffff',
+ border: '#e0e0e0',
+ }
+ },
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
- "gradient-conic":
- "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
+ "gradient-conic": "conic-gradient(from 180deg at 50% 50%,
var(--tw-gradient-stops))",
+ },
+ boxShadow: {
+ 'card': '0 2px 8px rgba(0, 0, 0, 0.08)',
+ 'card-hover': '0 4px 12px rgba(0, 0, 0, 0.15)',
+ 'sidebar': '2px 0 5px rgba(0, 0, 0, 0.05)',
+ },
+ transitionProperty: {
+ 'height': 'height',
+ 'spacing': 'margin, padding',
},
},
},