'use client'
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { toast } from 'sonner'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Select } from '@/components/ui/select'
import { VisualSelector } from '@/components/visual-selector'
import { monitorTemplates, applyTemplate, MonitorTemplate } from '@/lib/templates'
import { Sparkline } from '@/components/sparkline'
import { Monitor } from '@/lib/types'
import { usePlan } from '@/lib/use-plan'
const IGNORE_PRESETS = [
{ label: 'None', value: '' },
{ label: 'Timestamps & Dates', value: 'time, .time, .date, .datetime, .timestamp, .random, .updated, .modified, .posted, .published, [class*="time"], [class*="date"], [class*="timestamp"], [class*="updated"], [class*="modified"]' },
{ label: 'Cookie Banners', value: '[id*="cookie"], [class*="cookie"], [id*="consent"], [class*="consent"]' },
{ label: 'Social Widgets', value: '.social-share, .twitter-tweet, iframe[src*="youtube"]' },
{ label: 'Custom Selector', value: 'custom' },
]
const FREQUENCY_OPTIONS = [
{ value: 5, label: 'Every 5 minutes' },
{ value: 30, label: 'Every 30 minutes' },
{ value: 60, label: 'Every hour' },
{ value: 360, label: 'Every 6 hours' },
{ value: 1440, label: 'Every 24 hours' },
]
// Stats card component
function StatCard({ icon, label, value, subtext, color }: {
icon: React.ReactNode
label: string
value: string | number
subtext?: string
color: 'green' | 'amber' | 'red' | 'blue'
}) {
const colorClasses = {
green: {
container: 'bg-green-50 text-green-600 border border-green-200',
iconBg: 'bg-white shadow-sm'
},
amber: {
container: 'bg-amber-50 text-amber-600 border border-amber-200',
iconBg: 'bg-white shadow-sm'
},
red: {
container: 'bg-red-50 text-red-600 border border-red-200',
iconBg: 'bg-white shadow-sm'
},
blue: {
container: 'bg-blue-50 text-blue-600 border border-blue-200',
iconBg: 'bg-white shadow-sm'
},
}
const currentColor = colorClasses[color]
return (
{icon}
{value}
{label}
{subtext &&
{subtext}
}
)
}
export default function MonitorsPage() {
const router = useRouter()
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
const [showAddForm, setShowAddForm] = useState(false)
const [checkingId, setCheckingId] = useState(null)
const [checkingSeoId, setCheckingSeoId] = useState(null)
const [editingId, setEditingId] = useState(null)
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'error' | 'paused'>('all')
const [newMonitor, setNewMonitor] = useState({
url: '',
name: '',
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: [] as Array<{
keyword: string
type: 'appears' | 'disappears' | 'count'
threshold?: number
caseSensitive?: boolean
}>,
seoKeywords: [] as string[],
seoInterval: 'off',
})
const [showVisualSelector, setShowVisualSelector] = useState(false)
const [showTemplates, setShowTemplates] = useState(false)
const { data, isLoading, refetch } = useQuery({
queryKey: ['monitors'],
queryFn: async () => {
const response = await monitorAPI.list()
return response.monitors
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const payload: any = {
url: newMonitor.url,
name: newMonitor.name,
frequency: newMonitor.frequency,
}
if (newMonitor.ignoreSelector) {
payload.ignoreRules = [{ type: 'css', value: newMonitor.ignoreSelector }]
}
if (newMonitor.keywordRules.length > 0) {
payload.keywordRules = newMonitor.keywordRules
}
if (newMonitor.seoKeywords.length > 0) {
payload.seoKeywords = newMonitor.seoKeywords
payload.seoInterval = newMonitor.seoInterval
}
if (editingId) {
await monitorAPI.update(editingId, payload)
toast.success('Monitor updated successfully')
} else {
await monitorAPI.create(payload)
toast.success('Monitor created successfully')
}
setNewMonitor({
url: '',
name: '',
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
})
setShowAddForm(false)
setEditingId(null)
refetch()
} catch (err: any) {
console.error('Failed to save monitor:', err)
toast.error(err.response?.data?.message || 'Failed to save monitor')
}
}
const handleEdit = (monitor: any) => {
let selectedPreset = ''
let ignoreSelector = ''
if (monitor.ignoreRules && monitor.ignoreRules.length > 0) {
const ruleValue = monitor.ignoreRules[0].value
const matchingPreset = IGNORE_PRESETS.find(p => p.value === ruleValue)
if (matchingPreset) {
selectedPreset = ruleValue
ignoreSelector = ruleValue
} else {
selectedPreset = 'custom'
ignoreSelector = ruleValue
}
}
setNewMonitor({
url: monitor.url,
name: monitor.name || '',
frequency: monitor.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitor.keywordRules || [],
seoKeywords: monitor.seoKeywords || [],
seoInterval: monitor.seoInterval || 'off',
})
setEditingId(monitor.id)
setShowAddForm(true)
}
const handleCancelForm = () => {
setShowAddForm(false)
setEditingId(null)
setNewMonitor({
url: '',
name: '',
frequency: 60,
ignoreSelector: '',
selectedPreset: '',
keywordRules: [],
seoKeywords: [],
seoInterval: 'off',
})
}
const handleSelectTemplate = (template: MonitorTemplate) => {
const monitorData = applyTemplate(template, template.urlPlaceholder)
// Convert ignoreRules to format expected by form
let ignoreSelector = ''
let selectedPreset = ''
if (monitorData.ignoreRules && monitorData.ignoreRules.length > 0) {
// Use first rule for now as form supports single selector
const rule = monitorData.ignoreRules[0]
if (rule.type === 'css') {
ignoreSelector = rule.value
selectedPreset = 'custom'
// Check if matches preset
const preset = IGNORE_PRESETS.find(p => p.value === rule.value)
if (preset) selectedPreset = preset.value
}
}
setNewMonitor({
url: monitorData.url,
name: monitorData.name,
frequency: monitorData.frequency,
ignoreSelector,
selectedPreset,
keywordRules: monitorData.keywordRules as any[],
seoKeywords: [],
seoInterval: 'off',
})
setShowTemplates(false)
setShowAddForm(true)
}
const handleCheckNow = async (id: string, type: 'content' | 'seo' = 'content') => {
// Prevent multiple simultaneous checks of the same type
if (type === 'seo') {
if (checkingSeoId !== null) return
setCheckingSeoId(id)
} else {
if (checkingId !== null) return
setCheckingId(id)
}
try {
const result = await monitorAPI.check(id, type)
if (type === 'seo') {
toast.success('SEO Ranking check completed')
// For SEO check, we might want to refresh rankings specifically if we had a way
} else {
if (result.snapshot?.errorMessage) {
toast.error(`Check failed: ${result.snapshot.errorMessage}`)
} else if (result.snapshot?.changed) {
toast.success('Changes detected!', {
action: {
label: 'View',
onClick: () => router.push(`/monitors/${id}`)
}
})
} else {
toast.info('No changes detected')
}
}
refetch()
} catch (err: any) {
console.error('Failed to trigger check:', err)
toast.error(err.response?.data?.message || `Failed to check ${type === 'seo' ? 'SEO' : 'monitor'}`)
} finally {
if (type === 'seo') {
setCheckingSeoId(null)
} else {
setCheckingId(null)
}
}
}
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this monitor?')) return
try {
await monitorAPI.delete(id)
toast.success('Monitor deleted')
refetch()
} catch (err) {
console.error('Failed to delete monitor:', err)
toast.error('Failed to delete monitor')
}
}
const monitors = useMemo(() => data ?? [], [data])
const filteredMonitors = useMemo(() => {
if (filterStatus === 'all') return monitors
return monitors.filter((m: any) => m.status === filterStatus)
}, [monitors, filterStatus])
// Calculate stats
const stats = useMemo(() => {
const total = monitors.length
const active = monitors.filter((m: any) => m.status === 'active').length
const errors = monitors.filter((m: any) => m.status === 'error').length
const avgUptime = total > 0 ? ((active / total) * 100).toFixed(1) : '0'
return { total, active, errors, avgUptime }
}, [monitors])
if (isLoading) {
return (
)
}
return (
{/* Stats Overview */}
}
label="Total Monitors"
value={stats.total}
color="blue"
/>
}
label="Active"
value={stats.active}
subtext="Running smoothly"
color="green"
/>
}
label="Errors"
value={stats.errors}
subtext="Needs attention"
color="red"
/>
}
label="Avg Uptime"
value={`${stats.avgUptime}%`}
subtext="Last 30 days"
color="amber"
/>
{/* Template Selection Modal */}
{
showTemplates && (
Choose a Template
Start with a pre-configured monitor setup
{monitorTemplates.map(template => (
))}
)
}
{/* Visual Selector Modal */}
{
showVisualSelector && (
{
setNewMonitor({ ...newMonitor, ignoreSelector: selector, selectedPreset: 'custom' })
setShowVisualSelector(false)
}}
onClose={() => setShowVisualSelector(false)}
/>
)
}
{/* Actions Bar */}
{/* Filter Tabs */}
{(['all', 'active', 'error'] as const).map((status) => (
))}
{/* View Toggle */}
{/* Add/Edit Monitor Form */}
{
showAddForm && (
{editingId ? 'Edit Monitor' : 'Add New Monitor'}
{editingId ? 'Update your monitor settings' : 'Enter the details for your new website monitor'}
)
}
{/* Monitors Grid/List */}
{
filteredMonitors.length === 0 ? (
{filterStatus === 'all' ? 'No monitors yet' : `No ${filterStatus} monitors`}
{filterStatus === 'all'
? 'Start monitoring your first website to get notified of changes'
: 'Try changing the filter to see other monitors'}
{filterStatus === 'all' && (
)}
) : viewMode === 'grid' ? (
/* Grid View */
{filteredMonitors.map((monitor: any) => (
{/* Monitor Info */}
{monitor.name || new URL(monitor.url).hostname}
{monitor.status}
{monitor.url}
{/* Stats Row */}
{/* SEO Status */}
{monitor.seoInterval && monitor.seoInterval !== 'off' && (
{monitor.seoInterval === '2d' ? 'Every 2 days' : monitor.seoInterval}
SEO Check
{monitor.lastSeoCheckAt ? (
<>
{new Date(monitor.lastSeoCheckAt).toLocaleDateString()}
Last SEO
>
) : (
<>
-
Last SEO
>
)}
)}
{monitor.frequency}m
Frequency
{monitor.lastChangedAt ? (
<>
{new Date(monitor.lastChangedAt).toLocaleDateString()}
Last Change
>
) : (
<>
-
Last Change
>
)}
{/* Last Checked */}
{monitor.lastCheckedAt ? (
Last checked: {new Date(monitor.lastCheckedAt).toLocaleString()}
) : (
Not checked yet
)}
{/* SEO Rankings */}
{monitor.latestRankings && monitor.latestRankings.length > 0 && (
Top Rankings
{monitor.latestRankings.slice(0, 3).map((r: any, idx: number) => (
{r.keyword}
#{r.rank || '100+'}
))}
)}
{/* Change Summary */}
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (
"{monitor.recentSnapshots[0].summary}"
)}
{/* Sparkline & Importance */}
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && (
Response Time
s.responseTime).reverse()}
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
height={30}
width={100}
/>
Importance
70 ? 'border-red-200 bg-red-50 text-red-700' :
(monitor.recentSnapshots[0].importanceScore || 0) > 40 ? 'border-amber-200 bg-amber-50 text-amber-700' :
'border-slate-200 bg-slate-50 text-slate-700'
}`}>
{monitor.recentSnapshots[0].importanceScore || 0}/100
)}
{/* Actions */}
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
)}
)
)}
) : (
/* List View */
{filteredMonitors.map((monitor: any) => {
return (
{/* Status Indicator */}
{/* Monitor Info */}
{monitor.name || new URL(monitor.url).hostname}
{monitor.status}
{monitor.url}
{/* Stats */}
{monitor.frequency}m
Frequency
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && monitor.recentSnapshots[0].importanceScore !== undefined ? (
70 ? 'border-red-200 bg-red-50 text-red-700' :
(monitor.recentSnapshots[0].importanceScore || 0) > 40 ? 'border-amber-200 bg-amber-50 text-amber-700' :
'border-slate-200 bg-slate-50 text-slate-700'
}`}>
{monitor.recentSnapshots[0].importanceScore}/100
) : (
-
)}
Importance
{monitor.recentSnapshots && monitor.recentSnapshots.length > 1 && (
s.responseTime).reverse()}
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
height={24}
width={96}
/>
Response Time
)}
{monitor.lastChangedAt ? new Date(monitor.lastChangedAt).toLocaleDateString() : '-'}
Last Change
{/* Actions */}
{monitor.seoKeywords && monitor.seoKeywords.length > 0 && (
)}
)
})}
)
}
)
}