mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
feat: add Cluster management page and update API response types
This commit is contained in:
+11
-9
@@ -1,12 +1,13 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider, useTheme } from '@/components/theme-provider';
|
||||
import { Layout } from '@/components/layout/layout';
|
||||
import { Dashboard } from '@/pages/Dashboard';
|
||||
import { Buckets } from '@/pages/Buckets';
|
||||
import { AccessControl } from '@/pages/AccessControl';
|
||||
import { Toaster } from 'sonner';
|
||||
import { queryClient } from '@/lib/query-client';
|
||||
import {BrowserRouter, Route, Routes} from 'react-router-dom';
|
||||
import {QueryClientProvider} from '@tanstack/react-query';
|
||||
import {ThemeProvider, useTheme} from '@/components/theme-provider';
|
||||
import {Layout} from '@/components/layout/layout';
|
||||
import {Dashboard} from '@/pages/Dashboard';
|
||||
import {Buckets} from '@/pages/Buckets';
|
||||
import {Cluster} from '@/pages/Cluster';
|
||||
import {AccessControl} from '@/pages/AccessControl';
|
||||
import {Toaster} from 'sonner';
|
||||
import {queryClient} from '@/lib/query-client';
|
||||
|
||||
function ThemedToaster() {
|
||||
const { theme } = useTheme();
|
||||
@@ -23,6 +24,7 @@ function App() {
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="buckets" element={<Buckets />} />
|
||||
<Route path="cluster" element={<Cluster />} />
|
||||
<Route path="access" element={<AccessControl />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -280,7 +280,7 @@ export function ObjectsTable({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{obj.isFolder ? 'Folder' : getFileType(obj.key.replace(currentPath, ''))}
|
||||
{obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{obj.storageClass && (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PieChart, Pie, Cell, Legend, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import type { BucketUsage } from '@/types';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { chartColorPalette, getTextColor, getTooltipStyle } from '@/lib/chart-colors';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip} from 'recharts';
|
||||
import type {BucketUsage} from '@/types';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors';
|
||||
|
||||
interface BucketUsageChartProps {
|
||||
data: BucketUsage[];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import type { ClusterHealth } from '@/types';
|
||||
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import type {ClusterHealth} from '@/types';
|
||||
import {getGridColor, getTextColor, getTooltipStyle, grafanaColors} from '@/lib/chart-colors';
|
||||
|
||||
interface ClusterHealthChartProps {
|
||||
data: ClusterHealth;
|
||||
@@ -32,13 +32,13 @@ export function ClusterHealthChart({ data }: ClusterHealthChartProps) {
|
||||
const chartData = [
|
||||
{
|
||||
metric: 'Nodes',
|
||||
healthy: data.healthyStorageNodes,
|
||||
unhealthy: data.declaredStorageNodes - data.healthyStorageNodes,
|
||||
healthy: data.storageNodesUp,
|
||||
unhealthy: data.storageNodes - data.storageNodesUp,
|
||||
},
|
||||
{
|
||||
metric: 'Partitions',
|
||||
healthy: data.healthyPartitions,
|
||||
unhealthy: data.totalPartitions - data.healthyPartitions,
|
||||
healthy: data.partitionsAllOk,
|
||||
unhealthy: data.partitions - data.partitionsAllOk,
|
||||
},
|
||||
{
|
||||
metric: 'Connected',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Link, useLocation} from 'react-router-dom';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {Database, Key, LayoutDashboard,} from 'lucide-react';
|
||||
import {Database, Key, LayoutDashboard, Server} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
title: string;
|
||||
@@ -19,6 +19,11 @@ const navItems: NavItem[] = [
|
||||
href: '/buckets',
|
||||
icon: Database,
|
||||
},
|
||||
{
|
||||
title: 'Cluster',
|
||||
href: '/cluster',
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
title: 'Access Control',
|
||||
href: '/access',
|
||||
@@ -71,17 +76,17 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="border-t p-4">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
|
||||
AD
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="text-sm font-medium truncate">Admin User</p>
|
||||
<p className="text-xs text-muted-foreground truncate">admin@garage.local</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/*<div className="border-t p-4">*/}
|
||||
{/* <div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">*/}
|
||||
{/* <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">*/}
|
||||
{/* AD*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="flex-1 overflow-hidden">*/}
|
||||
{/* <p className="text-sm font-medium truncate">Admin User</p>*/}
|
||||
{/* <p className="text-xs text-muted-foreground truncate">admin@garage.local</p>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import type {
|
||||
BucketDetails,
|
||||
ClusterHealth,
|
||||
ClusterStatistics,
|
||||
ClusterStatus,
|
||||
GarageMetrics,
|
||||
NodeInfo,
|
||||
MultiNodeResponse,
|
||||
MultiNodeStatisticsResponse,
|
||||
ObjectListResponse,
|
||||
ObjectMetadata,
|
||||
S3Object,
|
||||
@@ -261,8 +263,7 @@ export const garageApi = {
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getClusterStatus: async (): Promise<any> => {
|
||||
getClusterStatus: async (): Promise<ClusterStatus> => {
|
||||
const response = await api.get('/v1/cluster/status');
|
||||
return response.data.data;
|
||||
},
|
||||
@@ -272,23 +273,21 @@ export const garageApi = {
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getNodeInfo: async (nodeId: string = 'self'): Promise<NodeInfo> => {
|
||||
getNodeInfo: async (nodeId: string = 'self'): Promise<MultiNodeResponse> => {
|
||||
const response = await api.get(`/v1/cluster/nodes/${nodeId}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getNodeStatistics: async (nodeId: string): Promise<any> => {
|
||||
getNodeStatistics: async (nodeId: string): Promise<MultiNodeStatisticsResponse> => {
|
||||
const response = await api.get(`/v1/cluster/nodes/${nodeId}/statistics`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getFullMetrics: async (): Promise<GarageMetrics> => {
|
||||
// Fetch all cluster-related metrics
|
||||
const [health, statistics, nodeInfo, storageMetrics] = await Promise.all([
|
||||
const [health, statistics, storageMetrics] = await Promise.all([
|
||||
garageApi.getClusterHealth(),
|
||||
garageApi.getClusterStatistics(),
|
||||
garageApi.getNodeInfo(),
|
||||
analyticsApi.getMetrics(),
|
||||
]);
|
||||
|
||||
@@ -296,7 +295,6 @@ export const garageApi = {
|
||||
...storageMetrics,
|
||||
clusterHealth: health,
|
||||
clusterStatistics: statistics,
|
||||
nodeInfo: nodeInfo,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {garageApi} from '@/lib/api';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import type {ClusterNode, LocalNodeInfo, NodeStatistics} from '@/types';
|
||||
import {useState} from 'react';
|
||||
|
||||
export function Cluster() {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
|
||||
const { data: health, isLoading: healthLoading } = useQuery({
|
||||
queryKey: ['cluster-health'],
|
||||
queryFn: () => garageApi.getClusterHealth(),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['cluster-status'],
|
||||
queryFn: () => garageApi.getClusterStatus(),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const { data: statistics, isLoading: statisticsLoading } = useQuery({
|
||||
queryKey: ['cluster-statistics'],
|
||||
queryFn: () => garageApi.getClusterStatistics(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: nodeInfo, isLoading: nodeInfoLoading } = useQuery({
|
||||
queryKey: ['node-info', selectedNodeId || '*'],
|
||||
queryFn: () => garageApi.getNodeInfo(selectedNodeId || '*'),
|
||||
enabled: !!selectedNodeId || selectedNodeId === null,
|
||||
});
|
||||
|
||||
const { data: nodeStats } = useQuery({
|
||||
queryKey: ['node-statistics', selectedNodeId || '*'],
|
||||
queryFn: () => garageApi.getNodeStatistics(selectedNodeId || '*'),
|
||||
enabled: !!selectedNodeId,
|
||||
});
|
||||
|
||||
const isLoading = healthLoading || statusLoading || statisticsLoading;
|
||||
|
||||
const getHealthStatus = () => {
|
||||
if (!health) return { color: 'text-gray-500', bgColor: 'bg-gray-100', label: 'Unknown', icon: AlertCircle };
|
||||
if (
|
||||
health.storageNodesUp === health.storageNodes &&
|
||||
health.partitionsAllOk === health.partitions &&
|
||||
health.connectedNodes === health.knownNodes
|
||||
) {
|
||||
return { color: 'text-green-600', bgColor: 'bg-green-100', label: 'Healthy', icon: CheckCircle2 };
|
||||
}
|
||||
if (health.storageNodesUp > 0 && health.partitionsQuorum > 0) {
|
||||
return { color: 'text-yellow-600', bgColor: 'bg-yellow-100', label: 'Degraded', icon: AlertCircle };
|
||||
}
|
||||
return { color: 'text-red-600', bgColor: 'bg-red-100', label: 'Unhealthy', icon: XCircle };
|
||||
};
|
||||
|
||||
const healthStatus = getHealthStatus();
|
||||
const HealthIcon = healthStatus.icon;
|
||||
|
||||
const getNodeStatus = (node: ClusterNode) => {
|
||||
if (!node.isUp) {
|
||||
return { color: 'text-red-600', bgColor: 'bg-red-100', label: 'Down', icon: XCircle };
|
||||
}
|
||||
if (node.draining) {
|
||||
return { color: 'text-yellow-600', bgColor: 'bg-yellow-100', label: 'Draining', icon: AlertCircle };
|
||||
}
|
||||
return { color: 'text-green-600', bgColor: 'bg-green-100', label: 'Up', icon: CheckCircle2 };
|
||||
};
|
||||
|
||||
const formatUptime = (seconds?: number) => {
|
||||
if (!seconds) return 'N/A';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Cluster" />
|
||||
<div className="p-4 sm:p-6 flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading cluster information...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title="Cluster Management" />
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Cluster Health Overview */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
|
||||
<HealthIcon className={`h-4 w-4 ${healthStatus.color}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${healthStatus.color}`}>{healthStatus.label}</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Layout v{status?.layoutVersion || 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Connected Nodes</CardTitle>
|
||||
<Network className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{health?.connectedNodes || 0}/{health?.knownNodes || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nodes online
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Storage Nodes</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{health?.storageNodesUp || 0}/{health?.storageNodes || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy storage nodes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Partitions</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{health?.partitionsAllOk || 0}/{health?.partitions || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy partitions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs for different views */}
|
||||
<Tabs defaultValue="nodes" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="nodes">Nodes</TabsTrigger>
|
||||
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Nodes Tab */}
|
||||
<TabsContent value="nodes" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cluster Nodes</CardTitle>
|
||||
<CardDescription>
|
||||
Overview of all nodes in the Garage cluster
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{status?.nodes && status.nodes.length > 0 ? (
|
||||
status.nodes.map((node) => {
|
||||
const nodeStatus = getNodeStatus(node);
|
||||
const NodeIcon = nodeStatus.icon;
|
||||
const dataUsage = node.dataPartition
|
||||
? ((node.dataPartition.total - node.dataPartition.available) / node.dataPartition.total) * 100
|
||||
: 0;
|
||||
const metadataUsage = node.metadataPartition
|
||||
? ((node.metadataPartition.total - node.metadataPartition.available) / node.metadataPartition.total) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={node.id}
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
selectedNodeId === node.id ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedNodeId(node.id)}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<NodeIcon className={`h-5 w-5 ${nodeStatus.color}`} />
|
||||
<div>
|
||||
<div className="font-mono text-sm font-medium">
|
||||
{node.id.substring(0, 16)}...
|
||||
</div>
|
||||
{node.hostname && (
|
||||
<div className="text-xs text-muted-foreground">{node.hostname}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Status</div>
|
||||
<Badge variant={node.isUp ? 'default' : 'destructive'} className="mt-1">
|
||||
{nodeStatus.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{node.addr && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Address</div>
|
||||
<div className="text-sm font-mono">{node.addr}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.garageVersion && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Version</div>
|
||||
<div className="text-sm">{node.garageVersion}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.role && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Zone</div>
|
||||
<div className="text-sm">{node.role.zone}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{node.role?.capacity && (
|
||||
<div className="pt-2">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Capacity: {formatBytes(node.role.capacity)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(node.dataPartition || node.metadataPartition) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
|
||||
{node.dataPartition && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Data Partition: {formatBytes(node.dataPartition.total - node.dataPartition.available)} / {formatBytes(node.dataPartition.total)}
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
dataUsage > 90 ? 'bg-red-500' : dataUsage > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${dataUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.metadataPartition && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Metadata Partition: {formatBytes(node.metadataPartition.total - node.metadataPartition.available)} / {formatBytes(node.metadataPartition.total)}
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
metadataUsage > 90 ? 'bg-red-500' : metadataUsage > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${metadataUsage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!node.isUp && node.lastSeenSecsAgo !== undefined && (
|
||||
<div className="text-xs text-muted-foreground pt-2">
|
||||
<Clock className="inline h-3 w-3 mr-1" />
|
||||
Last seen: {node.lastSeenSecsAgo === null ? 'Never' : formatUptime(node.lastSeenSecsAgo) + ' ago'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Server className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No nodes found in the cluster</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Statistics Tab */}
|
||||
<TabsContent value="statistics" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cluster Statistics</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed statistics and metrics from the Garage cluster
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{statistics ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{statistics.freeform}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Activity className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No statistics available</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Details Tab */}
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
{selectedNodeId ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Node Information</CardTitle>
|
||||
<CardDescription>
|
||||
Detailed information for node: {selectedNodeId.substring(0, 16)}...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{nodeInfoLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block h-6 w-6 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading node info...</p>
|
||||
</div>
|
||||
) : nodeInfo ? (
|
||||
<div className="space-y-4">
|
||||
{/* Success responses */}
|
||||
{Object.entries(nodeInfo.success || {}).map(([nodeId, info]) => (
|
||||
<div key={nodeId} className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<h4 className="font-medium">
|
||||
Node: {nodeId.substring(0, 16)}...
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Node ID</div>
|
||||
<div className="font-mono text-sm break-all">{(info as LocalNodeInfo).nodeId}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Garage Version</div>
|
||||
<div className="text-sm">{(info as LocalNodeInfo).garageVersion}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Rust Version</div>
|
||||
<div className="text-sm">{(info as LocalNodeInfo).rustVersion}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">Database Engine</div>
|
||||
<div className="text-sm">{(info as LocalNodeInfo).dbEngine}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(info as LocalNodeInfo).garageFeatures && (info as LocalNodeInfo).garageFeatures!.length > 0 && (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-xs text-muted-foreground mb-2">Garage Features</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(info as LocalNodeInfo).garageFeatures!.map((feature) => (
|
||||
<Badge key={feature} variant="secondary">
|
||||
{feature}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Error responses */}
|
||||
{Object.entries(nodeInfo.error || {}).map(([nodeId, error]) => (
|
||||
<div key={nodeId} className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-1">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<div className="font-medium">Error for node {nodeId.substring(0, 16)}...</div>
|
||||
</div>
|
||||
<div className="text-sm text-red-800">{error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Info className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No node information available</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{nodeStats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Node Statistics</CardTitle>
|
||||
<CardDescription>
|
||||
Performance metrics for the selected node
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Success responses */}
|
||||
{Object.entries(nodeStats.success || {}).map(([nodeId, stats]) => (
|
||||
<div key={nodeId} className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-4 w-4 text-primary" />
|
||||
<h4 className="font-medium">
|
||||
Statistics for: {nodeId.substring(0, 16)}...
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap font-mono">
|
||||
{(stats as NodeStatistics).freeform}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Error responses */}
|
||||
{Object.entries(nodeStats.error || {}).map(([nodeId, error]) => (
|
||||
<div key={nodeId} className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="flex items-center gap-2 text-red-600 mb-1">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<div className="font-medium">Error for node {nodeId.substring(0, 16)}...</div>
|
||||
</div>
|
||||
<div className="text-sm text-red-800">{error}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
<Server className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-2">Select a Node</p>
|
||||
<p className="text-sm">
|
||||
Click on a node in the Nodes tab to view detailed information and statistics
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { Database, FolderOpen, HardDrive, Activity, Server, Zap, AlertCircle } from 'lucide-react';
|
||||
import { BucketUsageChart } from '@/components/charts/BucketUsageChart';
|
||||
import { RequestMetricsChart } from '@/components/charts/RequestMetricsChart';
|
||||
import { useDashboardData } from '@/hooks/useApi';
|
||||
import type { ClusterHealth } from '@/types';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react';
|
||||
import {BucketUsageChart} from '@/components/charts/BucketUsageChart';
|
||||
import {useDashboardData} from '@/hooks/useApi';
|
||||
import type {ClusterHealth} from '@/types';
|
||||
|
||||
export function Dashboard() {
|
||||
const { metrics: metricsQuery, buckets: bucketsQuery, health: healthQuery, isLoading } = useDashboardData();
|
||||
@@ -17,15 +16,15 @@ export function Dashboard() {
|
||||
const getHealthStatus = (health: ClusterHealth | null) => {
|
||||
if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle };
|
||||
if (
|
||||
health.healthyStorageNodes === health.declaredStorageNodes &&
|
||||
health.healthyPartitions === health.totalPartitions &&
|
||||
health.storageNodesUp === health.storageNodes &&
|
||||
health.partitionsAllOk === health.partitions &&
|
||||
health.connectedNodes === health.knownNodes
|
||||
) {
|
||||
return { color: 'text-green-500', label: 'Healthy', icon: Zap };
|
||||
}
|
||||
if (
|
||||
health.healthyStorageNodes > 0 &&
|
||||
health.healthyPartitions > 0
|
||||
health.storageNodesUp > 0 &&
|
||||
health.partitionsQuorum > 0
|
||||
) {
|
||||
return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle };
|
||||
}
|
||||
@@ -53,7 +52,7 @@ export function Dashboard() {
|
||||
<Header title="Dashboard" />
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Top Stats Grid */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
|
||||
@@ -96,28 +95,6 @@ export function Dashboard() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Requests (24h)</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics
|
||||
? (
|
||||
metrics.requestMetrics.getRequests +
|
||||
metrics.requestMetrics.putRequests +
|
||||
metrics.requestMetrics.deleteRequests +
|
||||
metrics.requestMetrics.listRequests
|
||||
).toLocaleString()
|
||||
: null}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GET, PUT, DELETE, LIST
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cluster Status */}
|
||||
@@ -146,7 +123,7 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{clusterHealth?.healthyStorageNodes || 0}/{clusterHealth?.declaredStorageNodes || 0}
|
||||
{clusterHealth?.storageNodesUp || 0}/{clusterHealth?.storageNodes || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy storage nodes
|
||||
@@ -161,7 +138,7 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{clusterHealth?.healthyPartitions || 0}/{clusterHealth?.totalPartitions || 0}
|
||||
{clusterHealth?.partitionsAllOk || 0}/{clusterHealth?.partitions || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy partitions
|
||||
@@ -187,59 +164,44 @@ export function Dashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Request Metrics Chart */}
|
||||
{/* Bucket Details Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Request Metrics</CardTitle>
|
||||
<CardDescription>API request distribution (24h)</CardDescription>
|
||||
<CardTitle>Storage Usage by Bucket (Table)</CardTitle>
|
||||
<CardDescription>Detailed breakdown of storage across all buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{metrics?.requestMetrics ? (
|
||||
<RequestMetricsChart data={metrics.requestMetrics} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No data available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Bucket Details Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Storage Usage by Bucket (Table)</CardTitle>
|
||||
<CardDescription>Detailed breakdown of storage across all buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
|
||||
metrics.usageByBucket.map((bucket) => (
|
||||
<div key={bucket.bucketName} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm flex-wrap gap-2">
|
||||
<span className="font-medium">{bucket.bucketName}</span>
|
||||
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm">
|
||||
<div className="space-y-4">
|
||||
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
|
||||
metrics.usageByBucket.map((bucket) => (
|
||||
<div key={bucket.bucketName} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm flex-wrap gap-2">
|
||||
<span className="font-medium">{bucket.bucketName}</span>
|
||||
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{bucket.objectCount.toLocaleString()} objects
|
||||
</span>
|
||||
<span className="font-medium">{formatBytes(bucket.size)}</span>
|
||||
<span className="text-muted-foreground w-12 text-right">
|
||||
<span className="font-medium">{formatBytes(bucket.size)}</span>
|
||||
<span className="text-muted-foreground w-12 text-right">
|
||||
{bucket.percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-secondary overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${bucket.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No buckets available</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-secondary overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${bucket.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No buckets available</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Buckets */}
|
||||
<Card>
|
||||
|
||||
@@ -160,10 +160,11 @@ export interface ClusterHealth {
|
||||
status: string;
|
||||
connectedNodes: number;
|
||||
knownNodes: number;
|
||||
healthyStorageNodes: number;
|
||||
declaredStorageNodes: number;
|
||||
healthyPartitions: number;
|
||||
totalPartitions: number;
|
||||
storageNodes: number;
|
||||
storageNodesUp: number;
|
||||
partitions: number;
|
||||
partitionsQuorum: number;
|
||||
partitionsAllOk: number;
|
||||
}
|
||||
|
||||
export interface ClusterStatistics {
|
||||
@@ -173,6 +174,57 @@ export interface ClusterStatistics {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ClusterStatus {
|
||||
layoutVersion: number;
|
||||
nodes: ClusterNode[];
|
||||
}
|
||||
|
||||
export interface ClusterNode {
|
||||
id: string;
|
||||
isUp: boolean;
|
||||
lastSeenSecsAgo?: number;
|
||||
hostname?: string;
|
||||
addr?: string;
|
||||
garageVersion?: string;
|
||||
role?: NodeRole;
|
||||
draining: boolean;
|
||||
dataPartition?: FreeSpaceInfo;
|
||||
metadataPartition?: FreeSpaceInfo;
|
||||
}
|
||||
|
||||
export interface NodeRole {
|
||||
zone: string;
|
||||
capacity?: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface FreeSpaceInfo {
|
||||
available: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LocalNodeInfo {
|
||||
nodeId: string;
|
||||
garageVersion: string;
|
||||
rustVersion: string;
|
||||
dbEngine: string;
|
||||
garageFeatures?: string[];
|
||||
}
|
||||
|
||||
export interface MultiNodeResponse {
|
||||
success: Record<string, LocalNodeInfo>;
|
||||
error: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NodeStatistics {
|
||||
freeform: string;
|
||||
}
|
||||
|
||||
export interface MultiNodeStatisticsResponse {
|
||||
success: Record<string, NodeStatistics>;
|
||||
error: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
nodeId: string;
|
||||
version: string;
|
||||
|
||||
@@ -139,13 +139,6 @@ func (h *MonitoringHandler) GetDashboardMetrics(c fiber.Ctx) error {
|
||||
ObjectCount: totalObjects,
|
||||
BucketCount: len(buckets),
|
||||
UsageByBucket: usageByBucket,
|
||||
RequestMetrics: models.RequestMetrics{
|
||||
GetRequests: 0,
|
||||
PutRequests: 0,
|
||||
DeleteRequests: 0,
|
||||
ListRequests: 0,
|
||||
Period: "last-24h",
|
||||
},
|
||||
}
|
||||
|
||||
return c.JSON(models.SuccessResponse(dashboardMetrics))
|
||||
|
||||
@@ -4,11 +4,10 @@ import "time"
|
||||
|
||||
// DashboardMetrics represents aggregated metrics for the dashboard
|
||||
type DashboardMetrics struct {
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
ObjectCount int64 `json:"objectCount"`
|
||||
BucketCount int `json:"bucketCount"`
|
||||
UsageByBucket []BucketUsage `json:"usageByBucket"`
|
||||
RequestMetrics RequestMetrics `json:"requestMetrics"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
ObjectCount int64 `json:"objectCount"`
|
||||
BucketCount int `json:"bucketCount"`
|
||||
UsageByBucket []BucketUsage `json:"usageByBucket"`
|
||||
}
|
||||
|
||||
// BucketUsage represents storage usage for a single bucket
|
||||
@@ -19,15 +18,6 @@ type BucketUsage struct {
|
||||
Percentage float64 `json:"percentage"`
|
||||
}
|
||||
|
||||
// RequestMetrics represents API request statistics
|
||||
type RequestMetrics struct {
|
||||
GetRequests int64 `json:"getRequests"`
|
||||
PutRequests int64 `json:"putRequests"`
|
||||
DeleteRequests int64 `json:"deleteRequests"`
|
||||
ListRequests int64 `json:"listRequests"`
|
||||
Period string `json:"period"`
|
||||
}
|
||||
|
||||
// APIResponse is the standard response structure for all API endpoints
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
Reference in New Issue
Block a user