This is an automated email from the ASF dual-hosted git repository. hulk pushed a commit to branch unstable in repository https://gitbox.apache.org/repos/asf/kvrocks-controller.git
The following commit(s) were added to refs/heads/unstable by this push:
new 6bac9a4 feat(webui/shard): redesign the shard page (#325)
6bac9a4 is described below
commit 6bac9a4202a1aa3fd6a1f55e47352441242fa06c
Author: Agnik Misra <[email protected]>
AuthorDate: Mon Jul 14 12:20:17 2025 +0530
feat(webui/shard): redesign the shard page (#325)
---
.../[namespace]/clusters/[cluster]/page.tsx | 1239 ++++++++++++++++++--
webui/src/app/ui/formCreation.tsx | 12 +-
webui/src/app/ui/sidebar.tsx | 106 +-
3 files changed, 1237 insertions(+), 120 deletions(-)
diff --git a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
index a970e64..0517517 100644
--- a/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
+++ b/webui/src/app/namespaces/[namespace]/clusters/[cluster]/page.tsx
@@ -19,22 +19,90 @@
"use client";
-import { Box, Container, Typography, Chip, Badge } from "@mui/material";
+import {
+ Box,
+ Typography,
+ Chip,
+ Paper,
+ Grid,
+ Button,
+ IconButton,
+ Tooltip,
+ Popover,
+ RadioGroup,
+ FormControlLabel,
+ Radio,
+ Fade,
+ Badge,
+} from "@mui/material";
import { ClusterSidebar } from "../../../../ui/sidebar";
import { useState, useEffect } from "react";
-import { listShards } from "@/app/lib/api";
+import { listShards, listNodes, fetchCluster } from "@/app/lib/api";
import { AddShardCard, ResourceCard } from "@/app/ui/createCard";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { LoadingSpinner } from "@/app/ui/loadingSpinner";
import DnsIcon from "@mui/icons-material/Dns";
import StorageIcon from "@mui/icons-material/Storage";
+import 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 SwapHorizIcon from "@mui/icons-material/SwapHoriz";
+import { ShardCreation, MigrateSlot } from "@/app/ui/formCreation";
+
+interface ResourceCounts {
+ shards: number;
+ nodes: number;
+ withSlots: number;
+ migrating: number;
+}
+
+interface ShardData {
+ index: number;
+ nodes: any[];
+ slotRanges: string[];
+ migratingSlot: number;
+ importingSlot: number;
+ targetShardIndex: number;
+ nodeCount: number;
+ hasSlots: boolean;
+ hasMigration: boolean;
+ hasImporting: boolean;
+}
+
+type FilterOption =
+ | "all"
+ | "with-migration"
+ | "no-migration"
+ | "with-slots"
+ | "no-slots"
+ | "with-importing";
+type SortOption = "index-asc" | "index-desc" | "nodes-desc" | "nodes-asc";
export default function Cluster({ params }: { params: { namespace: string;
cluster: string } }) {
const { namespace, cluster } = params;
- const [shardsData, setShardsData] = useState<any[]>([]);
+ const [shardsData, setShardsData] = useState<ShardData[]>([]);
+ const [resourceCounts, setResourceCounts] = useState<ResourceCounts>({
+ shards: 0,
+ nodes: 0,
+ withSlots: 0,
+ migrating: 0,
+ });
const [loading, setLoading] = useState<boolean>(true);
+ const [searchTerm, setSearchTerm] = useState<string>("");
+ const [filterAnchorEl, setFilterAnchorEl] = useState<null |
HTMLElement>(null);
+ const [sortAnchorEl, setSortAnchorEl] = useState<null | HTMLElement>(null);
+ const [filterOption, setFilterOption] = useState<FilterOption>("all");
+ const [sortOption, setSortOption] = useState<SortOption>("index-asc");
const router = useRouter();
useEffect(() => {
@@ -48,7 +116,55 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
return;
}
- setShardsData(fetchedShards);
+ 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++;
+
+ // Ensure we're using the correct field names from the
API
+ // Handle null values properly - null means no
migration/import
+ // Also handle missing fields (import_slot might not
be present in all responses)
+ 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,
+ nodes: shard.nodes || [],
+ slotRanges: shard.slot_ranges || [],
+ migratingSlot,
+ importingSlot,
+ targetShardIndex: shard.target_shard_index || -1,
+ nodeCount,
+ hasSlots,
+ hasMigration,
+ hasImporting: importingSlot >= 0,
+ };
+ })
+ );
+
+ setShardsData(processedShards);
+ setResourceCounts({
+ shards: processedShards.length,
+ nodes: totalNodes,
+ withSlots,
+ migrating,
+ });
} catch (error) {
console.error("Error fetching shards:", error);
} finally {
@@ -58,6 +174,63 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
fetchData();
}, [namespace, cluster, router]);
+ const handleFilterClick = (event: React.MouseEvent<HTMLElement>) => {
+ setFilterAnchorEl(event.currentTarget);
+ };
+
+ const handleSortClick = (event: React.MouseEvent<HTMLElement>) => {
+ 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 <LoadingSpinner />;
}
@@ -70,104 +243,1002 @@ export default function Cluster({ params }: { params: {
namespace: string; clust
return (
<div className="flex h-full">
- <ClusterSidebar namespace={namespace} />
- <div className="flex-1 overflow-auto">
- <Box className="container-inner">
- <Box className="mb-6 flex items-center justify-between">
+ <div className="relative h-full">
+ <ClusterSidebar namespace={namespace} />
+ </div>
+ <div className="no-scrollbar flex-1 overflow-y-auto bg-white pb-8
dark:bg-dark">
+ <Box className="px-6 py-4 sm:px-8 sm:py-6">
+ <div className="mb-4 flex flex-col gap-3 sm:mb-5
lg:flex-row lg:items-center lg:justify-between">
<div>
<Typography
- variant="h5"
- className="flex items-center font-medium
text-gray-800 dark:text-gray-100"
+ variant="h4"
+ className="flex items-center font-medium
text-gray-900 dark:text-white"
>
- <StorageIcon className="mr-2 text-primary
dark:text-primary-light" />
+ <StorageIcon className="mr-3 text-primary
dark:text-primary-light" />
{cluster}
- <Chip
- label={`${shardsData.length} shards`}
- size="small"
- color="primary"
- className="ml-3"
- />
</Typography>
<Typography
- variant="body2"
- className="mt-1 text-gray-500
dark:text-gray-400"
+ variant="body1"
+ className="mt-0.5 text-gray-500
dark:text-gray-400"
>
- Cluster in namespace: {namespace}
+ Manage shards in this cluster
</Typography>
</div>
- </Box>
-
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2
lg:grid-cols-3 xl:grid-cols-4">
- <Box className="col-span-1">
- <AddShardCard namespace={namespace}
cluster={cluster} />
- </Box>
-
- {shardsData.length > 0 ? (
- shardsData.map((shard, index) => (
- <Link
- key={index}
-
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${index}`}
- className="col-span-1"
+
+ <div className="flex w-full flex-row items-center
gap-2 lg:w-auto">
+ <div className="search-container relative max-w-md
flex-grow transition-all duration-300 lg:min-w-[280px]">
+ <div className="search-inner relative w-full
rounded-lg bg-gray-50 transition-all duration-300 focus-within:bg-white
focus-within:shadow-md dark:bg-dark-paper/90 dark:focus-within:bg-dark-paper">
+ <div className="pointer-events-none
absolute inset-y-0 left-3 flex items-center">
+ <SearchIcon
+ className="text-gray-400"
+ sx={{ fontSize: 18 }}
+ />
+ </div>
+ <input
+ type="text"
+ placeholder="Search shards..."
+ className="w-full rounded-lg border-0
bg-transparent py-2.5 pl-9 pr-4 text-sm text-gray-800 outline-none ring-1
ring-gray-200 transition-all focus:ring-2 focus:ring-primary dark:text-gray-200
dark:ring-gray-700 dark:focus:ring-primary-light"
+ value={searchTerm}
+ onChange={(e) =>
setSearchTerm(e.target.value)}
+ />
+ {searchTerm && (
+ <button
+ className="absolute inset-y-0
right-3 flex items-center text-gray-400 transition-colors hover:text-gray-600
dark:hover:text-gray-300"
+ onClick={() => setSearchTerm("")}
+ >
+ <span className="text-xs">✕</span>
+ </button>
+ )}
+ </div>
+ </div>
+
+ <div className="flex flex-shrink-0 gap-3">
+ <ShardCreation
+ position="page"
+ namespace={namespace}
+ cluster={cluster}
>
- <ResourceCard
- title={`Shard ${index + 1}`}
- tags={[
- {
- label: `${shard.nodes.length}
nodes`,
- color: "secondary",
- },
- ...(shard.migrating_slot >= 0
- ? [{ label: "Migrating",
color: "warning" }]
- : []),
- ]}
+ <Button
+ variant="outlined"
+ color="primary"
+ className="whitespace-nowrap
rounded-lg px-5 py-2.5 font-medium shadow-sm transition-all hover:shadow-md"
+ startIcon={<AddIcon />}
+ disableElevation
+ size="medium"
>
- <div className="space-y-2 text-sm">
- <div className="flex
justify-between">
- <span className="text-gray-500
dark:text-gray-400">
- Slots:
- </span>
- <span className="font-medium">
-
{formatSlotRanges(shard.slot_ranges)}
- </span>
- </div>
+ Create Shard
+ </Button>
+ </ShardCreation>
+ <MigrateSlot
+ position="page"
+ namespace={namespace}
+ cluster={cluster}
+ >
+ <Button
+ variant="outlined"
+ color="warning"
+ className="whitespace-nowrap
rounded-lg px-5 py-2.5 font-medium shadow-sm transition-all hover:shadow-md"
+ startIcon={<SwapHorizIcon />}
+ disableElevation
+ size="medium"
+ >
+ Migrate Slot
+ </Button>
+ </MigrateSlot>
+ </div>
+ </div>
+ </div>
- {shard.target_shard_index >= 0 && (
- <div className="flex
justify-between">
- <span
className="text-gray-500 dark:text-gray-400">
- Target Shard:
- </span>
- <span
className="font-medium">
-
{shard.target_shard_index + 1}
- </span>
- </div>
- )}
-
- {shard.migrating_slot >= 0 && (
- <div className="flex
justify-between">
- <span
className="text-gray-500 dark:text-gray-400">
- Migrating Slot:
- </span>
- <Badge color="warning"
variant="dot">
- <span
className="font-medium">
-
{shard.migrating_slot}
+ <div className="mb-4 sm:mb-5">
+ <Grid container spacing={2}>
+ <Grid item xs={12} sm={6} lg={3}>
+ <Paper
+ elevation={0}
+ className="relative h-full overflow-hidden
rounded-2xl border border-gray-100 p-4 transition-all hover:-translate-y-1
hover:shadow-md dark:border-gray-800 dark:bg-dark-paper"
+ >
+ <div className="flex items-center
justify-between">
+ <div className="flex h-12 w-12
items-center justify-center rounded-xl bg-purple-50 text-purple-500
dark:bg-purple-900/30 dark:text-purple-400">
+ <DnsIcon sx={{ fontSize: 24 }} />
+ </div>
+ <div className="flex flex-col
items-end">
+ <Typography
+ variant="h4"
+ className="font-semibold
text-gray-900 dark:text-white"
+ >
+ {resourceCounts.shards}
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ Shards
+ </Typography>
+ </div>
+ </div>
+ <div className="absolute -bottom-4
-right-4 h-24 w-24 rounded-full bg-purple-500/5 blur-xl"></div>
+ </Paper>
+ </Grid>
+
+ <Grid item xs={12} sm={6} lg={3}>
+ <Paper
+ elevation={0}
+ className="relative h-full overflow-hidden
rounded-2xl border border-gray-100 p-4 transition-all hover:-translate-y-1
hover:shadow-md dark:border-gray-800 dark:bg-dark-paper"
+ >
+ <div className="flex items-center
justify-between">
+ <div className="flex h-12 w-12
items-center justify-center rounded-xl bg-green-50 text-green-500
dark:bg-green-900/30 dark:text-green-400">
+ <DeviceHubIcon sx={{ fontSize: 24
}} />
+ </div>
+ <div className="flex flex-col
items-end">
+ <Typography
+ variant="h4"
+ className="font-semibold
text-gray-900 dark:text-white"
+ >
+ {resourceCounts.nodes}
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ Nodes
+ </Typography>
+ </div>
+ </div>
+ <div className="absolute -bottom-4
-right-4 h-24 w-24 rounded-full bg-green-500/5 blur-xl"></div>
+ </Paper>
+ </Grid>
+
+ <Grid item xs={12} sm={6} lg={3}>
+ <Paper
+ elevation={0}
+ className="relative h-full overflow-hidden
rounded-2xl border border-gray-100 p-4 transition-all hover:-translate-y-1
hover:shadow-md dark:border-gray-800 dark:bg-dark-paper"
+ >
+ <div className="flex items-center
justify-between">
+ <div className="flex h-12 w-12
items-center justify-center rounded-xl bg-amber-50 text-amber-500
dark:bg-amber-900/30 dark:text-amber-400">
+ <StorageIcon sx={{ fontSize: 24 }}
/>
+ </div>
+ <div className="flex flex-col
items-end">
+ <Typography
+ variant="h4"
+ className="font-semibold
text-gray-900 dark:text-white"
+ >
+ {resourceCounts.withSlots}
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ With Slots
+ </Typography>
+ </div>
+ </div>
+ <div className="absolute -bottom-4
-right-4 h-24 w-24 rounded-full bg-amber-500/5 blur-xl"></div>
+ </Paper>
+ </Grid>
+
+ <Grid item xs={12} sm={6} lg={3}>
+ <Paper
+ elevation={0}
+ className="relative h-full overflow-hidden
rounded-2xl border border-gray-100 p-4 transition-all hover:-translate-y-1
hover:shadow-md dark:border-gray-800 dark:bg-dark-paper"
+ >
+ <div className="flex items-center
justify-between">
+ <div className="flex h-12 w-12
items-center justify-center rounded-xl bg-orange-50 text-orange-500
dark:bg-orange-900/30 dark:text-orange-400">
+ <WarningIcon sx={{ fontSize: 24 }}
/>
+ </div>
+ <div className="flex flex-col
items-end">
+ <Typography
+ variant="h4"
+ className="font-semibold
text-gray-900 dark:text-white"
+ >
+ {resourceCounts.migrating}
+ </Typography>
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ Migrating
+ </Typography>
+ </div>
+ </div>
+ <div className="absolute -bottom-4
-right-4 h-24 w-24 rounded-full bg-orange-500/5 blur-xl"></div>
+ </Paper>
+ </Grid>
+ </Grid>
+ </div>
+
+ <Paper
+ elevation={0}
+ className="overflow-hidden rounded-2xl border
border-gray-100 transition-all hover:shadow-md dark:border-gray-800
dark:bg-dark-paper"
+ >
+ <div className="border-b border-gray-100 px-6 py-3
dark:border-gray-800 sm:px-8">
+ <div className="flex items-center justify-between">
+ <Typography
+ variant="h6"
+ className="font-medium text-gray-800
dark:text-gray-100"
+ >
+ All Shards
+ </Typography>
+ <div className="flex items-center gap-2">
+ <Tooltip title="Filter">
+ <IconButton
+ size="small"
+ onClick={handleFilterClick}
+ aria-describedby={filterId}
+ className="rounded-full bg-gray-50
text-gray-500 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400
dark:hover:bg-gray-700"
+ >
+ <FilterListIcon fontSize="small" />
+ </IconButton>
+ </Tooltip>
+ <Tooltip title="Sort">
+ <IconButton
+ size="small"
+ onClick={handleSortClick}
+ aria-describedby={sortId}
+ className="rounded-full bg-gray-50
text-gray-500 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-400
dark:hover:bg-gray-700"
+ >
+ <SortIcon fontSize="small" />
+ </IconButton>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+
+ <Popover
+ id={filterId}
+ open={isFilterOpen}
+ anchorEl={filterAnchorEl}
+ onClose={handleFilterClose}
+ anchorOrigin={{
+ vertical: "bottom",
+ horizontal: "right",
+ }}
+ transformOrigin={{
+ vertical: "top",
+ horizontal: "right",
+ }}
+ TransitionComponent={Fade}
+ PaperProps={{
+ className:
+ "rounded-xl shadow-xl border
border-gray-100 dark:border-gray-700",
+ elevation: 3,
+ sx: { width: 280 },
+ }}
+ >
+ <div className="p-4">
+ <div className="mb-3 flex items-center
justify-between border-b border-gray-100 pb-2 dark:border-gray-700">
+ <Typography variant="subtitle1"
className="font-medium">
+ Filter Shards
+ </Typography>
+ </div>
+
+ <RadioGroup
+ value={filterOption}
+ onChange={(e) =>
+ setFilterOption(e.target.value as
FilterOption)
+ }
+ >
+ <div className="space-y-2">
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="all"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <span
className="text-sm font-medium">
+ All shards
</span>
- </Badge>
- </div>
- )}
+ <Chip
+ size="small"
+
label={shardsData.length}
+ className="ml-2"
+ sx={{ height: 20,
fontSize: "0.7rem" }}
+ />
+ </div>
+ }
+ className="m-0 w-full"
+ />
</div>
- </ResourceCard>
- </Link>
- ))
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="with-migration"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <span
className="text-sm font-medium">
+ With migration
+ </span>
+ <Chip
+ size="small"
+ label={
+
shardsData.filter(
+ (shard) =>
shard.hasMigration
+ ).length
+ }
+ className="ml-2"
+ sx={{ height: 20,
fontSize: "0.7rem" }}
+ />
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="no-migration"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <span
className="text-sm font-medium">
+ No migration
+ </span>
+ <Chip
+ size="small"
+ label={
+
shardsData.filter(
+ (shard) =>
!shard.hasMigration
+ ).length
+ }
+ className="ml-2"
+ sx={{ height: 20,
fontSize: "0.7rem" }}
+ />
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="with-slots"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <span
className="text-sm font-medium">
+ With slots
+ </span>
+ <Chip
+ size="small"
+ label={
+
shardsData.filter(
+ (shard) =>
shard.hasSlots
+ ).length
+ }
+ className="ml-2"
+ sx={{ height: 20,
fontSize: "0.7rem" }}
+ />
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="no-slots"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <span
className="text-sm font-medium">
+ No slots
+ </span>
+ <Chip
+ size="small"
+ label={
+
shardsData.filter(
+ (shard) =>
!shard.hasSlots
+ ).length
+ }
+ className="ml-2"
+ sx={{ height: 20,
fontSize: "0.7rem" }}
+ />
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="with-importing"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <span
className="text-sm font-medium">
+ With importing
+ </span>
+ <Chip
+ size="small"
+ label={
+
shardsData.filter(
+ (shard) =>
shard.hasImporting
+ ).length
+ }
+ className="ml-2"
+ sx={{ height: 20,
fontSize: "0.7rem" }}
+ />
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+ </div>
+ </RadioGroup>
+
+ <div className="mt-4 flex justify-end">
+ <Button
+ variant="text"
+ size="small"
+ onClick={handleFilterClose}
+ className="rounded-lg px-3 py-1
text-xs"
+ >
+ Close
+ </Button>
+ </div>
+ </div>
+ </Popover>
+
+ <Popover
+ id={sortId}
+ open={isSortOpen}
+ anchorEl={sortAnchorEl}
+ onClose={handleSortClose}
+ anchorOrigin={{
+ vertical: "bottom",
+ horizontal: "right",
+ }}
+ transformOrigin={{
+ vertical: "top",
+ horizontal: "right",
+ }}
+ TransitionComponent={Fade}
+ PaperProps={{
+ className:
+ "rounded-xl shadow-xl border
border-gray-100 dark:border-gray-700",
+ elevation: 3,
+ sx: { width: 280 },
+ }}
+ >
+ <div className="p-4">
+ <div className="mb-3 flex items-center
justify-between border-b border-gray-100 pb-2 dark:border-gray-700">
+ <Typography variant="subtitle1"
className="font-medium">
+ Sort Shards
+ </Typography>
+ </div>
+
+ <RadioGroup
+ value={sortOption}
+ onChange={(e) =>
setSortOption(e.target.value as SortOption)}
+ >
+ <div className="space-y-2">
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="index-asc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowUpwardIcon
+ style={{ fontSize:
16 }}
+ className="mr-1
text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ Index
1-{shardsData.length}
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="index-desc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowDownwardIcon
+ style={{ fontSize:
16 }}
+ className="mr-1
text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ Index
{shardsData.length}-1
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="nodes-desc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowDownwardIcon
+ style={{ fontSize:
16 }}
+ className="mr-1
text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ Most nodes
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+
+ <div className="rounded-lg bg-gray-50
p-2 dark:bg-gray-800">
+ <FormControlLabel
+ value="nodes-asc"
+ control={
+ <Radio
+ size="small"
+
className="text-primary"
+ checkedIcon={
+ <div
className="flex h-5 w-5 items-center justify-center rounded-full border-2
border-primary bg-primary text-white">
+ <CheckIcon
+ style={{
fontSize: 12 }}
+ />
+ </div>
+ }
+ />
+ }
+ label={
+ <div className="flex
items-center">
+ <ArrowUpwardIcon
+ style={{ fontSize:
16 }}
+ className="mr-1
text-gray-500"
+ />
+ <span
className="text-sm font-medium">
+ Least nodes
+ </span>
+ </div>
+ }
+ className="m-0 w-full"
+ />
+ </div>
+ </div>
+ </RadioGroup>
+
+ <div className="mt-4 flex justify-end">
+ <Button
+ variant="text"
+ size="small"
+ onClick={handleSortClose}
+ className="rounded-lg px-3 py-1
text-xs"
+ >
+ Close
+ </Button>
+ </div>
+ </div>
+ </Popover>
+
+ {filteredAndSortedShards.length > 0 ? (
+ <div className="divide-y divide-gray-100
dark:divide-gray-800">
+ {filteredAndSortedShards.map((shard) => (
+ <div
+ key={shard.index}
+ className="group p-2 transition-colors
hover:bg-gray-50 dark:hover:bg-gray-800/30"
+ >
+ <Paper
+ elevation={0}
+ className="overflow-hidden
rounded-xl border border-transparent bg-white p-4 transition-all
group-hover:border-primary/10 group-hover:shadow-sm dark:bg-dark-paper
dark:group-hover:border-primary-dark/20"
+ >
+ <div className="flex flex-col
items-start sm:flex-row sm:items-center">
+ <div className="mb-3 flex h-14
w-14 flex-shrink-0 items-center justify-center rounded-xl bg-green-50
text-green-500 dark:bg-green-900/30 dark:text-green-400 sm:mb-0">
+ <DnsIcon sx={{ fontSize:
28 }} />
+ </div>
+
+ <div className="flex flex-1
flex-col sm:ml-5 sm:flex-row sm:items-center sm:overflow-hidden">
+ <div className="flex-1
overflow-hidden">
+ <Link
+
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard.index}`}
+ className="block"
+ >
+ <div
className="flex items-center gap-2">
+ <Typography
+
variant="h6"
+
className="truncate font-medium text-gray-900 transition-colors
hover:text-primary dark:text-gray-100 dark:hover:text-primary-light"
+ >
+ Shard
{shard.index + 1}
+ </Typography>
+
+
{shard.hasMigration &&
+
shard.migratingSlot >= 0 && (
+ <div
className="flex items-center gap-1 rounded-full border border-orange-200
bg-orange-50 px-2.5 py-1 dark:border-orange-800 dark:bg-orange-900/30">
+
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-orange-500"></div>
+
<span className="text-xs font-medium text-orange-700 dark:text-orange-300">
+
Migrating{" "}
+
{
+
shard.migratingSlot
+
}
+
</span>
+ </div>
+ )}
+
+
{!shard.hasMigration &&
+
!shard.hasImporting && (
+ <div
className="flex items-center gap-1 rounded-full border border-green-200
bg-green-50 px-2.5 py-1 dark:border-green-800 dark:bg-green-900/30">
+
<div className="h-1.5 w-1.5 rounded-full bg-green-500"></div>
+
<span className="text-xs font-medium text-green-700 dark:text-green-300">
+
Stable
+
</span>
+ </div>
+ )}
+
+
{shard.hasImporting &&
+
shard.importingSlot >= 0 && (
+ <div
className="flex items-center gap-1 rounded-full border border-blue-200
bg-blue-50 px-2.5 py-1 dark:border-blue-800 dark:bg-blue-900/30">
+
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500"></div>
+
<span className="text-xs font-medium text-blue-700 dark:text-blue-300">
+
Importing{" "}
+
{
+
shard.importingSlot
+
}
+
</span>
+ </div>
+ )}
+ </div>
+
+ <div
className="mt-2 space-y-1">
+ <Typography
+
variant="body2"
+
className="flex items-center text-gray-500 dark:text-gray-400"
+ >
+
<DeviceHubIcon
+ sx={{
fontSize: 14 }}
+
className="mr-1"
+ />
+
{shard.nodeCount} nodes
+ </Typography>
+
+
{shard.hasSlots ? (
+ <div
className="flex items-center">
+
<Typography
+
variant="body2"
+
className="flex items-center text-gray-500 dark:text-gray-400"
+ >
+
<StorageIcon
+
sx={{
+
fontSize: 14,
+
}}
+
className="mr-1"
+ />
+
Slots:{" "}
+
{formatSlotRanges(
+
shard.slotRanges
+ )}
+
</Typography>
+ <Chip
+
size="small"
+
label={`${shard.slotRanges.length} range${shard.slotRanges.length !== 1 ? "s" :
""}`}
+
color="primary"
+
variant="outlined"
+
className="ml-2"
+
sx={{
+
height: 20,
+
fontSize: "0.7rem",
+ }}
+ />
+ </div>
+ ) : (
+ <Typography
+
variant="body2"
+
className="flex items-center text-gray-400 dark:text-gray-500"
+ >
+
<StorageIcon
+
sx={{ fontSize: 14 }}
+
className="mr-1"
+ />
+ No
slots assigned
+
</Typography>
+ )}
+
+
{shard.targetShardIndex >= 0 && (
+ <Typography
+
variant="body2"
+
className="flex items-center text-amber-600 dark:text-amber-400"
+ >
+
<InfoIcon
+
sx={{ fontSize: 14 }}
+
className="mr-1"
+ />
+ Target
shard:{" "}
+
{shard.targetShardIndex + 1}
+
</Typography>
+ )}
+ </div>
+ </Link>
+ </div>
+
+ <div className="mt-3 flex
space-x-2 overflow-x-auto sm:ml-6 sm:mt-0 md:hidden lg:flex xl:hidden 2xl:flex">
+ <Chip
+ icon={
+ <DeviceHubIcon
fontSize="small" />
+ }
+
label={`${shard.nodeCount} nodes`}
+ size="small"
+ color="secondary"
+ variant="outlined"
+
className="whitespace-nowrap"
+ />
+
+ {shard.hasSlots ? (
+ <Chip
+ icon={
+
<StorageIcon fontSize="small" />
+ }
+
label={`${shard.slotRanges.length} slot${shard.slotRanges.length !== 1 ? "s" :
""}`}
+ size="small"
+ color="primary"
+
variant="outlined"
+
className="whitespace-nowrap"
+ />
+ ) : (
+ <Chip
+
icon={<InfoIcon fontSize="small" />}
+ label="No
slots"
+ size="small"
+ color="info"
+
variant="outlined"
+
className="whitespace-nowrap"
+ />
+ )}
+
+ {shard.hasMigration &&
(
+ <Chip
+ icon={
+
<WarningIcon fontSize="small" />
+ }
+
label={`Migrating ${shard.migratingSlot}`}
+ size="small"
+ color="warning"
+
variant="outlined"
+
className="whitespace-nowrap"
+ />
+ )}
+
+ {shard.hasImporting &&
(
+ <Chip
+
icon={<InfoIcon fontSize="small" />}
+
label={`Importing ${shard.importingSlot}`}
+ size="small"
+ color="info"
+
variant="outlined"
+
className="whitespace-nowrap"
+ />
+ )}
+
+
{shard.targetShardIndex >= 0 && (
+ <Chip
+
label={`Target: ${shard.targetShardIndex + 1}`}
+ size="small"
+ color="primary"
+
variant="outlined"
+
className="whitespace-nowrap"
+ />
+ )}
+ </div>
+
+ <div className="hidden
space-x-3 sm:ml-8 md:flex lg:hidden xl:flex 2xl:hidden">
+ <div className="flex
flex-col items-center rounded-lg border border-gray-100 bg-gray-50 px-4 py-2
dark:border-gray-800 dark:bg-gray-800/50">
+ <Typography
+
variant="caption"
+
className="text-gray-500 dark:text-gray-400"
+ >
+ Nodes
+ </Typography>
+ <Typography
+
variant="subtitle1"
+
className="font-semibold text-gray-900 dark:text-white"
+ >
+
{shard.nodeCount}
+ </Typography>
+ </div>
+
+ <div className="flex
flex-col items-center rounded-lg border border-gray-100 bg-gray-50 px-4 py-2
dark:border-gray-800 dark:bg-gray-800/50">
+ <Typography
+
variant="caption"
+
className="text-gray-500 dark:text-gray-400"
+ >
+ Slots
+ </Typography>
+ <Typography
+
variant="subtitle1"
+
className="font-semibold text-gray-900 dark:text-white"
+ >
+ {shard.hasSlots
+ ?
shard.slotRanges.length
+ : 0}
+ </Typography>
+ </div>
+
+ <div className="flex
flex-col items-center rounded-lg border border-gray-100 bg-gray-50 px-4 py-2
dark:border-gray-800 dark:bg-gray-800/50">
+ <Typography
+
variant="caption"
+
className="text-gray-500 dark:text-gray-400"
+ >
+ Status
+ </Typography>
+ <Typography
+
variant="subtitle1"
+
className="font-semibold text-gray-900 dark:text-white"
+ >
+
{shard.hasMigration
+ ?
"Migrating"
+ :
shard.hasImporting
+ ?
"Importing"
+ :
"Stable"}
+ </Typography>
+ </div>
+ </div>
+
+ <div className="ml-2 mt-3
flex items-center space-x-2 sm:mt-0">
+ <Link
+
href={`/namespaces/${namespace}/clusters/${cluster}/shards/${shard.index}`}
+
className="rounded-full bg-primary/10 p-2 text-primary transition-colors
hover:bg-primary/20 dark:bg-primary-dark/20 dark:text-primary-light
dark:hover:bg-primary-dark/30"
+ >
+ <ChevronRightIcon
/>
+ </Link>
+ </div>
+ </div>
+ </div>
+ </Paper>
+ </div>
+ ))}
+ </div>
) : (
- <Box className="col-span-full">
+ <div className="p-12">
<EmptyState
- title="No shards found"
- description="Create a shard to get started"
- icon={<DnsIcon sx={{ fontSize: 60 }} />}
+ title={
+ filterOption !== "all"
+ ? "No matching shards"
+ : "No shards found"
+ }
+ description={
+ filterOption !== "all"
+ ? "Try changing your filter
settings"
+ : searchTerm
+ ? "Try adjusting your search
term"
+ : "Create a shard to get started"
+ }
+ icon={<DnsIcon sx={{ fontSize: 64 }} />}
+ action={{
+ label: "Create Shard",
+ onClick: () => {},
+ }}
/>
- </Box>
+ <div className="hidden">
+ <ShardCreation
+ position="card"
+ namespace={namespace}
+ cluster={cluster}
+ />
+ </div>
+ </div>
)}
- </div>
+
+ {filteredAndSortedShards.length > 0 && (
+ <div className="bg-gray-50 px-6 py-4
dark:bg-gray-800/30 sm:px-8">
+ <Typography
+ variant="body2"
+ className="text-gray-500
dark:text-gray-400"
+ >
+ Showing {filteredAndSortedShards.length}
of {shardsData.length}{" "}
+ shards
+ </Typography>
+ </div>
+ )}
+ </Paper>
</Box>
</div>
</div>
diff --git a/webui/src/app/ui/formCreation.tsx
b/webui/src/app/ui/formCreation.tsx
index dbdcf0d..0a731aa 100644
--- a/webui/src/app/ui/formCreation.tsx
+++ b/webui/src/app/ui/formCreation.tsx
@@ -45,6 +45,7 @@ type ShardFormProps = {
position: string;
namespace: string;
cluster: string;
+ children?: React.ReactNode;
};
type NodeFormProps = {
@@ -158,7 +159,12 @@ export const ClusterCreation: React.FC<ClusterFormProps> =
({ position, namespac
);
};
-export const ShardCreation: React.FC<ShardFormProps> = ({ position, namespace,
cluster }) => {
+export const ShardCreation: React.FC<ShardFormProps> = ({
+ position,
+ namespace,
+ cluster,
+ children,
+}) => {
const router = useRouter();
const handleSubmit = async (formData: FormData) => {
const fieldsToValidate = ["nodes"];
@@ -203,7 +209,9 @@ export const ShardCreation: React.FC<ShardFormProps> = ({
position, namespace, c
},
]}
onSubmit={handleSubmit}
- />
+ >
+ {children}
+ </FormDialog>
);
};
diff --git a/webui/src/app/ui/sidebar.tsx b/webui/src/app/ui/sidebar.tsx
index 8fcedc4..9fcbe60 100644
--- a/webui/src/app/ui/sidebar.tsx
+++ b/webui/src/app/ui/sidebar.tsx
@@ -185,6 +185,18 @@ export function ClusterSidebar({ namespace }: { namespace:
string }) {
const [clusters, setClusters] = useState<string[]>([]);
const [error, setError] = useState<string | null>(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);
+ }, []);
useEffect(() => {
const fetchData = async () => {
@@ -198,52 +210,78 @@ export function ClusterSidebar({ namespace }: {
namespace: string }) {
fetchData();
}, [namespace]);
+ const toggleSidebar = () => {
+ if (isMobile) {
+ setSidebarWidth(isOpen ? 0 : 260);
+ }
+ setIsOpen(!isOpen);
+ };
+
return (
<Paper
- className="flex h-full w-64 flex-col overflow-hidden border-r
border-light-border/50 bg-white/90 backdrop-blur-sm dark:border-dark-border/50
dark:bg-dark-paper/90"
+ className="sidebar-container flex h-full flex-col overflow-hidden
border-r border-light-border/50 bg-white/90 backdrop-blur-sm transition-all
duration-300 dark:border-dark-border/50 dark:bg-dark-paper/90"
elevation={0}
sx={{
+ width: `${sidebarWidth}px`,
+ minWidth: isMobile ? 0 : "260px",
+ maxWidth: "260px",
borderTopRightRadius: "16px",
borderBottomRightRadius: "16px",
boxShadow: "4px 0 15px rgba(0, 0, 0, 0.03)",
+ transition: "width 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}}
>
- <Box className="p-4 pb-2">
- <ClusterCreation namespace={namespace} position="sidebar" />
- </Box>
+ {isMobile && (
+ <button
+ onClick={toggleSidebar}
+ className="sidebar-toggle-btn absolute -right-10 top-4
z-50 flex h-9 w-9 items-center justify-center rounded-full bg-white
text-gray-600 shadow-lg transition-all hover:bg-gray-50 dark:bg-dark-paper
dark:text-gray-300 dark:hover:bg-dark-border"
+ >
+ {isOpen ? (
+ <ChevronRightIcon />
+ ) : (
+ <ChevronRightIcon sx={{ transform: "rotate(180deg)" }}
/>
+ )}
+ </button>
+ )}
- <Box className="px-4 py-2">
- <SidebarHeader
- title="Clusters"
- count={clusters.length}
- isOpen={isOpen}
- toggleOpen={() => setIsOpen(!isOpen)}
- icon={<StorageIcon fontSize="small" />}
- />
- </Box>
+ <div className="sidebar-inner w-[260px]">
+ <Box className="p-4 pb-2">
+ <ClusterCreation namespace={namespace} position="sidebar"
/>
+ </Box>
- <Collapse in={isOpen} className="flex-1 overflow-hidden">
- <div className="h-full overflow-hidden px-4">
- <div className="custom-scrollbar max-h-[calc(100vh-180px)]
overflow-y-auto rounded-xl bg-gray-50/50 p-2 dark:bg-dark-border/20">
- {error && (
- <div className="my-2 rounded-lg bg-red-50 p-2
text-center text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
- {error}
- </div>
- )}
- <List className="p-0">
- {clusters.map((cluster) => (
- <Link
-
href={`/namespaces/${namespace}/clusters/${cluster}`}
- passHref
- key={cluster}
- >
- <Item type="cluster" item={cluster}
namespace={namespace} />
- </Link>
- ))}
- </List>
+ <Box className="px-4 py-2">
+ <SidebarHeader
+ title="Clusters"
+ count={clusters.length}
+ isOpen={isOpen}
+ toggleOpen={toggleSidebar}
+ icon={<StorageIcon fontSize="small" />}
+ />
+ </Box>
+
+ <Collapse in={isOpen} className="flex-1 overflow-hidden">
+ <div className="h-full overflow-hidden px-4">
+ <div className="sidebar-scrollbar
max-h-[calc(100vh-200px)] overflow-y-auto rounded-xl bg-gray-50/50 p-2
dark:bg-dark-border/20">
+ {error && (
+ <div className="my-2 rounded-lg bg-red-50 p-2
text-center text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400">
+ {error}
+ </div>
+ )}
+ <List className="p-0">
+ {clusters.map((cluster) => (
+ <Link
+
href={`/namespaces/${namespace}/clusters/${cluster}`}
+ passHref
+ key={cluster}
+ >
+ <Item type="cluster" item={cluster}
namespace={namespace} />
+ </Link>
+ ))}
+ </List>
+ </div>
</div>
- </div>
- </Collapse>
+ </Collapse>
+ </div>
</Paper>
);
}
