fix chart issue.
move metrics to summary page. fix clusters page summary charts and stats information. update favicons.
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "{consumerToDelete}"? This action cannot be undone.
|
||||
Are you sure you want to delete consumer "{consumerToDelete?.name}"? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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]);
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 648 B After Width: | Height: | Size: 524 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 973 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 220 KiB After Width: | Height: | Size: 53 KiB |
@@ -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 |
@@ -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',
|
||||
}
|
||||
)
|
||||
);
|
||||