fix analytics

add 2fa
add multi line charts.
This commit is contained in:
Fizer Khan
2025-12-05 16:46:45 +05:30
parent 66569fe2ca
commit 136a58dd83
22 changed files with 2982 additions and 120 deletions
+2
View File
@@ -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"
},
+221
View File
@@ -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);
}
+111 -2
View File
@@ -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;
+7
View File
@@ -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() {
+6
View File
@@ -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' }
);
+219
View File
@@ -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.
`,
});
}
+316 -8
View File
@@ -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 };
}
}
);
};
+135
View File
@@ -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);
});
};
+137 -22
View File
@@ -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,
+11 -12
View File
@@ -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,
};
});
};
+89 -19
View File
@@ -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>
);
}
+174 -7
View File
@@ -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
View File
@@ -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 }} />;
}
+40
View File
@@ -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
+9 -3
View File
@@ -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`);
+202
View File
@@ -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');
}
}
+232
View File
@@ -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: