mirror of
https://github.com/KLogicHQ/nats-console.git
synced 2026-10-14 00:30:16 +00:00
add support for websocket api for real time updates.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user