mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-27 07:27:40 +00:00
feat: implement UI redesign with updated button styles and new components
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
+213
-220
@@ -1,244 +1,237 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Header} from '@/components/layout/header';
|
||||
import {formatBytes} from '@/lib/file-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';
|
||||
import { AlertCircle, Database, FolderOpen, HardDrive, Server, Zap } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { IconTile } from '@/components/ui/icon-tile';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import { BucketUsageChart } from '@/components/charts/BucketUsageChart';
|
||||
import { useDashboardData } from '@/hooks/useApi';
|
||||
import { formatBytes } from '@/lib/file-utils';
|
||||
import type { ClusterHealth } from '@/types';
|
||||
|
||||
type StatTone = 'primary' | 'destructive' | 'neutral';
|
||||
type HealthLabel = 'Healthy' | 'Degraded' | 'Unhealthy' | 'Unknown';
|
||||
|
||||
function deriveHealth(health: ClusterHealth | null): { label: HealthLabel; tone: StatTone } {
|
||||
if (!health) return { label: 'Unknown', tone: 'neutral' };
|
||||
if (
|
||||
health.storageNodesUp === health.storageNodes &&
|
||||
health.partitionsAllOk === health.partitions &&
|
||||
health.connectedNodes === health.knownNodes
|
||||
) return { label: 'Healthy', tone: 'primary' };
|
||||
if (health.storageNodesUp > 0 && health.partitionsQuorum > 0) return { label: 'Degraded', tone: 'primary' };
|
||||
return { label: 'Unhealthy', tone: 'destructive' };
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { metrics: metricsQuery, buckets: bucketsQuery, health: healthQuery, isLoading } = useDashboardData();
|
||||
|
||||
const metrics = metricsQuery.data;
|
||||
const buckets = bucketsQuery.data || [];
|
||||
const clusterHealth = healthQuery.data;
|
||||
|
||||
const getHealthStatus = (health: ClusterHealth | null) => {
|
||||
if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle };
|
||||
if (
|
||||
health.storageNodesUp === health.storageNodes &&
|
||||
health.partitionsAllOk === health.partitions &&
|
||||
health.connectedNodes === health.knownNodes
|
||||
) {
|
||||
return { color: 'text-green-500', label: 'Healthy', icon: Zap };
|
||||
}
|
||||
if (
|
||||
health.storageNodesUp > 0 &&
|
||||
health.partitionsQuorum > 0
|
||||
) {
|
||||
return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle };
|
||||
}
|
||||
return { color: 'text-red-500', label: 'Unhealthy', icon: AlertCircle };
|
||||
};
|
||||
|
||||
const healthStatus = getHealthStatus(clusterHealth ?? null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<Header title="Dashboard" />
|
||||
<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 dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const buckets = bucketsQuery.data ?? [];
|
||||
const clusterHealth = healthQuery.data ?? null;
|
||||
const health = deriveHealth(clusterHealth);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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-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>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics ? formatBytes(metrics.totalSize) : '---'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Across {metrics?.bucketCount || 0} buckets
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle={
|
||||
clusterHealth
|
||||
? `${clusterHealth.connectedNodes}/${clusterHealth.knownNodes} nodes connected`
|
||||
: 'Loading cluster status…'
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Objects</CardTitle>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics?.objectCount.toLocaleString() || '---'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Files and folders
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Buckets</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics?.bucketCount || '---'}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Active storage buckets
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[360px] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-[var(--primary)] border-r-transparent" />
|
||||
<p className="mt-3 text-[13.5px] text-[var(--muted-foreground)]">Loading dashboard…</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 px-6 py-5">
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label="Total storage"
|
||||
value={metrics ? formatBytes(metrics.totalSize) : '—'}
|
||||
sub={`across ${metrics?.bucketCount ?? 0} bucket${metrics?.bucketCount === 1 ? '' : 's'}`}
|
||||
icon={<HardDrive />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Objects"
|
||||
value={metrics?.objectCount.toLocaleString() ?? '—'}
|
||||
sub="files and folders"
|
||||
icon={<FolderOpen />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Buckets"
|
||||
value={metrics?.bucketCount.toLocaleString() ?? '—'}
|
||||
sub="active storage buckets"
|
||||
icon={<Database />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cluster"
|
||||
value={health.label}
|
||||
valueTone={health.tone}
|
||||
sub={
|
||||
clusterHealth
|
||||
? `${clusterHealth.storageNodesUp}/${clusterHealth.storageNodes} storage nodes`
|
||||
: '—'
|
||||
}
|
||||
icon={health.label === 'Unhealthy' ? <AlertCircle /> : <Zap />}
|
||||
iconTone={health.tone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cluster Status */}
|
||||
<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">Cluster Status</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`text-2xl font-bold ${healthStatus.color}`}>
|
||||
{healthStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{clusterHealth?.connectedNodes || 0}/{clusterHealth?.knownNodes || 0} nodes connected
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Cluster row */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<StatCard
|
||||
label="Storage nodes"
|
||||
value={clusterHealth ? `${clusterHealth.storageNodesUp}/${clusterHealth.storageNodes}` : '—'}
|
||||
sub="healthy"
|
||||
icon={<Server />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Partitions"
|
||||
value={clusterHealth ? `${clusterHealth.partitionsAllOk}/${clusterHealth.partitions}` : '—'}
|
||||
sub="healthy"
|
||||
icon={<Zap />}
|
||||
/>
|
||||
<StatCard
|
||||
label="Connected nodes"
|
||||
value={clusterHealth ? `${clusterHealth.connectedNodes}/${clusterHealth.knownNodes}` : '—'}
|
||||
sub="cluster membership"
|
||||
icon={<Server />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{clusterHealth?.storageNodesUp || 0}/{clusterHealth?.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>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{clusterHealth?.partitionsAllOk || 0}/{clusterHealth?.partitions || 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Healthy partitions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Row 1 */}
|
||||
<div className="grid gap-3 sm:gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
{/* Bucket Usage Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Storage Usage by Bucket</CardTitle>
|
||||
<CardDescription>Distribution of storage across buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<Card title="Storage usage by bucket" description="Distribution of storage across buckets">
|
||||
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
|
||||
<BucketUsageChart data={metrics.usageByBucket} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No data available</div>
|
||||
<div className="py-8 text-center text-[13.5px] text-[var(--muted-foreground)]">No data available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
{/* 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">
|
||||
{bucket.objectCount.toLocaleString()} objects
|
||||
</span>
|
||||
<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>
|
||||
<Card title="Breakdown" description="Detailed breakdown of storage across all buckets">
|
||||
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{metrics.usageByBucket.map((bucket) => (
|
||||
<div key={bucket.bucketName} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2 text-[13.5px]">
|
||||
<span className="truncate font-medium">{bucket.bucketName}</span>
|
||||
<div className="flex items-center gap-3 text-[13px] text-[var(--muted-foreground)]">
|
||||
<span>{bucket.objectCount.toLocaleString()} objects</span>
|
||||
<span className="font-medium text-[var(--foreground)]">{formatBytes(bucket.size)}</span>
|
||||
<span className="w-10 text-right">{bucket.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">No buckets available</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Buckets */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Buckets</CardTitle>
|
||||
<CardDescription>Your most recently created buckets</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{buckets.slice(0, 5).map((bucket) => (
|
||||
<div
|
||||
key={bucket.name}
|
||||
className="flex items-center justify-between py-3 border-b last:border-0 gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full bg-[var(--primary)] transition-all"
|
||||
style={{ width: `${bucket.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{bucket.name}</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-[13.5px] text-[var(--muted-foreground)]">No buckets available</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent buckets */}
|
||||
<Card title="Recent buckets" description="Your most recently created buckets">
|
||||
{buckets.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Database />}
|
||||
title="No buckets yet"
|
||||
description="Create your first bucket from the Buckets page to start storing objects."
|
||||
tone="neutral"
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--border)]">
|
||||
{buckets.slice(0, 5).map((bucket) => (
|
||||
<li key={bucket.name} className="flex items-center gap-3 py-3">
|
||||
<IconTile icon={<Database />} tone="primary" size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[14px] font-medium">{bucket.name}</p>
|
||||
<p className="truncate text-[12.5px] text-[var(--muted-foreground)]">
|
||||
Created {new Date(bucket.creationDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="font-medium text-sm sm:text-base">{bucket.objectCount?.toLocaleString()} objects</p>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
{bucket.size ? formatBytes(bucket.size) : '---'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[14px] font-medium">{bucket.objectCount?.toLocaleString() ?? '—'} objects</p>
|
||||
<p className="text-[12.5px] text-[var(--muted-foreground)]">
|
||||
{bucket.size ? formatBytes(bucket.size) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
icon,
|
||||
iconTone = 'primary',
|
||||
valueTone = 'neutral',
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
icon: React.ReactNode;
|
||||
iconTone?: StatTone;
|
||||
valueTone?: StatTone;
|
||||
}) {
|
||||
const valueColor =
|
||||
valueTone === 'primary'
|
||||
? 'text-[var(--primary)]'
|
||||
: valueTone === 'destructive'
|
||||
? 'text-[var(--destructive)]'
|
||||
: 'text-[var(--foreground)]';
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
<IconTile icon={icon} tone={iconTone} size="sm" />
|
||||
</div>
|
||||
<div className={`mt-2 text-[26px] font-semibold tracking-[-0.02em] leading-none ${valueColor}`}>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="mt-1.5 text-[13px] text-[var(--muted-foreground)]">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
|
||||
<header className="border-b border-[var(--border)] px-5 py-3">
|
||||
<h2 className="text-[15px] font-semibold">{title}</h2>
|
||||
{description && <p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">{description}</p>}
|
||||
</header>
|
||||
<div className="px-5 py-5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user