add api, web, worker.

This commit is contained in:
Fizer Khan
2025-12-04 21:46:20 +05:30
parent ede3b87d8c
commit 22c1bc1134
66 changed files with 8234 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Dependencies
node_modules
.pnpm-store
# Build outputs
dist
.next
out
build
# Environment files
.env
.env.local
.env.*.local
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs
# Testing
coverage
# Turbo
.turbo
# Prisma
apps/api/prisma/*.db
apps/api/prisma/*.db-journal
# Docker volumes (if local)
.docker-data
+34
View File
@@ -0,0 +1,34 @@
# Server
NODE_ENV=development
PORT=3001
HOST=0.0.0.0
# PostgreSQL
DATABASE_URL=postgresql://nats_console:nats_console_dev@localhost:5432/nats_console
# Redis
REDIS_URL=redis://localhost:6379
# ClickHouse
CLICKHOUSE_URL=http://localhost:8123
CLICKHOUSE_DATABASE=nats_console
CLICKHOUSE_USER=nats_console
CLICKHOUSE_PASSWORD=nats_console_dev
# NATS (internal for job queues)
NATS_URL=nats://localhost:4222
# JWT (generate with: openssl rand -base64 32)
JWT_SECRET=your-super-secret-jwt-key-at-least-32-chars
JWT_ACCESS_EXPIRY=15m
JWT_REFRESH_EXPIRY=7d
# Encryption (generate with: openssl rand -base64 32)
ENCRYPTION_KEY=your-super-secret-encryption-key-32
# CORS
CORS_ORIGIN=http://localhost:3000
# Rate Limiting
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW=60000
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@nats-console/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rm -rf dist",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^12.0.1",
"@fastify/jwt": "^9.0.1",
"@fastify/rate-limit": "^10.2.1",
"@fastify/sensible": "^6.0.1",
"@fastify/websocket": "^11.0.1",
"@nats-console/shared": "workspace:*",
"@clickhouse/client": "^1.8.1",
"@prisma/client": "^6.0.1",
"argon2": "^0.41.1",
"fastify": "^5.1.0",
"ioredis": "^5.4.1",
"jose": "^5.9.6",
"nats": "^2.28.2",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.1",
"prisma": "^6.0.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
+363
View File
@@ -0,0 +1,363 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ==================== Enums ====================
enum OrganizationPlan {
free
pro
enterprise
}
enum UserStatus {
active
inactive
suspended
}
enum MemberRole {
owner
admin
member
viewer
}
enum ClusterEnvironment {
development
staging
production
}
enum ClusterStatus {
connected
disconnected
degraded
}
enum HealthStatus {
healthy
unhealthy
unknown
}
enum AlertSeverity {
info
warning
critical
}
// ==================== User & Organization ====================
model User {
id String @id @default(uuid()) @db.Uuid
email String @unique @db.VarChar(255)
passwordHash String @map("password_hash") @db.VarChar(255)
firstName String? @map("first_name") @db.VarChar(100)
lastName String? @map("last_name") @db.VarChar(100)
avatarUrl String? @map("avatar_url") @db.VarChar(500)
status UserStatus @default(active)
emailVerified Boolean @default(false) @map("email_verified")
mfaEnabled Boolean @default(false) @map("mfa_enabled")
mfaSecret String? @map("mfa_secret") @db.VarChar(255)
lastLoginAt DateTime? @map("last_login_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
organizationMemberships OrganizationMember[]
teamMemberships TeamMember[]
sessions Session[]
apiKeys ApiKey[]
streamConfigs StreamConfig[] @relation("CreatedBy")
consumerConfigs ConsumerConfig[] @relation("CreatedBy")
dashboards Dashboard[]
savedQueries SavedQuery[]
invitedMembers OrganizationMember[] @relation("InvitedBy")
@@map("users")
}
model Organization {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(100)
slug String @unique @db.VarChar(50)
plan OrganizationPlan @default(free)
settings Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
members OrganizationMember[]
teams Team[]
clusters NatsCluster[]
roles Role[]
apiKeys ApiKey[]
dashboards Dashboard[]
savedQueries SavedQuery[]
alertRules AlertRule[]
@@map("organizations")
}
model OrganizationMember {
id String @id @default(uuid()) @db.Uuid
orgId String @map("org_id") @db.Uuid
userId String @map("user_id") @db.Uuid
role MemberRole @default(member)
invitedBy String? @map("invited_by") @db.Uuid
joinedAt DateTime @default(now()) @map("joined_at")
// Relations
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
inviter User? @relation("InvitedBy", fields: [invitedBy], references: [id])
@@unique([orgId, userId])
@@map("organization_members")
}
// ==================== Teams ====================
model Team {
id String @id @default(uuid()) @db.Uuid
orgId String @map("org_id") @db.Uuid
name String @db.VarChar(100)
description String? @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
members TeamMember[]
@@map("teams")
}
model TeamMember {
id String @id @default(uuid()) @db.Uuid
teamId String @map("team_id") @db.Uuid
userId String @map("user_id") @db.Uuid
role MemberRole @default(member)
addedAt DateTime @default(now()) @map("added_at")
// Relations
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([teamId, userId])
@@map("team_members")
}
// ==================== NATS Clusters ====================
model NatsCluster {
id String @id @default(uuid()) @db.Uuid
orgId String @map("org_id") @db.Uuid
name String @db.VarChar(100)
description String? @db.Text
environment ClusterEnvironment @default(development)
status ClusterStatus @default(disconnected)
version String? @db.VarChar(50)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
connections ClusterConnection[]
streams StreamConfig[]
alertRules AlertRule[]
@@map("nats_clusters")
}
model ClusterConnection {
id String @id @default(uuid()) @db.Uuid
clusterId String @map("cluster_id") @db.Uuid
serverUrl String @map("server_url") @db.VarChar(500)
credentials Json? @db.JsonB // Encrypted
tlsConfig Json? @map("tls_config") @db.JsonB
isPrimary Boolean @default(false) @map("is_primary")
healthStatus HealthStatus @default(unknown) @map("health_status")
lastHealthCheck DateTime? @map("last_health_check")
// Relations
cluster NatsCluster @relation(fields: [clusterId], references: [id], onDelete: Cascade)
@@map("cluster_connections")
}
// ==================== Streams & Consumers ====================
model StreamConfig {
id String @id @default(uuid()) @db.Uuid
clusterId String @map("cluster_id") @db.Uuid
streamName String @map("stream_name") @db.VarChar(256)
configSnapshot Json @map("config_snapshot") @db.JsonB
createdBy String @map("created_by") @db.Uuid
isManaged Boolean @default(true) @map("is_managed")
tags Json @default("[]") @db.JsonB
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
cluster NatsCluster @relation(fields: [clusterId], references: [id], onDelete: Cascade)
creator User @relation("CreatedBy", fields: [createdBy], references: [id])
consumers ConsumerConfig[]
@@unique([clusterId, streamName])
@@map("stream_configs")
}
model ConsumerConfig {
id String @id @default(uuid()) @db.Uuid
streamConfigId String @map("stream_config_id") @db.Uuid
consumerName String @map("consumer_name") @db.VarChar(256)
configSnapshot Json @map("config_snapshot") @db.JsonB
createdBy String @map("created_by") @db.Uuid
isManaged Boolean @default(true) @map("is_managed")
tags Json @default("[]") @db.JsonB
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
streamConfig StreamConfig @relation(fields: [streamConfigId], references: [id], onDelete: Cascade)
creator User @relation("CreatedBy", fields: [createdBy], references: [id])
@@unique([streamConfigId, consumerName])
@@map("consumer_configs")
}
// ==================== Roles & Permissions ====================
model Role {
id String @id @default(uuid()) @db.Uuid
orgId String @map("org_id") @db.Uuid
name String @db.VarChar(100)
description String? @db.Text
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
permissions Permission[]
@@unique([orgId, name])
@@map("roles")
}
model Permission {
id String @id @default(uuid()) @db.Uuid
roleId String @map("role_id") @db.Uuid
resource String @db.VarChar(100)
action String @db.VarChar(100)
conditions Json? @db.JsonB
// Relations
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@map("permissions")
}
// ==================== Sessions & API Keys ====================
model Session {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
tokenHash String @map("token_hash") @db.VarChar(255)
ipAddress String? @map("ip_address") @db.Inet
userAgent String? @map("user_agent") @db.Text
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
model ApiKey {
id String @id @default(uuid()) @db.Uuid
orgId String @map("org_id") @db.Uuid
userId String @map("user_id") @db.Uuid
name String @db.VarChar(100)
keyHash String @map("key_hash") @db.VarChar(255)
prefix String @db.VarChar(10)
permissions Json @default("[]") @db.JsonB
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
// Relations
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("api_keys")
}
// ==================== Dashboards & Saved Queries ====================
model Dashboard {
id String @id @default(uuid()) @db.Uuid
orgId String @map("org_id") @db.Uuid
userId String @map("user_id") @db.Uuid
name String @db.VarChar(100)
layout Json @default("{\"columns\": 12, \"rowHeight\": 80}") @db.JsonB
widgets Json @default("[]") @db.JsonB
isShared Boolean @default(false) @map("is_shared")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("dashboards")
}
model SavedQuery {
id String @id @default(uuid()) @db.Uuid
orgId String @map("org_id") @db.Uuid
userId String @map("user_id") @db.Uuid
name String @db.VarChar(100)
query String @db.Text
description String? @db.Text
isShared Boolean @default(false) @map("is_shared")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("saved_queries")
}
// ==================== Alerts ====================
model AlertRule {
id String @id @default(uuid()) @db.Uuid
orgId String @map("org_id") @db.Uuid
clusterId String? @map("cluster_id") @db.Uuid
name String @db.VarChar(100)
condition Json @db.JsonB
threshold Json @db.JsonB
severity AlertSeverity @default(warning)
channels Json @default("[]") @db.JsonB
isEnabled Boolean @default(true) @map("is_enabled")
cooldownMins Int @default(5) @map("cooldown_mins")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
cluster NatsCluster? @relation(fields: [clusterId], references: [id], onDelete: SetNull)
@@map("alert_rules")
}
+120
View File
@@ -0,0 +1,120 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import { verifyToken } from '../../modules/auth/auth.service.js';
import { getSession, updateSessionActivity } from '../../lib/redis.js';
import type { JwtPayload } from '@nats-console/shared';
// Extend FastifyRequest to include user
declare module 'fastify' {
interface FastifyRequest {
user?: JwtPayload;
}
}
export async function authenticate(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return reply.status(401).send({
error: {
code: 'UNAUTHORIZED',
message: 'Missing or invalid authorization header',
},
});
}
const token = authHeader.slice(7);
try {
const payload = await verifyToken(token);
request.user = payload;
// Update session activity
const sessionId = token.split('.')[2];
if (sessionId) {
const session = await getSession(sessionId);
if (session) {
await updateSessionActivity(sessionId);
}
}
} catch (error) {
return reply.status(401).send({
error: {
code: 'UNAUTHORIZED',
message: 'Invalid or expired token',
},
});
}
}
export async function optionalAuth(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return; // No auth, continue without user
}
const token = authHeader.slice(7);
try {
const payload = await verifyToken(token);
request.user = payload;
} catch {
// Invalid token, continue without user
}
}
export function requirePermission(...permissions: string[]) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
await authenticate(request, reply);
if (!request.user) {
return; // Already handled by authenticate
}
const userPermissions = request.user.permissions || [];
// Check if user has any of the required permissions
const hasPermission = permissions.some((required) => {
return userPermissions.some((userPerm) => {
return matchPermission(userPerm, required);
});
});
if (!hasPermission) {
return reply.status(403).send({
error: {
code: 'FORBIDDEN',
message: 'You do not have permission to perform this action',
},
});
}
};
}
function matchPermission(userPermission: string, requiredPermission: string): boolean {
const [userResource, userAction, userScope] = userPermission.split(':');
const [reqResource, reqAction, reqScope] = requiredPermission.split(':');
// Check resource
if (userResource !== '*' && userResource !== reqResource) {
return false;
}
// Check action
if (userAction !== '*' && userAction !== reqAction) {
return false;
}
// Check scope
if (userScope !== '*' && userScope !== reqScope) {
return false;
}
return true;
}
+54
View File
@@ -0,0 +1,54 @@
import { z } from 'zod';
const envSchema = z.object({
// Server
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().default(3001),
HOST: z.string().default('0.0.0.0'),
// Database
DATABASE_URL: z.string().url(),
// Redis
REDIS_URL: z.string().default('redis://localhost:6379'),
// ClickHouse
CLICKHOUSE_URL: z.string().default('http://localhost:8123'),
CLICKHOUSE_DATABASE: z.string().default('nats_console'),
CLICKHOUSE_USER: z.string().default('nats_console'),
CLICKHOUSE_PASSWORD: z.string().default('nats_console_dev'),
// NATS (internal for job queues)
NATS_URL: z.string().default('nats://localhost:4222'),
// JWT
JWT_SECRET: z.string().min(32),
JWT_ACCESS_EXPIRY: z.string().default('15m'),
JWT_REFRESH_EXPIRY: z.string().default('7d'),
// Encryption
ENCRYPTION_KEY: z.string().min(32).optional(),
// CORS
CORS_ORIGIN: z.string().default('http://localhost:3000'),
// Rate limiting
RATE_LIMIT_MAX: z.coerce.number().default(100),
RATE_LIMIT_WINDOW: z.coerce.number().default(60000), // 1 minute
});
function loadConfig() {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
console.error(parsed.error.format());
process.exit(1);
}
return parsed.data;
}
export const config = loadConfig();
export type Config = typeof config;
+144
View File
@@ -0,0 +1,144 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import sensible from '@fastify/sensible';
import websocket from '@fastify/websocket';
import { config } from './config/index.js';
import { connectDatabase, disconnectDatabase } from './lib/prisma.js';
import { redis } from './lib/redis.js';
import { connectInternalNats, disconnectInternalNats, disconnectAllClusters } from './lib/nats.js';
import { closeClickHouseClient } from './lib/clickhouse.js';
// Import routes
import { authRoutes } from './modules/auth/auth.routes.js';
import { clusterRoutes } from './modules/clusters/clusters.routes.js';
import { streamRoutes } from './modules/streams/streams.routes.js';
import { consumerRoutes } from './modules/consumers/consumers.routes.js';
import { organizationRoutes } from './modules/organizations/organizations.routes.js';
import { userRoutes } from './modules/users/users.routes.js';
import { teamRoutes } from './modules/teams/teams.routes.js';
import { analyticsRoutes } from './modules/analytics/analytics.routes.js';
import { alertRoutes } from './modules/alerts/alerts.routes.js';
import { dashboardRoutes } from './modules/dashboards/dashboards.routes.js';
const app = Fastify({
logger: {
level: config.NODE_ENV === 'development' ? 'debug' : 'info',
transport:
config.NODE_ENV === 'development'
? {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
}
: undefined,
},
});
// Register plugins
await app.register(cors, {
origin: config.CORS_ORIGIN.split(','),
credentials: true,
});
await app.register(helmet, {
contentSecurityPolicy: false, // Disable for API
});
await app.register(rateLimit, {
max: config.RATE_LIMIT_MAX,
timeWindow: config.RATE_LIMIT_WINDOW,
});
await app.register(sensible);
await app.register(websocket, {
options: {
maxPayload: 1048576, // 1MB
},
});
// Health check endpoint
app.get('/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
// API version prefix
app.register(
async (api) => {
// Register all routes
await api.register(authRoutes, { prefix: '/auth' });
await api.register(organizationRoutes, { prefix: '/organizations' });
await api.register(userRoutes, { prefix: '/users' });
await api.register(teamRoutes, { prefix: '/teams' });
await api.register(clusterRoutes, { prefix: '/clusters' });
await api.register(streamRoutes, { prefix: '/clusters' });
await api.register(consumerRoutes, { prefix: '/clusters' });
await api.register(analyticsRoutes, { prefix: '/analytics' });
await api.register(alertRoutes, { prefix: '/alerts' });
await api.register(dashboardRoutes, { prefix: '/dashboards' });
},
{ prefix: '/api/v1' }
);
// Global error handler
app.setErrorHandler((error, request, reply) => {
app.log.error(error);
const statusCode = error.statusCode || 500;
const message = statusCode === 500 ? 'Internal Server Error' : error.message;
reply.status(statusCode).send({
error: {
code: error.code || 'INTERNAL_ERROR',
message,
...(config.NODE_ENV === 'development' && { stack: error.stack }),
},
});
});
// Graceful shutdown
const shutdown = async (signal: string) => {
app.log.info(`Received ${signal}, shutting down gracefully...`);
await app.close();
await disconnectAllClusters();
await disconnectInternalNats();
await closeClickHouseClient();
await redis.quit();
await disconnectDatabase();
app.log.info('Shutdown complete');
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Start server
async function start() {
try {
// Connect to databases
await connectDatabase();
app.log.info('PostgreSQL connected');
// Connect to internal NATS
await connectInternalNats();
// Start HTTP server
await app.listen({
port: config.PORT,
host: config.HOST,
});
app.log.info(`Server running at http://${config.HOST}:${config.PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
start();
+349
View File
@@ -0,0 +1,349 @@
import { createClient, ClickHouseClient } from '@clickhouse/client';
import { config } from '../config/index.js';
import type { StreamMetrics, ConsumerMetrics, ClusterMetrics, AuditLog } from '@nats-console/shared';
let client: ClickHouseClient | null = null;
export function getClickHouseClient(): ClickHouseClient {
if (!client) {
client = createClient({
url: config.CLICKHOUSE_URL,
database: config.CLICKHOUSE_DATABASE,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
}
return client;
}
export async function closeClickHouseClient(): Promise<void> {
if (client) {
await client.close();
client = null;
}
}
// ==================== Stream Metrics ====================
export async function insertStreamMetrics(metrics: StreamMetrics[]): Promise<void> {
const ch = getClickHouseClient();
await ch.insert({
table: 'stream_metrics',
values: metrics.map((m) => ({
cluster_id: m.clusterId,
stream_name: m.streamName,
timestamp: m.timestamp.toISOString(),
messages_total: m.messagesTotal,
bytes_total: m.bytesTotal,
messages_rate: m.messagesRate,
bytes_rate: m.bytesRate,
consumer_count: m.consumerCount,
first_seq: m.firstSeq,
last_seq: m.lastSeq,
subjects: m.subjects,
})),
format: 'JSONEachRow',
});
}
export async function queryStreamMetrics(
clusterId: string,
streamName: string,
from: Date,
to: Date,
interval: string = '5m'
): Promise<StreamMetrics[]> {
const ch = getClickHouseClient();
const intervalSeconds = parseInterval(interval);
const result = await ch.query({
query: `
SELECT
cluster_id,
stream_name,
toStartOfInterval(timestamp, INTERVAL ${intervalSeconds} SECOND) as timestamp,
avg(messages_total) as messages_total,
avg(bytes_total) as bytes_total,
avg(messages_rate) as messages_rate,
avg(bytes_rate) as bytes_rate,
avg(consumer_count) as consumer_count,
min(first_seq) as first_seq,
max(last_seq) as last_seq,
arrayDistinct(flatten(groupArray(subjects))) as subjects
FROM stream_metrics
WHERE cluster_id = {clusterId:UUID}
AND stream_name = {streamName:String}
AND timestamp >= {from:DateTime64(3)}
AND timestamp <= {to:DateTime64(3)}
GROUP BY cluster_id, stream_name, timestamp
ORDER BY timestamp ASC
`,
query_params: {
clusterId,
streamName,
from: from.toISOString(),
to: to.toISOString(),
},
format: 'JSONEachRow',
});
const rows = await result.json<any[]>();
return rows.map((row) => ({
clusterId: row.cluster_id,
streamName: row.stream_name,
timestamp: new Date(row.timestamp),
messagesTotal: Number(row.messages_total),
bytesTotal: Number(row.bytes_total),
messagesRate: Number(row.messages_rate),
bytesRate: Number(row.bytes_rate),
consumerCount: Number(row.consumer_count),
firstSeq: Number(row.first_seq),
lastSeq: Number(row.last_seq),
subjects: row.subjects || [],
}));
}
// ==================== Consumer Metrics ====================
export async function insertConsumerMetrics(metrics: ConsumerMetrics[]): Promise<void> {
const ch = getClickHouseClient();
await ch.insert({
table: 'consumer_metrics',
values: metrics.map((m) => ({
cluster_id: m.clusterId,
stream_name: m.streamName,
consumer_name: m.consumerName,
timestamp: m.timestamp.toISOString(),
pending_count: m.pendingCount,
ack_pending: m.ackPending,
redelivered: m.redelivered,
waiting: m.waiting,
delivered_rate: m.deliveredRate,
ack_rate: m.ackRate,
lag: m.lag,
})),
format: 'JSONEachRow',
});
}
export async function queryConsumerMetrics(
clusterId: string,
streamName: string,
consumerName: string,
from: Date,
to: Date,
interval: string = '5m'
): Promise<ConsumerMetrics[]> {
const ch = getClickHouseClient();
const intervalSeconds = parseInterval(interval);
const result = await ch.query({
query: `
SELECT
cluster_id,
stream_name,
consumer_name,
toStartOfInterval(timestamp, INTERVAL ${intervalSeconds} SECOND) as timestamp,
avg(pending_count) as pending_count,
avg(ack_pending) as ack_pending,
sum(redelivered) as redelivered,
avg(waiting) as waiting,
avg(delivered_rate) as delivered_rate,
avg(ack_rate) as ack_rate,
avg(lag) as lag
FROM consumer_metrics
WHERE cluster_id = {clusterId:UUID}
AND stream_name = {streamName:String}
AND consumer_name = {consumerName:String}
AND timestamp >= {from:DateTime64(3)}
AND timestamp <= {to:DateTime64(3)}
GROUP BY cluster_id, stream_name, consumer_name, timestamp
ORDER BY timestamp ASC
`,
query_params: {
clusterId,
streamName,
consumerName,
from: from.toISOString(),
to: to.toISOString(),
},
format: 'JSONEachRow',
});
const rows = await result.json<any[]>();
return rows.map((row) => ({
clusterId: row.cluster_id,
streamName: row.stream_name,
consumerName: row.consumer_name,
timestamp: new Date(row.timestamp),
pendingCount: Number(row.pending_count),
ackPending: Number(row.ack_pending),
redelivered: Number(row.redelivered),
waiting: Number(row.waiting),
deliveredRate: Number(row.delivered_rate),
ackRate: Number(row.ack_rate),
lag: Number(row.lag),
}));
}
// ==================== Cluster Metrics ====================
export async function insertClusterMetrics(metrics: ClusterMetrics[]): Promise<void> {
const ch = getClickHouseClient();
await ch.insert({
table: 'cluster_metrics',
values: metrics.map((m) => ({
cluster_id: m.clusterId,
server_id: m.serverId,
server_name: m.serverName,
timestamp: m.timestamp.toISOString(),
cpu_percent: m.cpuPercent,
memory_bytes: m.memoryBytes,
connections: m.connections,
subscriptions: m.subscriptions,
slow_consumers: m.slowConsumers,
in_msgs: m.inMsgs,
out_msgs: m.outMsgs,
in_bytes: m.inBytes,
out_bytes: m.outBytes,
})),
format: 'JSONEachRow',
});
}
// ==================== Audit Logs ====================
export async function insertAuditLog(log: Omit<AuditLog, 'id'>): Promise<void> {
const ch = getClickHouseClient();
await ch.insert({
table: 'audit_logs',
values: [
{
id: crypto.randomUUID(),
org_id: log.orgId,
user_id: log.userId,
user_email: log.userEmail,
timestamp: log.timestamp.toISOString(),
action: log.action,
resource_type: log.resourceType,
resource_id: log.resourceId,
resource_name: log.resourceName,
cluster_id: log.clusterId,
ip_address: log.ipAddress,
user_agent: log.userAgent,
request_id: log.requestId,
changes: log.changes,
status: log.status,
error_message: log.errorMessage,
},
],
format: 'JSONEachRow',
});
}
export async function queryAuditLogs(
orgId: string,
options: {
from?: Date;
to?: Date;
action?: string;
resourceType?: string;
userId?: string;
limit?: number;
offset?: number;
} = {}
): Promise<{ logs: AuditLog[]; total: number }> {
const ch = getClickHouseClient();
const { from, to, action, resourceType, userId, limit = 50, offset = 0 } = options;
const conditions = ['org_id = {orgId:UUID}'];
const params: Record<string, unknown> = { orgId };
if (from) {
conditions.push('timestamp >= {from:DateTime64(3)}');
params.from = from.toISOString();
}
if (to) {
conditions.push('timestamp <= {to:DateTime64(3)}');
params.to = to.toISOString();
}
if (action) {
conditions.push('action = {action:String}');
params.action = action;
}
if (resourceType) {
conditions.push('resource_type = {resourceType:String}');
params.resourceType = resourceType;
}
if (userId) {
conditions.push('user_id = {userId:UUID}');
params.userId = userId;
}
const whereClause = conditions.join(' AND ');
// Get total count
const countResult = await ch.query({
query: `SELECT count() as total FROM audit_logs WHERE ${whereClause}`,
query_params: params,
format: 'JSONEachRow',
});
const countRows = await countResult.json<{ total: string }[]>();
const total = parseInt(countRows[0]?.total || '0');
// Get logs
const result = await ch.query({
query: `
SELECT *
FROM audit_logs
WHERE ${whereClause}
ORDER BY timestamp DESC
LIMIT ${limit} OFFSET ${offset}
`,
query_params: params,
format: 'JSONEachRow',
});
const rows = await result.json<any[]>();
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,
}));
return { logs, total };
}
// ==================== Helpers ====================
function parseInterval(interval: string): number {
const match = interval.match(/^(\d+)(m|h|d)$/);
if (!match) return 300; // Default 5 minutes
const value = parseInt(match[1]!);
const unit = match[2];
switch (unit) {
case 'm':
return value * 60;
case 'h':
return value * 3600;
case 'd':
return value * 86400;
default:
return 300;
}
}
+449
View File
@@ -0,0 +1,449 @@
import {
connect,
NatsConnection,
JetStreamManager,
JetStreamClient,
StreamInfo,
ConsumerInfo,
StreamConfig as NatsStreamConfig,
ConsumerConfig as NatsConsumerConfig,
PubAck,
JsMsg,
StoredMsg,
} from 'nats';
import { config } from '../config/index.js';
import type { EncryptedCredentials, TlsConfig } from '@nats-console/shared';
// Connection pool for managed clusters
const connectionPool = new Map<string, NatsConnection>();
const jsmPool = new Map<string, JetStreamManager>();
const jsPool = new Map<string, JetStreamClient>();
// Internal NATS connection for job queues
let internalNc: NatsConnection | null = null;
let internalJsm: JetStreamManager | null = null;
let internalJs: JetStreamClient | null = null;
// ==================== Internal NATS (Job Queues) ====================
export async function connectInternalNats(): Promise<void> {
if (internalNc) return;
internalNc = await connect({
servers: config.NATS_URL,
name: 'nats-console-api',
});
internalJsm = await internalNc.jetstreamManager();
internalJs = internalNc.jetstream();
console.log('Internal NATS connected');
// Setup internal streams for job queues
await setupInternalStreams();
}
export async function disconnectInternalNats(): Promise<void> {
if (internalNc) {
await internalNc.drain();
internalNc = null;
internalJsm = null;
internalJs = null;
}
}
export function getInternalJetStream(): JetStreamClient {
if (!internalJs) {
throw new Error('Internal NATS not connected');
}
return internalJs;
}
export function getInternalJetStreamManager(): JetStreamManager {
if (!internalJsm) {
throw new Error('Internal NATS not connected');
}
return internalJsm;
}
async function setupInternalStreams(): Promise<void> {
if (!internalJsm) return;
const streams = [
{
name: 'JOBS_METRICS',
subjects: ['jobs.metrics.>'],
retention: 'workqueue' as const,
maxAge: 24 * 60 * 60 * 1000000000, // 24 hours in ns
},
{
name: 'JOBS_ALERTS',
subjects: ['jobs.alerts.>'],
retention: 'workqueue' as const,
maxAge: 24 * 60 * 60 * 1000000000,
},
{
name: 'JOBS_AUDIT',
subjects: ['jobs.audit.>'],
retention: 'workqueue' as const,
maxAge: 7 * 24 * 60 * 60 * 1000000000, // 7 days
},
{
name: 'EVENTS',
subjects: ['events.>'],
retention: 'limits' as const,
maxAge: 60 * 60 * 1000000000, // 1 hour
maxMsgs: 10000,
},
];
for (const stream of streams) {
try {
await internalJsm.streams.info(stream.name);
} catch {
await internalJsm.streams.add({
name: stream.name,
subjects: stream.subjects,
retention: stream.retention,
max_age: stream.maxAge,
max_msgs: stream.maxMsgs,
storage: 'file',
num_replicas: 1,
});
console.log(`Created internal stream: ${stream.name}`);
}
}
}
// ==================== Managed Cluster Connections ====================
export async function connectCluster(
clusterId: string,
serverUrl: string,
credentials?: EncryptedCredentials | null,
tlsConfig?: TlsConfig | null
): Promise<NatsConnection> {
// Close existing connection if any
await disconnectCluster(clusterId);
const options: Record<string, unknown> = {
servers: serverUrl,
name: `nats-console-${clusterId}`,
};
// Apply credentials
if (credentials) {
if (credentials.token) {
options.token = credentials.token;
}
if (credentials.username && credentials.password) {
options.user = credentials.username;
options.pass = credentials.password;
}
if (credentials.nkey) {
// NKey authentication requires special handling
options.authenticator = credentials.nkey;
}
}
// Apply TLS config
if (tlsConfig?.enabled) {
options.tls = {
rejectUnauthorized: !tlsConfig.skipVerify,
};
}
const nc = await connect(options);
connectionPool.set(clusterId, nc);
const jsm = await nc.jetstreamManager();
jsmPool.set(clusterId, jsm);
const js = nc.jetstream();
jsPool.set(clusterId, js);
console.log(`Connected to cluster: ${clusterId}`);
return nc;
}
export async function disconnectCluster(clusterId: string): Promise<void> {
const nc = connectionPool.get(clusterId);
if (nc) {
await nc.drain();
connectionPool.delete(clusterId);
jsmPool.delete(clusterId);
jsPool.delete(clusterId);
console.log(`Disconnected from cluster: ${clusterId}`);
}
}
export async function disconnectAllClusters(): Promise<void> {
const clusterIds = Array.from(connectionPool.keys());
await Promise.all(clusterIds.map((id) => disconnectCluster(id)));
}
export function getClusterConnection(clusterId: string): NatsConnection {
const nc = connectionPool.get(clusterId);
if (!nc) {
throw new Error(`Cluster ${clusterId} not connected`);
}
return nc;
}
export function getClusterJetStreamManager(clusterId: string): JetStreamManager {
const jsm = jsmPool.get(clusterId);
if (!jsm) {
throw new Error(`Cluster ${clusterId} not connected`);
}
return jsm;
}
export function getClusterJetStream(clusterId: string): JetStreamClient {
const js = jsPool.get(clusterId);
if (!js) {
throw new Error(`Cluster ${clusterId} not connected`);
}
return js;
}
export function isClusterConnected(clusterId: string): boolean {
return connectionPool.has(clusterId);
}
// ==================== Stream Operations ====================
export async function listStreams(clusterId: string): Promise<StreamInfo[]> {
const jsm = getClusterJetStreamManager(clusterId);
const streams: StreamInfo[] = [];
for await (const si of jsm.streams.list()) {
streams.push(si);
}
return streams;
}
export async function getStreamInfo(clusterId: string, streamName: string): Promise<StreamInfo> {
const jsm = getClusterJetStreamManager(clusterId);
return jsm.streams.info(streamName);
}
export async function createStream(clusterId: string, config: Partial<NatsStreamConfig>): Promise<StreamInfo> {
const jsm = getClusterJetStreamManager(clusterId);
return jsm.streams.add(config);
}
export async function updateStream(clusterId: string, config: Partial<NatsStreamConfig>): Promise<StreamInfo> {
const jsm = getClusterJetStreamManager(clusterId);
return jsm.streams.update(config.name!, config);
}
export async function deleteStream(clusterId: string, streamName: string): Promise<boolean> {
const jsm = getClusterJetStreamManager(clusterId);
return jsm.streams.delete(streamName);
}
export async function purgeStream(
clusterId: string,
streamName: string,
options?: { filter?: string; seq?: number; keep?: number }
): Promise<{ purged: number }> {
const jsm = getClusterJetStreamManager(clusterId);
const result = await jsm.streams.purge(streamName, options);
return { purged: result.purged };
}
// ==================== Consumer Operations ====================
export async function listConsumers(clusterId: string, streamName: string): Promise<ConsumerInfo[]> {
const jsm = getClusterJetStreamManager(clusterId);
const consumers: ConsumerInfo[] = [];
for await (const ci of jsm.consumers.list(streamName)) {
consumers.push(ci);
}
return consumers;
}
export async function getConsumerInfo(
clusterId: string,
streamName: string,
consumerName: string
): Promise<ConsumerInfo> {
const jsm = getClusterJetStreamManager(clusterId);
return jsm.consumers.info(streamName, consumerName);
}
export async function createConsumer(
clusterId: string,
streamName: string,
config: Partial<NatsConsumerConfig>
): Promise<ConsumerInfo> {
const jsm = getClusterJetStreamManager(clusterId);
return jsm.consumers.add(streamName, config);
}
export async function updateConsumer(
clusterId: string,
streamName: string,
config: Partial<NatsConsumerConfig>
): Promise<ConsumerInfo> {
const jsm = getClusterJetStreamManager(clusterId);
return jsm.consumers.update(streamName, config.durable_name || config.name!, config);
}
export async function deleteConsumer(clusterId: string, streamName: string, consumerName: string): Promise<boolean> {
const jsm = getClusterJetStreamManager(clusterId);
return jsm.consumers.delete(streamName, consumerName);
}
// ==================== Message Operations ====================
export async function publishMessage(
clusterId: string,
subject: string,
data: Uint8Array | string,
headers?: Record<string, string>
): Promise<PubAck> {
const js = getClusterJetStream(clusterId);
const msgHeaders = headers
? Object.entries(headers).reduce((h, [k, v]) => {
h.set(k, v);
return h;
}, new Map<string, string>())
: undefined;
return js.publish(subject, typeof data === 'string' ? new TextEncoder().encode(data) : data, {
headers: msgHeaders as any,
});
}
export async function getMessage(
clusterId: string,
streamName: string,
sequence: number
): Promise<StoredMsg | null> {
const jsm = getClusterJetStreamManager(clusterId);
try {
return await jsm.streams.getMessage(streamName, { seq: sequence });
} catch {
return null;
}
}
export async function getMessages(
clusterId: string,
streamName: string,
options: { startSeq?: number; limit?: number; subject?: string }
): Promise<StoredMsg[]> {
const jsm = getClusterJetStreamManager(clusterId);
const messages: StoredMsg[] = [];
const { startSeq = 1, limit = 100, subject } = options;
let seq = startSeq;
let count = 0;
while (count < limit) {
try {
const msg = await jsm.streams.getMessage(streamName, {
seq,
next_by_subj: subject,
});
if (msg) {
messages.push(msg);
count++;
seq = msg.seq + 1;
} else {
break;
}
} catch {
// No more messages or error
break;
}
}
return messages;
}
export async function deleteMessage(clusterId: string, streamName: string, sequence: number): Promise<boolean> {
const jsm = getClusterJetStreamManager(clusterId);
return jsm.streams.deleteMessage(streamName, sequence);
}
// ==================== Health Check ====================
export async function checkClusterHealth(clusterId: string): Promise<{
connected: boolean;
rtt?: number;
serverInfo?: {
serverId: string;
serverName: string;
version: string;
jetstream: boolean;
};
}> {
try {
const nc = getClusterConnection(clusterId);
const start = Date.now();
await nc.flush();
const rtt = Date.now() - start;
const info = nc.info;
return {
connected: true,
rtt,
serverInfo: info
? {
serverId: info.server_id,
serverName: info.server_name,
version: info.version,
jetstream: info.jetstream || false,
}
: undefined,
};
} catch {
return { connected: false };
}
}
// ==================== KV Store Operations ====================
export async function listKvStores(clusterId: string): Promise<string[]> {
const jsm = getClusterJetStreamManager(clusterId);
const stores: string[] = [];
for await (const si of jsm.streams.list()) {
if (si.config.name.startsWith('KV_')) {
stores.push(si.config.name.slice(3)); // Remove 'KV_' prefix
}
}
return stores;
}
export async function getKvStore(clusterId: string, bucket: string) {
const js = getClusterJetStream(clusterId);
return js.views.kv(bucket);
}
// ==================== Object Store Operations ====================
export async function listObjectStores(clusterId: string): Promise<string[]> {
const jsm = getClusterJetStreamManager(clusterId);
const stores: string[] = [];
for await (const si of jsm.streams.list()) {
if (si.config.name.startsWith('OBJ_')) {
stores.push(si.config.name.slice(4)); // Remove 'OBJ_' prefix
}
}
return stores;
}
export async function getObjectStore(clusterId: string, bucket: string) {
const js = getClusterJetStream(clusterId);
return js.views.os(bucket);
}
+25
View File
@@ -0,0 +1,25 @@
import { PrismaClient } from '@prisma/client';
import { config } from '../config/index.js';
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma =
globalThis.prisma ||
new PrismaClient({
log: config.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (config.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}
export async function connectDatabase(): Promise<void> {
await prisma.$connect();
}
export async function disconnectDatabase(): Promise<void> {
await prisma.$disconnect();
}
+142
View File
@@ -0,0 +1,142 @@
import Redis from 'ioredis';
import { config } from '../config/index.js';
export const redis = new Redis(config.REDIS_URL, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
redis.on('error', (err) => {
console.error('Redis connection error:', err);
});
redis.on('connect', () => {
console.log('Redis connected');
});
// Session management
const SESSION_PREFIX = 'session:';
const SESSION_TTL = 24 * 60 * 60; // 24 hours
export interface SessionData {
userId: string;
orgId: string;
email: string;
role: string;
permissions: string[];
ipAddress: string;
createdAt: string;
lastActivity: string;
}
export async function setSession(sessionId: string, data: SessionData): Promise<void> {
await redis.hset(`${SESSION_PREFIX}${sessionId}`, data as Record<string, string>);
await redis.expire(`${SESSION_PREFIX}${sessionId}`, SESSION_TTL);
}
export async function getSession(sessionId: string): Promise<SessionData | null> {
const data = await redis.hgetall(`${SESSION_PREFIX}${sessionId}`);
if (!data || Object.keys(data).length === 0) {
return null;
}
return {
...data,
permissions: JSON.parse(data.permissions || '[]'),
} as SessionData;
}
export async function deleteSession(sessionId: string): Promise<void> {
await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
export async function updateSessionActivity(sessionId: string): Promise<void> {
await redis.hset(`${SESSION_PREFIX}${sessionId}`, 'lastActivity', new Date().toISOString());
await redis.expire(`${SESSION_PREFIX}${sessionId}`, SESSION_TTL);
}
// Rate limiting
const RATE_LIMIT_PREFIX = 'ratelimit:';
export async function checkRateLimit(
key: string,
maxRequests: number,
windowMs: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const now = Date.now();
const windowStart = now - windowMs;
const redisKey = `${RATE_LIMIT_PREFIX}${key}`;
// Remove old entries
await redis.zremrangebyscore(redisKey, 0, windowStart);
// Count current entries
const count = await redis.zcard(redisKey);
if (count >= maxRequests) {
const oldestEntry = await redis.zrange(redisKey, 0, 0, 'WITHSCORES');
const resetAt = oldestEntry[1] ? parseInt(oldestEntry[1]) + windowMs : now + windowMs;
return { allowed: false, remaining: 0, resetAt };
}
// Add new entry
await redis.zadd(redisKey, now, `${now}-${Math.random()}`);
await redis.pexpire(redisKey, windowMs);
return { allowed: true, remaining: maxRequests - count - 1, resetAt: now + windowMs };
}
// Cluster status cache
const CLUSTER_STATUS_PREFIX = 'cluster:';
const CLUSTER_STATUS_TTL = 30; // 30 seconds
export interface ClusterStatusCache {
status: string;
serverCount: number;
version: string;
lastCheck: string;
}
export async function setClusterStatus(clusterId: string, data: ClusterStatusCache): Promise<void> {
await redis.hset(`${CLUSTER_STATUS_PREFIX}${clusterId}:status`, data as Record<string, string>);
await redis.expire(`${CLUSTER_STATUS_PREFIX}${clusterId}:status`, CLUSTER_STATUS_TTL);
}
export async function getClusterStatus(clusterId: string): Promise<ClusterStatusCache | null> {
const data = await redis.hgetall(`${CLUSTER_STATUS_PREFIX}${clusterId}:status`);
if (!data || Object.keys(data).length === 0) {
return null;
}
return {
...data,
serverCount: parseInt(data.serverCount || '0'),
} as ClusterStatusCache;
}
// Permissions cache
const PERMISSIONS_PREFIX = 'permissions:';
const PERMISSIONS_TTL = 5 * 60; // 5 minutes
export async function setUserPermissions(userId: string, orgId: string, permissions: string[]): Promise<void> {
const key = `${PERMISSIONS_PREFIX}${userId}:${orgId}`;
await redis.del(key);
if (permissions.length > 0) {
await redis.sadd(key, ...permissions);
await redis.expire(key, PERMISSIONS_TTL);
}
}
export async function getUserPermissions(userId: string, orgId: string): Promise<string[] | null> {
const key = `${PERMISSIONS_PREFIX}${userId}:${orgId}`;
const permissions = await redis.smembers(key);
if (permissions.length === 0) {
return null; // Cache miss
}
return permissions;
}
export async function invalidateUserPermissions(userId: string, orgId: string): Promise<void> {
await redis.del(`${PERMISSIONS_PREFIX}${userId}:${orgId}`);
}
@@ -0,0 +1,125 @@
import type { FastifyPluginAsync } from 'fastify';
import { CreateAlertRuleSchema, UpdateAlertRuleSchema } from '@nats-console/shared';
import { prisma } from '../../lib/prisma.js';
import { authenticate } from '../../common/middleware/auth.js';
import { NotFoundError } from '@nats-console/shared';
export const alertRoutes: FastifyPluginAsync = async (fastify) => {
fastify.addHook('preHandler', authenticate);
// GET /alerts/rules - List alert rules
fastify.get('/rules', async (request) => {
const rules = await prisma.alertRule.findMany({
where: { orgId: request.user!.orgId },
include: { cluster: true },
orderBy: { createdAt: 'desc' },
});
return { rules };
});
// POST /alerts/rules - Create alert rule
fastify.post('/rules', async (request, reply) => {
const body = CreateAlertRuleSchema.parse(request.body);
const rule = await prisma.alertRule.create({
data: {
orgId: request.user!.orgId,
clusterId: body.clusterId,
name: body.name,
condition: body.condition as any,
threshold: body.threshold as any,
severity: body.severity,
channels: body.channels as any,
isEnabled: body.isEnabled,
cooldownMins: body.cooldownMins,
},
});
return reply.status(201).send({ rule });
});
// GET /alerts/rules/:id - Get alert rule
fastify.get<{ Params: { id: string } }>('/rules/:id', async (request) => {
const rule = await prisma.alertRule.findFirst({
where: {
id: request.params.id,
orgId: request.user!.orgId,
},
include: { cluster: true },
});
if (!rule) {
throw new NotFoundError('Alert rule', request.params.id);
}
return { rule };
});
// PATCH /alerts/rules/:id - Update alert rule
fastify.patch<{ Params: { id: string } }>('/rules/:id', async (request) => {
const body = UpdateAlertRuleSchema.parse(request.body);
const rule = await prisma.alertRule.findFirst({
where: {
id: request.params.id,
orgId: request.user!.orgId,
},
});
if (!rule) {
throw new NotFoundError('Alert rule', request.params.id);
}
const updated = await prisma.alertRule.update({
where: { id: request.params.id },
data: {
name: body.name,
clusterId: body.clusterId,
condition: body.condition as any,
threshold: body.threshold as any,
severity: body.severity,
channels: body.channels as any,
isEnabled: body.isEnabled,
cooldownMins: body.cooldownMins,
},
});
return { rule: updated };
});
// DELETE /alerts/rules/:id - Delete alert rule
fastify.delete<{ Params: { id: string } }>('/rules/:id', async (request, reply) => {
const rule = await prisma.alertRule.findFirst({
where: {
id: request.params.id,
orgId: request.user!.orgId,
},
});
if (!rule) {
throw new NotFoundError('Alert rule', request.params.id);
}
await prisma.alertRule.delete({
where: { id: request.params.id },
});
return reply.status(204).send();
});
// GET /alerts/events - List alert events
fastify.get<{
Querystring: { ruleId?: string; from?: string; to?: string; limit?: string };
}>('/events', async (request) => {
// TODO: Query from ClickHouse
return { events: [] };
});
// POST /alerts/test - Test alert rule
fastify.post('/test', async (request) => {
const body = CreateAlertRuleSchema.parse(request.body);
// TODO: Implement test alert
return { success: true, message: 'Test alert sent' };
});
};
@@ -0,0 +1,119 @@
import type { FastifyPluginAsync } from 'fastify';
import { MetricsQuerySchema, AuditLogQuerySchema } from '@nats-console/shared';
import {
queryStreamMetrics,
queryConsumerMetrics,
queryAuditLogs,
} from '../../lib/clickhouse.js';
import { authenticate } from '../../common/middleware/auth.js';
export const analyticsRoutes: FastifyPluginAsync = async (fastify) => {
fastify.addHook('preHandler', authenticate);
// GET /analytics/metrics - Query metrics
fastify.get('/', async (request) => {
const query = MetricsQuerySchema.parse(request.query);
const from = new Date(query.from);
const to = new Date(query.to);
if (query.consumerName && query.streamName && query.clusterId) {
const metrics = await queryConsumerMetrics(
query.clusterId,
query.streamName,
query.consumerName,
from,
to,
query.interval
);
return { metrics, type: 'consumer' };
}
if (query.streamName && query.clusterId) {
const metrics = await queryStreamMetrics(
query.clusterId,
query.streamName,
from,
to,
query.interval
);
return { metrics, type: 'stream' };
}
return { metrics: [], type: 'unknown' };
});
// GET /analytics/streams/:name/throughput - Stream throughput
fastify.get<{
Params: { name: string };
Querystring: { clusterId: string; from: string; to: string; interval?: string };
}>('/streams/:name/throughput', async (request) => {
const { clusterId, from, to, interval } = request.query;
const metrics = await queryStreamMetrics(
clusterId,
request.params.name,
new Date(from),
new Date(to),
interval || '5m'
);
return {
streamName: request.params.name,
data: metrics.map((m) => ({
timestamp: m.timestamp,
messagesRate: m.messagesRate,
bytesRate: m.bytesRate,
})),
};
});
// GET /analytics/consumers/:name/lag - Consumer lag
fastify.get<{
Params: { name: string };
Querystring: {
clusterId: string;
streamName: string;
from: string;
to: string;
interval?: string;
};
}>('/consumers/:name/lag', async (request) => {
const { clusterId, streamName, from, to, interval } = request.query;
const metrics = await queryConsumerMetrics(
clusterId,
streamName,
request.params.name,
new Date(from),
new Date(to),
interval || '5m'
);
return {
consumerName: request.params.name,
streamName,
data: metrics.map((m) => ({
timestamp: m.timestamp,
lag: m.lag,
pendingCount: m.pendingCount,
ackRate: m.ackRate,
})),
};
});
// GET /analytics/cluster/overview - Cluster overview
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,
};
}
);
};
+108
View File
@@ -0,0 +1,108 @@
import type { FastifyPluginAsync } from 'fastify';
import {
LoginSchema,
RegisterSchema,
RefreshTokenSchema,
ForgotPasswordSchema,
ResetPasswordSchema,
MfaVerifySchema,
} from '@nats-console/shared';
import * as authService from './auth.service.js';
import { authenticate } from '../../common/middleware/auth.js';
export const authRoutes: FastifyPluginAsync = async (fastify) => {
// POST /auth/register - User registration
fastify.post('/register', async (request, reply) => {
const body = RegisterSchema.parse(request.body);
const result = await authService.register({
email: body.email,
password: body.password,
firstName: body.firstName,
lastName: body.lastName,
organizationName: body.organizationName,
});
return reply.status(201).send({
user: result.user,
tokens: result.tokens,
});
});
// POST /auth/login - User login
fastify.post('/login', async (request, reply) => {
const body = LoginSchema.parse(request.body);
const result = await authService.login(
body.email,
body.password,
request.ip,
request.headers['user-agent']
);
return {
user: result.user,
tokens: result.tokens,
orgId: result.orgId,
};
});
// POST /auth/logout - User logout
fastify.post('/logout', { preHandler: authenticate }, async (request, reply) => {
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
await authService.logout(token);
}
return { success: true };
});
// POST /auth/refresh - Refresh tokens
fastify.post('/refresh', async (request, reply) => {
const body = RefreshTokenSchema.parse(request.body);
const tokens = await authService.refreshTokens(body.refreshToken);
return { tokens };
});
// GET /auth/me - Get current user
fastify.get('/me', { preHandler: authenticate }, async (request) => {
const user = await authService.getCurrentUser(request.user!.sub);
return { user };
});
// POST /auth/forgot-password - Request password reset
fastify.post('/forgot-password', async (request, reply) => {
const body = ForgotPasswordSchema.parse(request.body);
await authService.requestPasswordReset(body.email);
// Always return success to prevent email enumeration
return { message: 'If an account exists with that email, a reset link has been sent' };
});
// POST /auth/reset-password - Reset password
fastify.post('/reset-password', async (request, reply) => {
const body = ResetPasswordSchema.parse(request.body);
await authService.resetPassword(body.token, body.password);
return { message: 'Password has been reset successfully' };
});
// POST /auth/mfa/enable - Enable MFA
fastify.post('/mfa/enable', { preHandler: authenticate }, async (request) => {
const result = await authService.enableMfa(request.user!.sub);
return result;
});
// POST /auth/mfa/verify - Verify MFA code
fastify.post('/mfa/verify', { preHandler: authenticate }, async (request) => {
const body = MfaVerifySchema.parse(request.body);
const valid = await authService.verifyMfa(request.user!.sub, body.code);
return { valid };
});
// DELETE /auth/mfa/disable - Disable MFA
fastify.delete('/mfa/disable', { preHandler: authenticate }, async (request) => {
await authService.disableMfa(request.user!.sub);
return { success: true };
});
};
+429
View File
@@ -0,0 +1,429 @@
import * as argon2 from 'argon2';
import { SignJWT, jwtVerify } from 'jose';
import { prisma } from '../../lib/prisma.js';
import { redis, setSession, getSession, deleteSession } from '../../lib/redis.js';
import { config } from '../../config/index.js';
import {
UnauthorizedError,
NotFoundError,
ConflictError,
ValidationError,
} from '@nats-console/shared';
import type { User, AuthTokens, JwtPayload } from '@nats-console/shared';
const JWT_SECRET = new TextEncoder().encode(config.JWT_SECRET);
// ==================== Password Hashing ====================
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password);
}
// ==================== JWT Operations ====================
export async function generateAccessToken(payload: Omit<JwtPayload, 'iat' | 'exp'>): Promise<string> {
return new SignJWT(payload as unknown as Record<string, unknown>)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(config.JWT_ACCESS_EXPIRY)
.sign(JWT_SECRET);
}
export async function generateRefreshToken(userId: string): Promise<string> {
const payload = { sub: userId, type: 'refresh' };
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime(config.JWT_REFRESH_EXPIRY)
.sign(JWT_SECRET);
}
export async function verifyToken(token: string): Promise<JwtPayload> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as unknown as JwtPayload;
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
}
// ==================== User Registration ====================
export async function register(data: {
email: string;
password: string;
firstName: string;
lastName: string;
organizationName?: string;
}): Promise<{ user: User; tokens: AuthTokens }> {
const { email, password, firstName, lastName, organizationName } = data;
// Check if user exists
const existingUser = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (existingUser) {
throw new ConflictError('User with this email already exists');
}
// Hash password
const passwordHash = await hashPassword(password);
// Create user and organization in transaction
const result = await prisma.$transaction(async (tx) => {
// Create user
const user = await tx.user.create({
data: {
email: email.toLowerCase(),
passwordHash,
firstName,
lastName,
status: 'active',
emailVerified: false,
},
});
// Create default organization
const orgName = organizationName || `${firstName}'s Organization`;
const orgSlug = generateSlug(orgName);
const organization = await tx.organization.create({
data: {
name: orgName,
slug: orgSlug,
plan: 'free',
},
});
// Add user as owner of organization
await tx.organizationMember.create({
data: {
orgId: organization.id,
userId: user.id,
role: 'owner',
},
});
// Create default roles for organization
const adminRole = await tx.role.create({
data: {
orgId: organization.id,
name: 'Admin',
description: 'Full access to all resources',
isSystem: true,
},
});
await tx.permission.create({
data: {
roleId: adminRole.id,
resource: '*',
action: '*',
},
});
const viewerRole = await tx.role.create({
data: {
orgId: organization.id,
name: 'Viewer',
description: 'Read-only access to resources',
isSystem: true,
},
});
await tx.permission.create({
data: {
roleId: viewerRole.id,
resource: '*',
action: 'read',
},
});
return { user, organization };
});
// Generate tokens
const tokens = await generateTokens(result.user.id, result.user.email, result.organization.id, 'owner');
return {
user: mapUser(result.user),
tokens,
};
}
// ==================== User Login ====================
export async function login(
email: string,
password: string,
ipAddress?: string,
userAgent?: string
): Promise<{ user: User; tokens: AuthTokens; orgId: string }> {
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
include: {
organizationMemberships: {
include: {
organization: true,
},
take: 1, // Get first organization
},
},
});
if (!user) {
throw new UnauthorizedError('Invalid email or password');
}
if (user.status !== 'active') {
throw new UnauthorizedError('Account is not active');
}
const isValidPassword = await verifyPassword(user.passwordHash, password);
if (!isValidPassword) {
throw new UnauthorizedError('Invalid email or password');
}
const membership = user.organizationMemberships[0];
if (!membership) {
throw new UnauthorizedError('User has no organization membership');
}
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Generate tokens
const tokens = await generateTokens(user.id, user.email, membership.orgId, membership.role);
// Store session in Redis
await setSession(tokens.accessToken.split('.')[2]!, {
userId: user.id,
orgId: membership.orgId,
email: user.email,
role: membership.role,
permissions: ['*:*:*'], // TODO: Fetch actual permissions
ipAddress: ipAddress || '',
createdAt: new Date().toISOString(),
lastActivity: new Date().toISOString(),
});
return {
user: mapUser(user),
tokens,
orgId: membership.orgId,
};
}
// ==================== Token Refresh ====================
export async function refreshTokens(refreshToken: string): Promise<AuthTokens> {
const payload = await verifyToken(refreshToken);
if ((payload as any).type !== 'refresh') {
throw new UnauthorizedError('Invalid refresh token');
}
const user = await prisma.user.findUnique({
where: { id: payload.sub },
include: {
organizationMemberships: {
take: 1,
},
},
});
if (!user || user.status !== 'active') {
throw new UnauthorizedError('User not found or inactive');
}
const membership = user.organizationMemberships[0];
if (!membership) {
throw new UnauthorizedError('User has no organization membership');
}
return generateTokens(user.id, user.email, membership.orgId, membership.role);
}
// ==================== Logout ====================
export async function logout(accessToken: string): Promise<void> {
const sessionId = accessToken.split('.')[2];
if (sessionId) {
await deleteSession(sessionId);
}
}
// ==================== Get Current User ====================
export async function getCurrentUser(userId: string): Promise<User> {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new NotFoundError('User');
}
return mapUser(user);
}
// ==================== Password Reset ====================
export async function requestPasswordReset(email: string): Promise<void> {
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user) {
// Don't reveal if user exists
return;
}
// Generate reset token
const resetToken = await new SignJWT({ sub: user.id, type: 'password_reset' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(JWT_SECRET);
// Store in Redis with 1 hour TTL
await redis.set(`password_reset:${user.id}`, resetToken, 'EX', 3600);
// TODO: Send email with reset link
console.log(`Password reset token for ${email}: ${resetToken}`);
}
export async function resetPassword(token: string, newPassword: string): Promise<void> {
const payload = await verifyToken(token);
if ((payload as any).type !== 'password_reset') {
throw new UnauthorizedError('Invalid reset token');
}
// Verify token is still valid in Redis
const storedToken = await redis.get(`password_reset:${payload.sub}`);
if (storedToken !== token) {
throw new UnauthorizedError('Reset token has expired or been used');
}
// Update password
const passwordHash = await hashPassword(newPassword);
await prisma.user.update({
where: { id: payload.sub },
data: { passwordHash },
});
// Delete reset token
await redis.del(`password_reset:${payload.sub}`);
}
// ==================== MFA ====================
export async function enableMfa(userId: string): Promise<{ secret: string; qrCode: string }> {
// TODO: Implement TOTP generation with speakeasy
throw new Error('MFA not implemented yet');
}
export async function verifyMfa(userId: string, code: string): Promise<boolean> {
// TODO: Implement TOTP verification
throw new Error('MFA not implemented yet');
}
export async function disableMfa(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
mfaEnabled: false,
mfaSecret: null,
},
});
}
// ==================== Helpers ====================
async function generateTokens(
userId: string,
email: string,
orgId: string,
role: string
): Promise<AuthTokens> {
// TODO: Fetch actual permissions from database
const permissions = ['*:*:*'];
const accessToken = await generateAccessToken({
sub: userId,
email,
orgId,
role: role as any,
permissions,
});
const refreshToken = await generateRefreshToken(userId);
return {
accessToken,
refreshToken,
expiresIn: parseExpiry(config.JWT_ACCESS_EXPIRY),
};
}
function parseExpiry(expiry: string): number {
const match = expiry.match(/^(\d+)(m|h|d)$/);
if (!match) return 900; // Default 15 minutes
const value = parseInt(match[1]!);
const unit = match[2];
switch (unit) {
case 'm':
return value * 60;
case 'h':
return value * 3600;
case 'd':
return value * 86400;
default:
return 900;
}
}
function generateSlug(name: string): string {
const baseSlug = name
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
// Add random suffix to ensure uniqueness
const suffix = Math.random().toString(36).substring(2, 6);
return `${baseSlug}-${suffix}`;
}
function mapUser(user: any): User {
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
avatarUrl: user.avatarUrl,
status: user.status,
emailVerified: user.emailVerified,
mfaEnabled: user.mfaEnabled,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
@@ -0,0 +1,82 @@
import type { FastifyPluginAsync } from 'fastify';
import { CreateClusterSchema, UpdateClusterSchema } from '@nats-console/shared';
import * as clusterService from './clusters.service.js';
import { authenticate, requirePermission } from '../../common/middleware/auth.js';
export const clusterRoutes: FastifyPluginAsync = async (fastify) => {
// All routes require authentication
fastify.addHook('preHandler', authenticate);
// GET /clusters - List clusters
fastify.get('/', async (request) => {
const clusters = await clusterService.listClusters(request.user!.orgId);
return { clusters };
});
// POST /clusters - Create cluster
fastify.post('/', async (request, reply) => {
const body = CreateClusterSchema.parse(request.body);
const cluster = await clusterService.createCluster(
request.user!.orgId,
request.user!.sub,
body
);
return reply.status(201).send({ cluster });
});
// GET /clusters/:id - Get cluster
fastify.get<{ Params: { id: string } }>('/:id', async (request) => {
const cluster = await clusterService.getClusterWithConnections(
request.user!.orgId,
request.params.id
);
return { cluster };
});
// PATCH /clusters/:id - Update cluster
fastify.patch<{ Params: { id: string } }>('/:id', async (request) => {
const body = UpdateClusterSchema.parse(request.body);
const cluster = await clusterService.updateCluster(
request.user!.orgId,
request.params.id,
body
);
return { cluster };
});
// DELETE /clusters/:id - Delete cluster
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
await clusterService.deleteCluster(request.user!.orgId, request.params.id);
return reply.status(204).send();
});
// GET /clusters/:id/health - Cluster health check
fastify.get<{ Params: { id: string } }>('/:id/health', async (request) => {
const health = await clusterService.checkHealth(
request.user!.orgId,
request.params.id
);
return health;
});
// GET /clusters/:id/info - Cluster detailed info
fastify.get<{ Params: { id: string } }>('/:id/info', async (request) => {
const info = await clusterService.getClusterInfo(
request.user!.orgId,
request.params.id
);
return info;
});
// POST /clusters/:id/connect - Connect to cluster
fastify.post<{ Params: { id: string } }>('/:id/connect', async (request) => {
await clusterService.connectToCluster(request.params.id);
return { success: true };
});
// POST /clusters/:id/disconnect - Disconnect from cluster
fastify.post<{ Params: { id: string } }>('/:id/disconnect', async (request) => {
await clusterService.disconnectFromCluster(request.params.id);
return { success: true };
});
};
@@ -0,0 +1,380 @@
import { prisma } from '../../lib/prisma.js';
import {
connectCluster,
disconnectCluster,
checkClusterHealth,
isClusterConnected,
listStreams,
} from '../../lib/nats.js';
import { setClusterStatus, getClusterStatus } from '../../lib/redis.js';
import { NotFoundError, ConflictError } from '@nats-console/shared';
import type { NatsCluster, ClusterConnection, ClusterStatus } from '@nats-console/shared';
import type { CreateClusterInput, UpdateClusterInput } from '@nats-console/shared';
// ==================== Cluster CRUD ====================
export async function listClusters(orgId: string): Promise<NatsCluster[]> {
const clusters = await prisma.natsCluster.findMany({
where: { orgId },
orderBy: { createdAt: 'desc' },
});
return clusters.map(mapCluster);
}
export async function getCluster(orgId: string, clusterId: string): Promise<NatsCluster> {
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
return mapCluster(cluster);
}
export async function getClusterWithConnections(
orgId: string,
clusterId: string
): Promise<NatsCluster & { connections: ClusterConnection[] }> {
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
include: { connections: true },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
return {
...mapCluster(cluster),
connections: cluster.connections.map(mapConnection),
};
}
export async function createCluster(
orgId: string,
userId: string,
input: CreateClusterInput
): Promise<NatsCluster> {
// Create cluster and connection in transaction
const result = await prisma.$transaction(async (tx) => {
const cluster = await tx.natsCluster.create({
data: {
orgId,
name: input.name,
description: input.description,
environment: input.environment,
status: 'disconnected',
},
});
await tx.clusterConnection.create({
data: {
clusterId: cluster.id,
serverUrl: input.serverUrl,
credentials: input.credentials as any,
tlsConfig: input.tlsConfig as any,
isPrimary: true,
healthStatus: 'unknown',
},
});
return cluster;
});
// Try to connect
try {
await connectToCluster(result.id);
} catch (error) {
console.error(`Failed to connect to new cluster ${result.id}:`, error);
}
return mapCluster(result);
}
export async function updateCluster(
orgId: string,
clusterId: string,
input: UpdateClusterInput
): Promise<NatsCluster> {
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
const updated = await prisma.natsCluster.update({
where: { id: clusterId },
data: {
name: input.name,
description: input.description,
environment: input.environment,
},
});
// Update connection if credentials/TLS changed
if (input.credentials || input.tlsConfig) {
await prisma.clusterConnection.updateMany({
where: { clusterId, isPrimary: true },
data: {
...(input.credentials && { credentials: input.credentials as any }),
...(input.tlsConfig && { tlsConfig: input.tlsConfig as any }),
},
});
// Reconnect with new credentials
await disconnectCluster(clusterId);
await connectToCluster(clusterId);
}
return mapCluster(updated);
}
export async function deleteCluster(orgId: string, clusterId: string): Promise<void> {
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
// Disconnect from cluster
await disconnectCluster(clusterId);
// Delete cluster (cascades to connections, streams, consumers)
await prisma.natsCluster.delete({
where: { id: clusterId },
});
}
// ==================== Cluster Connection ====================
export async function connectToCluster(clusterId: string): Promise<void> {
const cluster = await prisma.natsCluster.findUnique({
where: { id: clusterId },
include: { connections: { where: { isPrimary: true }, take: 1 } },
});
if (!cluster || cluster.connections.length === 0) {
throw new NotFoundError('Cluster', clusterId);
}
const connection = cluster.connections[0]!;
try {
await connectCluster(
clusterId,
connection.serverUrl,
connection.credentials as any,
connection.tlsConfig as any
);
// Update status
await prisma.natsCluster.update({
where: { id: clusterId },
data: { status: 'connected' },
});
await prisma.clusterConnection.update({
where: { id: connection.id },
data: {
healthStatus: 'healthy',
lastHealthCheck: new Date(),
},
});
// Cache status
await setClusterStatus(clusterId, {
status: 'connected',
serverCount: 1,
version: '',
lastCheck: new Date().toISOString(),
});
} catch (error) {
await prisma.natsCluster.update({
where: { id: clusterId },
data: { status: 'disconnected' },
});
await prisma.clusterConnection.update({
where: { id: connection.id },
data: {
healthStatus: 'unhealthy',
lastHealthCheck: new Date(),
},
});
throw error;
}
}
export async function disconnectFromCluster(clusterId: string): Promise<void> {
await disconnectCluster(clusterId);
await prisma.natsCluster.update({
where: { id: clusterId },
data: { status: 'disconnected' },
});
}
// ==================== Health Check ====================
export async function checkHealth(
orgId: string,
clusterId: string
): Promise<{
connected: boolean;
status: ClusterStatus;
rtt?: number;
serverInfo?: {
serverId: string;
serverName: string;
version: string;
jetstream: boolean;
};
streamCount?: number;
}> {
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
// Try to use cached status first
const cachedStatus = await getClusterStatus(clusterId);
if (cachedStatus) {
return {
connected: cachedStatus.status === 'connected',
status: cachedStatus.status as ClusterStatus,
serverInfo: {
serverId: '',
serverName: '',
version: cachedStatus.version,
jetstream: true,
},
};
}
// Check if connected
if (!isClusterConnected(clusterId)) {
// Try to connect
try {
await connectToCluster(clusterId);
} catch {
return { connected: false, status: 'disconnected' };
}
}
// Get health
const health = await checkClusterHealth(clusterId);
if (!health.connected) {
await prisma.natsCluster.update({
where: { id: clusterId },
data: { status: 'disconnected' },
});
return { connected: false, status: 'disconnected' };
}
// Get stream count
let streamCount = 0;
try {
const streams = await listStreams(clusterId);
streamCount = streams.length;
} catch {
// Ignore stream count errors
}
// Update and cache status
const version = health.serverInfo?.version || '';
await prisma.natsCluster.update({
where: { id: clusterId },
data: {
status: 'connected',
version,
},
});
await setClusterStatus(clusterId, {
status: 'connected',
serverCount: 1,
version,
lastCheck: new Date().toISOString(),
});
return {
connected: true,
status: 'connected',
rtt: health.rtt,
serverInfo: health.serverInfo,
streamCount,
};
}
// ==================== Cluster Info ====================
export async function getClusterInfo(orgId: string, clusterId: string): Promise<{
cluster: NatsCluster;
health: Awaited<ReturnType<typeof checkHealth>>;
streams: number;
consumers: number;
}> {
const cluster = await getCluster(orgId, clusterId);
const health = await checkHealth(orgId, clusterId);
let streamCount = 0;
let consumerCount = 0;
if (health.connected) {
try {
const streams = await listStreams(clusterId);
streamCount = streams.length;
consumerCount = streams.reduce((acc, s) => acc + (s.state?.consumer_count || 0), 0);
} catch {
// Ignore errors
}
}
return {
cluster,
health,
streams: streamCount,
consumers: consumerCount,
};
}
// ==================== Helpers ====================
function mapCluster(cluster: any): NatsCluster {
return {
id: cluster.id,
orgId: cluster.orgId,
name: cluster.name,
description: cluster.description,
environment: cluster.environment,
status: cluster.status,
version: cluster.version,
createdAt: cluster.createdAt,
updatedAt: cluster.updatedAt,
};
}
function mapConnection(connection: any): ClusterConnection {
return {
id: connection.id,
clusterId: connection.clusterId,
serverUrl: connection.serverUrl,
credentials: connection.credentials,
tlsConfig: connection.tlsConfig,
isPrimary: connection.isPrimary,
healthStatus: connection.healthStatus,
lastHealthCheck: connection.lastHealthCheck,
};
}
@@ -0,0 +1,96 @@
import type { FastifyPluginAsync } from 'fastify';
import { CreateConsumerSchema, UpdateConsumerSchema } from '@nats-console/shared';
import * as consumerService from './consumers.service.js';
import { authenticate } from '../../common/middleware/auth.js';
export const consumerRoutes: FastifyPluginAsync = async (fastify) => {
// All routes require authentication
fastify.addHook('preHandler', authenticate);
// GET /clusters/:cid/streams/:sid/consumers - List consumers
fastify.get<{ Params: { cid: string; sid: string } }>(
'/:cid/streams/:sid/consumers',
async (request) => {
const consumers = await consumerService.listConsumers(
request.user!.orgId,
request.params.cid,
request.params.sid
);
return { consumers };
}
);
// POST /clusters/:cid/streams/:sid/consumers - Create consumer
fastify.post<{ Params: { cid: string; sid: string } }>(
'/:cid/streams/:sid/consumers',
async (request, reply) => {
const body = CreateConsumerSchema.parse(request.body);
const consumer = await consumerService.createConsumer(
request.user!.orgId,
request.params.cid,
request.params.sid,
request.user!.sub,
body
);
return reply.status(201).send({ consumer });
}
);
// GET /clusters/:cid/streams/:sid/consumers/:name - Get consumer
fastify.get<{ Params: { cid: string; sid: string; name: string } }>(
'/:cid/streams/:sid/consumers/:name',
async (request) => {
const consumer = await consumerService.getConsumer(
request.user!.orgId,
request.params.cid,
request.params.sid,
request.params.name
);
return { consumer };
}
);
// PATCH /clusters/:cid/streams/:sid/consumers/:name - Update consumer
fastify.patch<{ Params: { cid: string; sid: string; name: string } }>(
'/:cid/streams/:sid/consumers/:name',
async (request) => {
const body = UpdateConsumerSchema.parse(request.body);
const consumer = await consumerService.updateConsumer(
request.user!.orgId,
request.params.cid,
request.params.sid,
request.params.name,
body
);
return { consumer };
}
);
// DELETE /clusters/:cid/streams/:sid/consumers/:name - Delete consumer
fastify.delete<{ Params: { cid: string; sid: string; name: string } }>(
'/:cid/streams/:sid/consumers/:name',
async (request, reply) => {
await consumerService.deleteConsumer(
request.user!.orgId,
request.params.cid,
request.params.sid,
request.params.name
);
return reply.status(204).send();
}
);
// GET /clusters/:cid/streams/:sid/consumers/:name/info - Consumer info
fastify.get<{ Params: { cid: string; sid: string; name: string } }>(
'/:cid/streams/:sid/consumers/:name/info',
async (request) => {
const consumer = await consumerService.getConsumer(
request.user!.orgId,
request.params.cid,
request.params.sid,
request.params.name
);
return { consumer };
}
);
};
@@ -0,0 +1,225 @@
import { prisma } from '../../lib/prisma.js';
import {
listConsumers as natsListConsumers,
getConsumerInfo as natsGetConsumerInfo,
createConsumer as natsCreateConsumer,
updateConsumer as natsUpdateConsumer,
deleteConsumer as natsDeleteConsumer,
} from '../../lib/nats.js';
import { NotFoundError } from '@nats-console/shared';
import type { ConsumerInfo, CreateConsumerInput, UpdateConsumerInput } from '@nats-console/shared';
// ==================== Consumer Operations ====================
export async function listConsumers(
orgId: string,
clusterId: string,
streamName: 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 natsListConsumers(clusterId, streamName);
}
export async function getConsumer(
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);
}
try {
return await natsGetConsumerInfo(clusterId, streamName, consumerName);
} catch {
throw new NotFoundError('Consumer', consumerName);
}
}
export async function createConsumer(
orgId: string,
clusterId: string,
streamName: string,
userId: string,
input: CreateConsumerInput
): Promise<ConsumerInfo> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
// Get stream config from database
const streamConfig = await prisma.streamConfig.findFirst({
where: { clusterId, streamName },
});
// Create consumer in NATS
const consumerInfo = await natsCreateConsumer(clusterId, streamName, {
name: input.name,
durable_name: input.durableName || input.name,
description: input.description,
deliver_policy: mapDeliverPolicy(input.deliverPolicy),
opt_start_seq: input.optStartSeq,
opt_start_time: input.optStartTime,
ack_policy: mapAckPolicy(input.ackPolicy),
ack_wait: input.ackWait,
max_deliver: input.maxDeliver,
backoff: input.backoff,
filter_subject: input.filterSubject,
filter_subjects: input.filterSubjects,
replay_policy: mapReplayPolicy(input.replayPolicy),
rate_limit_bps: input.rateLimit,
sample_freq: input.sampleFreq,
max_waiting: input.maxWaiting,
max_ack_pending: input.maxAckPending,
headers_only: input.headersOnly,
max_batch: input.maxBatch,
max_expires: input.maxExpires,
inactive_threshold: input.inactiveThreshold,
num_replicas: input.numReplicas,
mem_storage: input.memStorage,
});
// Store config in database for tracking
if (streamConfig) {
await prisma.consumerConfig.create({
data: {
streamConfigId: streamConfig.id,
consumerName: input.name,
configSnapshot: consumerInfo.config as any,
createdBy: userId,
isManaged: true,
tags: input.tags || [],
},
});
}
return consumerInfo;
}
export async function updateConsumer(
orgId: string,
clusterId: string,
streamName: string,
consumerName: string,
input: UpdateConsumerInput
): Promise<ConsumerInfo> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
// Get current consumer info
const currentConsumer = await natsGetConsumerInfo(clusterId, streamName, consumerName);
// Update consumer in NATS
const consumerInfo = await natsUpdateConsumer(clusterId, streamName, {
name: consumerName,
durable_name: consumerName,
description: input.description ?? currentConsumer.config.description,
ack_wait: input.ackWait ?? currentConsumer.config.ack_wait,
max_deliver: input.maxDeliver ?? currentConsumer.config.max_deliver,
max_ack_pending: input.maxAckPending ?? currentConsumer.config.max_ack_pending,
max_waiting: input.maxWaiting ?? currentConsumer.config.max_waiting,
});
// Update config in database
const streamConfig = await prisma.streamConfig.findFirst({
where: { clusterId, streamName },
});
if (streamConfig) {
await prisma.consumerConfig.updateMany({
where: { streamConfigId: streamConfig.id, consumerName },
data: {
configSnapshot: consumerInfo.config as any,
tags: input.tags,
},
});
}
return consumerInfo;
}
export async function deleteConsumer(
orgId: string,
clusterId: string,
streamName: string,
consumerName: string
): Promise<void> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
// Delete from NATS
await natsDeleteConsumer(clusterId, streamName, consumerName);
// Delete from database
const streamConfig = await prisma.streamConfig.findFirst({
where: { clusterId, streamName },
});
if (streamConfig) {
await prisma.consumerConfig.deleteMany({
where: { streamConfigId: streamConfig.id, consumerName },
});
}
}
// ==================== Helpers ====================
function mapDeliverPolicy(policy: string): any {
const map: Record<string, string> = {
all: 'all',
last: 'last',
new: 'new',
byStartSequence: 'by_start_sequence',
byStartTime: 'by_start_time',
lastPerSubject: 'last_per_subject',
};
return map[policy] || 'all';
}
function mapAckPolicy(policy: string): any {
const map: Record<string, string> = {
none: 'none',
all: 'all',
explicit: 'explicit',
};
return map[policy] || 'explicit';
}
function mapReplayPolicy(policy: string): any {
const map: Record<string, string> = {
instant: 'instant',
original: 'original',
};
return map[policy] || 'instant';
}
@@ -0,0 +1,143 @@
import type { FastifyPluginAsync } from 'fastify';
import { CreateDashboardSchema, UpdateDashboardSchema } from '@nats-console/shared';
import { prisma } from '../../lib/prisma.js';
import { authenticate } from '../../common/middleware/auth.js';
import { NotFoundError } from '@nats-console/shared';
export const dashboardRoutes: FastifyPluginAsync = async (fastify) => {
fastify.addHook('preHandler', authenticate);
// GET /dashboards - List dashboards
fastify.get('/', async (request) => {
const dashboards = await prisma.dashboard.findMany({
where: {
OR: [
{ orgId: request.user!.orgId, userId: request.user!.sub },
{ orgId: request.user!.orgId, isShared: true },
],
},
include: { user: { select: { firstName: true, lastName: true, email: true } } },
orderBy: { updatedAt: 'desc' },
});
return { dashboards };
});
// POST /dashboards - Create dashboard
fastify.post('/', async (request, reply) => {
const body = CreateDashboardSchema.parse(request.body);
const dashboard = await prisma.dashboard.create({
data: {
orgId: request.user!.orgId,
userId: request.user!.sub,
name: body.name,
layout: body.layout as any,
widgets: body.widgets as any,
isShared: body.isShared,
},
});
return reply.status(201).send({ dashboard });
});
// GET /dashboards/:id - Get dashboard
fastify.get<{ Params: { id: string } }>('/:id', async (request) => {
const dashboard = await prisma.dashboard.findFirst({
where: {
id: request.params.id,
OR: [
{ orgId: request.user!.orgId, userId: request.user!.sub },
{ orgId: request.user!.orgId, isShared: true },
],
},
include: { user: { select: { firstName: true, lastName: true, email: true } } },
});
if (!dashboard) {
throw new NotFoundError('Dashboard', request.params.id);
}
return { dashboard };
});
// PATCH /dashboards/:id - Update dashboard
fastify.patch<{ Params: { id: string } }>('/:id', async (request) => {
const body = UpdateDashboardSchema.parse(request.body);
const dashboard = await prisma.dashboard.findFirst({
where: {
id: request.params.id,
orgId: request.user!.orgId,
userId: request.user!.sub,
},
});
if (!dashboard) {
throw new NotFoundError('Dashboard', request.params.id);
}
const updated = await prisma.dashboard.update({
where: { id: request.params.id },
data: {
name: body.name,
layout: body.layout as any,
widgets: body.widgets as any,
isShared: body.isShared,
},
});
return { dashboard: updated };
});
// DELETE /dashboards/:id - Delete dashboard
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
const dashboard = await prisma.dashboard.findFirst({
where: {
id: request.params.id,
orgId: request.user!.orgId,
userId: request.user!.sub,
},
});
if (!dashboard) {
throw new NotFoundError('Dashboard', request.params.id);
}
await prisma.dashboard.delete({
where: { id: request.params.id },
});
return reply.status(204).send();
});
// POST /dashboards/:id/clone - Clone dashboard
fastify.post<{ Params: { id: string } }>('/:id/clone', async (request, reply) => {
const dashboard = await prisma.dashboard.findFirst({
where: {
id: request.params.id,
OR: [
{ orgId: request.user!.orgId, userId: request.user!.sub },
{ orgId: request.user!.orgId, isShared: true },
],
},
});
if (!dashboard) {
throw new NotFoundError('Dashboard', request.params.id);
}
const cloned = await prisma.dashboard.create({
data: {
orgId: request.user!.orgId,
userId: request.user!.sub,
name: `${dashboard.name} (Copy)`,
layout: dashboard.layout as any,
widgets: dashboard.widgets as any,
isShared: false,
},
});
return reply.status(201).send({ dashboard: cloned });
});
};
@@ -0,0 +1,137 @@
import type { FastifyPluginAsync } from 'fastify';
import { CreateOrganizationSchema, UpdateOrganizationSchema } from '@nats-console/shared';
import { prisma } from '../../lib/prisma.js';
import { authenticate } from '../../common/middleware/auth.js';
import { NotFoundError } from '@nats-console/shared';
export const organizationRoutes: FastifyPluginAsync = async (fastify) => {
fastify.addHook('preHandler', authenticate);
// GET /organizations - List user's organizations
fastify.get('/', async (request) => {
const memberships = await prisma.organizationMember.findMany({
where: { userId: request.user!.sub },
include: { organization: true },
});
return {
organizations: memberships.map((m) => ({
...m.organization,
role: m.role,
})),
};
});
// POST /organizations - Create organization
fastify.post('/', async (request, reply) => {
const body = CreateOrganizationSchema.parse(request.body);
const organization = await prisma.$transaction(async (tx) => {
const org = await tx.organization.create({
data: {
name: body.name,
slug: body.slug,
plan: 'free',
},
});
await tx.organizationMember.create({
data: {
orgId: org.id,
userId: request.user!.sub,
role: 'owner',
},
});
return org;
});
return reply.status(201).send({ organization });
});
// GET /organizations/:id - Get organization
fastify.get<{ Params: { id: string } }>('/:id', async (request) => {
const membership = await prisma.organizationMember.findFirst({
where: {
orgId: request.params.id,
userId: request.user!.sub,
},
include: { organization: true },
});
if (!membership) {
throw new NotFoundError('Organization', request.params.id);
}
return { organization: membership.organization };
});
// PATCH /organizations/:id - Update organization
fastify.patch<{ Params: { id: string } }>('/:id', async (request) => {
const body = UpdateOrganizationSchema.parse(request.body);
const membership = await prisma.organizationMember.findFirst({
where: {
orgId: request.params.id,
userId: request.user!.sub,
role: { in: ['owner', 'admin'] },
},
});
if (!membership) {
throw new NotFoundError('Organization', request.params.id);
}
const organization = await prisma.organization.update({
where: { id: request.params.id },
data: body,
});
return { organization };
});
// DELETE /organizations/:id - Delete organization
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
const membership = await prisma.organizationMember.findFirst({
where: {
orgId: request.params.id,
userId: request.user!.sub,
role: 'owner',
},
});
if (!membership) {
throw new NotFoundError('Organization', request.params.id);
}
await prisma.organization.delete({
where: { id: request.params.id },
});
return reply.status(204).send();
});
// GET /organizations/:id/members - List members
fastify.get<{ Params: { id: string } }>('/:id/members', async (request) => {
const members = await prisma.organizationMember.findMany({
where: { orgId: request.params.id },
include: { user: true },
});
return {
members: members.map((m) => ({
id: m.id,
userId: m.userId,
role: m.role,
joinedAt: m.joinedAt,
user: {
id: m.user.id,
email: m.user.email,
firstName: m.user.firstName,
lastName: m.user.lastName,
avatarUrl: m.user.avatarUrl,
},
})),
};
});
};
@@ -0,0 +1,150 @@
import type { FastifyPluginAsync } from 'fastify';
import {
CreateStreamSchema,
UpdateStreamSchema,
PurgeStreamSchema,
GetMessagesSchema,
PublishMessageSchema,
} from '@nats-console/shared';
import * as streamService from './streams.service.js';
import { authenticate } from '../../common/middleware/auth.js';
export const streamRoutes: FastifyPluginAsync = async (fastify) => {
// All routes require authentication
fastify.addHook('preHandler', authenticate);
// GET /clusters/:cid/streams - List streams
fastify.get<{ Params: { cid: string } }>('/:cid/streams', async (request) => {
const streams = await streamService.listStreams(
request.user!.orgId,
request.params.cid
);
return { streams };
});
// POST /clusters/:cid/streams - Create stream
fastify.post<{ Params: { cid: string } }>('/:cid/streams', async (request, reply) => {
const body = CreateStreamSchema.parse(request.body);
const stream = await streamService.createStream(
request.user!.orgId,
request.params.cid,
request.user!.sub,
body
);
return reply.status(201).send({ stream });
});
// GET /clusters/:cid/streams/:name - Get stream
fastify.get<{ Params: { cid: string; name: string } }>(
'/:cid/streams/:name',
async (request) => {
const stream = await streamService.getStream(
request.user!.orgId,
request.params.cid,
request.params.name
);
return { stream };
}
);
// PATCH /clusters/:cid/streams/:name - Update stream
fastify.patch<{ Params: { cid: string; name: string } }>(
'/:cid/streams/:name',
async (request) => {
const body = UpdateStreamSchema.parse(request.body);
const stream = await streamService.updateStream(
request.user!.orgId,
request.params.cid,
request.params.name,
body
);
return { stream };
}
);
// DELETE /clusters/:cid/streams/:name - Delete stream
fastify.delete<{ Params: { cid: string; name: string } }>(
'/:cid/streams/:name',
async (request, reply) => {
await streamService.deleteStream(
request.user!.orgId,
request.params.cid,
request.params.name
);
return reply.status(204).send();
}
);
// GET /clusters/:cid/streams/:name/info - Stream info
fastify.get<{ Params: { cid: string; name: string } }>(
'/:cid/streams/:name/info',
async (request) => {
const stream = await streamService.getStream(
request.user!.orgId,
request.params.cid,
request.params.name
);
return { stream };
}
);
// POST /clusters/:cid/streams/:name/purge - Purge stream
fastify.post<{ Params: { cid: string; name: string } }>(
'/:cid/streams/:name/purge',
async (request) => {
const body = PurgeStreamSchema.parse(request.body || {});
const result = await streamService.purgeStream(
request.user!.orgId,
request.params.cid,
request.params.name,
body
);
return result;
}
);
// GET /clusters/:cid/streams/:name/messages - Browse messages
fastify.get<{ Params: { cid: string; name: string }; Querystring: Record<string, string> }>(
'/:cid/streams/:name/messages',
async (request) => {
const query = GetMessagesSchema.parse(request.query);
const messages = await streamService.getMessages(
request.user!.orgId,
request.params.cid,
request.params.name,
query
);
return { messages };
}
);
// POST /clusters/:cid/streams/:name/messages - Publish message
fastify.post<{ Params: { cid: string; name: string } }>(
'/:cid/streams/:name/messages',
async (request, reply) => {
const body = PublishMessageSchema.parse(request.body);
const result = await streamService.publishMessage(
request.user!.orgId,
request.params.cid,
body.subject,
body.data,
body.headers
);
return reply.status(201).send(result);
}
);
// DELETE /clusters/:cid/streams/:name/messages/:seq - Delete message
fastify.delete<{ Params: { cid: string; name: string; seq: string } }>(
'/:cid/streams/:name/messages/:seq',
async (request, reply) => {
await streamService.deleteMessage(
request.user!.orgId,
request.params.cid,
request.params.name,
parseInt(request.params.seq)
);
return reply.status(204).send();
}
);
};
@@ -0,0 +1,314 @@
import { prisma } from '../../lib/prisma.js';
import {
listStreams as natsListStreams,
getStreamInfo as natsGetStreamInfo,
createStream as natsCreateStream,
updateStream as natsUpdateStream,
deleteStream as natsDeleteStream,
purgeStream as natsPurgeStream,
getMessages as natsGetMessages,
getMessage as natsGetMessage,
publishMessage as natsPublishMessage,
deleteMessage as natsDeleteMessage,
} from '../../lib/nats.js';
import { NotFoundError } from '@nats-console/shared';
import type {
StreamInfo,
StreamConfig,
StreamMessage,
CreateStreamInput,
UpdateStreamInput,
PurgeStreamInput,
GetMessagesInput,
} from '@nats-console/shared';
// ==================== Stream Operations ====================
export async function listStreams(
orgId: string,
clusterId: string
): Promise<StreamInfo[]> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
return natsListStreams(clusterId);
}
export async function getStream(
orgId: string,
clusterId: string,
streamName: string
): Promise<StreamInfo> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
try {
return await natsGetStreamInfo(clusterId, streamName);
} catch {
throw new NotFoundError('Stream', streamName);
}
}
export async function createStream(
orgId: string,
clusterId: string,
userId: string,
input: CreateStreamInput
): Promise<StreamInfo> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
// Create stream in NATS
const streamInfo = await natsCreateStream(clusterId, {
name: input.name,
subjects: input.subjects,
retention: input.retention,
max_consumers: input.maxConsumers,
max_msgs: input.maxMsgs,
max_bytes: input.maxBytes,
max_age: input.maxAge,
max_msg_size: input.maxMsgSize,
storage: input.storage,
num_replicas: input.replicas,
no_ack: input.noAck,
discard: input.discard,
duplicate_window: input.duplicateWindow,
placement: input.placement,
mirror: input.mirror,
sources: input.sources,
sealed: input.sealed,
deny_delete: input.denyDelete,
deny_purge: input.denyPurge,
allow_rollup_hdrs: input.allowRollup,
});
// Store config in database for tracking
await prisma.streamConfig.create({
data: {
clusterId,
streamName: input.name,
configSnapshot: streamInfo.config as any,
createdBy: userId,
isManaged: true,
tags: input.tags || [],
},
});
return streamInfo;
}
export async function updateStream(
orgId: string,
clusterId: string,
streamName: string,
input: UpdateStreamInput
): Promise<StreamInfo> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
// Get current config
const currentStream = await natsGetStreamInfo(clusterId, streamName);
// Update stream in NATS
const streamInfo = await natsUpdateStream(clusterId, {
name: streamName,
subjects: input.subjects ?? currentStream.config.subjects,
retention: input.retention ?? currentStream.config.retention,
max_consumers: input.maxConsumers ?? currentStream.config.max_consumers,
max_msgs: input.maxMsgs ?? currentStream.config.max_msgs,
max_bytes: input.maxBytes ?? currentStream.config.max_bytes,
max_age: input.maxAge ?? currentStream.config.max_age,
max_msg_size: input.maxMsgSize ?? currentStream.config.max_msg_size,
storage: input.storage ?? currentStream.config.storage,
num_replicas: input.replicas ?? currentStream.config.num_replicas,
discard: input.discard ?? currentStream.config.discard,
});
// Update config in database
await prisma.streamConfig.updateMany({
where: { clusterId, streamName },
data: {
configSnapshot: streamInfo.config as any,
tags: input.tags,
},
});
return streamInfo;
}
export async function deleteStream(
orgId: string,
clusterId: string,
streamName: string
): Promise<void> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
// Delete from NATS
await natsDeleteStream(clusterId, streamName);
// Delete from database
await prisma.streamConfig.deleteMany({
where: { clusterId, streamName },
});
}
export async function purgeStream(
orgId: string,
clusterId: string,
streamName: string,
input?: PurgeStreamInput
): Promise<{ purged: number }> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
return natsPurgeStream(clusterId, streamName, input);
}
// ==================== Message Operations ====================
export async function getMessages(
orgId: string,
clusterId: string,
streamName: string,
input: GetMessagesInput
): Promise<StreamMessage[]> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
const messages = await natsGetMessages(clusterId, streamName, {
startSeq: input.startSeq,
limit: input.limit,
subject: input.subject,
});
return messages.map((msg) => ({
subject: msg.subject,
sequence: msg.seq,
time: msg.time,
data: new TextDecoder().decode(msg.data),
headers: msg.header
? Object.fromEntries(
Array.from(msg.header.keys()).map((k) => [k, msg.header!.get(k)])
)
: undefined,
}));
}
export async function getMessage(
orgId: string,
clusterId: string,
streamName: string,
sequence: number
): Promise<StreamMessage | null> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
const msg = await natsGetMessage(clusterId, streamName, sequence);
if (!msg) {
return null;
}
return {
subject: msg.subject,
sequence: msg.seq,
time: msg.time,
data: new TextDecoder().decode(msg.data),
headers: msg.header
? Object.fromEntries(
Array.from(msg.header.keys()).map((k) => [k, msg.header!.get(k)])
)
: undefined,
};
}
export async function publishMessage(
orgId: string,
clusterId: string,
subject: string,
data: string,
headers?: Record<string, string>
): Promise<{ sequence: number; stream: string }> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
const ack = await natsPublishMessage(clusterId, subject, data, headers);
return {
sequence: ack.seq,
stream: ack.stream,
};
}
export async function deleteMessage(
orgId: string,
clusterId: string,
streamName: string,
sequence: number
): Promise<void> {
// Verify cluster belongs to org
const cluster = await prisma.natsCluster.findFirst({
where: { id: clusterId, orgId },
});
if (!cluster) {
throw new NotFoundError('Cluster', clusterId);
}
await natsDeleteMessage(clusterId, streamName, sequence);
}
+163
View File
@@ -0,0 +1,163 @@
import type { FastifyPluginAsync } from 'fastify';
import { CreateTeamSchema, UpdateTeamSchema, AddTeamMemberSchema } from '@nats-console/shared';
import { prisma } from '../../lib/prisma.js';
import { authenticate } from '../../common/middleware/auth.js';
import { NotFoundError } from '@nats-console/shared';
export const teamRoutes: FastifyPluginAsync = async (fastify) => {
fastify.addHook('preHandler', authenticate);
// GET /teams - List teams
fastify.get('/', async (request) => {
const teams = await prisma.team.findMany({
where: { orgId: request.user!.orgId },
include: { _count: { select: { members: true } } },
});
return {
teams: teams.map((t) => ({
id: t.id,
name: t.name,
description: t.description,
memberCount: t._count.members,
createdAt: t.createdAt,
})),
};
});
// POST /teams - Create team
fastify.post('/', async (request, reply) => {
const body = CreateTeamSchema.parse(request.body);
const team = await prisma.team.create({
data: {
orgId: request.user!.orgId,
name: body.name,
description: body.description,
},
});
// Add creator as team admin
await prisma.teamMember.create({
data: {
teamId: team.id,
userId: request.user!.sub,
role: 'admin',
},
});
return reply.status(201).send({ team });
});
// GET /teams/:id - Get team
fastify.get<{ Params: { id: string } }>('/:id', async (request) => {
const team = await prisma.team.findFirst({
where: {
id: request.params.id,
orgId: request.user!.orgId,
},
});
if (!team) {
throw new NotFoundError('Team', request.params.id);
}
return { team };
});
// PATCH /teams/:id - Update team
fastify.patch<{ Params: { id: string } }>('/:id', async (request) => {
const body = UpdateTeamSchema.parse(request.body);
const team = await prisma.team.findFirst({
where: {
id: request.params.id,
orgId: request.user!.orgId,
},
});
if (!team) {
throw new NotFoundError('Team', request.params.id);
}
const updated = await prisma.team.update({
where: { id: request.params.id },
data: body,
});
return { team: updated };
});
// DELETE /teams/:id - Delete team
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
const team = await prisma.team.findFirst({
where: {
id: request.params.id,
orgId: request.user!.orgId,
},
});
if (!team) {
throw new NotFoundError('Team', request.params.id);
}
await prisma.team.delete({
where: { id: request.params.id },
});
return reply.status(204).send();
});
// GET /teams/:id/members - List team members
fastify.get<{ Params: { id: string } }>('/:id/members', async (request) => {
const members = await prisma.teamMember.findMany({
where: { teamId: request.params.id },
include: { user: true },
});
return {
members: members.map((m) => ({
id: m.id,
userId: m.userId,
role: m.role,
addedAt: m.addedAt,
user: {
id: m.user.id,
email: m.user.email,
firstName: m.user.firstName,
lastName: m.user.lastName,
},
})),
};
});
// POST /teams/:id/members - Add team member
fastify.post<{ Params: { id: string } }>('/:id/members', async (request, reply) => {
const body = AddTeamMemberSchema.parse(request.body);
await prisma.teamMember.create({
data: {
teamId: request.params.id,
userId: body.userId,
role: body.role,
},
});
return reply.status(201).send({ success: true });
});
// DELETE /teams/:id/members/:uid - Remove team member
fastify.delete<{ Params: { id: string; uid: string } }>(
'/:id/members/:uid',
async (request, reply) => {
await prisma.teamMember.deleteMany({
where: {
teamId: request.params.id,
userId: request.params.uid,
},
});
return reply.status(204).send();
}
);
};
+142
View File
@@ -0,0 +1,142 @@
import type { FastifyPluginAsync } from 'fastify';
import { UpdateUserSchema, InviteUserSchema } from '@nats-console/shared';
import { prisma } from '../../lib/prisma.js';
import { authenticate } from '../../common/middleware/auth.js';
import { NotFoundError } from '@nats-console/shared';
export const userRoutes: FastifyPluginAsync = async (fastify) => {
fastify.addHook('preHandler', authenticate);
// GET /users - List users in organization
fastify.get('/', async (request) => {
const members = await prisma.organizationMember.findMany({
where: { orgId: request.user!.orgId },
include: { user: true },
});
return {
users: members.map((m) => ({
id: m.user.id,
email: m.user.email,
firstName: m.user.firstName,
lastName: m.user.lastName,
avatarUrl: m.user.avatarUrl,
status: m.user.status,
role: m.role,
joinedAt: m.joinedAt,
})),
};
});
// GET /users/:id - Get user
fastify.get<{ Params: { id: string } }>('/:id', async (request) => {
const membership = await prisma.organizationMember.findFirst({
where: {
orgId: request.user!.orgId,
userId: request.params.id,
},
include: { user: true },
});
if (!membership) {
throw new NotFoundError('User', request.params.id);
}
return {
user: {
id: membership.user.id,
email: membership.user.email,
firstName: membership.user.firstName,
lastName: membership.user.lastName,
avatarUrl: membership.user.avatarUrl,
status: membership.user.status,
role: membership.role,
},
};
});
// PATCH /users/:id - Update user
fastify.patch<{ Params: { id: string } }>('/:id', async (request) => {
const body = UpdateUserSchema.parse(request.body);
// Can only update self or if admin
if (request.params.id !== request.user!.sub) {
const membership = await prisma.organizationMember.findFirst({
where: {
orgId: request.user!.orgId,
userId: request.user!.sub,
role: { in: ['owner', 'admin'] },
},
});
if (!membership) {
throw new NotFoundError('User', request.params.id);
}
}
const user = await prisma.user.update({
where: { id: request.params.id },
data: body,
});
return {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
avatarUrl: user.avatarUrl,
status: user.status,
},
};
});
// POST /users/invite - Invite user
fastify.post('/invite', async (request, reply) => {
const body = InviteUserSchema.parse(request.body);
// Check if user exists
let user = await prisma.user.findUnique({
where: { email: body.email.toLowerCase() },
});
if (!user) {
// Create placeholder user
user = await prisma.user.create({
data: {
email: body.email.toLowerCase(),
passwordHash: '', // Will be set on registration
status: 'inactive',
},
});
}
// Check if already a member
const existingMembership = await prisma.organizationMember.findFirst({
where: {
orgId: request.user!.orgId,
userId: user.id,
},
});
if (existingMembership) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'User is already a member' },
});
}
// Create membership
await prisma.organizationMember.create({
data: {
orgId: request.user!.orgId,
userId: user.id,
role: body.role,
invitedBy: request.user!.sub,
},
});
// TODO: Send invitation email
return reply.status(201).send({ success: true });
});
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+11
View File
@@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-muted/40">
<div className="w-full max-w-md">{children}</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuthStore } from '@/stores/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
export default function LoginPage() {
const router = useRouter();
const { login, isLoading } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(email, password);
router.push('/clusters');
} catch (err: any) {
setError(err.message || 'Login failed');
}
};
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
<CardDescription>Enter your credentials to access NATS Console</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">{error}</div>
)}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Signing in...' : 'Sign in'}
</Button>
<p className="text-sm text-center text-muted-foreground">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}
+136
View File
@@ -0,0 +1,136 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuthStore } from '@/stores/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
export default function RegisterPage() {
const router = useRouter();
const { register, isLoading } = useAuthStore();
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: '',
});
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match');
return;
}
try {
await register({
email: formData.email,
password: formData.password,
firstName: formData.firstName,
lastName: formData.lastName,
});
router.push('/clusters');
} catch (err: any) {
setError(err.message || 'Registration failed');
}
};
return (
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold">Create an account</CardTitle>
<CardDescription>Enter your details to get started with NATS Console</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
{error && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="firstName" className="text-sm font-medium">
First name
</label>
<Input
id="firstName"
placeholder="John"
value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="lastName" className="text-sm font-medium">
Last name
</label>
<Input
id="lastName"
placeholder="Doe"
value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
required
/>
</div>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">
Email
</label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">
Password
</label>
<Input
id="password"
type="password"
placeholder="Create a password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<label htmlFor="confirmPassword" className="text-sm font-medium">
Confirm password
</label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
required
/>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Creating account...' : 'Create account'}
</Button>
<p className="text-sm text-center text-muted-foreground">
Already have an account?{' '}
<Link href="/login" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
);
}
+115
View File
@@ -0,0 +1,115 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Server, MoreVertical, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function ClustersPage() {
const { data, isLoading, error } = useQuery({
queryKey: ['clusters'],
queryFn: () => api.clusters.list(),
});
const getStatusIcon = (status: string) => {
switch (status) {
case 'connected':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'disconnected':
return <XCircle className="h-5 w-5 text-red-500" />;
case 'degraded':
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
default:
return <AlertCircle className="h-5 w-5 text-gray-500" />;
}
};
const getEnvironmentBadge = (env: string) => {
const colors: Record<string, string> = {
production: 'bg-red-100 text-red-700',
staging: 'bg-yellow-100 text-yellow-700',
development: 'bg-green-100 text-green-700',
};
return (
<span className={`px-2 py-1 text-xs font-medium rounded-full ${colors[env] || 'bg-gray-100 text-gray-700'}`}>
{env}
</span>
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Clusters</h1>
<p className="text-muted-foreground">Manage your NATS JetStream clusters</p>
</div>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Cluster
</Button>
</div>
{isLoading && (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
{error && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-destructive">Failed to load clusters</p>
</CardContent>
</Card>
)}
{data?.clusters && data.clusters.length === 0 && (
<Card>
<CardContent className="py-12 text-center">
<Server className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No clusters yet</h3>
<p className="text-muted-foreground mb-4">
Add your first NATS cluster to get started
</p>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Cluster
</Button>
</CardContent>
</Card>
)}
{data?.clusters && data.clusters.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.clusters.map((cluster: any) => (
<Card key={cluster.id} className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
<div className="flex items-center gap-3">
{getStatusIcon(cluster.status)}
<div>
<CardTitle className="text-lg">{cluster.name}</CardTitle>
<CardDescription>{cluster.description || 'No description'}</CardDescription>
</div>
</div>
<Button variant="ghost" size="icon">
<MoreVertical className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm">
{getEnvironmentBadge(cluster.environment)}
<span className="text-muted-foreground">
{cluster.version || 'Unknown version'}
</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth';
import { Sidebar } from '@/components/layout/sidebar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
useEffect(() => {
if (!isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router]);
if (!isAuthenticated) {
return null;
}
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto bg-muted/40">
<div className="container py-6">{children}</div>
</main>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Database, Search } from 'lucide-react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatBytes, formatNumber } from '@nats-console/shared';
export default function StreamsPage() {
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const [search, setSearch] = useState('');
const { data: clustersData } = useQuery({
queryKey: ['clusters'],
queryFn: () => api.clusters.list(),
});
const { data: streamsData, isLoading } = useQuery({
queryKey: ['streams', selectedCluster],
queryFn: () => (selectedCluster ? api.streams.list(selectedCluster) : null),
enabled: !!selectedCluster,
});
// Auto-select first cluster
if (clustersData?.clusters?.length && !selectedCluster) {
setSelectedCluster(clustersData.clusters[0].id);
}
const filteredStreams = streamsData?.streams?.filter((stream: any) =>
stream.config.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Streams</h1>
<p className="text-muted-foreground">Manage JetStream streams</p>
</div>
<Button disabled={!selectedCluster}>
<Plus className="h-4 w-4 mr-2" />
Create Stream
</Button>
</div>
<div className="flex gap-4">
<select
className="h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
value={selectedCluster || ''}
onChange={(e) => setSelectedCluster(e.target.value)}
>
<option value="">Select cluster...</option>
{clustersData?.clusters?.map((cluster: any) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name}
</option>
))}
</select>
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search streams..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
{!selectedCluster && (
<Card>
<CardContent className="py-12 text-center">
<Database className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Select a cluster</h3>
<p className="text-muted-foreground">Choose a cluster to view its streams</p>
</CardContent>
</Card>
)}
{selectedCluster && isLoading && (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
)}
{selectedCluster && filteredStreams && filteredStreams.length === 0 && (
<Card>
<CardContent className="py-12 text-center">
<Database className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No streams found</h3>
<p className="text-muted-foreground mb-4">
{search ? 'No streams match your search' : 'Create your first stream to get started'}
</p>
{!search && (
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Stream
</Button>
)}
</CardContent>
</Card>
)}
{filteredStreams && filteredStreams.length > 0 && (
<div className="border rounded-lg">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="text-left p-4 font-medium">Name</th>
<th className="text-left p-4 font-medium">Subjects</th>
<th className="text-right p-4 font-medium">Messages</th>
<th className="text-right p-4 font-medium">Size</th>
<th className="text-right p-4 font-medium">Consumers</th>
<th className="text-right p-4 font-medium">Storage</th>
</tr>
</thead>
<tbody>
{filteredStreams.map((stream: any) => (
<tr key={stream.config.name} className="border-t hover:bg-muted/30 cursor-pointer">
<td className="p-4 font-medium">{stream.config.name}</td>
<td className="p-4 text-muted-foreground">
{stream.config.subjects?.join(', ') || '-'}
</td>
<td className="p-4 text-right">{formatNumber(stream.state?.messages || 0)}</td>
<td className="p-4 text-right">{formatBytes(stream.state?.bytes || 0)}</td>
<td className="p-4 text-right">{stream.state?.consumer_count || 0}</td>
<td className="p-4 text-right">
<span
className={`px-2 py-1 text-xs rounded-full ${
stream.config.storage === 'file'
? 'bg-blue-100 text-blue-700'
: 'bg-purple-100 text-purple-700'
}`}
>
{stream.config.storage}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'NATS Console',
description: 'Enterprise-grade management console for NATS JetStream',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/login');
}
+20
View File
@@ -0,0 +1,20 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Server,
Database,
Users,
BarChart3,
Bell,
Settings,
LogOut,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth';
import { Button } from '@/components/ui/button';
const navigation = [
{ name: 'Overview', href: '/clusters', icon: LayoutDashboard },
{ name: 'Clusters', href: '/clusters', icon: Server },
{ name: 'Streams', href: '/streams', icon: Database },
{ name: 'Consumers', href: '/consumers', icon: Users },
{ name: 'Analytics', href: '/analytics', icon: BarChart3 },
{ name: 'Alerts', href: '/alerts', icon: Bell },
{ name: 'Settings', href: '/settings', icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
const { logout, user } = useAuthStore();
return (
<div className="flex h-full w-64 flex-col border-r bg-sidebar">
{/* Logo */}
<div className="flex h-16 items-center border-b px-6">
<Link href="/clusters" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-sm">NC</span>
</div>
<span className="font-semibold text-lg">NATS Console</span>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 p-4">
{navigation.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
)}
>
<item.icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
{/* User section */}
<div className="border-t p-4">
<div className="flex items-center gap-3 mb-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-sm font-medium">
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-muted-foreground truncate">{user?.email}</p>
</div>
</div>
<Button variant="ghost" className="w-full justify-start gap-2" onClick={() => logout()}>
<LogOut className="h-4 w-4" />
Sign out
</Button>
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+49
View File
@@ -0,0 +1,49 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+22
View File
@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };
+247
View File
@@ -0,0 +1,247 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
interface ApiOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
headers?: Record<string, string>;
}
class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
async function request<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, headers = {} } = options;
// Get token from localStorage
const token = typeof window !== 'undefined' ? localStorage.getItem('accessToken') : null;
const response = await fetch(`${API_URL}${endpoint}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: { message: 'Request failed' } }));
throw new ApiError(
response.status,
error.error?.code || 'UNKNOWN_ERROR',
error.error?.message || 'An error occurred'
);
}
if (response.status === 204) {
return {} as T;
}
return response.json();
}
// Auth API
export const auth = {
login: (email: string, password: string) =>
request<{ user: any; tokens: any; orgId: string }>('/auth/login', {
method: 'POST',
body: { email, password },
}),
register: (data: { email: string; password: string; firstName: string; lastName: string }) =>
request<{ user: any; tokens: any }>('/auth/register', {
method: 'POST',
body: data,
}),
logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
refresh: (refreshToken: string) =>
request<{ tokens: any }>('/auth/refresh', {
method: 'POST',
body: { refreshToken },
}),
me: () => request<{ user: any }>('/auth/me'),
};
// Clusters API
export const clusters = {
list: () => request<{ clusters: any[] }>('/clusters'),
get: (id: string) => request<{ cluster: any }>(`/clusters/${id}`),
create: (data: any) =>
request<{ cluster: any }>('/clusters', {
method: 'POST',
body: data,
}),
update: (id: string, data: any) =>
request<{ cluster: any }>(`/clusters/${id}`, {
method: 'PATCH',
body: data,
}),
delete: (id: string) => request(`/clusters/${id}`, { method: 'DELETE' }),
health: (id: string) => request<any>(`/clusters/${id}/health`),
info: (id: string) => request<any>(`/clusters/${id}/info`),
};
// Streams API
export const streams = {
list: (clusterId: string) => request<{ streams: any[] }>(`/clusters/${clusterId}/streams`),
get: (clusterId: string, name: string) =>
request<{ stream: any }>(`/clusters/${clusterId}/streams/${name}`),
create: (clusterId: string, data: any) =>
request<{ stream: any }>(`/clusters/${clusterId}/streams`, {
method: 'POST',
body: data,
}),
update: (clusterId: string, name: string, data: any) =>
request<{ stream: any }>(`/clusters/${clusterId}/streams/${name}`, {
method: 'PATCH',
body: data,
}),
delete: (clusterId: string, name: string) =>
request(`/clusters/${clusterId}/streams/${name}`, { method: 'DELETE' }),
purge: (clusterId: string, name: string, options?: any) =>
request<{ purged: number }>(`/clusters/${clusterId}/streams/${name}/purge`, {
method: 'POST',
body: options,
}),
messages: (clusterId: string, name: string, params?: Record<string, string>) => {
const query = params ? `?${new URLSearchParams(params)}` : '';
return request<{ messages: any[] }>(`/clusters/${clusterId}/streams/${name}/messages${query}`);
},
publish: (clusterId: string, name: string, data: { subject: string; data: string }) =>
request<{ sequence: number; stream: string }>(`/clusters/${clusterId}/streams/${name}/messages`, {
method: 'POST',
body: data,
}),
};
// Consumers API
export const consumers = {
list: (clusterId: string, streamName: string) =>
request<{ consumers: any[] }>(`/clusters/${clusterId}/streams/${streamName}/consumers`),
get: (clusterId: string, streamName: string, name: string) =>
request<{ consumer: any }>(`/clusters/${clusterId}/streams/${streamName}/consumers/${name}`),
create: (clusterId: string, streamName: string, data: any) =>
request<{ consumer: any }>(`/clusters/${clusterId}/streams/${streamName}/consumers`, {
method: 'POST',
body: data,
}),
update: (clusterId: string, streamName: string, name: string, data: any) =>
request<{ consumer: any }>(`/clusters/${clusterId}/streams/${streamName}/consumers/${name}`, {
method: 'PATCH',
body: data,
}),
delete: (clusterId: string, streamName: string, name: string) =>
request(`/clusters/${clusterId}/streams/${streamName}/consumers/${name}`, { method: 'DELETE' }),
};
// Analytics API
export const analytics = {
metrics: (params: Record<string, string>) => {
const query = `?${new URLSearchParams(params)}`;
return request<{ metrics: any[]; type: string }>(`/analytics${query}`);
},
streamThroughput: (name: string, params: Record<string, string>) => {
const query = `?${new URLSearchParams(params)}`;
return request<any>(`/analytics/streams/${name}/throughput${query}`);
},
consumerLag: (name: string, params: Record<string, string>) => {
const query = `?${new URLSearchParams(params)}`;
return request<any>(`/analytics/consumers/${name}/lag${query}`);
},
clusterOverview: (clusterId?: string) => {
const query = clusterId ? `?clusterId=${clusterId}` : '';
return request<any>(`/analytics/cluster/overview${query}`);
},
};
// Alerts API
export const alerts = {
listRules: () => request<{ rules: any[] }>('/alerts/rules'),
createRule: (data: any) =>
request<{ rule: any }>('/alerts/rules', {
method: 'POST',
body: data,
}),
updateRule: (id: string, data: any) =>
request<{ rule: any }>(`/alerts/rules/${id}`, {
method: 'PATCH',
body: data,
}),
deleteRule: (id: string) => request(`/alerts/rules/${id}`, { method: 'DELETE' }),
listEvents: (params?: Record<string, string>) => {
const query = params ? `?${new URLSearchParams(params)}` : '';
return request<{ events: any[] }>(`/alerts/events${query}`);
},
};
// Dashboards API
export const dashboards = {
list: () => request<{ dashboards: any[] }>('/dashboards'),
get: (id: string) => request<{ dashboard: any }>(`/dashboards/${id}`),
create: (data: any) =>
request<{ dashboard: any }>('/dashboards', {
method: 'POST',
body: data,
}),
update: (id: string, data: any) =>
request<{ dashboard: any }>(`/dashboards/${id}`, {
method: 'PATCH',
body: data,
}),
delete: (id: string) => request(`/dashboards/${id}`, { method: 'DELETE' }),
clone: (id: string) =>
request<{ dashboard: any }>(`/dashboards/${id}/clone`, {
method: 'POST',
}),
};
export const api = {
auth,
clusters,
streams,
consumers,
analytics,
alerts,
dashboards,
};
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
transpilePackages: ['@nats-console/shared'],
};
export default nextConfig;
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@nats-console/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@nats-console/shared": "workspace:*",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.3",
"@tanstack/react-query": "^5.62.2",
"@tanstack/react-table": "^8.20.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"next": "^15.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.53.2",
"recharts": "^2.14.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/node": "^22.10.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2"
}
}
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+133
View File
@@ -0,0 +1,133 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { api } from '@/lib/api';
interface User {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
avatarUrl: string | null;
}
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
orgId: string | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: { email: string; password: string; firstName: string; lastName: string }) => Promise<void>;
logout: () => Promise<void>;
refreshTokens: () => Promise<void>;
setUser: (user: User) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
orgId: null,
isLoading: false,
isAuthenticated: false,
login: async (email, password) => {
set({ isLoading: true });
try {
const { user, tokens, orgId } = await api.auth.login(email, password);
// Store token in localStorage for API client
localStorage.setItem('accessToken', tokens.accessToken);
set({
user,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
orgId,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
set({ isLoading: false });
throw error;
}
},
register: async (data) => {
set({ isLoading: true });
try {
const { user, tokens } = await api.auth.register(data);
localStorage.setItem('accessToken', tokens.accessToken);
set({
user,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
set({ isLoading: false });
throw error;
}
},
logout: async () => {
try {
await api.auth.logout();
} catch {
// Ignore errors during logout
}
localStorage.removeItem('accessToken');
set({
user: null,
accessToken: null,
refreshToken: null,
orgId: null,
isAuthenticated: false,
});
},
refreshTokens: async () => {
const { refreshToken } = get();
if (!refreshToken) {
throw new Error('No refresh token');
}
try {
const { tokens } = await api.auth.refresh(refreshToken);
localStorage.setItem('accessToken', tokens.accessToken);
set({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
} catch (error) {
// Refresh failed, logout
get().logout();
throw error;
}
},
setUser: (user) => set({ user }),
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
orgId: state.orgId,
isAuthenticated: state.isAuthenticated,
}),
}
)
);
+72
View File
@@ -0,0 +1,72 @@
import type { Config } from 'tailwindcss';
export default {
darkMode: ['class'],
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require('tailwindcss-animate')],
} satisfies Config;
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+21
View File
@@ -0,0 +1,21 @@
# Environment
NODE_ENV=development
# PostgreSQL
DATABASE_URL=postgresql://nats_console:nats_console_dev@localhost:5432/nats_console
# Redis
REDIS_URL=redis://localhost:6379
# ClickHouse
CLICKHOUSE_URL=http://localhost:8123
CLICKHOUSE_DATABASE=nats_console
CLICKHOUSE_USER=nats_console
CLICKHOUSE_PASSWORD=nats_console_dev
# NATS
NATS_URL=nats://localhost:4222
# Worker settings
METRICS_INTERVAL_MS=10000
CLUSTER_METRICS_INTERVAL_MS=30000
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@nats-console/workers",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@nats-console/shared": "workspace:*",
"@clickhouse/client": "^1.8.1",
"@prisma/client": "^6.0.1",
"ioredis": "^5.4.1",
"nats": "^2.28.2",
"node-cron": "^3.0.3",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"@types/node": "^22.10.1",
"@types/node-cron": "^3.0.11",
"prisma": "^6.0.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+364
View File
@@ -0,0 +1,364 @@
import { connect, NatsConnection, JetStreamManager } from 'nats';
import { createClient, ClickHouseClient } from '@clickhouse/client';
import { PrismaClient } from '@prisma/client';
import pino from 'pino';
import { config } from '../config.js';
import type { StreamMetrics, ConsumerMetrics, ClusterMetrics } from '@nats-console/shared';
const logger = pino({ name: 'metrics-collector' });
interface ClusterConnection {
id: string;
nc: NatsConnection;
jsm: JetStreamManager;
serverUrl: string;
}
export class MetricsCollector {
private prisma: PrismaClient;
private clickhouse: ClickHouseClient;
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();
constructor() {
this.prisma = new PrismaClient();
this.clickhouse = createClient({
url: config.CLICKHOUSE_URL,
database: config.CLICKHOUSE_DATABASE,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
}
async start(): Promise<void> {
logger.info('Starting metrics collector...');
// Connect to all clusters
await this.connectToClusters();
// Start collecting metrics
this.streamMetricsInterval = setInterval(
() => this.collectStreamMetrics(),
config.METRICS_INTERVAL_MS
);
this.clusterMetricsInterval = setInterval(
() => this.collectClusterMetrics(),
config.CLUSTER_METRICS_INTERVAL_MS
);
logger.info('Metrics collector started');
}
async stop(): Promise<void> {
logger.info('Stopping metrics collector...');
if (this.streamMetricsInterval) {
clearInterval(this.streamMetricsInterval);
}
if (this.clusterMetricsInterval) {
clearInterval(this.clusterMetricsInterval);
}
// Disconnect from all clusters
for (const [id, conn] of this.connections) {
try {
await conn.nc.drain();
} catch (err) {
logger.error({ clusterId: id, err }, 'Error disconnecting from cluster');
}
}
this.connections.clear();
await this.clickhouse.close();
await this.prisma.$disconnect();
logger.info('Metrics collector stopped');
}
private async connectToClusters(): Promise<void> {
// Get all clusters with their connections
const clusters = await this.prisma.natsCluster.findMany({
where: { status: 'connected' },
include: {
connections: {
where: { isPrimary: true },
take: 1,
},
},
});
for (const cluster of clusters) {
const connection = cluster.connections[0];
if (!connection) continue;
try {
const nc = await connect({
servers: connection.serverUrl,
name: `metrics-collector-${cluster.id}`,
});
const jsm = await nc.jetstreamManager();
this.connections.set(cluster.id, {
id: cluster.id,
nc,
jsm,
serverUrl: connection.serverUrl,
});
logger.info({ clusterId: cluster.id }, 'Connected to cluster');
} catch (err) {
logger.error({ clusterId: cluster.id, err }, 'Failed to connect to cluster');
}
}
}
private async collectStreamMetrics(): Promise<void> {
const streamMetrics: StreamMetrics[] = [];
const consumerMetrics: ConsumerMetrics[] = [];
const timestamp = new Date();
for (const [clusterId, conn] of this.connections) {
try {
// List all streams
for await (const streamInfo of conn.jsm.streams.list()) {
const streamName = streamInfo.config.name;
const state = streamInfo.state;
// Calculate rate
const key = `${clusterId}:${streamName}`;
const prev = this.previousStreamStats.get(key);
const now = Date.now();
let messagesRate = 0;
let bytesRate = 0;
if (prev) {
const timeDiff = (now - prev.timestamp) / 1000; // seconds
if (timeDiff > 0) {
messagesRate = (state.messages - prev.messages) / timeDiff;
bytesRate = (state.bytes - prev.bytes) / timeDiff;
}
}
this.previousStreamStats.set(key, {
messages: state.messages,
bytes: state.bytes,
timestamp: now,
});
streamMetrics.push({
clusterId,
streamName,
timestamp,
messagesTotal: state.messages,
bytesTotal: state.bytes,
messagesRate: Math.max(0, messagesRate),
bytesRate: Math.max(0, bytesRate),
consumerCount: state.consumer_count,
firstSeq: state.first_seq,
lastSeq: state.last_seq,
subjects: streamInfo.config.subjects || [],
});
// Collect consumer metrics for this stream
try {
for await (const consumerInfo of conn.jsm.consumers.list(streamName)) {
consumerMetrics.push({
clusterId,
streamName,
consumerName: consumerInfo.name,
timestamp,
pendingCount: consumerInfo.num_pending,
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
lag: consumerInfo.num_pending,
});
}
} catch (err) {
logger.error({ clusterId, streamName, err }, 'Error collecting consumer metrics');
}
}
} catch (err) {
logger.error({ clusterId, err }, 'Error collecting stream metrics');
}
}
// Insert metrics into ClickHouse
if (streamMetrics.length > 0) {
try {
await this.clickhouse.insert({
table: 'stream_metrics',
values: streamMetrics.map((m) => ({
cluster_id: m.clusterId,
stream_name: m.streamName,
timestamp: m.timestamp.toISOString(),
messages_total: m.messagesTotal,
bytes_total: m.bytesTotal,
messages_rate: m.messagesRate,
bytes_rate: m.bytesRate,
consumer_count: m.consumerCount,
first_seq: m.firstSeq,
last_seq: m.lastSeq,
subjects: m.subjects,
})),
format: 'JSONEachRow',
});
logger.debug({ count: streamMetrics.length }, 'Inserted stream metrics');
} catch (err) {
logger.error({ err }, 'Error inserting stream metrics');
}
}
if (consumerMetrics.length > 0) {
try {
await this.clickhouse.insert({
table: 'consumer_metrics',
values: consumerMetrics.map((m) => ({
cluster_id: m.clusterId,
stream_name: m.streamName,
consumer_name: m.consumerName,
timestamp: m.timestamp.toISOString(),
pending_count: m.pendingCount,
ack_pending: m.ackPending,
redelivered: m.redelivered,
waiting: m.waiting,
delivered_rate: m.deliveredRate,
ack_rate: m.ackRate,
lag: m.lag,
})),
format: 'JSONEachRow',
});
logger.debug({ count: consumerMetrics.length }, 'Inserted consumer metrics');
} catch (err) {
logger.error({ err }, 'Error inserting consumer metrics');
}
}
}
private async collectClusterMetrics(): Promise<void> {
const clusterMetrics: ClusterMetrics[] = [];
const timestamp = new Date();
for (const [clusterId, conn] of this.connections) {
try {
const info = conn.nc.info;
if (!info) continue;
// Get server stats via monitoring endpoint
// For now, use basic info from connection
clusterMetrics.push({
clusterId,
serverId: info.server_id,
serverName: info.server_name,
timestamp,
cpuPercent: 0, // Need monitoring endpoint
memoryBytes: 0,
connections: 0,
subscriptions: 0,
slowConsumers: 0,
inMsgs: 0,
outMsgs: 0,
inBytes: 0,
outBytes: 0,
});
} catch (err) {
logger.error({ clusterId, err }, 'Error collecting cluster metrics');
}
}
if (clusterMetrics.length > 0) {
try {
await this.clickhouse.insert({
table: 'cluster_metrics',
values: clusterMetrics.map((m) => ({
cluster_id: m.clusterId,
server_id: m.serverId,
server_name: m.serverName,
timestamp: m.timestamp.toISOString(),
cpu_percent: m.cpuPercent,
memory_bytes: m.memoryBytes,
connections: m.connections,
subscriptions: m.subscriptions,
slow_consumers: m.slowConsumers,
in_msgs: m.inMsgs,
out_msgs: m.outMsgs,
in_bytes: m.inBytes,
out_bytes: m.outBytes,
})),
format: 'JSONEachRow',
});
logger.debug({ count: clusterMetrics.length }, 'Inserted cluster metrics');
} catch (err) {
logger.error({ err }, 'Error inserting cluster metrics');
}
}
}
async refreshConnections(): Promise<void> {
logger.info('Refreshing cluster connections...');
// Get all clusters
const clusters = await this.prisma.natsCluster.findMany({
include: {
connections: {
where: { isPrimary: true },
take: 1,
},
},
});
const activeClusterIds = new Set(clusters.map((c) => c.id));
// Disconnect from removed clusters
for (const [id, conn] of this.connections) {
if (!activeClusterIds.has(id)) {
try {
await conn.nc.drain();
this.connections.delete(id);
logger.info({ clusterId: id }, 'Disconnected from removed cluster');
} catch (err) {
logger.error({ clusterId: id, err }, 'Error disconnecting from cluster');
}
}
}
// Connect to new clusters
for (const cluster of clusters) {
if (this.connections.has(cluster.id)) continue;
const connection = cluster.connections[0];
if (!connection) continue;
try {
const nc = await connect({
servers: connection.serverUrl,
name: `metrics-collector-${cluster.id}`,
});
const jsm = await nc.jetstreamManager();
this.connections.set(cluster.id, {
id: cluster.id,
nc,
jsm,
serverUrl: connection.serverUrl,
});
logger.info({ clusterId: cluster.id }, 'Connected to new cluster');
} catch (err) {
logger.error({ clusterId: cluster.id, err }, 'Failed to connect to new cluster');
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
// PostgreSQL
DATABASE_URL: z.string().url(),
// Redis
REDIS_URL: z.string().default('redis://localhost:6379'),
// ClickHouse
CLICKHOUSE_URL: z.string().default('http://localhost:8123'),
CLICKHOUSE_DATABASE: z.string().default('nats_console'),
CLICKHOUSE_USER: z.string().default('nats_console'),
CLICKHOUSE_PASSWORD: z.string().default('nats_console_dev'),
// NATS
NATS_URL: z.string().default('nats://localhost:4222'),
// Worker settings
METRICS_INTERVAL_MS: z.coerce.number().default(10000), // 10 seconds
CLUSTER_METRICS_INTERVAL_MS: z.coerce.number().default(30000), // 30 seconds
});
function loadConfig() {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
console.error(parsed.error.format());
process.exit(1);
}
return parsed.data;
}
export const config = loadConfig();
+69
View File
@@ -0,0 +1,69 @@
import pino from 'pino';
import cron from 'node-cron';
import { MetricsCollector } from './collectors/metrics.js';
import { AlertProcessor } from './processors/alerts.js';
import { config } from './config.js';
const logger = pino({
name: 'nats-console-workers',
level: config.NODE_ENV === 'development' ? 'debug' : 'info',
transport:
config.NODE_ENV === 'development'
? {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
}
: undefined,
});
const metricsCollector = new MetricsCollector();
const alertProcessor = new AlertProcessor();
// Graceful shutdown
async function shutdown(signal: string): Promise<void> {
logger.info(`Received ${signal}, shutting down...`);
await metricsCollector.stop();
await alertProcessor.stop();
logger.info('Shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Start workers
async function start(): Promise<void> {
logger.info('Starting NATS Console Workers...');
try {
// Start metrics collector
await metricsCollector.start();
// Start alert processor
await alertProcessor.start();
// Schedule connection refresh every 5 minutes
cron.schedule('*/5 * * * *', async () => {
logger.info('Refreshing cluster connections...');
await metricsCollector.refreshConnections();
});
// Schedule cleanup job daily at 2 AM
cron.schedule('0 2 * * *', async () => {
logger.info('Running cleanup job...');
// TODO: Implement cleanup (expired sessions, old data archival)
});
logger.info('All workers started successfully');
} catch (err) {
logger.error({ err }, 'Failed to start workers');
process.exit(1);
}
}
start();
+349
View File
@@ -0,0 +1,349 @@
import { PrismaClient } from '@prisma/client';
import { createClient, ClickHouseClient } from '@clickhouse/client';
import pino from 'pino';
import { config } from '../config.js';
import type { AlertSeverity } from '@nats-console/shared';
const logger = pino({ name: 'alert-processor' });
interface AlertRule {
id: string;
orgId: string;
clusterId: string | null;
name: string;
condition: {
metric: string;
operator: 'gt' | 'lt' | 'gte' | 'lte' | 'eq' | 'neq';
window: number;
aggregation: 'avg' | 'min' | 'max' | 'sum' | 'count';
};
threshold: {
value: number;
type: 'absolute' | 'percentage';
};
severity: AlertSeverity;
channels: Array<{ type: string; config: Record<string, unknown> }>;
isEnabled: boolean;
cooldownMins: number;
}
interface AlertState {
ruleId: string;
lastFired: number | null;
isFiring: boolean;
}
export class AlertProcessor {
private prisma: PrismaClient;
private clickhouse: ClickHouseClient;
private alertStates: Map<string, AlertState> = new Map();
private processInterval: NodeJS.Timeout | null = null;
constructor() {
this.prisma = new PrismaClient();
this.clickhouse = createClient({
url: config.CLICKHOUSE_URL,
database: config.CLICKHOUSE_DATABASE,
username: config.CLICKHOUSE_USER,
password: config.CLICKHOUSE_PASSWORD,
});
}
async start(): Promise<void> {
logger.info('Starting alert processor...');
// Process alerts every minute
this.processInterval = setInterval(() => this.processAlerts(), 60000);
// Run immediately on start
await this.processAlerts();
logger.info('Alert processor started');
}
async stop(): Promise<void> {
logger.info('Stopping alert processor...');
if (this.processInterval) {
clearInterval(this.processInterval);
}
await this.clickhouse.close();
await this.prisma.$disconnect();
logger.info('Alert processor stopped');
}
private async processAlerts(): Promise<void> {
try {
// Get all enabled alert rules
const rules = await this.prisma.alertRule.findMany({
where: { isEnabled: true },
});
for (const rule of rules) {
await this.evaluateRule(rule as unknown as AlertRule);
}
} catch (err) {
logger.error({ err }, 'Error processing alerts');
}
}
private async evaluateRule(rule: AlertRule): Promise<void> {
try {
// Get current metric value
const metricValue = await this.getMetricValue(rule);
if (metricValue === null) {
return; // No data available
}
// Check if threshold is exceeded
const isExceeded = this.checkThreshold(metricValue, rule.condition.operator, rule.threshold.value);
// Get current alert state
let state = this.alertStates.get(rule.id);
if (!state) {
state = { ruleId: rule.id, lastFired: null, isFiring: false };
this.alertStates.set(rule.id, state);
}
if (isExceeded && !state.isFiring) {
// Check cooldown
if (state.lastFired) {
const cooldownMs = rule.cooldownMins * 60 * 1000;
if (Date.now() - state.lastFired < cooldownMs) {
return; // Still in cooldown
}
}
// Fire alert
await this.fireAlert(rule, metricValue);
state.isFiring = true;
state.lastFired = Date.now();
} else if (!isExceeded && state.isFiring) {
// Resolve alert
await this.resolveAlert(rule);
state.isFiring = false;
}
} catch (err) {
logger.error({ ruleId: rule.id, err }, 'Error evaluating alert rule');
}
}
private async getMetricValue(rule: AlertRule): Promise<number | null> {
const { metric, aggregation, window } = rule.condition;
// Parse metric name (e.g., "stream.ORDERS.messages_rate" or "consumer.ORDERS.processor.lag")
const parts = metric.split('.');
const metricType = parts[0];
const windowStart = new Date(Date.now() - window * 1000);
const windowEnd = new Date();
try {
if (metricType === 'stream' && parts.length >= 3) {
const streamName = parts[1];
const metricName = parts[2];
const result = await this.clickhouse.query({
query: `
SELECT ${aggregation}(${metricName}) as value
FROM stream_metrics
WHERE stream_name = {streamName:String}
${rule.clusterId ? 'AND cluster_id = {clusterId:UUID}' : ''}
AND timestamp >= {from:DateTime64(3)}
AND timestamp <= {to:DateTime64(3)}
`,
query_params: {
streamName,
clusterId: rule.clusterId,
from: windowStart.toISOString(),
to: windowEnd.toISOString(),
},
format: 'JSONEachRow',
});
const rows = await result.json<{ value: number }[]>();
return rows[0]?.value ?? null;
}
if (metricType === 'consumer' && parts.length >= 4) {
const streamName = parts[1];
const consumerName = parts[2];
const metricName = parts[3];
const result = await this.clickhouse.query({
query: `
SELECT ${aggregation}(${metricName}) as value
FROM consumer_metrics
WHERE stream_name = {streamName:String}
AND consumer_name = {consumerName:String}
${rule.clusterId ? 'AND cluster_id = {clusterId:UUID}' : ''}
AND timestamp >= {from:DateTime64(3)}
AND timestamp <= {to:DateTime64(3)}
`,
query_params: {
streamName,
consumerName,
clusterId: rule.clusterId,
from: windowStart.toISOString(),
to: windowEnd.toISOString(),
},
format: 'JSONEachRow',
});
const rows = await result.json<{ value: number }[]>();
return rows[0]?.value ?? null;
}
} catch (err) {
logger.error({ metric, err }, 'Error querying metric');
}
return null;
}
private checkThreshold(value: number, operator: string, threshold: number): boolean {
switch (operator) {
case 'gt':
return value > threshold;
case 'lt':
return value < threshold;
case 'gte':
return value >= threshold;
case 'lte':
return value <= threshold;
case 'eq':
return value === threshold;
case 'neq':
return value !== threshold;
default:
return false;
}
}
private async fireAlert(rule: AlertRule, metricValue: number): Promise<void> {
logger.info(
{ ruleId: rule.id, ruleName: rule.name, metricValue, threshold: rule.threshold.value },
'Alert fired'
);
// Insert alert event into ClickHouse
await this.clickhouse.insert({
table: 'alert_events',
values: [
{
id: crypto.randomUUID(),
org_id: rule.orgId,
alert_rule_id: rule.id,
cluster_id: rule.clusterId || '00000000-0000-0000-0000-000000000000',
timestamp: new Date().toISOString(),
severity: rule.severity,
status: 'firing',
metric_value: metricValue,
threshold_value: rule.threshold.value,
message: `Alert "${rule.name}" fired: value ${metricValue} exceeded threshold ${rule.threshold.value}`,
notified_at: new Date().toISOString(),
resolved_at: null,
},
],
format: 'JSONEachRow',
});
// Send notifications
for (const channel of rule.channels) {
await this.sendNotification(channel, rule, metricValue, 'firing');
}
}
private async resolveAlert(rule: AlertRule): Promise<void> {
logger.info({ ruleId: rule.id, ruleName: rule.name }, 'Alert resolved');
// Insert resolved event
await this.clickhouse.insert({
table: 'alert_events',
values: [
{
id: crypto.randomUUID(),
org_id: rule.orgId,
alert_rule_id: rule.id,
cluster_id: rule.clusterId || '00000000-0000-0000-0000-000000000000',
timestamp: new Date().toISOString(),
severity: rule.severity,
status: 'resolved',
metric_value: 0,
threshold_value: rule.threshold.value,
message: `Alert "${rule.name}" resolved`,
notified_at: new Date().toISOString(),
resolved_at: new Date().toISOString(),
},
],
format: 'JSONEachRow',
});
// Send resolved notifications
for (const channel of rule.channels) {
await this.sendNotification(channel, rule, 0, 'resolved');
}
}
private async sendNotification(
channel: { type: string; config: Record<string, unknown> },
rule: AlertRule,
metricValue: number,
status: 'firing' | 'resolved'
): Promise<void> {
const message =
status === 'firing'
? `🚨 Alert "${rule.name}" fired: value ${metricValue} exceeded threshold ${rule.threshold.value}`
: `✅ Alert "${rule.name}" resolved`;
switch (channel.type) {
case 'webhook':
await this.sendWebhook(channel.config.url as string, {
rule: rule.name,
severity: rule.severity,
status,
metricValue,
threshold: rule.threshold.value,
message,
timestamp: new Date().toISOString(),
});
break;
case 'slack':
// TODO: Implement Slack notification
logger.info({ channel: 'slack', message }, 'Would send Slack notification');
break;
case 'email':
// TODO: Implement email notification
logger.info({ channel: 'email', message }, 'Would send email notification');
break;
case 'pagerduty':
// TODO: Implement PagerDuty notification
logger.info({ channel: 'pagerduty', message }, 'Would send PagerDuty notification');
break;
default:
logger.warn({ channelType: channel.type }, 'Unknown notification channel');
}
}
private async sendWebhook(url: string, payload: Record<string, unknown>): Promise<void> {
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
logger.error({ url, status: response.status }, 'Webhook request failed');
}
} catch (err) {
logger.error({ url, err }, 'Error sending webhook');
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+82
View File
@@ -0,0 +1,82 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: nats-console-postgres
environment:
POSTGRES_USER: nats_console
POSTGRES_PASSWORD: nats_console_dev
POSTGRES_DB: nats_console
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nats_console -d nats_console"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: nats-console-redis
command: redis-server --appendonly yes
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
clickhouse:
image: clickhouse/clickhouse-server:latest
container_name: nats-console-clickhouse
environment:
CLICKHOUSE_DB: nats_console
CLICKHOUSE_USER: nats_console
CLICKHOUSE_PASSWORD: nats_console_dev
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
ports:
- "8123:8123" # HTTP interface
- "9000:9000" # Native client
volumes:
- clickhouse_data:/var/lib/clickhouse
- ./infrastructure/clickhouse/init:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
interval: 10s
timeout: 5s
retries: 5
nats:
image: nats:latest
container_name: nats-console-nats
command:
- "--jetstream"
- "--store_dir=/data"
- "--http_port=8222"
- "-m=8222"
ports:
- "4222:4222" # Client connections
- "8222:8222" # HTTP monitoring
volumes:
- nats_data:/data
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
clickhouse_data:
nats_data:
networks:
default:
name: nats-console-network
@@ -0,0 +1,159 @@
-- Stream metrics table
CREATE TABLE IF NOT EXISTS stream_metrics
(
cluster_id UUID,
stream_name String,
timestamp DateTime64(3),
messages_total UInt64,
bytes_total UInt64,
messages_rate Float64,
bytes_rate Float64,
consumer_count UInt32,
first_seq UInt64,
last_seq UInt64,
subjects Array(String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (cluster_id, stream_name, timestamp)
TTL timestamp + INTERVAL 90 DAY;
-- Consumer metrics table
CREATE TABLE IF NOT EXISTS consumer_metrics
(
cluster_id UUID,
stream_name String,
consumer_name String,
timestamp DateTime64(3),
pending_count UInt64,
ack_pending UInt64,
redelivered UInt64,
waiting UInt64,
delivered_rate Float64,
ack_rate Float64,
lag Int64
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (cluster_id, stream_name, consumer_name, timestamp)
TTL timestamp + INTERVAL 90 DAY;
-- Cluster metrics table
CREATE TABLE IF NOT EXISTS cluster_metrics
(
cluster_id UUID,
server_id String,
server_name String,
timestamp DateTime64(3),
cpu_percent Float32,
memory_bytes UInt64,
connections UInt32,
subscriptions UInt32,
slow_consumers UInt32,
in_msgs UInt64,
out_msgs UInt64,
in_bytes UInt64,
out_bytes UInt64
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (cluster_id, server_id, timestamp)
TTL timestamp + INTERVAL 180 DAY;
-- Message samples table
CREATE TABLE IF NOT EXISTS message_samples
(
cluster_id UUID,
stream_name String,
subject String,
sequence UInt64,
timestamp DateTime64(3),
headers Map(String, String),
payload_preview String,
payload_size UInt32,
payload_type String
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (cluster_id, stream_name, subject, timestamp)
TTL timestamp + INTERVAL 7 DAY;
-- Audit logs table
CREATE TABLE IF NOT EXISTS audit_logs
(
id UUID,
org_id UUID,
user_id UUID,
user_email String,
timestamp DateTime64(3),
action String,
resource_type String,
resource_id String,
resource_name String,
cluster_id Nullable(UUID),
ip_address IPv6,
user_agent String,
request_id String,
changes String,
status Enum8('success' = 1, 'failure' = 2, 'denied' = 3),
error_message Nullable(String)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (org_id, timestamp, action)
TTL timestamp + INTERVAL 365 DAY;
-- Alert events table
CREATE TABLE IF NOT EXISTS alert_events
(
id UUID,
org_id UUID,
alert_rule_id UUID,
cluster_id UUID,
timestamp DateTime64(3),
severity Enum8('info' = 1, 'warning' = 2, 'critical' = 3),
status Enum8('firing' = 1, 'resolved' = 2),
metric_value Float64,
threshold_value Float64,
message String,
notified_at Nullable(DateTime64(3)),
resolved_at Nullable(DateTime64(3))
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (org_id, alert_rule_id, timestamp)
TTL timestamp + INTERVAL 90 DAY;
-- Materialized view for hourly stream metrics
CREATE MATERIALIZED VIEW IF NOT EXISTS stream_metrics_hourly
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (cluster_id, stream_name, hour)
AS SELECT
cluster_id,
stream_name,
toStartOfHour(timestamp) AS hour,
sum(messages_total) AS messages_sum,
sum(bytes_total) AS bytes_sum,
avg(messages_rate) AS avg_rate,
max(messages_rate) AS max_rate,
count() AS sample_count
FROM stream_metrics
GROUP BY cluster_id, stream_name, hour;
-- Materialized view for daily stream metrics
CREATE MATERIALIZED VIEW IF NOT EXISTS stream_metrics_daily
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(day)
ORDER BY (cluster_id, stream_name, day)
AS SELECT
cluster_id,
stream_name,
toDate(timestamp) AS day,
sum(messages_total) AS messages_sum,
sum(bytes_total) AS bytes_sum,
avg(messages_rate) AS avg_rate,
max(messages_rate) AS max_rate,
count() AS sample_count
FROM stream_metrics
GROUP BY cluster_id, stream_name, day;
+27
View File
@@ -0,0 +1,27 @@
{
"name": "nats-console",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"type-check": "turbo type-check",
"test": "turbo test",
"clean": "turbo clean && rm -rf node_modules",
"db:generate": "turbo db:generate",
"db:push": "turbo db:push",
"db:migrate": "turbo db:migrate",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:logs": "docker-compose logs -f"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.7.2"
},
"packageManager": "pnpm@9.14.2",
"engines": {
"node": ">=20.0.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@nats-console/shared",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./types": "./src/types/index.ts",
"./schemas": "./src/schemas/index.ts",
"./utils": "./src/utils/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src/",
"clean": "rm -rf dist"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.1",
"typescript": "^5.7.2"
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './types/index.js';
export * from './schemas/index.js';
export * from './utils/index.js';
+369
View File
@@ -0,0 +1,369 @@
import { z } from 'zod';
// ==================== Enums ====================
export const OrganizationPlanSchema = z.enum(['free', 'pro', 'enterprise']);
export const UserStatusSchema = z.enum(['active', 'inactive', 'suspended']);
export const MemberRoleSchema = z.enum(['owner', 'admin', 'member', 'viewer']);
export const ClusterEnvironmentSchema = z.enum(['development', 'staging', 'production']);
export const ClusterStatusSchema = z.enum(['connected', 'disconnected', 'degraded']);
export const HealthStatusSchema = z.enum(['healthy', 'unhealthy', 'unknown']);
export const AlertSeveritySchema = z.enum(['info', 'warning', 'critical']);
export const AlertEventStatusSchema = z.enum(['firing', 'resolved']);
export const AuditStatusSchema = z.enum(['success', 'failure', 'denied']);
// ==================== Auth Schemas ====================
export const LoginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export const RegisterSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
firstName: z.string().min(1, 'First name is required').max(100),
lastName: z.string().min(1, 'Last name is required').max(100),
organizationName: z.string().min(1, 'Organization name is required').max(100).optional(),
});
export const RefreshTokenSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
});
export const ForgotPasswordSchema = z.object({
email: z.string().email('Invalid email address'),
});
export const ResetPasswordSchema = z.object({
token: z.string().min(1, 'Reset token is required'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
});
export const MfaVerifySchema = z.object({
code: z.string().length(6, 'MFA code must be 6 digits'),
});
// ==================== User Schemas ====================
export const UpdateUserSchema = z.object({
firstName: z.string().min(1).max(100).optional(),
lastName: z.string().min(1).max(100).optional(),
avatarUrl: z.string().url().optional().nullable(),
});
export const InviteUserSchema = z.object({
email: z.string().email('Invalid email address'),
role: MemberRoleSchema,
teamIds: z.array(z.string().uuid()).optional(),
});
// ==================== Organization Schemas ====================
export const CreateOrganizationSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
slug: z.string()
.min(3, 'Slug must be at least 3 characters')
.max(50)
.regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens'),
});
export const UpdateOrganizationSchema = z.object({
name: z.string().min(1).max(100).optional(),
settings: z.record(z.unknown()).optional(),
});
// ==================== Team Schemas ====================
export const CreateTeamSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
});
export const UpdateTeamSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional().nullable(),
});
export const AddTeamMemberSchema = z.object({
userId: z.string().uuid(),
role: MemberRoleSchema,
});
// ==================== Cluster Schemas ====================
export const TlsConfigSchema = z.object({
enabled: z.boolean(),
certFile: z.string().optional(),
keyFile: z.string().optional(),
caFile: z.string().optional(),
skipVerify: z.boolean().optional(),
});
export const ClusterCredentialsSchema = z.object({
username: z.string().optional(),
password: z.string().optional(),
token: z.string().optional(),
nkey: z.string().optional(),
jwt: z.string().optional(),
credsFile: z.string().optional(),
});
export const CreateClusterSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
description: z.string().max(500).optional(),
environment: ClusterEnvironmentSchema,
serverUrl: z.string().url('Invalid server URL'),
credentials: ClusterCredentialsSchema.optional(),
tlsConfig: TlsConfigSchema.optional(),
});
export const UpdateClusterSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional().nullable(),
environment: ClusterEnvironmentSchema.optional(),
credentials: ClusterCredentialsSchema.optional(),
tlsConfig: TlsConfigSchema.optional(),
});
// ==================== Stream Schemas ====================
export const StreamPlacementSchema = z.object({
cluster: z.string().optional(),
tags: z.array(z.string()).optional(),
});
export const StreamMirrorSchema = z.object({
name: z.string().min(1),
optStartSeq: z.number().int().positive().optional(),
optStartTime: z.string().datetime().optional(),
filterSubject: z.string().optional(),
});
export const StreamSourceSchema = z.object({
name: z.string().min(1),
optStartSeq: z.number().int().positive().optional(),
optStartTime: z.string().datetime().optional(),
filterSubject: z.string().optional(),
});
export const CreateStreamSchema = z.object({
name: z.string()
.min(1, 'Name is required')
.max(256)
.regex(/^[A-Za-z0-9_-]+$/, 'Name can only contain letters, numbers, underscores, and hyphens'),
subjects: z.array(z.string().min(1)).min(1, 'At least one subject is required'),
retention: z.enum(['limits', 'interest', 'workqueue']).default('limits'),
maxConsumers: z.number().int().min(-1).default(-1),
maxMsgs: z.number().int().min(-1).default(-1),
maxBytes: z.number().int().min(-1).default(-1),
maxAge: z.number().int().min(0).default(0), // nanoseconds
maxMsgSize: z.number().int().min(-1).default(-1),
storage: z.enum(['file', 'memory']).default('file'),
replicas: z.number().int().min(1).max(5).default(1),
noAck: z.boolean().default(false),
discard: z.enum(['old', 'new']).default('old'),
duplicateWindow: z.number().int().min(0).default(120000000000), // 2 min in ns
placement: StreamPlacementSchema.optional(),
mirror: StreamMirrorSchema.optional(),
sources: z.array(StreamSourceSchema).optional(),
sealed: z.boolean().optional(),
denyDelete: z.boolean().optional(),
denyPurge: z.boolean().optional(),
allowRollup: z.boolean().optional(),
tags: z.array(z.string()).optional(),
});
export const UpdateStreamSchema = CreateStreamSchema.partial().omit({ name: true });
export const PurgeStreamSchema = z.object({
filter: z.string().optional(),
seq: z.number().int().positive().optional(),
keep: z.number().int().positive().optional(),
});
export const PublishMessageSchema = z.object({
subject: z.string().min(1, 'Subject is required'),
data: z.string(),
headers: z.record(z.string()).optional(),
});
export const GetMessagesSchema = z.object({
startSeq: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().min(1).max(1000).default(100),
subject: z.string().optional(),
});
// ==================== Consumer Schemas ====================
export const CreateConsumerSchema = z.object({
name: z.string()
.min(1, 'Name is required')
.max(256)
.regex(/^[A-Za-z0-9_-]+$/, 'Name can only contain letters, numbers, underscores, and hyphens'),
durableName: z.string().optional(),
description: z.string().max(500).optional(),
deliverPolicy: z.enum(['all', 'last', 'new', 'byStartSequence', 'byStartTime', 'lastPerSubject']).default('all'),
optStartSeq: z.number().int().positive().optional(),
optStartTime: z.string().datetime().optional(),
ackPolicy: z.enum(['none', 'all', 'explicit']).default('explicit'),
ackWait: z.number().int().min(0).default(30000000000), // 30s in ns
maxDeliver: z.number().int().min(-1).default(-1),
backoff: z.array(z.number().int().min(0)).optional(),
filterSubject: z.string().optional(),
filterSubjects: z.array(z.string()).optional(),
replayPolicy: z.enum(['instant', 'original']).default('instant'),
rateLimit: z.number().int().min(0).optional(),
sampleFreq: z.string().optional(),
maxWaiting: z.number().int().min(0).default(512),
maxAckPending: z.number().int().min(-1).default(1000),
headersOnly: z.boolean().optional(),
maxBatch: z.number().int().min(0).optional(),
maxExpires: z.number().int().min(0).optional(),
inactiveThreshold: z.number().int().min(0).optional(),
numReplicas: z.number().int().min(0).max(5).default(0),
memStorage: z.boolean().optional(),
tags: z.array(z.string()).optional(),
});
export const UpdateConsumerSchema = CreateConsumerSchema.partial().omit({ name: true, durableName: true });
// ==================== Dashboard Schemas ====================
export const DashboardWidgetSchema = z.object({
id: z.string().uuid(),
type: z.enum(['chart', 'stat', 'table', 'text']),
title: z.string().min(1).max(100),
x: z.number().int().min(0),
y: z.number().int().min(0),
w: z.number().int().min(1),
h: z.number().int().min(1),
config: z.record(z.unknown()),
});
export const CreateDashboardSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
layout: z.object({
columns: z.number().int().min(1).max(24).default(12),
rowHeight: z.number().int().min(20).max(200).default(80),
}).default({ columns: 12, rowHeight: 80 }),
widgets: z.array(DashboardWidgetSchema).default([]),
isShared: z.boolean().default(false),
});
export const UpdateDashboardSchema = CreateDashboardSchema.partial();
// ==================== Alert Schemas ====================
export const AlertConditionSchema = z.object({
metric: z.string().min(1),
operator: z.enum(['gt', 'lt', 'gte', 'lte', 'eq', 'neq']),
window: z.number().int().min(1), // seconds
aggregation: z.enum(['avg', 'min', 'max', 'sum', 'count']),
});
export const AlertThresholdSchema = z.object({
value: z.number(),
type: z.enum(['absolute', 'percentage']),
});
export const AlertChannelSchema = z.object({
type: z.enum(['email', 'slack', 'pagerduty', 'webhook']),
config: z.record(z.unknown()),
});
export const CreateAlertRuleSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
clusterId: z.string().uuid().optional().nullable(),
condition: AlertConditionSchema,
threshold: AlertThresholdSchema,
severity: AlertSeveritySchema.default('warning'),
channels: z.array(AlertChannelSchema).min(1, 'At least one notification channel is required'),
isEnabled: z.boolean().default(true),
cooldownMins: z.number().int().min(1).max(1440).default(5),
});
export const UpdateAlertRuleSchema = CreateAlertRuleSchema.partial();
// ==================== API Key Schemas ====================
export const CreateApiKeySchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
permissions: z.array(z.string()).min(1, 'At least one permission is required'),
expiresAt: z.string().datetime().optional().nullable(),
});
// ==================== Query Schemas ====================
export const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export const SortSchema = z.object({
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
export const DateRangeSchema = z.object({
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
});
export const MetricsQuerySchema = z.object({
clusterId: z.string().uuid().optional(),
streamName: z.string().optional(),
consumerName: z.string().optional(),
from: z.string().datetime(),
to: z.string().datetime(),
interval: z.enum(['1m', '5m', '15m', '1h', '6h', '1d']).default('5m'),
});
export const AuditLogQuerySchema = PaginationSchema.extend({
action: z.string().optional(),
resourceType: z.string().optional(),
userId: z.string().uuid().optional(),
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
});
// ==================== Type Exports ====================
export type LoginInput = z.infer<typeof LoginSchema>;
export type RegisterInput = z.infer<typeof RegisterSchema>;
export type RefreshTokenInput = z.infer<typeof RefreshTokenSchema>;
export type ForgotPasswordInput = z.infer<typeof ForgotPasswordSchema>;
export type ResetPasswordInput = z.infer<typeof ResetPasswordSchema>;
export type MfaVerifyInput = z.infer<typeof MfaVerifySchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
export type InviteUserInput = z.infer<typeof InviteUserSchema>;
export type CreateOrganizationInput = z.infer<typeof CreateOrganizationSchema>;
export type UpdateOrganizationInput = z.infer<typeof UpdateOrganizationSchema>;
export type CreateTeamInput = z.infer<typeof CreateTeamSchema>;
export type UpdateTeamInput = z.infer<typeof UpdateTeamSchema>;
export type AddTeamMemberInput = z.infer<typeof AddTeamMemberSchema>;
export type CreateClusterInput = z.infer<typeof CreateClusterSchema>;
export type UpdateClusterInput = z.infer<typeof UpdateClusterSchema>;
export type CreateStreamInput = z.infer<typeof CreateStreamSchema>;
export type UpdateStreamInput = z.infer<typeof UpdateStreamSchema>;
export type PurgeStreamInput = z.infer<typeof PurgeStreamSchema>;
export type PublishMessageInput = z.infer<typeof PublishMessageSchema>;
export type GetMessagesInput = z.infer<typeof GetMessagesSchema>;
export type CreateConsumerInput = z.infer<typeof CreateConsumerSchema>;
export type UpdateConsumerInput = z.infer<typeof UpdateConsumerSchema>;
export type CreateDashboardInput = z.infer<typeof CreateDashboardSchema>;
export type UpdateDashboardInput = z.infer<typeof UpdateDashboardSchema>;
export type CreateAlertRuleInput = z.infer<typeof CreateAlertRuleSchema>;
export type UpdateAlertRuleInput = z.infer<typeof UpdateAlertRuleSchema>;
export type CreateApiKeyInput = z.infer<typeof CreateApiKeySchema>;
export type PaginationInput = z.infer<typeof PaginationSchema>;
export type MetricsQueryInput = z.infer<typeof MetricsQuerySchema>;
export type AuditLogQueryInput = z.infer<typeof AuditLogQuerySchema>;
+557
View File
@@ -0,0 +1,557 @@
// ==================== Enums ====================
export type OrganizationPlan = 'free' | 'pro' | 'enterprise';
export type UserStatus = 'active' | 'inactive' | 'suspended';
export type MemberRole = 'owner' | 'admin' | 'member' | 'viewer';
export type ClusterEnvironment = 'development' | 'staging' | 'production';
export type ClusterStatus = 'connected' | 'disconnected' | 'degraded';
export type HealthStatus = 'healthy' | 'unhealthy' | 'unknown';
export type AlertSeverity = 'info' | 'warning' | 'critical';
export type AlertEventStatus = 'firing' | 'resolved';
export type AuditStatus = 'success' | 'failure' | 'denied';
// ==================== User & Organization ====================
export interface User {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
avatarUrl: string | null;
status: UserStatus;
emailVerified: boolean;
mfaEnabled: boolean;
lastLoginAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface Organization {
id: string;
name: string;
slug: string;
plan: OrganizationPlan;
settings: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
export interface OrganizationMember {
id: string;
orgId: string;
userId: string;
role: MemberRole;
invitedBy: string | null;
joinedAt: Date;
user?: User;
}
export interface Team {
id: string;
orgId: string;
name: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface TeamMember {
id: string;
teamId: string;
userId: string;
role: MemberRole;
addedAt: Date;
user?: User;
}
// ==================== NATS Cluster ====================
export interface NatsCluster {
id: string;
orgId: string;
name: string;
description: string | null;
environment: ClusterEnvironment;
status: ClusterStatus;
version: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface ClusterConnection {
id: string;
clusterId: string;
serverUrl: string;
credentials: EncryptedCredentials | null;
tlsConfig: TlsConfig | null;
isPrimary: boolean;
healthStatus: HealthStatus;
lastHealthCheck: Date | null;
}
export interface EncryptedCredentials {
username?: string;
password?: string;
token?: string;
nkey?: string;
jwt?: string;
credsFile?: string;
}
export interface TlsConfig {
enabled: boolean;
certFile?: string;
keyFile?: string;
caFile?: string;
skipVerify?: boolean;
}
// ==================== Streams & Consumers ====================
export interface StreamConfig {
id: string;
clusterId: string;
streamName: string;
configSnapshot: NatsStreamConfig;
createdBy: string;
isManaged: boolean;
tags: string[];
createdAt: Date;
updatedAt: Date;
}
export interface NatsStreamConfig {
name: string;
subjects: string[];
retention: 'limits' | 'interest' | 'workqueue';
maxConsumers: number;
maxMsgs: number;
maxBytes: number;
maxAge: number; // nanoseconds
maxMsgSize: number;
storage: 'file' | 'memory';
replicas: number;
noAck: boolean;
discard: 'old' | 'new';
duplicateWindow: number; // nanoseconds
placement?: {
cluster?: string;
tags?: string[];
};
mirror?: {
name: string;
optStartSeq?: number;
optStartTime?: string;
filterSubject?: string;
};
sources?: Array<{
name: string;
optStartSeq?: number;
optStartTime?: string;
filterSubject?: string;
}>;
sealed?: boolean;
denyDelete?: boolean;
denyPurge?: boolean;
allowRollup?: boolean;
}
export interface ConsumerConfig {
id: string;
streamConfigId: string;
consumerName: string;
configSnapshot: NatsConsumerConfig;
createdBy: string;
isManaged: boolean;
tags: string[];
createdAt: Date;
updatedAt: Date;
}
export interface NatsConsumerConfig {
name: string;
durableName?: string;
description?: string;
deliverPolicy: 'all' | 'last' | 'new' | 'byStartSequence' | 'byStartTime' | 'lastPerSubject';
optStartSeq?: number;
optStartTime?: string;
ackPolicy: 'none' | 'all' | 'explicit';
ackWait: number; // nanoseconds
maxDeliver: number;
backoff?: number[]; // nanoseconds
filterSubject?: string;
filterSubjects?: string[];
replayPolicy: 'instant' | 'original';
rateLimit?: number;
sampleFreq?: string;
maxWaiting: number;
maxAckPending: number;
headersOnly?: boolean;
maxBatch?: number;
maxExpires?: number; // nanoseconds
inactiveThreshold?: number; // nanoseconds
numReplicas: number;
memStorage?: boolean;
}
// ==================== Stream/Consumer Info (Runtime) ====================
export interface StreamInfo {
config: NatsStreamConfig;
created: Date;
state: StreamState;
cluster?: ClusterInfo;
mirror?: StreamSourceInfo;
sources?: StreamSourceInfo[];
}
export interface StreamState {
messages: number;
bytes: number;
firstSeq: number;
firstTs: Date;
lastSeq: number;
lastTs: Date;
numSubjects: number;
subjects?: Record<string, number>;
numDeleted: number;
deleted?: number[];
consumerCount: number;
}
export interface ClusterInfo {
name: string;
leader: string;
replicas?: PeerInfo[];
}
export interface PeerInfo {
name: string;
current: boolean;
offline: boolean;
active: number;
lag: number;
}
export interface StreamSourceInfo {
name: string;
lag: number;
active: number;
filterSubject?: string;
error?: string;
}
export interface ConsumerInfo {
name: string;
streamName: string;
created: Date;
config: NatsConsumerConfig;
delivered: SequenceInfo;
ackFloor: SequenceInfo;
numAckPending: number;
numRedelivered: number;
numWaiting: number;
numPending: number;
cluster?: ClusterInfo;
pushBound?: boolean;
}
export interface SequenceInfo {
consumerSeq: number;
streamSeq: number;
lastActive?: Date;
}
// ==================== Messages ====================
export interface StreamMessage {
subject: string;
sequence: number;
time: Date;
data: string;
headers?: Record<string, string[]>;
redelivered?: boolean;
}
// ==================== Roles & Permissions ====================
export interface Role {
id: string;
orgId: string;
name: string;
description: string | null;
isSystem: boolean;
createdAt: Date;
updatedAt: Date;
permissions?: Permission[];
}
export interface Permission {
id: string;
roleId: string;
resource: string;
action: string;
conditions: Record<string, unknown> | null;
}
// ==================== API Keys & Sessions ====================
export interface ApiKey {
id: string;
orgId: string;
userId: string;
name: string;
prefix: string;
permissions: string[];
lastUsedAt: Date | null;
expiresAt: Date | null;
createdAt: Date;
}
export interface Session {
id: string;
userId: string;
ipAddress: string | null;
userAgent: string | null;
expiresAt: Date;
createdAt: Date;
}
// ==================== Dashboards & Alerts ====================
export interface Dashboard {
id: string;
orgId: string;
userId: string;
name: string;
layout: DashboardLayout;
widgets: DashboardWidget[];
isShared: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface DashboardLayout {
columns: number;
rowHeight: number;
}
export interface DashboardWidget {
id: string;
type: 'chart' | 'stat' | 'table' | 'text';
title: string;
x: number;
y: number;
w: number;
h: number;
config: Record<string, unknown>;
}
export interface SavedQuery {
id: string;
orgId: string;
userId: string;
name: string;
query: string;
description: string | null;
isShared: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface AlertRule {
id: string;
orgId: string;
clusterId: string | null;
name: string;
condition: AlertCondition;
threshold: AlertThreshold;
severity: AlertSeverity;
channels: AlertChannel[];
isEnabled: boolean;
cooldownMins: number;
createdAt: Date;
updatedAt: Date;
}
export interface AlertCondition {
metric: string;
operator: 'gt' | 'lt' | 'gte' | 'lte' | 'eq' | 'neq';
window: number; // seconds
aggregation: 'avg' | 'min' | 'max' | 'sum' | 'count';
}
export interface AlertThreshold {
value: number;
type: 'absolute' | 'percentage';
}
export interface AlertChannel {
type: 'email' | 'slack' | 'pagerduty' | 'webhook';
config: Record<string, unknown>;
}
// ==================== Metrics (ClickHouse) ====================
export interface StreamMetrics {
clusterId: string;
streamName: string;
timestamp: Date;
messagesTotal: number;
bytesTotal: number;
messagesRate: number;
bytesRate: number;
consumerCount: number;
firstSeq: number;
lastSeq: number;
subjects: string[];
}
export interface ConsumerMetrics {
clusterId: string;
streamName: string;
consumerName: string;
timestamp: Date;
pendingCount: number;
ackPending: number;
redelivered: number;
waiting: number;
deliveredRate: number;
ackRate: number;
lag: number;
}
export interface ClusterMetrics {
clusterId: string;
serverId: string;
serverName: string;
timestamp: Date;
cpuPercent: number;
memoryBytes: number;
connections: number;
subscriptions: number;
slowConsumers: number;
inMsgs: number;
outMsgs: number;
inBytes: number;
outBytes: number;
}
// ==================== Audit Log ====================
export interface AuditLog {
id: string;
orgId: string;
userId: string;
userEmail: string;
timestamp: Date;
action: string;
resourceType: string;
resourceId: string;
resourceName: string;
clusterId: string | null;
ipAddress: string;
userAgent: string;
requestId: string;
changes: string;
status: AuditStatus;
errorMessage: string | null;
}
// ==================== API Response Types ====================
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, unknown>;
}
// ==================== Auth Types ====================
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface JwtPayload {
sub: string; // userId
email: string;
orgId: string;
role: MemberRole;
permissions: string[];
iat: number;
exp: number;
}
// ==================== WebSocket Types ====================
export interface WsMessage {
type: string;
data: unknown;
}
export interface WsSubscribe {
type: 'subscribe';
channels: string[];
}
export interface WsUnsubscribe {
type: 'unsubscribe';
channels: string[];
}
export interface WsClusterStatus {
type: 'cluster_status';
clusterId: string;
data: {
status: ClusterStatus;
servers: Array<{ name: string; connected: boolean }>;
timestamp: string;
};
}
export interface WsStreamMetrics {
type: 'stream_metrics';
clusterId: string;
streamName: string;
data: {
messages: number;
bytes: number;
rate: number;
consumers: number;
timestamp: string;
};
}
export interface WsConsumerMetrics {
type: 'consumer_metrics';
clusterId: string;
streamName: string;
consumerName: string;
data: {
pending: number;
lag: number;
ackRate: number;
timestamp: string;
};
}
export interface WsAlert {
type: 'alert';
data: {
id: string;
ruleId: string;
severity: AlertSeverity;
message: string;
timestamp: string;
};
}
+245
View File
@@ -0,0 +1,245 @@
// ==================== Time Utilities ====================
export const NS_PER_MS = 1_000_000;
export const NS_PER_SEC = 1_000_000_000;
export const NS_PER_MIN = NS_PER_SEC * 60;
export const NS_PER_HOUR = NS_PER_MIN * 60;
export const NS_PER_DAY = NS_PER_HOUR * 24;
export function msToNs(ms: number): number {
return ms * NS_PER_MS;
}
export function nsToMs(ns: number): number {
return Math.floor(ns / NS_PER_MS);
}
export function secToNs(sec: number): number {
return sec * NS_PER_SEC;
}
export function nsToSec(ns: number): number {
return Math.floor(ns / NS_PER_SEC);
}
export function formatDuration(ns: number): string {
if (ns < NS_PER_SEC) {
return `${nsToMs(ns)}ms`;
}
if (ns < NS_PER_MIN) {
return `${nsToSec(ns)}s`;
}
if (ns < NS_PER_HOUR) {
return `${Math.floor(ns / NS_PER_MIN)}m`;
}
if (ns < NS_PER_DAY) {
return `${Math.floor(ns / NS_PER_HOUR)}h`;
}
return `${Math.floor(ns / NS_PER_DAY)}d`;
}
export function parseDuration(duration: string): number {
const match = duration.match(/^(\d+)(ms|s|m|h|d)$/);
if (!match) {
throw new Error(`Invalid duration format: ${duration}`);
}
const value = parseInt(match[1]!, 10);
const unit = match[2];
switch (unit) {
case 'ms':
return msToNs(value);
case 's':
return secToNs(value);
case 'm':
return value * NS_PER_MIN;
case 'h':
return value * NS_PER_HOUR;
case 'd':
return value * NS_PER_DAY;
default:
throw new Error(`Unknown duration unit: ${unit}`);
}
}
// ==================== Byte Utilities ====================
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 B';
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return `${value.toFixed(decimals)} ${BYTE_UNITS[i]}`;
}
export function parseBytes(str: string): number {
const match = str.match(/^([\d.]+)\s*(B|KB|MB|GB|TB|PB)?$/i);
if (!match) {
throw new Error(`Invalid byte format: ${str}`);
}
const value = parseFloat(match[1]!);
const unit = (match[2] || 'B').toUpperCase();
const index = BYTE_UNITS.indexOf(unit);
if (index === -1) {
throw new Error(`Unknown byte unit: ${unit}`);
}
return Math.floor(value * Math.pow(1024, index));
}
// ==================== Number Formatting ====================
export function formatNumber(num: number): string {
if (num >= 1_000_000_000) {
return `${(num / 1_000_000_000).toFixed(1)}B`;
}
if (num >= 1_000_000) {
return `${(num / 1_000_000).toFixed(1)}M`;
}
if (num >= 1_000) {
return `${(num / 1_000).toFixed(1)}K`;
}
return num.toString();
}
export function formatRate(rate: number, unit = '/s'): string {
return `${formatNumber(rate)}${unit}`;
}
// ==================== String Utilities ====================
export function slugify(str: string): string {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
export function generateId(): string {
return crypto.randomUUID();
}
// ==================== Date Utilities ====================
export function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) {
return 'just now';
}
if (diffMin < 60) {
return `${diffMin}m ago`;
}
if (diffHour < 24) {
return `${diffHour}h ago`;
}
if (diffDay < 7) {
return `${diffDay}d ago`;
}
return date.toLocaleDateString();
}
// ==================== NATS Subject Utilities ====================
export function isValidSubject(subject: string): boolean {
if (!subject || subject.length === 0) return false;
// NATS subjects can contain alphanumeric, dots, asterisks, and greater-than
return /^[a-zA-Z0-9._*>-]+$/.test(subject);
}
export function matchSubject(pattern: string, subject: string): boolean {
// Convert NATS pattern to regex
const regexPattern = pattern
.replace(/\./g, '\\.')
.replace(/\*/g, '[^.]+')
.replace(/>/g, '.*');
return new RegExp(`^${regexPattern}$`).test(subject);
}
// ==================== Permission Utilities ====================
export function parsePermission(permission: string): {
resource: string;
action: string;
scope: string;
} {
const parts = permission.split(':');
return {
resource: parts[0] || '*',
action: parts[1] || '*',
scope: parts[2] || '*',
};
}
export function hasPermission(
userPermissions: string[],
requiredResource: string,
requiredAction: string
): boolean {
return userPermissions.some((perm) => {
const { resource, action } = parsePermission(perm);
const resourceMatch = resource === '*' || resource === requiredResource;
const actionMatch = action === '*' || action === requiredAction;
return resourceMatch && actionMatch;
});
}
// ==================== Error Utilities ====================
export class AppError extends Error {
constructor(
public code: string,
message: string,
public statusCode: number = 500,
public details?: Record<string, unknown>
) {
super(message);
this.name = 'AppError';
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id?: string) {
super(
'NOT_FOUND',
id ? `${resource} with id ${id} not found` : `${resource} not found`,
404
);
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super('UNAUTHORIZED', message, 401);
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super('FORBIDDEN', message, 403);
}
}
export class ValidationError extends AppError {
constructor(message: string, details?: Record<string, unknown>) {
super('VALIDATION_ERROR', message, 400, details);
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super('CONFLICT', message, 409);
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"noUncheckedIndexedAccess": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+3
View File
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"
+35
View File
@@ -0,0 +1,35 @@
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"]
},
"type-check": {
"dependsOn": ["^build"]
},
"test": {
"dependsOn": ["^build"]
},
"clean": {
"cache": false
},
"db:generate": {
"cache": false
},
"db:push": {
"cache": false
},
"db:migrate": {
"cache": false
}
}
}