mirror of
https://github.com/KLogicHQ/nats-console.git
synced 2026-10-04 20:41:29 +00:00
add api, web, worker.
This commit is contained in:
+41
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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't have an account?{' '}
|
||||
<Link href="/register" className="text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect('/login');
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@nats-console/shared'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './types/index.js';
|
||||
export * from './schemas/index.js';
|
||||
export * from './utils/index.js';
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
+35
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user