fix chart issue.

move metrics to summary page.
fix clusters page summary charts and stats information.
update favicons.
This commit is contained in:
Fizer Khan
2025-12-06 12:20:02 +05:30
parent a2e43d75e7
commit ecea85e82b
27 changed files with 955 additions and 628 deletions
+3
View File
@@ -102,6 +102,9 @@ export interface ClusterStatusCache {
serverCount: number;
version: string;
lastCheck: string;
serverId?: string;
serverName?: string;
rtt?: string;
}
export async function setClusterStatus(clusterId: string, data: ClusterStatusCache): Promise<void> {
@@ -254,9 +254,10 @@ export async function checkHealth(
return {
connected: cachedStatus.status === 'connected',
status: cachedStatus.status as ClusterStatus,
rtt: cachedStatus.rtt ? parseInt(cachedStatus.rtt) : undefined,
serverInfo: {
serverId: '',
serverName: '',
serverId: cachedStatus.serverId || '',
serverName: cachedStatus.serverName || '',
version: cachedStatus.version,
jetstream: true,
},
@@ -308,6 +309,9 @@ export async function checkHealth(
serverCount: 1,
version,
lastCheck: new Date().toISOString(),
serverId: health.serverInfo?.serverId || '',
serverName: health.serverInfo?.serverName || '',
rtt: health.rtt?.toString() || '',
});
return {
@@ -7,6 +7,18 @@ export const consumerRoutes: FastifyPluginAsync = async (fastify) => {
// All routes require authentication
fastify.addHook('preHandler', authenticate);
// GET /clusters/:cid/consumers - List ALL consumers across all streams
fastify.get<{ Params: { cid: string } }>(
'/:cid/consumers',
async (request) => {
const consumers = await consumerService.listAllConsumers(
request.user!.orgId,
request.params.cid
);
return { consumers };
}
);
// GET /clusters/:cid/streams/:sid/consumers - List consumers
fastify.get<{ Params: { cid: string; sid: string } }>(
'/:cid/streams/:sid/consumers',
@@ -7,10 +7,16 @@ import {
deleteConsumer as natsDeleteConsumer,
pauseConsumer as natsPauseConsumer,
resumeConsumer as natsResumeConsumer,
listStreams as natsListStreams,
} from '../../lib/nats';
import { NotFoundError } from '../../../../shared/src/index';
import type { ConsumerInfo, CreateConsumerInput, UpdateConsumerInput } from '../../../../shared/src/index';
// Extended consumer info with stream name
export interface ConsumerInfoWithStream extends ConsumerInfo {
streamName: string;
}
// ==================== Consumer Operations ====================
export async function listConsumers(
@@ -30,6 +36,41 @@ export async function listConsumers(
return natsListConsumers(clusterId, streamName);
}
export async function listAllConsumers(
orgId: string,
clusterId: string
): Promise<ConsumerInfoWithStream[]> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
// Get all streams
const streams = await natsListStreams(clusterId);
// Get consumers from each stream
const allConsumers: ConsumerInfoWithStream[] = [];
for (const stream of streams) {
try {
const consumers = await natsListConsumers(clusterId, stream.config.name);
for (const consumer of consumers) {
allConsumers.push({
...consumer,
streamName: stream.config.name,
});
}
} catch {
// Skip streams that have issues
}
}
return allConsumers;
}
export async function getConsumer(
orgId: string,
clusterId: string,
+67 -28
View File
@@ -1,15 +1,17 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { BarChart3, TrendingUp, TrendingDown, Activity, ArrowUpRight, ArrowDownRight, Loader2 } from 'lucide-react';
import { BarChart3, TrendingUp, TrendingDown, Activity, ArrowUpRight, ArrowDownRight, Loader2, WifiOff, RefreshCw } from 'lucide-react';
import { api } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { LineChart, BarChart, MultiLineChart } from '@/components/charts';
import { formatBytes, formatNumber } from '@nats-console/shared';
import { useClusterStore } from '@/stores/cluster';
export default function AnalyticsPage() {
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const { selectedClusterId, setSelectedClusterId } = useClusterStore();
const [timeRange, setTimeRange] = useState('24h');
const { data: clustersData } = useQuery({
@@ -17,47 +19,68 @@ export default function AnalyticsPage() {
queryFn: () => api.clusters.list(),
});
// Ensure cluster connection before fetching analytics
const { data: healthData, isLoading: isLoadingHealth, refetch: refetchHealth } = useQuery({
queryKey: ['cluster-health', selectedClusterId],
queryFn: () => (selectedClusterId ? api.clusters.health(selectedClusterId) : null),
enabled: !!selectedClusterId,
staleTime: 30000,
retry: 1,
});
const isClusterConnected = healthData?.connected === true;
const isClusterDisconnected = selectedClusterId && healthData && !healthData.connected;
const { data: analyticsData, isLoading } = useQuery({
queryKey: ['analytics', selectedCluster, timeRange],
queryKey: ['analytics', selectedClusterId, timeRange],
queryFn: () =>
selectedCluster
? api.analytics.overview(selectedCluster, timeRange)
selectedClusterId
? api.analytics.overview(selectedClusterId, timeRange)
: null,
enabled: !!selectedCluster,
enabled: !!selectedClusterId && isClusterConnected,
});
// Chart data queries
const { data: throughputData, isLoading: throughputLoading } = useQuery({
queryKey: ['analytics-throughput', selectedCluster, timeRange],
queryKey: ['analytics-throughput', selectedClusterId, timeRange],
queryFn: () =>
selectedCluster
? api.analytics.chartThroughput(selectedCluster, timeRange)
selectedClusterId
? api.analytics.chartThroughput(selectedClusterId, timeRange)
: null,
enabled: !!selectedCluster,
enabled: !!selectedClusterId && isClusterConnected,
});
const { data: consumerLagData, isLoading: consumerLagLoading } = useQuery({
queryKey: ['analytics-consumer-lag', selectedCluster, timeRange],
queryKey: ['analytics-consumer-lag', selectedClusterId, timeRange],
queryFn: () =>
selectedCluster
? api.analytics.chartConsumerLag(selectedCluster, timeRange)
selectedClusterId
? api.analytics.chartConsumerLag(selectedClusterId, timeRange)
: null,
enabled: !!selectedCluster,
enabled: !!selectedClusterId && isClusterConnected,
});
const { data: streamActivityData, isLoading: streamActivityLoading } = useQuery({
queryKey: ['analytics-stream-activity', selectedCluster, timeRange],
queryKey: ['analytics-stream-activity', selectedClusterId, timeRange],
queryFn: () =>
selectedCluster
? api.analytics.chartStreamActivity(selectedCluster, timeRange)
selectedClusterId
? api.analytics.chartStreamActivity(selectedClusterId, timeRange)
: null,
enabled: !!selectedCluster,
enabled: !!selectedClusterId && isClusterConnected,
});
// Auto-select first cluster
if (clustersData?.clusters?.length && !selectedCluster) {
setSelectedCluster(clustersData.clusters[0].id);
}
// Auto-select saved cluster or first cluster
useEffect(() => {
if (clustersData?.clusters?.length) {
const savedClusterExists = clustersData.clusters.some(
(c: any) => c.id === selectedClusterId
);
if (!savedClusterExists) {
setSelectedClusterId(clustersData.clusters[0].id);
} else if (!selectedClusterId) {
setSelectedClusterId(clustersData.clusters[0].id);
}
}
}, [clustersData?.clusters, selectedClusterId, setSelectedClusterId]);
const stats = analyticsData || {
totalMessages: 0,
@@ -132,8 +155,8 @@ export default function AnalyticsPage() {
<div className="flex gap-4">
<select
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedCluster || ''}
onChange={(e) => setSelectedCluster(e.target.value)}
value={selectedClusterId || ''}
onChange={(e) => setSelectedClusterId(e.target.value)}
>
<option value="">Select cluster...</option>
{clustersData?.clusters?.map((cluster: any) => (
@@ -155,7 +178,7 @@ export default function AnalyticsPage() {
</select>
</div>
{!selectedCluster && (
{!selectedClusterId && (
<Card>
<CardContent className="py-12 text-center">
<BarChart3 className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
@@ -165,13 +188,29 @@ export default function AnalyticsPage() {
</Card>
)}
{selectedCluster && isLoading && (
{isClusterDisconnected && (
<Card className="border-red-200 bg-red-50">
<CardContent className="py-12 text-center">
<WifiOff className="h-12 w-12 mx-auto text-red-500 mb-4" />
<h3 className="text-lg font-semibold mb-2 text-red-700">Cluster Not Reachable</h3>
<p className="text-red-600 mb-4">
Unable to connect to the selected cluster. Please check if the cluster is running and accessible.
</p>
<Button variant="outline" onClick={() => refetchHealth()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry Connection
</Button>
</CardContent>
</Card>
)}
{selectedClusterId && (isLoading || isLoadingHealth) && !isClusterDisconnected && (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
{selectedCluster && !isLoading && (
{selectedClusterId && isClusterConnected && !isLoading && (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
+167 -212
View File
@@ -1,6 +1,6 @@
'use client';
import { Suspense, useState, useMemo } from 'react';
import { Suspense, useState, useMemo, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -37,12 +37,13 @@ import {
import { formatBytes, formatNumber } from '@nats-console/shared';
import { CreateClusterDialog } from '@/components/forms/create-cluster-dialog';
import { GaugeChart } from '@/components/charts';
import { useClusterStore } from '@/stores/cluster';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
const tabs: Tab[] = [
{ id: 'overview', label: 'Overview', icon: Server },
{ id: 'streams', label: 'Streams', icon: Database },
{ id: 'config', label: 'Configuration', icon: Settings },
{ id: 'metrics', label: 'Metrics', icon: BarChart3 },
];
function ClusterDetailContent() {
@@ -52,9 +53,17 @@ function ClusterDetailContent() {
const clusterId = params.id as string;
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const { setSelectedClusterId } = useClusterStore();
const { activeTab, setActiveTab } = useTabs(tabs, 'overview');
// Set this cluster as selected when visiting
useEffect(() => {
if (clusterId) {
setSelectedClusterId(clusterId);
}
}, [clusterId, setSelectedClusterId]);
const { data: clusterData, isLoading } = useQuery({
queryKey: ['cluster', clusterId],
queryFn: () => api.clusters.get(clusterId),
@@ -120,15 +129,17 @@ function ClusterDetailContent() {
const getEnvironmentBadge = (env: string) => {
const colors: Record<string, string> = {
production: 'bg-red-100 text-red-700',
staging: 'bg-yellow-100 text-yellow-700',
development: 'bg-green-100 text-green-700',
production: 'bg-purple-100 text-purple-700',
staging: 'bg-amber-100 text-amber-700',
development: 'bg-sky-100 text-sky-700',
};
return colors[env] || 'bg-gray-100 text-gray-700';
};
const jetstream = infoData?.jetstream;
const jetstream = infoData?.jetstream || { streams: 0, consumers: 0, messages: 0, bytes: 0 };
const streams = streamsData?.streams || [];
// Get URL from primary connection
const clusterUrl = cluster?.connections?.[0]?.serverUrl || '';
return (
<div className="space-y-6">
@@ -229,7 +240,7 @@ function ClusterDetailContent() {
</div>
<div>
<p className="text-sm text-muted-foreground">URL</p>
<p className="text-lg font-mono">{cluster.url}</p>
<p className="text-lg font-mono">{clusterUrl || 'Not configured'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Version</p>
@@ -240,50 +251,48 @@ function ClusterDetailContent() {
</Card>
{/* JetStream Info */}
{jetstream && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Streams</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{jetstream.streams || 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Consumers</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{jetstream.consumers || 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(jetstream.messages || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Storage Used</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatBytes(jetstream.bytes || 0)}
</div>
</CardContent>
</Card>
</div>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Streams</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{jetstream.streams || 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Consumers</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{jetstream.consumers || 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatNumber(jetstream.messages || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Storage Used</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatBytes(jetstream.bytes || 0)}
</div>
</CardContent>
</Card>
</div>
{/* Server Info */}
<Card>
@@ -291,44 +300,117 @@ function ClusterDetailContent() {
<CardTitle>Server Information</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Server ID</span>
<span className="font-mono text-sm truncate max-w-[200px]">
{healthData?.serverInfo?.serverId || '-'}
</span>
<TooltipProvider>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<div className="flex justify-between items-center gap-4">
<span className="text-muted-foreground shrink-0">Server ID</span>
{healthData?.serverInfo?.serverId ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono text-sm truncate max-w-[180px] cursor-help">
{healthData.serverInfo.serverId}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs break-all max-w-[300px]">{healthData.serverInfo.serverId}</p>
</TooltipContent>
</Tooltip>
) : (
<span>-</span>
)}
</div>
<div className="flex justify-between items-center gap-4">
<span className="text-muted-foreground shrink-0">Server Name</span>
{healthData?.serverInfo?.serverName ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate max-w-[180px] cursor-help">
{healthData.serverInfo.serverName}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="break-all max-w-[300px]">{healthData.serverInfo.serverName}</p>
</TooltipContent>
</Tooltip>
) : (
<span>-</span>
)}
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Version</span>
<span>{healthData?.serverInfo?.version || cluster.version || '-'}</span>
</div>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Server Name</span>
<span>{healthData?.serverInfo?.serverName || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Version</span>
<span>{healthData?.serverInfo?.version || cluster.version || '-'}</span>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">JetStream</span>
<span className={healthData?.serverInfo?.jetstream ? 'text-green-500' : 'text-red-500'}>
{healthData?.serverInfo?.jetstream ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">RTT</span>
<span>{healthData?.rtt ? `${healthData.rtt}ms` : '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<span className={cluster.status === 'connected' ? 'text-green-500' : 'text-red-500'}>
{cluster.status}
</span>
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">JetStream</span>
<span className={healthData?.serverInfo?.jetstream ? 'text-green-500' : 'text-red-500'}>
{healthData?.serverInfo?.jetstream ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">RTT</span>
<span>{healthData?.rtt ? `${healthData.rtt}ms` : '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<span className={cluster.status === 'connected' ? 'text-green-500' : 'text-red-500'}>
{cluster.status}
</span>
</div>
</div>
</div>
</TooltipProvider>
</CardContent>
</Card>
{/* Resource Usage Gauges */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Streams</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<GaugeChart
value={jetstream?.streams || 0}
max={Math.max(100, (jetstream?.streams || 0) * 2)}
title={`${jetstream?.streams || 0} streams`}
color="#2563eb"
height={150}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Consumers</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<GaugeChart
value={jetstream?.consumers || 0}
max={Math.max(100, (jetstream?.consumers || 0) * 2)}
title={`${jetstream?.consumers || 0} consumers`}
color="#16a34a"
height={150}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Storage Usage</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<GaugeChart
value={jetstream?.bytes ? Math.round(jetstream.bytes / (1024 * 1024)) : 0}
max={Math.max(1024, jetstream?.bytes ? Math.round(jetstream.bytes / (1024 * 1024)) * 2 : 1024)}
title={formatBytes(jetstream?.bytes || 0)}
unit=" MB"
color="#f59e0b"
height={150}
/>
</CardContent>
</Card>
</div>
</div>
)}
@@ -412,7 +494,7 @@ function ClusterDetailContent() {
</div>
<div>
<label className="text-sm font-medium">URL</label>
<p className="mt-1 font-mono">{cluster.url}</p>
<p className="mt-1 font-mono">{clusterUrl || 'Not configured'}</p>
</div>
<div>
<label className="text-sm font-medium">Authentication</label>
@@ -428,133 +510,6 @@ function ClusterDetailContent() {
</Card>
)}
{/* Metrics Tab */}
{activeTab === 'metrics' && (
<div className="space-y-6">
{/* Resource Usage Gauges */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Streams</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<GaugeChart
value={jetstream?.streams || 0}
max={Math.max(100, (jetstream?.streams || 0) * 2)}
title={`${jetstream?.streams || 0} streams`}
color="#2563eb"
height={150}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Consumers</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<GaugeChart
value={jetstream?.consumers || 0}
max={Math.max(100, (jetstream?.consumers || 0) * 2)}
title={`${jetstream?.consumers || 0} consumers`}
color="#16a34a"
height={150}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Storage Usage</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<GaugeChart
value={jetstream?.bytes ? Math.round(jetstream.bytes / (1024 * 1024)) : 0}
max={Math.max(1024, jetstream?.bytes ? Math.round(jetstream.bytes / (1024 * 1024)) * 2 : 1024)}
title={formatBytes(jetstream?.bytes || 0)}
unit=" MB"
color="#f59e0b"
height={150}
/>
</CardContent>
</Card>
</div>
{/* JetStream Stats */}
<Card>
<CardHeader>
<CardTitle>JetStream Overview</CardTitle>
<CardDescription>Current JetStream resource usage</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Streams</p>
<p className="text-3xl font-bold">{formatNumber(jetstream?.streams || 0)}</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Consumers</p>
<p className="text-3xl font-bold">{formatNumber(jetstream?.consumers || 0)}</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Messages</p>
<p className="text-3xl font-bold">{formatNumber(jetstream?.messages || 0)}</p>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Data Size</p>
<p className="text-3xl font-bold">{formatBytes(jetstream?.bytes || 0)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Server Info */}
<Card>
<CardHeader>
<CardTitle>Server Information</CardTitle>
<CardDescription>Server runtime statistics</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Server ID</span>
<span className="font-medium font-mono text-sm truncate max-w-[200px]">
{healthData?.serverInfo?.serverId || '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Server Name</span>
<span className="font-medium">{healthData?.serverInfo?.serverName || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">RTT</span>
<span className="font-medium">{healthData?.rtt ? `${healthData.rtt}ms` : '-'}</span>
</div>
</div>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-muted-foreground">Server Version</span>
<span className="font-medium">{healthData?.serverInfo?.version || cluster.version || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">JetStream</span>
<span className={`font-medium ${healthData?.serverInfo?.jetstream ? 'text-green-500' : 'text-red-500'}`}>
{healthData?.serverInfo?.jetstream ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Status</span>
<span className={`font-medium capitalize ${
cluster.status === 'connected' ? 'text-green-500' : 'text-red-500'
}`}>
{cluster.status}
</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
+84 -28
View File
@@ -1,22 +1,48 @@
'use client';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Server, CheckCircle, XCircle, AlertCircle, RefreshCw } from 'lucide-react';
import { Plus, Server, CheckCircle, XCircle, AlertCircle, RefreshCw, Search, Star } from 'lucide-react';
import Link from 'next/link';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CreateClusterDialog } from '@/components/forms/create-cluster-dialog';
import { useClusterStore } from '@/stores/cluster';
export default function ClustersPage() {
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [search, setSearch] = useState('');
const { selectedClusterId, setSelectedClusterId } = useClusterStore();
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['clusters'],
queryFn: () => api.clusters.list(),
});
// Filter and sort clusters alphabetically
const filteredClusters = useMemo(() => {
if (!data?.clusters) return [];
let clusters = [...data.clusters];
// Filter by search
if (search) {
clusters = clusters.filter((cluster: any) =>
cluster.name.toLowerCase().includes(search.toLowerCase()) ||
cluster.description?.toLowerCase().includes(search.toLowerCase())
);
}
// Sort alphabetically by name
clusters.sort((a: any, b: any) => a.name.localeCompare(b.name));
return clusters;
}, [data?.clusters, search]);
const showSearch = (data?.clusters?.length || 0) > 3;
const getStatusIcon = (status: string) => {
switch (status) {
case 'connected':
@@ -32,9 +58,9 @@ export default function ClustersPage() {
const getEnvironmentBadge = (env: string) => {
const colors: Record<string, string> = {
production: 'bg-red-100 text-red-700',
staging: 'bg-yellow-100 text-yellow-700',
development: 'bg-green-100 text-green-700',
production: 'bg-purple-100 text-purple-700',
staging: 'bg-amber-100 text-amber-700',
development: 'bg-sky-100 text-sky-700',
};
return (
<span className={`px-2 py-1 text-xs font-medium rounded-full ${colors[env] || 'bg-gray-100 text-gray-700'}`}>
@@ -62,6 +88,19 @@ export default function ClustersPage() {
<CreateClusterDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
</div>
{/* Search - only show if more than 3 clusters */}
{showSearch && (
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search clusters..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
)}
{isLoading && (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
@@ -92,34 +131,51 @@ export default function ClustersPage() {
</Card>
)}
{data?.clusters && data.clusters.length > 0 && (
{filteredClusters.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.clusters.map((cluster: any) => (
<Link key={cluster.id} href={`/clusters/${cluster.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex items-center gap-3">
{getStatusIcon(cluster.status)}
<div>
<CardTitle className="text-lg">{cluster.name}</CardTitle>
<CardDescription>{cluster.description || 'No description'}</CardDescription>
{filteredClusters.map((cluster: any) => {
const isSelected = cluster.id === selectedClusterId;
return (
<Link key={cluster.id} href={`/clusters/${cluster.id}`} onClick={() => setSelectedClusterId(cluster.id)}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex items-center gap-3">
{getStatusIcon(cluster.status)}
<div>
<CardTitle className="text-lg flex items-center gap-2">
{cluster.name}
{isSelected && <Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />}
</CardTitle>
<CardDescription>{cluster.description || 'No description'}</CardDescription>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm">
{getEnvironmentBadge(cluster.environment)}
<span className="text-muted-foreground">
{cluster.version || 'Unknown version'}
</span>
</div>
</CardContent>
</Card>
</Link>
))}
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm">
{getEnvironmentBadge(cluster.environment)}
<span className="text-muted-foreground">
{cluster.version || 'Unknown version'}
</span>
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
)}
{/* No results from search */}
{search && filteredClusters.length === 0 && data?.clusters && data.clusters.length > 0 && (
<Card>
<CardContent className="py-8 text-center">
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No clusters found</h3>
<p className="text-muted-foreground">No clusters match "{search}"</p>
</CardContent>
</Card>
)}
</div>
);
}
@@ -39,7 +39,6 @@ import { LineChart } from '@/components/charts';
const tabs: Tab[] = [
{ id: 'overview', label: 'Overview', icon: Users },
{ id: 'config', label: 'Configuration', icon: Settings },
{ id: 'metrics', label: 'Metrics', icon: BarChart3 },
];
function ConsumerDetailContent() {
@@ -103,7 +102,7 @@ function ConsumerDetailContent() {
const { data: metricsData, isLoading: isLoadingMetrics } = useQuery({
queryKey: ['consumer-metrics', clusterId, streamName, consumerName, metricsTimeRange],
queryFn: () => api.analytics.consumerLag(consumerName, getTimeRangeParams()),
enabled: activeTab === 'metrics',
enabled: activeTab === 'overview',
});
// Transform metrics data for charts
@@ -442,6 +441,108 @@ function ConsumerDetailContent() {
</div>
</CardContent>
</Card>
{/* Consumer Lag Chart */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Consumer Lag Over Time</CardTitle>
<CardDescription>Message lag trend</CardDescription>
</div>
<select
className="h-9 px-3 border rounded-md bg-background text-sm"
value={metricsTimeRange}
onChange={(e) => setMetricsTimeRange(e.target.value)}
>
<option value="1h">Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
</select>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-48 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.lag.length > 0 ? (
<LineChart
data={chartData.lag}
title=""
yAxisLabel="messages"
color="#ef4444"
height={200}
/>
) : (
<div className="h-48 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Pending Messages Chart */}
<Card>
<CardHeader>
<CardTitle>Pending Messages Over Time</CardTitle>
<CardDescription>Messages pending delivery trend</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-48 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.pending.length > 0 ? (
<LineChart
data={chartData.pending}
title=""
yAxisLabel="messages"
color="#f59e0b"
height={200}
/>
) : (
<div className="h-48 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Ack Rate Chart */}
<Card>
<CardHeader>
<CardTitle>Acknowledgment Rate</CardTitle>
<CardDescription>Message acknowledgment rate over time</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-48 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.ackRate.length > 0 ? (
<LineChart
data={chartData.ackRate}
title=""
yAxisLabel="acks/s"
color="#22c55e"
height={200}
/>
) : (
<div className="h-48 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
@@ -460,153 +561,6 @@ function ConsumerDetailContent() {
</Card>
)}
{/* Metrics Tab */}
{activeTab === 'metrics' && (
<div className="space-y-6">
{/* Time Range Selector */}
<div className="flex justify-end">
<select
className="h-9 px-3 border rounded-md bg-background text-sm"
value={metricsTimeRange}
onChange={(e) => setMetricsTimeRange(e.target.value)}
>
<option value="1h">Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
</select>
</div>
{/* Stats Cards - Moved to top */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Pending</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(consumer.num_pending || 0)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Ack Pending</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(consumer.num_ack_pending || 0)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Redelivered</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(consumer.num_redelivered || 0)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Waiting</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(consumer.num_waiting || 0)}</div>
</CardContent>
</Card>
</div>
{/* Consumer Lag Chart */}
<Card>
<CardHeader>
<CardTitle>Consumer Lag</CardTitle>
<CardDescription>Message lag over time</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-64 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.lag.length > 0 ? (
<LineChart
data={chartData.lag}
title=""
yAxisLabel="messages"
color="#ef4444"
height={250}
/>
) : (
<div className="h-64 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
<p className="text-sm">Metrics will appear once the consumer has activity</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Pending Messages Chart */}
<Card>
<CardHeader>
<CardTitle>Pending Messages</CardTitle>
<CardDescription>Messages pending delivery over time</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-64 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.pending.length > 0 ? (
<LineChart
data={chartData.pending}
title=""
yAxisLabel="messages"
color="#f59e0b"
height={250}
/>
) : (
<div className="h-64 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
<p className="text-sm">Metrics will appear once the consumer has activity</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Ack Rate Chart */}
<Card>
<CardHeader>
<CardTitle>Acknowledgment Rate</CardTitle>
<CardDescription>Message acknowledgment rate over time</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-64 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.ackRate.length > 0 ? (
<LineChart
data={chartData.ackRate}
title=""
yAxisLabel="acks/s"
color="#22c55e"
height={250}
/>
) : (
<div className="h-64 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
<p className="text-sm">Metrics will appear once the consumer has activity</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}
+90 -49
View File
@@ -1,8 +1,8 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Users, Search, AlertTriangle, CheckCircle, ChevronRight, RefreshCw, Trash2 } from 'lucide-react';
import { Plus, Users, Search, AlertTriangle, CheckCircle, ChevronRight, RefreshCw, Trash2, WifiOff } from 'lucide-react';
import Link from 'next/link';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
@@ -20,53 +20,74 @@ import {
} from '@/components/ui/alert-dialog';
import { formatNumber, formatDuration } from '@nats-console/shared';
import { CreateConsumerDialog } from '@/components/forms/create-consumer-dialog';
import { useClusterStore } from '@/stores/cluster';
export default function ConsumersPage() {
const queryClient = useQueryClient();
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const [selectedStream, setSelectedStream] = useState<string | null>(null);
const { selectedClusterId, setSelectedClusterId } = useClusterStore();
const [selectedStream, setSelectedStream] = useState<string>('__all__');
const [search, setSearch] = useState('');
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [consumerToDelete, setConsumerToDelete] = useState<string | null>(null);
const [consumerToDelete, setConsumerToDelete] = useState<{ name: string; streamName: string } | null>(null);
const { data: clustersData } = useQuery({
queryKey: ['clusters'],
queryFn: () => api.clusters.list(),
});
const { data: streamsData } = useQuery({
queryKey: ['streams', selectedCluster],
queryFn: () => (selectedCluster ? api.streams.list(selectedCluster) : null),
enabled: !!selectedCluster,
// Ensure cluster connection before fetching data
const { data: healthData, isLoading: isLoadingHealth, refetch: refetchHealth } = useQuery({
queryKey: ['cluster-health', selectedClusterId],
queryFn: () => (selectedClusterId ? api.clusters.health(selectedClusterId) : null),
enabled: !!selectedClusterId,
staleTime: 30000, // Cache for 30 seconds
retry: 1,
});
const isClusterConnected = healthData?.connected === true;
const isClusterDisconnected = selectedClusterId && healthData && !healthData.connected;
const { data: streamsData } = useQuery({
queryKey: ['streams', selectedClusterId],
queryFn: () => (selectedClusterId ? api.streams.list(selectedClusterId) : null),
enabled: !!selectedClusterId && isClusterConnected,
});
// Fetch all consumers when "All" is selected, or specific stream consumers
const { data: consumersData, isLoading, refetch } = useQuery({
queryKey: ['consumers', selectedCluster, selectedStream],
queryFn: () =>
selectedCluster && selectedStream
? api.consumers.list(selectedCluster, selectedStream)
: null,
enabled: !!selectedCluster && !!selectedStream,
queryKey: ['consumers', selectedClusterId, selectedStream],
queryFn: () => {
if (!selectedClusterId) return null;
if (selectedStream === '__all__') {
return api.consumers.listAll(selectedClusterId);
}
return api.consumers.list(selectedClusterId, selectedStream);
},
enabled: !!selectedClusterId && isClusterConnected,
});
const deleteMutation = useMutation({
mutationFn: (consumerName: string) =>
api.consumers.delete(selectedCluster!, selectedStream!, consumerName),
mutationFn: ({ name, streamName }: { name: string; streamName: string }) =>
api.consumers.delete(selectedClusterId!, streamName, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['consumers', selectedCluster, selectedStream] });
queryClient.invalidateQueries({ queryKey: ['consumers', selectedClusterId, selectedStream] });
setConsumerToDelete(null);
},
});
// Auto-select first cluster
if (clustersData?.clusters?.length && !selectedCluster) {
setSelectedCluster(clustersData.clusters[0].id);
}
// Auto-select first stream when cluster changes
if (streamsData?.streams?.length && !selectedStream) {
setSelectedStream(streamsData.streams[0].config.name);
}
// Auto-select saved cluster or first cluster
useEffect(() => {
if (clustersData?.clusters?.length) {
const savedClusterExists = clustersData.clusters.some(
(c: any) => c.id === selectedClusterId
);
if (!savedClusterExists) {
setSelectedClusterId(clustersData.clusters[0].id);
} else if (!selectedClusterId) {
setSelectedClusterId(clustersData.clusters[0].id);
}
}
}, [clustersData?.clusters, selectedClusterId, setSelectedClusterId]);
const filteredConsumers = consumersData?.consumers?.filter((consumer: any) =>
consumer.name.toLowerCase().includes(search.toLowerCase())
@@ -87,19 +108,19 @@ export default function ConsumersPage() {
<p className="text-muted-foreground">Manage JetStream consumers</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={!selectedStream}>
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={!selectedClusterId}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button disabled={!selectedStream} onClick={() => setShowCreateDialog(true)}>
<Button disabled={!selectedClusterId || selectedStream === '__all__'} onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Consumer
</Button>
</div>
{selectedCluster && selectedStream && (
{selectedClusterId && selectedStream && selectedStream !== '__all__' && (
<CreateConsumerDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
clusterId={selectedCluster}
clusterId={selectedClusterId}
streamName={selectedStream}
/>
)}
@@ -108,10 +129,10 @@ export default function ConsumersPage() {
<div className="flex gap-4">
<select
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedCluster || ''}
value={selectedClusterId || ''}
onChange={(e) => {
setSelectedCluster(e.target.value);
setSelectedStream(null);
setSelectedClusterId(e.target.value);
setSelectedStream('__all__');
}}
>
<option value="">Select cluster...</option>
@@ -123,11 +144,11 @@ export default function ConsumersPage() {
</select>
<select
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedStream || ''}
value={selectedStream}
onChange={(e) => setSelectedStream(e.target.value)}
disabled={!selectedCluster}
disabled={!selectedClusterId}
>
<option value="">Select stream...</option>
<option value="__all__">All Streams</option>
{streamsData?.streams?.map((stream: any) => (
<option key={stream.config.name} value={stream.config.name}>
{stream.config.name}
@@ -145,23 +166,29 @@ export default function ConsumersPage() {
</div>
</div>
{!selectedStream && (
<Card>
{isClusterDisconnected && (
<Card className="border-red-200 bg-red-50">
<CardContent className="py-12 text-center">
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Select a stream</h3>
<p className="text-muted-foreground">Choose a cluster and stream to view consumers</p>
<WifiOff className="h-12 w-12 mx-auto text-red-500 mb-4" />
<h3 className="text-lg font-semibold mb-2 text-red-700">Cluster Not Reachable</h3>
<p className="text-red-600 mb-4">
Unable to connect to the selected cluster. Please check if the cluster is running and accessible.
</p>
<Button variant="outline" onClick={() => refetchHealth()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry Connection
</Button>
</CardContent>
</Card>
)}
{selectedStream && isLoading && (
{(isLoading || isLoadingHealth) && !isClusterDisconnected && (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
{selectedStream && filteredConsumers && filteredConsumers.length === 0 && (
{!isLoading && isClusterConnected && filteredConsumers && filteredConsumers.length === 0 && (
<Card>
<CardContent className="py-12 text-center">
<Users className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
@@ -169,7 +196,7 @@ export default function ConsumersPage() {
<p className="text-muted-foreground mb-4">
{search ? 'No consumers match your search' : 'Create your first consumer to get started'}
</p>
{!search && (
{!search && selectedStream !== '__all__' && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Consumer
@@ -185,6 +212,9 @@ export default function ConsumersPage() {
<thead className="bg-muted/50">
<tr>
<th className="text-left p-4 font-medium">Name</th>
{selectedStream === '__all__' && (
<th className="text-left p-4 font-medium">Stream</th>
)}
<th className="text-left p-4 font-medium">Type</th>
<th className="text-right p-4 font-medium">Pending</th>
<th className="text-right p-4 font-medium">Redelivered</th>
@@ -198,16 +228,27 @@ export default function ConsumersPage() {
{filteredConsumers.map((consumer: any) => {
const health = getHealthStatus(consumer);
const HealthIcon = health.icon;
const streamName = consumer.streamName || selectedStream;
return (
<tr key={consumer.name} className="border-t hover:bg-muted/30">
<tr key={`${streamName}-${consumer.name}`} className="border-t hover:bg-muted/30">
<td className="p-4">
<Link
href={`/consumers/${selectedCluster}/${selectedStream}/${consumer.name}`}
href={`/consumers/${selectedClusterId}/${streamName}/${consumer.name}`}
className="font-medium text-primary hover:underline flex items-center gap-1"
>
{consumer.name}
</Link>
</td>
{selectedStream === '__all__' && (
<td className="p-4">
<Link
href={`/streams/${selectedClusterId}/${streamName}`}
className="text-muted-foreground hover:text-primary hover:underline"
>
{streamName}
</Link>
</td>
)}
<td className="p-4">
<span
className={`px-2 py-1 text-xs rounded-full ${
@@ -234,7 +275,7 @@ export default function ConsumersPage() {
size="icon"
onClick={(e) => {
e.preventDefault();
setConsumerToDelete(consumer.name);
setConsumerToDelete({ name: consumer.name, streamName });
}}
>
<Trash2 className="h-4 w-4 text-red-500" />
@@ -254,7 +295,7 @@ export default function ConsumersPage() {
<AlertDialogHeader>
<AlertDialogTitle>Delete Consumer</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete consumer &quot;{consumerToDelete}&quot;? This action cannot be undone.
Are you sure you want to delete consumer &quot;{consumerToDelete?.name}&quot;? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
+28 -8
View File
@@ -1,6 +1,6 @@
'use client';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient, useQueries } from '@tanstack/react-query';
import {
Server,
Database,
@@ -31,13 +31,33 @@ export default function OverviewPage() {
});
const clusters = clustersData?.clusters || [];
const connectedClusters = clusters.filter((c: any) => c.status === 'connected').length;
const connectedClusters = clusters.filter((c: any) => c.status === 'connected');
const totalClusters = clusters.length;
const openIncidents = incidentsData?.total || 0;
// Fetch info for all connected clusters to get stream/consumer counts
const clusterInfoQueries = useQueries({
queries: connectedClusters.map((cluster: any) => ({
queryKey: ['cluster-info', cluster.id],
queryFn: () => api.clusters.info(cluster.id),
enabled: cluster.status === 'connected',
staleTime: 60000, // Cache for 1 minute
})),
});
// Calculate totals from all connected clusters
const totalStreams = clusterInfoQueries.reduce((sum, query) => {
return sum + (query.data?.jetstream?.streams || 0);
}, 0);
const totalConsumers = clusterInfoQueries.reduce((sum, query) => {
return sum + (query.data?.jetstream?.consumers || 0);
}, 0);
const handleRefresh = () => {
refetchClusters();
refetchIncidents();
queryClient.invalidateQueries({ queryKey: ['cluster-info'] });
};
return (
@@ -62,7 +82,7 @@ export default function OverviewPage() {
<CardContent>
<div className="text-2xl font-bold">{totalClusters}</div>
<p className="text-xs text-muted-foreground">
{connectedClusters} connected
{connectedClusters.length} connected
</p>
</CardContent>
</Card>
@@ -73,9 +93,9 @@ export default function OverviewPage() {
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<div className="text-2xl font-bold">{formatNumber(totalStreams)}</div>
<p className="text-xs text-muted-foreground">
Across all clusters
Across {connectedClusters.length} cluster{connectedClusters.length !== 1 ? 's' : ''}
</p>
</CardContent>
</Card>
@@ -86,7 +106,7 @@ export default function OverviewPage() {
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<div className="text-2xl font-bold">{formatNumber(totalConsumers)}</div>
<p className="text-xs text-muted-foreground">
Processing messages
</p>
@@ -95,13 +115,13 @@ export default function OverviewPage() {
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
<CardTitle className="text-sm font-medium">Alert Incidents</CardTitle>
<AlertTriangle className={`h-4 w-4 ${openIncidents > 0 ? 'text-red-500' : 'text-muted-foreground'}`} />
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${openIncidents > 0 ? 'text-red-500' : ''}`}>{openIncidents}</div>
<p className="text-xs text-muted-foreground">
{openIncidents === 0 ? 'No active alerts' : openIncidents === 1 ? '1 open incident' : `${openIncidents} open incidents`}
{openIncidents === 0 ? 'No open incidents' : openIncidents === 1 ? '1 open incident' : `${openIncidents} open incidents`}
</p>
</CardContent>
</Card>
@@ -74,7 +74,6 @@ const tabs: Tab[] = [
{ id: 'schema', label: 'Schema', icon: FileCode },
{ id: 'consumers', label: 'Consumers', icon: Users },
{ id: 'config', label: 'Configuration', icon: Settings },
{ id: 'metrics', label: 'Metrics', icon: BarChart3 },
];
function StreamDetailContent() {
@@ -257,7 +256,7 @@ function StreamDetailContent() {
const { data: metricsData, isLoading: isLoadingMetrics } = useQuery({
queryKey: ['stream-metrics', clusterId, streamName, metricsTimeRange],
queryFn: () => api.analytics.streamThroughput(streamName, getTimeRangeParams()),
enabled: activeTab === 'metrics',
enabled: activeTab === 'overview',
});
// Schema data
@@ -650,6 +649,77 @@ function StreamDetailContent() {
</div>
</CardContent>
</Card>
{/* Throughput Charts */}
<Card className="md:col-span-4">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Message Throughput</CardTitle>
<CardDescription>Messages per second over time</CardDescription>
</div>
<select
className="h-9 px-3 border rounded-md bg-background text-sm"
value={metricsTimeRange}
onChange={(e) => setMetricsTimeRange(e.target.value)}
>
<option value="1h">Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
</select>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-48 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.messages.length > 0 ? (
<LineChart
data={chartData.messages}
title=""
yAxisLabel="msg/s"
color="#2563eb"
height={200}
/>
) : (
<div className="h-48 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
</div>
</div>
)}
</CardContent>
</Card>
<Card className="md:col-span-4">
<CardHeader>
<CardTitle>Data Throughput</CardTitle>
<CardDescription>Bytes per second over time</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-48 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.bytes.length > 0 ? (
<LineChart
data={chartData.bytes}
title=""
yAxisLabel="bytes/s"
color="#16a34a"
height={200}
/>
) : (
<div className="h-48 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
@@ -1052,122 +1122,6 @@ function StreamDetailContent() {
</Card>
)}
{/* Metrics Tab */}
{activeTab === 'metrics' && (
<div className="space-y-6">
{/* Time Range Selector */}
<div className="flex justify-end">
<select
className="h-9 px-3 border rounded-md bg-background text-sm"
value={metricsTimeRange}
onChange={(e) => setMetricsTimeRange(e.target.value)}
>
<option value="1h">Last 1 hour</option>
<option value="6h">Last 6 hours</option>
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
</select>
</div>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Current Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(stream.state?.messages || 0)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Current Size</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatBytes(stream.state?.bytes || 0)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">First Sequence</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(stream.state?.first_seq || 0)}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Last Sequence</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatNumber(stream.state?.last_seq || 0)}</div>
</CardContent>
</Card>
</div>
{/* Message Throughput Chart */}
<Card>
<CardHeader>
<CardTitle>Message Throughput</CardTitle>
<CardDescription>Messages per second over time</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-64 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.messages.length > 0 ? (
<LineChart
data={chartData.messages}
title=""
yAxisLabel="msg/s"
color="#2563eb"
height={250}
/>
) : (
<div className="h-64 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
<p className="text-sm">Metrics will appear once the stream has activity</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Bytes Throughput Chart */}
<Card>
<CardHeader>
<CardTitle>Data Throughput</CardTitle>
<CardDescription>Bytes per second over time</CardDescription>
</CardHeader>
<CardContent>
{isLoadingMetrics ? (
<div className="h-64 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : chartData.bytes.length > 0 ? (
<LineChart
data={chartData.bytes}
title=""
yAxisLabel="bytes/s"
color="#16a34a"
height={250}
/>
) : (
<div className="h-64 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No metrics data available</p>
<p className="text-sm">Metrics will appear once the stream has activity</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}
+60 -21
View File
@@ -1,8 +1,8 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Database, Search, ChevronRight, RefreshCw } from 'lucide-react';
import { Plus, Database, Search, ChevronRight, RefreshCw, WifiOff } from 'lucide-react';
import Link from 'next/link';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
@@ -10,9 +10,10 @@ import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatBytes, formatNumber } from '@nats-console/shared';
import { CreateStreamDialog } from '@/components/forms/create-stream-dialog';
import { useClusterStore } from '@/stores/cluster';
export default function StreamsPage() {
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const { selectedClusterId, setSelectedClusterId } = useClusterStore();
const [search, setSearch] = useState('');
const [showCreateDialog, setShowCreateDialog] = useState(false);
@@ -21,16 +22,38 @@ export default function StreamsPage() {
queryFn: () => api.clusters.list(),
});
const { data: streamsData, isLoading, refetch } = useQuery({
queryKey: ['streams', selectedCluster],
queryFn: () => (selectedCluster ? api.streams.list(selectedCluster) : null),
enabled: !!selectedCluster,
// Ensure cluster connection before fetching streams
const { data: healthData, isLoading: isLoadingHealth, refetch: refetchHealth } = useQuery({
queryKey: ['cluster-health', selectedClusterId],
queryFn: () => (selectedClusterId ? api.clusters.health(selectedClusterId) : null),
enabled: !!selectedClusterId,
staleTime: 30000, // Cache for 30 seconds
retry: 1,
});
// Auto-select first cluster
if (clustersData?.clusters?.length && !selectedCluster) {
setSelectedCluster(clustersData.clusters[0].id);
}
const isClusterConnected = healthData?.connected === true;
const isClusterDisconnected = selectedClusterId && healthData && !healthData.connected;
const { data: streamsData, isLoading, refetch } = useQuery({
queryKey: ['streams', selectedClusterId],
queryFn: () => (selectedClusterId ? api.streams.list(selectedClusterId) : null),
enabled: !!selectedClusterId && isClusterConnected,
});
// Auto-select saved cluster or first cluster
useEffect(() => {
if (clustersData?.clusters?.length) {
// Check if saved cluster still exists
const savedClusterExists = clustersData.clusters.some(
(c: any) => c.id === selectedClusterId
);
if (!savedClusterExists) {
setSelectedClusterId(clustersData.clusters[0].id);
} else if (!selectedClusterId) {
setSelectedClusterId(clustersData.clusters[0].id);
}
}
}, [clustersData?.clusters, selectedClusterId, setSelectedClusterId]);
const filteredStreams = streamsData?.streams?.filter((stream: any) =>
stream.config.name.toLowerCase().includes(search.toLowerCase())
@@ -44,19 +67,19 @@ export default function StreamsPage() {
<p className="text-muted-foreground">Manage JetStream streams</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={!selectedCluster}>
<Button variant="outline" size="icon" onClick={() => refetch()} disabled={!selectedClusterId}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button disabled={!selectedCluster} onClick={() => setShowCreateDialog(true)}>
<Button disabled={!selectedClusterId} onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Stream
</Button>
</div>
{selectedCluster && (
{selectedClusterId && (
<CreateStreamDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
clusterId={selectedCluster}
clusterId={selectedClusterId}
/>
)}
</div>
@@ -64,8 +87,8 @@ export default function StreamsPage() {
<div className="flex gap-4">
<select
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedCluster || ''}
onChange={(e) => setSelectedCluster(e.target.value)}
value={selectedClusterId || ''}
onChange={(e) => setSelectedClusterId(e.target.value)}
>
<option value="">Select cluster...</option>
{clustersData?.clusters?.map((cluster: any) => (
@@ -85,7 +108,7 @@ export default function StreamsPage() {
</div>
</div>
{!selectedCluster && (
{!selectedClusterId && (
<Card>
<CardContent className="py-12 text-center">
<Database className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
@@ -95,13 +118,29 @@ export default function StreamsPage() {
</Card>
)}
{selectedCluster && isLoading && (
{isClusterDisconnected && (
<Card className="border-red-200 bg-red-50">
<CardContent className="py-12 text-center">
<WifiOff className="h-12 w-12 mx-auto text-red-500 mb-4" />
<h3 className="text-lg font-semibold mb-2 text-red-700">Cluster Not Reachable</h3>
<p className="text-red-600 mb-4">
Unable to connect to the selected cluster. Please check if the cluster is running and accessible.
</p>
<Button variant="outline" onClick={() => refetchHealth()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry Connection
</Button>
</CardContent>
</Card>
)}
{selectedClusterId && (isLoading || isLoadingHealth) && !isClusterDisconnected && (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
{selectedCluster && filteredStreams && filteredStreams.length === 0 && (
{selectedClusterId && isClusterConnected && filteredStreams && filteredStreams.length === 0 && (
<Card>
<CardContent className="py-12 text-center">
<Database className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
@@ -137,7 +176,7 @@ export default function StreamsPage() {
<tr key={stream.config.name} className="border-t hover:bg-muted/30">
<td className="p-4">
<Link
href={`/streams/${selectedCluster}/${stream.config.name}`}
href={`/streams/${selectedClusterId}/${stream.config.name}`}
className="font-medium text-primary hover:underline flex items-center gap-1"
>
{stream.config.name}
+44 -1
View File
@@ -1,5 +1,6 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
@@ -20,6 +21,37 @@ export function BarChart({
height = 300,
horizontal = false,
}: BarChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
// Wait for container to have dimensions before rendering chart
useEffect(() => {
let observer: ResizeObserver | null = null;
let timeout: NodeJS.Timeout | null = null;
const checkDimensions = () => {
if (containerRef.current) {
const { clientWidth, clientHeight } = containerRef.current;
if (clientWidth > 0 && clientHeight > 0) {
setIsReady(true);
}
}
};
checkDimensions();
timeout = setTimeout(checkDimensions, 100);
if (containerRef.current && typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(checkDimensions);
observer.observe(containerRef.current);
}
return () => {
if (timeout) clearTimeout(timeout);
if (observer) observer.disconnect();
};
}, []);
const option: EChartsOption = {
title: title ? { text: title, left: 'center', textStyle: { fontSize: 14 } } : undefined,
tooltip: {
@@ -76,5 +108,16 @@ export function BarChart({
],
};
return <ReactECharts option={option} style={{ height }} />;
return (
<div ref={containerRef} style={{ width: '100%', height }}>
{isReady && (
<ReactECharts
option={option}
style={{ width: '100%', height: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
)}
</div>
);
}
+46 -1
View File
@@ -1,5 +1,6 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
@@ -22,6 +23,39 @@ export function GaugeChart({
height = 200,
thresholds = { warning: 70, critical: 90 },
}: GaugeChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
// Wait for container to have dimensions before rendering chart
useEffect(() => {
let observer: ResizeObserver | null = null;
let timeout: NodeJS.Timeout | null = null;
const checkDimensions = () => {
if (containerRef.current) {
const { clientWidth, clientHeight } = containerRef.current;
if (clientWidth > 0 && clientHeight > 0) {
setIsReady(true);
}
}
};
// Check immediately and also after a short delay for lazy-loaded content
checkDimensions();
timeout = setTimeout(checkDimensions, 100);
// Use ResizeObserver to detect when container gets dimensions
if (containerRef.current && typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(checkDimensions);
observer.observe(containerRef.current);
}
return () => {
if (timeout) clearTimeout(timeout);
if (observer) observer.disconnect();
};
}, []);
const percentage = (value / max) * 100;
const gaugeColor =
percentage >= thresholds.critical
@@ -74,5 +108,16 @@ export function GaugeChart({
],
};
return <ReactECharts option={option} style={{ height }} />;
return (
<div ref={containerRef} style={{ width: '100%', minWidth: 150, height }}>
{isReady && (
<ReactECharts
option={option}
style={{ width: '100%', height: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
)}
</div>
);
}
+44 -1
View File
@@ -1,5 +1,6 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
@@ -20,6 +21,37 @@ export function LineChart({
height = 300,
showArea = true,
}: LineChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
// Wait for container to have dimensions before rendering chart
useEffect(() => {
let observer: ResizeObserver | null = null;
let timeout: NodeJS.Timeout | null = null;
const checkDimensions = () => {
if (containerRef.current) {
const { clientWidth, clientHeight } = containerRef.current;
if (clientWidth > 0 && clientHeight > 0) {
setIsReady(true);
}
}
};
checkDimensions();
timeout = setTimeout(checkDimensions, 100);
if (containerRef.current && typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(checkDimensions);
observer.observe(containerRef.current);
}
return () => {
if (timeout) clearTimeout(timeout);
if (observer) observer.disconnect();
};
}, []);
const option: EChartsOption = {
title: title ? { text: title, left: 'center', textStyle: { fontSize: 14 } } : undefined,
tooltip: {
@@ -64,5 +96,16 @@ export function LineChart({
],
};
return <ReactECharts option={option} style={{ height }} />;
return (
<div ref={containerRef} style={{ width: '100%', height }}>
{isReady && (
<ReactECharts
option={option}
style={{ width: '100%', height: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
)}
</div>
);
}
@@ -1,5 +1,6 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
import { BarChart3 } from 'lucide-react';
@@ -30,6 +31,37 @@ export function MultiLineChart({
height = 300,
showArea = false,
}: MultiLineChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isReady, setIsReady] = useState(false);
// Wait for container to have dimensions before rendering chart
useEffect(() => {
let observer: ResizeObserver | null = null;
let timeout: NodeJS.Timeout | null = null;
const checkDimensions = () => {
if (containerRef.current) {
const { clientWidth, clientHeight } = containerRef.current;
if (clientWidth > 0 && clientHeight > 0) {
setIsReady(true);
}
}
};
checkDimensions();
timeout = setTimeout(checkDimensions, 100);
if (containerRef.current && typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(checkDimensions);
observer.observe(containerRef.current);
}
return () => {
if (timeout) clearTimeout(timeout);
if (observer) observer.disconnect();
};
}, []);
const seriesNames = Object.keys(series);
// Get all unique timestamps
@@ -112,5 +144,16 @@ export function MultiLineChart({
);
}
return <ReactECharts option={option} style={{ height }} />;
return (
<div ref={containerRef} style={{ width: '100%', height }}>
{isReady && (
<ReactECharts
option={option}
style={{ width: '100%', height: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
)}
</div>
);
}
+20 -7
View File
@@ -20,6 +20,7 @@ export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
const hasConnectedBefore = useRef(false);
const subscribedChannels = useRef<Set<string>>(new Set());
const messageHandlers = useRef<Map<string, Set<MessageHandler>>>(new Map());
@@ -28,8 +29,8 @@ export function useWebSocket() {
const { accessToken, isAuthenticated } = useAuthStore();
const maxReconnectAttempts = 5;
const baseReconnectDelay = 1000;
const maxReconnectAttempts = 3;
const baseReconnectDelay = 2000;
const connect = useCallback(() => {
if (!accessToken || !isAuthenticated) {
@@ -48,6 +49,7 @@ export function useWebSocket() {
ws.onopen = () => {
setStatus('connected');
reconnectAttempts.current = 0;
hasConnectedBefore.current = true;
// Resubscribe to channels
subscribedChannels.current.forEach((channel) => {
@@ -73,12 +75,22 @@ export function useWebSocket() {
}
};
ws.onclose = () => {
ws.onclose = (event) => {
setStatus('disconnected');
wsRef.current = null;
// Attempt reconnection
if (isAuthenticated && reconnectAttempts.current < maxReconnectAttempts) {
// Only attempt reconnection if:
// 1. We've successfully connected before (server is available)
// 2. It wasn't a clean close
// 3. We're still authenticated
// 4. We haven't exceeded max attempts
const shouldReconnect =
hasConnectedBefore.current &&
!event.wasClean &&
isAuthenticated &&
reconnectAttempts.current < maxReconnectAttempts;
if (shouldReconnect) {
const delay = baseReconnectDelay * Math.pow(2, reconnectAttempts.current);
reconnectAttempts.current += 1;
setStatus('reconnecting');
@@ -89,8 +101,9 @@ export function useWebSocket() {
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
ws.onerror = () => {
// WebSocket errors are typically followed by onclose, so we just silently handle them
// The actual error details are not available in the browser for security reasons
};
}, [accessToken, isAuthenticated]);
+3
View File
@@ -278,6 +278,9 @@ export const consumers = {
list: (clusterId: string, streamName: string) =>
request<{ consumers: any[] }>(`/clusters/${clusterId}/streams/${streamName}/consumers`),
listAll: (clusterId: string) =>
request<{ consumers: any[] }>(`/clusters/${clusterId}/consumers`),
get: (clusterId: string, streamName: string, name: string) =>
request<{ consumer: any }>(`/clusters/${clusterId}/streams/${streamName}/consumers/${name}`),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 B

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+2 -2
View File
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<defs>
<linearGradient id="favGrad4" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#22d3ee"/>
<stop offset="100%" style="stop-color:#3b82f6"/>
<stop offset="0%" style="stop-color:#2563eb"/>
<stop offset="100%" style="stop-color:#2563eb"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="28" fill="url(#favGrad4)"/>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+19
View File
@@ -0,0 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface ClusterState {
selectedClusterId: string | null;
setSelectedClusterId: (clusterId: string | null) => void;
}
export const useClusterStore = create<ClusterState>()(
persist(
(set) => ({
selectedClusterId: null,
setSelectedClusterId: (clusterId) => set({ selectedClusterId: clusterId }),
}),
{
name: 'cluster-storage',
}
)
);