mirror of
https://github.com/KLogicHQ/nats-console.git
synced 2026-10-11 23:41:41 +00:00
fix analytics
add 2fa add multi line charts.
This commit is contained in:
@@ -31,7 +31,9 @@
|
||||
"ioredis": "^5.4.1",
|
||||
"jose": "^5.9.6",
|
||||
"nats": "^2.28.2",
|
||||
"otpauth": "^9.3.5",
|
||||
"pino": "^9.5.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { insertAuditLog } from '../../lib/clickhouse';
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({ name: 'audit-middleware' });
|
||||
|
||||
// Map HTTP methods to actions
|
||||
const METHOD_ACTION_MAP: Record<string, string> = {
|
||||
GET: 'read',
|
||||
POST: 'create',
|
||||
PUT: 'update',
|
||||
PATCH: 'update',
|
||||
DELETE: 'delete',
|
||||
};
|
||||
|
||||
// Routes that should be audited (patterns)
|
||||
const AUDITED_ROUTES: Array<{
|
||||
pattern: RegExp;
|
||||
resourceType: string;
|
||||
extractResourceInfo: (url: string, body?: unknown) => { resourceId?: string; resourceName?: string };
|
||||
}> = [
|
||||
{
|
||||
pattern: /^\/clusters\/([^/]+)$/,
|
||||
resourceType: 'cluster',
|
||||
extractResourceInfo: (url) => {
|
||||
const match = url.match(/^\/clusters\/([^/]+)$/);
|
||||
return { resourceId: match?.[1] };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/clusters\/([^/]+)\/streams\/([^/]+)$/,
|
||||
resourceType: 'stream',
|
||||
extractResourceInfo: (url) => {
|
||||
const match = url.match(/^\/clusters\/([^/]+)\/streams\/([^/]+)$/);
|
||||
return { resourceId: match?.[1], resourceName: match?.[2] };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/clusters\/([^/]+)\/streams$/,
|
||||
resourceType: 'stream',
|
||||
extractResourceInfo: (_url, body) => {
|
||||
const b = body as { name?: string } | undefined;
|
||||
return { resourceName: b?.name };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/clusters\/([^/]+)\/streams\/([^/]+)\/consumers\/([^/]+)$/,
|
||||
resourceType: 'consumer',
|
||||
extractResourceInfo: (url) => {
|
||||
const match = url.match(/^\/clusters\/([^/]+)\/streams\/([^/]+)\/consumers\/([^/]+)$/);
|
||||
return { resourceId: match?.[1], resourceName: match?.[3] };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/clusters\/([^/]+)\/streams\/([^/]+)\/consumers$/,
|
||||
resourceType: 'consumer',
|
||||
extractResourceInfo: (_url, body) => {
|
||||
const b = body as { name?: string } | undefined;
|
||||
return { resourceName: b?.name };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/alerts\/rules\/([^/]+)$/,
|
||||
resourceType: 'alert_rule',
|
||||
extractResourceInfo: (url) => {
|
||||
const match = url.match(/^\/alerts\/rules\/([^/]+)$/);
|
||||
return { resourceId: match?.[1] };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/alerts\/channels\/([^/]+)$/,
|
||||
resourceType: 'notification_channel',
|
||||
extractResourceInfo: (url) => {
|
||||
const match = url.match(/^\/alerts\/channels\/([^/]+)$/);
|
||||
return { resourceId: match?.[1] };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/dashboards\/([^/]+)$/,
|
||||
resourceType: 'dashboard',
|
||||
extractResourceInfo: (url) => {
|
||||
const match = url.match(/^\/dashboards\/([^/]+)$/);
|
||||
return { resourceId: match?.[1] };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/organizations\/([^/]+)$/,
|
||||
resourceType: 'organization',
|
||||
extractResourceInfo: (url) => {
|
||||
const match = url.match(/^\/organizations\/([^/]+)$/);
|
||||
return { resourceId: match?.[1] };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/teams\/([^/]+)$/,
|
||||
resourceType: 'team',
|
||||
extractResourceInfo: (url) => {
|
||||
const match = url.match(/^\/teams\/([^/]+)$/);
|
||||
return { resourceId: match?.[1] };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/invites$/,
|
||||
resourceType: 'invite',
|
||||
extractResourceInfo: (_url, body) => {
|
||||
const b = body as { email?: string } | undefined;
|
||||
return { resourceName: b?.email };
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /^\/settings\/api-keys\/([^/]+)$/,
|
||||
resourceType: 'api_key',
|
||||
extractResourceInfo: (url) => {
|
||||
const match = url.match(/^\/settings\/api-keys\/([^/]+)$/);
|
||||
return { resourceId: match?.[1] };
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Skip audit for read operations (GET) except for specific sensitive endpoints
|
||||
const SKIP_READ_AUDIT = true;
|
||||
|
||||
interface AuditInfo {
|
||||
resourceType: string;
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
clusterId?: string;
|
||||
}
|
||||
|
||||
function getAuditInfo(url: string, body?: unknown): AuditInfo | null {
|
||||
// Remove /api/v1 prefix if present
|
||||
const path = url.replace(/^\/api\/v1/, '');
|
||||
|
||||
for (const route of AUDITED_ROUTES) {
|
||||
if (route.pattern.test(path)) {
|
||||
const info = route.extractResourceInfo(path, body);
|
||||
|
||||
// Extract cluster ID from URL if present
|
||||
const clusterMatch = path.match(/^\/clusters\/([^/]+)/);
|
||||
|
||||
return {
|
||||
resourceType: route.resourceType,
|
||||
resourceId: info.resourceId,
|
||||
resourceName: info.resourceName,
|
||||
clusterId: clusterMatch?.[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function auditLogger(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
done: () => void
|
||||
): Promise<void> {
|
||||
// Skip if no user (unauthenticated request)
|
||||
if (!request.user) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const method = request.method;
|
||||
const action = METHOD_ACTION_MAP[method] || method.toLowerCase();
|
||||
|
||||
// Skip read operations if configured
|
||||
if (SKIP_READ_AUDIT && action === 'read') {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get audit info for this route
|
||||
const auditInfo = getAuditInfo(request.url, request.body);
|
||||
if (!auditInfo) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
// Store start time for latency calculation
|
||||
const startTime = Date.now();
|
||||
|
||||
// Add hook to log after response
|
||||
reply.addHook('onSend', async (_request, _reply, payload) => {
|
||||
try {
|
||||
const statusCode = reply.statusCode;
|
||||
const status = statusCode >= 200 && statusCode < 400 ? 'success' : 'failure';
|
||||
|
||||
await insertAuditLog({
|
||||
orgId: request.user!.orgId,
|
||||
userId: request.user!.sub,
|
||||
userEmail: request.user!.email,
|
||||
timestamp: new Date(),
|
||||
action,
|
||||
resourceType: auditInfo.resourceType,
|
||||
resourceId: auditInfo.resourceId || '',
|
||||
resourceName: auditInfo.resourceName || '',
|
||||
clusterId: auditInfo.clusterId || null,
|
||||
ipAddress: request.ip || '',
|
||||
userAgent: request.headers['user-agent'] || '',
|
||||
requestId: request.id,
|
||||
changes: method !== 'GET' && request.body ? request.body as Record<string, unknown> : {},
|
||||
status,
|
||||
errorMessage: status === 'failure' && typeof payload === 'string'
|
||||
? JSON.parse(payload)?.error?.message
|
||||
: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to insert audit log');
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
// Fastify plugin for audit logging
|
||||
export async function auditPlugin(fastify: any): Promise<void> {
|
||||
fastify.addHook('preHandler', auditLogger);
|
||||
}
|
||||
@@ -1,21 +1,113 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { verifyToken } from '../../modules/auth/auth.service';
|
||||
import * as argon2 from 'argon2';
|
||||
import { verifyToken, getPermissionsForRole } from '../../modules/auth/auth.service';
|
||||
import { getSession, updateSessionActivity } from '../../lib/redis';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import type { JwtPayload } from '../../../../shared/src/index';
|
||||
|
||||
// Extend FastifyRequest to include user
|
||||
// Extend FastifyRequest to include user and apiKeyId
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user?: JwtPayload;
|
||||
apiKeyId?: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the token is an API key (starts with nats_)
|
||||
function isApiKey(token: string): boolean {
|
||||
return token.startsWith('nats_');
|
||||
}
|
||||
|
||||
// Validate API key and return user context
|
||||
async function validateApiKey(
|
||||
apiKey: string
|
||||
): Promise<{ payload: JwtPayload; keyId: string } | null> {
|
||||
// Extract the actual key (remove nats_ prefix)
|
||||
const rawKey = apiKey.slice(5);
|
||||
const prefix = rawKey.substring(0, 8);
|
||||
|
||||
// Find API keys with matching prefix
|
||||
const candidates = await prisma.apiKey.findMany({
|
||||
where: {
|
||||
prefix,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
organizationMemberships: {
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the key hash
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const isValid = await argon2.verify(candidate.keyHash, rawKey);
|
||||
if (isValid) {
|
||||
// Update last used timestamp
|
||||
await prisma.apiKey.update({
|
||||
where: { id: candidate.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
|
||||
const membership = candidate.user.organizationMemberships[0];
|
||||
if (!membership) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build JWT-like payload for API key
|
||||
const permissions =
|
||||
candidate.permissions.length > 0
|
||||
? candidate.permissions
|
||||
: getPermissionsForRole(membership.role);
|
||||
|
||||
return {
|
||||
payload: {
|
||||
sub: candidate.userId,
|
||||
email: candidate.user.email,
|
||||
orgId: membership.orgId,
|
||||
role: membership.role as 'owner' | 'admin' | 'member' | 'viewer',
|
||||
permissions,
|
||||
},
|
||||
keyId: candidate.id,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Hash verification failed, continue to next candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function authenticate(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const authHeader = request.headers.authorization;
|
||||
const apiKeyHeader = request.headers['x-api-key'] as string | undefined;
|
||||
|
||||
// Check for API key in X-API-Key header first
|
||||
if (apiKeyHeader && isApiKey(apiKeyHeader)) {
|
||||
const result = await validateApiKey(apiKeyHeader);
|
||||
if (result) {
|
||||
request.user = result.payload;
|
||||
request.apiKeyId = result.keyId;
|
||||
return;
|
||||
}
|
||||
return reply.status(401).send({
|
||||
error: {
|
||||
code: 'INVALID_API_KEY',
|
||||
message: 'Invalid or expired API key',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check for Bearer token (could be JWT or API key)
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return reply.status(401).send({
|
||||
error: {
|
||||
@@ -27,6 +119,23 @@ export async function authenticate(
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
|
||||
// Check if it's an API key
|
||||
if (isApiKey(token)) {
|
||||
const result = await validateApiKey(token);
|
||||
if (result) {
|
||||
request.user = result.payload;
|
||||
request.apiKeyId = result.keyId;
|
||||
return;
|
||||
}
|
||||
return reply.status(401).send({
|
||||
error: {
|
||||
code: 'INVALID_API_KEY',
|
||||
message: 'Invalid or expired API key',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Regular JWT token
|
||||
try {
|
||||
const payload = await verifyToken(token);
|
||||
request.user = payload;
|
||||
|
||||
@@ -35,6 +35,13 @@ const envSchema = z.object({
|
||||
// Rate limiting (only applies in production, disabled in development)
|
||||
RATE_LIMIT_MAX: z.coerce.number().default(1000), // 1000 requests per window in production
|
||||
RATE_LIMIT_WINDOW: z.coerce.number().default(60000), // 1 minute
|
||||
|
||||
// Email (Resend)
|
||||
RESEND_API_KEY: z.string().optional(),
|
||||
EMAIL_FROM: z.string().default('NATS Console <noreply@nats-console.local>'),
|
||||
|
||||
// Frontend URL (for links in emails)
|
||||
FRONTEND_URL: z.string().default('http://localhost:3000'),
|
||||
});
|
||||
|
||||
function loadConfig() {
|
||||
|
||||
@@ -24,6 +24,8 @@ import { alertRoutes } from './modules/alerts/alerts.routes';
|
||||
import { dashboardRoutes } from './modules/dashboards/dashboards.routes';
|
||||
import { inviteRoutes } from './modules/invites/invites.routes';
|
||||
import { settingsRoutes } from './modules/settings/settings.routes';
|
||||
import { auditRoutes } from './modules/audit/audit.routes';
|
||||
import { auditPlugin } from './common/middleware/audit';
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -77,6 +79,9 @@ app.get('/health', async () => {
|
||||
// API version prefix
|
||||
app.register(
|
||||
async (api) => {
|
||||
// Register audit logging middleware
|
||||
await api.register(auditPlugin);
|
||||
|
||||
// Register all routes
|
||||
await api.register(authRoutes, { prefix: '/auth' });
|
||||
await api.register(organizationRoutes, { prefix: '/organizations' });
|
||||
@@ -90,6 +95,7 @@ app.register(
|
||||
await api.register(dashboardRoutes, { prefix: '/dashboards' });
|
||||
await api.register(inviteRoutes, { prefix: '/invites' });
|
||||
await api.register(settingsRoutes, { prefix: '/settings' });
|
||||
await api.register(auditRoutes, { prefix: '/audit' });
|
||||
},
|
||||
{ prefix: '/api/v1' }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { config } from '../config/index';
|
||||
import pino from 'pino';
|
||||
|
||||
const logger = pino({ name: 'email-service' });
|
||||
|
||||
interface EmailOptions {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
||||
const apiKey = config.RESEND_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn('RESEND_API_KEY not configured, skipping email');
|
||||
// In development, log the email content
|
||||
if (config.NODE_ENV === 'development') {
|
||||
logger.info({ ...options }, 'Email would be sent (dev mode)');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: config.EMAIL_FROM || 'NATS Console <noreply@nats-console.local>',
|
||||
to: Array.isArray(options.to) ? options.to : [options.to],
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
logger.error({ error, status: response.status }, 'Failed to send email');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info({ to: options.to, subject: options.subject }, 'Email sent successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Error sending email');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendInviteEmail(
|
||||
email: string,
|
||||
inviteToken: string,
|
||||
inviterName: string,
|
||||
organizationName: string
|
||||
): Promise<boolean> {
|
||||
const inviteUrl = `${config.FRONTEND_URL}/invite/${inviteToken}`;
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: `You've been invited to join ${organizationName} on NATS Console`,
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #0066cc; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }
|
||||
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>You're invited to join ${organizationName}</h2>
|
||||
<p>${inviterName} has invited you to join their organization on NATS Console.</p>
|
||||
<p>NATS Console is a management platform for NATS JetStream clusters, providing real-time monitoring, stream and consumer management, and alerting.</p>
|
||||
<a href="${inviteUrl}" class="button">Accept Invitation</a>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p><a href="${inviteUrl}">${inviteUrl}</a></p>
|
||||
<p>This invitation will expire in 7 days.</p>
|
||||
<div class="footer">
|
||||
<p>If you didn't expect this invitation, you can safely ignore this email.</p>
|
||||
<p>NATS Console</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
text: `
|
||||
You're invited to join ${organizationName}
|
||||
|
||||
${inviterName} has invited you to join their organization on NATS Console.
|
||||
|
||||
Accept the invitation: ${inviteUrl}
|
||||
|
||||
This invitation will expire in 7 days.
|
||||
|
||||
If you didn't expect this invitation, you can safely ignore this email.
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(
|
||||
email: string,
|
||||
resetToken: string,
|
||||
userName?: string
|
||||
): Promise<boolean> {
|
||||
const resetUrl = `${config.FRONTEND_URL}/reset-password?token=${resetToken}`;
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: 'Reset your NATS Console password',
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #0066cc; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }
|
||||
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Reset your password</h2>
|
||||
<p>Hi${userName ? ` ${userName}` : ''},</p>
|
||||
<p>We received a request to reset your password for your NATS Console account.</p>
|
||||
<a href="${resetUrl}" class="button">Reset Password</a>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p><a href="${resetUrl}">${resetUrl}</a></p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
<div class="footer">
|
||||
<p>If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.</p>
|
||||
<p>NATS Console</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
text: `
|
||||
Reset your password
|
||||
|
||||
Hi${userName ? ` ${userName}` : ''},
|
||||
|
||||
We received a request to reset your password for your NATS Console account.
|
||||
|
||||
Reset your password: ${resetUrl}
|
||||
|
||||
This link will expire in 1 hour.
|
||||
|
||||
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendWelcomeEmail(
|
||||
email: string,
|
||||
firstName: string
|
||||
): Promise<boolean> {
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: 'Welcome to NATS Console',
|
||||
html: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.button { display: inline-block; padding: 12px 24px; background-color: #0066cc; color: white; text-decoration: none; border-radius: 6px; margin: 20px 0; }
|
||||
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Welcome to NATS Console!</h2>
|
||||
<p>Hi ${firstName},</p>
|
||||
<p>Thank you for signing up for NATS Console. We're excited to have you on board!</p>
|
||||
<p>With NATS Console, you can:</p>
|
||||
<ul>
|
||||
<li>Manage your NATS JetStream clusters</li>
|
||||
<li>Monitor streams and consumers in real-time</li>
|
||||
<li>Set up alerts for critical metrics</li>
|
||||
<li>Browse and publish messages</li>
|
||||
</ul>
|
||||
<a href="${config.FRONTEND_URL}/clusters" class="button">Get Started</a>
|
||||
<div class="footer">
|
||||
<p>If you have any questions, feel free to reach out to our support team.</p>
|
||||
<p>NATS Console</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
text: `
|
||||
Welcome to NATS Console!
|
||||
|
||||
Hi ${firstName},
|
||||
|
||||
Thank you for signing up for NATS Console. We're excited to have you on board!
|
||||
|
||||
With NATS Console, you can:
|
||||
- Manage your NATS JetStream clusters
|
||||
- Monitor streams and consumers in real-time
|
||||
- Set up alerts for critical metrics
|
||||
- Browse and publish messages
|
||||
|
||||
Get started: ${config.FRONTEND_URL}/clusters
|
||||
|
||||
If you have any questions, feel free to reach out to our support team.
|
||||
`,
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { authenticate } from '../../common/middleware/auth';
|
||||
import { IncidentStatus } from '@prisma/client';
|
||||
import { getClickHouseClient } from '../../lib/clickhouse';
|
||||
|
||||
// Incident Schemas
|
||||
const UpdateIncidentSchema = z.object({
|
||||
@@ -276,8 +277,145 @@ export const alertRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
throw new NotFoundError('Notification channel', request.params.id);
|
||||
}
|
||||
|
||||
// TODO: Implement actual test notification
|
||||
return { success: true, message: 'Test notification sent' };
|
||||
const config = channel.config as Record<string, unknown>;
|
||||
const testMessage = {
|
||||
rule: 'Test Alert Rule',
|
||||
severity: 'info',
|
||||
status: 'test',
|
||||
metricValue: 42,
|
||||
threshold: 100,
|
||||
message: 'This is a test notification from NATS Console',
|
||||
incidentId: 'test-' + Date.now(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
switch (channel.type) {
|
||||
case 'webhook': {
|
||||
const url = config.url as string;
|
||||
if (!url) throw new Error('Webhook URL not configured');
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(testMessage),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Webhook returned ${response.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'slack': {
|
||||
const webhookUrl = config.webhookUrl as string;
|
||||
if (!webhookUrl) throw new Error('Slack webhook URL not configured');
|
||||
const payload = {
|
||||
attachments: [{
|
||||
color: '#3b82f6',
|
||||
blocks: [
|
||||
{ type: 'header', text: { type: 'plain_text', text: '🧪 Test Notification', emoji: true } },
|
||||
{ type: 'section', text: { type: 'mrkdwn', text: 'This is a test notification from NATS Console. Your Slack integration is working correctly!' } },
|
||||
],
|
||||
}],
|
||||
};
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Slack returned ${response.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'email': {
|
||||
const apiKey = (config.apiKey as string) || process.env.RESEND_API_KEY;
|
||||
const recipients = config.recipients as string[];
|
||||
if (!apiKey) throw new Error('Email API key not configured');
|
||||
if (!recipients?.length) throw new Error('No email recipients configured');
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
from: (config.fromEmail as string) || 'alerts@nats-console.local',
|
||||
to: recipients,
|
||||
subject: '🧪 Test Notification - NATS Console',
|
||||
html: '<h2>Test Notification</h2><p>This is a test notification from NATS Console. Your email integration is working correctly!</p>',
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Email API returned ${response.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'teams': {
|
||||
const webhookUrl = config.webhookUrl as string;
|
||||
if (!webhookUrl) throw new Error('Teams webhook URL not configured');
|
||||
const payload = {
|
||||
'@type': 'MessageCard',
|
||||
'@context': 'http://schema.org/extensions',
|
||||
themeColor: '3b82f6',
|
||||
summary: 'Test Notification',
|
||||
sections: [{
|
||||
activityTitle: '🧪 Test Notification',
|
||||
text: 'This is a test notification from NATS Console. Your Teams integration is working correctly!',
|
||||
}],
|
||||
};
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Teams returned ${response.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'pagerduty': {
|
||||
const routingKey = config.routingKey as string;
|
||||
if (!routingKey) throw new Error('PagerDuty routing key not configured');
|
||||
const payload = {
|
||||
routing_key: routingKey,
|
||||
event_action: 'trigger',
|
||||
dedup_key: `nats-console-test-${Date.now()}`,
|
||||
payload: {
|
||||
summary: 'Test notification from NATS Console',
|
||||
severity: 'info',
|
||||
source: 'NATS Console',
|
||||
custom_details: { test: true },
|
||||
},
|
||||
};
|
||||
const response = await fetch('https://events.pagerduty.com/v2/enqueue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(`PagerDuty returned ${response.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'google_chat': {
|
||||
const webhookUrl = config.webhookUrl as string;
|
||||
if (!webhookUrl) throw new Error('Google Chat webhook URL not configured');
|
||||
const payload = {
|
||||
cards: [{
|
||||
header: { title: '🧪 Test Notification', subtitle: 'NATS Console' },
|
||||
sections: [{
|
||||
widgets: [{ textParagraph: { text: 'This is a test notification. Your Google Chat integration is working correctly!' } }],
|
||||
}],
|
||||
}],
|
||||
};
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Google Chat returned ${response.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown channel type: ${channel.type}`);
|
||||
}
|
||||
|
||||
return { success: true, message: 'Test notification sent successfully' };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Failed to send test notification' };
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Incidents ====================
|
||||
@@ -460,14 +598,184 @@ export const alertRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.get<{
|
||||
Querystring: { ruleId?: string; from?: string; to?: string; limit?: string };
|
||||
}>('/events', async (request) => {
|
||||
// TODO: Query from ClickHouse
|
||||
return { events: [] };
|
||||
const { ruleId, from, to, limit = '100' } = request.query;
|
||||
const ch = getClickHouseClient();
|
||||
|
||||
const conditions = ['org_id = {orgId:UUID}'];
|
||||
const params: Record<string, unknown> = { orgId: request.user!.orgId };
|
||||
|
||||
if (ruleId) {
|
||||
conditions.push('alert_rule_id = {ruleId:UUID}');
|
||||
params.ruleId = ruleId;
|
||||
}
|
||||
|
||||
if (from) {
|
||||
conditions.push('timestamp >= {from:DateTime64(3)}');
|
||||
params.from = new Date(from).toISOString();
|
||||
}
|
||||
|
||||
if (to) {
|
||||
conditions.push('timestamp <= {to:DateTime64(3)}');
|
||||
params.to = new Date(to).toISOString();
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' AND ');
|
||||
|
||||
try {
|
||||
const result = await ch.query({
|
||||
query: `
|
||||
SELECT
|
||||
id,
|
||||
org_id,
|
||||
alert_rule_id,
|
||||
cluster_id,
|
||||
timestamp,
|
||||
severity,
|
||||
status,
|
||||
metric_value,
|
||||
threshold_value,
|
||||
message,
|
||||
notified_at,
|
||||
resolved_at
|
||||
FROM alert_events
|
||||
WHERE ${whereClause}
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ${parseInt(limit)}
|
||||
`,
|
||||
query_params: params,
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const rows = await result.json() as Record<string, unknown>[];
|
||||
const events = rows.map((row) => ({
|
||||
id: row.id,
|
||||
orgId: row.org_id,
|
||||
ruleId: row.alert_rule_id,
|
||||
clusterId: row.cluster_id,
|
||||
timestamp: row.timestamp,
|
||||
severity: row.severity,
|
||||
status: row.status,
|
||||
metricValue: Number(row.metric_value),
|
||||
thresholdValue: Number(row.threshold_value),
|
||||
message: row.message,
|
||||
notifiedAt: row.notified_at,
|
||||
resolvedAt: row.resolved_at,
|
||||
}));
|
||||
|
||||
return { events };
|
||||
} catch (err) {
|
||||
fastify.log.error({ err }, 'Error querying alert events');
|
||||
return { events: [] };
|
||||
}
|
||||
});
|
||||
|
||||
// POST /alerts/test - Test alert rule
|
||||
fastify.post('/test', async (request) => {
|
||||
// POST /alerts/test - Test alert rule evaluation
|
||||
fastify.post<{ Body: z.infer<typeof CreateAlertRuleSchema> }>('/test', async (request) => {
|
||||
const body = CreateAlertRuleSchema.parse(request.body);
|
||||
// TODO: Implement test alert
|
||||
return { success: true, message: 'Test alert sent' };
|
||||
const ch = getClickHouseClient();
|
||||
|
||||
const { metric, aggregation, window } = body.condition;
|
||||
const parts = metric.split('.');
|
||||
const metricType = parts[0];
|
||||
|
||||
const windowStart = new Date(Date.now() - window * 1000);
|
||||
const windowEnd = new Date();
|
||||
|
||||
try {
|
||||
let metricValue: number | null = null;
|
||||
|
||||
if (metricType === 'stream' && parts.length >= 3) {
|
||||
const streamName = parts[1];
|
||||
const metricName = parts[2];
|
||||
|
||||
const result = await ch.query({
|
||||
query: `
|
||||
SELECT ${aggregation}(${metricName}) as value
|
||||
FROM stream_metrics
|
||||
WHERE stream_name = {streamName:String}
|
||||
${body.clusterId ? 'AND cluster_id = {clusterId:UUID}' : ''}
|
||||
AND timestamp >= {from:DateTime64(3)}
|
||||
AND timestamp <= {to:DateTime64(3)}
|
||||
`,
|
||||
query_params: {
|
||||
streamName,
|
||||
clusterId: body.clusterId,
|
||||
from: windowStart.toISOString(),
|
||||
to: windowEnd.toISOString(),
|
||||
},
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const rows = await result.json() as { value: number }[];
|
||||
metricValue = rows[0]?.value ?? null;
|
||||
} else if (metricType === 'consumer' && parts.length >= 4) {
|
||||
const streamName = parts[1];
|
||||
const consumerName = parts[2];
|
||||
const metricName = parts[3];
|
||||
|
||||
const result = await ch.query({
|
||||
query: `
|
||||
SELECT ${aggregation}(${metricName}) as value
|
||||
FROM consumer_metrics
|
||||
WHERE stream_name = {streamName:String}
|
||||
AND consumer_name = {consumerName:String}
|
||||
${body.clusterId ? 'AND cluster_id = {clusterId:UUID}' : ''}
|
||||
AND timestamp >= {from:DateTime64(3)}
|
||||
AND timestamp <= {to:DateTime64(3)}
|
||||
`,
|
||||
query_params: {
|
||||
streamName,
|
||||
consumerName,
|
||||
clusterId: body.clusterId,
|
||||
from: windowStart.toISOString(),
|
||||
to: windowEnd.toISOString(),
|
||||
},
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const rows = await result.json() as { value: number }[];
|
||||
metricValue = rows[0]?.value ?? null;
|
||||
}
|
||||
|
||||
if (metricValue === null) {
|
||||
return {
|
||||
success: true,
|
||||
wouldFire: false,
|
||||
message: 'No metric data available for the specified window',
|
||||
metricValue: null,
|
||||
threshold: body.threshold.value,
|
||||
};
|
||||
}
|
||||
|
||||
// Check threshold
|
||||
const { operator } = body.condition;
|
||||
let wouldFire = false;
|
||||
switch (operator) {
|
||||
case 'gt': wouldFire = metricValue > body.threshold.value; break;
|
||||
case 'lt': wouldFire = metricValue < body.threshold.value; break;
|
||||
case 'gte': wouldFire = metricValue >= body.threshold.value; break;
|
||||
case 'lte': wouldFire = metricValue <= body.threshold.value; break;
|
||||
case 'eq': wouldFire = metricValue === body.threshold.value; break;
|
||||
case 'neq': wouldFire = metricValue !== body.threshold.value; break;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
wouldFire,
|
||||
message: wouldFire
|
||||
? `Alert would fire: ${metricValue} ${operator} ${body.threshold.value}`
|
||||
: `Alert would not fire: ${metricValue} ${operator} ${body.threshold.value}`,
|
||||
metricValue,
|
||||
threshold: body.threshold.value,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
wouldFire: false,
|
||||
message: err.message || 'Failed to evaluate alert rule',
|
||||
metricValue: null,
|
||||
threshold: body.threshold.value,
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -121,4 +121,205 @@ export const analyticsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return queryOverviewMetrics(clusterId, timeRange || '1h');
|
||||
}
|
||||
);
|
||||
|
||||
// GET /analytics/charts/throughput - Throughput over time for charts
|
||||
fastify.get<{ Querystring: { clusterId: string; timeRange?: string } }>(
|
||||
'/charts/throughput',
|
||||
async (request) => {
|
||||
const { clusterId, timeRange = '24h' } = request.query;
|
||||
|
||||
// Parse time range
|
||||
const rangeHours: Record<string, number> = {
|
||||
'1h': 1,
|
||||
'6h': 6,
|
||||
'24h': 24,
|
||||
'7d': 168,
|
||||
'30d': 720,
|
||||
};
|
||||
const hours = rangeHours[timeRange] || 24;
|
||||
const interval = hours <= 6 ? '5m' : hours <= 24 ? '15m' : hours <= 168 ? '1h' : '6h';
|
||||
|
||||
const from = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
const to = new Date();
|
||||
|
||||
// Get all streams for the cluster
|
||||
const { prisma } = await import('../../lib/prisma');
|
||||
const streams = await prisma.stream.findMany({
|
||||
where: { clusterId },
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
if (streams.length === 0) {
|
||||
return { data: [], interval };
|
||||
}
|
||||
|
||||
// Query aggregated throughput for all streams
|
||||
const { getClickHouseClient } = await import('../../lib/clickhouse');
|
||||
const ch = getClickHouseClient();
|
||||
|
||||
const intervalSeconds = interval === '5m' ? 300 : interval === '15m' ? 900 : interval === '1h' ? 3600 : 21600;
|
||||
|
||||
try {
|
||||
const result = await ch.query({
|
||||
query: `
|
||||
SELECT
|
||||
toStartOfInterval(timestamp, INTERVAL ${intervalSeconds} SECOND) as time,
|
||||
sum(messages_rate) as messages_per_sec
|
||||
FROM stream_metrics
|
||||
WHERE cluster_id = {clusterId:UUID}
|
||||
AND timestamp >= {from:DateTime64(3)}
|
||||
AND timestamp <= {to:DateTime64(3)}
|
||||
GROUP BY time
|
||||
ORDER BY time ASC
|
||||
`,
|
||||
query_params: {
|
||||
clusterId,
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
},
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const rows = (await result.json()) as Array<{ time: string; messages_per_sec: string }>;
|
||||
|
||||
return {
|
||||
data: rows.map((row) => ({
|
||||
name: 'Throughput',
|
||||
value: Math.round(Number(row.messages_per_sec) * 100) / 100,
|
||||
time: new Date(row.time).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(hours > 24 ? { month: 'short', day: 'numeric' } : {}),
|
||||
}),
|
||||
})),
|
||||
interval,
|
||||
};
|
||||
} catch {
|
||||
return { data: [], interval };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /analytics/charts/consumer-lag - Consumer lag by consumer
|
||||
fastify.get<{ Querystring: { clusterId: string; timeRange?: string } }>(
|
||||
'/charts/consumer-lag',
|
||||
async (request) => {
|
||||
const { clusterId, timeRange = '24h' } = request.query;
|
||||
|
||||
const rangeHours: Record<string, number> = {
|
||||
'1h': 1,
|
||||
'6h': 6,
|
||||
'24h': 24,
|
||||
'7d': 168,
|
||||
'30d': 720,
|
||||
};
|
||||
const hours = rangeHours[timeRange] || 24;
|
||||
|
||||
const { getClickHouseClient } = await import('../../lib/clickhouse');
|
||||
const ch = getClickHouseClient();
|
||||
|
||||
try {
|
||||
const result = await ch.query({
|
||||
query: `
|
||||
SELECT
|
||||
consumer_name as name,
|
||||
avg(pending_count) as pending
|
||||
FROM consumer_metrics
|
||||
WHERE cluster_id = {clusterId:UUID}
|
||||
AND timestamp >= now() - INTERVAL ${hours} HOUR
|
||||
GROUP BY consumer_name
|
||||
ORDER BY pending DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
query_params: { clusterId },
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const rows = (await result.json()) as Array<{ name: string; pending: string }>;
|
||||
|
||||
return {
|
||||
data: rows.map((row) => ({
|
||||
name: row.name,
|
||||
value: Math.round(Number(row.pending)),
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { data: [] };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /analytics/charts/stream-activity - Messages by stream over time
|
||||
fastify.get<{ Querystring: { clusterId: string; timeRange?: string } }>(
|
||||
'/charts/stream-activity',
|
||||
async (request) => {
|
||||
const { clusterId, timeRange = '24h' } = request.query;
|
||||
|
||||
const rangeHours: Record<string, number> = {
|
||||
'1h': 1,
|
||||
'6h': 6,
|
||||
'24h': 24,
|
||||
'7d': 168,
|
||||
'30d': 720,
|
||||
};
|
||||
const hours = rangeHours[timeRange] || 24;
|
||||
const interval = hours <= 6 ? '5m' : hours <= 24 ? '15m' : hours <= 168 ? '1h' : '6h';
|
||||
const intervalSeconds = interval === '5m' ? 300 : interval === '15m' ? 900 : interval === '1h' ? 3600 : 21600;
|
||||
|
||||
const from = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
const to = new Date();
|
||||
|
||||
const { getClickHouseClient } = await import('../../lib/clickhouse');
|
||||
const ch = getClickHouseClient();
|
||||
|
||||
try {
|
||||
const result = await ch.query({
|
||||
query: `
|
||||
SELECT
|
||||
stream_name,
|
||||
toStartOfInterval(timestamp, INTERVAL ${intervalSeconds} SECOND) as time,
|
||||
avg(messages_rate) as messages_rate
|
||||
FROM stream_metrics
|
||||
WHERE cluster_id = {clusterId:UUID}
|
||||
AND timestamp >= {from:DateTime64(3)}
|
||||
AND timestamp <= {to:DateTime64(3)}
|
||||
GROUP BY stream_name, time
|
||||
ORDER BY stream_name, time ASC
|
||||
`,
|
||||
query_params: {
|
||||
clusterId,
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
},
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
|
||||
const rows = (await result.json()) as Array<{
|
||||
stream_name: string;
|
||||
time: string;
|
||||
messages_rate: string;
|
||||
}>;
|
||||
|
||||
// Group by stream
|
||||
const streams: Record<string, Array<{ time: string; value: number }>> = {};
|
||||
for (const row of rows) {
|
||||
if (!streams[row.stream_name]) {
|
||||
streams[row.stream_name] = [];
|
||||
}
|
||||
streams[row.stream_name].push({
|
||||
time: new Date(row.time).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
...(hours > 24 ? { month: 'short', day: 'numeric' } : {}),
|
||||
}),
|
||||
value: Math.round(Number(row.messages_rate) * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
return { streams, interval };
|
||||
} catch {
|
||||
return { streams: {}, interval };
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { queryAuditLogs } from '../../lib/clickhouse';
|
||||
import { authenticate } from '../../common/middleware/auth';
|
||||
import { hasPermission } from '../auth/auth.service';
|
||||
import { ForbiddenError } from '../../../../shared/src/index';
|
||||
|
||||
const QueryLogsSchema = z.object({
|
||||
from: z.string().datetime().optional(),
|
||||
to: z.string().datetime().optional(),
|
||||
action: z.enum(['create', 'update', 'delete', 'read']).optional(),
|
||||
resourceType: z.string().optional(),
|
||||
userId: z.string().uuid().optional(),
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
||||
offset: z.coerce.number().min(0).optional().default(0),
|
||||
});
|
||||
|
||||
export const auditRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
fastify.addHook('preHandler', authenticate);
|
||||
|
||||
// GET /audit/logs - List audit logs
|
||||
fastify.get<{
|
||||
Querystring: z.infer<typeof QueryLogsSchema>;
|
||||
}>('/logs', async (request) => {
|
||||
// Check if user has permission to view audit logs (admin/owner only)
|
||||
if (!hasPermission(request.user!.permissions, 'settings', 'read')) {
|
||||
throw new ForbiddenError('You do not have permission to view audit logs');
|
||||
}
|
||||
|
||||
const query = QueryLogsSchema.parse(request.query);
|
||||
|
||||
const { logs, total } = await queryAuditLogs(request.user!.orgId, {
|
||||
from: query.from ? new Date(query.from) : undefined,
|
||||
to: query.to ? new Date(query.to) : undefined,
|
||||
action: query.action,
|
||||
resourceType: query.resourceType,
|
||||
userId: query.userId,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
|
||||
return {
|
||||
logs,
|
||||
pagination: {
|
||||
total,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
hasMore: query.offset + logs.length < total,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// GET /audit/logs/actions - Get distinct action types
|
||||
fastify.get('/logs/actions', async () => {
|
||||
return {
|
||||
actions: ['create', 'update', 'delete'],
|
||||
};
|
||||
});
|
||||
|
||||
// GET /audit/logs/resource-types - Get distinct resource types
|
||||
fastify.get('/logs/resource-types', async () => {
|
||||
return {
|
||||
resourceTypes: [
|
||||
'cluster',
|
||||
'stream',
|
||||
'consumer',
|
||||
'alert_rule',
|
||||
'notification_channel',
|
||||
'dashboard',
|
||||
'organization',
|
||||
'team',
|
||||
'invite',
|
||||
'api_key',
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// GET /audit/logs/export - Export audit logs
|
||||
fastify.get<{
|
||||
Querystring: z.infer<typeof QueryLogsSchema> & { format?: 'json' | 'csv' };
|
||||
}>('/logs/export', async (request, reply) => {
|
||||
// Check if user has permission to view audit logs
|
||||
if (!hasPermission(request.user!.permissions, 'settings', 'read')) {
|
||||
throw new ForbiddenError('You do not have permission to export audit logs');
|
||||
}
|
||||
|
||||
const { format = 'json', ...rest } = request.query;
|
||||
const query = QueryLogsSchema.parse(rest);
|
||||
|
||||
const { logs } = await queryAuditLogs(request.user!.orgId, {
|
||||
from: query.from ? new Date(query.from) : undefined,
|
||||
to: query.to ? new Date(query.to) : undefined,
|
||||
action: query.action,
|
||||
resourceType: query.resourceType,
|
||||
userId: query.userId,
|
||||
limit: 10000, // Max export limit
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
if (format === 'csv') {
|
||||
const headers = [
|
||||
'timestamp',
|
||||
'action',
|
||||
'resource_type',
|
||||
'resource_name',
|
||||
'user_email',
|
||||
'ip_address',
|
||||
'status',
|
||||
];
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
for (const log of logs) {
|
||||
const row = [
|
||||
log.timestamp,
|
||||
log.action,
|
||||
log.resourceType,
|
||||
`"${(log.resourceName || '').replace(/"/g, '""')}"`,
|
||||
log.userEmail,
|
||||
log.ipAddress,
|
||||
log.status,
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
}
|
||||
|
||||
reply.header('Content-Type', 'text/csv');
|
||||
reply.header('Content-Disposition', 'attachment; filename="audit-logs.csv"');
|
||||
return csvRows.join('\n');
|
||||
}
|
||||
|
||||
// JSON format
|
||||
reply.header('Content-Type', 'application/json');
|
||||
reply.header('Content-Disposition', 'attachment; filename="audit-logs.json"');
|
||||
return JSON.stringify(logs, null, 2);
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,11 @@
|
||||
import * as argon2 from 'argon2';
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { redis, setSession, getSession, deleteSession } from '../../lib/redis';
|
||||
import { config } from '../../config/index';
|
||||
import { sendPasswordResetEmail } from '../../lib/email';
|
||||
import {
|
||||
UnauthorizedError,
|
||||
NotFoundError,
|
||||
@@ -13,6 +16,68 @@ import type { User, AuthTokens, JwtPayload } from '../../../../shared/src/index'
|
||||
|
||||
const JWT_SECRET = new TextEncoder().encode(config.JWT_SECRET);
|
||||
|
||||
// ==================== RBAC Permissions ====================
|
||||
|
||||
// Permission format: resource:action:scope
|
||||
// Resources: clusters, streams, consumers, alerts, dashboards, settings, users, teams
|
||||
// Actions: read, write, delete, admin
|
||||
// Scope: * (all) or specific ID
|
||||
|
||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
owner: ['*:*:*'], // Full access to everything
|
||||
admin: [
|
||||
'clusters:*:*',
|
||||
'streams:*:*',
|
||||
'consumers:*:*',
|
||||
'alerts:*:*',
|
||||
'dashboards:*:*',
|
||||
'settings:read:*',
|
||||
'settings:write:*',
|
||||
'users:read:*',
|
||||
'users:write:*',
|
||||
'teams:*:*',
|
||||
],
|
||||
member: [
|
||||
'clusters:read:*',
|
||||
'streams:read:*',
|
||||
'streams:write:*',
|
||||
'consumers:read:*',
|
||||
'consumers:write:*',
|
||||
'alerts:read:*',
|
||||
'dashboards:read:*',
|
||||
'dashboards:write:own',
|
||||
'settings:read:*',
|
||||
],
|
||||
viewer: [
|
||||
'clusters:read:*',
|
||||
'streams:read:*',
|
||||
'consumers:read:*',
|
||||
'alerts:read:*',
|
||||
'dashboards:read:*',
|
||||
],
|
||||
};
|
||||
|
||||
export function getPermissionsForRole(role: string): string[] {
|
||||
return ROLE_PERMISSIONS[role] || ROLE_PERMISSIONS.viewer;
|
||||
}
|
||||
|
||||
export function hasPermission(permissions: string[], resource: string, action: string, scope: string = '*'): boolean {
|
||||
return permissions.some((perm) => {
|
||||
const [permResource, permAction, permScope] = perm.split(':');
|
||||
|
||||
// Check resource match (wildcard or exact)
|
||||
const resourceMatch = permResource === '*' || permResource === resource;
|
||||
|
||||
// Check action match (wildcard or exact)
|
||||
const actionMatch = permAction === '*' || permAction === action;
|
||||
|
||||
// Check scope match (wildcard or exact)
|
||||
const scopeMatch = permScope === '*' || permScope === scope || permScope === 'own';
|
||||
|
||||
return resourceMatch && actionMatch && scopeMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Password Hashing ====================
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
@@ -208,13 +273,16 @@ export async function login(
|
||||
// Generate tokens
|
||||
const tokens = await generateTokens(user.id, user.email, membership.orgId, membership.role);
|
||||
|
||||
// Get permissions based on role
|
||||
const permissions = getPermissionsForRole(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
|
||||
permissions,
|
||||
ipAddress: ipAddress || '',
|
||||
createdAt: new Date().toISOString(),
|
||||
lastActivity: new Date().toISOString(),
|
||||
@@ -302,21 +370,9 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
||||
// Store in Redis with 1 hour TTL
|
||||
await redis.set(`password_reset:${user.id}`, resetToken, 'EX', 3600);
|
||||
|
||||
// Build reset URL
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`;
|
||||
|
||||
// In dev mode, print the URL to console
|
||||
if (config.NODE_ENV === 'development') {
|
||||
console.log('\n========================================');
|
||||
console.log('📧 PASSWORD RESET EMAIL (DEV MODE)');
|
||||
console.log('========================================');
|
||||
console.log(`To: ${email}`);
|
||||
console.log(`Reset URL: ${resetUrl}`);
|
||||
console.log('========================================\n');
|
||||
}
|
||||
|
||||
// TODO: In production, send email with reset link
|
||||
// Send password reset email
|
||||
const userName = user.firstName || undefined;
|
||||
await sendPasswordResetEmail(email, resetToken, userName);
|
||||
}
|
||||
|
||||
export async function resetPassword(token: string, newPassword: string): Promise<void> {
|
||||
@@ -346,13 +402,73 @@ export async function resetPassword(token: string, newPassword: string): Promise
|
||||
// ==================== 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');
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('User', userId);
|
||||
}
|
||||
|
||||
// Generate a new TOTP secret
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'NATS Console',
|
||||
label: user.email,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromHex(crypto.randomUUID().replace(/-/g, '')),
|
||||
});
|
||||
|
||||
const secret = totp.secret.base32;
|
||||
|
||||
// Generate QR code
|
||||
const otpauthUrl = totp.toString();
|
||||
const qrCode = await QRCode.toDataURL(otpauthUrl);
|
||||
|
||||
// Store secret temporarily (not enabled yet until verified)
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaSecret: secret },
|
||||
});
|
||||
|
||||
return { secret, qrCode };
|
||||
}
|
||||
|
||||
export async function verifyMfa(userId: string, code: string): Promise<boolean> {
|
||||
// TODO: Implement TOTP verification
|
||||
throw new Error('MFA not implemented yet');
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || !user.mfaSecret) {
|
||||
throw new ValidationError('MFA not set up for this user');
|
||||
}
|
||||
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: 'NATS Console',
|
||||
label: user.email,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(user.mfaSecret),
|
||||
});
|
||||
|
||||
// Validate the code (allow 1 period window for clock drift)
|
||||
const delta = totp.validate({ token: code, window: 1 });
|
||||
|
||||
if (delta === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If MFA was not yet enabled, enable it now (first successful verification)
|
||||
if (!user.mfaEnabled) {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaEnabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function disableMfa(userId: string): Promise<void> {
|
||||
@@ -429,8 +545,7 @@ async function generateTokens(
|
||||
orgId: string,
|
||||
role: string
|
||||
): Promise<AuthTokens> {
|
||||
// TODO: Fetch actual permissions from database
|
||||
const permissions = ['*:*:*'];
|
||||
const permissions = getPermissionsForRole(role);
|
||||
|
||||
const accessToken = await generateAccessToken({
|
||||
sub: userId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { randomBytes } from 'crypto';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { authenticate } from '../../common/middleware/auth';
|
||||
import { config } from '../../config/index';
|
||||
import { sendInviteEmail } from '../../lib/email';
|
||||
import { NotFoundError, ConflictError, ForbiddenError } from '../../../../shared/src/index';
|
||||
|
||||
const CreateInviteSchema = z.object({
|
||||
@@ -111,19 +112,17 @@ export const inviteRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
const inviteLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/invite/${token}`;
|
||||
|
||||
// In dev mode, print the URL to console
|
||||
if (config.NODE_ENV === 'development') {
|
||||
console.log('\n========================================');
|
||||
console.log('📨 TEAM INVITE EMAIL (DEV MODE)');
|
||||
console.log('========================================');
|
||||
console.log(`To: ${body.email}`);
|
||||
console.log(`Organization: ${invite.organization.name}`);
|
||||
console.log(`Role: ${body.role}`);
|
||||
console.log(`Invite URL: ${inviteLink}`);
|
||||
console.log('========================================\n');
|
||||
}
|
||||
// Send invite email
|
||||
const inviterName = invite.inviter
|
||||
? `${invite.inviter.firstName || ''} ${invite.inviter.lastName || ''}`.trim() || invite.inviter.email
|
||||
: 'A team member';
|
||||
|
||||
// TODO: In production, send email with invite link
|
||||
await sendInviteEmail(
|
||||
body.email,
|
||||
token,
|
||||
inviterName,
|
||||
invite.organization.name
|
||||
);
|
||||
|
||||
return reply.status(201).send({
|
||||
invite: {
|
||||
|
||||
@@ -147,4 +147,121 @@ export const streamRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
// GET /clusters/:cid/streams/:name/messages/export - Export messages
|
||||
fastify.get<{
|
||||
Params: { cid: string; name: string };
|
||||
Querystring: {
|
||||
format?: 'json' | 'csv';
|
||||
start_seq?: string;
|
||||
limit?: string;
|
||||
subject?: string;
|
||||
};
|
||||
}>('/:cid/streams/:name/messages/export', async (request, reply) => {
|
||||
const { format = 'json', start_seq, limit = '1000', subject } = request.query;
|
||||
|
||||
const messages = await streamService.getMessages(
|
||||
request.user!.orgId,
|
||||
request.params.cid,
|
||||
request.params.name,
|
||||
{
|
||||
startSeq: start_seq ? parseInt(start_seq) : undefined,
|
||||
limit: Math.min(parseInt(limit), 10000), // Max 10k messages per export
|
||||
subject,
|
||||
}
|
||||
);
|
||||
|
||||
if (format === 'csv') {
|
||||
// CSV format
|
||||
const headers = ['sequence', 'subject', 'time', 'data'];
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
for (const msg of messages) {
|
||||
const row = [
|
||||
msg.sequence,
|
||||
`"${msg.subject.replace(/"/g, '""')}"`,
|
||||
msg.time,
|
||||
`"${String(msg.data).replace(/"/g, '""').replace(/\n/g, '\\n')}"`,
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
}
|
||||
|
||||
reply.header('Content-Type', 'text/csv');
|
||||
reply.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${request.params.name}-messages.csv"`
|
||||
);
|
||||
return csvRows.join('\n');
|
||||
}
|
||||
|
||||
// JSON format (default)
|
||||
reply.header('Content-Type', 'application/json');
|
||||
reply.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${request.params.name}-messages.json"`
|
||||
);
|
||||
return JSON.stringify(messages, null, 2);
|
||||
});
|
||||
|
||||
// POST /clusters/:cid/streams/:name/messages/replay - Replay messages to another subject
|
||||
fastify.post<{
|
||||
Params: { cid: string; name: string };
|
||||
Body: {
|
||||
targetSubject: string;
|
||||
startSeq?: number;
|
||||
endSeq?: number;
|
||||
limit?: number;
|
||||
};
|
||||
}>('/:cid/streams/:name/messages/replay', async (request) => {
|
||||
const { targetSubject, startSeq, endSeq, limit = 100 } = request.body as {
|
||||
targetSubject: string;
|
||||
startSeq?: number;
|
||||
endSeq?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
// Get messages to replay
|
||||
const messages = await streamService.getMessages(
|
||||
request.user!.orgId,
|
||||
request.params.cid,
|
||||
request.params.name,
|
||||
{
|
||||
startSeq,
|
||||
limit: Math.min(limit, 1000), // Max 1000 messages per replay
|
||||
}
|
||||
);
|
||||
|
||||
// Filter by end sequence if specified
|
||||
const filteredMessages = endSeq
|
||||
? messages.filter((m) => m.sequence <= endSeq)
|
||||
: messages;
|
||||
|
||||
// Replay each message
|
||||
let replayedCount = 0;
|
||||
const errors: Array<{ sequence: number; error: string }> = [];
|
||||
|
||||
for (const msg of filteredMessages) {
|
||||
try {
|
||||
await streamService.publishMessage(
|
||||
request.user!.orgId,
|
||||
request.params.cid,
|
||||
targetSubject,
|
||||
String(msg.data),
|
||||
msg.headers
|
||||
);
|
||||
replayedCount++;
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
sequence: msg.sequence,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
replayed: replayedCount,
|
||||
total: filteredMessages.length,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BarChart3, TrendingUp, TrendingDown, Activity, ArrowUpRight, ArrowDownRight } from 'lucide-react';
|
||||
import { BarChart3, TrendingUp, TrendingDown, Activity, ArrowUpRight, ArrowDownRight, Loader2 } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LineChart, BarChart, MultiLineChart } from '@/components/charts';
|
||||
import { formatBytes, formatNumber } from '@nats-console/shared';
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
@@ -25,6 +26,34 @@ export default function AnalyticsPage() {
|
||||
enabled: !!selectedCluster,
|
||||
});
|
||||
|
||||
// Chart data queries
|
||||
const { data: throughputData, isLoading: throughputLoading } = useQuery({
|
||||
queryKey: ['analytics-throughput', selectedCluster, timeRange],
|
||||
queryFn: () =>
|
||||
selectedCluster
|
||||
? api.analytics.chartThroughput(selectedCluster, timeRange)
|
||||
: null,
|
||||
enabled: !!selectedCluster,
|
||||
});
|
||||
|
||||
const { data: consumerLagData, isLoading: consumerLagLoading } = useQuery({
|
||||
queryKey: ['analytics-consumer-lag', selectedCluster, timeRange],
|
||||
queryFn: () =>
|
||||
selectedCluster
|
||||
? api.analytics.chartConsumerLag(selectedCluster, timeRange)
|
||||
: null,
|
||||
enabled: !!selectedCluster,
|
||||
});
|
||||
|
||||
const { data: streamActivityData, isLoading: streamActivityLoading } = useQuery({
|
||||
queryKey: ['analytics-stream-activity', selectedCluster, timeRange],
|
||||
queryFn: () =>
|
||||
selectedCluster
|
||||
? api.analytics.chartStreamActivity(selectedCluster, timeRange)
|
||||
: null,
|
||||
enabled: !!selectedCluster,
|
||||
});
|
||||
|
||||
// Auto-select first cluster
|
||||
if (clustersData?.clusters?.length && !selectedCluster) {
|
||||
setSelectedCluster(clustersData.clusters[0].id);
|
||||
@@ -185,13 +214,27 @@ export default function AnalyticsPage() {
|
||||
<CardDescription>Messages per second over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Chart visualization coming soon</p>
|
||||
<p className="text-sm">Integrate with your preferred charting library</p>
|
||||
{throughputLoading ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
) : throughputData?.data && throughputData.data.length > 0 ? (
|
||||
<LineChart
|
||||
data={throughputData.data}
|
||||
yAxisLabel="msg/s"
|
||||
color="#2563eb"
|
||||
height={300}
|
||||
showArea={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No throughput data available</p>
|
||||
<p className="text-sm">Start producing messages to see metrics</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -201,13 +244,27 @@ export default function AnalyticsPage() {
|
||||
<CardDescription>Pending messages by consumer</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Chart visualization coming soon</p>
|
||||
<p className="text-sm">Integrate with your preferred charting library</p>
|
||||
{consumerLagLoading ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
) : consumerLagData?.data && consumerLagData.data.length > 0 ? (
|
||||
<BarChart
|
||||
data={consumerLagData.data}
|
||||
yAxisLabel="pending"
|
||||
color="#dc2626"
|
||||
height={300}
|
||||
horizontal={true}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No consumer lag data available</p>
|
||||
<p className="text-sm">Create consumers to see lag metrics</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -218,13 +275,26 @@ export default function AnalyticsPage() {
|
||||
<CardDescription>Messages by stream over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Chart visualization coming soon</p>
|
||||
<p className="text-sm">Integrate with your preferred charting library</p>
|
||||
{streamActivityLoading ? (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
) : streamActivityData?.streams && Object.keys(streamActivityData.streams).length > 0 ? (
|
||||
<MultiLineChart
|
||||
series={streamActivityData.streams}
|
||||
yAxisLabel="msg/s"
|
||||
height={300}
|
||||
showArea={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No stream activity data available</p>
|
||||
<p className="text-sm">Create streams and produce messages to see activity</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Plus,
|
||||
Settings,
|
||||
Trash2,
|
||||
Copy,
|
||||
Share2,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export default function DashboardsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newDashboardName, setNewDashboardName] = useState('');
|
||||
const [newDashboardDescription, setNewDashboardDescription] = useState('');
|
||||
const [deleteDashboardId, setDeleteDashboardId] = useState<string | null>(null);
|
||||
|
||||
const { data: dashboardsData, isLoading } = useQuery({
|
||||
queryKey: ['dashboards'],
|
||||
queryFn: () => api.dashboards.list(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: { name: string; description?: string }) => api.dashboards.create(data),
|
||||
onSuccess: () => {
|
||||
setShowCreateDialog(false);
|
||||
setNewDashboardName('');
|
||||
setNewDashboardDescription('');
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboards'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.dashboards.delete(id),
|
||||
onSuccess: () => {
|
||||
setDeleteDashboardId(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboards'] });
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const original = await api.dashboards.get(id);
|
||||
return api.dashboards.create({
|
||||
name: `${original.dashboard.name} (Copy)`,
|
||||
description: original.dashboard.description,
|
||||
config: original.dashboard.config,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboards'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!newDashboardName.trim()) return;
|
||||
createMutation.mutate({
|
||||
name: newDashboardName,
|
||||
description: newDashboardDescription || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Dashboards</h1>
|
||||
<p className="text-muted-foreground">Create custom dashboards to visualize your NATS metrics</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (!dashboardsData?.dashboards || dashboardsData.dashboards.length === 0) && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<LayoutDashboard className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No dashboards yet</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Create your first custom dashboard to visualize NATS metrics
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Dashboard
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{dashboardsData?.dashboards && dashboardsData.dashboards.length > 0 && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{dashboardsData.dashboards.map((dashboard: any) => (
|
||||
<Card key={dashboard.id} className="group relative hover:border-primary/50 transition-colors">
|
||||
<Link href={`/dashboards/${dashboard.id}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<LayoutDashboard className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{dashboard.name}</CardTitle>
|
||||
<CardDescription className="mt-1 line-clamp-2">
|
||||
{dashboard.description || 'No description'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Updated {formatDate(dashboard.updatedAt)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-4 w-4" />
|
||||
<span>{dashboard.isPublic ? 'Public' : 'Private'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
<div className="absolute top-4 right-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/dashboards/${dashboard.id}`}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
duplicateMutation.mutate(dashboard.id);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setDeleteDashboardId(dashboard.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Dashboard Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Dashboard</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a custom dashboard to visualize your NATS metrics
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Name *</label>
|
||||
<Input
|
||||
placeholder="e.g., Production Metrics"
|
||||
value={newDashboardName}
|
||||
onChange={(e) => setNewDashboardName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
placeholder="Optional description..."
|
||||
value={newDashboardDescription}
|
||||
onChange={(e) => setNewDashboardDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!newDashboardName.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
Create Dashboard
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteDashboardId} onOpenChange={() => setDeleteDashboardId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Dashboard?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the dashboard and all its widgets.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteDashboardId && deleteMutation.mutate(deleteDashboardId)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +113,13 @@ function SettingsPageContent() {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [deleteApiKeyId, setDeleteApiKeyId] = useState<string | null>(null);
|
||||
|
||||
// 2FA state
|
||||
const [show2faDialog, setShow2faDialog] = useState(false);
|
||||
const [mfaSetupData, setMfaSetupData] = useState<{ secret: string; qrCode: string } | null>(null);
|
||||
const [mfaVerifyCode, setMfaVerifyCode] = useState('');
|
||||
const [mfaError, setMfaError] = useState('');
|
||||
const [showDisable2faDialog, setShowDisable2faDialog] = useState(false);
|
||||
|
||||
// Initialize form with user data
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -204,6 +211,56 @@ function SettingsPageContent() {
|
||||
},
|
||||
});
|
||||
|
||||
// 2FA mutations
|
||||
const enable2faMutation = useMutation({
|
||||
mutationFn: () => api.mfa.enable(),
|
||||
onSuccess: (data) => {
|
||||
setMfaSetupData(data);
|
||||
setShow2faDialog(true);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setMfaError(err.message || 'Failed to enable 2FA');
|
||||
},
|
||||
});
|
||||
|
||||
const verify2faMutation = useMutation({
|
||||
mutationFn: (code: string) => api.mfa.verify(code),
|
||||
onSuccess: (data) => {
|
||||
if (data.valid) {
|
||||
setShow2faDialog(false);
|
||||
setMfaSetupData(null);
|
||||
setMfaVerifyCode('');
|
||||
setMfaError('');
|
||||
// Refresh user data to get updated mfaEnabled status
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
// Update local user state
|
||||
if (user) {
|
||||
setUser({ ...user, mfaEnabled: true });
|
||||
}
|
||||
} else {
|
||||
setMfaError('Invalid verification code. Please try again.');
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setMfaError(err.message || 'Failed to verify code');
|
||||
},
|
||||
});
|
||||
|
||||
const disable2faMutation = useMutation({
|
||||
mutationFn: () => api.mfa.disable(),
|
||||
onSuccess: () => {
|
||||
setShowDisable2faDialog(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||
// Update local user state
|
||||
if (user) {
|
||||
setUser({ ...user, mfaEnabled: false });
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setMfaError(err.message || 'Failed to disable 2FA');
|
||||
},
|
||||
});
|
||||
|
||||
const handleProfileSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
profileMutation.mutate(profileForm);
|
||||
@@ -631,19 +688,129 @@ function SettingsPageContent() {
|
||||
: 'Two-factor authentication is not enabled'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={user?.mfaEnabled ? 'destructive' : 'outline'}
|
||||
disabled
|
||||
>
|
||||
{user?.mfaEnabled ? 'Disable 2FA' : 'Enable 2FA'}
|
||||
</Button>
|
||||
{user?.mfaEnabled ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDisable2faDialog(true)}
|
||||
disabled={disable2faMutation.isPending}
|
||||
>
|
||||
{disable2faMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Disable 2FA
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => enable2faMutation.mutate()}
|
||||
disabled={enable2faMutation.isPending}
|
||||
>
|
||||
{enable2faMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Shield className="h-4 w-4" />
|
||||
)}
|
||||
Enable 2FA
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
2FA setup coming soon. This will allow you to use an authenticator app for additional security.
|
||||
Use an authenticator app like Google Authenticator, Authy, or 1Password to generate verification codes.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2FA Setup Dialog */}
|
||||
<Dialog open={show2faDialog} onOpenChange={setShow2faDialog}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Up Two-Factor Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
Scan the QR code with your authenticator app, then enter the verification code.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{mfaSetupData && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||
<img src={mfaSetupData.qrCode} alt="QR Code" className="w-48 h-48" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-1">Or enter this secret manually:</p>
|
||||
<code className="px-3 py-1 bg-muted rounded text-sm font-mono">
|
||||
{mfaSetupData.secret}
|
||||
</code>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Verification Code</label>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
placeholder="Enter 6-digit code"
|
||||
value={mfaVerifyCode}
|
||||
onChange={(e) => {
|
||||
setMfaVerifyCode(e.target.value.replace(/\D/g, ''));
|
||||
setMfaError('');
|
||||
}}
|
||||
className="text-center text-lg tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
{mfaError && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
{mfaError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShow2faDialog(false);
|
||||
setMfaSetupData(null);
|
||||
setMfaVerifyCode('');
|
||||
setMfaError('');
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => verify2faMutation.mutate(mfaVerifyCode)}
|
||||
disabled={mfaVerifyCode.length !== 6 || verify2faMutation.isPending}
|
||||
>
|
||||
{verify2faMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
)}
|
||||
Verify & Enable
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Disable 2FA Confirmation Dialog */}
|
||||
<AlertDialog open={showDisable2faDialog} onOpenChange={setShowDisable2faDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Disable Two-Factor Authentication?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove the extra layer of security from your account. You can re-enable it at any time.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => disable2faMutation.mutate()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{disable2faMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : null}
|
||||
Disable 2FA
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Sessions</CardTitle>
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
ChevronsRight,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Search,
|
||||
Download,
|
||||
RotateCcw,
|
||||
Filter,
|
||||
X,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -41,6 +47,21 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { formatBytes, formatNumber, formatDuration } from '@nats-console/shared';
|
||||
import { LineChart } from '@/components/charts';
|
||||
import { CreateStreamDialog } from '@/components/forms/create-stream-dialog';
|
||||
@@ -75,6 +96,23 @@ function StreamDetailContent() {
|
||||
// Metrics time range
|
||||
const [metricsTimeRange, setMetricsTimeRange] = useState('1h');
|
||||
|
||||
// Search/Filter state
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [subjectFilter, setSubjectFilter] = useState('');
|
||||
const [activeSubjectFilter, setActiveSubjectFilter] = useState('');
|
||||
|
||||
// Export dialog state
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [exportFormat, setExportFormat] = useState<'json' | 'csv'>('json');
|
||||
const [exportLimit, setExportLimit] = useState('1000');
|
||||
|
||||
// Replay dialog state
|
||||
const [showReplayDialog, setShowReplayDialog] = useState(false);
|
||||
const [replayTargetSubject, setReplayTargetSubject] = useState('');
|
||||
const [replayStartSeq, setReplayStartSeq] = useState('');
|
||||
const [replayEndSeq, setReplayEndSeq] = useState('');
|
||||
const [replayLimit, setReplayLimit] = useState('100');
|
||||
|
||||
// Helper to format message data - detect JSON and pretty print
|
||||
const formatMessageData = (data: unknown): { formatted: string; isJson: boolean } => {
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
@@ -136,15 +174,65 @@ function StreamDetailContent() {
|
||||
});
|
||||
|
||||
const { data: messagesData, refetch: refetchMessages, isFetching: isLoadingMessages } = useQuery({
|
||||
queryKey: ['messages', clusterId, streamName, currentPage, pageSize],
|
||||
queryKey: ['messages', clusterId, streamName, currentPage, pageSize, activeSubjectFilter],
|
||||
queryFn: () => {
|
||||
const firstSeq = streamData?.stream?.state?.first_seq || 1;
|
||||
const startSeq = String(firstSeq + (currentPage - 1) * pageSize);
|
||||
return api.streams.messages(clusterId, streamName, { start_seq: startSeq, limit: String(pageSize) });
|
||||
const params: Record<string, string> = { start_seq: startSeq, limit: String(pageSize) };
|
||||
if (activeSubjectFilter) {
|
||||
params.subject = activeSubjectFilter;
|
||||
}
|
||||
return api.streams.messages(clusterId, streamName, params);
|
||||
},
|
||||
enabled: activeTab === 'messages' && !!streamData?.stream,
|
||||
});
|
||||
|
||||
// Replay mutation
|
||||
const replayMutation = useMutation({
|
||||
mutationFn: (data: { targetSubject: string; startSeq?: number; endSeq?: number; limit?: number }) =>
|
||||
api.streams.replayMessages(clusterId, streamName, data),
|
||||
onSuccess: () => {
|
||||
setShowReplayDialog(false);
|
||||
setReplayTargetSubject('');
|
||||
setReplayStartSeq('');
|
||||
setReplayEndSeq('');
|
||||
},
|
||||
});
|
||||
|
||||
// Apply subject filter
|
||||
const applyFilter = () => {
|
||||
setActiveSubjectFilter(subjectFilter);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Clear filters
|
||||
const clearFilters = () => {
|
||||
setSubjectFilter('');
|
||||
setActiveSubjectFilter('');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Handle export
|
||||
const handleExport = () => {
|
||||
const url = api.streams.exportMessages(clusterId, streamName, exportFormat, {
|
||||
limit: parseInt(exportLimit),
|
||||
subject: activeSubjectFilter || undefined,
|
||||
});
|
||||
window.open(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'}${url}`, '_blank');
|
||||
setShowExportDialog(false);
|
||||
};
|
||||
|
||||
// Handle replay
|
||||
const handleReplay = () => {
|
||||
if (!replayTargetSubject) return;
|
||||
replayMutation.mutate({
|
||||
targetSubject: replayTargetSubject,
|
||||
startSeq: replayStartSeq ? parseInt(replayStartSeq) : undefined,
|
||||
endSeq: replayEndSeq ? parseInt(replayEndSeq) : undefined,
|
||||
limit: parseInt(replayLimit),
|
||||
});
|
||||
};
|
||||
|
||||
// Metrics data
|
||||
const getTimeRangeParams = () => {
|
||||
const now = new Date();
|
||||
@@ -316,6 +404,148 @@ function StreamDetailContent() {
|
||||
mode="edit"
|
||||
/>
|
||||
|
||||
{/* Export Dialog */}
|
||||
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Messages</DialogTitle>
|
||||
<DialogDescription>
|
||||
Export messages from this stream in JSON or CSV format
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Format</label>
|
||||
<Select value={exportFormat} onValueChange={(v: 'json' | 'csv') => setExportFormat(v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Max Messages</label>
|
||||
<Select value={exportLimit} onValueChange={setExportLimit}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="100">100 messages</SelectItem>
|
||||
<SelectItem value="500">500 messages</SelectItem>
|
||||
<SelectItem value="1000">1,000 messages</SelectItem>
|
||||
<SelectItem value="5000">5,000 messages</SelectItem>
|
||||
<SelectItem value="10000">10,000 messages</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{activeSubjectFilter && (
|
||||
<div className="p-3 bg-muted rounded-lg text-sm">
|
||||
<span className="text-muted-foreground">Filter applied: </span>
|
||||
<span className="font-medium">{activeSubjectFilter}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowExportDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport}>
|
||||
<Download className="h-4 w-4" />
|
||||
Export {exportFormat.toUpperCase()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Replay Dialog */}
|
||||
<Dialog open={showReplayDialog} onOpenChange={setShowReplayDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Replay Messages</DialogTitle>
|
||||
<DialogDescription>
|
||||
Replay messages from this stream to another subject
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Target Subject *</label>
|
||||
<Input
|
||||
placeholder="e.g., orders.replay or orders.reprocess"
|
||||
value={replayTargetSubject}
|
||||
onChange={(e) => setReplayTargetSubject(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Messages will be published to this subject
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Start Sequence</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="From beginning"
|
||||
value={replayStartSeq}
|
||||
onChange={(e) => setReplayStartSeq(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">End Sequence</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="To end"
|
||||
value={replayEndSeq}
|
||||
onChange={(e) => setReplayEndSeq(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Max Messages</label>
|
||||
<Select value={replayLimit} onValueChange={setReplayLimit}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 messages</SelectItem>
|
||||
<SelectItem value="50">50 messages</SelectItem>
|
||||
<SelectItem value="100">100 messages</SelectItem>
|
||||
<SelectItem value="500">500 messages</SelectItem>
|
||||
<SelectItem value="1000">1,000 messages</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{replayMutation.isSuccess && (
|
||||
<div className="p-3 bg-green-50 text-green-700 rounded-lg text-sm">
|
||||
Successfully replayed {replayMutation.data?.replayed} of {replayMutation.data?.total} messages
|
||||
</div>
|
||||
)}
|
||||
{replayMutation.isError && (
|
||||
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
||||
Failed to replay messages: {(replayMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowReplayDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReplay}
|
||||
disabled={!replayTargetSubject || replayMutation.isPending}
|
||||
>
|
||||
{replayMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
Replay Messages
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Tabs */}
|
||||
<TabsList tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
@@ -451,52 +681,113 @@ function StreamDetailContent() {
|
||||
|
||||
{/* Message Browser */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Message Browser</CardTitle>
|
||||
<CardDescription>
|
||||
{totalMessages > 0 ? `${formatNumber(totalMessages)} messages total` : 'Browse messages in this stream'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={expandAllMessages}
|
||||
disabled={!messagesData?.messages?.length}
|
||||
>
|
||||
<Maximize2 className="h-3 w-3 mr-1" />
|
||||
Expand All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={collapseAllMessages}
|
||||
disabled={expandedMessages.size === 0}
|
||||
>
|
||||
<Minimize2 className="h-3 w-3 mr-1" />
|
||||
Collapse All
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border" />
|
||||
<select
|
||||
className="h-9 px-3 border rounded-md bg-background text-sm"
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<option value={10}>10 / page</option>
|
||||
<option value={20}>20 / page</option>
|
||||
<option value={50}>50 / page</option>
|
||||
<option value={100}>100 / page</option>
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={() => refetchMessages()} disabled={isLoadingMessages}>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingMessages ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Message Browser</CardTitle>
|
||||
<CardDescription>
|
||||
{totalMessages > 0 ? `${formatNumber(totalMessages)} messages total` : 'Browse messages in this stream'}
|
||||
{activeSubjectFilter && (
|
||||
<span className="ml-2 text-primary">
|
||||
(filtered by: {activeSubjectFilter})
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={showFilters ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
disabled={!messagesData?.messages?.length}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowReplayDialog(true)}
|
||||
disabled={!messagesData?.messages?.length}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Replay
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={expandAllMessages}
|
||||
disabled={!messagesData?.messages?.length}
|
||||
>
|
||||
<Maximize2 className="h-3 w-3 mr-1" />
|
||||
Expand All
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={collapseAllMessages}
|
||||
disabled={expandedMessages.size === 0}
|
||||
>
|
||||
<Minimize2 className="h-3 w-3 mr-1" />
|
||||
Collapse All
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-border" />
|
||||
<select
|
||||
className="h-9 px-3 border rounded-md bg-background text-sm"
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<option value={10}>10 / page</option>
|
||||
<option value={20}>20 / page</option>
|
||||
<option value={50}>50 / page</option>
|
||||
<option value={100}>100 / page</option>
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={() => refetchMessages()} disabled={isLoadingMessages}>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoadingMessages ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters Panel */}
|
||||
{showFilters && (
|
||||
<div className="flex items-center gap-4 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-1 block">Subject Filter</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="e.g., orders.* or orders.created"
|
||||
value={subjectFilter}
|
||||
onChange={(e) => setSubjectFilter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilter()}
|
||||
/>
|
||||
<Button onClick={applyFilter}>
|
||||
<Search className="h-4 w-4" />
|
||||
Search
|
||||
</Button>
|
||||
{activeSubjectFilter && (
|
||||
<Button variant="ghost" onClick={clearFilters}>
|
||||
<X className="h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingMessages && !messagesData?.messages?.length ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { LineChart } from './line-chart';
|
||||
export { BarChart } from './bar-chart';
|
||||
export { GaugeChart } from './gauge-chart';
|
||||
export { MultiLineChart } from './multi-line-chart';
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
interface MultiLineChartProps {
|
||||
series: Record<string, Array<{ time: string; value: number }>>;
|
||||
title?: string;
|
||||
yAxisLabel?: string;
|
||||
height?: number;
|
||||
showArea?: boolean;
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#2563eb', // blue
|
||||
'#16a34a', // green
|
||||
'#dc2626', // red
|
||||
'#ca8a04', // yellow
|
||||
'#9333ea', // purple
|
||||
'#0891b2', // cyan
|
||||
'#ea580c', // orange
|
||||
'#84cc16', // lime
|
||||
];
|
||||
|
||||
export function MultiLineChart({
|
||||
series,
|
||||
title,
|
||||
yAxisLabel = '',
|
||||
height = 300,
|
||||
showArea = false,
|
||||
}: MultiLineChartProps) {
|
||||
const seriesNames = Object.keys(series);
|
||||
|
||||
// Get all unique timestamps
|
||||
const allTimes = new Set<string>();
|
||||
for (const data of Object.values(series)) {
|
||||
for (const item of data) {
|
||||
allTimes.add(item.time);
|
||||
}
|
||||
}
|
||||
const timeLabels = Array.from(allTimes).sort();
|
||||
|
||||
const chartSeries = seriesNames.map((name, index) => {
|
||||
const data = series[name];
|
||||
const color = COLORS[index % COLORS.length];
|
||||
|
||||
// Map values to time labels
|
||||
const values = timeLabels.map((time) => {
|
||||
const item = data.find((d) => d.time === time);
|
||||
return item?.value ?? 0;
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
type: 'line' as const,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
sampling: 'lttb' as const,
|
||||
itemStyle: { color },
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: showArea ? { color: `${color}20` } : undefined,
|
||||
data: values,
|
||||
};
|
||||
});
|
||||
|
||||
const option: EChartsOption = {
|
||||
title: title ? { text: title, left: 'center', textStyle: { fontSize: 14 } } : undefined,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
},
|
||||
legend: {
|
||||
data: seriesNames,
|
||||
bottom: 0,
|
||||
type: 'scroll',
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: seriesNames.length > 0 ? '15%' : '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: timeLabels,
|
||||
axisLine: { lineStyle: { color: '#e5e7eb' } },
|
||||
axisLabel: { color: '#6b7280', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: yAxisLabel,
|
||||
nameTextStyle: { color: '#6b7280' },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: '#f3f4f6' } },
|
||||
axisLabel: { color: '#6b7280', fontSize: 11 },
|
||||
},
|
||||
series: chartSeries,
|
||||
};
|
||||
|
||||
if (seriesNames.length === 0) {
|
||||
return (
|
||||
<div style={{ height }} className="flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No data available</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ReactECharts option={option} style={{ height }} />;
|
||||
}
|
||||
@@ -196,6 +196,29 @@ export const streams = {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
exportMessages: (
|
||||
clusterId: string,
|
||||
name: string,
|
||||
format: 'json' | 'csv' = 'json',
|
||||
options?: { startSeq?: number; limit?: number; subject?: string }
|
||||
) => {
|
||||
const params = new URLSearchParams({ format });
|
||||
if (options?.startSeq) params.set('start_seq', String(options.startSeq));
|
||||
if (options?.limit) params.set('limit', String(options.limit));
|
||||
if (options?.subject) params.set('subject', options.subject);
|
||||
return `/clusters/${clusterId}/streams/${name}/messages/export?${params}`;
|
||||
},
|
||||
|
||||
replayMessages: (
|
||||
clusterId: string,
|
||||
name: string,
|
||||
data: { targetSubject: string; startSeq?: number; endSeq?: number; limit?: number }
|
||||
) =>
|
||||
request<{ replayed: number; total: number; errors?: Array<{ sequence: number; error: string }> }>(
|
||||
`/clusters/${clusterId}/streams/${name}/messages/replay`,
|
||||
{ method: 'POST', body: data }
|
||||
),
|
||||
};
|
||||
|
||||
// Consumers API
|
||||
@@ -267,6 +290,23 @@ export const analytics = {
|
||||
const query = clusterId ? `?clusterId=${clusterId}` : '';
|
||||
return request<any>(`/analytics/cluster/overview${query}`);
|
||||
},
|
||||
|
||||
chartThroughput: (clusterId: string, timeRange: string = '24h') =>
|
||||
request<{
|
||||
data: Array<{ name: string; value: number; time: string }>;
|
||||
interval: string;
|
||||
}>(`/analytics/charts/throughput?clusterId=${clusterId}&timeRange=${timeRange}`),
|
||||
|
||||
chartConsumerLag: (clusterId: string, timeRange: string = '24h') =>
|
||||
request<{
|
||||
data: Array<{ name: string; value: number }>;
|
||||
}>(`/analytics/charts/consumer-lag?clusterId=${clusterId}&timeRange=${timeRange}`),
|
||||
|
||||
chartStreamActivity: (clusterId: string, timeRange: string = '24h') =>
|
||||
request<{
|
||||
streams: Record<string, Array<{ time: string; value: number }>>;
|
||||
interval: string;
|
||||
}>(`/analytics/charts/stream-activity?clusterId=${clusterId}&timeRange=${timeRange}`),
|
||||
};
|
||||
|
||||
// Alerts API
|
||||
|
||||
@@ -3,6 +3,7 @@ import pino from 'pino';
|
||||
import cron from 'node-cron';
|
||||
import { MetricsCollector } from './collectors/metrics';
|
||||
import { AlertProcessor } from './processors/alerts';
|
||||
import { CleanupProcessor } from './processors/cleanup';
|
||||
import { config } from './config';
|
||||
|
||||
const logger = pino({
|
||||
@@ -22,6 +23,7 @@ const logger = pino({
|
||||
|
||||
const metricsCollector = new MetricsCollector();
|
||||
const alertProcessor = new AlertProcessor();
|
||||
const cleanupProcessor = new CleanupProcessor();
|
||||
|
||||
// Health check server
|
||||
const healthServer = createServer((req, res) => {
|
||||
@@ -48,6 +50,7 @@ async function shutdown(signal: string): Promise<void> {
|
||||
healthServer.close();
|
||||
await metricsCollector.stop();
|
||||
await alertProcessor.stop();
|
||||
await cleanupProcessor.close();
|
||||
|
||||
logger.info('Shutdown complete');
|
||||
process.exit(0);
|
||||
@@ -73,12 +76,15 @@ async function start(): Promise<void> {
|
||||
await metricsCollector.refreshConnections();
|
||||
});
|
||||
|
||||
// Schedule cleanup job daily at 2 AM
|
||||
cron.schedule('0 2 * * *', async () => {
|
||||
// Schedule cleanup job every 6 hours
|
||||
cron.schedule('0 */6 * * *', async () => {
|
||||
logger.info('Running cleanup job...');
|
||||
// TODO: Implement cleanup (expired sessions, old data archival)
|
||||
await cleanupProcessor.runCleanup();
|
||||
});
|
||||
|
||||
// Run initial cleanup on startup
|
||||
await cleanupProcessor.runCleanup();
|
||||
|
||||
// Start health server
|
||||
healthServer.listen(config.PORT, () => {
|
||||
logger.info(`Health server listening on http://localhost:${config.PORT}/health`);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import Redis from 'ioredis';
|
||||
import pino from 'pino';
|
||||
import { config } from '../config';
|
||||
|
||||
const logger = pino({ name: 'cleanup-processor' });
|
||||
|
||||
// Session cleanup settings
|
||||
const SESSION_PREFIX = 'session:';
|
||||
const SESSION_INACTIVITY_THRESHOLD = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
// Password reset token prefix
|
||||
const PASSWORD_RESET_PREFIX = 'password_reset:';
|
||||
|
||||
// Rate limit prefix
|
||||
const RATE_LIMIT_PREFIX = 'ratelimit:';
|
||||
|
||||
export class CleanupProcessor {
|
||||
private redis: Redis;
|
||||
private running = false;
|
||||
|
||||
constructor() {
|
||||
this.redis = new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
this.redis.on('error', (err) => {
|
||||
logger.error({ err }, 'Redis connection error');
|
||||
});
|
||||
}
|
||||
|
||||
async runCleanup(): Promise<void> {
|
||||
if (this.running) {
|
||||
logger.warn('Cleanup already running, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
logger.info('Starting cleanup job...');
|
||||
|
||||
try {
|
||||
const results = await Promise.all([
|
||||
this.cleanupInactiveSessions(),
|
||||
this.cleanupExpiredPasswordResets(),
|
||||
this.cleanupOldRateLimitData(),
|
||||
]);
|
||||
|
||||
const [sessions, resets, rateLimits] = results;
|
||||
|
||||
logger.info(
|
||||
{
|
||||
sessionsDeleted: sessions,
|
||||
passwordResetsDeleted: resets,
|
||||
rateLimitsDeleted: rateLimits,
|
||||
},
|
||||
'Cleanup job completed'
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Cleanup job failed');
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupInactiveSessions(): Promise<number> {
|
||||
const now = Date.now();
|
||||
let deletedCount = 0;
|
||||
let cursor = '0';
|
||||
|
||||
try {
|
||||
do {
|
||||
// Scan for session keys
|
||||
const [nextCursor, keys] = await this.redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${SESSION_PREFIX}*`,
|
||||
'COUNT',
|
||||
100
|
||||
);
|
||||
cursor = nextCursor;
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const lastActivity = await this.redis.hget(key, 'lastActivity');
|
||||
if (lastActivity) {
|
||||
const lastActivityTime = new Date(lastActivity).getTime();
|
||||
if (now - lastActivityTime > SESSION_INACTIVITY_THRESHOLD) {
|
||||
await this.redis.del(key);
|
||||
deletedCount++;
|
||||
logger.debug({ key }, 'Deleted inactive session');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, key }, 'Error processing session key');
|
||||
}
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
logger.info({ count: deletedCount }, 'Inactive sessions cleaned up');
|
||||
return deletedCount;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error scanning sessions');
|
||||
return deletedCount;
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupExpiredPasswordResets(): Promise<number> {
|
||||
let deletedCount = 0;
|
||||
let cursor = '0';
|
||||
|
||||
try {
|
||||
do {
|
||||
// Scan for password reset keys
|
||||
const [nextCursor, keys] = await this.redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${PASSWORD_RESET_PREFIX}*`,
|
||||
'COUNT',
|
||||
100
|
||||
);
|
||||
cursor = nextCursor;
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
// Check if key still exists (may have been expired)
|
||||
const ttl = await this.redis.ttl(key);
|
||||
if (ttl === -2 || ttl === -1) {
|
||||
// Key doesn't exist or has no TTL, delete it
|
||||
await this.redis.del(key);
|
||||
deletedCount++;
|
||||
logger.debug({ key }, 'Deleted expired password reset token');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, key }, 'Error processing password reset key');
|
||||
}
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
logger.info({ count: deletedCount }, 'Expired password resets cleaned up');
|
||||
return deletedCount;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error scanning password resets');
|
||||
return deletedCount;
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupOldRateLimitData(): Promise<number> {
|
||||
let deletedCount = 0;
|
||||
let cursor = '0';
|
||||
const now = Date.now();
|
||||
// Rate limit window - clean up entries older than 1 hour
|
||||
const cleanupThreshold = now - 60 * 60 * 1000;
|
||||
|
||||
try {
|
||||
do {
|
||||
// Scan for rate limit keys
|
||||
const [nextCursor, keys] = await this.redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${RATE_LIMIT_PREFIX}*`,
|
||||
'COUNT',
|
||||
100
|
||||
);
|
||||
cursor = nextCursor;
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
// Remove old entries from sorted set
|
||||
const removed = await this.redis.zremrangebyscore(key, 0, cleanupThreshold);
|
||||
if (removed > 0) {
|
||||
deletedCount += removed;
|
||||
logger.debug({ key, removed }, 'Removed old rate limit entries');
|
||||
}
|
||||
|
||||
// If the sorted set is now empty, delete the key
|
||||
const count = await this.redis.zcard(key);
|
||||
if (count === 0) {
|
||||
await this.redis.del(key);
|
||||
logger.debug({ key }, 'Deleted empty rate limit key');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, key }, 'Error processing rate limit key');
|
||||
}
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
|
||||
logger.info({ count: deletedCount }, 'Old rate limit entries cleaned up');
|
||||
return deletedCount;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Error scanning rate limits');
|
||||
return deletedCount;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.redis.quit();
|
||||
logger.info('Cleanup processor closed');
|
||||
}
|
||||
}
|
||||
Generated
+232
@@ -56,12 +56,18 @@ importers:
|
||||
nats:
|
||||
specifier: ^2.28.2
|
||||
version: 2.29.3
|
||||
otpauth:
|
||||
specifier: ^9.3.5
|
||||
version: 9.4.1
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
pino-pretty:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.3
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.25.76
|
||||
@@ -826,6 +832,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1521,6 +1531,14 @@ packages:
|
||||
ajv@8.17.1:
|
||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
@@ -1596,6 +1614,10 @@ packages:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001759:
|
||||
resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
|
||||
|
||||
@@ -1624,6 +1646,9 @@ packages:
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1632,6 +1657,13 @@ packages:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
@@ -1674,6 +1706,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
deep-eql@5.0.2:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1710,6 +1746,9 @@ packages:
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
@@ -1738,6 +1777,9 @@ packages:
|
||||
electron-to-chromium@1.5.264:
|
||||
resolution: {integrity: sha512-1tEf0nLgltC3iy9wtlYDlQDc5Rg9lEKVjEmIHJ21rI9OcqkvD45K1oyNIRA4rR1z3LgJ7KeGzEBojVcV6m4qjA==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
empathic@2.0.0:
|
||||
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1841,6 +1883,10 @@ packages:
|
||||
resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -1856,6 +1902,10 @@ packages:
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-nonce@1.0.1:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1913,6 +1963,10 @@ packages:
|
||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1952,6 +2006,10 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
@@ -2093,6 +2151,25 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
otpauth@9.4.1:
|
||||
resolution: {integrity: sha512-+iVvys36CFsyXEqfNftQm1II7SW23W1wx9RwNk0Cd97lbvorqAhBDksb/0bYry087QMxjiuBS0wokdoZ0iUeAw==}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
path-exists@4.0.0:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
@@ -2152,6 +2229,10 @@ packages:
|
||||
pkg-types@2.3.0:
|
||||
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
postcss-import@15.1.0:
|
||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -2225,6 +2306,11 @@ packages:
|
||||
pure-rand@6.1.0:
|
||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -2306,10 +2392,17 @@ packages:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
@@ -2361,6 +2454,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
@@ -2407,9 +2503,17 @@ packages:
|
||||
stream-shift@1.0.3:
|
||||
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-json-comments@5.0.3:
|
||||
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||
engines: {node: '>=14.16'}
|
||||
@@ -2663,11 +2767,18 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
@@ -2687,6 +2798,17 @@ packages:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
@@ -3097,6 +3219,8 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@15.5.7':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -3755,6 +3879,12 @@ snapshots:
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
@@ -3837,6 +3967,8 @@ snapshots:
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001759: {}
|
||||
|
||||
chai@5.3.3:
|
||||
@@ -3875,10 +4007,22 @@ snapshots:
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
@@ -3901,6 +4045,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
deepmerge-ts@7.1.5: {}
|
||||
@@ -3922,6 +4068,8 @@ snapshots:
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
@@ -3956,6 +4104,8 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.264: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
empathic@2.0.0: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
@@ -4123,6 +4273,11 @@ snapshots:
|
||||
fast-querystring: 1.1.2
|
||||
safe-regex2: 5.0.0
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
@@ -4132,6 +4287,8 @@ snapshots:
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
@@ -4199,6 +4356,8 @@ snapshots:
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
@@ -4229,6 +4388,10 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
@@ -4349,6 +4512,22 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
otpauth@9.4.1:
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
pathe@1.1.2: {}
|
||||
@@ -4429,6 +4608,8 @@ snapshots:
|
||||
exsolve: 1.0.8
|
||||
pathe: 2.0.3
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
postcss-import@15.1.0(postcss@8.5.6):
|
||||
dependencies:
|
||||
postcss: 8.5.6
|
||||
@@ -4493,6 +4674,12 @@ snapshots:
|
||||
|
||||
pure-rand@6.1.0: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
@@ -4564,8 +4751,12 @@ snapshots:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
resolve@1.22.11:
|
||||
@@ -4628,6 +4819,8 @@ snapshots:
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
@@ -4694,10 +4887,20 @@ snapshots:
|
||||
|
||||
stream-shift@1.0.3: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-ansi@6.0.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-json-comments@5.0.3: {}
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.1):
|
||||
@@ -4933,17 +5136,46 @@ snapshots:
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zrender@5.6.1:
|
||||
|
||||
Reference in New Issue
Block a user