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
+6
View File
@@ -39,3 +39,9 @@ apps/api/prisma/*.db-journal
# Docker volumes (if local)
.docker-data
# Compiled TypeScript in shared package
apps/shared/src/**/*.js
apps/shared/src/**/*.js.map
apps/shared/src/**/*.d.ts
apps/shared/src/**/*.d.ts.map
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025 NATS Console
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+57 -54
View File
@@ -1,8 +1,8 @@
# NATS JetStream Console - Roadmap
## Current Status: Alpha
## Current Status: Alpha (v0.3.0)
The core infrastructure is in place with basic CRUD operations for clusters, streams, and consumers.
Phase 1 (Core MVP), Phase 2 (Detail Views & Forms), and Phase 3 (Real-time & Monitoring) are complete. The application now includes full CRUD operations, detailed management views, metrics charts, real-time WebSocket updates, and a complete alert system with notification channels.
---
@@ -37,73 +37,76 @@ The core infrastructure is in place with basic CRUD operations for clusters, str
---
## Phase 2: Detail Views & Forms 🚧 (In Progress)
## Phase 2: Detail Views & Forms ✅ (Completed)
### Stream Management
- [ ] Stream detail page
- [ ] Overview tab with stats
- [ ] Messages tab with browser
- [ ] Consumers tab
- [ ] Configuration tab
- [ ] Metrics tab with charts
- [ ] Create stream dialog/page
- [ ] Edit stream configuration
- [ ] Delete stream with confirmation
- [ ] Purge stream messages
- [ ] Message publishing interface
- [x] Stream detail page
- [x] Overview tab with stats
- [x] Messages tab with browser (pagination, JSON formatting, copy, expand/collapse)
- [x] Consumers tab
- [x] Configuration tab
- [x] Metrics tab with charts (message throughput, data throughput)
- [x] Create stream dialog
- [x] Edit stream configuration
- [x] Delete stream with confirmation
- [x] Purge stream messages
- [x] Message publishing interface
### Consumer Management
- [ ] Consumer detail page
- [ ] Overview with lag visualization
- [ ] Configuration details
- [ ] Metrics and performance
- [ ] Create consumer dialog
- [ ] Edit consumer configuration
- [ ] Delete consumer
- [ ] Pause/Resume consumer
- [x] Consumer detail page
- [x] Overview with lag visualization (progress bars)
- [x] Configuration details
- [x] Metrics tab with charts (lag, pending, ack rate)
- [x] Create consumer dialog
- [x] Edit consumer configuration
- [x] Delete consumer with confirmation
- [x] Pause/Resume consumer
### Cluster Management
- [ ] Cluster detail page
- [ ] Server info and health
- [ ] JetStream account info
- [ ] Connected streams overview
- [ ] Real-time metrics
- [ ] Add cluster wizard
- [ ] Edit cluster connection
- [ ] Test connection functionality
- [ ] Cluster health monitoring
- [x] Cluster detail page
- [x] Server info and health
- [x] JetStream account info
- [x] Connected streams overview
- [x] Metrics tab with gauge charts (memory, connections, storage)
- [x] Add cluster wizard
- [x] Edit cluster connection
- [x] Test connection functionality
- [x] Cluster health monitoring
---
## Phase 3: Real-time & Monitoring
## Phase 3: Real-time & Monitoring ✅ (Completed)
### WebSocket Integration
- [ ] WebSocket server in API
- [ ] Real-time metrics streaming
- [ ] Live message preview
- [ ] Alert notifications
- [ ] Connection status indicators
- [x] WebSocket server in API
- [x] Real-time metrics streaming via Redis pub/sub
- [x] Live message preview
- [x] Alert notifications (real-time via WebSocket)
- [x] Connection status indicators in UI
### Charts & Visualization
- [ ] Integrate charting library (Recharts/Chart.js)
- [ ] Message throughput charts
- [ ] Consumer lag visualization
- [ ] Storage usage graphs
- [ ] Historical trends
- [x] Integrate charting library (ECharts)
- [x] Message throughput charts
- [x] Consumer lag visualization
- [x] Storage usage graphs (gauge charts)
- [x] Historical trends (via ClickHouse time-series)
### Alert System
- [ ] Alert rule builder UI
- [ ] Condition types:
- [ ] Consumer lag threshold
- [ ] Message rate anomaly
- [ ] Storage capacity
- [ ] Connection health
- [ ] Notification channels:
- [ ] Email notifications
- [ ] Slack integration
- [ ] Webhook endpoints
- [ ] PagerDuty integration
- [ ] Alert history and acknowledgment
- [x] Alert rule builder UI
- [x] Condition types:
- [x] Consumer lag threshold
- [x] Message rate anomaly
- [x] Storage capacity
- [x] Connection health
- [x] Notification channels:
- [x] Email notifications (via Resend)
- [x] Slack integration
- [x] Webhook endpoints
- [x] PagerDuty integration
- [x] Microsoft Teams integration
- [x] Google Chat integration
- [x] Alert history and acknowledgment
- [x] Incident management (create, acknowledge, resolve, close)
---
+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(