mirror of
https://github.com/KLogicHQ/nats-console.git
synced 2026-10-04 20:41:29 +00:00
add support for websocket api for real time updates.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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