website-monitor/backend/src/routes/monitors.ts

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;