From 4cbbc1c3890e6091b79f04296f5e26d4ffd7f792 Mon Sep 17 00:00:00 2001 From: git-hulk Date: Sun, 5 Jul 2026 23:21:14 +0800 Subject: [PATCH 1/2] Redesign UI with Linear-style design tokens and shared page chrome Introduce a token-based design system (colors, typography, elevation, motion) in Tailwind and globals.css, and consolidate page layout into a new PageShell/PageBody/PageHeader chrome. Refactor every page and shared UI component to use the new tokens and utility classes, replacing the MUI Container wrapper and ad-hoc class recipes. Net -4800 lines by removing duplicated layout/styling code. --- webui/src/app/globals.css | 626 ++---- webui/src/app/layout.tsx | 15 +- .../[namespace]/clusters/[cluster]/page.tsx | 1742 +++------------ .../shards/[shard]/nodes/[node]/page.tsx | 536 ++--- .../[cluster]/shards/[shard]/page.tsx | 970 +++------ webui/src/app/namespaces/[namespace]/page.tsx | 1878 +++++------------ webui/src/app/namespaces/page.tsx | 1359 +++--------- webui/src/app/not-found.tsx | 44 +- webui/src/app/page.tsx | 1012 ++------- webui/src/app/theme-provider.tsx | 392 +++- webui/src/app/ui/banner.tsx | 333 +-- webui/src/app/ui/breadcrumb.tsx | 233 +- webui/src/app/ui/createCard.tsx | 155 +- webui/src/app/ui/emptyState.tsx | 47 +- webui/src/app/ui/failoverDialog.tsx | 423 ++-- webui/src/app/ui/footer.tsx | 327 +-- webui/src/app/ui/formCreation.tsx | 106 +- webui/src/app/ui/formDialog.tsx | 477 ++--- webui/src/app/ui/loadingSpinner.tsx | 45 +- webui/src/app/ui/migrationDialog.tsx | 541 ++--- webui/src/app/ui/nav-links.tsx | 79 +- webui/src/app/ui/pageChrome.tsx | 328 +++ webui/src/app/ui/sidebar.tsx | 816 ++++--- webui/src/app/ui/sidebarItem.tsx | 282 +-- webui/src/app/ui/spotlight-search.tsx | 542 ++--- webui/tailwind.config.ts | 168 +- webui/tsconfig.json | 70 +- 27 files changed, 4371 insertions(+), 9175 deletions(-) create mode 100644 webui/src/app/ui/pageChrome.tsx diff --git a/webui/src/app/globals.css b/webui/src/app/globals.css index 6f4d1ab4..fe31cdcd 100644 --- a/webui/src/app/globals.css +++ b/webui/src/app/globals.css @@ -1,4 +1,4 @@ -/* +/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information @@ -6,265 +6,166 @@ * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations - * under the License. + * under the License. */ @tailwind base; @tailwind components; @tailwind utilities; -@layer components { - .card { - @apply flex flex-col rounded-lg border border-light-border bg-white p-5 shadow-card transition-all hover:shadow-card-hover dark:border-dark-border dark:bg-dark-paper; - transition: all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1); - backface-visibility: hidden; - } - - .card:hover { - transform: translateY(-5px); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); - } - - .sidebar-item { - @apply my-1 flex items-center rounded-lg px-4 py-2 text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-paper; - } - - .sidebar-item-active { - @apply bg-primary-light/10 text-primary dark:text-primary-light; - } - - .btn { - @apply rounded-md px-4 py-2 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 mx-auto max-w-screen-2xl p-6; - } - .page-section { - @apply mb-0; - } - - .page-section:last-of-type { - @apply mb-0; - } +/* Linear-style design tokens — expose colors and elevations as CSS variables + so both Tailwind and MUI reach for the same values. */ +:root { + --lin-bg-base: #ffffff; + --lin-bg-subtle: #fbfbfc; + --lin-bg-muted: #f4f5f8; + --lin-bg-hover: #eeeff2; + --lin-bg-active: #e6e7eb; + + --lin-border-subtle: #e6e7eb; + --lin-border-strong: #d0d3d9; + + --lin-text-primary: #0d0e10; + --lin-text-secondary: #4b4f57; + --lin-text-muted: #6b7280; + + --lin-accent: #5e6ad2; + --lin-accent-hover: #4f5abd; + --lin-accent-muted: rgba(94, 106, 210, 0.12); + + --lin-danger: #eb5757; + --lin-success: #4cb782; + --lin-warning: #f2c94c; + + --lin-shadow-card: 0 1px 2px 0 rgba(15, 17, 22, 0.04); + --lin-shadow-popover: + 0 4px 12px -2px rgba(15, 17, 22, 0.08), 0 0 0 1px rgba(15, 17, 22, 0.06); + --lin-shadow-overlay: + 0 8px 32px -8px rgba(15, 17, 22, 0.16), 0 0 0 1px rgba(15, 17, 22, 0.08); + + --lin-topbar-height: 56px; + --lin-sidebar-width: 260px; + --lin-content-max: 1360px; + --lin-content-pad-x: 32px; + --lin-content-pad-y: 28px; + --lin-section-gap: 24px; +} + +.dark { + --lin-bg-base: #08090a; + --lin-bg-subtle: #101113; + --lin-bg-muted: #181a1f; + --lin-bg-hover: #22262f; + --lin-bg-active: #2a2e37; + + --lin-border-subtle: #23252a; + --lin-border-strong: #33363d; + + --lin-text-primary: #f7f8f8; + --lin-text-secondary: #b4b8c0; + --lin-text-muted: #8a8f98; + + --lin-accent: #7079e0; + --lin-accent-hover: #8d95f2; + --lin-accent-muted: rgba(112, 121, 224, 0.18); + + --lin-shadow-card: 0 1px 2px 0 rgba(0, 0, 0, 0.35); + --lin-shadow-popover: + 0 4px 12px -2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.06); + --lin-shadow-overlay: + 0 8px 32px -8px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.08); } -/* 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; +html { + color-scheme: light; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-feature-settings: + "cv02" on, + "cv03" on, + "cv04" on, + "cv11" on; } -.dark ::-webkit-scrollbar-thumb:hover { - background: #777; +html.dark { + color-scheme: dark; } body { - transition: background-color 0.3s ease; -} - -.dark body { - background-color: #121212; - color: #e0e0e0; -} - -.MuiAppBar-root { - transition: - background-color 0.3s ease, - color 0.3s ease, - height 0.3s ease, - box-shadow 0.3s ease, - border-bottom 0.3s ease !important; -} - -.dark .MuiAppBar-root { - background-color: rgba(21, 101, 192, 0.95) !important; -} - -.navbar-dark-mode { - background-color: rgba(21, 101, 192, 0.95) !important; - color: white !important; -} - -.dark .MuiAppBar-root, -.navbar-dark-mode { - background-color: #1565c0 !important; - color: white !important; - opacity: 0.9; -} - -.navbar-dark-mode { - background-color: #1565c0 !important; - color: white !important; -} - -.dark .MuiAppBar-root *, -.navbar-dark-mode * { - color: white !important; + background-color: var(--lin-bg-base); + color: var(--lin-text-primary); + font-size: 15px; + line-height: 1.55; + letter-spacing: -0.006em; } -.MuiAppBar-root[class*="navbar-dark-mode"] { - background-color: #1565c0 !important; +::selection { + background-color: var(--lin-accent-muted); + color: var(--lin-text-primary); } -.MuiButton-root, -.MuiIconButton-root, -.MuiListItemButton-root { - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important; -} - -.MuiButton-root:focus-visible, -.MuiIconButton-root:focus-visible { - outline: 2px solid rgba(25, 118, 210, 0.5); - outline-offset: 2px; -} +@layer components { + .lin-surface { + background-color: var(--lin-bg-base); + border: 1px solid var(--lin-border-subtle); + border-radius: 8px; + } -img { - transition: all 0.3s ease; -} + .lin-surface-muted { + background-color: var(--lin-bg-subtle); + border: 1px solid var(--lin-border-subtle); + border-radius: 8px; + } -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); + .lin-row-hover { + transition: + background-color 120ms ease, + border-color 120ms ease; } - to { - opacity: 1; - transform: translateY(0); + .lin-row-hover:hover { + background-color: var(--lin-bg-hover); } -} - -.animate-fadeIn { - animation: fadeIn 0.5s ease-out forwards; -} - -.transform { - transition: transform 0.3s ease-out; -} - -.hover\:-translate-y-1:hover { - transform: translateY(-4px); -} - -.card { - transition: all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1); - backface-visibility: hidden; -} - -.card:hover { - transform: translateY(-5px); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); -} -.bg-gradient-radial { - background-image: radial-gradient(circle at center, var(--tw-gradient-stops)); -} - -html { - scroll-behavior: smooth; -} - -.MuiButton-root { - transition: all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1) !important; -} - -.MuiButton-root:hover { - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); -} - -.interactive-element { - transform: translateY(0); -} + .lin-eyebrow { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--lin-text-muted); + } -.interactive-element:hover { - transform: translateY(-4px); - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); -} + .lin-divider { + border-color: var(--lin-border-subtle); + } -@keyframes blink { - 0%, - 100% { - opacity: 1; + .lin-input { + @apply w-full border bg-transparent px-3 py-2 text-sm text-text-primary transition-colors focus:outline-none dark:text-text-dark-primary; + border-color: var(--lin-border-subtle); + border-radius: 6px; } - 50% { - opacity: 0; + .lin-input:hover { + border-color: var(--lin-border-strong); + } + .lin-input:focus, + .lin-input:focus-visible { + border-color: var(--lin-accent); + box-shadow: 0 0 0 2px rgba(94, 106, 210, 0.2); } } -.cursor-blink { - animation: blink 1s steps(1) infinite; -} - -.gradient-text { - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.smooth-transition { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.hover-lift { - transition: - transform 0.3s ease, - box-shadow 0.3s ease; -} - -.hover-lift:hover { - transform: translateY(-5px); - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); -} - -.dark .shadow-enhanced { - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); -} - -.bg-pattern { - background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%231976d2' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); -} - -.dark .bg-pattern { - background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%2342a5f5' fill-opacity='0.05'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); -} - +/* Custom scrollbar — thin, neutral, no track */ ::-webkit-scrollbar { - width: 6px; - height: 6px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { @@ -272,246 +173,107 @@ html { } ::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.2); + background: rgba(15, 17, 22, 0.14); border-radius: 8px; } -.dark ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); -} - ::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.3); + background: rgba(15, 17, 22, 0.24); } -.dark ::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); -} - -@keyframes float { - 0% { - transform: translate3d(0, 0, 0) rotate(12deg); - } - 50% { - transform: translate3d(0, -10px, 0) rotate(10deg); - } - 100% { - transform: translate3d(0, 0, 0) rotate(12deg); - } -} - -.animate-float { - animation: float 6s ease-in-out infinite; - will-change: transform; -} - -@keyframes blink { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0; - } -} - -.cursor-blink { - animation: blink 1s steps(1) infinite; - will-change: opacity; -} - -.will-change-transform { - will-change: transform; -} - -.will-change-scroll { - will-change: scroll-position; -} - -img[data-loaded="true"] { - transition: opacity 0.5s ease; - opacity: 1; -} - -img[data-loaded="false"] { - opacity: 0; -} - -.transform, -.transition-all, -.hover\:transform, -.hover\:-translate-y-1:hover, -.hover\:scale-110:hover, -.animate-bounce, -.animate-pulse { - transform: translate3d(0, 0, 0); - backface-visibility: hidden; - perspective: 1000; - will-change: transform, opacity; -} - -.custom-scrollbar { - scrollbar-width: thin; -} - -.custom-scrollbar::-webkit-scrollbar { - width: 4px; - height: 4px; -} - -.custom-scrollbar::-webkit-scrollbar-track { - background: transparent; -} - -.custom-scrollbar::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: 10px; -} - -.dark .custom-scrollbar::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); -} - -.custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); +.dark ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); } -.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.25); +.dark ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.18); } .no-scrollbar { scrollbar-width: none; -ms-overflow-style: none; } - .no-scrollbar::-webkit-scrollbar { display: none; } -.search-container { - max-width: 100%; - transition: all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1); -} - -.search-container:focus-within { - max-width: 100%; - transform: translateY(-2px); -} - -.search-inner { - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); -} - -.search-inner:focus-within { - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08); -} - -@media (max-width: 640px) { - .search-container { - width: 100%; - } - - .search-inner input { - font-size: 16px; - } -} - -.sidebar-container { - position: relative; - transform-origin: left center; - will-change: width, transform; - box-shadow: 0 0 20px rgba(0, 0, 0, 0.04); - z-index: 40; -} - -.sidebar-inner { - min-width: 260px; - overflow-x: hidden; -} - -.sidebar-toggle-btn { - opacity: 0.9; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.sidebar-toggle-btn:hover { - opacity: 1; - transform: scale(1.1); +/* MUI overrides — strip the drop-shadows and easing MUI applies by default so + components respect Linear's flatter surface treatment. */ +.MuiAppBar-root { + background-color: var(--lin-bg-base) !important; + color: var(--lin-text-primary) !important; + box-shadow: none !important; + border-bottom: 1px solid var(--lin-border-subtle) !important; + transition: none !important; } -.sidebar-scrollbar { - scrollbar-width: thin; - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; +.MuiButton-root { + text-transform: none !important; + letter-spacing: -0.006em !important; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease !important; } - -.sidebar-scrollbar::-webkit-scrollbar { - width: 4px; +.MuiButton-root:hover { + transform: none !important; + box-shadow: none !important; } - -.sidebar-scrollbar::-webkit-scrollbar-track { - background: transparent; +.MuiButton-root:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(94, 106, 210, 0.35); } -.sidebar-scrollbar::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.15); - border-radius: 20px; +.MuiIconButton-root { + transition: + background-color 120ms ease, + color 120ms ease !important; } - -.dark .sidebar-scrollbar::-webkit-scrollbar-thumb { - background-color: rgba(255, 255, 255, 0.15); +.MuiIconButton-root:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(94, 106, 210, 0.35); } -.sidebar-scrollbar::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0.25); +/* Keyboard hint chip */ +kbd { + display: inline-flex; + align-items: center; + justify-content: center; + height: 18px; + min-width: 18px; + padding: 0 5px; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + font-size: 10px; + font-weight: 500; + line-height: 1; + color: var(--lin-text-muted); + background: var(--lin-bg-muted); + border: 1px solid var(--lin-border-subtle); + border-radius: 4px; } -.dark .sidebar-scrollbar::-webkit-scrollbar-thumb:hover { - background-color: rgba(255, 255, 255, 0.25); +/* Focus-visible ring for native elements */ +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid rgba(94, 106, 210, 0.35); + outline-offset: 1px; + border-radius: 4px; } -@media (max-width: 767px) { - .sidebar-container { - position: fixed; - height: 100vh; - top: 0; - left: 0; - z-index: 100; +/* Subtle fade for route transitions / initial mount */ +@keyframes lin-fade-in { + from { + opacity: 0; } - - .sidebar-container[style*="width: 0px"] { - border-right: none; - box-shadow: none; + to { + opacity: 1; } } -/* Spotlight Search Keyboard Shortcuts */ -kbd { - display: inline-block; - padding: 3px 8px; - font-family: - ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; - font-size: 0.7rem; - line-height: 1.2; - font-weight: 600; - color: #555; - background: linear-gradient(180deg, #fafafa 0%, #f0f0f0 100%); - border: 1px solid #d0d0d0; - border-radius: 6px; - box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.1), - 0 0 0 1px rgba(255, 255, 255, 0.8) inset, - 0 -1px 0 rgba(0, 0, 0, 0.05) inset; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.8); -} - -.dark kbd { - color: #d0d0d0; - background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%); - border: 1px solid #4a4a4a; - box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.3), - 0 0 0 1px rgba(255, 255, 255, 0.05) inset, - 0 -1px 0 rgba(0, 0, 0, 0.2) inset; - text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5); +.lin-fade-in { + animation: lin-fade-in 200ms ease-out both; } diff --git a/webui/src/app/layout.tsx b/webui/src/app/layout.tsx index c0a5c874..e323bff8 100644 --- a/webui/src/app/layout.tsx +++ b/webui/src/app/layout.tsx @@ -21,13 +21,12 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import Banner from "./ui/banner"; -import { Container } from "@mui/material"; import { ThemeProvider } from "./theme-provider"; import Footer from "./ui/footer"; import Breadcrumb from "./ui/breadcrumb"; import SpotlightSearch from "./ui/spotlight-search"; -const inter = Inter({ subsets: ["latin"] }); +const inter = Inter({ subsets: ["latin"], display: "swap" }); export const metadata: Metadata = { title: "Apache Kvrocks Controller", @@ -42,21 +41,17 @@ export default function RootLayout({ 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 62885416..096f2f58 100644 --- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx +++ b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx @@ -19,59 +19,33 @@ "use client"; -import { - Box, - Typography, - Chip, - Paper, - Grid, - Button, - IconButton, - Tooltip, - Popover, - RadioGroup, - FormControlLabel, - Radio, - Fade, - Badge, - Collapse, - Divider, -} from "@mui/material"; -import { ClusterSidebar } from "../../../../ui/sidebar"; -import { useState, useEffect, use } from "react"; -import { listShards, listNodes, fetchCluster, deleteShard } from "@/app/lib/api"; -import { AddShardCard, ResourceCard } from "@/app/ui/createCard"; -import Link from "next/link"; +import { use, useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { LoadingSpinner } from "@/app/ui/loadingSpinner"; +import Link from "next/link"; +import { Button, Chip } from "@mui/material"; import DnsIcon from "@mui/icons-material/Dns"; import StorageIcon from "@mui/icons-material/Storage"; import DeviceHubIcon from "@mui/icons-material/DeviceHub"; -import EmptyState from "@/app/ui/emptyState"; -import SearchIcon from "@mui/icons-material/Search"; -import FilterListIcon from "@mui/icons-material/FilterList"; -import SortIcon from "@mui/icons-material/Sort"; -import CheckIcon from "@mui/icons-material/Check"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import AddIcon from "@mui/icons-material/Add"; -import WarningIcon from "@mui/icons-material/Warning"; -import InfoIcon from "@mui/icons-material/Info"; +import GridViewIcon from "@mui/icons-material/GridView"; import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import ExpandLessIcon from "@mui/icons-material/ExpandLess"; -import { ShardCreation } from "@/app/ui/formCreation"; -import DeleteIcon from "@mui/icons-material/Delete"; import MoveUpIcon from "@mui/icons-material/MoveUp"; -import { MigrationDialog } from "@/app/ui/migrationDialog"; -interface ResourceCounts { - shards: number; - nodes: number; - withSlots: number; - migrating: number; -} +import { ClusterSidebar } from "../../../../ui/sidebar"; +import { deleteShard, listShards } from "@/app/lib/api"; +import { LoadingSpinner } from "@/app/ui/loadingSpinner"; +import EmptyState from "@/app/ui/emptyState"; +import { ShardCreation } from "@/app/ui/formCreation"; +import { MigrationDialog } from "@/app/ui/migrationDialog"; +import { + FilterListIcon, + FilterSortMenu, + PageHeader, + PageShell, + ResourceRow, + SearchInput, + SortIcon, + StatCard, +} from "@/app/ui/pageChrome"; interface ShardData { index: number; @@ -95,1454 +69,316 @@ type FilterOption = | "with-importing"; type SortOption = "index-asc" | "index-desc" | "nodes-desc" | "nodes-asc"; -export default function Cluster(props: { +const isActive = (value: string | null | undefined) => + value !== null && value !== undefined && value !== "" && value !== "-1"; + +function summarise(shards: any[]): { data: ShardData[]; nodes: number; withSlots: number; migrating: number } { + let nodes = 0; + let withSlots = 0; + let migrating = 0; + + const data = shards.map((shard: any, index: number) => { + const nodeCount = shard.nodes?.length || 0; + nodes += nodeCount; + const hasSlots = shard.slot_ranges && shard.slot_ranges.length > 0; + if (hasSlots) withSlots++; + const migratingSlot = shard.migrating_slot || ""; + const importingSlot = shard.import_slot || ""; + const hasMigration = isActive(migratingSlot); + if (hasMigration) migrating++; + return { + index, + nodes: shard.nodes || [], + slotRanges: shard.slot_ranges || [], + migratingSlot, + importingSlot, + targetShardIndex: shard.target_shard_index ?? -1, + nodeCount, + hasSlots, + hasMigration, + hasImporting: isActive(importingSlot), + }; + }); + return { data, nodes, withSlots, migrating }; +} + +export default function ClusterPage(props: { params: Promise<{ namespace: string; cluster: string }>; }) { const params = use(props.params); const { namespace, cluster } = params; - const [shardsData, setShardsData] = useState([]); - const [resourceCounts, setResourceCounts] = useState({ - shards: 0, - nodes: 0, - withSlots: 0, - migrating: 0, - }); - const [loading, setLoading] = useState(true); - const [deletingShardIndex, setDeletingShardIndex] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - const [filterAnchorEl, setFilterAnchorEl] = useState(null); - const [sortAnchorEl, setSortAnchorEl] = useState(null); - const [filterOption, setFilterOption] = useState("all"); - const [sortOption, setSortOption] = useState("index-asc"); - const [expandedSlots, setExpandedSlots] = useState>(new Set()); - const [migrationDialogOpen, setMigrationDialogOpen] = useState(false); + const [rows, setRows] = useState([]); + const [totals, setTotals] = useState({ shards: 0, nodes: 0, withSlots: 0, migrating: 0 }); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [sort, setSort] = useState("index-asc"); + const [migrationOpen, setMigrationOpen] = useState(false); const router = useRouter(); - const isActiveMigration = (migratingSlot: string | null | undefined): boolean => { - return ( - migratingSlot !== null && - migratingSlot !== undefined && - migratingSlot !== "" && - migratingSlot !== "-1" - ); - }; - - const isActiveImport = (importingSlot: string | null | undefined): boolean => { - return ( - importingSlot !== null && - importingSlot !== undefined && - importingSlot !== "" && - importingSlot !== "-1" - ); - }; - - useEffect(() => { - const fetchData = async () => { - try { - const fetchedShards = await listShards(namespace, cluster); - - if (!fetchedShards) { - console.error(`Shards not found`); - router.push("/404"); - return; - } - - let totalNodes = 0; - let withSlots = 0; - let migrating = 0; - - const processedShards = await Promise.all( - fetchedShards.map(async (shard: any, index: number) => { - const nodeCount = shard.nodes?.length || 0; - totalNodes += nodeCount; - - const hasSlots = shard.slot_ranges && shard.slot_ranges.length > 0; - if (hasSlots) withSlots++; - - // Handle string values from API as per documentation - const migratingSlot = shard.migrating_slot || ""; - const importingSlot = shard.import_slot || ""; - - const hasMigration = isActiveMigration(migratingSlot); - if (hasMigration) migrating++; - - return { - index, - nodes: shard.nodes || [], - slotRanges: shard.slot_ranges || [], - migratingSlot, - importingSlot, - targetShardIndex: shard.target_shard_index || -1, - nodeCount, - hasSlots, - hasMigration, - hasImporting: isActiveImport(importingSlot), - }; - }) - ); - - setShardsData(processedShards); - setResourceCounts({ - shards: processedShards.length, - nodes: totalNodes, - withSlots, - migrating, - }); - } catch (error) { - console.error("Error fetching shards:", error); - } finally { - setLoading(false); - } - }; - fetchData(); - }, [namespace, cluster, router]); - - const refreshShardData = async () => { - setLoading(true); + const refresh = useCallback(async () => { try { - const fetchedShards = await listShards(namespace, cluster); - if (!fetchedShards) { - console.error(`Shards not found`); + const shards = await listShards(namespace, cluster); + if (!shards) { router.push("/404"); return; } - - let totalNodes = 0; - let withSlots = 0; - let migrating = 0; - - const processedShards = await Promise.all( - fetchedShards.map(async (shard: any, index: number) => { - const nodeCount = shard.nodes?.length || 0; - totalNodes += nodeCount; - - const hasSlots = shard.slot_ranges && shard.slot_ranges.length > 0; - if (hasSlots) withSlots++; - - const migratingSlot = shard.migrating_slot || ""; - const importingSlot = shard.import_slot || ""; - - const hasMigration = isActiveMigration(migratingSlot); - if (hasMigration) migrating++; - - return { - index, - nodes: shard.nodes || [], - slotRanges: shard.slot_ranges || [], - migratingSlot, - importingSlot, - targetShardIndex: shard.target_shard_index || -1, - nodeCount, - hasSlots, - hasMigration, - hasImporting: isActiveImport(importingSlot), - }; - }) - ); - - setShardsData(processedShards); - setResourceCounts({ - shards: processedShards.length, - nodes: totalNodes, - withSlots, - migrating, - }); + const { data, nodes, withSlots, migrating } = summarise(shards as any[]); + setRows(data); + setTotals({ shards: data.length, nodes, withSlots, migrating }); } catch (error) { console.error("Error fetching shards:", error); - } finally { - setLoading(false); } - }; + }, [namespace, cluster, router]); - const handleDeleteShard = async (index: number) => { - if ( - !confirm( - `Are you sure you want to delete Shard ${index + 1}? This action cannot be undone.` - ) - ) - return; + useEffect(() => { + (async () => { + setLoading(true); + await refresh(); + setLoading(false); + })(); + }, [refresh]); + const handleDelete = async (index: number) => { + if (!confirm(`Delete Shard ${index + 1}? This cannot be undone.`)) return; try { - setDeletingShardIndex(index); - const responseMessage = await deleteShard(namespace, cluster, index.toString()); - if (responseMessage && responseMessage !== "") { - alert(`Failed to delete shard: ${responseMessage}`); + setDeleting(index); + const res = await deleteShard(namespace, cluster, index.toString()); + if (res) { + alert(`Failed to delete shard: ${res}`); return; } - - const fetchedShards = await listShards(namespace, cluster); - let totalNodes = 0; - let withSlots = 0; - let migrating = 0; - - const processedShards = (fetchedShards || []).map((shard: any, idx: number) => { - const nodeCount = shard.nodes?.length || 0; - totalNodes += nodeCount; - - const hasSlots = shard.slot_ranges && shard.slot_ranges.length > 0; - if (hasSlots) withSlots++; - - const migratingSlot = - shard.migrating_slot !== null && shard.migrating_slot !== undefined - ? shard.migrating_slot - : -1; - const importingSlot = - shard.import_slot !== null && shard.import_slot !== undefined - ? shard.import_slot - : -1; - - const hasMigration = migratingSlot >= 0; - if (hasMigration) migrating++; - - return { - index: idx, - nodes: shard.nodes || [], - slotRanges: shard.slot_ranges || [], - migratingSlot, - importingSlot, - targetShardIndex: shard.target_shard_index || -1, - nodeCount, - hasSlots, - hasMigration, - hasImporting: importingSlot >= 0, - } as ShardData; - }); - - setShardsData(processedShards); - setResourceCounts({ - shards: processedShards.length, - nodes: totalNodes, - withSlots, - migrating, - }); - } catch (error) { - alert(`Failed to delete shard: ${error}`); + await refresh(); + } catch (e) { + alert(`Failed to delete shard: ${e}`); } finally { - setDeletingShardIndex(null); + setDeleting(null); } }; - const handleFilterClick = (event: React.MouseEvent) => { - setFilterAnchorEl(event.currentTarget); - }; - - const handleSortClick = (event: React.MouseEvent) => { - setSortAnchorEl(event.currentTarget); - }; - - const handleFilterClose = () => { - setFilterAnchorEl(null); - }; - - const handleSortClose = () => { - setSortAnchorEl(null); - }; - - const filteredAndSortedShards = shardsData - .filter((shard) => { - if (!`shard ${shard.index + 1}`.toLowerCase().includes(searchTerm.toLowerCase())) { - return false; - } - - switch (filterOption) { - case "with-migration": - return shard.hasMigration; - case "no-migration": - return !shard.hasMigration; - case "with-slots": - return shard.hasSlots; - case "no-slots": - return !shard.hasSlots; - case "with-importing": - return shard.hasImporting; - default: - return true; - } - }) - .sort((a, b) => { - switch (sortOption) { - case "index-asc": - return a.index - b.index; - case "index-desc": - return b.index - a.index; - case "nodes-desc": - return b.nodeCount - a.nodeCount; - case "nodes-asc": - return a.nodeCount - b.nodeCount; - default: - return 0; - } - }); - - const isFilterOpen = Boolean(filterAnchorEl); - const isSortOpen = Boolean(sortAnchorEl); - const filterId = isFilterOpen ? "filter-popover" : undefined; - const sortId = isSortOpen ? "sort-popover" : undefined; - - if (loading) { - return ; - } - - const formatSlotRanges = (ranges: string[], showAll: boolean = false) => { - if (!ranges || ranges.length === 0) return "None"; - if (showAll || ranges.length <= 2) return ranges.join(", "); - return `${ranges[0]}, ${ranges[1]}, ... (+${ranges.length - 2} more)`; - }; - - const expandSlotRanges = (ranges: string[]) => { - if (!ranges || ranges.length === 0) return []; - const slots: number[] = []; - for (const range of ranges) { - if (range.includes("-")) { - const [start, end] = range.split("-").map(Number); - if (!isNaN(start) && !isNaN(end)) { - for (let i = start; i <= end; i++) { - slots.push(i); - } + const filtered = useMemo(() => { + return rows + .filter((s) => { + if (!`shard ${s.index + 1}`.toLowerCase().includes(search.toLowerCase())) + return false; + switch (filter) { + case "with-migration": + return s.hasMigration; + case "no-migration": + return !s.hasMigration; + case "with-slots": + return s.hasSlots; + case "no-slots": + return !s.hasSlots; + case "with-importing": + return s.hasImporting; + default: + return true; } - } else { - const slot = Number(range); - if (!isNaN(slot)) { - slots.push(slot); + }) + .sort((a, b) => { + switch (sort) { + case "index-asc": + return a.index - b.index; + case "index-desc": + return b.index - a.index; + case "nodes-desc": + return b.nodeCount - a.nodeCount; + case "nodes-asc": + return a.nodeCount - b.nodeCount; } - } - } - return slots.sort((a, b) => a - b); - }; + }); + }, [rows, search, filter, sort]); - const toggleSlotExpansion = (shardIndex: number) => { - const newExpanded = new Set(expandedSlots); - if (newExpanded.has(shardIndex)) { - newExpanded.delete(shardIndex); - } else { - newExpanded.add(shardIndex); - } - setExpandedSlots(newExpanded); - }; + if (loading) return ; - return ( -
-
- -
-
- -
-
- - - {cluster} - - - Manage shards in this cluster - -
+ const migrationShards = rows.map((r) => ({ + ...r, + migratingSlot: String(r.migratingSlot), + importingSlot: String(r.importingSlot), + })); -
-
-
-
- -
- setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
-
+ return ( + }> + } + title={cluster} + subtitle={`Cluster in ${namespace}`} + actions={ + <> + + + + + } + /> -
- - - - -
+
+
+ } + accent="primary" + /> + } + accent="warning" + /> + } + accent="info" + /> + } + accent={totals.migrating > 0 ? "warning" : "default"} + /> +
+ +
+
+
All shards
+
+ } + value={filter} + onChange={setFilter} + options={[ + { value: "all", label: `All (${rows.length})` }, + { + value: "with-migration", + label: `Migrating (${rows.filter((r) => r.hasMigration).length})`, + }, + { + value: "no-migration", + label: `Stable (${rows.filter((r) => !r.hasMigration).length})`, + }, + { + value: "with-slots", + label: `With slots (${rows.filter((r) => r.hasSlots).length})`, + }, + { + value: "no-slots", + label: `Without slots (${rows.filter((r) => !r.hasSlots).length})`, + }, + { + value: "with-importing", + label: `Importing (${rows.filter((r) => r.hasImporting).length})`, + }, + ]} + /> + } + value={sort} + onChange={setSort} + options={[ + { value: "index-asc", label: "Index 1 → N", group: "Index" }, + { value: "index-desc", label: "Index N → 1", group: "Index" }, + { value: "nodes-desc", label: "Most nodes", group: "Nodes" }, + { value: "nodes-asc", label: "Fewest nodes", group: "Nodes" }, + ]} + />
-
- - - -
-
- -
-
- - {resourceCounts.shards} - - - Shards - -
-
-
-
-
- - - -
-
- -
-
- - {resourceCounts.nodes} - - - Nodes - -
-
-
-
-
- - - -
-
- -
-
- - {resourceCounts.withSlots} - - - With Slots - -
-
-
-
-
- - - -
-
- -
-
- - {resourceCounts.migrating} - - - Migrating - -
-
-
-
-
-
-
- - -
-
- - All Shards - -
- - - - - - - - - - -
-
-
- - -
-
- - Filter Shards - -
- - - setFilterOption(e.target.value as FilterOption) - } - > -
-
- - -
- } - /> - } - label={ -
- - All shards - - -
- } - className="m-0 w-full" - /> -
- -
- - -
- } - /> - } - label={ -
- - With migration - - shard.hasMigration - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> -
- -
- - -
- } - /> - } - label={ -
- - No migration - - !shard.hasMigration - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> -
- -
- - -
- } - /> - } - label={ -
- - With slots - - shard.hasSlots - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> -
- -
- - -
- } - /> - } - label={ -
- - No slots - - !shard.hasSlots - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> -
- -
- - -
- } - /> - } - label={ -
- - With importing - - shard.hasImporting - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> -
-
- - -
- -
-
- - - -
-
- - Sort Shards - -
- - setSortOption(e.target.value as SortOption)} - > -
-
- - -
- } - /> - } - label={ -
- - - Index 1-{shardsData.length} - -
- } - className="m-0 w-full" - /> -
- -
- - -
- } - /> - } - label={ -
- - - Index {shardsData.length}-1 + {filtered.length > 0 ? ( +
    + {filtered.map((shard) => { + const slotSummary = shard.slotRanges.length + ? shard.slotRanges.length > 2 + ? `${shard.slotRanges.slice(0, 2).join(", ")} (+${shard.slotRanges.length - 2})` + : shard.slotRanges.join(", ") + : "No slots"; + return ( +
  • + } + title={`Shard ${shard.index + 1}`} + subtitle={`${shard.nodeCount} nodes · slots: ${slotSummary}`} + badges={ + <> + {shard.hasMigration && ( + + + Migrating {shard.migratingSlot} -
- } - className="m-0 w-full" - /> -
- -
- - -
- } - /> - } - label={ -
- - - Most nodes + )} + {shard.hasImporting && ( + + + Importing {shard.importingSlot} -
- } - className="m-0 w-full" - /> - - -
- - -
- } /> - } - label={ -
- - - Least nodes - -
- } - className="m-0 w-full" - /> - - - - -
- -
- -
- - {filteredAndSortedShards.length > 0 ? ( -
- {filteredAndSortedShards.map((shard) => ( -
- -
-
- -
- -
-
- -
- - Shard {shard.index + 1} - - - {shard.hasMigration && ( -
-
- - Migrating{" "} - {shard.migratingSlot} - -
- )} - - {!shard.hasMigration && - !shard.hasImporting && ( -
-
- - Stable - -
- )} - - {shard.hasImporting && ( -
-
- - Importing{" "} - {shard.importingSlot} - -
- )} -
- -
- - - {shard.nodeCount} nodes - - - {shard.hasSlots ? ( -
-
- - - Slots:{" "} - {formatSlotRanges( - shard.slotRanges - )} - - - {shard.slotRanges - .length > 2 && ( - { - e.preventDefault(); - e.stopPropagation(); - toggleSlotExpansion( - shard.index - ); - }} - className="ml-1 p-1" - sx={{ - fontSize: 16, - }} - > - {expandedSlots.has( - shard.index - ) ? ( - - ) : ( - - )} - - )} -
- -
- - All Slot Ranges - ( - { - expandSlotRanges( - shard.slotRanges - ).length - }{" "} - total slots): - - - {shard.slotRanges.join( - ", " - )} - -
-
-
- ) : ( - - - No slots assigned - - )} - - {shard.targetShardIndex >= 0 && ( - - - Target shard:{" "} - {shard.targetShardIndex + 1} - - )} -
- -
- -
- - } - label={`${shard.nodeCount} nodes`} - size="small" - color="secondary" - variant="outlined" - className="whitespace-nowrap" - /> - - {shard.hasSlots ? ( - - } - label={`${shard.slotRanges.length} slot${shard.slotRanges.length !== 1 ? "s" : ""}`} - size="small" - color="primary" - variant="outlined" - className="whitespace-nowrap" - /> - ) : ( - } - label="No slots" - size="small" - color="info" - variant="outlined" - className="whitespace-nowrap" - /> - )} - - {shard.hasMigration && ( - - } - label={`Migrating ${shard.migratingSlot}`} - size="small" - color="warning" - variant="outlined" - className="whitespace-nowrap" - /> - )} - - {shard.hasImporting && ( - } - label={`Importing ${shard.importingSlot}`} - size="small" - color="info" - variant="outlined" - className="whitespace-nowrap" - /> - )} - - {shard.targetShardIndex >= 0 && ( - - )} -
- -
-
- - Nodes - - - {shard.nodeCount} - -
- -
- - Slots - - - {shard.hasSlots - ? shard.slotRanges.length - : 0} - -
- -
- - Status - - - {shard.hasMigration - ? "Migrating" - : shard.hasImporting - ? "Importing" - : "Stable"} - -
-
- -
- - - - -
-
-
-
-
- ))} -
- ) : ( -
- } - action={{ - label: "Create Shard", - onClick: () => {}, - }} - /> -
- -
-
- )} + + } + href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard.index}`} + onDelete={() => handleDelete(shard.index)} + deleteDisabled={deleting === shard.index} + /> + + ); + })} + + ) : ( +
+ } + /> +
+ )} - {filteredAndSortedShards.length > 0 && ( -
- - Showing {filteredAndSortedShards.length} of {shardsData.length}{" "} - shards - -
- )} - - + {filtered.length > 0 && ( +
+ Showing {filtered.length} of {rows.length} + {filter !== "all" && " (filtered)"} +
+ )} + setMigrationDialogOpen(false)} + open={migrationOpen} + onClose={() => setMigrationOpen(false)} namespace={namespace} cluster={cluster} - shards={shardsData} - onSuccess={refreshShardData} + shards={migrationShards} + onSuccess={refresh} /> - + ); } 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 b785cb43..8391247f 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 @@ -19,430 +19,180 @@ "use client"; +import { use, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Alert, IconButton, Tooltip } from "@mui/material"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import CheckIcon from "@mui/icons-material/Check"; +import DeviceHubIcon from "@mui/icons-material/DeviceHub"; + import { listNodes } from "@/app/lib/api"; import { NodeSidebar } from "@/app/ui/sidebar"; -import { Box, Typography, Chip, Paper, Divider, Grid, Alert, IconButton } from "@mui/material"; -import { useEffect, useState, use } from "react"; -import { useRouter } from "next/navigation"; import { LoadingSpinner } from "@/app/ui/loadingSpinner"; -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"; -import InfoIcon from "@mui/icons-material/Info"; -import SettingsIcon from "@mui/icons-material/Settings"; -import NetworkCheckIcon from "@mui/icons-material/NetworkCheck"; -import SecurityIcon from "@mui/icons-material/Security"; -import LinkIcon from "@mui/icons-material/Link"; +import { PageHeader, PageShell } from "@/app/ui/pageChrome"; + +function CopyableField({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + const [copied, setCopied] = useState(false); + const copy = () => { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + return ( +
+
{label}
+
+
+ {value || "—"} +
+ + + + {copied ? ( + + ) : ( + + )} + + + +
+
+ ); +} + +function InfoField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} -export default function Node(props: { +export default function NodePage(props: { params: Promise<{ namespace: string; cluster: string; shard: string; node: string }>; }) { const params = use(props.params); const { namespace, cluster, shard, node } = params; const router = useRouter(); - const [nodeData, setNodeData] = useState([]); - const [loading, setLoading] = useState(true); - const [copied, setCopied] = useState(null); + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { - const fetchData = async () => { + (async () => { try { - const fetchedNodes = await listNodes(namespace, cluster, shard); - if (!fetchedNodes) { - console.error(`Shard ${shard} not found`); + const fetched = await listNodes(namespace, cluster, shard); + if (!fetched) { router.push("/404"); return; } - setNodeData(fetchedNodes); + setNodes(fetched as any[]); } catch (error) { - console.error("Error fetching shard data:", error); + console.error("Error fetching nodes:", error); } finally { setLoading(false); } - }; - - fetchData(); + })(); }, [namespace, cluster, shard, router]); - if (loading) { - return ; - } + if (loading) return ; - const currentNode = nodeData[parseInt(node)]; - if (!currentNode) { + const current = nodes[parseInt(node)]; + if (!current) { return ( -
- - - - Node not found + }> +
+ + 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: , - bgClass: "bg-green-50 dark:bg-green-900/30", - borderClass: "border-green-200 dark:border-green-800", - textColor: "text-green-700 dark:text-green-300", - }; - } - return { - color: "info", - textClass: "text-info font-medium", - icon: , - bgClass: "bg-blue-50 dark:bg-blue-900/30", - borderClass: "border-blue-200 dark:border-blue-800", - textColor: "text-blue-700 dark:text-blue-300", - }; - }; - - 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(); - const roleStyles = getRoleStyles(currentNode.role); + const roleBadge = + current.role === "master" ? ( + + + Master + + ) : ( + + + Replica + + ); return ( -
- -
- - {/* Header Section */} -
-
- -
- -
- Node {parseInt(node) + 1} -
- {roleStyles.icon} - - {currentNode.role} - -
-
- - Shard {parseInt(shard) + 1} • {cluster} cluster • {namespace}{" "} - namespace - -
-
- - {/* Node Details Section */} - -
- - - Node Configuration - -
- -
- - -
-
- - - Node ID - -
-
- - {currentNode.id} - -
- - copyToClipboard(currentNode.id, "id") - } - className="ml-3 bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-primary dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-primary-light" - title="Copy ID" - style={{ borderRadius: "16px" }} - > - {copied === "id" ? ( - - ) : ( - - )} - -
-
- -
- - - Address - -
-
- - {currentNode.addr} - -
- - copyToClipboard(currentNode.addr, "addr") - } - className="ml-3 bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-primary dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-primary-light" - title="Copy Address" - style={{ borderRadius: "16px" }} - > - {copied === "addr" ? ( - - ) : ( - - )} - -
-
-
-
- - -
-
- - - Role - -
- {roleStyles.icon} - - {currentNode.role} - -
-
- -
- - - Created At - -
- - {formattedDate} - -
-
- - {currentNode.password && ( -
- - - Authentication - -
-
- - {currentNode.password - ? "••••••••" - : "No password set"} - -
- - copyToClipboard( - currentNode.password, - "pwd" - ) - } - className="ml-3 bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-primary dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-primary-light" - title="Copy Password" - disabled={!currentNode.password} - style={{ borderRadius: "16px" }} - > - {copied === "pwd" ? ( - - ) : ( - - )} - -
-
- )} -
-
-
+ }> + } + title={ + + Node {parseInt(node) + 1} + {roleBadge} + + } + subtitle={`Shard ${parseInt(shard) + 1} · ${cluster} · ${namespace}`} + /> + +
+
+
+

+ Configuration +

+
+ + + + + {current.password && ( + + )}
- - - {/* Shard Information Section */} - -
- - - Shard Information - +
+ +
+

+ Location +

+
+ + +
- -
- - -
- - - Shard - - - Shard {parseInt(shard) + 1} - -
-
- -
- - - Cluster - - - {cluster} - -
-
- -
- - - Namespace - - - {namespace} - -
-
-
-
- - +
+
-
+ ); } 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 a7406a66..24a01fcd 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,751 +19,293 @@ "use client"; -import { - Box, - Typography, - Chip, - Paper, - Grid, - Button, - IconButton, - Tooltip, - Popover, - RadioGroup, - FormControlLabel, - Radio, - Fade, -} from "@mui/material"; -import { ShardSidebar } from "@/app/ui/sidebar"; -import { fetchShard, deleteNode } from "@/app/lib/api"; +import { use, useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { useState, useEffect, use } from "react"; -import { AddNodeCard } from "@/app/ui/createCard"; -import Link from "next/link"; +import { Button, Chip } from "@mui/material"; +import DnsIcon from "@mui/icons-material/Dns"; +import DeviceHubIcon from "@mui/icons-material/DeviceHub"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import AlarmIcon from "@mui/icons-material/Alarm"; +import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; + +import { ShardSidebar } from "@/app/ui/sidebar"; +import { deleteNode, fetchShard } from "@/app/lib/api"; 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"; -import SearchIcon from "@mui/icons-material/Search"; -import FilterListIcon from "@mui/icons-material/FilterList"; -import SortIcon from "@mui/icons-material/Sort"; -import CheckIcon from "@mui/icons-material/Check"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import { NodeCreation } from "@/app/ui/formCreation"; -import AddIcon from "@mui/icons-material/Add"; -import DeleteIcon from "@mui/icons-material/Delete"; -import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; import { FailoverDialog } from "@/app/ui/failoverDialog"; +import { + FilterListIcon, + FilterSortMenu, + PageHeader, + PageShell, + ResourceRow, + SearchInput, + SortIcon, + StatCard, +} from "@/app/ui/pageChrome"; + +type FilterOption = "all" | "master" | "replica"; +type SortOption = "index-asc" | "index-desc" | "uptime-desc" | "uptime-asc"; + +const calculateUptime = (timestamp: number) => { + const now = Math.floor(Date.now() / 1000); + const seconds = now - timestamp; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86400)}d`; +}; -export default function Shard(props: { +export default function ShardPage(props: { params: Promise<{ namespace: string; cluster: string; shard: string }>; }) { const params = use(props.params); const { namespace, cluster, shard } = params; const [nodesData, setNodesData] = useState(null); - const [loading, setLoading] = useState(true); - const [deletingNodeIndex, setDeletingNodeIndex] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - const [filterAnchorEl, setFilterAnchorEl] = useState(null); - const [sortAnchorEl, setSortAnchorEl] = useState(null); - const [filterOption, setFilterOption] = useState("all"); - const [sortOption, setSortOption] = useState("index-asc"); - const [failoverDialogOpen, setFailoverDialogOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [sort, setSort] = useState("index-asc"); + const [failoverOpen, setFailoverOpen] = useState(false); const router = useRouter(); - useEffect(() => { - const fetchData = async () => { - try { - const fetchedNodes = await fetchShard(namespace, cluster, shard); - if (!fetchedNodes) { - console.error(`Shard ${shard} not found`); - router.push("/404"); - return; - } - setNodesData(fetchedNodes); - } catch (error) { - console.error("Error fetching shard data:", error); - } finally { - setLoading(false); - } - }; - fetchData(); - }, [namespace, cluster, shard, router]); - - const refreshShardData = async () => { - setLoading(true); + const refresh = useCallback(async () => { try { - const fetchedNodes = await fetchShard(namespace, cluster, shard); - setNodesData(fetchedNodes); + const fetched = await fetchShard(namespace, cluster, shard); + if (!fetched) { + router.push("/404"); + return; + } + setNodesData(fetched); } catch (error) { - console.error("Error refreshing shard data:", error); - } finally { - setLoading(false); + console.error("Error fetching shard:", error); } - }; + }, [namespace, cluster, shard, router]); - const hasReplicaNodes = nodesData?.nodes?.some((node: any) => node.role === "slave") || false; + useEffect(() => { + (async () => { + setLoading(true); + await refresh(); + setLoading(false); + })(); + }, [refresh]); - if (loading) { - return ; - } + const nodes = useMemo(() => (nodesData?.nodes as any[]) || [], [nodesData]); + const hasReplicas = nodes.some((n) => n.role === "slave"); - // 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`; - }; + const filtered = useMemo( + () => + nodes + .map((node, idx) => ({ ...node, __index: idx })) + .filter((node) => { + if (!`node ${node.__index + 1}`.toLowerCase().includes(search.toLowerCase())) + return false; + if (filter === "master") return node.role === "master"; + if (filter === "replica") return node.role !== "master"; + return true; + }) + .sort((a, b) => { + switch (sort) { + case "index-asc": + return a.__index - b.__index; + case "index-desc": + return b.__index - a.__index; + case "uptime-desc": + return b.created_at - a.created_at; + case "uptime-asc": + return a.created_at - b.created_at; + } + }), + [nodes, search, filter, sort] + ); - // Get role color and icon - const getRoleInfo = (role: string) => { - if (role === "master") { - return { - color: "success", - icon: , - }; + const handleDelete = async (nodeId: string, index: number) => { + if (!confirm(`Delete Node ${index + 1}? This cannot be undone.`)) return; + try { + setDeleting(index); + const res = await deleteNode(namespace, cluster, shard, nodeId); + if (res) { + alert(`Failed to delete node: ${res}`); + return; + } + await refresh(); + } catch (e) { + alert(`Failed to delete node: ${e}`); + } finally { + setDeleting(null); } - return { - color: "info", - icon: , - }; }; - // Filtering and sorting logic for nodes - const filteredAndSortedNodes = (nodesData?.nodes || []) - .filter((node: any, idx: number) => { - if (!`node ${idx + 1}`.toLowerCase().includes(searchTerm.toLowerCase())) { - return false; - } - switch (filterOption) { - case "master": - return node.role === "master"; - case "replica": - return node.role !== "master"; - default: - return true; - } - }) - .sort((a: any, b: any) => { - switch (sortOption) { - case "index-asc": - return a.index - b.index; - case "index-desc": - return b.index - a.index; - case "uptime-desc": - return b.created_at - a.created_at; - case "uptime-asc": - return a.created_at - b.created_at; - default: - return 0; - } - }); + if (loading) return ; - const isFilterOpen = Boolean(filterAnchorEl); - const isSortOpen = Boolean(sortAnchorEl); - const filterId = isFilterOpen ? "filter-popover" : undefined; - const sortId = isSortOpen ? "sort-popover" : undefined; + const masterCount = nodes.filter((n) => n.role === "master").length; + const replicaCount = nodes.length - masterCount; return ( -
- -
- -
-
- - - Shard {parseInt(shard) + 1} - - - Manage nodes in this shard - -
-
-
-
-
- -
- setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
-
-
- - - - -
+ }> + } + title={`Shard ${parseInt(shard) + 1}`} + subtitle={`${cluster} · ${namespace}`} + actions={ + <> + + + + + } + /> + +
+
+ } + accent="warning" + /> + } + accent="success" + /> + } + accent="info" + /> +
+ +
+
+
All nodes
+
+ } + value={filter} + onChange={setFilter} + options={[ + { value: "all", label: `All (${nodes.length})` }, + { value: "master", label: `Master (${masterCount})` }, + { value: "replica", label: `Replicas (${replicaCount})` }, + ]} + /> + } + value={sort} + onChange={setSort} + options={[ + { value: "index-asc", label: "Index 1 → N", group: "Index" }, + { value: "index-desc", label: "Index N → 1", group: "Index" }, + { value: "uptime-desc", label: "Newest", group: "Uptime" }, + { value: "uptime-asc", label: "Oldest", group: "Uptime" }, + ]} + />
- -
-
- - All Nodes - -
- - setFilterAnchorEl(e.currentTarget)} - aria-describedby={filterId} - className="bg-gray-50 text-gray-500 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700" - style={{ borderRadius: "16px" }} - > - - - - - setSortAnchorEl(e.currentTarget)} - aria-describedby={sortId} - className="bg-gray-50 text-gray-500 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700" - style={{ borderRadius: "16px" }} - > - - - -
-
+ + {filtered.length > 0 ? ( +
    + {filtered.map((node) => ( +
  • + } + title={`Node ${node.__index + 1}`} + subtitle={ + + {truncateText(node.id, 10)} + · + + {node.addr} + + + } + badges={ + node.role === "master" ? ( + + + Master + + ) : ( + + + Replica + + ) + } + meta={ + + + {calculateUptime(node.created_at)} + + } + href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}/nodes/${node.__index}`} + onDelete={() => handleDelete(node.id, node.__index)} + deleteDisabled={deleting === node.__index} + /> +
  • + ))} +
+ ) : ( +
+ } + />
- setFilterAnchorEl(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "right" }} - transformOrigin={{ vertical: "top", horizontal: "right" }} - TransitionComponent={Fade} - PaperProps={{ - className: "shadow-xl border border-gray-100 dark:border-gray-700", - elevation: 3, - sx: { width: 220, borderRadius: "20px" }, - }} - > -
-
- - Filter Nodes - -
- setFilterOption(e.target.value)} - > -
-
- - -
- } - /> - } - label={ - - All nodes - - } - className="m-0 w-full" - /> -
-
- - -
- } - /> - } - label={ - - Master nodes - - } - className="m-0 w-full" - /> -
-
- - -
- } - /> - } - label={ - - Replica nodes - - } - className="m-0 w-full" - /> -
-
- -
- -
-
- - setSortAnchorEl(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "right" }} - transformOrigin={{ vertical: "top", horizontal: "right" }} - TransitionComponent={Fade} - PaperProps={{ - className: "shadow-xl border border-gray-100 dark:border-gray-700", - elevation: 3, - sx: { width: 220, borderRadius: "20px" }, - }} - > -
-
- - Sort Nodes - -
- setSortOption(e.target.value)} - > -
-
- - -
- } - /> - } - label={ - - Index 1-N - - } - className="m-0 w-full" - /> -
-
- - -
- } - /> - } - label={ - - Index N-1 - - } - className="m-0 w-full" - /> -
-
- - -
- } - /> - } - label={ - - Newest - - } - className="m-0 w-full" - /> -
-
- - -
- } - /> - } - label={ - - Oldest - - } - className="m-0 w-full" - /> -
-
- -
- -
-
- - {filteredAndSortedNodes.length > 0 ? ( -
- {filteredAndSortedNodes.map((node: any, index: number) => { - const roleInfo = getRoleInfo(node.role); - return ( -
- -
-
- -
-
-
- -
- - Node {index + 1} - - {node.role === "master" ? ( -
-
- - Master - -
- ) : ( -
-
- - Replica - -
- )} -
-
- - ID:{" "} - - {truncateText( - node.id, - 10 - )} - - - - Address:{" "} - - {node.addr} - - - - - Uptime:{" "} - {calculateUptime( - node.created_at - )} - -
- -
-
-
- -
-
-
-
- ); - })} -
- ) : ( -
- } - /> -
- )} - {filteredAndSortedNodes.length > 0 && ( -
- - Showing {filteredAndSortedNodes.length} of{" "} - {nodesData.nodes.length} nodes - -
- )} -
-
+ )} - setFailoverDialogOpen(false)} - namespace={namespace} - cluster={cluster} - shard={shard} - nodes={nodesData?.nodes || []} - onSuccess={refreshShardData} - /> + {filtered.length > 0 && ( +
+ Showing {filtered.length} of {nodes.length} + {filter !== "all" && " (filtered)"} +
+ )} +
- + + setFailoverOpen(false)} + namespace={namespace} + cluster={cluster} + shard={shard} + nodes={nodes} + onSuccess={refresh} + /> + ); } diff --git a/webui/src/app/namespaces/[namespace]/page.tsx b/webui/src/app/namespaces/[namespace]/page.tsx index 49278027..3dc539ed 100644 --- a/webui/src/app/namespaces/[namespace]/page.tsx +++ b/webui/src/app/namespaces/[namespace]/page.tsx @@ -19,78 +19,61 @@ "use client"; -import { - Box, - Typography, - Chip, - Paper, - Grid, - Button, - IconButton, - Tooltip, - Popover, - RadioGroup, - FormControlLabel, - Radio, - Fade, -} from "@mui/material"; +import { notFound, useRouter } from "next/navigation"; +import { use, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { IconButton } from "@mui/material"; +import FolderIcon from "@mui/icons-material/Folder"; +import StorageIcon from "@mui/icons-material/Storage"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; +import DownloadingIcon from "@mui/icons-material/Downloading"; +import MoveToInboxIcon from "@mui/icons-material/MoveToInbox"; +import AddIcon from "@mui/icons-material/Add"; + import { NamespaceSidebar } from "../../ui/sidebar"; import { + deleteCluster, fetchCluster, fetchClusters, fetchNamespaces, - listShards, listNodes, - deleteCluster, } from "@/app/lib/api"; -import Link from "next/link"; -import { useRouter, notFound } from "next/navigation"; -import { useState, useEffect, use } 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"; - -import SearchIcon from "@mui/icons-material/Search"; -import FilterListIcon from "@mui/icons-material/FilterList"; -import SortIcon from "@mui/icons-material/Sort"; -import CheckIcon from "@mui/icons-material/Check"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; -import DnsIcon from "@mui/icons-material/Dns"; -import DeviceHubIcon from "@mui/icons-material/DeviceHub"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import AccessTimeIcon from "@mui/icons-material/AccessTime"; -import AddIcon from "@mui/icons-material/Add"; -import FileUploadIcon from "@mui/icons-material/FileUpload"; -import WarningIcon from "@mui/icons-material/Warning"; -import InfoIcon from "@mui/icons-material/Info"; import { ClusterCreation, ImportCluster } from "@/app/ui/formCreation"; -import DeleteIcon from "@mui/icons-material/Delete"; +import { + FilterListIcon, + FilterSortMenu, + PageHeader, + PageShell, + SearchInput, + SortIcon, +} from "@/app/ui/pageChrome"; -interface ResourceCounts { - clusters: number; - shards: number; - nodes: number; -} +const TOTAL_SLOTS = 16384; interface ClusterData { name: string; version: string; - shards: any[]; shardCount: number; nodeCount: number; - hasSlots: boolean; + slotCount: number; + slotRanges: string[]; hasMigration: boolean; - hasNoMigration: boolean; hasImporting: boolean; - slotRanges: string[]; - migratingSlot: number; - importingSlot: number; - targetShardIndex: number; + migratingSlot: string; + importingSlot: string; } +// The controller marshals `migrating_slot` / `import_slot` as `null` when the +// shard is idle and as a slot-range string (e.g. "3300" or "100-200") while +// active. We can't check with a numeric comparison because JS coerces `null` +// to `0`, which would keep "in progress" showing forever after a migration ends. +const isActiveSlot = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value !== "-1"; + type FilterOption = | "all" | "with-migration" @@ -104,1306 +87,603 @@ type SortOption = | "shards-desc" | "shards-asc" | "nodes-desc" - | "nodes-asc"; + | "nodes-asc" + | "coverage-desc" + | "coverage-asc"; + +function parseSlotCount(ranges: unknown): number { + if (!Array.isArray(ranges)) return 0; + let total = 0; + for (const range of ranges) { + if (typeof range !== "string") continue; + const parts = range.split("-"); + if (parts.length === 1) { + const n = Number(parts[0]); + if (!isNaN(n)) total += 1; + } else if (parts.length === 2) { + const a = Number(parts[0]); + const b = Number(parts[1]); + if (!isNaN(a) && !isNaN(b)) total += Math.max(0, b - a + 1); + } + } + return total; +} + +async function buildClusterData(namespace: string): Promise<{ + clusters: ClusterData[]; + totals: { shards: number; nodes: number; slots: number }; +}> { + const clusters = await fetchClusters(namespace); + let totalShards = 0; + let totalNodes = 0; + let totalSlots = 0; + + const results = await Promise.all( + clusters.map(async (cluster) => { + try { + const clusterInfo = await fetchCluster(namespace, cluster); + if (!clusterInfo || typeof clusterInfo !== "object" || !("shards" in clusterInfo)) + return null; + + const shards = ((clusterInfo as any).shards as any[]) || []; + const nodeLists = await Promise.all( + shards.map((_, i) => listNodes(namespace, cluster, i.toString())), + ); + const nodeCount = nodeLists.reduce( + (acc, nodes) => acc + (Array.isArray(nodes) ? nodes.length : 0), + 0, + ); + + const slotCount = shards.reduce( + (acc: number, s: any) => acc + parseSlotCount(s?.slot_ranges), + 0, + ); + const slotRanges: string[] = shards + .flatMap((s: any) => (Array.isArray(s?.slot_ranges) ? s.slot_ranges : [])) + .filter((x: unknown): x is string => typeof x === "string" && x.length > 0); + + totalShards += shards.length; + totalNodes += nodeCount; + totalSlots += slotCount; + + const migrating = shards.find((s: any) => isActiveSlot(s?.migrating_slot)); + const importing = shards.find((s: any) => isActiveSlot(s?.import_slot)); + + return { + ...(clusterInfo as any), + shardCount: shards.length, + nodeCount, + slotCount, + slotRanges, + hasMigration: !!migrating, + hasImporting: !!importing, + migratingSlot: (migrating?.migrating_slot as string | undefined) ?? "", + importingSlot: (importing?.import_slot as string | undefined) ?? "", + } as ClusterData; + } catch (error) { + console.error(`Failed to load cluster ${cluster}:`, error); + return null; + } + }), + ); + + return { + clusters: results.filter(Boolean) as ClusterData[], + totals: { shards: totalShards, nodes: totalNodes, slots: totalSlots }, + }; +} + +function formatSummary(clusters: number, shards: number, nodes: number, coveragePct: number) { + const parts = [ + `${clusters} ${clusters === 1 ? "cluster" : "clusters"}`, + `${shards} ${shards === 1 ? "shard" : "shards"}`, + `${nodes} ${nodes === 1 ? "node" : "nodes"}`, + ]; + if (coveragePct > 0) parts.push(`${coveragePct}% slot coverage`); + return parts.join(" · "); +} -export default function Namespace(props: { params: Promise<{ namespace: string }> }) { +export default function NamespacePage(props: { + params: Promise<{ namespace: string }>; +}) { const params = use(props.params); - const [clusterData, setClusterData] = useState([]); - const [resourceCounts, setResourceCounts] = useState({ - clusters: 0, - shards: 0, - nodes: 0, - }); - const [loading, setLoading] = useState(true); - const [searchTerm, setSearchTerm] = useState(""); - const [filterAnchorEl, setFilterAnchorEl] = useState(null); - const [sortAnchorEl, setSortAnchorEl] = useState(null); - const [filterOption, setFilterOption] = useState("all"); - const [sortOption, setSortOption] = useState("name-asc"); + const [rows, setRows] = useState([]); + const [totals, setTotals] = useState({ shards: 0, nodes: 0, slots: 0 }); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [sort, setSort] = useState("name-asc"); const router = useRouter(); - const [deletingCluster, setDeletingCluster] = useState(null); useEffect(() => { - const fetchData = async () => { + (async () => { try { - const fetchedNamespaces = await fetchNamespaces(); - - if (!fetchedNamespaces.includes(params.namespace)) { - console.error(`Namespace ${params.namespace} not found`); + const namespaces = await fetchNamespaces(); + if (!namespaces.includes(params.namespace)) { notFound(); return; } - - const clusters = await fetchClusters(params.namespace); - let totalShards = 0; - let totalNodes = 0; - - const data = await Promise.all( - clusters.map(async (cluster) => { - try { - const clusterInfo = await fetchCluster(params.namespace, cluster); - if ( - clusterInfo && - typeof clusterInfo === "object" && - "shards" in clusterInfo - ) { - const shards = (clusterInfo as any).shards || []; - - let clusterNodeCount = 0; - for (let i = 0; i < shards.length; i++) { - try { - const nodes = await listNodes( - params.namespace, - cluster, - i.toString() - ); - if (Array.isArray(nodes)) { - clusterNodeCount += nodes.length; - } - } catch (error) { - console.error( - `Failed to fetch nodes for shard ${i}:`, - error - ); - } - } - - totalShards += shards.length; - totalNodes += clusterNodeCount; - - const hasSlots = shards.some( - (s: any) => s.slot_ranges && s.slot_ranges.length > 0 - ); - const hasMigration = shards.some((s: any) => s.migrating_slot >= 0); - const hasNoMigration = shards.every( - (s: any) => s.migrating_slot === -1 - ); - const hasImporting = shards.some((s: any) => s.import_slot >= 0); - - const slotRanges = - shards.find( - (s: any) => s.slot_ranges && s.slot_ranges.length > 0 - )?.slot_ranges || []; - - const migratingSlot = - shards.find((s: any) => s.migrating_slot >= 0) - ?.migrating_slot || -1; - const importingSlot = - shards.find((s: any) => s.import_slot >= 0)?.import_slot || -1; - const targetShardIndex = - shards.find((s: any) => s.target_shard_index >= 0) - ?.target_shard_index || -1; - - return { - ...clusterInfo, - shards, - shardCount: shards.length, - nodeCount: clusterNodeCount, - hasSlots, - hasMigration, - hasNoMigration, - hasImporting, - slotRanges, - migratingSlot, - importingSlot, - targetShardIndex, - }; - } - return null; - } catch (error) { - console.error(`Failed to fetch data for cluster ${cluster}:`, error); - return null; - } - }) - ); - - const validData = data.filter(Boolean) as ClusterData[]; - setClusterData(validData); - setResourceCounts({ - clusters: validData.length, - shards: totalShards, - nodes: totalNodes, - }); + const { clusters, totals: t } = await buildClusterData(params.namespace); + setRows(clusters); + setTotals(t); } catch (error) { console.error("Error fetching data:", error); } finally { setLoading(false); } - }; - - fetchData(); + })(); }, [params.namespace, router]); - const handleDeleteCluster = async (clusterName: string) => { - if (!confirm(`Are you sure you want to delete cluster "${clusterName}"?`)) return; - + const handleDelete = async (clusterName: string) => { + if (!confirm(`Delete cluster "${clusterName}"?`)) return; try { - setDeletingCluster(clusterName); + setDeleting(clusterName); const res = await deleteCluster(params.namespace, clusterName); if (res) { alert(`Failed to delete cluster: ${res}`); return; } - - // Re-fetch clusters and rebuild data setLoading(true); - const clusters = await fetchClusters(params.namespace); - let totalShards = 0; - let totalNodes = 0; - - const data = await Promise.all( - clusters.map(async (cluster) => { - try { - const clusterInfo = await fetchCluster(params.namespace, cluster); - if ( - clusterInfo && - typeof clusterInfo === "object" && - "shards" in clusterInfo - ) { - const shards = (clusterInfo as any).shards || []; - - let clusterNodeCount = 0; - for (let i = 0; i < shards.length; i++) { - try { - const nodes = await listNodes( - params.namespace, - cluster, - i.toString() - ); - if (Array.isArray(nodes)) { - clusterNodeCount += nodes.length; - } - } catch (error) { - console.error(`Failed to fetch nodes for shard ${i}:`, error); - } - } - - totalShards += shards.length; - totalNodes += clusterNodeCount; - - const hasSlots = shards.some( - (s: any) => s.slot_ranges && s.slot_ranges.length > 0 - ); - const hasMigration = shards.some((s: any) => s.migrating_slot >= 0); - const hasNoMigration = shards.every( - (s: any) => s.migrating_slot === -1 - ); - const hasImporting = shards.some((s: any) => s.import_slot >= 0); - - const slotRanges = - shards.find((s: any) => s.slot_ranges && s.slot_ranges.length > 0) - ?.slot_ranges || []; - const migratingSlot = - shards.find((s: any) => s.migrating_slot >= 0)?.migrating_slot || - -1; - const importingSlot = - shards.find((s: any) => s.import_slot >= 0)?.import_slot || -1; - const targetShardIndex = - shards.find((s: any) => s.target_shard_index >= 0) - ?.target_shard_index || -1; - - return { - ...clusterInfo, - shards, - shardCount: shards.length, - nodeCount: clusterNodeCount, - hasSlots, - hasMigration, - hasNoMigration, - hasImporting, - slotRanges, - migratingSlot, - importingSlot, - targetShardIndex, - } as ClusterData; - } - return null; - } catch (error) { - console.error(`Failed to fetch data for cluster ${cluster}:`, error); - return null; - } - }) - ); - - const validData = data.filter(Boolean) as ClusterData[]; - setClusterData(validData); - setResourceCounts({ - clusters: validData.length, - shards: totalShards, - nodes: totalNodes, - }); + const { clusters, totals: t } = await buildClusterData(params.namespace); + setRows(clusters); + setTotals(t); } catch (e) { alert(`Failed to delete cluster: ${e}`); } finally { - setDeletingCluster(null); + setDeleting(null); setLoading(false); } }; - const handleFilterClick = (event: React.MouseEvent) => { - setFilterAnchorEl(event.currentTarget); - }; - - const handleSortClick = (event: React.MouseEvent) => { - setSortAnchorEl(event.currentTarget); - }; - - const handleFilterClose = () => { - setFilterAnchorEl(null); - }; - - const handleSortClose = () => { - setSortAnchorEl(null); - }; - - const filteredAndSortedClusters = clusterData - .filter((cluster) => { - if (!cluster.name.toLowerCase().includes(searchTerm.toLowerCase())) { - return false; - } - - switch (filterOption) { - case "with-migration": - return cluster.hasMigration; - case "no-migration": - return !cluster.hasMigration; - case "with-slots": - return cluster.hasSlots; - case "no-slots": - return !cluster.hasSlots; - case "with-importing": - return cluster.hasImporting; - default: - return true; - } - }) - .sort((a, b) => { - switch (sortOption) { - case "name-asc": - return a.name.localeCompare(b.name); - case "name-desc": - return b.name.localeCompare(a.name); - case "shards-desc": - return b.shardCount - a.shardCount; - case "shards-asc": - return a.shardCount - b.shardCount; - case "nodes-desc": - return b.nodeCount - a.nodeCount; - case "nodes-asc": - return a.nodeCount - b.nodeCount; - default: - return 0; - } - }); + const filtered = useMemo(() => { + return rows + .filter((c) => { + if (!c.name.toLowerCase().includes(search.toLowerCase())) return false; + switch (filter) { + case "with-migration": + return c.hasMigration; + case "no-migration": + return !c.hasMigration; + case "with-slots": + return c.slotCount > 0; + case "no-slots": + return c.slotCount === 0; + case "with-importing": + return c.hasImporting; + default: + return true; + } + }) + .sort((a, b) => { + switch (sort) { + case "name-asc": + return a.name.localeCompare(b.name); + case "name-desc": + return b.name.localeCompare(a.name); + case "shards-desc": + return b.shardCount - a.shardCount; + case "shards-asc": + return a.shardCount - b.shardCount; + case "nodes-desc": + return b.nodeCount - a.nodeCount; + case "nodes-asc": + return a.nodeCount - b.nodeCount; + case "coverage-desc": + return b.slotCount - a.slotCount; + case "coverage-asc": + return a.slotCount - b.slotCount; + } + }); + }, [rows, search, filter, sort]); - const isFilterOpen = Boolean(filterAnchorEl); - const isSortOpen = Boolean(sortAnchorEl); - const filterId = isFilterOpen ? "filter-popover" : undefined; - const sortId = isSortOpen ? "sort-popover" : undefined; + if (loading) return ; - if (loading) { - return ; - } + const migrating = rows.filter((r) => r.hasMigration); + const importing = rows.filter((r) => r.hasImporting); + const hasActivity = migrating.length > 0 || importing.length > 0; + const coveragePct = Math.min(100, Math.round((totals.slots / TOTAL_SLOTS) * 100)); + const withSlotsCount = rows.filter((r) => r.slotCount > 0).length; return ( -
-
- -
-
- -
-
- - - {params.namespace} - - - Manage clusters in this namespace - -
- -
-
- -
- -
- setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
+ }> + } + title={params.namespace} + subtitle={ + rows.length === 0 + ? "No clusters yet" + : formatSummary(rows.length, totals.shards, totals.nodes, coveragePct) + } + actions={ + + } + /> + +
+ {hasActivity && ( + + )} + + {rows.length === 0 ? ( +
+
+
+
- -
- - - - - - +
+ Add your first cluster
-
-
- -
- - - -
- - - -
- - {resourceCounts.clusters} - - - Clusters - -
-
-
-
-
- - - -
- - - -
- - {resourceCounts.shards} - - - Shards - -
-
-
-
-
- - - -
- - - -
- - {resourceCounts.nodes} - - - Nodes - -
-
-
-
-
- - - -
- - - -
- - {clusterData.filter((c) => c.hasSlots).length} - - - With Slots - -
-
-
-
-
-
-
- - -
-
- - All Clusters - -
- - - - - - - - - - -
+

+ A cluster is a set of shards that hold your data. Start fresh, or + import an existing Kvrocks deployment. +

+
+ } + /> + } + />
- - -
-
- - Filter Clusters - -
- - - setFilterOption(e.target.value as FilterOption) - } +
+ ) : ( +
+
+
+

-
- - - -
- } - /> - } - label={ -
- - All clusters - - -
- } - className="m-0 w-full" - /> - - - - - -

- } - /> - } - label={ -
- - With migration - - - cluster.hasMigration - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> - - - - - -
- } - /> - } - label={ -
- - No migration - - - !cluster.hasMigration - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> - - - - - -
- } - /> - } - label={ -
- - With slots - - cluster.hasSlots - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> - - - - - -
- } - /> - } - label={ -
- - No slots - - !cluster.hasSlots - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> - - - - - -
- } - /> - } - label={ -
- - With importing - - - cluster.hasImporting - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> - -
- - -
- -
+ Clusters + + + {filtered.length === rows.length + ? rows.length + : `${filtered.length} of ${rows.length}`} +
- - - -
-
- - Sort Clusters - -
- - setSortOption(e.target.value as SortOption)} - > -
- - - -
- } - /> - } - label={ -
- - - Name A-Z - -
- } - className="m-0 w-full" - /> - - - - - -
- } - /> - } - label={ -
- - - Name Z-A - -
- } - className="m-0 w-full" - /> - - - - - -
- } - /> - } - label={ -
- - - Most shards - -
- } - className="m-0 w-full" - /> -
- - - - -
- } - /> - } - label={ -
- - - Most nodes - -
- } - className="m-0 w-full" - /> - -
- - -
- -
+
+ } + value={filter} + onChange={setFilter} + options={[ + { value: "all", label: `All (${rows.length})` }, + { + value: "with-migration", + label: `Migrating (${migrating.length})`, + group: "Activity", + }, + { + value: "with-importing", + label: `Importing (${importing.length})`, + group: "Activity", + }, + { + value: "no-migration", + label: `Stable (${rows.length - migrating.length})`, + group: "Activity", + }, + { + value: "with-slots", + label: `Serving slots (${withSlotsCount})`, + group: "Slots", + }, + { + value: "no-slots", + label: `Empty (${rows.length - withSlotsCount})`, + group: "Slots", + }, + ]} + /> + } + value={sort} + onChange={setSort} + options={[ + { value: "name-asc", label: "Name A → Z", group: "Name" }, + { value: "name-desc", label: "Name Z → A", group: "Name" }, + { + value: "shards-desc", + label: "Most shards", + group: "Shards", + }, + { + value: "shards-asc", + label: "Fewest shards", + group: "Shards", + }, + { + value: "nodes-desc", + label: "Most nodes", + group: "Nodes", + }, + { + value: "nodes-asc", + label: "Fewest nodes", + group: "Nodes", + }, + { + value: "coverage-desc", + label: "Most slots", + group: "Coverage", + }, + { + value: "coverage-asc", + label: "Fewest slots", + group: "Coverage", + }, + ]} + />
- - - {filteredAndSortedClusters.length > 0 ? ( -
- {filteredAndSortedClusters.map((cluster) => ( -
- -
- - - - -
-
- -
- - {cluster.name} - - - {cluster.hasMigration && - cluster.migratingSlot >= 0 && ( - -
- - Migrating{" "} - { - cluster.migratingSlot - } - -
- )} - - {(!cluster.hasMigration || - cluster.migratingSlot === - -1) && ( - -
- - Stable - -
- )} - - {cluster.hasImporting && - cluster.importingSlot >= 0 && ( - -
- - Importing{" "} - { - cluster.importingSlot - } - -
- )} -
- - - - Version: {cluster.version} - - {cluster.hasSlots && ( - - Slots:{" "} - {cluster.slotRanges.length > 2 - ? `${cluster.slotRanges.slice(0, 2).join(", ")}, ...` - : cluster.slotRanges.join( - ", " - )} - - )} - -
- -
- } - label={`${cluster.shardCount} shards`} - size="small" - color="secondary" - variant="outlined" - className="whitespace-nowrap" - sx={{ borderRadius: "12px" }} - /> - - - } - label={`${cluster.nodeCount} nodes`} - size="small" - color="default" - variant="outlined" - className="whitespace-nowrap" - sx={{ borderRadius: "12px" }} - /> - - {!cluster.hasSlots && ( - } - label="No slots" - size="small" - color="info" - variant="outlined" - className="whitespace-nowrap" - sx={{ borderRadius: "12px" }} - /> - )} - - {cluster.targetShardIndex >= 0 && ( - - )} -
- -
- - - Shards - - - {cluster.shardCount} - - - - - - Nodes - - - {cluster.nodeCount} - - -
+
-
- - - - -
-
-
- -
+ {filtered.length > 0 ? ( +
    + {filtered.map((cluster) => ( +
  • + +
  • ))} - +
) : ( -
+
} - action={{ - label: "Create Cluster", - onClick: () => {}, - }} + icon={} /> -
- -
)} + + )} +
+ + ); +} - {filteredAndSortedClusters.length > 0 && ( -
- - Showing {filteredAndSortedClusters.length} of{" "} - {clusterData.length} clusters - -
- )} - - +function ActivityStrip({ + migrating, + importing, + namespace, +}: { + migrating: ClusterData[]; + importing: ClusterData[]; + namespace: string; +}) { + const bits: string[] = []; + if (migrating.length > 0) + bits.push( + `${migrating.length} ${migrating.length === 1 ? "cluster" : "clusters"} migrating`, + ); + if (importing.length > 0) + bits.push( + `${importing.length} ${importing.length === 1 ? "cluster" : "clusters"} importing`, + ); + + const active = [ + ...migrating, + ...importing.filter((c) => !migrating.some((m) => m.name === c.name)), + ].slice(0, 5); + + return ( +
+
+
+ +
+
+
+ Slot movement in progress +
+
+ {bits.join(" · ")} +
+
+
+
+ {active.map((c) => ( + + + {c.name} + + ))}
); } + +function ClusterRow({ + cluster, + namespace, + onDelete, + deleting, +}: { + cluster: ClusterData; + namespace: string; + onDelete: (name: string) => void; + deleting: boolean; +}) { + const coverage = Math.min(100, Math.round((cluster.slotCount / TOTAL_SLOTS) * 100)); + const state: "migrating" | "importing" | "empty" | "stable" = cluster.hasMigration + ? "migrating" + : cluster.hasImporting + ? "importing" + : cluster.slotCount === 0 + ? "empty" + : "stable"; + + const dotClass = + state === "migrating" + ? "bg-warning animate-pulse" + : state === "importing" + ? "bg-info animate-pulse" + : state === "empty" + ? "bg-text-muted/40 dark:bg-text-dark-muted/40" + : "bg-success"; + + const barClass = + state === "empty" + ? "bg-text-muted/25 dark:bg-text-dark-muted/25" + : state === "migrating" + ? "bg-warning/80" + : state === "importing" + ? "bg-info/80" + : "bg-primary/80"; + + const stateLabel = + state === "migrating" + ? "Migrating" + : state === "importing" + ? "Importing" + : state === "empty" + ? "No slots" + : "Stable"; + + return ( + + + +
+
+ + {cluster.name} + + {cluster.version && ( + + v{cluster.version} + + )} + {cluster.hasMigration && ( + + + slot {cluster.migratingSlot} + + )} + {cluster.hasImporting && ( + + + slot {cluster.importingSlot} + + )} +
+
+ + + {cluster.shardCount} + {" "} + {cluster.shardCount === 1 ? "shard" : "shards"} + + + · + + + + {cluster.nodeCount} + {" "} + {cluster.nodeCount === 1 ? "node" : "nodes"} + + + · + + + + {cluster.slotCount.toLocaleString()} + {" "} + {cluster.slotCount === 1 ? "slot" : "slots"} + +
+
+ +
+
+
+
+
+ {stateLabel} + + {coverage}% + +
+
+ +
+ { + e.preventDefault(); + e.stopPropagation(); + onDelete(cluster.name); + }} + disabled={deleting} + aria-label={`Delete cluster ${cluster.name}`} + sx={{ + width: 26, + height: 26, + opacity: 0.6, + "&:hover": { color: "error.main", opacity: 1 }, + }} + > + + + +
+ + ); +} diff --git a/webui/src/app/namespaces/page.tsx b/webui/src/app/namespaces/page.tsx index 93384713..05c04762 100644 --- a/webui/src/app/namespaces/page.tsx +++ b/webui/src/app/namespaces/page.tsx @@ -19,61 +19,42 @@ "use client"; -import { - Container, - Typography, - Paper, - Box, - Tooltip, - Chip, - Divider, - Grid, - Button, - Card, - CardContent, - IconButton, -} from "@mui/material"; -import { NamespaceSidebar } from "../ui/sidebar"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { useState, useEffect, useRef } from "react"; -import { deleteNamespace, fetchClusters, fetchNamespaces, listShards, listNodes } from "../lib/api"; -import { LoadingSpinner } from "../ui/loadingSpinner"; -import { NamespaceCreation } from "../ui/formCreation"; -import Link from "next/link"; +import { Chip } from "@mui/material"; import FolderIcon from "@mui/icons-material/Folder"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; -import EmptyState from "../ui/emptyState"; import StorageIcon from "@mui/icons-material/Storage"; -import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import AccessTimeIcon from "@mui/icons-material/AccessTime"; -import DeleteIcon from "@mui/icons-material/Delete"; -import InfoIcon from "@mui/icons-material/Info"; import DnsIcon from "@mui/icons-material/Dns"; import DeviceHubIcon from "@mui/icons-material/DeviceHub"; -import EqualizerIcon from "@mui/icons-material/Equalizer"; -import AddIcon from "@mui/icons-material/Add"; -import SearchIcon from "@mui/icons-material/Search"; -import SortIcon from "@mui/icons-material/Sort"; -import FilterListIcon from "@mui/icons-material/FilterList"; -import MoreVertIcon from "@mui/icons-material/MoreVert"; -import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; -import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; -import CheckIcon from "@mui/icons-material/Check"; -import Breadcrumb from "../ui/breadcrumb"; -import { Popover, RadioGroup, FormControlLabel, Radio, Fade } from "@mui/material"; -interface ResourceCounts { - namespaces: number; - clusters: number; - shards: number; - nodes: number; -} +import { NamespaceSidebar } from "../ui/sidebar"; +import { + deleteNamespace, + fetchClusters, + fetchNamespaces, + listNodes, + listShards, +} from "../lib/api"; +import { LoadingSpinner } from "../ui/loadingSpinner"; +import EmptyState from "../ui/emptyState"; +import { + FilterListIcon, + FilterSortMenu, + PageHeader, + PageShell, + ResourceRow, + SearchInput, + SortIcon, + StatCard, +} from "../ui/pageChrome"; interface NamespaceData { name: string; clusterCount: number; shardCount: number; nodeCount: number; + loading: boolean; } type FilterOption = "all" | "with-clusters" | "no-clusters"; @@ -85,135 +66,133 @@ type SortOption = | "nodes-desc" | "nodes-asc"; -export default function Namespace() { - const [namespacesData, setNamespacesData] = useState([]); - const [resourceCounts, setResourceCounts] = useState({ - namespaces: 0, - clusters: 0, - shards: 0, - nodes: 0, - }); - const [loading, setLoading] = useState(true); - const [deletingNamespace, setDeletingNamespace] = useState(null); - const [searchTerm, setSearchTerm] = useState(""); - - const [filterAnchorEl, setFilterAnchorEl] = useState(null); - const [sortAnchorEl, setSortAnchorEl] = useState(null); - const [filterOption, setFilterOption] = useState("all"); - const [sortOption, setSortOption] = useState("name-asc"); - +export default function Namespaces() { + const [rows, setRows] = useState([]); + const [totals, setTotals] = useState({ namespaces: 0, clusters: 0, shards: 0, nodes: 0 }); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [sort, setSort] = useState("name-asc"); const router = useRouter(); useEffect(() => { - const fetchData = async () => { - try { - const fetchedNamespaces = await fetchNamespaces(); - - let totalClusters = 0; - let totalShards = 0; - let totalNodes = 0; - const nsData: NamespaceData[] = []; - - for (const namespace of fetchedNamespaces) { - const clusters = await fetchClusters(namespace); - totalClusters += clusters.length; + let cancelled = false; - let namespaceShardCount = 0; - let namespaceNodeCount = 0; - - for (const cluster of clusters) { - const shards = await listShards(namespace, cluster); - if (Array.isArray(shards)) { - namespaceShardCount += shards.length; - totalShards += shards.length; + (async () => { + try { + const namespaces = await fetchNamespaces(); + if (cancelled) return; + + // Render the list immediately with skeleton counts so the page never + // blocks on the deepest fetch. Per-namespace counts populate below. + setRows( + namespaces.map((name) => ({ + name, + clusterCount: 0, + shardCount: 0, + nodeCount: 0, + loading: true, + })), + ); + setTotals({ namespaces: namespaces.length, clusters: 0, shards: 0, nodes: 0 }); + setLoading(false); - for (let i = 0; i < shards.length; i++) { - const nodes = await listNodes(namespace, cluster, i.toString()); - if (Array.isArray(nodes)) { - namespaceNodeCount += nodes.length; - totalNodes += nodes.length; + await Promise.all( + namespaces.map(async (namespace) => { + try { + const clusters = await fetchClusters(namespace); + const shardLists = await Promise.all( + clusters.map((cluster) => listShards(namespace, cluster)), + ); + + let shardCount = 0; + const nodePromises: Promise[] = []; + clusters.forEach((cluster, ci) => { + const shards = shardLists[ci]; + if (Array.isArray(shards)) { + shardCount += shards.length; + shards.forEach((_, i) => + nodePromises.push( + listNodes(namespace, cluster, i.toString()), + ), + ); } + }); + + const nodeLists = await Promise.all(nodePromises); + const nodeCount = nodeLists.reduce( + (acc, nodes) => acc + (Array.isArray(nodes) ? nodes.length : 0), + 0, + ); + + if (cancelled) return; + + setRows((prev) => + prev.map((n) => + n.name === namespace + ? { + ...n, + clusterCount: clusters.length, + shardCount, + nodeCount, + loading: false, + } + : n, + ), + ); + setTotals((prev) => ({ + namespaces: prev.namespaces, + clusters: prev.clusters + clusters.length, + shards: prev.shards + shardCount, + nodes: prev.nodes + nodeCount, + })); + } catch (err) { + console.error(`Failed to load namespace ${namespace}:`, err); + if (!cancelled) { + setRows((prev) => + prev.map((n) => + n.name === namespace ? { ...n, loading: false } : n, + ), + ); } } - } - - nsData.push({ - name: namespace, - clusterCount: clusters.length, - shardCount: namespaceShardCount, - nodeCount: namespaceNodeCount, - }); - } - - setNamespacesData(nsData); - setResourceCounts({ - namespaces: fetchedNamespaces.length, - clusters: totalClusters, - shards: totalShards, - nodes: totalNodes, - }); + }), + ); } catch (error) { console.error("Error fetching namespaces data:", error); - } finally { - setLoading(false); + if (!cancelled) setLoading(false); } - }; + })(); - fetchData(); + return () => { + cancelled = true; + }; }, [router]); - const handleDeleteNamespace = async (namespace: string) => { - if (confirm(`Are you sure you want to delete namespace "${namespace}"?`)) { - try { - setDeletingNamespace(namespace); - await deleteNamespace(namespace); - setNamespacesData(namespacesData.filter((ns) => ns.name !== namespace)); - setResourceCounts((prev) => ({ - ...prev, - namespaces: prev.namespaces - 1, - })); - } catch (error) { - console.error(`Error deleting namespace ${namespace}:`, error); - alert(`Failed to delete namespace: ${error}`); - } finally { - setDeletingNamespace(null); - } + const handleDelete = async (name: string) => { + if (!confirm(`Delete namespace "${name}"?`)) return; + try { + setDeleting(name); + await deleteNamespace(name); + setRows((prev) => prev.filter((n) => n.name !== name)); + setTotals((prev) => ({ ...prev, namespaces: prev.namespaces - 1 })); + } catch (e) { + alert(`Failed to delete namespace: ${e}`); + } finally { + setDeleting(null); } }; - const handleFilterClick = (event: React.MouseEvent) => { - setFilterAnchorEl(event.currentTarget); - }; - - const handleSortClick = (event: React.MouseEvent) => { - setSortAnchorEl(event.currentTarget); - }; - - const handleFilterClose = () => { - setFilterAnchorEl(null); - }; - - const handleSortClose = () => { - setSortAnchorEl(null); - }; - - const filteredAndSortedNamespaces = namespacesData - .filter((ns) => { - if (!ns.name.toLowerCase().includes(searchTerm.toLowerCase())) { - return false; - } - - switch (filterOption) { - case "with-clusters": - return ns.clusterCount > 0; - case "no-clusters": - return ns.clusterCount === 0; - default: - return true; - } + const filtered = rows + .filter((n) => { + if (!n.name.toLowerCase().includes(search.toLowerCase())) return false; + if (filter === "with-clusters") return n.clusterCount > 0; + if (filter === "no-clusters") return n.clusterCount === 0; + return true; }) .sort((a, b) => { - switch (sortOption) { + switch (sort) { case "name-asc": return a.name.localeCompare(b.name); case "name-desc": @@ -226,948 +205,170 @@ export default function Namespace() { return b.nodeCount - a.nodeCount; case "nodes-asc": return a.nodeCount - b.nodeCount; - default: - return 0; } }); - const isFilterOpen = Boolean(filterAnchorEl); - const isSortOpen = Boolean(sortAnchorEl); - const filterId = isFilterOpen ? "filter-popover" : undefined; - const sortId = isSortOpen ? "sort-popover" : undefined; - - if (loading) { - return ; - } + if (loading) return ; return ( -
-
- -
-
- -
-
- - Namespaces - - - Manage your Kvrocks database namespaces - -
- -
-
-
-
- -
- setSearchTerm(e.target.value)} - /> - {searchTerm && ( - - )} -
-
- -
- - - -
+ }> + + } + /> + +
+
+ } + accent="primary" + /> + } + accent="info" + /> + } + accent="success" + /> + } + accent="warning" + /> +
+ +
+
+
All namespaces
+
+ } + value={filter} + onChange={setFilter} + options={[ + { value: "all", label: `All (${rows.length})` }, + { + value: "with-clusters", + label: `With clusters (${rows.filter((r) => r.clusterCount > 0).length})`, + }, + { + value: "no-clusters", + label: `Empty (${rows.filter((r) => r.clusterCount === 0).length})`, + }, + ]} + /> + } + value={sort} + onChange={setSort} + options={[ + { value: "name-asc", label: "Name A → Z", group: "Name" }, + { value: "name-desc", label: "Name Z → A", group: "Name" }, + { + value: "clusters-desc", + label: "Most clusters", + group: "Clusters", + }, + { + value: "clusters-asc", + label: "Fewest clusters", + group: "Clusters", + }, + { value: "nodes-desc", label: "Most nodes", group: "Nodes" }, + { value: "nodes-asc", label: "Fewest nodes", group: "Nodes" }, + ]} + />
-
- - - -
-
- -
-
- - {resourceCounts.namespaces} - - - Namespaces - -
-
-
-
-
- - - -
-
- -
-
- - {resourceCounts.clusters} - - - Clusters - -
-
-
-
-
- - - -
-
- -
-
- - {resourceCounts.shards} - - - Shards - -
-
-
-
-
- - - -
-
- -
-
- - {resourceCounts.nodes} - - - Nodes - -
-
-
-
-
-
-
- - -
-
- - All Namespaces - -
- - - - - - - - - - -
-
-
- - -
-
- - Filter Namespaces - -
- - - setFilterOption(e.target.value as FilterOption) - } - > -
- - 0 ? ( +
    + {filtered.map((ns) => ( +
  • + } + title={ns.name} + subtitle={ + ns.loading ? ( + + + + ) : ( + `${ns.clusterCount} clusters · ${ns.shardCount} shards · ${ns.nodeCount} nodes` + ) + } + badges={ + ns.loading ? null : ( + <> + - -
- } /> - } - label={ -
- - All namespaces - - -
- } - className="m-0 w-full" - /> - - -
- - -
- } /> - } - label={ -
- - With clusters - - ns.clusterCount > 0 - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> -
- - - - -
- } /> - } - label={ -
- - No clusters - - ns.clusterCount === 0 - ).length - } - className="ml-2" - sx={{ height: 20, fontSize: "0.7rem" }} - /> -
- } - className="m-0 w-full" - /> - -
- - -
- -
-
- - - -
-
- - Sort By - -
- - setSortOption(e.target.value as SortOption)} - > -
-
- Name -
-
- - - -
- } - /> - } - label={ -
- - - A to Z - -
- } - className="m-0 w-full" - /> - - - - -
- } - /> - } - label={ -
- - - Z to A - -
- } - className="m-0 w-full" - /> - -
- -
- Clusters -
-
- - - -
- } - /> - } - label={ -
- - - Most clusters - -
- } - className="m-0 w-full" - /> - - - - -
- } - /> - } - label={ -
- - - Fewest clusters - -
- } - className="m-0 w-full" - /> -
-
- -
- Nodes -
-
- - - -
- } - /> - } - label={ -
- - - Most nodes - -
- } - className="m-0 w-full" - /> - - - - -
- } - /> - } - label={ -
- - - Fewest nodes - -
- } - className="m-0 w-full" - /> - -
- - - -
- -
- - - - {filteredAndSortedNamespaces.length > 0 ? ( -
- {filteredAndSortedNamespaces.map((nsData) => ( -
- -
-
- -
- -
-
- - - {nsData.name} - - - - Created recently - - -
- -
- } - label={`${nsData.clusterCount} clusters`} - size="small" - color="primary" - variant="outlined" - className="whitespace-nowrap" - style={{ borderRadius: "12px" }} - /> - - } - label={`${nsData.shardCount} shards`} - size="small" - color="secondary" - variant="outlined" - className="whitespace-nowrap" - style={{ borderRadius: "12px" }} - /> - - - } - label={`${nsData.nodeCount} nodes`} - size="small" - color="default" - variant="outlined" - className="whitespace-nowrap" - style={{ borderRadius: "12px" }} - /> -
- -
-
- - Clusters - - - {nsData.clusterCount} - -
- -
- - Shards - - - {nsData.shardCount} - -
- -
- - Nodes - - - {nsData.nodeCount} - -
-
- -
- - - - - -
-
-
-
-
- ))} -
- ) : ( -
- - } - action={{ - label: "Create Namespace", - onClick: () => - document - .getElementById("create-namespace-btn") - ?.click(), - }} - /> -
- - - -
-
- )} + + ) + } + href={`/namespaces/${ns.name}`} + onDelete={() => handleDelete(ns.name)} + deleteDisabled={deleting === ns.name} + /> + + ))} + + ) : ( +
+ } + /> +
+ )} - {filteredAndSortedNamespaces.length > 0 && ( -
- - Showing {filteredAndSortedNamespaces.length} of{" "} - {namespacesData.length} namespaces - {filterOption !== "all" && " (filtered)"} - -
- )} - - + {filtered.length > 0 && ( +
+ Showing {filtered.length} of {rows.length} + {filter !== "all" && " (filtered)"} +
+ )} + - + ); } diff --git a/webui/src/app/not-found.tsx b/webui/src/app/not-found.tsx index 851b6e8a..94f3471c 100644 --- a/webui/src/app/not-found.tsx +++ b/webui/src/app/not-found.tsx @@ -19,9 +19,8 @@ "use client"; -import { Button, Typography, Box } from "@mui/material"; +import { Button } 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"; @@ -30,42 +29,35 @@ 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. - - -
+
+
+
404
+

+ Page not found +

+

+ The page you're looking for doesn't exist or has been moved. +

+
-
- +
); } diff --git a/webui/src/app/page.tsx b/webui/src/app/page.tsx index 64e157eb..c65185f2 100644 --- a/webui/src/app/page.tsx +++ b/webui/src/app/page.tsx @@ -19,832 +19,264 @@ "use client"; -import React, { useCallback, useMemo } from "react"; -import mainImage from "./main.png"; -import { - Button, - Typography, - Box, - Grid, - Chip, - Card, - CardContent, - Container, - useMediaQuery, - useTheme as useMuiTheme, -} from "@mui/material"; -import Image from "next/image"; import { useRouter } from "next/navigation"; -import { useTheme } from "./theme-provider"; +import { Button } from "@mui/material"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import GitHubIcon from "@mui/icons-material/GitHub"; +import LaunchIcon from "@mui/icons-material/Launch"; import StorageIcon from "@mui/icons-material/Storage"; import SyncIcon from "@mui/icons-material/Sync"; import SecurityIcon from "@mui/icons-material/Security"; import HubIcon from "@mui/icons-material/Hub"; -import GitHubIcon from "@mui/icons-material/GitHub"; -import LaunchIcon from "@mui/icons-material/Launch"; -import MenuBookIcon from "@mui/icons-material/MenuBook"; -import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; -import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; import SpeedIcon from "@mui/icons-material/Speed"; import CloudIcon from "@mui/icons-material/Cloud"; -import TerminalIcon from "@mui/icons-material/Terminal"; +import MenuBookIcon from "@mui/icons-material/MenuBook"; import Link from "next/link"; -import { useEffect, useState, useRef } from "react"; + +const features = [ + { + title: "Namespaces", + description: "Isolate tenants with per-namespace auth tokens.", + icon: , + }, + { + title: "Replication", + description: "Binlog-based async replication, like MySQL.", + icon: , + }, + { + title: "High availability", + description: "Automatic failover for master and replica nodes.", + icon: , + }, + { + title: "Cluster", + description: "Centralized control, standard Redis cluster clients.", + icon: , + }, +]; + +const benefits = [ + { + title: "Enterprise-grade reliability", + description: "Fault-tolerant behaviour for mission-critical workloads.", + icon: , + }, + { + title: "High-performance storage", + description: "RocksDB engine tuned for low-latency access.", + icon: , + }, + { + title: "Simplified operations", + description: "One interface for the full topology, top to bottom.", + icon: , + }, + { + title: "Redis-compatible", + description: "Works with the redis-cli and existing Redis clients.", + icon: , + }, +]; + +const resources = [ + { + title: "Documentation", + description: "Reference for configuration, deployment, and commands.", + href: "https://kvrocks.apache.org/docs/", + icon: , + }, + { + title: "GitHub repository", + description: "Source, issues, and releases.", + href: "https://github.com/apache/kvrocks-controller", + icon: , + }, +]; + +const terminalLines = [ + { text: "$ redis-cli -p 6666", tone: "text-emerald-400" }, + { text: '127.0.0.1:6666> SET mykey "Hello Kvrocks"', tone: "text-primary-light" }, + { text: "OK", tone: "text-amber-300" }, + { text: "127.0.0.1:6666> GET mykey", tone: "text-primary-light" }, + { text: '"Hello Kvrocks"', tone: "text-amber-300" }, + { text: "127.0.0.1:6666> INFO", tone: "text-primary-light" }, + { text: "# Server", tone: "text-amber-200" }, + { text: "kvrocks_version:unstable", tone: "text-amber-300" }, +]; export default function Home() { const router = useRouter(); - const { isDarkMode } = useTheme(); - const muiTheme = useMuiTheme(); - const isMobile = useMediaQuery(muiTheme.breakpoints.down("md")); - const [isLoaded, setIsLoaded] = useState(true); - const [scrollY, setScrollY] = useState(0); - const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 }); - const [cursorVisible, setCursorVisible] = useState(true); - const requestRef = useRef(undefined); - const prevScrollY = useRef(0); - - const terminalRef = useRef({ lineIndex: 0, charIndex: 0 }); - - const terminalLines = useMemo( - () => [ - { text: "$ redis-cli -p 6666", className: "text-green-400" }, - { text: '127.0.0.1:6666> SET mykey "Hello Kvrocks"', className: "text-blue-400" }, - { text: "OK", className: "text-yellow-400" }, - { text: "127.0.0.1:6666> GET mykey", className: "text-blue-400" }, - { text: '"Hello Kvrocks"', className: "text-yellow-400" }, - { text: "127.0.0.1:6666> INFO", className: "text-blue-400" }, - { text: "# Server", className: "text-yellow-200" }, - { text: "kvrocks_version:unstable", className: "text-yellow-400" }, - { text: "kvrocks_git_sha1:fffffff", className: "text-yellow-400" }, - ], - [] - ); - - const handleScroll = useCallback(() => { - if (Math.abs(window.scrollY - prevScrollY.current) > 5) { - setScrollY(window.scrollY); - prevScrollY.current = window.scrollY; - } - requestRef.current = requestAnimationFrame(handleScroll); - }, []); - - useEffect(() => { - requestRef.current = requestAnimationFrame(handleScroll); - - const cursorInterval = setInterval(() => { - setCursorVisible((prev) => !prev); - }, 800); - - const typeNextChar = () => { - const { lineIndex, charIndex } = terminalRef.current; - - if (lineIndex < terminalLines.length) { - const line = terminalLines[lineIndex]; - - if (charIndex <= line.text.length) { - setCursorPosition({ - x: charIndex, - y: lineIndex, - }); - terminalRef.current.charIndex++; - } else { - terminalRef.current.lineIndex++; - terminalRef.current.charIndex = 0; - } - } - }; - - const typeInterval = setInterval(typeNextChar, 50); - - return () => { - if (requestRef.current) { - cancelAnimationFrame(requestRef.current); - } - clearInterval(cursorInterval); - clearInterval(typeInterval); - }; - }, [handleScroll, terminalLines]); - - const features = useMemo( - () => [ - { - title: "Namespace", - description: "Similar to Redis SELECT but equipped with token per namespace", - icon: , - color: isDarkMode ? "#42a5f5" : "#1976d2", - delay: 100, - }, - { - title: "Replication", - description: "Async replication using binlog like MySQL", - icon: , - color: isDarkMode ? "#ba68c8" : "#9c27b0", - delay: 200, - }, - { - title: "High Availability", - description: "Support Redis sentinel to failover when master or replica was failed", - icon: , - color: isDarkMode ? "#4caf50" : "#2e7d32", - delay: 300, - }, - { - title: "Cluster", - description: "Centralized management but accessible via any Redis cluster client", - icon: , - color: isDarkMode ? "#ff9800" : "#ed6c02", - delay: 400, - }, - ], - [isDarkMode] - ); - - const resources = useMemo( - () => [ - { - title: "Documentation", - description: "Learn how to use Kvrocks Controller", - icon: , - url: "https://kvrocks.apache.org/docs/", - color: "#1976d2", - }, - { - title: "GitHub Repository", - description: "View the source code on GitHub", - icon: , - url: "https://github.com/apache/kvrocks-controller", - color: "#333333", - }, - ], - [] - ); - - const benefits = useMemo( - () => [ - { - title: "Enterprise-grade reliability", - description: "Built to handle mission-critical workloads with fault tolerance", - icon: , - }, - { - title: "High performance data access", - description: "Optimized for speed with RocksDB as the storage engine", - icon: , - }, - { - title: "Simplified cluster management", - description: "Intuitive UI for managing your distributed database infrastructure", - icon: , - }, - { - title: "Seamless Redis compatibility", - description: "Works with existing Redis clients and tools", - icon: , - }, - ], - [] - ); - - const heroParallax = { - transform: `translate3d(0, ${scrollY * 0.3}px, 0)`, - willChange: "transform", - transition: "transform 0.05s linear", - }; - - const featureSectionStyle = { - opacity: 1, - transform: `translate3d(0, ${Math.min(0, (scrollY - 300) * 0.05)}px, 0)`, - willChange: "transform", - }; - - const handleGetStarted = useCallback(() => router.push("/namespaces"), [router]); return ( -
- {/* Hero Section */} -
-
-
- -
-
- - - - - - - - -
-
- -
-
-
- - - - +
+
+ + + Apache Software Foundation + +

+ Apache Kvrocks + + Controller + +

+

+ A distributed key-value NoSQL database built on RocksDB, compatible with the + Redis protocol. This controller manages your Kvrocks clusters at scale. +

+
+ - - -
- -
-
-
-
-
- - Compatible with Redis protocol - -
-
- - - - + +
+
-
-
- - Scroll to explore - - - - -
+
+
+ + + + + redis-cli +
- +
+                        {terminalLines.map((line, i) => (
+                            
+ {line.text} +
+ ))} +
+
-
-
-
+
+
+
Features
+

+ Built for production topologies +

- - - +
+ {features.map((f) => (
- Powerful Features -
- - - Why Choose Kvrocks? - - -
- - Apache Kvrocks offers powerful capabilities that make it an - excellent choice for your database needs - +
+ {f.icon} +
+
+ {f.title} +
+

+ {f.description} +

- - - - {features.map((feature, index) => ( - - - -
- {feature.icon} -
- - - {feature.title} - - - - {feature.description} - -
-
-
- ))} -
- + ))} +
-
-
- - - - - - - - +
+
+
Why Kvrocks
+

+ Simplified management, without compromise +

- - - +
+ {benefits.map((b) => (
- Powerful Management +
+ {b.icon} +
+
+
+ {b.title} +
+

+ {b.description} +

+
- - - About Kvrocks Controller - - - - - - - - - - Simplified Management - - -
- - - Kvrocks Controller provides a web management interface - for Apache Kvrocks clusters, enabling efficient - distribution, monitoring, and maintenance of your Redis - compatible database infrastructure. - -
- -
- {benefits.map((benefit, index) => ( -
-
- {benefit.icon} -
-
- - {benefit.title} - - - {benefit.description} - -
-
- ))} -
-
-
- - - -
-
-
-
-
-
-
- - Terminal -
-
- -
-
-
- $ redis-cli -p 6666 -
-
- 127.0.0.1:6666> SET mykey "Hello - Kvrocks" -
-
OK
-
- 127.0.0.1:6666> GET mykey -
-
- "Hello Kvrocks" -
-
- 127.0.0.1:6666> INFO -
-
# Server
-
- redis_version:6.0.0 -
-
- kvrocks_version:2.0.0 -
-
- 127.0.0.1:6666> KEYS * -
-
- 1) "mykey" -
-
- 127.0.0.1:6666> _ -
-
- -
-
- -
- - Compatible with Redis protocol - - } - label="Ready" - size="small" - color="success" - className="bg-success/80 transition-colors hover:bg-success" - style={{ borderRadius: "12px" }} - /> -
-
-
-
-
-
- + ))} +
-
-
-
+
+
+
Resources
+

+ Docs and source +

- - - - + {resources.map((r) => ( + - Resources - - -
- - Explore documentation and source code to get the most out of Kvrocks - Controller - -
-
- - - {resources.map((resource, index) => ( - - - - -
- {React.cloneElement(resource.icon, { - sx: { fontSize: 28 }, - })} -
- - - {resource.title} - - - - - {resource.description} - -
-
- -
- ))} -
-
-
- -
-
-
- -
-
+
+ {r.icon} +
+
+
+ {r.title} + +
+

+ {r.description} +

+
+ + ))}
+
- - +

+ Ready to manage your fleet? +

+

+ Open the dashboard to inspect namespaces, clusters, shards, and nodes — with + keyboard-first navigation. +

+
+ - - - + Open dashboard + +
); diff --git a/webui/src/app/theme-provider.tsx b/webui/src/app/theme-provider.tsx index 2eddc71e..3f08d6b8 100644 --- a/webui/src/app/theme-provider.tsx +++ b/webui/src/app/theme-provider.tsx @@ -19,7 +19,7 @@ "use client"; -import { createContext, useContext, useEffect, useState } from "react"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; import { ThemeProvider as MuiThemeProvider, createTheme } from "@mui/material/styles"; import CssBaseline from "@mui/material/CssBaseline"; @@ -35,78 +35,390 @@ const ThemeContext = createContext({ export const useTheme = () => useContext(ThemeContext); +// Linear-style tokens — must stay in sync with tailwind.config.ts and globals.css. +const tokens = { + light: { + background: "#ffffff", + surface: "#fbfbfc", + surfaceMuted: "#f4f5f8", + border: "#e6e7eb", + borderStrong: "#d0d3d9", + textPrimary: "#0d0e10", + textSecondary: "#4b4f57", + textMuted: "#6b7280", + accent: "#5e6ad2", + accentHover: "#4f5abd", + }, + dark: { + background: "#08090a", + surface: "#101113", + surfaceMuted: "#181a1f", + border: "#23252a", + borderStrong: "#33363d", + textPrimary: "#f7f8f8", + textSecondary: "#b4b8c0", + textMuted: "#8a8f98", + accent: "#7079e0", + accentHover: "#8d95f2", + }, +}; + export function ThemeProvider({ children }: { children: React.ReactNode }) { const [isDarkMode, setIsDarkMode] = useState(false); - const [mounted, setMounted] = 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; - const shouldBeDark = storedTheme === "dark" || (!storedTheme && prefersDark); - // Apply dark mode styles if (shouldBeDark) { document.documentElement.classList.add("dark"); - // Also add the special class to navbar if it exists - document.getElementById("navbar")?.classList.add("navbar-dark-mode"); } else { document.documentElement.classList.remove("dark"); - document.getElementById("navbar")?.classList.remove("navbar-dark-mode"); } - setIsDarkMode(shouldBeDark); - setMounted(true); }, []); const toggleTheme = () => { setIsDarkMode((prev) => { - const newMode = !prev; - if (newMode) { + const next = !prev; + if (next) { document.documentElement.classList.add("dark"); - document.getElementById("navbar")?.classList.add("navbar-dark-mode"); localStorage.setItem("theme", "dark"); } else { document.documentElement.classList.remove("dark"); - document.getElementById("navbar")?.classList.remove("navbar-dark-mode"); localStorage.setItem("theme", "light"); } - return newMode; + return next; }); }; - // Create MUI theme based on current mode - const theme = createTheme({ - palette: { - mode: isDarkMode ? "dark" : "light", - primary: { - main: "#1976d2", - light: "#42a5f5", - dark: "#1565c0", - contrastText: "#fff", + const theme = useMemo(() => { + const t = isDarkMode ? tokens.dark : tokens.light; + + return createTheme({ + palette: { + mode: isDarkMode ? "dark" : "light", + primary: { + main: t.accent, + light: isDarkMode ? "#8d95f2" : "#8d95f2", + dark: t.accentHover, + contrastText: "#ffffff", + }, + secondary: { + main: "#7170ff", + light: "#a5a4ff", + dark: "#5352d9", + contrastText: "#ffffff", + }, + error: { + main: "#eb5757", + }, + warning: { + main: "#f2c94c", + }, + success: { + main: "#4cb782", + }, + info: { + main: "#26b5ce", + }, + background: { + default: t.background, + paper: t.surface, + }, + text: { + primary: t.textPrimary, + secondary: t.textSecondary, + disabled: t.textMuted, + }, + divider: t.border, }, - secondary: { - main: "#9c27b0", - light: "#ba68c8", - dark: "#7b1fa2", - contrastText: "#fff", + shape: { + borderRadius: 6, }, - background: { - default: isDarkMode ? "#121212" : "#fafafa", - paper: isDarkMode ? "#1e1e1e" : "#ffffff", + typography: { + fontFamily: + 'InterVariable, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif', + fontSize: 15, + htmlFontSize: 16, + h1: { fontSize: "2.25rem", fontWeight: 600, letterSpacing: "-0.02em" }, + h2: { fontSize: "1.75rem", fontWeight: 600, letterSpacing: "-0.015em" }, + h3: { fontSize: "1.5rem", fontWeight: 600, letterSpacing: "-0.01em" }, + h4: { fontSize: "1.25rem", fontWeight: 600, letterSpacing: "-0.005em" }, + h5: { fontSize: "1.125rem", fontWeight: 600 }, + h6: { fontSize: "1rem", fontWeight: 600 }, + subtitle1: { fontSize: "0.9375rem", fontWeight: 500 }, + subtitle2: { fontSize: "0.875rem", fontWeight: 500 }, + body1: { fontSize: "0.9375rem", lineHeight: 1.55 }, + body2: { fontSize: "0.875rem", lineHeight: 1.55, color: t.textSecondary }, + button: { fontSize: "0.875rem", fontWeight: 500, textTransform: "none" }, + caption: { fontSize: "0.8125rem", color: t.textMuted }, + overline: { + fontSize: "0.6875rem", + fontWeight: 500, + letterSpacing: "0.06em", + textTransform: "uppercase", + color: t.textMuted, + }, }, - }, - components: { - MuiPaper: { - styleOverrides: { - root: { - transition: "background-color 0.3s ease", + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + backgroundColor: t.background, + color: t.textPrimary, + }, + }, + }, + MuiPaper: { + defaultProps: { elevation: 0 }, + styleOverrides: { + root: { + backgroundImage: "none", + backgroundColor: t.background, + border: `1px solid ${t.border}`, + borderRadius: 8, + }, + }, + }, + MuiAppBar: { + styleOverrides: { + root: { + boxShadow: "none", + backgroundColor: t.background, + color: t.textPrimary, + borderBottom: `1px solid ${t.border}`, + }, + }, + }, + MuiButton: { + defaultProps: { disableElevation: true, disableRipple: false }, + styleOverrides: { + root: { + textTransform: "none", + fontWeight: 500, + borderRadius: 8, + paddingInline: 14, + paddingBlock: 7, + minHeight: 36, + boxShadow: "none", + "&:hover": { boxShadow: "none" }, + }, + sizeSmall: { minHeight: 30, paddingInline: 12, fontSize: "0.8125rem" }, + sizeLarge: { minHeight: 44, paddingInline: 20, fontSize: "1rem" }, + contained: { + backgroundColor: t.accent, + color: "#ffffff", + "&:hover": { backgroundColor: t.accentHover }, + }, + outlined: { + borderColor: t.border, + color: t.textPrimary, + backgroundColor: "transparent", + "&:hover": { + borderColor: t.borderStrong, + backgroundColor: isDarkMode + ? "rgba(255,255,255,0.04)" + : "rgba(0,0,0,0.03)", + }, + }, + text: { + color: t.textSecondary, + "&:hover": { + backgroundColor: isDarkMode + ? "rgba(255,255,255,0.05)" + : "rgba(0,0,0,0.04)", + }, + }, + }, + }, + MuiIconButton: { + styleOverrides: { + root: { + borderRadius: 8, + color: t.textSecondary, + "&:hover": { + backgroundColor: isDarkMode + ? "rgba(255,255,255,0.06)" + : "rgba(0,0,0,0.05)", + }, + }, + sizeSmall: { width: 34, height: 34 }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + borderRadius: 6, + fontSize: "0.75rem", + fontWeight: 500, + height: 26, + border: `1px solid ${t.border}`, + backgroundColor: t.surfaceMuted, + color: t.textSecondary, + "& .MuiChip-icon": { color: "inherit", fontSize: 15, marginLeft: 6 }, + "& .MuiChip-label": { paddingInline: 10 }, + }, + outlined: { + backgroundColor: "transparent", + }, + sizeSmall: { height: 24, fontSize: "0.75rem" }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + backgroundImage: "none", + borderRadius: 10, + border: `1px solid ${t.border}`, + boxShadow: isDarkMode + ? "0 20px 60px -20px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)" + : "0 20px 60px -20px rgba(15,17,22,0.2), 0 0 0 1px rgba(15,17,22,0.06)", + }, + }, + }, + MuiDialogTitle: { + styleOverrides: { + root: { + fontSize: "1.0625rem", + fontWeight: 600, + padding: "18px 24px", + borderBottom: `1px solid ${t.border}`, + }, + }, + }, + MuiDialogContent: { + styleOverrides: { + root: { padding: "24px", "&.MuiDialogContent-root": { paddingTop: 24 } }, + }, + }, + MuiDialogActions: { + styleOverrides: { + root: { + padding: "16px 24px", + borderTop: `1px solid ${t.border}`, + gap: 10, + }, + }, + }, + MuiOutlinedInput: { + styleOverrides: { + root: { + borderRadius: 8, + backgroundColor: t.background, + fontSize: "0.875rem", + "& .MuiOutlinedInput-notchedOutline": { + borderColor: t.border, + }, + "&:hover .MuiOutlinedInput-notchedOutline": { + borderColor: t.borderStrong, + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: t.accent, + borderWidth: 1, + boxShadow: "0 0 0 2px rgba(94, 106, 210, 0.2)", + }, + }, + input: { padding: "10px 12px" }, + }, + }, + MuiInputLabel: { + styleOverrides: { + root: { fontSize: "0.875rem" }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + borderRadius: 6, + "&.Mui-selected": { + backgroundColor: isDarkMode + ? "rgba(112,121,224,0.16)" + : "rgba(94,106,210,0.1)", + color: t.accent, + "&:hover": { + backgroundColor: isDarkMode + ? "rgba(112,121,224,0.22)" + : "rgba(94,106,210,0.14)", + }, + }, + }, + }, + }, + MuiMenu: { + styleOverrides: { + paper: { + border: `1px solid ${t.border}`, + borderRadius: 8, + marginTop: 4, + boxShadow: isDarkMode + ? "0 4px 16px -4px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.06)" + : "0 4px 16px -4px rgba(15,17,22,0.1), 0 0 0 1px rgba(15,17,22,0.06)", + }, + }, + }, + MuiMenuItem: { + styleOverrides: { + root: { + fontSize: "0.875rem", + paddingBlock: 8, + paddingInline: 12, + borderRadius: 6, + marginInline: 4, + }, + }, + }, + MuiPopover: { + styleOverrides: { + paper: { + border: `1px solid ${t.border}`, + borderRadius: 8, + boxShadow: isDarkMode + ? "0 4px 16px -4px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.06)" + : "0 4px 16px -4px rgba(15,17,22,0.1), 0 0 0 1px rgba(15,17,22,0.06)", + }, + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + backgroundColor: isDarkMode ? "#22262f" : "#0d0e10", + color: isDarkMode ? "#f7f8f8" : "#ffffff", + fontSize: "0.6875rem", + fontWeight: 500, + padding: "5px 8px", + borderRadius: 4, + }, + arrow: { + color: isDarkMode ? "#22262f" : "#0d0e10", + }, + }, + }, + MuiDivider: { + styleOverrides: { + root: { borderColor: t.border }, + }, + }, + MuiAlert: { + styleOverrides: { + root: { borderRadius: 8, fontSize: "0.875rem", padding: "10px 14px" }, + }, + }, + MuiSwitch: { + styleOverrides: { + switchBase: { + "&.Mui-checked": { color: t.accent }, + "&.Mui-checked + .MuiSwitch-track": { + backgroundColor: t.accent, + opacity: 0.6, + }, + }, }, }, }, - }, - }); + }); + }, [isDarkMode]); return ( diff --git a/webui/src/app/ui/banner.tsx b/webui/src/app/ui/banner.tsx index b9d6b231..ad51a544 100644 --- a/webui/src/app/ui/banner.tsx +++ b/webui/src/app/ui/banner.tsx @@ -19,16 +19,7 @@ "use client"; -import { - AppBar, - Container, - Toolbar, - IconButton, - Box, - Tooltip, - Typography, - Button, -} from "@mui/material"; +import { IconButton, Tooltip } from "@mui/material"; import Image from "next/image"; import NavLinks from "./nav-links"; import { useTheme } from "../theme-provider"; @@ -39,269 +30,105 @@ import HomeIcon from "@mui/icons-material/Home"; import FolderIcon from "@mui/icons-material/Folder"; import MenuBookIcon from "@mui/icons-material/MenuBook"; import SearchIcon from "@mui/icons-material/Search"; -import { usePathname } from "next/navigation"; -import { useEffect, useState } from "react"; import Link from "next/link"; const links = [ - { - url: "/", - title: "Home", - icon: , - }, - { - url: "/namespaces", - title: "Namespaces", - icon: , - }, + { url: "/", title: "Home", icon: }, + { url: "/namespaces", title: "Namespaces", icon: }, { url: "https://kvrocks.apache.org", - title: "Documentation", - icon: , + title: "Docs", + icon: , _blank: true, }, ]; export default function Banner() { const { isDarkMode, toggleTheme } = useTheme(); - const pathname = usePathname(); - const [mounted, setMounted] = useState(false); - const [scrolled, setScrolled] = useState(false); - - useEffect(() => { - const storedTheme = localStorage.getItem("theme"); - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - const shouldBeDark = storedTheme === "dark" || (!storedTheme && prefersDark); - - if (shouldBeDark) { - document.getElementById("navbar")?.classList.add("navbar-dark-mode"); - } - - const handleScroll = () => { - if (window.scrollY > 10) { - setScrolled(true); - } else { - setScrolled(false); - } - }; - - window.addEventListener("scroll", handleScroll); - setMounted(true); - return () => { - window.removeEventListener("scroll", handleScroll); - }; - }, []); + const openSearch = () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "k", metaKey: true, ctrlKey: true }) + ); + }; return ( - - - - - - Apache Kvrocks - - - Apache Kvrocks - - - Controller - - - - +
+ + Apache Kvrocks + + Kvrocks + + + Controller + + - + - - - +
- - - + - - - - {isDarkMode ? ( - - ) : ( - - )} - - + - - - - - - - - - + + + {isDarkMode ? ( + + ) : ( + + )} + + + + + + + + +
+ ); } diff --git a/webui/src/app/ui/breadcrumb.tsx b/webui/src/app/ui/breadcrumb.tsx index da7f20ff..d3686401 100644 --- a/webui/src/app/ui/breadcrumb.tsx +++ b/webui/src/app/ui/breadcrumb.tsx @@ -19,207 +19,84 @@ "use client"; -import { Box, Typography, Chip, Paper } from "@mui/material"; import { usePathname } from "next/navigation"; import Link from "next/link"; -import { useState, useEffect } from "react"; -import HomeIcon from "@mui/icons-material/Home"; +import { useMemo } from "react"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -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"; -import { fetchClusters, listShards, listNodes } from "@/app/lib/api"; -interface BreadcrumbItem { - name: string; - displayName: string; - url: string; - icon: React.ReactNode | null; - isNumeric: boolean; - isLast: boolean; +interface Crumb { + label: string; + href: string | null; } export default function Breadcrumb() { const pathname = usePathname(); - const [breadcrumbItems, setBreadcrumbItems] = useState([]); - const [loading, setLoading] = useState(false); - useEffect(() => { - if (pathname === "/") { - setBreadcrumbItems([]); - setLoading(false); - return; - } - - const generateBreadcrumbs = async () => { - setLoading(true); - const pathSegments = pathname.split("/").filter(Boolean); - - if (pathSegments.length === 0) { - setBreadcrumbItems([]); - setLoading(false); - return; - } - - const items: BreadcrumbItem[] = []; - - for (let index = 0; index < pathSegments.length; index++) { - const segment = pathSegments[index]; - const url = `/${pathSegments.slice(0, index + 1).join("/")}`; - const isLast = index === pathSegments.length - 1; - const isNumeric = !isNaN(Number(segment)); - - let icon = null; - let displayName = segment; - - if (index === 0 && segment === "namespaces") { - icon = ; - displayName = "Namespaces"; - } else if (segment === "clusters") { - icon = ; - displayName = "Clusters"; - } else if (segment === "shards") { - icon = ; - displayName = "Shards"; - } else if (segment === "nodes") { - icon = ; - displayName = "Nodes"; - } else if (isNumeric) { - const prevSegment = pathSegments[index - 1]; - if (prevSegment === "shards") { - displayName = `Shard ${parseInt(segment) + 1}`; - icon = null; - } else if (prevSegment === "nodes") { - displayName = `Node ${parseInt(segment) + 1}`; - icon = null; - } else { - displayName = `ID: ${segment}`; - icon = null; - } - } else { - // For namespace and cluster names, capitalize first letter - displayName = segment.charAt(0).toUpperCase() + segment.slice(1); - - const prevSegment = pathSegments[index - 1]; - if (prevSegment === "namespaces") { - icon = null; - } else if (prevSegment === "clusters") { - icon = null; - } - } - - items.push({ - name: segment, - displayName, - url, - icon, - isNumeric, - isLast, - }); - } - - setBreadcrumbItems(items); - setLoading(false); - }; - - generateBreadcrumbs(); + const crumbs = useMemo(() => { + if (pathname === "/") return []; + const parts = pathname.split("/").filter(Boolean); + const out: Crumb[] = []; + + parts.forEach((segment, index) => { + const href = "/" + parts.slice(0, index + 1).join("/"); + const isLast = index === parts.length - 1; + const prev = parts[index - 1]; + const numeric = !isNaN(Number(segment)); + + let label = segment; + if (index === 0 && segment === "namespaces") label = "Namespaces"; + else if (segment === "clusters") label = "Clusters"; + else if (segment === "shards") label = "Shards"; + else if (segment === "nodes") label = "Nodes"; + else if (numeric && prev === "shards") label = `Shard ${Number(segment) + 1}`; + else if (numeric && prev === "nodes") label = `Node ${Number(segment) + 1}`; + else if (numeric) label = segment; + + // Container-only segments have no destination page. + const containerOnly = + (segment === "clusters" || segment === "shards" || segment === "nodes") && !isLast; + + out.push({ label, href: isLast || containerOnly ? null : href }); + }); + + return out; }, [pathname]); - if (pathname === "/" || breadcrumbItems.length === 0) return null; + if (crumbs.length === 0) return null; return ( - - + ); } diff --git a/webui/src/app/ui/createCard.tsx b/webui/src/app/ui/createCard.tsx index bd0cf73f..dba58d88 100644 --- a/webui/src/app/ui/createCard.tsx +++ b/webui/src/app/ui/createCard.tsx @@ -19,8 +19,9 @@ "use client"; -import { Box, Paper, Chip, Tooltip } from "@mui/material"; +import { Chip } from "@mui/material"; import React, { ReactNode } from "react"; +import AddIcon from "@mui/icons-material/Add"; import { ClusterCreation, ImportCluster, @@ -28,8 +29,6 @@ import { NodeCreation, ShardCreation, } from "./formCreation"; -import { faCirclePlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; interface CreateCardProps { children: ReactNode; @@ -38,53 +37,39 @@ interface CreateCardProps { export const CreateCard: React.FC = ({ children, className = "" }) => { return ( - - - {children} - - +
+ {children} +
); }; -export const AddClusterCard = ({ namespace }: { namespace: string }) => { - return ( - -
- -
-
- -
-
- -
-
-
-
- ); -}; +const AddGlyph = () => ( +
+ +
+); -export const AddShardCard = ({ namespace, cluster }: { namespace: string; cluster: string }) => { - return ( - -
- -
- - -
-
-
- ); -}; +export const AddClusterCard = ({ namespace }: { namespace: string }) => ( + + +
+ + +
+
+); + +export const AddShardCard = ({ namespace, cluster }: { namespace: string; cluster: string }) => ( + + +
+ + +
+
+); export const AddNodeCard = ({ namespace, @@ -94,27 +79,12 @@ export const AddNodeCard = ({ namespace: string; cluster: string; shard: string; -}) => { - return ( - -
- -
- -
-
-
- ); -}; +}) => ( + + + + +); export const ResourceCard = ({ title, @@ -126,31 +96,28 @@ export const ResourceCard = ({ description?: string; tags?: Array<{ label: string; color?: string }>; children: ReactNode; -}) => { - return ( - -
-
{title}
- {description && ( -
- {description} -
- )} -
{children}
- {tags && tags.length > 0 && ( -
- {tags.map((tag, i) => ( - - ))} -
- )} +}) => ( +
+
+ {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 index 795172a8..30f5816c 100644 --- a/webui/src/app/ui/emptyState.tsx +++ b/webui/src/app/ui/emptyState.tsx @@ -18,7 +18,7 @@ */ import React, { ReactNode } from "react"; -import { Box, Paper, Typography, Button } from "@mui/material"; +import { Button } from "@mui/material"; interface EmptyStateProps { title: string; @@ -30,32 +30,23 @@ interface EmptyStateProps { }; } -const EmptyState: React.FC = ({ title, description, icon, action }) => { - return ( - - {icon && {icon}} - - {title} - - - {description} - - {action && ( - - )} - - ); -}; +const EmptyState: React.FC = ({ title, description, icon, action }) => ( +
+ {icon && ( +
+ {icon} +
+ )} +
+ {title} +
+

{description}

+ {action && ( + + )} +
+); export default EmptyState; diff --git a/webui/src/app/ui/failoverDialog.tsx b/webui/src/app/ui/failoverDialog.tsx index 99587043..c9ea5a78 100644 --- a/webui/src/app/ui/failoverDialog.tsx +++ b/webui/src/app/ui/failoverDialog.tsx @@ -21,24 +21,15 @@ import React, { useState } from "react"; import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, + Alert, Button, - Typography, - FormControl, - FormLabel, - RadioGroup, - FormControlLabel, - Radio, - Box, Chip, CircularProgress, - Alert, + Dialog, + DialogActions, + DialogContent, + DialogTitle, Snackbar, - alpha, - useTheme, } from "@mui/material"; import SwapHorizIcon from "@mui/icons-material/SwapHoriz"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; @@ -62,6 +53,39 @@ interface FailoverDialogProps { onSuccess: () => void; } +const truncateId = (id: string, length = 8) => + id.length > length ? `${id.substring(0, length)}…` : id; + +const RadioRow = ({ + checked, + onChange, + children, +}: { + checked: boolean; + onChange: () => void; + children: React.ReactNode; +}) => ( + +); + export const FailoverDialog: React.FC = ({ open, onClose, @@ -73,8 +97,7 @@ export const FailoverDialog: React.FC = ({ }) => { const [selectedNodeId, setSelectedNodeId] = useState("auto"); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const theme = useTheme(); + const [error, setError] = useState(""); const masterNode = nodes.find((node) => node.role === "master"); const slaveNodes = nodes.filter((node) => node.role === "slave"); @@ -82,7 +105,6 @@ export const FailoverDialog: React.FC = ({ const handleFailover = async () => { setLoading(true); setError(""); - try { const result = await failoverShard( namespace, @@ -90,15 +112,13 @@ export const FailoverDialog: React.FC = ({ shard, selectedNodeId === "auto" ? undefined : selectedNodeId ); - - if (result.error) { - setError(result.error); - } else { + if (result.error) setError(result.error); + else { onSuccess(); onClose(); setSelectedNodeId("auto"); } - } catch (err) { + } catch { setError("An unexpected error occurred during failover"); } finally { setLoading(false); @@ -106,295 +126,121 @@ export const FailoverDialog: React.FC = ({ }; const handleClose = () => { - if (!loading) { - onClose(); - setSelectedNodeId("auto"); - setError(""); - } - }; - - const truncateId = (id: string, length: number = 8) => { - return id.length > length ? `${id.substring(0, length)}...` : id; + if (loading) return; + onClose(); + setSelectedNodeId("auto"); + setError(""); }; return ( <> - - - - - - - Failover Shard Master - - - Promote a replica node to master - - - + + + + + Failover shard master + - - - - Current Master - - {masterNode && ( - - - - - - {masterNode.addr} - - - ID: {truncateId(masterNode.id)} - - - - - - )} - + +

+ Promote a replica to master. The current master will become a replica once + the operation completes. +

+ + {masterNode && ( +
+
Current master
+
+ +
+
+ {masterNode.addr} +
+
+ ID: {truncateId(masterNode.id)} +
+
+ +
+
+ )} {slaveNodes.length > 0 ? ( - - - Select New Master - - - setSelectedNodeId(e.target.value)} +
+
Select new master
+
+ setSelectedNodeId("auto")} > - - } - label={ - - - Automatic Selection - - - Let the controller choose the best replica - - - } - sx={{ - p: 2, - m: 0, - mb: 2, - border: `1px solid ${ - selectedNodeId === "auto" - ? theme.palette.primary.main - : theme.palette.grey[300] - }`, - borderRadius: "16px", - backgroundColor: - selectedNodeId === "auto" - ? alpha(theme.palette.primary.main, 0.1) - : "transparent", - transition: "all 0.2s ease", - "&:hover": { - backgroundColor: alpha( - theme.palette.primary.main, - 0.05 - ), - }, - }} - /> +
+ Automatic +
+
+ Controller picks the best replica +
+
- {slaveNodes.map((node) => ( - - } - label={ - - - - - {node.addr} - - - ID: {truncateId(node.id)} - - - - - } - sx={{ - p: 2, - m: 0, - mb: 2, - border: `1px solid ${ - selectedNodeId === node.id - ? theme.palette.primary.main - : theme.palette.grey[300] - }`, - borderRadius: "16px", - backgroundColor: - selectedNodeId === node.id - ? alpha(theme.palette.primary.main, 0.1) - : "transparent", - transition: "all 0.2s ease", - "&:hover": { - backgroundColor: alpha( - theme.palette.primary.main, - 0.05 - ), - }, - }} - /> - ))} - - - + {slaveNodes.map((node) => ( + setSelectedNodeId(node.id)} + > +
+ +
+
+ {node.addr} +
+
+ ID: {truncateId(node.id)} +
+
+ +
+
+ ))} +
+
) : ( - - No replica nodes available for failover. At least one replica node is - required. + + No replica nodes available. At least one replica is required for a + manual failover. )}
- +
@@ -405,12 +251,7 @@ export const FailoverDialog: React.FC = ({ onClose={() => setError("")} anchorOrigin={{ vertical: "bottom", horizontal: "right" }} > - setError("")} - severity="error" - variant="filled" - sx={{ borderRadius: "16px" }} - > + setError("")} severity="error" variant="filled"> {error} diff --git a/webui/src/app/ui/footer.tsx b/webui/src/app/ui/footer.tsx index aa7106c1..5afc3835 100644 --- a/webui/src/app/ui/footer.tsx +++ b/webui/src/app/ui/footer.tsx @@ -21,227 +21,27 @@ import Image from "next/image"; import Link from "next/link"; -import { - Container, - Typography, - Box, - Divider, - Grid, - IconButton, - useMediaQuery, - useTheme as useMuiTheme, -} from "@mui/material"; -import { - Launch as LaunchIcon, - GitHub as GitHubIcon, - Twitter as TwitterIcon, - LinkedIn as LinkedInIcon, - ArrowUpward as ArrowUpwardIcon, -} from "@mui/icons-material"; +import GitHubIcon from "@mui/icons-material/GitHub"; +import TwitterIcon from "@mui/icons-material/Twitter"; +import LinkedInIcon from "@mui/icons-material/LinkedIn"; +import LaunchIcon from "@mui/icons-material/Launch"; import { footerConfig } from "../../../config"; -import { footerColumn, footerColumnItem, footerLogo } from "../lib/definitions"; -import { useTheme } from "../theme-provider"; - -export default function Footer() { - const { isDarkMode } = useTheme(); - const muiTheme = useMuiTheme(); - const isMobile = useMediaQuery(muiTheme.breakpoints.down("md")); - - const socialLinks = [ - { icon: , href: "https://github.com/apache", label: "GitHub" }, - { icon: , href: "https://x.com/TheASF", label: "Twitter" }, - { - icon: , - href: "https://www.linkedin.com/company/the-apache-software-foundation/", - label: "LinkedIn", - }, - ]; - - const scrollToTop = () => { - window.scrollTo({ top: 0, behavior: "smooth" }); - }; - - return ( -
-
-
- -
- - - - - - - - -
- -
-
-
- -
- -
- - -
- -
- Apache Kvrocks -
-
-
- - Apache Kvrocks - - - Controller - -
- - - - A distributed key-value NoSQL database that uses RocksDB as - storage engine and is compatible with Redis protocol. - - -
- {socialLinks.map((social, index) => ( - - {social.icon} - - ))} -
-
-
- - - - {footerConfig.links.map((column) => ( - - - - ))} - - -
-
- -
-
- - {footerConfig.logo.alt} - - An Apache Software Foundation Project - - - -
- - {`Copyright © ${new Date().getFullYear()} ${footerConfig.copyright}`} - - - - - -
-
-
-
-
-
- ); -} - -const FooterColumn = ({ column }: { column: footerColumn }) => ( -
- - {column.title} -
-
-
    +import { footerColumn, footerColumnItem } from "../lib/definitions"; + +const socials = [ + { icon: , href: "https://github.com/apache", label: "GitHub" }, + { icon: , href: "https://x.com/TheASF", label: "Twitter" }, + { + icon: , + href: "https://www.linkedin.com/company/the-apache-software-foundation/", + label: "LinkedIn", + }, +]; + +const Column = ({ column }: { column: footerColumn }) => ( +
    +
    {column.title}
    +
      {column.items.map((item) => ( ))} @@ -255,18 +55,83 @@ const FooterLink = ({ item }: { item: footerColumnItem }) => ( href={item.href || item.to || "/"} target={item.href ? "_blank" : undefined} rel={item.href ? "noopener noreferrer" : undefined} - className="group inline-flex items-center text-gray-600 transition-all duration-300 hover:text-primary dark:text-gray-300 dark:hover:text-primary-light" + className="inline-flex items-center gap-1 text-sm text-text-secondary transition-colors hover:text-text-primary dark:text-text-dark-secondary dark:hover:text-text-dark-primary" > - - {item.label} - - - {item.href && ( - - )} + {item.label} + {item.href && } ); + +export default function Footer() { + return ( +
      +
      +
      +
      + + Apache Kvrocks + + Kvrocks Controller + + +

      + Distributed key-value store built on RocksDB, compatible with the Redis + protocol. +

      +
      + {socials.map((social) => ( + + {social.icon} + + ))} +
      +
      + +
      + {footerConfig.links.map((column) => ( + + ))} +
      +
      + +
      + + {footerConfig.logo.alt} + + An Apache Software Foundation project + + + + {`© ${new Date().getFullYear()} ${footerConfig.copyright}`} + +
      +
      +
      + ); +} diff --git a/webui/src/app/ui/formCreation.tsx b/webui/src/app/ui/formCreation.tsx index ad0d9dee..d68418b9 100644 --- a/webui/src/app/ui/formCreation.tsx +++ b/webui/src/app/ui/formCreation.tsx @@ -33,12 +33,18 @@ import { useRouter } from "next/navigation"; type NamespaceFormProps = { position: string; + emphasis?: "primary" | "secondary"; + triggerLabel?: string; + triggerIcon?: React.ReactNode; children?: React.ReactNode; }; type ClusterFormProps = { position: string; namespace: string; + emphasis?: "primary" | "secondary"; + triggerLabel?: string; + triggerIcon?: React.ReactNode; children?: React.ReactNode; }; @@ -71,7 +77,13 @@ const validateFormData = (formData: FormData, fields: string[]): string | null = return null; }; -export const NamespaceCreation: React.FC = ({ position, children }) => { +export const NamespaceCreation: React.FC = ({ + position, + emphasis, + triggerLabel, + triggerIcon, + children, +}) => { const router = useRouter(); const handleSubmit = async (formData: FormData) => { const fieldsToValidate = ["name"]; @@ -92,15 +104,27 @@ export const NamespaceCreation: React.FC = ({ position, chil return ( + > + {children} + ); }; -export const ClusterCreation: React.FC = ({ position, namespace, children }) => { +export const ClusterCreation: React.FC = ({ + position, + namespace, + emphasis, + triggerLabel, + triggerIcon, + children, +}) => { const router = useRouter(); const handleSubmit = async (formData: FormData) => { const fieldsToValidate = ["name", "replicas"]; @@ -137,22 +161,16 @@ export const ClusterCreation: React.FC = ({ position, namespac return ( @@ -223,12 +241,8 @@ export const ShardCreation: React.FC = ({ title="Create Shard" submitButtonLabel="Create" formFields={[ - { name: "nodes", label: "Input Nodes", type: "array", required: true }, - { - name: "password", - label: "Input Password", - type: "password", - }, + { name: "nodes", label: "Nodes", type: "array", required: true }, + { name: "password", label: "Password", type: "password" }, ]} onSubmit={handleSubmit} > @@ -237,7 +251,14 @@ export const ShardCreation: React.FC = ({ ); }; -export const ImportCluster: React.FC = ({ position, namespace, children }) => { +export const ImportCluster: React.FC = ({ + position, + namespace, + emphasis, + triggerLabel, + triggerIcon, + children, +}) => { const router = useRouter(); const handleSubmit = async (formData: FormData) => { const fieldsToValidate = ["nodes"]; @@ -272,21 +293,15 @@ export const ImportCluster: React.FC = ({ position, namespace, return ( @@ -446,24 +461,15 @@ export const NodeCreation: React.FC = ({ title="Create Node" submitButtonLabel="Create" formFields={[ - { - name: "Address", - label: "Input Address", - type: "text", - required: true, - }, + { name: "Address", label: "Nodes", type: "text", required: true }, { name: "Role", - label: "Select Role", + label: "Role", type: "enum", required: true, values: ["master", "slave"], }, - { - name: "Password", - label: "Input Password", - type: "password", - }, + { name: "Password", label: "Password", type: "password" }, ]} onSubmit={handleSubmit} > diff --git a/webui/src/app/ui/formDialog.tsx b/webui/src/app/ui/formDialog.tsx index 8114513b..c9c9da46 100644 --- a/webui/src/app/ui/formDialog.tsx +++ b/webui/src/app/ui/formDialog.tsx @@ -19,34 +19,31 @@ import { Alert, + Autocomplete, Button, + Chip, + CircularProgress, Dialog, DialogActions, DialogContent, - DialogContentText, DialogTitle, - Snackbar, - TextField, - Box, - Chip, - Typography, - Autocomplete, + FormControl, + InputLabel, MenuItem, Select, - InputLabel, - FormControl, - Paper, - CircularProgress, - alpha, - useTheme, + Snackbar, + TextField, } from "@mui/material"; -import React, { useCallback, useState, FormEvent } from "react"; import AddIcon from "@mui/icons-material/Add"; +import React, { FormEvent, useCallback, useState } from "react"; interface FormDialogProps { position: string; title: string; submitButtonLabel: string; + triggerLabel?: string; + triggerIcon?: React.ReactNode; + emphasis?: "primary" | "secondary"; formFields: { name: string; label: string; @@ -62,359 +59,219 @@ const FormDialog: React.FC = ({ position, title, submitButtonLabel, + triggerLabel, + triggerIcon, + emphasis, formFields, onSubmit, children, }) => { - const [showDialog, setShowDialog] = useState(false); - const openDialog = useCallback(() => setShowDialog(true), []); - const closeDialog = useCallback(() => setShowDialog(false), []); - const [errorMessage, setErrorMessage] = useState(""); - const [arrayValues, setArrayValues] = useState<{ [key: string]: string[] }>({}); + const [open, setOpen] = useState(false); + const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); - const theme = useTheme(); + const [arrayValues, setArrayValues] = useState>({}); + + const openDialog = useCallback(() => setOpen(true), []); + const closeDialog = useCallback(() => setOpen(false), []); const handleArrayChange = (name: string, value: string[]) => { - setArrayValues({ ...arrayValues, [name]: value }); + setArrayValues((prev) => ({ ...prev, [name]: value })); }; 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])); }); try { - const error = await onSubmit(formData); - if (error) { - setErrorMessage(error); - } else { - closeDialog(); - } - } catch (error) { - setErrorMessage("An unexpected error occurred"); + const err = await onSubmit(formData); + if (err) setError(err); + else closeDialog(); + } catch { + setError("An unexpected error occurred"); } finally { setSubmitting(false); } }; - return ( - <> - {children ? ( -
      {children}
      - ) : position === "card" ? ( - - ) : ( - - )} + const label = triggerLabel ?? title; + const icon = triggerIcon ?? ; - {children}
    ; + } else if (position === "card") { + trigger = ( + + ); + } else if (position === "page") { + // Page-level triggers sit next to search and other actions; content-sized, + // with primary/secondary distinction so callers can express hierarchy. + trigger = ( + + ); + } else { + // Sidebar (and any legacy caller) — full-width. `emphasis="primary"` + // upgrades it to the brand-filled CTA used for the top-level create action; + // everything else stays as an unobtrusive outlined button. + const isPrimary = emphasis === "primary"; + trigger = ( + + ); + } + + return ( + <> + {trigger} + +
    - - - {title} - - + {title} - - {formFields.map((field, index) => - field.type === "array" ? ( - - - {field.label} - - - handleArrayChange(field.name, newValue) - } - renderTags={(value, getTagProps) => - value.map((option, index) => ( - - )) - } - renderInput={(params) => ( - +
    + {formFields.map((field, index) => { + if (field.type === "array") { + return ( +
    + + + handleArrayChange(field.name, value) + } + renderTags={(value, getTagProps) => + value.map((option, i) => ( + + )) + } + renderInput={(params) => ( + + )} /> - )} - /> - - ) : field.type === "enum" ? ( - - - {field.label} - - + {field.values?.map((value) => ( + + {value} + + ))} + + + ); + } + return ( + - {field.values?.map((value, index) => ( - - {value} - - ))} - - - ) : ( - - ) - )} + /> + ); + })} +
    - +
    setErrorMessage("")} + onClose={() => setError("")} anchorOrigin={{ vertical: "bottom", horizontal: "right" }} - className="mb-4" > - setErrorMessage("")} - severity="error" - variant="filled" - className="shadow-lg" - sx={{ - borderRadius: "16px", - boxShadow: "0 10px 30px rgba(0, 0, 0, 0.15)", - }} - > - {errorMessage} + setError("")} severity="error" variant="filled"> + {error} diff --git a/webui/src/app/ui/loadingSpinner.tsx b/webui/src/app/ui/loadingSpinner.tsx index 24e208a6..b3da5f45 100644 --- a/webui/src/app/ui/loadingSpinner.tsx +++ b/webui/src/app/ui/loadingSpinner.tsx @@ -20,7 +20,7 @@ "use client"; import React from "react"; -import { Box, CircularProgress, Typography, Fade } from "@mui/material"; +import { CircularProgress } from "@mui/material"; interface LoadingSpinnerProps { message?: string; @@ -29,44 +29,19 @@ interface LoadingSpinnerProps { } export const LoadingSpinner: React.FC = ({ - message = "Loading...", + message = "Loading…", size = "medium", fullScreen = false, }) => { - const spinnerSize = { - small: 24, - medium: 40, - large: 60, - }[size]; + const px = { small: 14, medium: 18, large: 24 }[size]; return ( - - - - {message && ( - - {message} - - )} - - +
    + + {message && {message}} +
    ); }; diff --git a/webui/src/app/ui/migrationDialog.tsx b/webui/src/app/ui/migrationDialog.tsx index b823c3c4..73905c91 100644 --- a/webui/src/app/ui/migrationDialog.tsx +++ b/webui/src/app/ui/migrationDialog.tsx @@ -19,32 +19,23 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, + Alert, Button, - Typography, - FormControl, - FormLabel, - RadioGroup, - FormControlLabel, - Radio, - Box, Chip, CircularProgress, - Alert, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, Snackbar, - TextField, Switch, - alpha, - useTheme, + TextField, } from "@mui/material"; import MoveUpIcon from "@mui/icons-material/MoveUp"; import StorageIcon from "@mui/icons-material/Storage"; -import DeviceHubIcon from "@mui/icons-material/DeviceHub"; import { migrateSlot } from "@/app/lib/api"; interface Shard { @@ -69,6 +60,91 @@ interface MigrationDialogProps { onSuccess: () => void; } +const RadioRow = ({ + checked, + onChange, + children, +}: { + checked: boolean; + onChange: () => void; + children: React.ReactNode; +}) => ( + +); + +// Given the user's slot input, find which shard currently owns that slot. +// Any slot inside a range is enough to identify the source, so we peek at the +// first slot for ranges. Returns -1 when the slot is unassigned or invalid. +const findSourceShardIndex = (input: string, shards: Shard[]): number => { + const trimmed = input.trim(); + if (!trimmed) return -1; + const first = trimmed.includes("-") ? trimmed.split("-")[0] : trimmed; + const slot = parseInt(first.trim()); + if (isNaN(slot)) return -1; + for (const shard of shards) { + for (const range of shard.slotRanges) { + let start: number; + let end: number; + if (range.includes("-")) { + [start, end] = range.split("-").map(Number); + } else { + start = end = Number(range); + } + if (!isNaN(start) && !isNaN(end) && slot >= start && slot <= end) { + return shard.index; + } + } + } + return -1; +}; + +const validateSlotInput = (input: string): { isValid: boolean; error?: string } => { + if (!input.trim()) return { isValid: false, error: "Enter a slot number or range" }; + + if (input.includes("-")) { + const parts = input.split("-"); + if (parts.length !== 2) { + return { isValid: false, error: "Range format: start-end (e.g., 100-200)" }; + } + const start = parseInt(parts[0].trim()); + const end = parseInt(parts[1].trim()); + if (isNaN(start) || isNaN(end)) { + return { isValid: false, error: "Range values must be valid numbers" }; + } + if (start < 0 || end > 16383 || start > 16383 || end < 0) { + return { isValid: false, error: "Slots must be 0 to 16383" }; + } + if (start > end) { + return { isValid: false, error: "Start must be ≤ end" }; + } + return { isValid: true }; + } + + const slot = parseInt(input.trim()); + if (isNaN(slot) || slot < 0 || slot > 16383) { + return { isValid: false, error: "Slot must be 0 to 16383" }; + } + return { isValid: true }; +}; + export const MigrationDialog: React.FC = ({ open, onClose, @@ -78,76 +154,54 @@ export const MigrationDialog: React.FC = ({ onSuccess, }) => { const [targetShardIndex, setTargetShardIndex] = useState(-1); - const [slotNumber, setSlotNumber] = useState(""); - const [slotOnly, setSlotOnly] = useState(false); + const [slotNumber, setSlotNumber] = useState(""); + const [slotOnly, setSlotOnly] = useState(false); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); - const theme = useTheme(); - - const availableTargetShards = shards.filter((shard) => shard.hasSlots); - - const validateSlotInput = (input: string): { isValid: boolean; error?: string } => { - if (!input.trim()) { - return { isValid: false, error: "Please enter a slot number or range" }; - } - - // Check if it's a range (contains dash) - if (input.includes("-")) { - const parts = input.split("-"); - if (parts.length !== 2) { - return { - isValid: false, - error: "Invalid range format. Use format: start-end (e.g., 100-200)", - }; - } + const [error, setError] = useState(""); - const start = parseInt(parts[0].trim()); - const end = parseInt(parts[1].trim()); + const sourceShardIndex = useMemo( + () => findSourceShardIndex(slotNumber, shards), + [slotNumber, shards] + ); - if (isNaN(start) || isNaN(end)) { - return { - isValid: false, - error: "Both start and end of range must be valid numbers", - }; - } + const availableTargetShards = useMemo( + () => shards.filter((s) => s.index !== sourceShardIndex), + [shards, sourceShardIndex] + ); - if (start < 0 || end > 16383 || start > 16383 || end < 0) { - return { isValid: false, error: "Slot numbers must be between 0 and 16383" }; - } + // Clear the selection if the user typed a slot that maps to the currently + // selected target — the source can never be its own target. + useEffect(() => { + if (targetShardIndex !== -1 && targetShardIndex === sourceShardIndex) { + setTargetShardIndex(-1); + } + }, [sourceShardIndex, targetShardIndex]); - if (start > end) { - return { - isValid: false, - error: "Start slot must be less than or equal to end slot", - }; - } + const resetForm = () => { + setTargetShardIndex(-1); + setSlotNumber(""); + setSlotOnly(false); + setError(""); + }; - return { isValid: true }; - } else { - const slot = parseInt(input.trim()); - if (isNaN(slot) || slot < 0 || slot > 16383) { - return { isValid: false, error: "Slot number must be between 0 and 16383" }; - } - return { isValid: true }; - } + const handleClose = () => { + if (loading) return; + onClose(); + resetForm(); }; const handleMigration = async () => { if (targetShardIndex === -1 || !slotNumber.trim()) { - setError("Please select a target shard and enter a slot number or range"); + setError("Select a target shard and enter a slot"); return; } - - // Validate slot input const validation = validateSlotInput(slotNumber); if (!validation.isValid) { - setError(validation.error || "Invalid slot input"); + setError(validation.error || "Invalid slot"); return; } - setLoading(true); setError(""); - try { const result = await migrateSlot( namespace, @@ -156,310 +210,152 @@ export const MigrationDialog: React.FC = ({ slotNumber.trim(), slotOnly ); - - if (result) { - setError(result); - } else { + if (result) setError(result); + else { onSuccess(); onClose(); resetForm(); } - } catch (err) { + } catch { setError("An unexpected error occurred during migration"); } finally { setLoading(false); } }; - const resetForm = () => { - setTargetShardIndex(-1); - setSlotNumber(""); - setSlotOnly(false); - setError(""); - }; - - const handleClose = () => { - if (!loading) { - onClose(); - resetForm(); - } - }; - - const getSlotRangeDisplay = (slotRanges: string[]) => { - if (!slotRanges || slotRanges.length === 0) return "No slots"; - return slotRanges.join(", "); - }; - return ( <> - - - - - - - Migrate Slot - - - Move a slot to a different shard - - - + + + + + Migrate slot + - - + +

    + Move a slot or slot range between shards. Slots range from 0 to 16383. +

    + +
    setSlotNumber(e.target.value)} fullWidth - variant="outlined" - placeholder="e.g., 123 or 100-200" - helperText="Enter a single slot (123) or slot range (100-200). Slots must be between 0 and 16383" - sx={{ - "& .MuiOutlinedInput-root": { - borderRadius: "16px", - "&.Mui-focused": { - boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`, - }, - }, - }} + size="small" + placeholder="e.g. 123 or 100-200" + helperText="Single slot (123) or range (100-200)" /> - +
    - +
    setSlotOnly(e.target.checked)} - sx={{ - "& .MuiSwitch-switchBase.Mui-checked": { - color: theme.palette.primary.main, - }, - "& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": { - backgroundColor: theme.palette.primary.main, - }, - }} + size="small" /> } label={ - - + + Slot-only migration - - - Migrate only the slot without data - - + + + Move the slot without data + + } /> - +
    {availableTargetShards.length > 0 ? ( - - - Select Target Shard - - - setTargetShardIndex(parseInt(e.target.value))} - > - {availableTargetShards.map((shard) => ( - - } - label={ - - - - - Shard {shard.index} - - - Slots:{" "} - {getSlotRangeDisplay(shard.slotRanges)} - - - Nodes: {shard.nodeCount} - - - - - {shard.hasMigration && ( - - )} - {shard.hasImporting && ( - - )} - - - } - sx={{ - p: 2, - m: 0, - mb: 2, - border: `1px solid ${ - targetShardIndex === shard.index - ? theme.palette.primary.main - : theme.palette.grey[300] - }`, - borderRadius: "16px", - backgroundColor: - targetShardIndex === shard.index - ? alpha(theme.palette.primary.main, 0.1) - : "transparent", - transition: "all 0.2s ease", - "&:hover": { - backgroundColor: alpha( - theme.palette.primary.main, - 0.05 - ), - }, - }} - /> - ))} - - - +
    +
    + Select target shard + {sourceShardIndex !== -1 && ( + + Source: Shard {sourceShardIndex + 1} (excluded) + + )} +
    +
    + {availableTargetShards.map((shard) => ( + setTargetShardIndex(shard.index)} + > +
    + +
    +
    + Shard {shard.index + 1} +
    +
    + Slots:{" "} + {shard.slotRanges.length + ? shard.slotRanges.join(", ") + : "—"}{" "} + · {shard.nodeCount} nodes +
    +
    +
    + {shard.hasMigration && ( + + )} + {shard.hasImporting && ( + + )} +
    +
    +
    + ))} +
    +
    ) : ( - - No target shards available for migration. At least one shard with slots - is required. + + {sourceShardIndex !== -1 + ? `Slot ${slotNumber.trim()} lives on Shard ${sourceShardIndex + 1}, and there are no other shards to migrate it to.` + : "No target shards available. Add another shard to migrate slots."} )}
    - +
    @@ -470,12 +366,7 @@ export const MigrationDialog: React.FC = ({ onClose={() => setError("")} anchorOrigin={{ vertical: "bottom", horizontal: "right" }} > - setError("")} - severity="error" - variant="filled" - sx={{ borderRadius: "16px" }} - > + setError("")} severity="error" variant="filled"> {error} diff --git a/webui/src/app/ui/nav-links.tsx b/webui/src/app/ui/nav-links.tsx index ee5aa557..cf82c4fa 100644 --- a/webui/src/app/ui/nav-links.tsx +++ b/webui/src/app/ui/nav-links.tsx @@ -19,22 +19,17 @@ "use client"; -import { Button } from "@mui/material"; import Link from "next/link"; import { usePathname } from "next/navigation"; -export default function NavLinks({ - links, - scrolled = false, -}: { - links: Array<{ - url: string; - title: string; - icon?: React.ReactNode; - _blank?: boolean; - }>; - scrolled?: boolean; -}) { +interface NavLink { + url: string; + title: string; + icon?: React.ReactNode; + _blank?: boolean; +} + +export default function NavLinks({ links }: { links: NavLink[] }) { const pathname = usePathname(); return ( @@ -47,59 +42,15 @@ export default function NavLinks({ - + {link.icon} + {link.title} ); })} diff --git a/webui/src/app/ui/pageChrome.tsx b/webui/src/app/ui/pageChrome.tsx new file mode 100644 index 00000000..624f1712 --- /dev/null +++ b/webui/src/app/ui/pageChrome.tsx @@ -0,0 +1,328 @@ +/* + * 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 { ReactNode, useState } from "react"; +import { IconButton, Menu, MenuItem, Tooltip } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import SortIcon from "@mui/icons-material/Sort"; +import CloseIcon from "@mui/icons-material/Close"; +import CheckIcon from "@mui/icons-material/Check"; + +export interface PageShellProps { + sidebar?: ReactNode; + children: ReactNode; +} + +export function PageShell({ sidebar, children }: PageShellProps) { + return ( +
    + {sidebar} +
    {children}
    +
    + ); +} + +export function PageBody({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + +export interface PageHeaderProps { + title: ReactNode; + subtitle?: ReactNode; + icon?: ReactNode; + actions?: ReactNode; +} + +export function PageHeader({ title, subtitle, icon, actions }: PageHeaderProps) { + return ( +
    +
    + {icon && ( +
    + {icon} +
    + )} +
    +

    + {title} +

    + {subtitle && ( +

    + {subtitle} +

    + )} +
    +
    + {actions &&
    {actions}
    } +
    + ); +} + +export interface StatCardProps { + label: string; + value: number | string; + icon?: ReactNode; + accent?: "default" | "primary" | "success" | "warning" | "info"; +} + +const accentClasses: Record, string> = { + default: + "text-text-muted bg-surface-muted dark:text-text-dark-muted dark:bg-surface-dark-muted", + primary: "text-primary bg-primary/10 dark:text-primary-light dark:bg-primary/15", + success: "text-success bg-success/10 dark:bg-success/15", + warning: "text-warning bg-warning/10 dark:bg-warning/15", + info: "text-info bg-info/10 dark:bg-info/15", +}; + +export function StatCard({ label, value, icon, accent = "default" }: StatCardProps) { + return ( +
    + {icon && ( +
    + {icon} +
    + )} +
    +
    + {label} +
    +
    + {value} +
    +
    +
    + ); +} + +export interface SearchInputProps { + value: string; + onChange: (v: string) => void; + placeholder?: string; +} + +export function SearchInput({ value, onChange, placeholder = "Search…" }: SearchInputProps) { + return ( +
    + + onChange(e.target.value)} + placeholder={placeholder} + className="h-10 w-full rounded-lg border border-border-subtle bg-surface-base pl-10 pr-9 text-sm text-text-primary transition-colors placeholder:text-text-muted hover:border-border-strong focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 dark:border-border-dark-subtle dark:bg-surface-dark-subtle dark:text-text-dark-primary dark:placeholder:text-text-dark-muted" + /> + {value && ( + + )} +
    + ); +} + +export interface FilterSortOption { + value: T; + label: string; + group?: string; +} + +export interface FilterSortMenuProps { + ariaLabel: string; + icon: ReactNode; + tooltip: string; + value: T; + options: FilterSortOption[]; + onChange: (v: T) => void; +} + +export function FilterSortMenu({ + ariaLabel, + icon, + tooltip, + value, + options, + onChange, +}: FilterSortMenuProps) { + const [anchor, setAnchor] = useState(null); + + // Group options while preserving order. + const groups: { title: string | null; items: FilterSortOption[] }[] = []; + for (const opt of options) { + const g = groups.find((x) => x.title === (opt.group ?? null)); + if (g) g.items.push(opt); + else groups.push({ title: opt.group ?? null, items: [opt] }); + } + + return ( + <> + + setAnchor(e.currentTarget)} + aria-label={ariaLabel} + sx={{ width: 34, height: 34 }} + > + {icon} + + + setAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + PaperProps={{ sx: { minWidth: 200 } }} + > + {groups.map((group, gi) => [ + group.title ? ( +
  • + {group.title} +
  • + ) : null, + ...group.items.map((opt) => ( + { + onChange(opt.value); + setAnchor(null); + }} + > + {opt.label} + {opt.value === value && ( + + )} + + )), + ])} +
    + + ); +} + +export interface ResourceRowProps { + icon: ReactNode; + title: ReactNode; + subtitle?: ReactNode; + badges?: ReactNode; + meta?: ReactNode; + href?: string; + onDelete?: () => void; + deleteDisabled?: boolean; + children?: ReactNode; +} + +import Link from "next/link"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; + +export function ResourceRow({ + icon, + title, + subtitle, + badges, + meta, + href, + onDelete, + deleteDisabled, + children, +}: ResourceRowProps) { + const body = ( +
    +
    + {icon} +
    +
    +
    + + {title} + + {badges} +
    + {subtitle && ( +
    + {subtitle} +
    + )} + {children} +
    + {meta &&
    {meta}
    } +
    + {onDelete && ( + { + e.preventDefault(); + e.stopPropagation(); + onDelete(); + }} + disabled={deleteDisabled} + aria-label="Delete" + sx={{ + width: 32, + height: 32, + "&:hover": { color: "error.main" }, + }} + > + + + )} + {href && ( + + )} +
    +
    + ); + + const rowClasses = + "block px-6 py-4 transition-colors hover:bg-surface-hover dark:hover:bg-surface-dark-hover"; + return href ? ( + + {body} + + ) : ( +
    {body}
    + ); +} + +// Icon aliases so pages don't need to import twice. +export { FilterListIcon, SortIcon }; diff --git a/webui/src/app/ui/sidebar.tsx b/webui/src/app/ui/sidebar.tsx index abc25dbd..e92acc86 100644 --- a/webui/src/app/ui/sidebar.tsx +++ b/webui/src/app/ui/sidebar.tsx @@ -19,382 +19,394 @@ "use client"; -import { Divider, List, Typography, Paper, Box, Collapse } from "@mui/material"; -import { fetchClusters, fetchNamespaces, listNodes, listShards } from "@/app/lib/api"; -import Item from "./sidebarItem"; -import { ClusterCreation, NamespaceCreation, NodeCreation, ShardCreation } from "./formCreation"; +import { ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; 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 { usePathname } from "next/navigation"; +import SearchIcon from "@mui/icons-material/Search"; +import CloseIcon from "@mui/icons-material/Close"; +import FolderOpenIcon from "@mui/icons-material/FolderOpen"; import StorageIcon from "@mui/icons-material/Storage"; import DnsIcon from "@mui/icons-material/Dns"; import DeviceHubIcon from "@mui/icons-material/DeviceHub"; +import Item from "./sidebarItem"; +import { ClusterCreation, NamespaceCreation, NodeCreation, ShardCreation } from "./formCreation"; +import { fetchClusters, fetchNamespaces, listNodes, listShards } from "@/app/lib/api"; -// Sidebar section header component -const SidebarHeader = ({ - title, - count, - isOpen, - toggleOpen, +const FILTER_THRESHOLD = 5; +const SKELETON_WIDTHS = ["68%", "52%", "78%", "58%", "44%"]; + +interface SidebarShellProps { + label: string; + total: number; + filteredCount: number; + error: string | null; + loading: boolean; + action: ReactNode; + filterValue: string; + onFilterChange: (v: string) => void; + filterPlaceholder: string; + emptyIcon: ReactNode; + emptyTitle: string; + emptyHint: string; + children: ReactNode; +} + +function SidebarShell({ + label, + total, + filteredCount, + error, + loading, + action, + filterValue, + onFilterChange, + filterPlaceholder, + emptyIcon, + emptyTitle, + emptyHint, + children, +}: SidebarShellProps) { + const isEmpty = !loading && total === 0; + const isFilteredEmpty = !loading && total > 0 && filteredCount === 0; + const showFilter = !loading && total > FILTER_THRESHOLD; + const isFiltering = filterValue.trim().length > 0; + + return ( + + ); +} + +function SidebarSkeleton() { + return ( +
      + {SKELETON_WIDTHS.map((w, i) => ( +
    • + + +
    • + ))} +
    + ); +} + +function SidebarEmpty({ icon, + title, + hint, }: { + icon: ReactNode; title: string; - count: number; - isOpen: boolean; - toggleOpen: () => void; - icon: React.ReactNode; -}) => ( -
    -
    -
    + hint: string; +}) { + return ( +
    +
    {icon}
    - +
    {title} - - {count > 0 && ( - - {count} - - )} +
    +

    + {hint} +

    -
    - {isOpen ? ( - - ) : ( - - )} + ); +} + +function SidebarNoMatches({ query, onClear }: { query: string; onClear: () => void }) { + return ( +
    +
    + No matches for{" "} + + “{query}” + +
    +
    -
    -); + ); +} + +const matchesQuery = (haystack: string, query: string) => { + if (!query) return true; + return haystack.replace(/\t/g, " ").toLowerCase().includes(query.toLowerCase()); +}; + +// Scrolls the currently-active list item into view when the sidebar mounts or +// the pathname changes. Uses layout effect so users deep-linking to a node +// don't see a jump after paint. +function useScrollActiveIntoView(deps: unknown[]) { + const ref = useRef(null); + useLayoutEffect(() => { + if (!ref.current) return; + const active = ref.current.querySelector('[data-active="true"]'); + if (active) active.scrollIntoView({ block: "nearest" }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + return ref; +} export function NamespaceSidebar() { const [namespaces, setNamespaces] = useState([]); const [error, setError] = useState(null); - const [isOpen, setIsOpen] = useState(true); - const [sidebarWidth, setSidebarWidth] = useState(260); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(""); + const pathname = usePathname(); + const activeSlug = pathname.startsWith("/namespaces/") ? pathname.split("/")[2] : null; useEffect(() => { - const fetchData = async () => { + (async () => { try { - const fetchedNamespaces = await fetchNamespaces(); - setNamespaces(fetchedNamespaces); - } catch (err) { + setNamespaces(await fetchNamespaces()); + } catch { setError("Failed to fetch namespaces"); + } finally { + setLoading(false); } - }; - fetchData(); + })(); }, []); - const toggleSidebar = () => { - if (isMobile) { - setSidebarWidth(isOpen ? 0 : 260); - } - setIsOpen(!isOpen); - }; + const filtered = useMemo( + () => namespaces.filter((n) => matchesQuery(n, filter)), + [namespaces, filter] + ); + const listRef = useScrollActiveIntoView([loading, activeSlug, filtered.length]); return ( - + } + filterValue={filter} + onFilterChange={setFilter} + filterPlaceholder="Filter namespaces…" + emptyIcon={} + emptyTitle="No namespaces" + emptyHint="Create a namespace to organize your Kvrocks clusters." > - {isMobile && ( - - )} - -
    - - - - - - } - /> - - - -
    -
    - {error && ( -
    - {error} -
    - )} - - {namespaces.map((namespace) => ( - - - - ))} - -
    -
    -
    -
    -
    +
      + {filtered.map((namespace) => ( +
    • + + + +
    • + ))} +
    + ); } export function ClusterSidebar({ namespace }: { namespace: string }) { const [clusters, setClusters] = useState([]); const [error, setError] = useState(null); - const [isOpen, setIsOpen] = useState(true); - const [sidebarWidth, setSidebarWidth] = useState(260); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(""); + const pathname = usePathname(); + const parts = pathname.split("/"); + const activeSlug = parts[3] === "clusters" ? parts[4] ?? null : null; useEffect(() => { - const fetchData = async () => { + setLoading(true); + setFilter(""); + (async () => { try { - const fetchedClusters = await fetchClusters(namespace); - setClusters(fetchedClusters); - } catch (err) { + setClusters(await fetchClusters(namespace)); + } catch { setError("Failed to fetch clusters"); + } finally { + setLoading(false); } - }; - fetchData(); + })(); }, [namespace]); - const toggleSidebar = () => { - if (isMobile) { - setSidebarWidth(isOpen ? 0 : 260); - } - setIsOpen(!isOpen); - }; + const filtered = useMemo( + () => clusters.filter((c) => matchesQuery(c, filter)), + [clusters, filter] + ); + const listRef = useScrollActiveIntoView([loading, activeSlug, filtered.length]); return ( - } + filterValue={filter} + onFilterChange={setFilter} + filterPlaceholder="Filter clusters…" + emptyIcon={} + emptyTitle="No clusters" + emptyHint={`Add a cluster to ${namespace} to start managing shards and nodes.`} > - {isMobile && ( - - )} - -
    - - - - - - } - /> - - - -
    -
    - {error && ( -
    - {error} -
    - )} - - {clusters.map((cluster) => ( - - - - ))} - -
    -
    -
    -
    -
    +
      + {filtered.map((cluster) => ( +
    • + + + +
    • + ))} +
    + ); } export function ShardSidebar({ namespace, cluster }: { namespace: string; cluster: string }) { - const [shards, setShards] = useState([]); + const [shardCount, setShardCount] = useState(0); const [error, setError] = useState(null); - const [isOpen, setIsOpen] = useState(true); - const [sidebarWidth, setSidebarWidth] = useState(260); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(""); + const pathname = usePathname(); + const parts = pathname.split("/"); + const activeSlug = parts[5] === "shards" ? parts[6] ?? null : null; useEffect(() => { - const fetchData = async () => { + setLoading(true); + setFilter(""); + (async () => { try { - const fetchedShards = await listShards(namespace, cluster); - const shardsIndex = fetchedShards.map( - (shard, index) => "Shard\t" + (index + 1).toString() - ); - setShards(shardsIndex); - } catch (err) { + const list = await listShards(namespace, cluster); + setShardCount(Array.isArray(list) ? list.length : 0); + } catch { setError("Failed to fetch shards"); + } finally { + setLoading(false); } - }; - fetchData(); + })(); }, [namespace, cluster]); - const toggleSidebar = () => { - if (isMobile) { - setSidebarWidth(isOpen ? 0 : 260); - } - setIsOpen(!isOpen); - }; + const shards = useMemo( + () => Array.from({ length: shardCount }, (_, i) => `Shard\t${i + 1}`), + [shardCount] + ); + const filtered = useMemo( + () => shards.filter((s) => matchesQuery(s, filter)), + [shards, filter] + ); + const listRef = useScrollActiveIntoView([loading, activeSlug, filtered.length]); return ( - + } + filterValue={filter} + onFilterChange={setFilter} + filterPlaceholder="Filter shards…" + emptyIcon={} + emptyTitle="No shards" + emptyHint={`Create a shard in ${cluster} to distribute your data.`} > - {isMobile && ( - - )} - -
    - - - - - - } - /> - - - -
    -
    - {error && ( -
    - {error} -
    - )} - - {shards.map((shard, index) => ( - - - - ))} - -
    -
    -
    -
    -
    +
      + {filtered.map((shard) => { + const index = parseInt(shard.split("\t")[1]) - 1; + return ( +
    • + + + +
    • + ); + })} +
    + ); } @@ -417,116 +429,82 @@ export function NodeSidebar({ }) { const [nodes, setNodes] = useState([]); const [error, setError] = useState(null); - const [isOpen, setIsOpen] = useState(true); - const [sidebarWidth, setSidebarWidth] = useState(260); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(""); + const pathname = usePathname(); + const parts = pathname.split("/"); + const activeSlug = parts[7] === "nodes" ? parts[8] ?? null : null; useEffect(() => { - const fetchData = async () => { + setLoading(true); + setFilter(""); + (async () => { try { - const fetchedNodes = (await listNodes(namespace, cluster, shard)) as NodeItem[]; - setNodes(fetchedNodes); - } catch (err) { + const fetched = (await listNodes(namespace, cluster, shard)) as NodeItem[]; + setNodes(fetched); + } catch { setError("Failed to fetch nodes"); + } finally { + setLoading(false); } - }; - fetchData(); + })(); }, [namespace, cluster, shard]); - const toggleSidebar = () => { - if (isMobile) { - setSidebarWidth(isOpen ? 0 : 260); - } - setIsOpen(!isOpen); - }; + const filtered = useMemo(() => { + return nodes + .map((node, index) => ({ node, index })) + .filter(({ node, index }) => { + const label = `Node ${index + 1}`; + return ( + matchesQuery(label, filter) || + matchesQuery(node.addr ?? "", filter) || + matchesQuery(node.id ?? "", filter) + ); + }); + }, [nodes, filter]); + const listRef = useScrollActiveIntoView([loading, activeSlug, filtered.length]); return ( - + } + filterValue={filter} + onFilterChange={setFilter} + filterPlaceholder="Filter nodes, addr, or id…" + emptyIcon={} + emptyTitle="No nodes" + emptyHint="Add a node to bring this shard online." > - {isMobile && ( - - )} - -
    - - - - - - } - /> - - - -
    -
    - {error && ( -
    - {error} -
    - )} - - {nodes.map((node, index) => ( - - - - ))} - -
    -
    -
    -
    -
    +
      + {filtered.map(({ node, index }) => ( +
    • + + + +
    • + ))} +
    + ); } diff --git a/webui/src/app/ui/sidebarItem.tsx b/webui/src/app/ui/sidebarItem.tsx index de46a5f5..1340cc8b 100644 --- a/webui/src/app/ui/sidebarItem.tsx +++ b/webui/src/app/ui/sidebarItem.tsx @@ -18,6 +18,7 @@ */ "use client"; + import { Alert, Button, @@ -27,27 +28,19 @@ import { DialogContentText, DialogTitle, IconButton, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, Menu, MenuItem, Snackbar, - Tooltip, - Badge, } from "@mui/material"; -import MoreVertIcon from "@mui/icons-material/MoreVert"; -import { useCallback, useRef, useState } from "react"; -import { usePathname } from "next/navigation"; -import { useRouter } from "next/navigation"; -import { deleteCluster, deleteNamespace, deleteNode, deleteShard } from "../lib/api"; -import { faTrashCan } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; 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"; +import { useCallback, useRef, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { deleteCluster, deleteNamespace, deleteNode, deleteShard } from "../lib/api"; interface NamespaceItemProps { item: string; @@ -78,70 +71,52 @@ interface NodeItemProps { type ItemProps = NamespaceItemProps | ClusterItemProps | ShardItemProps | NodeItemProps; +const iconFor = (type: ItemProps["type"]) => { + const cls = "shrink-0 opacity-70"; + switch (type) { + case "namespace": + return ; + case "cluster": + return ; + case "shard": + return ; + case "node": + return ; + } +}; + export default function Item(props: ItemProps) { const { item, type } = props; - const [hover, setHover] = useState(false); - const [showMenu, setShowMenu] = useState(false); - const listItemRef = useRef(null); - const openMenu = useCallback(() => setShowMenu(true), []); - const closeMenu = useCallback(() => (setShowMenu(false), setHover(false)), []); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const openDeleteConfirmDialog = useCallback( - () => (setShowDeleteConfirm(true), closeMenu()), - [closeMenu] - ); - const closeDeleteConfirmDialog = useCallback(() => setShowDeleteConfirm(false), []); - const [errorMessage, setErrorMessage] = useState(""); + const [showMenu, setShowMenu] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + const anchorRef = useRef(null); const router = useRouter(); - let activeItem = usePathname().split("/").pop() || ""; + let activeSegment = usePathname().split("/").pop() || ""; - const getItemIcon = () => { - switch (type) { - case "namespace": - return ( - - ); - case "cluster": - return ( - - ); - case "shard": - return ( - - ); - case "node": - return ( - - ); - default: - return null; - } - }; + const openMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowMenu(true); + }, []); + const closeMenu = useCallback(() => setShowMenu(false), []); + + const openConfirm = useCallback(() => { + setShowConfirm(true); + closeMenu(); + }, [closeMenu]); + const closeConfirm = useCallback(() => setShowConfirm(false), []); const confirmDelete = useCallback(async () => { let response = ""; if (type === "namespace") { response = await deleteNamespace(item); - if (response === "") { - router.push("/namespaces"); - } - setErrorMessage(response); - router.refresh(); + if (response === "") router.push("/namespaces"); } else if (type === "cluster") { const { namespace } = props as ClusterItemProps; response = await deleteCluster(namespace, item); - if (response === "") { - router.push(`/namespaces/${namespace}`); - } - setErrorMessage(response); - router.refresh(); + if (response === "") router.push(`/namespaces/${namespace}`); } else if (type === "shard") { const { namespace, cluster } = props as ShardItemProps; response = await deleteShard( @@ -149,160 +124,87 @@ export default function Item(props: ItemProps) { cluster, (parseInt(item.split("\t")[1]) - 1).toString() ); - if (response === "") { - router.push(`/namespaces/${namespace}/clusters/${cluster}`); - } - setErrorMessage(response); - router.refresh(); + if (response === "") router.push(`/namespaces/${namespace}/clusters/${cluster}`); } else if (type === "node") { const { namespace, cluster, shard, id } = props as NodeItemProps; response = await deleteNode(namespace, cluster, shard, id); - if (response === "") { + if (response === "") router.push(`/namespaces/${namespace}/clusters/${cluster}/shards/${shard}`); - } - setErrorMessage(response); - router.refresh(); } - closeMenu(); - }, [item, type, props, closeMenu, router]); + if (response) setErrorMessage(response); + router.refresh(); + closeConfirm(); + }, [item, type, props, router, closeConfirm]); if (type === "shard") { - activeItem = "Shard\t" + (parseInt(activeItem) + 1); + activeSegment = "Shard\t" + (parseInt(activeSegment) + 1); } else if (type === "node") { - activeItem = "Node\t" + (parseInt(activeItem) + 1); + activeSegment = "Node\t" + (parseInt(activeSegment) + 1); } - const isActive = item === activeItem; - + const isActive = item === activeSegment; const displayName = item.includes("\t") ? item.split("\t")[0] + " " + item.split("\t")[1] : item; return ( - setHover(true)} - onMouseLeave={() => !showMenu && setHover(false)} - > - +
    - -
    - {getItemIcon()} -
    -
    - {displayName} + - {hover && ( - - - - )} - + aria-label="Item actions" + > + + +
    - - + + Delete - - - Confirm Delete - - - {type === "node" || type === "shard" ? ( - - Are you sure you want to delete {displayName}? - - ) : ( - - Are you sure you want to delete {type}{" "} - {item}? - - )} + + Delete {type} + + + {type === "node" || type === "shard" ? ( + <>Delete {displayName}? This cannot be undone. + ) : ( + <> + Delete {type} {item}? This cannot be undone. + + )} + - - - @@ -318,11 +220,11 @@ export default function Item(props: ItemProps) { onClose={() => setErrorMessage("")} severity="error" variant="filled" - sx={{ width: "100%", borderRadius: "8px" }} + sx={{ width: "100%" }} > {errorMessage} -
    + ); } diff --git a/webui/src/app/ui/spotlight-search.tsx b/webui/src/app/ui/spotlight-search.tsx index dd189f94..d40c7e93 100644 --- a/webui/src/app/ui/spotlight-search.tsx +++ b/webui/src/app/ui/spotlight-search.tsx @@ -19,20 +19,9 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useRouter, usePathname } from "next/navigation"; -import { - Dialog, - DialogContent, - TextField, - List, - ListItem, - ListItemButton, - Box, - Typography, - Chip, - alpha, -} from "@mui/material"; +import { Dialog, DialogContent } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import FolderIcon from "@mui/icons-material/Folder"; import StorageIcon from "@mui/icons-material/Storage"; @@ -40,8 +29,10 @@ import DnsIcon from "@mui/icons-material/Dns"; import DeviceHubIcon from "@mui/icons-material/DeviceHub"; import { fetchNamespaces, fetchClusters, listShards, listNodes } from "../lib/api"; +type SearchType = "namespace" | "cluster" | "shard" | "node"; + interface SearchResult { - type: "namespace" | "cluster" | "shard" | "node"; + type: SearchType; title: string; subtitle?: string; path: string; @@ -50,75 +41,72 @@ interface SearchResult { shard?: string; } +const iconFor = (type: SearchType) => { + switch (type) { + case "namespace": + return ; + case "cluster": + return ; + case "shard": + return ; + case "node": + return ; + } +}; + export default function SpotlightSearch() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); + const [selected, setSelected] = useState(0); const [allData, setAllData] = useState([]); const [loading, setLoading] = useState(false); const router = useRouter(); const pathname = usePathname(); - // Load all data when dialog opens - const loadAllData = useCallback(async () => { + const loadAll = useCallback(async () => { setLoading(true); try { const data: SearchResult[] = []; const namespaces = await fetchNamespaces(); - for (const ns of namespaces) { - // Add namespace - data.push({ - type: "namespace", - title: ns, - path: `/namespaces/${ns}`, - namespace: ns, - }); - - // Add clusters + data.push({ type: "namespace", title: ns, path: `/namespaces/${ns}`, namespace: ns }); const clusters = await fetchClusters(ns); for (const cluster of clusters) { data.push({ type: "cluster", title: cluster, - subtitle: `in ${ns}`, + subtitle: ns, path: `/namespaces/${ns}/clusters/${cluster}`, namespace: ns, cluster, }); - - // Add shards const shards = await listShards(ns, cluster); for (let i = 0; i < shards.length; i++) { data.push({ type: "shard", - title: `Shard ${i}`, - subtitle: `${cluster} / ${ns}`, + title: `Shard ${i + 1}`, + subtitle: `${cluster} · ${ns}`, path: `/namespaces/${ns}/clusters/${cluster}/shards/${i}`, namespace: ns, cluster, shard: String(i), }); - - // Add nodes const nodes = await listNodes(ns, cluster, String(i)); - for (let nodeIndex = 0; nodeIndex < (nodes as any[]).length; nodeIndex++) { - const node = (nodes as any[])[nodeIndex]; + (nodes as any[]).forEach((node, nodeIndex) => { data.push({ type: "node", title: node.addr || node.id, - subtitle: `${node.role} in Shard ${i} / ${cluster}`, + subtitle: `${node.role} · Shard ${i + 1} · ${cluster}`, path: `/namespaces/${ns}/clusters/${cluster}/shards/${i}/nodes/${nodeIndex}`, namespace: ns, cluster, shard: String(i), }); - } + }); } } } - setAllData(data); } catch (error) { console.error("Failed to load search data:", error); @@ -127,405 +115,183 @@ export default function SpotlightSearch() { } }, []); - // Context-aware search function - const contextAwareSearch = useCallback( - (searchQuery: string) => { - // Parse current context from pathname - const pathParts = pathname.split("/").filter(Boolean); - const currentNamespace = pathParts[1]; - const currentCluster = pathParts[3]; - const currentShard = pathParts[5]; + const contextSearch = useCallback( + (q: string) => { + const parts = pathname.split("/").filter(Boolean); + const [, ns, , cluster, , shard] = parts; - // If no query, show context-relevant items only - if (!searchQuery.trim()) { - let contextData: SearchResult[] = []; - - if (currentShard) { - // In shard page - show only nodes in this shard - contextData = allData.filter( - (item) => - item.type === "node" && - item.namespace === currentNamespace && - item.cluster === currentCluster && - item.shard === currentShard - ); - } else if (currentCluster) { - // In cluster page - show only shards in this cluster - contextData = allData.filter( - (item) => - item.type === "shard" && - item.namespace === currentNamespace && - item.cluster === currentCluster - ); - } else if (currentNamespace) { - // In namespace page - show only clusters in this namespace - contextData = allData.filter( - (item) => item.type === "cluster" && item.namespace === currentNamespace - ); - } else { - // On home/namespaces page - show only namespaces - contextData = allData.filter((item) => item.type === "namespace"); + if (!q.trim()) { + if (shard) { + return allData + .filter( + (i) => + i.type === "node" && + i.namespace === ns && + i.cluster === cluster && + i.shard === shard + ) + .slice(0, 10); } - - return contextData.slice(0, 10); + if (cluster) { + return allData + .filter( + (i) => i.type === "shard" && i.namespace === ns && i.cluster === cluster + ) + .slice(0, 10); + } + if (ns) { + return allData + .filter((i) => i.type === "cluster" && i.namespace === ns) + .slice(0, 10); + } + return allData.filter((i) => i.type === "namespace").slice(0, 10); } - // With query, search everything - const lowerQuery = searchQuery.toLowerCase(); - const filtered = allData.filter((item) => { - const searchText = - `${item.title} ${item.subtitle || ""} ${item.type}`.toLowerCase(); - return searchText.includes(lowerQuery); - }); - - return filtered.slice(0, 10); + const lower = q.toLowerCase(); + return allData + .filter((i) => `${i.title} ${i.subtitle ?? ""} ${i.type}`.toLowerCase().includes(lower)) + .slice(0, 10); }, [allData, pathname] ); - // Update results when query or pathname changes useEffect(() => { - const filtered = contextAwareSearch(query); - setResults(filtered); - setSelectedIndex(0); - }, [query, contextAwareSearch]); + setResults(contextSearch(query)); + setSelected(0); + }, [query, contextSearch]); + + const handleSelect = useCallback( + (result: SearchResult) => { + router.push(result.path); + setOpen(false); + setQuery(""); + }, + [router] + ); - // Handle keyboard shortcuts useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Cmd+K or Ctrl+K to open + const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setOpen(true); - if (!allData.length) { - loadAllData(); - } + if (!allData.length) loadAll(); } - - // Escape to close if (e.key === "Escape") { setOpen(false); setQuery(""); } - - // Arrow navigation when open if (open) { if (e.key === "ArrowDown") { e.preventDefault(); - setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1)); + setSelected((prev) => Math.min(prev + 1, results.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); - setSelectedIndex((prev) => Math.max(prev - 1, 0)); - } else if (e.key === "Enter" && results[selectedIndex]) { + setSelected((prev) => Math.max(prev - 1, 0)); + } else if (e.key === "Enter" && results[selected]) { e.preventDefault(); - handleSelect(results[selectedIndex]); + handleSelect(results[selected]); } } }; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [open, results, selectedIndex, allData.length, loadAllData]); - - const handleSelect = (result: SearchResult) => { - router.push(result.path); - setOpen(false); - setQuery(""); - }; - - const handleClose = () => { - setOpen(false); - setQuery(""); - }; - - const getTypeIcon = (type: string) => { - switch (type) { - case "namespace": - return ; - case "cluster": - return ; - case "shard": - return ; - case "node": - return ; - default: - return null; - } - }; - - const getTypeColor = (type: string) => { - switch (type) { - case "namespace": - return "#3b82f6"; // blue - case "cluster": - return "#8b5cf6"; // purple - case "shard": - return "#10b981"; // green - case "node": - return "#f59e0b"; // orange - default: - return "#6b7280"; - } - }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, results, selected, allData.length, loadAll, handleSelect]); return ( { + setOpen(false); + setQuery(""); + }} + maxWidth="sm" fullWidth PaperProps={{ sx: { position: "fixed", - top: "15%", + top: "18vh", m: 0, - borderRadius: 4, - boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.25)", - overflow: "hidden", - backdropFilter: "blur(20px)", - background: (theme) => - theme.palette.mode === "dark" - ? "rgba(30, 30, 30, 0.95)" - : "rgba(255, 255, 255, 0.95)", + borderRadius: "10px", + width: "560px", + maxWidth: "calc(100vw - 32px)", }, }} slotProps={{ - backdrop: { - sx: { - backdropFilter: "blur(4px)", - backgroundColor: "rgba(0, 0, 0, 0.3)", - }, - }, + backdrop: { sx: { backgroundColor: "rgba(15,17,22,0.4)" } }, }} > - - theme.palette.mode === "dark" - ? "rgba(255, 255, 255, 0.08)" - : "rgba(0, 0, 0, 0.08)", - }} - > - - - setQuery(e.target.value)} - variant="standard" - InputProps={{ - disableUnderline: true, - sx: { - fontSize: "1.125rem", - fontWeight: 400, - "& input::placeholder": { - opacity: 0.6, - }, - }, - }} - /> - - +
    + + setQuery(e.target.value)} + placeholder="Search namespaces, clusters, shards, nodes…" + className="w-full border-0 bg-transparent text-sm text-text-primary outline-none placeholder:text-text-muted dark:text-text-dark-primary dark:placeholder:text-text-dark-muted" + /> +
    - {loading ? ( - - - Loading resources... - - - ) : results.length > 0 ? ( - - {results.map((result, index) => ( - - handleSelect(result)} - sx={{ - borderRadius: 3, - px: 2.5, - py: 1.5, - transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)", - "&:hover": { - transform: "translateX(4px)", - bgcolor: (theme) => - theme.palette.mode === "dark" - ? alpha(getTypeColor(result.type), 0.15) - : alpha(getTypeColor(result.type), 0.08), - }, - "&.Mui-selected": { - bgcolor: (theme) => - theme.palette.mode === "dark" - ? alpha(getTypeColor(result.type), 0.2) - : alpha(getTypeColor(result.type), 0.12), - "&:hover": { - bgcolor: (theme) => - theme.palette.mode === "dark" - ? alpha(getTypeColor(result.type), 0.25) - : alpha(getTypeColor(result.type), 0.15), - }, - }, - }} - > - + {loading ? ( +
    + Loading… +
    + ) : results.length > 0 ? ( +
      + {results.map((result, index) => ( +
    • + +
    • + ))} +
    + ) : ( +
    + {query ? "No results" : "Start typing to search"} +
    + )} +
    - - theme.palette.mode === "dark" - ? "rgba(255, 255, 255, 0.08)" - : "rgba(0, 0, 0, 0.08)", - display: "flex", - gap: 3, - justifyContent: "flex-end", - bgcolor: (theme) => - theme.palette.mode === "dark" - ? "rgba(0, 0, 0, 0.2)" - : "rgba(0, 0, 0, 0.02)", - }} - > - - - ↑↓ Navigate - - - - - Select - - - - - Esc Close - - - +
    + + + + Navigate + + + + Open + + + Esc + Close + +
    ); diff --git a/webui/tailwind.config.ts b/webui/tailwind.config.ts index 387c2dda..7febebbf 100644 --- a/webui/tailwind.config.ts +++ b/webui/tailwind.config.ts @@ -28,86 +28,150 @@ const config: Config = { darkMode: "class", theme: { extend: { + fontFamily: { + sans: [ + "InterVariable", + "Inter", + "-apple-system", + "BlinkMacSystemFont", + "Segoe UI", + "Helvetica", + "Arial", + "sans-serif", + ], + mono: [ + "ui-monospace", + "SFMono-Regular", + "SF Mono", + "Menlo", + "Consolas", + "Liberation Mono", + "monospace", + ], + }, + fontSize: { + "2xs": ["0.6875rem", { lineHeight: "1rem" }], + xs: ["0.8125rem", { lineHeight: "1.125rem" }], + sm: ["0.875rem", { lineHeight: "1.25rem" }], + base: ["0.9375rem", { lineHeight: "1.4rem" }], + lg: ["1.0625rem", { lineHeight: "1.55rem" }], + xl: ["1.25rem", { lineHeight: "1.65rem" }], + "2xl": ["1.5rem", { lineHeight: "1.9rem" }], + "3xl": ["1.875rem", { lineHeight: "2.25rem" }], + "4xl": ["2.25rem", { lineHeight: "2.625rem" }], + "5xl": ["3rem", { lineHeight: "3.25rem" }], + }, colors: { + // Linear brand accent (indigo/violet) primary: { - DEFAULT: "#1976d2", - light: "#42a5f5", - dark: "#1565c0", - contrastText: "#fff", + DEFAULT: "#5e6ad2", + light: "#8d95f2", + dark: "#4a54b8", + contrastText: "#ffffff", }, secondary: { - DEFAULT: "#9c27b0", - light: "#ba68c8", - dark: "#7b1fa2", - contrastText: "#fff", + DEFAULT: "#7170ff", + light: "#a5a4ff", + dark: "#5352d9", + contrastText: "#ffffff", }, success: { - DEFAULT: "#2e7d32", - light: "#4caf50", - dark: "#1b5e20", + DEFAULT: "#4cb782", + light: "#68d9a0", + dark: "#2f8f5f", }, error: { - DEFAULT: "#d32f2f", - light: "#ef5350", - dark: "#c62828", + DEFAULT: "#eb5757", + light: "#ff7a7a", + dark: "#c94040", }, warning: { - DEFAULT: "#ed6c02", - light: "#ff9800", - dark: "#e65100", + DEFAULT: "#f2c94c", + light: "#f5da7a", + dark: "#c9a428", }, info: { - DEFAULT: "#0288d1", - light: "#03a9f4", - dark: "#01579b", + DEFAULT: "#26b5ce", + light: "#5cd0e3", + dark: "#1a8a9c", + }, + // Linear-style neutral surfaces + surface: { + // light mode + base: "#ffffff", + subtle: "#fbfbfc", + muted: "#f4f5f8", + hover: "#eeeff2", + active: "#e6e7eb", + // dark mode + "dark-base": "#08090a", + "dark-subtle": "#101113", + "dark-muted": "#181a1f", + "dark-hover": "#22262f", + "dark-active": "#2a2e37", }, + border: { + subtle: "#e6e7eb", + strong: "#d0d3d9", + "dark-subtle": "#23252a", + "dark-strong": "#33363d", + }, + text: { + primary: "#0d0e10", + secondary: "#4b4f57", + muted: "#6b7280", + "dark-primary": "#f7f8f8", + "dark-secondary": "#b4b8c0", + "dark-muted": "#8a8f98", + }, + // Legacy aliases kept so any lingering `bg-dark`/`bg-light` styles still resolve dark: { - DEFAULT: "#121212", - paper: "#1e1e1e", - border: "#333333", + DEFAULT: "#08090a", + paper: "#101113", + border: "#23252a", }, light: { - DEFAULT: "#fafafa", + DEFAULT: "#fbfbfc", paper: "#ffffff", - border: "#e0e0e0", + border: "#e6e7eb", }, }, - backgroundImage: { - "gradient-radial": "radial-gradient(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.06)", - "card-hover": "0 4px 12px rgba(0, 0, 0, 0.1)", - sidebar: "2px 0 5px rgba(0, 0, 0, 0.05)", - "footer-glow": "0 8px 25px rgba(0, 0, 0, 0.15)", - subtle: "0 2px 5px rgba(0, 0, 0, 0.05)", - elevated: "0 10px 30px rgba(0, 0, 0, 0.08)", + // Linear-style — extremely subtle, layered, never heavy + subtle: "0 1px 0 0 rgba(15, 17, 22, 0.04)", + card: "0 1px 2px 0 rgba(15, 17, 22, 0.04)", + "card-hover": "0 2px 4px 0 rgba(15, 17, 22, 0.06)", + popover: + "0 4px 12px -2px rgba(15, 17, 22, 0.08), 0 0 0 1px rgba(15, 17, 22, 0.06)", + overlay: + "0 8px 32px -8px rgba(15, 17, 22, 0.16), 0 0 0 1px rgba(15, 17, 22, 0.08)", + focus: "0 0 0 2px rgba(94, 106, 210, 0.35)", }, - transitionProperty: { - height: "height", - spacing: "margin, padding", - footer: "transform, opacity, box-shadow, border-color", + borderRadius: { + none: "0", + xs: "3px", + sm: "4px", + DEFAULT: "6px", + md: "6px", + lg: "8px", + xl: "10px", + "2xl": "12px", + "3xl": "16px", + full: "9999px", }, animation: { - "fade-in-up": "fade-in-up 0.6s ease-out forwards", - "scale-in": "scale-in 0.4s ease-out forwards", + "fade-in": "fade-in 160ms ease-out forwards", + "fade-in-up": "fade-in-up 220ms cubic-bezier(0.16, 1, 0.3, 1) forwards", }, keyframes: { + "fade-in": { + "0%": { opacity: "0" }, + "100%": { opacity: "1" }, + }, "fade-in-up": { - "0%": { opacity: "0", transform: "translateY(20px)" }, + "0%": { opacity: "0", transform: "translateY(4px)" }, "100%": { opacity: "1", transform: "translateY(0)" }, }, - "scale-in": { - "0%": { opacity: "0", transform: "scale(0.95)" }, - "100%": { opacity: "1", transform: "scale(1)" }, - }, - }, - borderRadius: { - "2xl": "16px", - "3xl": "24px", - "4xl": "32px", }, }, }, diff --git a/webui/tsconfig.json b/webui/tsconfig.json index 143f9d77..d2ef46c2 100644 --- a/webui/tsconfig.json +++ b/webui/tsconfig.json @@ -15,36 +15,44 @@ * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. - */ { - "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - }, - "target": "ES2017" - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + */{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } ], - "exclude": ["node_modules"] + "paths": { + "@/*": [ + "./src/*" + ] + }, + "target": "ES2017" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } From 91d56257ed9bc5edab787e50b26a3e809bcc46ff Mon Sep 17 00:00:00 2001 From: git-hulk Date: Sun, 5 Jul 2026 23:26:16 +0800 Subject: [PATCH 2/2] Fix lint issues --- webui/src/app/globals.css | 12 ++-- .../[namespace]/clusters/[cluster]/page.tsx | 14 ++-- .../shards/[shard]/nodes/[node]/page.tsx | 14 ++-- webui/src/app/namespaces/[namespace]/page.tsx | 30 +++----- webui/src/app/namespaces/page.tsx | 35 ++++------ webui/src/app/page.tsx | 2 +- webui/src/app/ui/failoverDialog.tsx | 5 +- webui/src/app/ui/footer.tsx | 6 +- webui/src/app/ui/formDialog.tsx | 4 +- webui/src/app/ui/pageChrome.tsx | 13 +--- webui/src/app/ui/sidebar.tsx | 30 ++------ webui/src/app/ui/spotlight-search.tsx | 11 ++- webui/tailwind.config.ts | 6 +- webui/tsconfig.json | 70 ++++++++----------- 14 files changed, 97 insertions(+), 155 deletions(-) diff --git a/webui/src/app/globals.css b/webui/src/app/globals.css index fe31cdcd..bc0f8fb0 100644 --- a/webui/src/app/globals.css +++ b/webui/src/app/globals.css @@ -46,10 +46,8 @@ --lin-warning: #f2c94c; --lin-shadow-card: 0 1px 2px 0 rgba(15, 17, 22, 0.04); - --lin-shadow-popover: - 0 4px 12px -2px rgba(15, 17, 22, 0.08), 0 0 0 1px rgba(15, 17, 22, 0.06); - --lin-shadow-overlay: - 0 8px 32px -8px rgba(15, 17, 22, 0.16), 0 0 0 1px rgba(15, 17, 22, 0.08); + --lin-shadow-popover: 0 4px 12px -2px rgba(15, 17, 22, 0.08), 0 0 0 1px rgba(15, 17, 22, 0.06); + --lin-shadow-overlay: 0 8px 32px -8px rgba(15, 17, 22, 0.16), 0 0 0 1px rgba(15, 17, 22, 0.08); --lin-topbar-height: 56px; --lin-sidebar-width: 260px; @@ -78,10 +76,8 @@ --lin-accent-muted: rgba(112, 121, 224, 0.18); --lin-shadow-card: 0 1px 2px 0 rgba(0, 0, 0, 0.35); - --lin-shadow-popover: - 0 4px 12px -2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.06); - --lin-shadow-overlay: - 0 8px 32px -8px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.08); + --lin-shadow-popover: 0 4px 12px -2px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.06); + --lin-shadow-overlay: 0 8px 32px -8px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.08); } html { diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx index 096f2f58..a12b6a55 100644 --- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx +++ b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx @@ -61,18 +61,18 @@ interface ShardData { } type FilterOption = - | "all" - | "with-migration" - | "no-migration" - | "with-slots" - | "no-slots" - | "with-importing"; + "all" | "with-migration" | "no-migration" | "with-slots" | "no-slots" | "with-importing"; type SortOption = "index-asc" | "index-desc" | "nodes-desc" | "nodes-asc"; const isActive = (value: string | null | undefined) => value !== null && value !== undefined && value !== "" && value !== "-1"; -function summarise(shards: any[]): { data: ShardData[]; nodes: number; withSlots: number; migrating: number } { +function summarise(shards: any[]): { + data: ShardData[]; + nodes: number; + withSlots: number; + migrating: number; +} { let nodes = 0; let withSlots = 0; let migrating = 0; 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 8391247f..089c15fe 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 @@ -31,15 +31,7 @@ import { NodeSidebar } from "@/app/ui/sidebar"; import { LoadingSpinner } from "@/app/ui/loadingSpinner"; import { PageHeader, PageShell } from "@/app/ui/pageChrome"; -function CopyableField({ - label, - value, - mono, -}: { - label: string; - value: string; - mono?: boolean; -}) { +function CopyableField({ label, value, mono }: { label: string; value: string; mono?: boolean }) { const [copied, setCopied] = useState(false); const copy = () => { navigator.clipboard.writeText(value); @@ -121,7 +113,9 @@ export default function NodePage(props: { const current = nodes[parseInt(node)]; if (!current) { return ( - }> + } + >
    Node not found. diff --git a/webui/src/app/namespaces/[namespace]/page.tsx b/webui/src/app/namespaces/[namespace]/page.tsx index 3dc539ed..fc99d096 100644 --- a/webui/src/app/namespaces/[namespace]/page.tsx +++ b/webui/src/app/namespaces/[namespace]/page.tsx @@ -75,12 +75,7 @@ const isActiveSlot = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value !== "-1"; type FilterOption = - | "all" - | "with-migration" - | "no-migration" - | "with-slots" - | "no-slots" - | "with-importing"; + "all" | "with-migration" | "no-migration" | "with-slots" | "no-slots" | "with-importing"; type SortOption = | "name-asc" | "name-desc" @@ -127,16 +122,16 @@ async function buildClusterData(namespace: string): Promise<{ const shards = ((clusterInfo as any).shards as any[]) || []; const nodeLists = await Promise.all( - shards.map((_, i) => listNodes(namespace, cluster, i.toString())), + shards.map((_, i) => listNodes(namespace, cluster, i.toString())) ); const nodeCount = nodeLists.reduce( (acc, nodes) => acc + (Array.isArray(nodes) ? nodes.length : 0), - 0, + 0 ); const slotCount = shards.reduce( (acc: number, s: any) => acc + parseSlotCount(s?.slot_ranges), - 0, + 0 ); const slotRanges: string[] = shards .flatMap((s: any) => (Array.isArray(s?.slot_ranges) ? s.slot_ranges : [])) @@ -164,7 +159,7 @@ async function buildClusterData(namespace: string): Promise<{ console.error(`Failed to load cluster ${cluster}:`, error); return null; } - }), + }) ); return { @@ -183,9 +178,7 @@ function formatSummary(clusters: number, shards: number, nodes: number, coverage return parts.join(" · "); } -export default function NamespacePage(props: { - params: Promise<{ namespace: string }>; -}) { +export default function NamespacePage(props: { params: Promise<{ namespace: string }> }) { const params = use(props.params); const [rows, setRows] = useState([]); const [totals, setTotals] = useState({ shards: 0, nodes: 0, slots: 0 }); @@ -485,11 +478,11 @@ function ActivityStrip({ const bits: string[] = []; if (migrating.length > 0) bits.push( - `${migrating.length} ${migrating.length === 1 ? "cluster" : "clusters"} migrating`, + `${migrating.length} ${migrating.length === 1 ? "cluster" : "clusters"} migrating` ); if (importing.length > 0) bits.push( - `${importing.length} ${importing.length === 1 ? "cluster" : "clusters"} importing`, + `${importing.length} ${importing.length === 1 ? "cluster" : "clusters"} importing` ); const active = [ @@ -586,10 +579,7 @@ function ClusterRow({ className="group grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-4 px-6 py-4 transition-colors hover:bg-surface-hover dark:hover:bg-surface-dark-hover md:grid-cols-[auto_minmax(0,1fr)_auto_auto]" aria-label={`${cluster.name}, ${stateLabel.toLowerCase()}`} > - +
    @@ -654,7 +644,7 @@ function ClusterRow({
    {stateLabel} - + {coverage}%
    diff --git a/webui/src/app/namespaces/page.tsx b/webui/src/app/namespaces/page.tsx index 05c04762..7530db6b 100644 --- a/webui/src/app/namespaces/page.tsx +++ b/webui/src/app/namespaces/page.tsx @@ -29,13 +29,7 @@ import DnsIcon from "@mui/icons-material/Dns"; import DeviceHubIcon from "@mui/icons-material/DeviceHub"; import { NamespaceSidebar } from "../ui/sidebar"; -import { - deleteNamespace, - fetchClusters, - fetchNamespaces, - listNodes, - listShards, -} from "../lib/api"; +import { deleteNamespace, fetchClusters, fetchNamespaces, listNodes, listShards } from "../lib/api"; import { LoadingSpinner } from "../ui/loadingSpinner"; import EmptyState from "../ui/emptyState"; import { @@ -59,12 +53,7 @@ interface NamespaceData { type FilterOption = "all" | "with-clusters" | "no-clusters"; type SortOption = - | "name-asc" - | "name-desc" - | "clusters-desc" - | "clusters-asc" - | "nodes-desc" - | "nodes-asc"; + "name-asc" | "name-desc" | "clusters-desc" | "clusters-asc" | "nodes-desc" | "nodes-asc"; export default function Namespaces() { const [rows, setRows] = useState([]); @@ -93,7 +82,7 @@ export default function Namespaces() { shardCount: 0, nodeCount: 0, loading: true, - })), + })) ); setTotals({ namespaces: namespaces.length, clusters: 0, shards: 0, nodes: 0 }); setLoading(false); @@ -103,7 +92,7 @@ export default function Namespaces() { try { const clusters = await fetchClusters(namespace); const shardLists = await Promise.all( - clusters.map((cluster) => listShards(namespace, cluster)), + clusters.map((cluster) => listShards(namespace, cluster)) ); let shardCount = 0; @@ -114,8 +103,8 @@ export default function Namespaces() { shardCount += shards.length; shards.forEach((_, i) => nodePromises.push( - listNodes(namespace, cluster, i.toString()), - ), + listNodes(namespace, cluster, i.toString()) + ) ); } }); @@ -123,7 +112,7 @@ export default function Namespaces() { const nodeLists = await Promise.all(nodePromises); const nodeCount = nodeLists.reduce( (acc, nodes) => acc + (Array.isArray(nodes) ? nodes.length : 0), - 0, + 0 ); if (cancelled) return; @@ -138,8 +127,8 @@ export default function Namespaces() { nodeCount, loading: false, } - : n, - ), + : n + ) ); setTotals((prev) => ({ namespaces: prev.namespaces, @@ -152,12 +141,12 @@ export default function Namespaces() { if (!cancelled) { setRows((prev) => prev.map((n) => - n.name === namespace ? { ...n, loading: false } : n, - ), + n.name === namespace ? { ...n, loading: false } : n + ) ); } } - }), + }) ); } catch (error) { console.error("Error fetching namespaces data:", error); diff --git a/webui/src/app/page.tsx b/webui/src/app/page.tsx index c65185f2..ab71293a 100644 --- a/webui/src/app/page.tsx +++ b/webui/src/app/page.tsx @@ -109,7 +109,7 @@ export default function Home() { const router = useRouter(); return ( -
    +
    diff --git a/webui/src/app/ui/failoverDialog.tsx b/webui/src/app/ui/failoverDialog.tsx index c9ea5a78..fa20782a 100644 --- a/webui/src/app/ui/failoverDialog.tsx +++ b/webui/src/app/ui/failoverDialog.tsx @@ -152,10 +152,7 @@ export const FailoverDialog: React.FC = ({
    Current master
    - +
    {masterNode.addr} diff --git a/webui/src/app/ui/footer.tsx b/webui/src/app/ui/footer.tsx index 5afc3835..97a19183 100644 --- a/webui/src/app/ui/footer.tsx +++ b/webui/src/app/ui/footer.tsx @@ -29,7 +29,11 @@ import { footerConfig } from "../../../config"; import { footerColumn, footerColumnItem } from "../lib/definitions"; const socials = [ - { icon: , href: "https://github.com/apache", label: "GitHub" }, + { + icon: , + href: "https://github.com/apache", + label: "GitHub", + }, { icon: , href: "https://x.com/TheASF", label: "Twitter" }, { icon: , diff --git a/webui/src/app/ui/formDialog.tsx b/webui/src/app/ui/formDialog.tsx index c9c9da46..5311995a 100644 --- a/webui/src/app/ui/formDialog.tsx +++ b/webui/src/app/ui/formDialog.tsx @@ -253,9 +253,7 @@ const FormDialog: React.FC = ({ size="small" disabled={submitting} startIcon={ - submitting ? ( - - ) : null + submitting ? : null } > {submitting ? "Working…" : submitButtonLabel} diff --git a/webui/src/app/ui/pageChrome.tsx b/webui/src/app/ui/pageChrome.tsx index 624f1712..dc2f81a3 100644 --- a/webui/src/app/ui/pageChrome.tsx +++ b/webui/src/app/ui/pageChrome.tsx @@ -36,7 +36,7 @@ export function PageShell({ sidebar, children }: PageShellProps) { return (
    {sidebar} -
    {children}
    +
    {children}
    ); } @@ -204,11 +204,7 @@ export function FilterSortMenu({ > {groups.map((group, gi) => [ group.title ? ( -
  • +
  • {group.title}
  • ) : null, @@ -223,10 +219,7 @@ export function FilterSortMenu({ > {opt.label} {opt.value === value && ( - + )} )), diff --git a/webui/src/app/ui/sidebar.tsx b/webui/src/app/ui/sidebar.tsx index e92acc86..b3775197 100644 --- a/webui/src/app/ui/sidebar.tsx +++ b/webui/src/app/ui/sidebar.tsx @@ -126,10 +126,7 @@ function SidebarShell({ ) : isEmpty ? ( ) : isFilteredEmpty ? ( - onFilterChange("")} - /> + onFilterChange("")} /> ) : ( children )} @@ -155,15 +152,7 @@ function SidebarSkeleton() { ); } -function SidebarEmpty({ - icon, - title, - hint, -}: { - icon: ReactNode; - title: string; - hint: string; -}) { +function SidebarEmpty({ icon, title, hint }: { icon: ReactNode; title: string; hint: string }) { return (
    @@ -285,7 +274,7 @@ export function ClusterSidebar({ namespace }: { namespace: string }) { const [filter, setFilter] = useState(""); const pathname = usePathname(); const parts = pathname.split("/"); - const activeSlug = parts[3] === "clusters" ? parts[4] ?? null : null; + const activeSlug = parts[3] === "clusters" ? (parts[4] ?? null) : null; useEffect(() => { setLoading(true); @@ -342,7 +331,7 @@ export function ShardSidebar({ namespace, cluster }: { namespace: string; cluste const [filter, setFilter] = useState(""); const pathname = usePathname(); const parts = pathname.split("/"); - const activeSlug = parts[5] === "shards" ? parts[6] ?? null : null; + const activeSlug = parts[5] === "shards" ? (parts[6] ?? null) : null; useEffect(() => { setLoading(true); @@ -363,10 +352,7 @@ export function ShardSidebar({ namespace, cluster }: { namespace: string; cluste () => Array.from({ length: shardCount }, (_, i) => `Shard\t${i + 1}`), [shardCount] ); - const filtered = useMemo( - () => shards.filter((s) => matchesQuery(s, filter)), - [shards, filter] - ); + const filtered = useMemo(() => shards.filter((s) => matchesQuery(s, filter)), [shards, filter]); const listRef = useScrollActiveIntoView([loading, activeSlug, filtered.length]); return ( @@ -376,9 +362,7 @@ export function ShardSidebar({ namespace, cluster }: { namespace: string; cluste filteredCount={filtered.length} error={error} loading={loading} - action={ - - } + action={} filterValue={filter} onFilterChange={setFilter} filterPlaceholder="Filter shards…" @@ -433,7 +417,7 @@ export function NodeSidebar({ const [filter, setFilter] = useState(""); const pathname = usePathname(); const parts = pathname.split("/"); - const activeSlug = parts[7] === "nodes" ? parts[8] ?? null : null; + const activeSlug = parts[7] === "nodes" ? (parts[8] ?? null) : null; useEffect(() => { setLoading(true); diff --git a/webui/src/app/ui/spotlight-search.tsx b/webui/src/app/ui/spotlight-search.tsx index d40c7e93..b2effabb 100644 --- a/webui/src/app/ui/spotlight-search.tsx +++ b/webui/src/app/ui/spotlight-search.tsx @@ -70,7 +70,12 @@ export default function SpotlightSearch() { const data: SearchResult[] = []; const namespaces = await fetchNamespaces(); for (const ns of namespaces) { - data.push({ type: "namespace", title: ns, path: `/namespaces/${ns}`, namespace: ns }); + data.push({ + type: "namespace", + title: ns, + path: `/namespaces/${ns}`, + namespace: ns, + }); const clusters = await fetchClusters(ns); for (const cluster of clusters) { data.push({ @@ -149,7 +154,9 @@ export default function SpotlightSearch() { const lower = q.toLowerCase(); return allData - .filter((i) => `${i.title} ${i.subtitle ?? ""} ${i.type}`.toLowerCase().includes(lower)) + .filter((i) => + `${i.title} ${i.subtitle ?? ""} ${i.type}`.toLowerCase().includes(lower) + ) .slice(0, 10); }, [allData, pathname] diff --git a/webui/tailwind.config.ts b/webui/tailwind.config.ts index 7febebbf..9737fdf4 100644 --- a/webui/tailwind.config.ts +++ b/webui/tailwind.config.ts @@ -141,10 +141,8 @@ const config: Config = { subtle: "0 1px 0 0 rgba(15, 17, 22, 0.04)", card: "0 1px 2px 0 rgba(15, 17, 22, 0.04)", "card-hover": "0 2px 4px 0 rgba(15, 17, 22, 0.06)", - popover: - "0 4px 12px -2px rgba(15, 17, 22, 0.08), 0 0 0 1px rgba(15, 17, 22, 0.06)", - overlay: - "0 8px 32px -8px rgba(15, 17, 22, 0.16), 0 0 0 1px rgba(15, 17, 22, 0.08)", + popover: "0 4px 12px -2px rgba(15, 17, 22, 0.08), 0 0 0 1px rgba(15, 17, 22, 0.06)", + overlay: "0 8px 32px -8px rgba(15, 17, 22, 0.16), 0 0 0 1px rgba(15, 17, 22, 0.08)", focus: "0 0 0 2px rgba(94, 106, 210, 0.35)", }, borderRadius: { diff --git a/webui/tsconfig.json b/webui/tsconfig.json index d2ef46c2..16f0ff75 100644 --- a/webui/tsconfig.json +++ b/webui/tsconfig.json @@ -15,44 +15,36 @@ * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. - */{ - "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": [ - "./src/*" - ] + */ { + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + }, + "target": "ES2017" }, - "target": "ES2017" - }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" - ], - "exclude": [ - "node_modules" - ] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] }