From 22c1bc11348161e5a445afbd466090cc5db55d2d Mon Sep 17 00:00:00 2001 From: Fizer Khan Date: Thu, 4 Dec 2025 21:46:20 +0530 Subject: [PATCH] add api, web, worker. --- .gitignore | 41 ++ apps/api/.env.example | 34 ++ apps/api/package.json | 46 ++ apps/api/prisma/schema.prisma | 363 ++++++++++++ apps/api/src/common/middleware/auth.ts | 120 ++++ apps/api/src/config/index.ts | 54 ++ apps/api/src/index.ts | 144 +++++ apps/api/src/lib/clickhouse.ts | 349 +++++++++++ apps/api/src/lib/nats.ts | 449 ++++++++++++++ apps/api/src/lib/prisma.ts | 25 + apps/api/src/lib/redis.ts | 142 +++++ apps/api/src/modules/alerts/alerts.routes.ts | 125 ++++ .../src/modules/analytics/analytics.routes.ts | 119 ++++ apps/api/src/modules/auth/auth.routes.ts | 108 ++++ apps/api/src/modules/auth/auth.service.ts | 429 ++++++++++++++ .../src/modules/clusters/clusters.routes.ts | 82 +++ .../src/modules/clusters/clusters.service.ts | 380 ++++++++++++ .../src/modules/consumers/consumers.routes.ts | 96 +++ .../modules/consumers/consumers.service.ts | 225 +++++++ .../modules/dashboards/dashboards.routes.ts | 143 +++++ .../organizations/organizations.routes.ts | 137 +++++ .../api/src/modules/streams/streams.routes.ts | 150 +++++ .../src/modules/streams/streams.service.ts | 314 ++++++++++ apps/api/src/modules/teams/teams.routes.ts | 163 +++++ apps/api/src/modules/users/users.routes.ts | 142 +++++ apps/api/tsconfig.json | 21 + apps/web/app/(auth)/layout.tsx | 11 + apps/web/app/(auth)/login/page.tsx | 82 +++ apps/web/app/(auth)/register/page.tsx | 136 +++++ apps/web/app/(dashboard)/clusters/page.tsx | 115 ++++ apps/web/app/(dashboard)/layout.tsx | 34 ++ apps/web/app/(dashboard)/streams/page.tsx | 149 +++++ apps/web/app/globals.css | 85 +++ apps/web/app/layout.tsx | 25 + apps/web/app/page.tsx | 5 + apps/web/app/providers.tsx | 20 + apps/web/components/layout/sidebar.tsx | 90 +++ apps/web/components/ui/button.tsx | 47 ++ apps/web/components/ui/card.tsx | 49 ++ apps/web/components/ui/input.tsx | 22 + apps/web/lib/api.ts | 247 ++++++++ apps/web/lib/utils.ts | 6 + apps/web/next.config.ts | 7 + apps/web/package.json | 48 ++ apps/web/postcss.config.mjs | 8 + apps/web/stores/auth.ts | 133 +++++ apps/web/tailwind.config.ts | 72 +++ apps/web/tsconfig.json | 27 + apps/workers/.env.example | 21 + apps/workers/package.json | 31 + apps/workers/src/collectors/metrics.ts | 364 ++++++++++++ apps/workers/src/config.ts | 38 ++ apps/workers/src/index.ts | 69 +++ apps/workers/src/processors/alerts.ts | 349 +++++++++++ apps/workers/tsconfig.json | 20 + docker-compose.yml | 82 +++ infrastructure/clickhouse/init/01-schema.sql | 159 +++++ package.json | 27 + packages/shared/package.json | 25 + packages/shared/src/index.ts | 3 + packages/shared/src/schemas/index.ts | 369 ++++++++++++ packages/shared/src/types/index.ts | 557 ++++++++++++++++++ packages/shared/src/utils/index.ts | 245 ++++++++ packages/shared/tsconfig.json | 18 + pnpm-workspace.yaml | 3 + turbo.json | 35 ++ 66 files changed, 8234 insertions(+) create mode 100644 .gitignore create mode 100644 apps/api/.env.example create mode 100644 apps/api/package.json create mode 100644 apps/api/prisma/schema.prisma create mode 100644 apps/api/src/common/middleware/auth.ts create mode 100644 apps/api/src/config/index.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/lib/clickhouse.ts create mode 100644 apps/api/src/lib/nats.ts create mode 100644 apps/api/src/lib/prisma.ts create mode 100644 apps/api/src/lib/redis.ts create mode 100644 apps/api/src/modules/alerts/alerts.routes.ts create mode 100644 apps/api/src/modules/analytics/analytics.routes.ts create mode 100644 apps/api/src/modules/auth/auth.routes.ts create mode 100644 apps/api/src/modules/auth/auth.service.ts create mode 100644 apps/api/src/modules/clusters/clusters.routes.ts create mode 100644 apps/api/src/modules/clusters/clusters.service.ts create mode 100644 apps/api/src/modules/consumers/consumers.routes.ts create mode 100644 apps/api/src/modules/consumers/consumers.service.ts create mode 100644 apps/api/src/modules/dashboards/dashboards.routes.ts create mode 100644 apps/api/src/modules/organizations/organizations.routes.ts create mode 100644 apps/api/src/modules/streams/streams.routes.ts create mode 100644 apps/api/src/modules/streams/streams.service.ts create mode 100644 apps/api/src/modules/teams/teams.routes.ts create mode 100644 apps/api/src/modules/users/users.routes.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/app/(auth)/layout.tsx create mode 100644 apps/web/app/(auth)/login/page.tsx create mode 100644 apps/web/app/(auth)/register/page.tsx create mode 100644 apps/web/app/(dashboard)/clusters/page.tsx create mode 100644 apps/web/app/(dashboard)/layout.tsx create mode 100644 apps/web/app/(dashboard)/streams/page.tsx create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/app/providers.tsx create mode 100644 apps/web/components/layout/sidebar.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/input.tsx create mode 100644 apps/web/lib/api.ts create mode 100644 apps/web/lib/utils.ts create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/stores/auth.ts create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/workers/.env.example create mode 100644 apps/workers/package.json create mode 100644 apps/workers/src/collectors/metrics.ts create mode 100644 apps/workers/src/config.ts create mode 100644 apps/workers/src/index.ts create mode 100644 apps/workers/src/processors/alerts.ts create mode 100644 apps/workers/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 infrastructure/clickhouse/init/01-schema.sql create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/schemas/index.ts create mode 100644 packages/shared/src/types/index.ts create mode 100644 packages/shared/src/utils/index.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-workspace.yaml create mode 100644 turbo.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fc73f8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..ccdb191 --- /dev/null +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..5f8fd06 --- /dev/null +++ b/apps/api/package.json @@ -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" + } +} diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..0b03932 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -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") +} diff --git a/apps/api/src/common/middleware/auth.ts b/apps/api/src/common/middleware/auth.ts new file mode 100644 index 0000000..651d0f3 --- /dev/null +++ b/apps/api/src/common/middleware/auth.ts @@ -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 { + 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 { + 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 => { + 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; +} diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts new file mode 100644 index 0000000..83975ba --- /dev/null +++ b/apps/api/src/config/index.ts @@ -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; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..482de5f --- /dev/null +++ b/apps/api/src/index.ts @@ -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(); diff --git a/apps/api/src/lib/clickhouse.ts b/apps/api/src/lib/clickhouse.ts new file mode 100644 index 0000000..31b4db7 --- /dev/null +++ b/apps/api/src/lib/clickhouse.ts @@ -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 { + if (client) { + await client.close(); + client = null; + } +} + +// ==================== Stream Metrics ==================== + +export async function insertStreamMetrics(metrics: StreamMetrics[]): Promise { + 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 { + 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(); + 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 { + 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 { + 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(); + 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 { + 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): Promise { + 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 = { 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(); + 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; + } +} diff --git a/apps/api/src/lib/nats.ts b/apps/api/src/lib/nats.ts new file mode 100644 index 0000000..9a43b5f --- /dev/null +++ b/apps/api/src/lib/nats.ts @@ -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(); +const jsmPool = new Map(); +const jsPool = new Map(); + +// 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 { + 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 { + 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 { + 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 { + // Close existing connection if any + await disconnectCluster(clusterId); + + const options: Record = { + 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 { + 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 { + 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 { + 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 { + const jsm = getClusterJetStreamManager(clusterId); + return jsm.streams.info(streamName); +} + +export async function createStream(clusterId: string, config: Partial): Promise { + const jsm = getClusterJetStreamManager(clusterId); + return jsm.streams.add(config); +} + +export async function updateStream(clusterId: string, config: Partial): Promise { + const jsm = getClusterJetStreamManager(clusterId); + return jsm.streams.update(config.name!, config); +} + +export async function deleteStream(clusterId: string, streamName: string): Promise { + 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 { + 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 { + const jsm = getClusterJetStreamManager(clusterId); + return jsm.consumers.info(streamName, consumerName); +} + +export async function createConsumer( + clusterId: string, + streamName: string, + config: Partial +): Promise { + const jsm = getClusterJetStreamManager(clusterId); + return jsm.consumers.add(streamName, config); +} + +export async function updateConsumer( + clusterId: string, + streamName: string, + config: Partial +): Promise { + 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 { + 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 +): Promise { + const js = getClusterJetStream(clusterId); + + const msgHeaders = headers + ? Object.entries(headers).reduce((h, [k, v]) => { + h.set(k, v); + return h; + }, new Map()) + : 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/apps/api/src/lib/prisma.ts b/apps/api/src/lib/prisma.ts new file mode 100644 index 0000000..9945df8 --- /dev/null +++ b/apps/api/src/lib/prisma.ts @@ -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 { + await prisma.$connect(); +} + +export async function disconnectDatabase(): Promise { + await prisma.$disconnect(); +} diff --git a/apps/api/src/lib/redis.ts b/apps/api/src/lib/redis.ts new file mode 100644 index 0000000..0e02ea2 --- /dev/null +++ b/apps/api/src/lib/redis.ts @@ -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 { + await redis.hset(`${SESSION_PREFIX}${sessionId}`, data as Record); + await redis.expire(`${SESSION_PREFIX}${sessionId}`, SESSION_TTL); +} + +export async function getSession(sessionId: string): Promise { + 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 { + await redis.del(`${SESSION_PREFIX}${sessionId}`); +} + +export async function updateSessionActivity(sessionId: string): Promise { + 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 { + await redis.hset(`${CLUSTER_STATUS_PREFIX}${clusterId}:status`, data as Record); + await redis.expire(`${CLUSTER_STATUS_PREFIX}${clusterId}:status`, CLUSTER_STATUS_TTL); +} + +export async function getClusterStatus(clusterId: string): Promise { + 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 { + 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 { + 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 { + await redis.del(`${PERMISSIONS_PREFIX}${userId}:${orgId}`); +} diff --git a/apps/api/src/modules/alerts/alerts.routes.ts b/apps/api/src/modules/alerts/alerts.routes.ts new file mode 100644 index 0000000..4f40505 --- /dev/null +++ b/apps/api/src/modules/alerts/alerts.routes.ts @@ -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' }; + }); +}; diff --git a/apps/api/src/modules/analytics/analytics.routes.ts b/apps/api/src/modules/analytics/analytics.routes.ts new file mode 100644 index 0000000..98968b1 --- /dev/null +++ b/apps/api/src/modules/analytics/analytics.routes.ts @@ -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, + }; + } + ); +}; diff --git a/apps/api/src/modules/auth/auth.routes.ts b/apps/api/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..765ff17 --- /dev/null +++ b/apps/api/src/modules/auth/auth.routes.ts @@ -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 }; + }); +}; diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..1a608ae --- /dev/null +++ b/apps/api/src/modules/auth/auth.service.ts @@ -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 { + return argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, + }); +} + +export async function verifyPassword(hash: string, password: string): Promise { + return argon2.verify(hash, password); +} + +// ==================== JWT Operations ==================== + +export async function generateAccessToken(payload: Omit): Promise { + return new SignJWT(payload as unknown as Record) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(config.JWT_ACCESS_EXPIRY) + .sign(JWT_SECRET); +} + +export async function generateRefreshToken(userId: string): Promise { + 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 { + 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 { + 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 { + const sessionId = accessToken.split('.')[2]; + if (sessionId) { + await deleteSession(sessionId); + } +} + +// ==================== Get Current User ==================== + +export async function getCurrentUser(userId: string): Promise { + 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 { + 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 { + 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 { + // TODO: Implement TOTP verification + throw new Error('MFA not implemented yet'); +} + +export async function disableMfa(userId: string): Promise { + 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 { + // 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, + }; +} diff --git a/apps/api/src/modules/clusters/clusters.routes.ts b/apps/api/src/modules/clusters/clusters.routes.ts new file mode 100644 index 0000000..a7af145 --- /dev/null +++ b/apps/api/src/modules/clusters/clusters.routes.ts @@ -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 }; + }); +}; diff --git a/apps/api/src/modules/clusters/clusters.service.ts b/apps/api/src/modules/clusters/clusters.service.ts new file mode 100644 index 0000000..4f89204 --- /dev/null +++ b/apps/api/src/modules/clusters/clusters.service.ts @@ -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 { + const clusters = await prisma.natsCluster.findMany({ + where: { orgId }, + orderBy: { createdAt: 'desc' }, + }); + + return clusters.map(mapCluster); +} + +export async function getCluster(orgId: string, clusterId: string): Promise { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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>; + 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, + }; +} diff --git a/apps/api/src/modules/consumers/consumers.routes.ts b/apps/api/src/modules/consumers/consumers.routes.ts new file mode 100644 index 0000000..f985a1e --- /dev/null +++ b/apps/api/src/modules/consumers/consumers.routes.ts @@ -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 }; + } + ); +}; diff --git a/apps/api/src/modules/consumers/consumers.service.ts b/apps/api/src/modules/consumers/consumers.service.ts new file mode 100644 index 0000000..18ca4d6 --- /dev/null +++ b/apps/api/src/modules/consumers/consumers.service.ts @@ -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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 = { + 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 = { + none: 'none', + all: 'all', + explicit: 'explicit', + }; + return map[policy] || 'explicit'; +} + +function mapReplayPolicy(policy: string): any { + const map: Record = { + instant: 'instant', + original: 'original', + }; + return map[policy] || 'instant'; +} diff --git a/apps/api/src/modules/dashboards/dashboards.routes.ts b/apps/api/src/modules/dashboards/dashboards.routes.ts new file mode 100644 index 0000000..b667574 --- /dev/null +++ b/apps/api/src/modules/dashboards/dashboards.routes.ts @@ -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 }); + }); +}; diff --git a/apps/api/src/modules/organizations/organizations.routes.ts b/apps/api/src/modules/organizations/organizations.routes.ts new file mode 100644 index 0000000..7f7cbf8 --- /dev/null +++ b/apps/api/src/modules/organizations/organizations.routes.ts @@ -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, + }, + })), + }; + }); +}; diff --git a/apps/api/src/modules/streams/streams.routes.ts b/apps/api/src/modules/streams/streams.routes.ts new file mode 100644 index 0000000..383a65a --- /dev/null +++ b/apps/api/src/modules/streams/streams.routes.ts @@ -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 }>( + '/: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(); + } + ); +}; diff --git a/apps/api/src/modules/streams/streams.service.ts b/apps/api/src/modules/streams/streams.service.ts new file mode 100644 index 0000000..a5b3f22 --- /dev/null +++ b/apps/api/src/modules/streams/streams.service.ts @@ -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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 +): 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 { + // 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); +} diff --git a/apps/api/src/modules/teams/teams.routes.ts b/apps/api/src/modules/teams/teams.routes.ts new file mode 100644 index 0000000..4f36b58 --- /dev/null +++ b/apps/api/src/modules/teams/teams.routes.ts @@ -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(); + } + ); +}; diff --git a/apps/api/src/modules/users/users.routes.ts b/apps/api/src/modules/users/users.routes.ts new file mode 100644 index 0000000..f71e757 --- /dev/null +++ b/apps/api/src/modules/users/users.routes.ts @@ -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 }); + }); +}; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..f05df34 --- /dev/null +++ b/apps/api/tsconfig.json @@ -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"] +} diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx new file mode 100644 index 0000000..e87517e --- /dev/null +++ b/apps/web/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx new file mode 100644 index 0000000..911fc1e --- /dev/null +++ b/apps/web/app/(auth)/login/page.tsx @@ -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 ( + + + Sign in + Enter your credentials to access NATS Console + +
+ + {error && ( +
{error}
+ )} +
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + +

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+ ); +} diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx new file mode 100644 index 0000000..ada66f2 --- /dev/null +++ b/apps/web/app/(auth)/register/page.tsx @@ -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 ( + + + Create an account + Enter your details to get started with NATS Console + +
+ + {error && ( +
{error}
+ )} +
+
+ + setFormData({ ...formData, firstName: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, lastName: e.target.value })} + required + /> +
+
+
+ + setFormData({ ...formData, email: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, password: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, confirmPassword: e.target.value })} + required + /> +
+
+ + +

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/clusters/page.tsx b/apps/web/app/(dashboard)/clusters/page.tsx new file mode 100644 index 0000000..bbb12b4 --- /dev/null +++ b/apps/web/app/(dashboard)/clusters/page.tsx @@ -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 ; + case 'disconnected': + return ; + case 'degraded': + return ; + default: + return ; + } + }; + + const getEnvironmentBadge = (env: string) => { + const colors: Record = { + production: 'bg-red-100 text-red-700', + staging: 'bg-yellow-100 text-yellow-700', + development: 'bg-green-100 text-green-700', + }; + return ( + + {env} + + ); + }; + + return ( +
+
+
+

Clusters

+

Manage your NATS JetStream clusters

+
+ +
+ + {isLoading && ( +
+
+
+ )} + + {error && ( + + +

Failed to load clusters

+
+
+ )} + + {data?.clusters && data.clusters.length === 0 && ( + + + +

No clusters yet

+

+ Add your first NATS cluster to get started +

+ +
+
+ )} + + {data?.clusters && data.clusters.length > 0 && ( +
+ {data.clusters.map((cluster: any) => ( + + +
+ {getStatusIcon(cluster.status)} +
+ {cluster.name} + {cluster.description || 'No description'} +
+
+ +
+ +
+ {getEnvironmentBadge(cluster.environment)} + + {cluster.version || 'Unknown version'} + +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..bdc9ee4 --- /dev/null +++ b/apps/web/app/(dashboard)/layout.tsx @@ -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 ( +
+ +
+
{children}
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/streams/page.tsx b/apps/web/app/(dashboard)/streams/page.tsx new file mode 100644 index 0000000..e80a750 --- /dev/null +++ b/apps/web/app/(dashboard)/streams/page.tsx @@ -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(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 ( +
+
+
+

Streams

+

Manage JetStream streams

+
+ +
+ +
+ +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + {!selectedCluster && ( + + + +

Select a cluster

+

Choose a cluster to view its streams

+
+
+ )} + + {selectedCluster && isLoading && ( +
+
+
+ )} + + {selectedCluster && filteredStreams && filteredStreams.length === 0 && ( + + + +

No streams found

+

+ {search ? 'No streams match your search' : 'Create your first stream to get started'} +

+ {!search && ( + + )} +
+
+ )} + + {filteredStreams && filteredStreams.length > 0 && ( +
+ + + + + + + + + + + + + {filteredStreams.map((stream: any) => ( + + + + + + + + + ))} + +
NameSubjectsMessagesSizeConsumersStorage
{stream.config.name} + {stream.config.subjects?.join(', ') || '-'} + {formatNumber(stream.state?.messages || 0)}{formatBytes(stream.state?.bytes || 0)}{stream.state?.consumer_count || 0} + + {stream.config.storage} + +
+
+ )} +
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..762289c --- /dev/null +++ b/apps/web/app/globals.css @@ -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; + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..e5a1966 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..1413e02 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + redirect('/login'); +} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx new file mode 100644 index 0000000..ed6f9e1 --- /dev/null +++ b/apps/web/app/providers.tsx @@ -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 {children}; +} diff --git a/apps/web/components/layout/sidebar.tsx b/apps/web/components/layout/sidebar.tsx new file mode 100644 index 0000000..0d568fa --- /dev/null +++ b/apps/web/components/layout/sidebar.tsx @@ -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 ( +
+ {/* Logo */} +
+ +
+ NC +
+ NATS Console + +
+ + {/* Navigation */} + + + {/* User section */} +
+
+
+ + {user?.firstName?.[0]} + {user?.lastName?.[0]} + +
+
+

+ {user?.firstName} {user?.lastName} +

+

{user?.email}

+
+
+ +
+
+ ); +} diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx new file mode 100644 index 0000000..495fea6 --- /dev/null +++ b/apps/web/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ; + } +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/apps/web/components/ui/card.tsx b/apps/web/components/ui/card.tsx new file mode 100644 index 0000000..b9b352d --- /dev/null +++ b/apps/web/components/ui/card.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) =>
+); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/apps/web/components/ui/input.tsx b/apps/web/components/ui/input.tsx new file mode 100644 index 0000000..3de1e6a --- /dev/null +++ b/apps/web/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + } +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts new file mode 100644 index 0000000..bb9f73e --- /dev/null +++ b/apps/web/lib/api.ts @@ -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; +} + +class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string + ) { + super(message); + this.name = 'ApiError'; + } +} + +async function request(endpoint: string, options: ApiOptions = {}): Promise { + 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(`/clusters/${id}/health`), + + info: (id: string) => request(`/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) => { + 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) => { + const query = `?${new URLSearchParams(params)}`; + return request<{ metrics: any[]; type: string }>(`/analytics${query}`); + }, + + streamThroughput: (name: string, params: Record) => { + const query = `?${new URLSearchParams(params)}`; + return request(`/analytics/streams/${name}/throughput${query}`); + }, + + consumerLag: (name: string, params: Record) => { + const query = `?${new URLSearchParams(params)}`; + return request(`/analytics/consumers/${name}/lag${query}`); + }, + + clusterOverview: (clusterId?: string) => { + const query = clusterId ? `?clusterId=${clusterId}` : ''; + return request(`/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) => { + 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, +}; diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts new file mode 100644 index 0000000..9ad0df4 --- /dev/null +++ b/apps/web/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..f58fe8e --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + transpilePackages: ['@nats-console/shared'], +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..0bd17df --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..a982c64 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,8 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/apps/web/stores/auth.ts b/apps/web/stores/auth.ts new file mode 100644 index 0000000..7964e2c --- /dev/null +++ b/apps/web/stores/auth.ts @@ -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; + register: (data: { email: string; password: string; firstName: string; lastName: string }) => Promise; + logout: () => Promise; + refreshTokens: () => Promise; + setUser: (user: User) => void; +} + +export const useAuthStore = create()( + 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, + }), + } + ) +); diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..e37451f --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -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; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..d8b9323 --- /dev/null +++ b/apps/web/tsconfig.json @@ -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"] +} diff --git a/apps/workers/.env.example b/apps/workers/.env.example new file mode 100644 index 0000000..2a46a50 --- /dev/null +++ b/apps/workers/.env.example @@ -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 diff --git a/apps/workers/package.json b/apps/workers/package.json new file mode 100644 index 0000000..3eedf31 --- /dev/null +++ b/apps/workers/package.json @@ -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" + } +} diff --git a/apps/workers/src/collectors/metrics.ts b/apps/workers/src/collectors/metrics.ts new file mode 100644 index 0000000..aff1b10 --- /dev/null +++ b/apps/workers/src/collectors/metrics.ts @@ -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 = new Map(); + private streamMetricsInterval: NodeJS.Timeout | null = null; + private clusterMetricsInterval: NodeJS.Timeout | null = null; + private previousStreamStats: Map = + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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'); + } + } + } +} diff --git a/apps/workers/src/config.ts b/apps/workers/src/config.ts new file mode 100644 index 0000000..cf6ff6d --- /dev/null +++ b/apps/workers/src/config.ts @@ -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(); diff --git a/apps/workers/src/index.ts b/apps/workers/src/index.ts new file mode 100644 index 0000000..2057377 --- /dev/null +++ b/apps/workers/src/index.ts @@ -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 { + 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 { + 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(); diff --git a/apps/workers/src/processors/alerts.ts b/apps/workers/src/processors/alerts.ts new file mode 100644 index 0000000..346ad51 --- /dev/null +++ b/apps/workers/src/processors/alerts.ts @@ -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 }>; + 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }, + rule: AlertRule, + metricValue: number, + status: 'firing' | 'resolved' + ): Promise { + 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): Promise { + 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'); + } + } +} diff --git a/apps/workers/tsconfig.json b/apps/workers/tsconfig.json new file mode 100644 index 0000000..253c9c2 --- /dev/null +++ b/apps/workers/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..07e3bc2 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/infrastructure/clickhouse/init/01-schema.sql b/infrastructure/clickhouse/init/01-schema.sql new file mode 100644 index 0000000..4a7fb7e --- /dev/null +++ b/infrastructure/clickhouse/init/01-schema.sql @@ -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; diff --git a/package.json b/package.json new file mode 100644 index 0000000..8f08131 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..dd2f422 --- /dev/null +++ b/packages/shared/package.json @@ -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" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..f406c9d --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export * from './types/index.js'; +export * from './schemas/index.js'; +export * from './utils/index.js'; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts new file mode 100644 index 0000000..6c91e8b --- /dev/null +++ b/packages/shared/src/schemas/index.ts @@ -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; +export type RegisterInput = z.infer; +export type RefreshTokenInput = z.infer; +export type ForgotPasswordInput = z.infer; +export type ResetPasswordInput = z.infer; +export type MfaVerifyInput = z.infer; +export type UpdateUserInput = z.infer; +export type InviteUserInput = z.infer; +export type CreateOrganizationInput = z.infer; +export type UpdateOrganizationInput = z.infer; +export type CreateTeamInput = z.infer; +export type UpdateTeamInput = z.infer; +export type AddTeamMemberInput = z.infer; +export type CreateClusterInput = z.infer; +export type UpdateClusterInput = z.infer; +export type CreateStreamInput = z.infer; +export type UpdateStreamInput = z.infer; +export type PurgeStreamInput = z.infer; +export type PublishMessageInput = z.infer; +export type GetMessagesInput = z.infer; +export type CreateConsumerInput = z.infer; +export type UpdateConsumerInput = z.infer; +export type CreateDashboardInput = z.infer; +export type UpdateDashboardInput = z.infer; +export type CreateAlertRuleInput = z.infer; +export type UpdateAlertRuleInput = z.infer; +export type CreateApiKeyInput = z.infer; +export type PaginationInput = z.infer; +export type MetricsQueryInput = z.infer; +export type AuditLogQueryInput = z.infer; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts new file mode 100644 index 0000000..5509eed --- /dev/null +++ b/packages/shared/src/types/index.ts @@ -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; + 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; + 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; + 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 | 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; +} + +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; +} + +// ==================== 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 { + data: T[]; + total: number; + page: number; + pageSize: number; + hasMore: boolean; +} + +export interface ApiError { + code: string; + message: string; + details?: Record; +} + +// ==================== 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; + }; +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts new file mode 100644 index 0000000..bbf581b --- /dev/null +++ b/packages/shared/src/utils/index.ts @@ -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 + ) { + 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) { + super('VALIDATION_ERROR', message, 400, details); + } +} + +export class ConflictError extends AppError { + constructor(message: string) { + super('CONFLICT', message, 409); + } +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..88aa9da --- /dev/null +++ b/packages/shared/tsconfig.json @@ -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"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..5de0b73 --- /dev/null +++ b/turbo.json @@ -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 + } + } +}