diff --git a/README.md b/README.md index 4bb517c..576f8ec 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,12 @@ The server exposes a set of endpoints for cluster management and raw data operat | POST | /api/raw/delete | Delete a key-value pair. | `{"key": "mykey"}` | | POST | /api/raw/scan | Scan a range of keys. | `{"start_key": "a", "end_key": "z", "limit": 100}` | +### Metrics + +| Method | Endpoint | Description | +| ------ | -------- | -------------------------------------- | +| GET | /metrics | PD and TiKV metrics from the instances | + ## 🤝 Contributing We welcome contributions from the community! If you're interested in making the TiKV Admin Web UI even better: diff --git a/app/app/globals.css b/app/app/globals.css index ddb5519..ebc2048 100644 --- a/app/app/globals.css +++ b/app/app/globals.css @@ -2,6 +2,13 @@ @import "tw-animate-css"; @theme { + --breakpoint-sm: 640px; + --breakpoint-md: 768px; + --breakpoint-lg: 1024px; + --breakpoint-xl: 1280px; + --breakpoint-2xl: 1536px; + --breakpoint-3xl: 2160px; + --color-background: hsl(0 0% 100%); --color-foreground: hsl(240 10% 3.9%); --color-card: hsl(0 0% 100%); @@ -23,6 +30,11 @@ --color-ring: hsl(346.8 77.2% 49.8%); --radius: 0.65rem; --color-menu: #eee; + --chart-1: hsl(346.8 77.2% 49.8%); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); } @layer base { @@ -47,6 +59,11 @@ --color-border: hsl(240 3.7% 15.9%); --color-input: hsl(240 3.7% 15.9%); --color-ring: hsl(346.8 77.2% 49.8%); + --chart-1: hsl(346.8 77.2% 49.8%); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); } * { diff --git a/app/app/layout.tsx b/app/app/layout.tsx index 0cd702c..587b2c3 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -4,6 +4,9 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { Toaster } from "@/components/ui/sonner"; import TiKV from "@/assets/img/tikv.webp"; +import Sidebar from "@/components/sidebar"; +import Provider from "@/components/provider"; +import Cluster from "@/components/cluster"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -33,8 +36,14 @@ export default function RootLayout({ - {children} - + +
+ + {children} +
+ + +
); diff --git a/app/app/metrics/page.tsx b/app/app/metrics/page.tsx new file mode 100644 index 0000000..69d2767 --- /dev/null +++ b/app/app/metrics/page.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useMetrics } from "@/hooks/use-metrics"; +import { + CircleXIcon, + CircleCheckIcon, + ServerIcon, + ClockIcon, + HeartIcon, + HardDriveIcon, + InfoIcon, + ChartSplineIcon, + ServerCrashIcon, +} from "lucide-react"; +import { Area, AreaChart, Line, LineChart, XAxis, YAxis } from "recharts"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { MetricCard } from "@/components/ui/metric-card"; +import { Spinner } from "@/components/ui/spinner"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { + parseBytes, + formatUptime, + getFormatter, + processGaugeData, +} from "@/lib/utils"; + +dayjs.extend(relativeTime); + +export default function MetricsPage() { + const { isLoading, data } = useMetrics(); + return ( +
+
+ Metrics +
+ {isLoading ? ( +
+
+ +
+
loading metrics...
+
+ ) : ( + +
+ PD Stores ({data?.pd?.count}) +
+
+ {data?.pd?.stores.map((store) => ( + +
+
+
+
+ Node: #{store.store.id} +
+
+ + {store.store.address} +
+
+ + {store.store.status_address} +
+
+
+ + {store.store.state_name.toLocaleLowerCase() === "up" ? ( + + + Online + + ) : ( + + + Offline + + )} +
+
+
+ +
+
+ uptime +
+
+ {formatUptime(store.status.uptime)} +
+
+
+
+ +
+
+ heartbeat +
+
+ {dayjs(store.store.last_heartbeat / 1000000).format( + "HH:mm:ss" + )} +
+
+
+
+
+
+
+ Storage +
+
+ {store.status.used_size} / {store.status.capacity} +
+
+
+
+
+
+
+ {store.status.available} available +
+
+ {( + (parseBytes(store.status.used_size) / + parseBytes(store.status.capacity)) * + 100 + ).toFixed(2)} + % used +
+
+
+
+ ))} +
+
+ TiKV Overview +
+
+ {data?.tikv?.gauges.map((gauge: any, i: number) => { + const { data: chartData, series } = processGaugeData( + gauge.points + ); + + const formatter = getFormatter(gauge.unit); + + const dynamicConfig: ChartConfig = { + ...series.reduce((acc, key, index) => { + const colorVar = `--chart-${(index % 5) + 1}`; + acc[key] = { + label: key, + color: `var(${colorVar})`, + }; + return acc; + }, {} as Record), + }; + + return ( + +
+ {gauge.name} +
+ + {series.length > 1 ? ( + + ( +
+
+
+ + {name} + +
+ + {formatter(value as number)} + +
+ )} + /> + } + /> + + dayjs(value).format("HH:mm") + } + /> + + {series.slice(0, 10).map((key) => ( + + ))} + + ) : ( + + ( +
+
+
+ + {name} + +
+ + {formatter(value as number)} + +
+ )} + /> + } + /> + + dayjs(value).format("HH:mm") + } + /> + + + {series.map((key, index) => ( + + + + + ))} + + {series.map((key, index) => ( + + ))} + + )} + +
+ {gauge.description} +
+ + ); + })} +
+ + )} +
+ ); +} diff --git a/app/app/page.tsx b/app/app/page.tsx index 574bac5..b013ff4 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -5,11 +5,9 @@ import { useEffect, useState } from "react"; import { DeleteKeyDialog } from "@/components/dialogs/deletekey"; import { useKeys, ScanItem } from "@/hooks/use-keys"; -import Sidebar from "@/components/sidebar"; import { KeyList } from "@/components/key/list"; import { KeyDetailsContent } from "@/components/key/content"; import { KeyDetailsHeader } from "@/components/key/header"; -import Cluster from "@/components/cluster"; import { useCluster } from "@/hooks/use-cluster"; export default function Home() { @@ -73,19 +71,13 @@ export default function Home() { }; return ( -
+ <>
- Select a key to view details
)} - loadKeys("", "", true)} - switchCluster={switchCluster} - clusters={clusters} - />
-
+ ); } diff --git a/app/components/cluster.tsx b/app/components/cluster.tsx index dff3ddc..c7ed28b 100644 --- a/app/components/cluster.tsx +++ b/app/components/cluster.tsx @@ -1,30 +1,21 @@ -import { CheckCircle2Icon, ServerIcon } from "lucide-react"; -import { useEffect, useState } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { cn } from "@/lib/utils"; -import { ClusterInfo } from "@/hooks/use-cluster"; +"use client"; -export default function Cluster({ - onChange, - switchCluster, - clusters, -}: { - onChange: () => void; - switchCluster: (name: string) => Promise; - clusters: ClusterInfo[]; -}) { +import { CheckCircle2Icon } from "lucide-react"; +import { useState } from "react"; +import { useCluster } from "@/hooks/use-cluster"; +import { useKeys } from "@/hooks/use-keys"; +import { SwitchClusterDialog } from "@/components/dialogs/switch-cluster"; + +export default function Cluster() { + const { loadKeys } = useKeys(); + const { clusters, switchCluster } = useCluster(); const [open, setOpen] = useState(false); const activeCluster = clusters.find((c) => c.active); const handleSwitch = async (name: string) => { await switchCluster(name); - onChange?.(); + loadKeys("", "", true); setOpen(false); }; @@ -47,45 +38,12 @@ export default function Cluster({ - - - - Switch Cluster - -
- {clusters.map((cluster) => ( -
handleSwitch(cluster.name)} - > -
- -
- {cluster.name} - - {cluster.pd_addrs.join(", ")} - -
-
- {cluster.active && ( - - )} -
- ))} - {clusters.length === 0 && ( -
- No clusters found. -
- )} -
-
-
+ ); } diff --git a/app/components/dialogs/switch-cluster.tsx b/app/components/dialogs/switch-cluster.tsx new file mode 100644 index 0000000..c438636 --- /dev/null +++ b/app/components/dialogs/switch-cluster.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { CheckCircle2Icon, ServerIcon } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { ClusterInfo } from "@/hooks/use-cluster"; + +interface SwitchClusterDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + clusters: ClusterInfo[]; + onSwitch: (name: string) => void; +} + +export function SwitchClusterDialog({ + open, + onOpenChange, + clusters, + onSwitch, +}: SwitchClusterDialogProps) { + return ( + + + + Switch Cluster + +
+ {clusters.map((cluster) => ( +
onSwitch(cluster.name)} + > +
+ +
+ {cluster.name} + + {cluster.pd_addrs.join(", ")} + +
+
+ {cluster.active && ( + + )} +
+ ))} + {clusters.length === 0 && ( +
+ No clusters found. +
+ )} +
+
+
+ ); +} + diff --git a/app/components/key/list.tsx b/app/components/key/list.tsx index 14c4ad1..a82a6b8 100644 --- a/app/components/key/list.tsx +++ b/app/components/key/list.tsx @@ -1,10 +1,17 @@ -import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "@/components/ui/button"; -import { BanIcon, KeyIcon } from "lucide-react"; +import { BanIcon, KeyIcon, PlusIcon, SearchIcon } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; import { ScanItem } from "@/hooks/use-keys"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "../ui/input-group"; +import { useState } from "react"; +import { AddKeyDialog } from "../dialogs/addkey"; interface KeyListProps { keys: ScanItem[]; @@ -21,6 +28,7 @@ interface KeyListProps { replaceList?: boolean ) => Promise; getNextKey: (key: string) => string; + addKey: (key: string, value: string) => Promise; } export function KeyList({ @@ -34,87 +42,124 @@ export function KeyList({ setSelectedItem, loadKeys, getNextKey, + addKey, }: KeyListProps) { + const [addDialogOpen, setAddDialogOpen] = useState(false); + + const handleAddWrapper = async (key: string, value: string) => { + await addKey(key, value); + // Refresh list + if (searchQuery) { + const endKey = getNextKey(searchQuery); + loadKeys(searchQuery, endKey, true); + } else { + loadKeys("", "", true); + } + }; + return ( -
-
- setSearchQuery(e.target.value)} - /> -
- + <> +
- {loading && keys.length === 0 && ( -
- Loading keys... -
- )} - {error &&
Error: {error}
} - {!loading && !error && keys.length === 0 && ( -
- - No keys found -
- )} - {keys.length > 0 && ( -
- {keys.map((item, index) => { - return ( -
setSelectedItem(item)} - className={cn( - "p-2 rounded hover:bg-primary/30 cursor-pointer text-sm font-mono w-[280px] rounded-md", - selectedItem?.key === item.key - ? "bg-primary text-white" - : "" - )} - > -
- -
{item.key}
+ + + + + setSearchQuery(e.target.value)} + /> + + setAddDialogOpen(true)} + > + + + + +
+ +
+ {loading && keys.length === 0 && ( +
+ Loading keys... +
+ )} + {error &&
Error: {error}
} + {!loading && !error && keys.length === 0 && ( +
+ + No keys found +
+ )} + {keys.length > 0 && ( +
+ {keys.map((item, index) => { + return ( +
setSelectedItem(item)} + title={item.key} + className={cn( + "p-2 py-3 rounded hover:bg-secondary cursor-pointer text-sm font-mono w-[355px] rounded-md", + selectedItem?.key === item.key + ? "bg-primary text-white" + : "" + )} + > +
+ +
{item.key}
+
-
- ); - })} + ); + })} - {hasMore ? ( -
- -
- ) : ( -
- No more keys -
- )} -
- )} -
- -
+ {hasMore ? ( +
+ +
+ ) : ( +
+ No more keys +
+ )} +
+ )} +
+
+
+ + ); } diff --git a/app/components/provider.tsx b/app/components/provider.tsx new file mode 100644 index 0000000..80724d9 --- /dev/null +++ b/app/components/provider.tsx @@ -0,0 +1,16 @@ +"use client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + }, + }, +}); + +export default function Provider({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index f152150..50f4226 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -1,39 +1,21 @@ +"use client"; + import { useState } from "react"; import { Button } from "./ui/button"; -import { PlusIcon, SettingsIcon } from "lucide-react"; +import { ListIcon, ServerIcon, SettingsIcon } from "lucide-react"; import Image from "next/image"; import TiKV from "@/assets/img/tikv.webp"; import { SettingsDialog } from "./dialogs/settings"; -import { AddKeyDialog } from "./dialogs/addkey"; - -interface SidebarProps { - addKey: (key: string, value: string) => Promise; - loadKeys: (key: string, endKey: string, reverse: boolean) => void; - getNextKey: (key: string) => string; - searchQuery: string; - listClusters: () => void; -} +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { useCluster } from "@/hooks/use-cluster"; +import { useKeys } from "@/hooks/use-keys"; -export default function Sidebar({ - addKey, - loadKeys, - getNextKey, - searchQuery, - listClusters, -}: SidebarProps) { +export default function Sidebar() { const [settingsOpen, setSettingsOpen] = useState(false); - const [addDialogOpen, setAddDialogOpen] = useState(false); - - const handleAddWrapper = async (key: string, value: string) => { - await addKey(key, value); - // Refresh list - if (searchQuery) { - const endKey = getNextKey(searchQuery); - loadKeys(searchQuery, endKey, true); - } else { - loadKeys("", "", true); - } - }; + const path = usePathname(); + const { listClusters } = useCluster(); + const { loadKeys } = useKeys(); return ( <>
@@ -42,12 +24,30 @@ export default function Sidebar({
+