615 lines
19 KiB
TypeScript
615 lines
19 KiB
TypeScript
import { Router, Response } from 'express';
|
|
import { z } from 'zod';
|
|
import db from '../db';
|
|
import { AuthRequest } from '../middleware/auth';
|
|
import { checkLimiter } from '../middleware/rateLimiter';
|
|
import { MonitorFrequency, Monitor } from '../types';
|
|
import { checkMonitor, scheduleMonitor, unscheduleMonitor, rescheduleMonitor } from '../services/monitor';
|
|
|
|
const router = Router();
|
|
|
|
const createMonitorSchema = z.object({
|
|
url: z.string().url(),
|
|
name: z.string().optional(),
|
|
frequency: z.number().int().positive(),
|
|
elementSelector: z.string().optional(),
|
|
ignoreRules: z
|
|
.array(
|
|
z.object({
|
|
type: z.enum(['css', 'regex', 'text']),
|
|
value: z.string(),
|
|
})
|
|
)
|
|
.optional(),
|
|
keywordRules: z
|
|
.array(
|
|
z.object({
|
|
keyword: z.string(),
|
|
type: z.enum(['appears', 'disappears', 'count']),
|
|
threshold: z.number().optional(),
|
|
caseSensitive: z.boolean().optional(),
|
|
})
|
|
)
|
|
.optional(),
|
|
});
|
|
|
|
const updateMonitorSchema = z.object({
|
|
name: z.string().optional(),
|
|
frequency: z.number().int().positive().optional(),
|
|
status: z.enum(['active', 'paused', 'error']).optional(),
|
|
elementSelector: z.string().optional(),
|
|
ignoreRules: z
|
|
.array(
|
|
z.object({
|
|
type: z.enum(['css', 'regex', 'text']),
|
|
value: z.string(),
|
|
})
|
|
)
|
|
.optional(),
|
|
keywordRules: z
|
|
.array(
|
|
z.object({
|
|
keyword: z.string(),
|
|
type: z.enum(['appears', 'disappears', 'count']),
|
|
threshold: z.number().optional(),
|
|
caseSensitive: z.boolean().optional(),
|
|
})
|
|
)
|
|
.optional(),
|
|
});
|
|
|
|
// Get plan limits
|
|
function getPlanLimits(plan: string) {
|
|
const limits = {
|
|
free: {
|
|
maxMonitors: parseInt(process.env.MAX_MONITORS_FREE || '5'),
|
|
minFrequency: parseInt(process.env.MIN_FREQUENCY_FREE || '60'),
|
|
},
|
|
pro: {
|
|
maxMonitors: parseInt(process.env.MAX_MONITORS_PRO || '50'),
|
|
minFrequency: parseInt(process.env.MIN_FREQUENCY_PRO || '5'),
|
|
},
|
|
business: {
|
|
maxMonitors: parseInt(process.env.MAX_MONITORS_BUSINESS || '200'),
|
|
minFrequency: parseInt(process.env.MIN_FREQUENCY_BUSINESS || '1'),
|
|
},
|
|
enterprise: {
|
|
maxMonitors: 999999,
|
|
minFrequency: 1,
|
|
},
|
|
};
|
|
|
|
return limits[plan as keyof typeof limits] || limits.free;
|
|
}
|
|
|
|
// List monitors
|
|
router.get('/', async (req: AuthRequest, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const monitors = await db.monitors.findByUserId(req.user.userId);
|
|
|
|
// Attach recent snapshots to each monitor for sparklines
|
|
const monitorsWithSnapshots = await Promise.all(monitors.map(async (monitor) => {
|
|
// Get last 20 snapshots for sparkline
|
|
const recentSnapshots = await db.snapshots.findByMonitorId(monitor.id, 20);
|
|
return {
|
|
...monitor,
|
|
recentSnapshots
|
|
};
|
|
}));
|
|
|
|
res.json({ monitors: monitorsWithSnapshots });
|
|
} catch (error) {
|
|
console.error('List monitors error:', error);
|
|
res.status(500).json({ error: 'server_error', message: 'Failed to list monitors' });
|
|
}
|
|
});
|
|
|
|
// Get monitor by ID
|
|
router.get('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const monitor = await db.monitors.findById(req.params.id);
|
|
|
|
if (!monitor) {
|
|
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
|
return;
|
|
}
|
|
|
|
if (monitor.userId !== req.user.userId) {
|
|
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
|
return;
|
|
}
|
|
|
|
res.json({ monitor });
|
|
} catch (error) {
|
|
console.error('Get monitor error:', error);
|
|
res.status(500).json({ error: 'server_error', message: 'Failed to get monitor' });
|
|
}
|
|
});
|
|
|
|
// Create monitor
|
|
router.post('/', async (req: AuthRequest, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const input = createMonitorSchema.parse(req.body);
|
|
|
|
// Check plan limits (fetch fresh user data)
|
|
const currentUser = await db.users.findById(req.user.userId);
|
|
const plan = currentUser?.plan || req.user.plan;
|
|
const limits = getPlanLimits(plan);
|
|
const currentCount = await db.monitors.countByUserId(req.user.userId);
|
|
|
|
if (currentCount >= limits.maxMonitors) {
|
|
res.status(403).json({
|
|
error: 'limit_exceeded',
|
|
message: `Your ${plan} plan allows max ${limits.maxMonitors} monitors`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (input.frequency < limits.minFrequency) {
|
|
res.status(403).json({
|
|
error: 'invalid_frequency',
|
|
message: `Your ${plan} plan requires minimum ${limits.minFrequency} minute frequency`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Extract domain from URL for name if not provided
|
|
const name = input.name || new URL(input.url).hostname;
|
|
|
|
const monitor = await db.monitors.create({
|
|
userId: req.user.userId,
|
|
url: input.url,
|
|
name,
|
|
frequency: input.frequency as MonitorFrequency,
|
|
status: 'active',
|
|
elementSelector: input.elementSelector,
|
|
ignoreRules: input.ignoreRules,
|
|
keywordRules: input.keywordRules,
|
|
});
|
|
|
|
// Schedule recurring checks
|
|
try {
|
|
await scheduleMonitor(monitor);
|
|
console.log(`Monitor ${monitor.id} scheduled successfully`);
|
|
} catch (err) {
|
|
console.error('Failed to schedule monitor:', err);
|
|
}
|
|
|
|
// Perform first check immediately
|
|
checkMonitor(monitor.id).catch((err) =>
|
|
console.error('Initial check failed:', err)
|
|
);
|
|
|
|
res.status(201).json({ monitor });
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
res.status(400).json({
|
|
error: 'validation_error',
|
|
message: 'Invalid input',
|
|
details: error.errors,
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.error('Create monitor error:', error);
|
|
res.status(500).json({ error: 'server_error', message: 'Failed to create monitor' });
|
|
}
|
|
});
|
|
|
|
// Update monitor
|
|
router.put('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const monitor = await db.monitors.findById(req.params.id);
|
|
|
|
if (!monitor) {
|
|
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
|
return;
|
|
}
|
|
|
|
if (monitor.userId !== req.user.userId) {
|
|
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
|
return;
|
|
}
|
|
|
|
const input = updateMonitorSchema.parse(req.body);
|
|
|
|
// Check frequency limit if being updated
|
|
if (input.frequency) {
|
|
// Fetch fresh user data to get current plan
|
|
const currentUser = await db.users.findById(req.user.userId);
|
|
const plan = currentUser?.plan || req.user.plan;
|
|
const limits = getPlanLimits(plan);
|
|
|
|
if (input.frequency < limits.minFrequency) {
|
|
res.status(403).json({
|
|
error: 'invalid_frequency',
|
|
message: `Your ${plan} plan requires minimum ${limits.minFrequency} minute frequency`,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const updateData: Partial<Monitor> = {
|
|
...input,
|
|
frequency: input.frequency as MonitorFrequency | undefined,
|
|
};
|
|
const updated = await db.monitors.update(req.params.id, updateData);
|
|
|
|
if (!updated) {
|
|
res.status(500).json({ error: 'update_failed', message: 'Failed to update monitor' });
|
|
return;
|
|
}
|
|
|
|
// Reschedule if frequency changed or status changed to/from active
|
|
const needsRescheduling =
|
|
input.frequency !== undefined ||
|
|
(input.status && (input.status === 'active' || monitor.status === 'active'));
|
|
|
|
if (needsRescheduling) {
|
|
try {
|
|
if (updated.status === 'active') {
|
|
await rescheduleMonitor(updated);
|
|
console.log(`Monitor ${updated.id} rescheduled`);
|
|
} else {
|
|
await unscheduleMonitor(updated.id);
|
|
console.log(`Monitor ${updated.id} unscheduled (status: ${updated.status})`);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to reschedule monitor:', err);
|
|
}
|
|
}
|
|
|
|
res.json({ monitor: updated });
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
res.status(400).json({
|
|
error: 'validation_error',
|
|
message: 'Invalid input',
|
|
details: error.errors,
|
|
});
|
|
return;
|
|
}
|
|
|
|
console.error('Update monitor error:', error);
|
|
res.status(500).json({ error: 'server_error', message: 'Failed to update monitor' });
|
|
}
|
|
});
|
|
|
|
// Delete monitor
|
|
router.delete('/:id', async (req: AuthRequest, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const monitor = await db.monitors.findById(req.params.id);
|
|
|
|
if (!monitor) {
|
|
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
|
return;
|
|
}
|
|
|
|
if (monitor.userId !== req.user.userId) {
|
|
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
|
return;
|
|
}
|
|
|
|
// Unschedule before deleting
|
|
try {
|
|
await unscheduleMonitor(req.params.id);
|
|
console.log(`Monitor ${req.params.id} unscheduled before deletion`);
|
|
} catch (err) {
|
|
console.error('Failed to unschedule monitor:', err);
|
|
}
|
|
|
|
await db.monitors.delete(req.params.id);
|
|
|
|
res.json({ message: 'Monitor deleted successfully' });
|
|
} catch (error) {
|
|
console.error('Delete monitor error:', error);
|
|
res.status(500).json({ error: 'server_error', message: 'Failed to delete monitor' });
|
|
}
|
|
});
|
|
|
|
// Trigger manual check
|
|
router.post('/:id/check', checkLimiter, async (req: AuthRequest, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const monitor = await db.monitors.findById(req.params.id);
|
|
|
|
if (!monitor) {
|
|
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
|
return;
|
|
}
|
|
|
|
if (monitor.userId !== req.user.userId) {
|
|
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
|
return;
|
|
}
|
|
|
|
// Await the check so user gets immediate feedback
|
|
try {
|
|
await checkMonitor(monitor.id);
|
|
|
|
// Get the latest snapshot to return to the user
|
|
const latestSnapshot = await db.snapshots.findLatestByMonitorId(monitor.id);
|
|
const updatedMonitor = await db.monitors.findById(monitor.id);
|
|
|
|
res.json({
|
|
message: 'Check completed successfully',
|
|
monitor: updatedMonitor,
|
|
snapshot: latestSnapshot ? {
|
|
id: latestSnapshot.id,
|
|
changed: latestSnapshot.changed,
|
|
changePercentage: latestSnapshot.changePercentage,
|
|
httpStatus: latestSnapshot.httpStatus,
|
|
responseTime: latestSnapshot.responseTime,
|
|
createdAt: latestSnapshot.createdAt,
|
|
errorMessage: latestSnapshot.errorMessage,
|
|
} : null,
|
|
});
|
|
} catch (checkError: any) {
|
|
console.error('Check failed:', checkError);
|
|
res.status(500).json({
|
|
error: 'check_failed',
|
|
message: checkError.message || 'Failed to check monitor'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Trigger check error:', error);
|
|
res.status(500).json({ error: 'server_error', message: 'Failed to trigger check' });
|
|
}
|
|
});
|
|
|
|
// Get monitor history (snapshots)
|
|
router.get('/:id/history', async (req: AuthRequest, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const monitor = await db.monitors.findById(req.params.id);
|
|
|
|
if (!monitor) {
|
|
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
|
return;
|
|
}
|
|
|
|
if (monitor.userId !== req.user.userId) {
|
|
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
|
return;
|
|
}
|
|
|
|
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
|
const snapshots = await db.snapshots.findByMonitorId(req.params.id, limit);
|
|
|
|
res.json({ snapshots });
|
|
} catch (error) {
|
|
console.error('Get history error:', error);
|
|
res.status(500).json({ error: 'server_error', message: 'Failed to get history' });
|
|
}
|
|
});
|
|
|
|
// Get specific snapshot
|
|
router.get(
|
|
'/:id/history/:snapshotId',
|
|
async (req: AuthRequest, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
const monitor = await db.monitors.findById(req.params.id);
|
|
|
|
if (!monitor) {
|
|
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
|
return;
|
|
}
|
|
|
|
if (monitor.userId !== req.user.userId) {
|
|
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
|
return;
|
|
}
|
|
|
|
const snapshot = await db.snapshots.findById(req.params.snapshotId);
|
|
|
|
if (!snapshot || snapshot.monitorId !== req.params.id) {
|
|
res.status(404).json({ error: 'not_found', message: 'Snapshot not found' });
|
|
return;
|
|
}
|
|
|
|
res.json({ snapshot });
|
|
} catch (error) {
|
|
console.error('Get snapshot error:', error);
|
|
res.status(500).json({ error: 'server_error', message: 'Failed to get snapshot' });
|
|
}
|
|
}
|
|
);
|
|
|
|
// Export monitor audit trail (JSON or CSV)
|
|
router.get('/:id/export', async (req: AuthRequest, res: Response): Promise<void> => {
|
|
try {
|
|
if (!req.user) {
|
|
res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
|
|
return;
|
|
}
|
|
|
|
// Check if user has export feature (PRO+)
|
|
const user = await db.users.findById(req.user.userId);
|
|
if (!user) {
|
|
res.status(404).json({ error: 'not_found', message: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
// Allow export for all users for now, but in production check plan
|
|
// if (!hasFeature(user.plan, 'audit_export')) {
|
|
// res.status(403).json({ error: 'forbidden', message: 'Export feature requires Pro plan' });
|
|
// return;
|
|
// }
|
|
|
|
const monitor = await db.monitors.findById(req.params.id);
|
|
|
|
if (!monitor) {
|
|
res.status(404).json({ error: 'not_found', message: 'Monitor not found' });
|
|
return;
|
|
}
|
|
|
|
if (monitor.userId !== req.user.userId) {
|
|
res.status(403).json({ error: 'forbidden', message: 'Access denied' });
|
|
return;
|
|
}
|
|
|
|
const format = (req.query.format as string)?.toLowerCase() || 'json';
|
|
const fromDate = req.query.from ? new Date(req.query.from as string) : undefined;
|
|
const toDate = req.query.to ? new Date(req.query.to as string) : undefined;
|
|
|
|
// Get all snapshots (up to 1000)
|
|
let snapshots = await db.snapshots.findByMonitorId(monitor.id, 1000);
|
|
|
|
// Filter by date range if provided
|
|
if (fromDate) {
|
|
snapshots = snapshots.filter(s => new Date(s.createdAt) >= fromDate);
|
|
}
|
|
if (toDate) {
|
|
snapshots = snapshots.filter(s => new Date(s.createdAt) <= toDate);
|
|
}
|
|
|
|
// Get alerts for this monitor
|
|
const allAlerts = await db.alerts.findByUserId(req.user.userId, 1000);
|
|
const monitorAlerts = allAlerts.filter(a => a.monitorId === monitor.id);
|
|
|
|
// Filter alerts by date range if provided
|
|
let filteredAlerts = monitorAlerts;
|
|
if (fromDate) {
|
|
filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) >= fromDate);
|
|
}
|
|
if (toDate) {
|
|
filteredAlerts = filteredAlerts.filter(a => new Date(a.createdAt) <= toDate);
|
|
}
|
|
|
|
const exportData = {
|
|
monitor: {
|
|
id: monitor.id,
|
|
name: monitor.name,
|
|
url: monitor.url,
|
|
frequency: monitor.frequency,
|
|
status: monitor.status,
|
|
createdAt: monitor.createdAt,
|
|
},
|
|
exportedAt: new Date().toISOString(),
|
|
dateRange: {
|
|
from: fromDate?.toISOString() || 'start',
|
|
to: toDate?.toISOString() || 'now',
|
|
},
|
|
summary: {
|
|
totalChecks: snapshots.length,
|
|
changesDetected: snapshots.filter(s => s.changed).length,
|
|
errorsDetected: snapshots.filter(s => s.errorMessage).length,
|
|
totalAlerts: filteredAlerts.length,
|
|
},
|
|
checks: snapshots.map(s => ({
|
|
id: s.id,
|
|
timestamp: s.createdAt,
|
|
changed: s.changed,
|
|
changePercentage: s.changePercentage,
|
|
httpStatus: s.httpStatus,
|
|
responseTime: s.responseTime,
|
|
errorMessage: s.errorMessage,
|
|
})),
|
|
alerts: filteredAlerts.map(a => ({
|
|
id: a.id,
|
|
type: a.type,
|
|
title: a.title,
|
|
summary: a.summary,
|
|
channels: a.channels,
|
|
createdAt: a.createdAt,
|
|
deliveredAt: a.deliveredAt,
|
|
})),
|
|
};
|
|
|
|
if (format === 'csv') {
|
|
// Generate CSV
|
|
const csvLines: string[] = [];
|
|
|
|
// Header
|
|
csvLines.push('Type,Timestamp,Changed,Change %,HTTP Status,Response Time (ms),Error,Alert Type,Alert Title');
|
|
|
|
// Checks
|
|
for (const check of exportData.checks) {
|
|
csvLines.push([
|
|
'check',
|
|
check.timestamp,
|
|
check.changed ? 'true' : 'false',
|
|
check.changePercentage?.toFixed(2) || '',
|
|
check.httpStatus,
|
|
check.responseTime,
|
|
`"${(check.errorMessage || '').replace(/"/g, '""')}"`,
|
|
'',
|
|
'',
|
|
].join(','));
|
|
}
|
|
|
|
// Alerts
|
|
for (const alert of exportData.alerts) {
|
|
csvLines.push([
|
|
'alert',
|
|
alert.createdAt,
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
'',
|
|
alert.type,
|
|
`"${(alert.title || '').replace(/"/g, '""')}"`,
|
|
].join(','));
|
|
}
|
|
|
|
const csv = csvLines.join('\n');
|
|
const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.csv`;
|
|
|
|
res.setHeader('Content-Type', 'text/csv');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
res.send(csv);
|
|
} else {
|
|
// JSON format
|
|
const filename = `${monitor.name.replace(/[^a-zA-Z0-9]/g, '_')}_audit_${new Date().toISOString().split('T')[0]}.json`;
|
|
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
|
res.json(exportData);
|
|
}
|
|
} catch (error) {
|
|
console.error('Export error:', error);
|
|
res.status(500).json({ error: 'server_error', message: 'Failed to export audit trail' });
|
|
}
|
|
});
|
|
|
|
export default router;
|