feat: add Cluster management page and update API response types

This commit is contained in:
Noooste
2025-11-25 18:41:28 +01:00
parent c6248adde9
commit ef1e0c9541
11 changed files with 641 additions and 150 deletions
+11 -9
View File
@@ -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',
+17 -12
View File
@@ -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>
);
}
+7 -9
View File
@@ -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,
};
},
};
+489
View File
@@ -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>
);
}
+43 -81
View File
@@ -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>
+56 -4
View File
@@ -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;
-7
View File
@@ -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 -14
View File
@@ -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"`