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 {BrowserRouter, Route, Routes} from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query'; import {QueryClientProvider} from '@tanstack/react-query';
import { ThemeProvider, useTheme } from '@/components/theme-provider'; import {ThemeProvider, useTheme} from '@/components/theme-provider';
import { Layout } from '@/components/layout/layout'; import {Layout} from '@/components/layout/layout';
import { Dashboard } from '@/pages/Dashboard'; import {Dashboard} from '@/pages/Dashboard';
import { Buckets } from '@/pages/Buckets'; import {Buckets} from '@/pages/Buckets';
import { AccessControl } from '@/pages/AccessControl'; import {Cluster} from '@/pages/Cluster';
import { Toaster } from 'sonner'; import {AccessControl} from '@/pages/AccessControl';
import { queryClient } from '@/lib/query-client'; import {Toaster} from 'sonner';
import {queryClient} from '@/lib/query-client';
function ThemedToaster() { function ThemedToaster() {
const { theme } = useTheme(); const { theme } = useTheme();
@@ -23,6 +24,7 @@ function App() {
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} /> <Route path="buckets" element={<Buckets />} />
<Route path="cluster" element={<Cluster />} />
<Route path="access" element={<AccessControl />} /> <Route path="access" element={<AccessControl />} />
</Route> </Route>
</Routes> </Routes>
@@ -280,7 +280,7 @@ export function ObjectsTable({
</div> </div>
</TableCell> </TableCell>
<TableCell className="hidden sm:table-cell"> <TableCell className="hidden sm:table-cell">
{obj.isFolder ? 'Folder' : getFileType(obj.key.replace(currentPath, ''))} {obj.isFolder ? 'Directory' : getFileType(obj.key.replace(currentPath, ''))}
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell"> <TableCell className="hidden md:table-cell">
{obj.storageClass && ( {obj.storageClass && (
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'; import {useEffect, useState} from 'react';
import { PieChart, Pie, Cell, Legend, Tooltip, ResponsiveContainer } from 'recharts'; import {Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip} from 'recharts';
import type { BucketUsage } from '@/types'; import type {BucketUsage} from '@/types';
import { formatBytes } from '@/lib/utils'; import {formatBytes} from '@/lib/utils';
import { chartColorPalette, getTextColor, getTooltipStyle } from '@/lib/chart-colors'; import {chartColorPalette, getTextColor, getTooltipStyle} from '@/lib/chart-colors';
interface BucketUsageChartProps { interface BucketUsageChartProps {
data: BucketUsage[]; data: BucketUsage[];
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import {useEffect, useState} from 'react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import {Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import type { ClusterHealth } from '@/types'; import type {ClusterHealth} from '@/types';
import { grafanaColors, getTextColor, getGridColor, getTooltipStyle } from '@/lib/chart-colors'; import {getGridColor, getTextColor, getTooltipStyle, grafanaColors} from '@/lib/chart-colors';
interface ClusterHealthChartProps { interface ClusterHealthChartProps {
data: ClusterHealth; data: ClusterHealth;
@@ -32,13 +32,13 @@ export function ClusterHealthChart({ data }: ClusterHealthChartProps) {
const chartData = [ const chartData = [
{ {
metric: 'Nodes', metric: 'Nodes',
healthy: data.healthyStorageNodes, healthy: data.storageNodesUp,
unhealthy: data.declaredStorageNodes - data.healthyStorageNodes, unhealthy: data.storageNodes - data.storageNodesUp,
}, },
{ {
metric: 'Partitions', metric: 'Partitions',
healthy: data.healthyPartitions, healthy: data.partitionsAllOk,
unhealthy: data.totalPartitions - data.healthyPartitions, unhealthy: data.partitions - data.partitionsAllOk,
}, },
{ {
metric: 'Connected', metric: 'Connected',
+17 -12
View File
@@ -1,6 +1,6 @@
import {Link, useLocation} from 'react-router-dom'; import {Link, useLocation} from 'react-router-dom';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {Database, Key, LayoutDashboard,} from 'lucide-react'; import {Database, Key, LayoutDashboard, Server} from 'lucide-react';
interface NavItem { interface NavItem {
title: string; title: string;
@@ -19,6 +19,11 @@ const navItems: NavItem[] = [
href: '/buckets', href: '/buckets',
icon: Database, icon: Database,
}, },
{
title: 'Cluster',
href: '/cluster',
icon: Server,
},
{ {
title: 'Access Control', title: 'Access Control',
href: '/access', href: '/access',
@@ -71,17 +76,17 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
); );
})} })}
</nav> </nav>
<div className="border-t p-4"> {/*<div className="border-t p-4">*/}
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2"> {/* <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"> {/* <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">*/}
AD {/* AD*/}
</div> {/* </div>*/}
<div className="flex-1 overflow-hidden"> {/* <div className="flex-1 overflow-hidden">*/}
<p className="text-sm font-medium truncate">Admin User</p> {/* <p className="text-sm font-medium truncate">Admin User</p>*/}
<p className="text-xs text-muted-foreground truncate">admin@garage.local</p> {/* <p className="text-xs text-muted-foreground truncate">admin@garage.local</p>*/}
</div> {/* </div>*/}
</div> {/* </div>*/}
</div> {/*</div>*/}
</div> </div>
); );
} }
+7 -9
View File
@@ -6,8 +6,10 @@ import type {
BucketDetails, BucketDetails,
ClusterHealth, ClusterHealth,
ClusterStatistics, ClusterStatistics,
ClusterStatus,
GarageMetrics, GarageMetrics,
NodeInfo, MultiNodeResponse,
MultiNodeStatisticsResponse,
ObjectListResponse, ObjectListResponse,
ObjectMetadata, ObjectMetadata,
S3Object, S3Object,
@@ -261,8 +263,7 @@ export const garageApi = {
return response.data.data; return response.data.data;
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any getClusterStatus: async (): Promise<ClusterStatus> => {
getClusterStatus: async (): Promise<any> => {
const response = await api.get('/v1/cluster/status'); const response = await api.get('/v1/cluster/status');
return response.data.data; return response.data.data;
}, },
@@ -272,23 +273,21 @@ export const garageApi = {
return response.data.data; 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}`); const response = await api.get(`/v1/cluster/nodes/${nodeId}`);
return response.data.data; return response.data.data;
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any getNodeStatistics: async (nodeId: string): Promise<MultiNodeStatisticsResponse> => {
getNodeStatistics: async (nodeId: string): Promise<any> => {
const response = await api.get(`/v1/cluster/nodes/${nodeId}/statistics`); const response = await api.get(`/v1/cluster/nodes/${nodeId}/statistics`);
return response.data.data; return response.data.data;
}, },
getFullMetrics: async (): Promise<GarageMetrics> => { getFullMetrics: async (): Promise<GarageMetrics> => {
// Fetch all cluster-related metrics // Fetch all cluster-related metrics
const [health, statistics, nodeInfo, storageMetrics] = await Promise.all([ const [health, statistics, storageMetrics] = await Promise.all([
garageApi.getClusterHealth(), garageApi.getClusterHealth(),
garageApi.getClusterStatistics(), garageApi.getClusterStatistics(),
garageApi.getNodeInfo(),
analyticsApi.getMetrics(), analyticsApi.getMetrics(),
]); ]);
@@ -296,7 +295,6 @@ export const garageApi = {
...storageMetrics, ...storageMetrics,
clusterHealth: health, clusterHealth: health,
clusterStatistics: statistics, 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 {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import { Header } from '@/components/layout/header'; import {Header} from '@/components/layout/header';
import { formatBytes } from '@/lib/utils'; import {formatBytes} from '@/lib/utils';
import { Database, FolderOpen, HardDrive, Activity, Server, Zap, AlertCircle } from 'lucide-react'; import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react';
import { BucketUsageChart } from '@/components/charts/BucketUsageChart'; import {BucketUsageChart} from '@/components/charts/BucketUsageChart';
import { RequestMetricsChart } from '@/components/charts/RequestMetricsChart'; import {useDashboardData} from '@/hooks/useApi';
import { useDashboardData } from '@/hooks/useApi'; import type {ClusterHealth} from '@/types';
import type { ClusterHealth } from '@/types';
export function Dashboard() { export function Dashboard() {
const { metrics: metricsQuery, buckets: bucketsQuery, health: healthQuery, isLoading } = useDashboardData(); const { metrics: metricsQuery, buckets: bucketsQuery, health: healthQuery, isLoading } = useDashboardData();
@@ -17,15 +16,15 @@ export function Dashboard() {
const getHealthStatus = (health: ClusterHealth | null) => { const getHealthStatus = (health: ClusterHealth | null) => {
if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle }; if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle };
if ( if (
health.healthyStorageNodes === health.declaredStorageNodes && health.storageNodesUp === health.storageNodes &&
health.healthyPartitions === health.totalPartitions && health.partitionsAllOk === health.partitions &&
health.connectedNodes === health.knownNodes health.connectedNodes === health.knownNodes
) { ) {
return { color: 'text-green-500', label: 'Healthy', icon: Zap }; return { color: 'text-green-500', label: 'Healthy', icon: Zap };
} }
if ( if (
health.healthyStorageNodes > 0 && health.storageNodesUp > 0 &&
health.healthyPartitions > 0 health.partitionsQuorum > 0
) { ) {
return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle }; return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle };
} }
@@ -53,7 +52,7 @@ export function Dashboard() {
<Header title="Dashboard" /> <Header title="Dashboard" />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6"> <div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Top Stats Grid */} {/* 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> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Storage</CardTitle> <CardTitle className="text-sm font-medium">Total Storage</CardTitle>
@@ -96,28 +95,6 @@ export function Dashboard() {
</p> </p>
</CardContent> </CardContent>
</Card> </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> </div>
{/* Cluster Status */} {/* Cluster Status */}
@@ -146,7 +123,7 @@ export function Dashboard() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{clusterHealth?.healthyStorageNodes || 0}/{clusterHealth?.declaredStorageNodes || 0} {clusterHealth?.storageNodesUp || 0}/{clusterHealth?.storageNodes || 0}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Healthy storage nodes Healthy storage nodes
@@ -161,7 +138,7 @@ export function Dashboard() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{clusterHealth?.healthyPartitions || 0}/{clusterHealth?.totalPartitions || 0} {clusterHealth?.partitionsAllOk || 0}/{clusterHealth?.partitions || 0}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Healthy partitions Healthy partitions
@@ -187,59 +164,44 @@ export function Dashboard() {
</CardContent> </CardContent>
</Card> </Card>
{/* Request Metrics Chart */} {/* Bucket Details Table */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Request Metrics</CardTitle> <CardTitle>Storage Usage by Bucket (Table)</CardTitle>
<CardDescription>API request distribution (24h)</CardDescription> <CardDescription>Detailed breakdown of storage across all buckets</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{metrics?.requestMetrics ? ( <div className="space-y-4">
<RequestMetricsChart data={metrics.requestMetrics} /> {metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
) : ( metrics.usageByBucket.map((bucket) => (
<div className="text-center py-8 text-muted-foreground">No data available</div> <div key={bucket.bucketName} className="space-y-2">
)} <div className="flex items-center justify-between text-sm flex-wrap gap-2">
</CardContent> <span className="font-medium">{bucket.bucketName}</span>
</Card> <div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm">
</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">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{bucket.objectCount.toLocaleString()} objects {bucket.objectCount.toLocaleString()} objects
</span> </span>
<span className="font-medium">{formatBytes(bucket.size)}</span> <span className="font-medium">{formatBytes(bucket.size)}</span>
<span className="text-muted-foreground w-12 text-right"> <span className="text-muted-foreground w-12 text-right">
{bucket.percentage.toFixed(1)}% {bucket.percentage.toFixed(1)}%
</span> </span>
</div> </div>
</div> </div>
<div className="h-2 rounded-full bg-secondary overflow-hidden"> <div className="h-2 rounded-full bg-secondary overflow-hidden">
<div <div
className="h-full bg-primary transition-all" className="h-full bg-primary transition-all"
style={{ width: `${bucket.percentage}%` }} style={{ width: `${bucket.percentage}%` }}
/> />
</div> </div>
</div> </div>
)) ))
) : ( ) : (
<div className="text-center py-8 text-muted-foreground">No buckets available</div> <div className="text-center py-8 text-muted-foreground">No buckets available</div>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
{/* Recent Buckets */} {/* Recent Buckets */}
<Card> <Card>
+56 -4
View File
@@ -160,10 +160,11 @@ export interface ClusterHealth {
status: string; status: string;
connectedNodes: number; connectedNodes: number;
knownNodes: number; knownNodes: number;
healthyStorageNodes: number; storageNodes: number;
declaredStorageNodes: number; storageNodesUp: number;
healthyPartitions: number; partitions: number;
totalPartitions: number; partitionsQuorum: number;
partitionsAllOk: number;
} }
export interface ClusterStatistics { export interface ClusterStatistics {
@@ -173,6 +174,57 @@ export interface ClusterStatistics {
[key: string]: any; [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 { export interface NodeInfo {
nodeId: string; nodeId: string;
version: string; version: string;
-7
View File
@@ -139,13 +139,6 @@ func (h *MonitoringHandler) GetDashboardMetrics(c fiber.Ctx) error {
ObjectCount: totalObjects, ObjectCount: totalObjects,
BucketCount: len(buckets), BucketCount: len(buckets),
UsageByBucket: usageByBucket, UsageByBucket: usageByBucket,
RequestMetrics: models.RequestMetrics{
GetRequests: 0,
PutRequests: 0,
DeleteRequests: 0,
ListRequests: 0,
Period: "last-24h",
},
} }
return c.JSON(models.SuccessResponse(dashboardMetrics)) return c.JSON(models.SuccessResponse(dashboardMetrics))
+4 -14
View File
@@ -4,11 +4,10 @@ import "time"
// DashboardMetrics represents aggregated metrics for the dashboard // DashboardMetrics represents aggregated metrics for the dashboard
type DashboardMetrics struct { type DashboardMetrics struct {
TotalSize int64 `json:"totalSize"` TotalSize int64 `json:"totalSize"`
ObjectCount int64 `json:"objectCount"` ObjectCount int64 `json:"objectCount"`
BucketCount int `json:"bucketCount"` BucketCount int `json:"bucketCount"`
UsageByBucket []BucketUsage `json:"usageByBucket"` UsageByBucket []BucketUsage `json:"usageByBucket"`
RequestMetrics RequestMetrics `json:"requestMetrics"`
} }
// BucketUsage represents storage usage for a single bucket // BucketUsage represents storage usage for a single bucket
@@ -19,15 +18,6 @@ type BucketUsage struct {
Percentage float64 `json:"percentage"` 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 // APIResponse is the standard response structure for all API endpoints
type APIResponse struct { type APIResponse struct {
Success bool `json:"success"` Success bool `json:"success"`