add support for websocket api for real time updates.

This commit is contained in:
Fizer Khan
2025-12-05 16:22:59 +05:30
parent 5e5a20c9a7
commit 66569fe2ca
24 changed files with 1713 additions and 175 deletions
+5
View File
@@ -9,6 +9,7 @@ import { connectDatabase, disconnectDatabase } from './lib/prisma';
import { redis } from './lib/redis';
import { connectInternalNats, disconnectInternalNats, disconnectAllClusters } from './lib/nats';
import { closeClickHouseClient } from './lib/clickhouse';
import { setupWebSocket } from './lib/websocket';
// Import routes
import { authRoutes } from './modules/auth/auth.routes';
@@ -137,6 +138,10 @@ async function start() {
// Connect to internal NATS
await connectInternalNats();
// Setup WebSocket server
setupWebSocket(app);
app.log.info('WebSocket server ready at /ws');
// Start HTTP server
await app.listen({
port: config.PORT,
+194 -28
View File
@@ -92,11 +92,11 @@ export async function queryStreamMetrics(
format: 'JSONEachRow',
});
const rows = await result.json<any[]>();
const rows = await result.json() as Record<string, unknown>[];
return rows.map((row) => ({
clusterId: row.cluster_id,
streamName: row.stream_name,
timestamp: new Date(row.timestamp),
clusterId: row.cluster_id as string,
streamName: row.stream_name as string,
timestamp: new Date(row.timestamp as string),
messagesTotal: Number(row.messages_total),
bytesTotal: Number(row.bytes_total),
messagesRate: Number(row.messages_rate),
@@ -104,7 +104,7 @@ export async function queryStreamMetrics(
consumerCount: Number(row.consumer_count),
firstSeq: Number(row.first_seq),
lastSeq: Number(row.last_seq),
subjects: row.subjects || [],
subjects: (row.subjects as string[]) || [],
}));
}
@@ -175,12 +175,12 @@ export async function queryConsumerMetrics(
format: 'JSONEachRow',
});
const rows = await result.json<any[]>();
const rows = await result.json() as Record<string, unknown>[];
return rows.map((row) => ({
clusterId: row.cluster_id,
streamName: row.stream_name,
consumerName: row.consumer_name,
timestamp: new Date(row.timestamp),
clusterId: row.cluster_id as string,
streamName: row.stream_name as string,
consumerName: row.consumer_name as string,
timestamp: new Date(row.timestamp as string),
pendingCount: Number(row.pending_count),
ackPending: Number(row.ack_pending),
redelivered: Number(row.redelivered),
@@ -293,7 +293,7 @@ export async function queryAuditLogs(
query_params: params,
format: 'JSONEachRow',
});
const countRows = await countResult.json<{ total: string }[]>();
const countRows = await countResult.json() as { total: string }[];
const total = parseInt(countRows[0]?.total || '0');
// Get logs
@@ -309,29 +309,195 @@ export async function queryAuditLogs(
format: 'JSONEachRow',
});
const rows = await result.json<any[]>();
const rows = await result.json() as Record<string, unknown>[];
const logs = rows.map((row) => ({
id: row.id,
orgId: row.org_id,
userId: row.user_id,
userEmail: row.user_email,
timestamp: new Date(row.timestamp),
action: row.action,
resourceType: row.resource_type,
resourceId: row.resource_id,
resourceName: row.resource_name,
clusterId: row.cluster_id,
ipAddress: row.ip_address,
userAgent: row.user_agent,
requestId: row.request_id,
changes: row.changes,
status: row.status,
errorMessage: row.error_message,
id: row.id as string,
orgId: row.org_id as string,
userId: row.user_id as string,
userEmail: row.user_email as string,
timestamp: new Date(row.timestamp as string),
action: row.action as string,
resourceType: row.resource_type as string,
resourceId: row.resource_id as string,
resourceName: row.resource_name as string,
clusterId: (row.cluster_id as string) || null,
ipAddress: row.ip_address as string,
userAgent: row.user_agent as string,
requestId: row.request_id as string,
changes: row.changes as Record<string, unknown>,
status: row.status as string,
errorMessage: row.error_message as string | undefined,
}));
return { logs, total };
}
// ==================== Cluster Metrics Queries ====================
export async function queryClusterOverview(
clusterId?: string
): Promise<{
totalStreams: number;
totalConsumers: number;
totalMessages: number;
messageRate: number;
activeAlerts: number;
}> {
const ch = getClickHouseClient();
// Get latest stream metrics for counts
const streamQuery = clusterId
? `
SELECT
count(DISTINCT stream_name) as total_streams,
sum(consumer_count) as total_consumers,
sum(messages_total) as total_messages,
avg(messages_rate) as message_rate
FROM stream_metrics
WHERE cluster_id = {clusterId:UUID}
AND timestamp >= now() - INTERVAL 5 MINUTE
`
: `
SELECT
count(DISTINCT stream_name) as total_streams,
count(DISTINCT cluster_id, consumer_name) as total_consumers,
sum(messages_total) as total_messages,
avg(messages_rate) as message_rate
FROM stream_metrics
WHERE timestamp >= now() - INTERVAL 5 MINUTE
`;
try {
const result = await ch.query({
query: streamQuery,
query_params: clusterId ? { clusterId } : {},
format: 'JSONEachRow',
});
const rows = await result.json() as Record<string, unknown>[];
const row = rows[0] || {};
return {
totalStreams: Number(row.total_streams) || 0,
totalConsumers: Number(row.total_consumers) || 0,
totalMessages: Number(row.total_messages) || 0,
messageRate: Number(row.message_rate) || 0,
activeAlerts: 0, // Alerts are managed separately
};
} catch {
return {
totalStreams: 0,
totalConsumers: 0,
totalMessages: 0,
messageRate: 0,
activeAlerts: 0,
};
}
}
export async function queryOverviewMetrics(
clusterId?: string,
timeRange: string = '1h'
): Promise<{
totalMessages: number;
totalBytes: number;
avgThroughput: number;
avgLatency: number;
messagesTrend: number;
bytesTrend: number;
throughputTrend: number;
latencyTrend: number;
}> {
const ch = getClickHouseClient();
// Parse time range
const rangeHours: Record<string, number> = {
'1h': 1,
'6h': 6,
'24h': 24,
'7d': 168,
};
const hours = rangeHours[timeRange] || 1;
const clusterFilter = clusterId ? 'AND cluster_id = {clusterId:UUID}' : '';
try {
// Current period metrics
const currentResult = await ch.query({
query: `
SELECT
sum(messages_total) as total_messages,
sum(bytes_total) as total_bytes,
avg(messages_rate) as avg_throughput
FROM stream_metrics
WHERE timestamp >= now() - INTERVAL ${hours} HOUR
${clusterFilter}
`,
query_params: clusterId ? { clusterId } : {},
format: 'JSONEachRow',
});
const currentRows = await currentResult.json() as Record<string, unknown>[];
const current = currentRows[0] || {};
// Previous period for trend calculation
const previousResult = await ch.query({
query: `
SELECT
sum(messages_total) as total_messages,
sum(bytes_total) as total_bytes,
avg(messages_rate) as avg_throughput
FROM stream_metrics
WHERE timestamp >= now() - INTERVAL ${hours * 2} HOUR
AND timestamp < now() - INTERVAL ${hours} HOUR
${clusterFilter}
`,
query_params: clusterId ? { clusterId } : {},
format: 'JSONEachRow',
});
const previousRows = await previousResult.json() as Record<string, unknown>[];
const previous = previousRows[0] || {};
// Calculate trends (percentage change)
const calcTrend = (curr: number, prev: number) => {
if (prev === 0) return curr > 0 ? 100 : 0;
return ((curr - prev) / prev) * 100;
};
return {
totalMessages: Number(current.total_messages) || 0,
totalBytes: Number(current.total_bytes) || 0,
avgThroughput: Number(current.avg_throughput) || 0,
avgLatency: 0, // Latency requires message-level tracking
messagesTrend: calcTrend(
Number(current.total_messages) || 0,
Number(previous.total_messages) || 0
),
bytesTrend: calcTrend(
Number(current.total_bytes) || 0,
Number(previous.total_bytes) || 0
),
throughputTrend: calcTrend(
Number(current.avg_throughput) || 0,
Number(previous.avg_throughput) || 0
),
latencyTrend: 0,
};
} catch {
return {
totalMessages: 0,
totalBytes: 0,
avgThroughput: 0,
avgLatency: 0,
messagesTrend: 0,
bytesTrend: 0,
throughputTrend: 0,
latencyTrend: 0,
};
}
}
// ==================== Helpers ====================
function parseInterval(interval: string): number {
+34
View File
@@ -298,6 +298,40 @@ export async function deleteConsumer(clusterId: string, streamName: string, cons
return jsm.consumers.delete(streamName, consumerName);
}
export async function pauseConsumer(
clusterId: string,
streamName: string,
consumerName: string,
pauseUntil?: Date
): Promise<ConsumerInfo> {
const jsm = getClusterJetStreamManager(clusterId);
// Get current consumer info
const consumer = await jsm.consumers.info(streamName, consumerName);
// Update with pause_until set to a future time
const until = pauseUntil || new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000); // 100 years if no time specified
return jsm.consumers.update(streamName, consumerName, {
...consumer.config,
pause_until: until,
});
}
export async function resumeConsumer(
clusterId: string,
streamName: string,
consumerName: string
): Promise<ConsumerInfo> {
const jsm = getClusterJetStreamManager(clusterId);
// Get current consumer info
const consumer = await jsm.consumers.info(streamName, consumerName);
// Update with pause_until removed (set to epoch/past time)
return jsm.consumers.update(streamName, consumerName, {
...consumer.config,
pause_until: undefined,
});
}
// ==================== Message Operations ====================
export async function publishMessage(
+64
View File
@@ -145,3 +145,67 @@ export async function getUserPermissions(userId: string, orgId: string): Promise
export async function invalidateUserPermissions(userId: string, orgId: string): Promise<void> {
await redis.del(`${PERMISSIONS_PREFIX}${userId}:${orgId}`);
}
// Pub/Sub for real-time metrics
const METRICS_CHANNEL = 'metrics';
const ALERTS_CHANNEL = 'alerts';
// Subscriber client for pub/sub (separate from main client)
let subscriber: Redis | null = null;
const messageHandlers = new Map<string, Set<(message: any) => void>>();
export function getSubscriber(): Redis {
if (!subscriber) {
subscriber = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
subscriber.on('message', (channel, message) => {
try {
const data = JSON.parse(message);
const handlers = messageHandlers.get(channel);
handlers?.forEach((handler) => handler(data));
} catch (err) {
console.error('Failed to parse Redis message:', err);
}
});
}
return subscriber;
}
export function subscribeToChannel(channel: string, handler: (data: any) => void): () => void {
const sub = getSubscriber();
if (!messageHandlers.has(channel)) {
messageHandlers.set(channel, new Set());
sub.subscribe(channel);
}
messageHandlers.get(channel)!.add(handler);
// Return unsubscribe function
return () => {
const handlers = messageHandlers.get(channel);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
sub.unsubscribe(channel);
messageHandlers.delete(channel);
}
}
};
}
export async function publishMetrics(data: any): Promise<void> {
await redis.publish(METRICS_CHANNEL, JSON.stringify(data));
}
export async function publishAlert(data: any): Promise<void> {
await redis.publish(ALERTS_CHANNEL, JSON.stringify(data));
}
export { METRICS_CHANNEL, ALERTS_CHANNEL };
+21
View File
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
import { WebSocket, WebSocketServer } from 'ws';
import { verifyToken } from '../common/middleware/auth';
import { logger } from './logger';
import { subscribeToChannel, METRICS_CHANNEL, ALERTS_CHANNEL } from './redis';
interface Client {
ws: WebSocket;
@@ -76,9 +77,29 @@ export function setupWebSocket(fastify: FastifyInstance): WebSocketServer {
}
});
// Set up Redis subscription bridge for real-time updates
setupRedisBridge();
return wss;
}
function setupRedisBridge() {
// Subscribe to metrics channel and broadcast to WebSocket clients
subscribeToChannel(METRICS_CHANNEL, (data) => {
const channel = data.channel || 'metrics';
broadcast(channel, data);
logger.debug({ channel }, 'Broadcasting metrics to WebSocket clients');
});
// Subscribe to alerts channel and broadcast to WebSocket clients
subscribeToChannel(ALERTS_CHANNEL, (data) => {
broadcast('alerts', data);
logger.debug('Broadcasting alert to WebSocket clients');
});
logger.info('Redis to WebSocket bridge established');
}
function handleMessage(clientId: string, client: Client, message: any) {
switch (message.type) {
case 'subscribe':
@@ -4,6 +4,8 @@ import {
queryStreamMetrics,
queryConsumerMetrics,
queryAuditLogs,
queryClusterOverview,
queryOverviewMetrics,
} from '../../lib/clickhouse';
import { authenticate } from '../../common/middleware/auth';
@@ -106,14 +108,8 @@ export const analyticsRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get<{ Querystring: { clusterId?: string } }>(
'/cluster/overview',
async (request) => {
// TODO: Implement cluster overview metrics
return {
totalStreams: 0,
totalConsumers: 0,
totalMessages: 0,
messageRate: 0,
activeAlerts: 0,
};
const { clusterId } = request.query;
return queryClusterOverview(clusterId);
}
);
@@ -121,17 +117,8 @@ export const analyticsRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get<{ Querystring: { clusterId?: string; timeRange?: string } }>(
'/overview',
async (request) => {
// TODO: Implement real metrics from ClickHouse
return {
totalMessages: 0,
totalBytes: 0,
avgThroughput: 0,
avgLatency: 0,
messagesTrend: 0,
bytesTrend: 0,
throughputTrend: 0,
latencyTrend: 0,
};
const { clusterId, timeRange } = request.query;
return queryOverviewMetrics(clusterId, timeRange || '1h');
}
);
};
@@ -93,4 +93,39 @@ export const consumerRoutes: FastifyPluginAsync = async (fastify) => {
return { consumer };
}
);
// POST /clusters/:cid/streams/:sid/consumers/:name/pause - Pause consumer
fastify.post<{
Params: { cid: string; sid: string; name: string };
Body: { pauseUntil?: string };
}>(
'/:cid/streams/:sid/consumers/:name/pause',
async (request) => {
const pauseUntil = request.body?.pauseUntil
? new Date(request.body.pauseUntil)
: undefined;
const consumer = await consumerService.pauseConsumer(
request.user!.orgId,
request.params.cid,
request.params.sid,
request.params.name,
pauseUntil
);
return { consumer, paused: true };
}
);
// POST /clusters/:cid/streams/:sid/consumers/:name/resume - Resume consumer
fastify.post<{ Params: { cid: string; sid: string; name: string } }>(
'/:cid/streams/:sid/consumers/:name/resume',
async (request) => {
const consumer = await consumerService.resumeConsumer(
request.user!.orgId,
request.params.cid,
request.params.sid,
request.params.name
);
return { consumer, resumed: true };
}
);
};
@@ -5,6 +5,8 @@ import {
createConsumer as natsCreateConsumer,
updateConsumer as natsUpdateConsumer,
deleteConsumer as natsDeleteConsumer,
pauseConsumer as natsPauseConsumer,
resumeConsumer as natsResumeConsumer,
} from '../../lib/nats';
import { NotFoundError } from '../../../../shared/src/index';
import type { ConsumerInfo, CreateConsumerInput, UpdateConsumerInput } from '../../../../shared/src/index';
@@ -228,3 +230,42 @@ function mapReplayPolicy(policy: string): any {
};
return map[policy] || 'instant';
}
// ==================== Pause/Resume Operations ====================
export async function pauseConsumer(
orgId: string,
clusterId: string,
streamName: string,
consumerName: string,
pauseUntil?: Date
): Promise<ConsumerInfo> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
return natsPauseConsumer(clusterId, streamName, consumerName, pauseUntil);
}
export async function resumeConsumer(
orgId: string,
clusterId: string,
streamName: string,
consumerName: string
): Promise<ConsumerInfo> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
return natsResumeConsumer(clusterId, streamName, consumerName);
}
+122 -15
View File
@@ -1,6 +1,6 @@
'use client';
import { Suspense, useState } from 'react';
import { Suspense, useState, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -36,6 +36,7 @@ import {
} from '@/components/ui/alert-dialog';
import { formatBytes, formatNumber } from '@nats-console/shared';
import { CreateClusterDialog } from '@/components/forms/create-cluster-dialog';
import { GaugeChart } from '@/components/charts';
const tabs: Tab[] = [
{ id: 'overview', label: 'Overview', icon: Server },
@@ -425,21 +426,127 @@ function ClusterDetailContent() {
{/* Metrics Tab */}
{activeTab === 'metrics' && (
<Card>
<CardHeader>
<CardTitle>Cluster Metrics</CardTitle>
<CardDescription>Performance metrics and trends</CardDescription>
</CardHeader>
<CardContent>
<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>Chart visualization coming soon</p>
<p className="text-sm">Integrate with your preferred charting library</p>
<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">Memory Usage</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<GaugeChart
value={healthData?.mem ? Math.round(healthData.mem / (1024 * 1024)) : 0}
max={1024}
title={formatBytes(healthData?.mem || 0)}
unit=" MB"
color="#2563eb"
height={150}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Connections</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<GaugeChart
value={healthData?.connections || 0}
max={100}
title={`${healthData?.connections || 0} active`}
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={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>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
{/* Server Info */}
<Card>
<CardHeader>
<CardTitle>Server Performance</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">Uptime</span>
<span className="font-medium">{healthData?.uptime || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Memory</span>
<span className="font-medium">{formatBytes(healthData?.mem || 0)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Connections</span>
<span className="font-medium">{formatNumber(healthData?.connections || 0)}</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">{cluster.version || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Go Version</span>
<span className="font-medium">{healthData?.go || '-'}</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,6 +1,6 @@
'use client';
import { Suspense, useState } from 'react';
import { Suspense, useState, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -34,6 +34,7 @@ import {
} from '@/components/ui/alert-dialog';
import { formatNumber, formatDuration } from '@nats-console/shared';
import { CreateConsumerDialog } from '@/components/forms/create-consumer-dialog';
import { LineChart } from '@/components/charts';
const tabs: Tab[] = [
{ id: 'overview', label: 'Overview', icon: Users },
@@ -50,6 +51,7 @@ function ConsumerDetailContent() {
const consumerName = params.name as string;
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [metricsTimeRange, setMetricsTimeRange] = useState('1h');
const { activeTab, setActiveTab } = useTabs(tabs, 'overview');
@@ -65,6 +67,67 @@ function ConsumerDetailContent() {
},
});
const pauseMutation = useMutation({
mutationFn: () => api.consumers.pause(clusterId, streamName, consumerName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['consumer', clusterId, streamName, consumerName] });
},
});
const resumeMutation = useMutation({
mutationFn: () => api.consumers.resume(clusterId, streamName, consumerName),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['consumer', clusterId, streamName, consumerName] });
},
});
// Metrics data
const getTimeRangeParams = () => {
const now = new Date();
const ranges: Record<string, number> = {
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
};
const from = new Date(now.getTime() - (ranges[metricsTimeRange] || ranges['1h']));
return {
clusterId,
streamName,
from: from.toISOString(),
to: now.toISOString(),
interval: metricsTimeRange === '7d' ? '1h' : metricsTimeRange === '24h' ? '30m' : '5m',
};
};
const { data: metricsData, isLoading: isLoadingMetrics } = useQuery({
queryKey: ['consumer-metrics', clusterId, streamName, consumerName, metricsTimeRange],
queryFn: () => api.analytics.consumerLag(consumerName, getTimeRangeParams()),
enabled: activeTab === 'metrics',
});
// Transform metrics data for charts
const chartData = useMemo(() => {
if (!metricsData?.data?.length) return { lag: [], pending: [], ackRate: [] };
return {
lag: metricsData.data.map((d: any) => ({
name: 'Lag',
value: d.lag || 0,
time: new Date(d.timestamp).toLocaleTimeString(),
})),
pending: metricsData.data.map((d: any) => ({
name: 'Pending',
value: d.pendingCount || 0,
time: new Date(d.timestamp).toLocaleTimeString(),
})),
ackRate: metricsData.data.map((d: any) => ({
name: 'Ack Rate',
value: d.ackRate || 0,
time: new Date(d.timestamp).toLocaleTimeString(),
})),
};
}, [metricsData]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -97,6 +160,15 @@ function ConsumerDetailContent() {
const health = getHealthStatus();
// Check if consumer is paused (pause_until is in the future)
const isPaused = () => {
const pauseUntil = consumer.config?.pause_until;
if (!pauseUntil) return false;
return new Date(pauseUntil) > new Date();
};
const paused = isPaused();
return (
<div className="space-y-6">
{/* Header */}
@@ -113,6 +185,11 @@ function ConsumerDetailContent() {
<span className={`px-2 py-1 text-xs rounded-full ${health.bg} ${health.color}`}>
{health.label}
</span>
{paused && (
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700">
Paused
</span>
)}
</div>
<p className="text-muted-foreground">
Stream: {streamName}
@@ -127,6 +204,27 @@ function ConsumerDetailContent() {
>
<RefreshCw className="h-4 w-4" />
</Button>
{paused ? (
<Button
variant="outline"
size="sm"
onClick={() => resumeMutation.mutate()}
disabled={resumeMutation.isPending}
>
<Play className="h-4 w-4" />
{resumeMutation.isPending ? 'Resuming...' : 'Resume'}
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => pauseMutation.mutate()}
disabled={pauseMutation.isPending}
>
<Pause className="h-4 w-4" />
{pauseMutation.isPending ? 'Pausing...' : 'Pause'}
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setShowEditDialog(true)}>
<Edit className="h-4 w-4" />
Edit
@@ -364,21 +462,150 @@ function ConsumerDetailContent() {
{/* Metrics Tab */}
{activeTab === 'metrics' && (
<Card>
<CardHeader>
<CardTitle>Consumer Metrics</CardTitle>
<CardDescription>Performance metrics and trends</CardDescription>
</CardHeader>
<CardContent>
<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>Chart visualization coming soon</p>
<p className="text-sm">Integrate with your preferred charting library</p>
</div>
</div>
</CardContent>
</Card>
<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>
{/* 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>
{/* Quick Stats */}
<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>
</div>
)}
</div>
);
+3 -2
View File
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth';
import { Sidebar } from '@/components/layout/sidebar';
import { WebSocketProvider } from '@/components/providers/websocket-provider';
export default function DashboardLayout({
children,
@@ -39,12 +40,12 @@ export default function DashboardLayout({
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : (
<>
<WebSocketProvider>
<Sidebar />
<main className="flex-1 overflow-auto bg-white">
<div className="p-6">{children}</div>
</main>
</>
</WebSocketProvider>
)}
</div>
);
@@ -1,6 +1,6 @@
'use client';
import { useState, Suspense } from 'react';
import { useState, Suspense, useMemo } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
@@ -42,6 +42,8 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { formatBytes, formatNumber, formatDuration } from '@nats-console/shared';
import { LineChart } from '@/components/charts';
import { CreateStreamDialog } from '@/components/forms/create-stream-dialog';
const tabs: Tab[] = [
{ id: 'overview', label: 'Overview', icon: Database },
@@ -62,6 +64,7 @@ function StreamDetailContent() {
const [messageSubject, setMessageSubject] = useState('');
const [messageData, setMessageData] = useState('');
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [copiedMessageId, setCopiedMessageId] = useState<number | null>(null);
const [expandedMessages, setExpandedMessages] = useState<Set<number>>(new Set());
@@ -69,6 +72,9 @@ function StreamDetailContent() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// Metrics time range
const [metricsTimeRange, setMetricsTimeRange] = useState('1h');
// Helper to format message data - detect JSON and pretty print
const formatMessageData = (data: unknown): { formatted: string; isJson: boolean } => {
if (typeof data === 'object' && data !== null) {
@@ -139,6 +145,47 @@ function StreamDetailContent() {
enabled: activeTab === 'messages' && !!streamData?.stream,
});
// Metrics data
const getTimeRangeParams = () => {
const now = new Date();
const ranges: Record<string, number> = {
'1h': 60 * 60 * 1000,
'6h': 6 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
};
const from = new Date(now.getTime() - (ranges[metricsTimeRange] || ranges['1h']));
return {
clusterId,
from: from.toISOString(),
to: now.toISOString(),
interval: metricsTimeRange === '7d' ? '1h' : metricsTimeRange === '24h' ? '30m' : '5m',
};
};
const { data: metricsData, isLoading: isLoadingMetrics } = useQuery({
queryKey: ['stream-metrics', clusterId, streamName, metricsTimeRange],
queryFn: () => api.analytics.streamThroughput(streamName, getTimeRangeParams()),
enabled: activeTab === 'metrics',
});
// Transform metrics data for charts
const chartData = useMemo(() => {
if (!metricsData?.data?.length) return { messages: [], bytes: [] };
return {
messages: metricsData.data.map((d: any) => ({
name: 'Messages/s',
value: d.messagesRate || 0,
time: new Date(d.timestamp).toLocaleTimeString(),
})),
bytes: metricsData.data.map((d: any) => ({
name: 'Bytes/s',
value: d.bytesRate || 0,
time: new Date(d.timestamp).toLocaleTimeString(),
})),
};
}, [metricsData]);
const publishMutation = useMutation({
mutationFn: (data: { subject: string; data: string }) =>
api.streams.publish(clusterId, streamName, data),
@@ -214,7 +261,7 @@ function StreamDetailContent() {
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" onClick={() => setShowEditDialog(true)}>
<Edit className="h-4 w-4" />
Edit
</Button>
@@ -260,6 +307,15 @@ function StreamDetailContent() {
</AlertDialogContent>
</AlertDialog>
{/* Edit Stream Dialog */}
<CreateStreamDialog
open={showEditDialog}
onOpenChange={setShowEditDialog}
clusterId={clusterId}
stream={stream}
mode="edit"
/>
{/* Tabs */}
<TabsList tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} />
@@ -674,21 +730,119 @@ function StreamDetailContent() {
{/* Metrics Tab */}
{activeTab === 'metrics' && (
<Card>
<CardHeader>
<CardTitle>Stream Metrics</CardTitle>
<CardDescription>Performance metrics and trends</CardDescription>
</CardHeader>
<CardContent>
<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>Chart visualization coming soon</p>
<p className="text-sm">Integrate with your preferred charting library</p>
</div>
</div>
</CardContent>
</Card>
<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>
{/* 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>
{/* 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>
</div>
)}
</div>
);
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
@@ -18,29 +18,45 @@ interface CreateStreamDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
clusterId: string;
stream?: any;
mode?: 'create' | 'edit';
}
export function CreateStreamDialog({
open,
onOpenChange,
clusterId,
stream,
mode = 'create',
}: CreateStreamDialogProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: '',
subjects: '',
description: '',
storage: 'file',
retention: 'limits',
maxMsgs: '-1',
maxBytes: '-1',
maxAge: '0',
maxMsgSize: '-1',
replicas: '1',
discard: 'old',
const isEditing = mode === 'edit' && stream;
const getInitialFormData = () => ({
name: stream?.config?.name || '',
subjects: stream?.config?.subjects?.join(', ') || '',
description: stream?.config?.description || '',
storage: stream?.config?.storage || 'file',
retention: stream?.config?.retention || 'limits',
maxMsgs: String(stream?.config?.max_msgs ?? -1),
maxBytes: String(stream?.config?.max_bytes ?? -1),
maxAge: String(stream?.config?.max_age ?? 0),
maxMsgSize: String(stream?.config?.max_msg_size ?? -1),
replicas: String(stream?.config?.num_replicas ?? 1),
discard: stream?.config?.discard || 'old',
});
const [formData, setFormData] = useState(getInitialFormData());
const [error, setError] = useState('');
// Reset form when stream changes or dialog opens
useEffect(() => {
if (open) {
setFormData(getInitialFormData());
setError('');
}
}, [open, stream]);
const createMutation = useMutation({
mutationFn: (data: any) => api.streams.create(clusterId, data),
onSuccess: () => {
@@ -53,6 +69,18 @@ export function CreateStreamDialog({
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => api.streams.update(clusterId, stream?.config?.name, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['streams', clusterId] });
queryClient.invalidateQueries({ queryKey: ['stream', clusterId, stream?.config?.name] });
onOpenChange(false);
},
onError: (err: any) => {
setError(err.message || 'Failed to update stream');
},
});
const resetForm = () => {
setFormData({
name: '',
@@ -84,9 +112,9 @@ export function CreateStreamDialog({
return;
}
const subjects = formData.subjects.split(',').map((s) => s.trim()).filter(Boolean);
const subjects = formData.subjects.split(',').map((s: string) => s.trim()).filter(Boolean);
createMutation.mutate({
const data = {
name: formData.name,
subjects,
storage: formData.storage,
@@ -97,16 +125,26 @@ export function CreateStreamDialog({
maxMsgSize: parseInt(formData.maxMsgSize),
replicas: parseInt(formData.replicas),
discard: formData.discard,
});
};
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate(data);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Stream</DialogTitle>
<DialogTitle>{isEditing ? 'Edit Stream' : 'Create Stream'}</DialogTitle>
<DialogDescription>
Create a new JetStream stream on this cluster
{isEditing
? `Update configuration for stream "${stream?.config?.name}"`
: 'Create a new JetStream stream on this cluster'}
</DialogDescription>
</DialogHeader>
@@ -125,7 +163,11 @@ export function CreateStreamDialog({
placeholder="ORDERS"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value.toUpperCase() })}
disabled={isEditing}
/>
{isEditing && (
<p className="text-xs text-muted-foreground">Stream name cannot be changed</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Subjects *</label>
@@ -243,13 +285,15 @@ export function CreateStreamDialog({
variant="outline"
onClick={() => {
onOpenChange(false);
resetForm();
if (!isEditing) resetForm();
}}
>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create Stream'}
<Button type="submit" disabled={isPending}>
{isPending
? isEditing ? 'Updating...' : 'Creating...'
: isEditing ? 'Update Stream' : 'Create Stream'}
</Button>
</DialogFooter>
</form>
+3 -1
View File
@@ -16,6 +16,7 @@ import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth';
import { Button } from '@/components/ui/button';
import { Logo } from '@/components/logo';
import { ConnectionStatus } from '@/components/ui/connection-status';
const navigation = [
{ name: 'Overview', href: '/overview', icon: LayoutDashboard },
@@ -34,8 +35,9 @@ export function Sidebar() {
return (
<div className="flex h-full w-52 flex-col border-r bg-sidebar">
{/* Logo */}
<div className="flex h-14 items-center border-b px-4">
<div className="flex h-14 items-center justify-between border-b px-4">
<Logo href="/clusters" size="sm" />
<ConnectionStatus />
</div>
{/* Navigation */}
@@ -0,0 +1,26 @@
'use client';
import { createContext, useContext, ReactNode } from 'react';
import { useWebSocket } from '@/hooks/useWebSocket';
type WebSocketContextType = ReturnType<typeof useWebSocket>;
const WebSocketContext = createContext<WebSocketContextType | null>(null);
export function WebSocketProvider({ children }: { children: ReactNode }) {
const ws = useWebSocket();
return (
<WebSocketContext.Provider value={ws}>
{children}
</WebSocketContext.Provider>
);
}
export function useWebSocketContext() {
const context = useContext(WebSocketContext);
if (!context) {
throw new Error('useWebSocketContext must be used within a WebSocketProvider');
}
return context;
}
@@ -0,0 +1,70 @@
'use client';
import { Wifi, WifiOff, Loader2 } from 'lucide-react';
import { useWebSocketContext } from '@/components/providers/websocket-provider';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
export function ConnectionStatus() {
const { status, isConnected } = useWebSocketContext();
const getStatusConfig = () => {
switch (status) {
case 'connected':
return {
icon: Wifi,
color: 'text-green-500',
label: 'Connected',
description: 'Real-time updates active',
};
case 'connecting':
return {
icon: Loader2,
color: 'text-yellow-500 animate-spin',
label: 'Connecting',
description: 'Establishing connection...',
};
case 'reconnecting':
return {
icon: Loader2,
color: 'text-yellow-500 animate-spin',
label: 'Reconnecting',
description: 'Attempting to reconnect...',
};
case 'disconnected':
default:
return {
icon: WifiOff,
color: 'text-muted-foreground',
label: 'Disconnected',
description: 'Real-time updates unavailable',
};
}
};
const config = getStatusConfig();
const Icon = config.icon;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted transition-colors">
<Icon className={`h-4 w-4 ${config.color}`} />
<span className="text-xs text-muted-foreground hidden sm:inline">
{config.label}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{config.label}</p>
<p className="text-xs text-muted-foreground">{config.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+25
View File
@@ -0,0 +1,25 @@
'use client';
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={`z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ${className || ''}`}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+179
View File
@@ -0,0 +1,179 @@
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
import { useAuthStore } from '@/stores/auth';
const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:3001/ws';
type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
interface WebSocketMessage {
type: string;
channel?: string;
data?: any;
timestamp?: number;
}
type MessageHandler = (data: any, channel: string) => void;
export function useWebSocket() {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
const subscribedChannels = useRef<Set<string>>(new Set());
const messageHandlers = useRef<Map<string, Set<MessageHandler>>>(new Map());
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const { accessToken, isAuthenticated } = useAuthStore();
const maxReconnectAttempts = 5;
const baseReconnectDelay = 1000;
const connect = useCallback(() => {
if (!accessToken || !isAuthenticated) {
return;
}
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
setStatus('connecting');
const ws = new WebSocket(`${WS_URL}?token=${accessToken}`);
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
reconnectAttempts.current = 0;
// Resubscribe to channels
subscribedChannels.current.forEach((channel) => {
ws.send(JSON.stringify({ type: 'subscribe', channel }));
});
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
setLastMessage(message);
if (message.type === 'message' && message.channel) {
const handlers = messageHandlers.current.get(message.channel);
handlers?.forEach((handler) => handler(message.data, message.channel!));
// Also notify wildcard handlers
const wildcardHandlers = messageHandlers.current.get('*');
wildcardHandlers?.forEach((handler) => handler(message.data, message.channel!));
}
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
ws.onclose = () => {
setStatus('disconnected');
wsRef.current = null;
// Attempt reconnection
if (isAuthenticated && reconnectAttempts.current < maxReconnectAttempts) {
const delay = baseReconnectDelay * Math.pow(2, reconnectAttempts.current);
reconnectAttempts.current += 1;
setStatus('reconnecting');
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}, [accessToken, isAuthenticated]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setStatus('disconnected');
reconnectAttempts.current = 0;
}, []);
const subscribe = useCallback((channel: string, handler?: MessageHandler) => {
subscribedChannels.current.add(channel);
if (handler) {
if (!messageHandlers.current.has(channel)) {
messageHandlers.current.set(channel, new Set());
}
messageHandlers.current.get(channel)!.add(handler);
}
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'subscribe', channel }));
}
// Return unsubscribe function
return () => {
if (handler) {
messageHandlers.current.get(channel)?.delete(handler);
}
};
}, []);
const unsubscribe = useCallback((channel: string) => {
subscribedChannels.current.delete(channel);
messageHandlers.current.delete(channel);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'unsubscribe', channel }));
}
}, []);
const send = useCallback((message: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
}
}, []);
// Auto-connect when authenticated
useEffect(() => {
if (isAuthenticated && accessToken) {
connect();
} else {
disconnect();
}
return () => {
disconnect();
};
}, [isAuthenticated, accessToken, connect, disconnect]);
return {
status,
isConnected: status === 'connected',
lastMessage,
connect,
disconnect,
subscribe,
unsubscribe,
send,
};
}
// Singleton instance for global access
let globalWsInstance: ReturnType<typeof useWebSocket> | null = null;
export function getWebSocketInstance(): ReturnType<typeof useWebSocket> | null {
return globalWsInstance;
}
+12
View File
@@ -220,6 +220,18 @@ export const consumers = {
delete: (clusterId: string, streamName: string, name: string) =>
request(`/clusters/${clusterId}/streams/${streamName}/consumers/${name}`, { method: 'DELETE' }),
pause: (clusterId: string, streamName: string, name: string, pauseUntil?: string) =>
request<{ consumer: any; paused: boolean }>(`/clusters/${clusterId}/streams/${streamName}/consumers/${name}/pause`, {
method: 'POST',
body: pauseUntil ? { pauseUntil } : {},
}),
resume: (clusterId: string, streamName: string, name: string) =>
request<{ consumer: any; resumed: boolean }>(`/clusters/${clusterId}/streams/${streamName}/consumers/${name}/resume`, {
method: 'POST',
body: {},
}),
};
// Analytics API
+99 -2
View File
@@ -1,6 +1,7 @@
import { connect, NatsConnection, JetStreamManager } from 'nats';
import { createClient, ClickHouseClient } from '@clickhouse/client';
import { PrismaClient } from '@prisma/client';
import Redis from 'ioredis';
import pino from 'pino';
import { config } from '../config';
import type { StreamMetrics, ConsumerMetrics, ClusterMetrics } from '../../../shared/src/index';
@@ -19,14 +20,24 @@ interface ClusterConnection {
serverUrl: string;
}
interface ConsumerStats {
delivered: number;
ackFloor: number;
timestamp: number;
}
const METRICS_CHANNEL = 'metrics';
export class MetricsCollector {
private prisma: PrismaClient;
private clickhouse: ClickHouseClient;
private redis: Redis;
private connections: Map<string, ClusterConnection> = new Map();
private streamMetricsInterval: NodeJS.Timeout | null = null;
private clusterMetricsInterval: NodeJS.Timeout | null = null;
private previousStreamStats: Map<string, { messages: number; bytes: number; timestamp: number }> =
new Map();
private previousConsumerStats: Map<string, ConsumerStats> = new Map();
constructor() {
this.prisma = new PrismaClient();
@@ -36,6 +47,7 @@ export class MetricsCollector {
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
this.redis = new Redis(config.REDIS_URL);
}
async start(): Promise<void> {
@@ -79,6 +91,7 @@ export class MetricsCollector {
this.connections.clear();
await this.clickhouse.close();
await this.redis.quit();
await this.prisma.$disconnect();
logger.info('Metrics collector stopped');
@@ -177,6 +190,31 @@ export class MetricsCollector {
// Collect consumer metrics for this stream
try {
for await (const consumerInfo of conn.jsm.consumers.list(streamName)) {
const consumerKey = `${clusterId}:${streamName}:${consumerInfo.name}`;
const prevConsumer = this.previousConsumerStats.get(consumerKey);
const now = Date.now();
// Get delivered and ack sequences
const delivered = consumerInfo.delivered?.stream_seq || 0;
const ackFloor = consumerInfo.ack_floor?.stream_seq || 0;
let deliveredRate = 0;
let ackRate = 0;
if (prevConsumer) {
const timeDiff = (now - prevConsumer.timestamp) / 1000; // seconds
if (timeDiff > 0) {
deliveredRate = Math.max(0, (delivered - prevConsumer.delivered) / timeDiff);
ackRate = Math.max(0, (ackFloor - prevConsumer.ackFloor) / timeDiff);
}
}
this.previousConsumerStats.set(consumerKey, {
delivered,
ackFloor,
timestamp: now,
});
consumerMetrics.push({
clusterId,
streamName,
@@ -186,8 +224,8 @@ export class MetricsCollector {
ackPending: consumerInfo.num_ack_pending,
redelivered: consumerInfo.num_redelivered,
waiting: consumerInfo.num_waiting,
deliveredRate: 0, // TODO: Calculate from delivered sequence
ackRate: 0, // TODO: Calculate from ack floor
deliveredRate,
ackRate,
lag: consumerInfo.num_pending,
});
}
@@ -222,6 +260,9 @@ export class MetricsCollector {
});
logger.debug({ count: streamMetrics.length }, 'Inserted stream metrics');
// Publish to Redis for real-time streaming
await this.publishStreamMetrics(streamMetrics);
} catch (err) {
logger.error({ err }, 'Error inserting stream metrics');
}
@@ -248,12 +289,68 @@ export class MetricsCollector {
});
logger.debug({ count: consumerMetrics.length }, 'Inserted consumer metrics');
// Publish to Redis for real-time streaming
await this.publishConsumerMetrics(consumerMetrics);
} catch (err) {
logger.error({ err }, 'Error inserting consumer metrics');
}
}
}
private async publishStreamMetrics(metrics: StreamMetrics[]): Promise<void> {
try {
for (const metric of metrics) {
await this.redis.publish(
METRICS_CHANNEL,
JSON.stringify({
type: 'stream',
channel: `stream:${metric.clusterId}:${metric.streamName}`,
data: {
clusterId: metric.clusterId,
streamName: metric.streamName,
timestamp: metric.timestamp.toISOString(),
messagesTotal: metric.messagesTotal,
bytesTotal: metric.bytesTotal,
messagesRate: metric.messagesRate,
bytesRate: metric.bytesRate,
consumerCount: metric.consumerCount,
},
})
);
}
} catch (err) {
logger.error({ err }, 'Error publishing stream metrics to Redis');
}
}
private async publishConsumerMetrics(metrics: ConsumerMetrics[]): Promise<void> {
try {
for (const metric of metrics) {
await this.redis.publish(
METRICS_CHANNEL,
JSON.stringify({
type: 'consumer',
channel: `consumer:${metric.clusterId}:${metric.streamName}:${metric.consumerName}`,
data: {
clusterId: metric.clusterId,
streamName: metric.streamName,
consumerName: metric.consumerName,
timestamp: metric.timestamp.toISOString(),
pendingCount: metric.pendingCount,
ackPending: metric.ackPending,
lag: metric.lag,
deliveredRate: metric.deliveredRate,
ackRate: metric.ackRate,
},
})
);
}
} catch (err) {
logger.error({ err }, 'Error publishing consumer metrics to Redis');
}
}
private async collectClusterMetrics(): Promise<void> {
const clusterMetrics: ClusterMetrics[] = [];
const timestamp = new Date();
+42
View File
@@ -1,9 +1,12 @@
import { PrismaClient, IncidentStatus, NotificationChannelType } from '@prisma/client';
import { createClient, ClickHouseClient } from '@clickhouse/client';
import Redis from 'ioredis';
import pino from 'pino';
import { config } from '../config';
import type { AlertSeverity } from '../../../shared/src/index';
const ALERTS_CHANNEL = 'alerts';
const logger = pino({ name: 'alert-processor' });
interface AlertRule {
@@ -44,6 +47,7 @@ interface AlertState {
export class AlertProcessor {
private prisma: PrismaClient;
private clickhouse: ClickHouseClient;
private redis: Redis;
private alertStates: Map<string, AlertState> = new Map();
private processInterval: NodeJS.Timeout | null = null;
@@ -55,6 +59,7 @@ export class AlertProcessor {
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
this.redis = new Redis(config.REDIS_URL);
}
async start(): Promise<void> {
@@ -77,6 +82,7 @@ export class AlertProcessor {
}
await this.clickhouse.close();
await this.redis.quit();
await this.prisma.$disconnect();
logger.info('Alert processor stopped');
@@ -307,6 +313,21 @@ export class AlertProcessor {
where: { id: incident.id },
data: { notifiedAt: new Date() },
});
// Broadcast to WebSocket clients via Redis
await this.broadcastAlert({
type: 'incident_created',
incident: {
id: incident.id,
ruleId: rule.id,
ruleName: rule.name,
severity: rule.severity,
status: 'open',
metricValue,
threshold: rule.threshold.value,
triggeredAt: new Date().toISOString(),
},
});
}
private async resolveAlert(rule: AlertRule, incidentId: string): Promise<void> {
@@ -351,6 +372,27 @@ export class AlertProcessor {
for (const channel of enabledChannels) {
await this.sendNotification(channel, rule, 0, 'resolved', incidentId);
}
// Broadcast to WebSocket clients via Redis
await this.broadcastAlert({
type: 'incident_resolved',
incident: {
id: incidentId,
ruleId: rule.id,
ruleName: rule.name,
status: 'resolved',
resolvedAt: new Date().toISOString(),
},
});
}
private async broadcastAlert(data: Record<string, unknown>): Promise<void> {
try {
await this.redis.publish(ALERTS_CHANNEL, JSON.stringify(data));
logger.debug({ type: data.type }, 'Alert broadcasted to WebSocket clients');
} catch (err) {
logger.error({ err }, 'Error broadcasting alert to Redis');
}
}
private async sendNotification(