gitea
This commit is contained in:
932
frontend/app/monitors/page.tsx
Normal file
932
frontend/app/monitors/page.tsx
Normal file
@@ -0,0 +1,932 @@
|
||||
'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 (
|
||||
<div className={`rounded-xl p-4 ${currentColor.container}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${currentColor.iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-xs opacity-80">{label}</p>
|
||||
{subtext && <p className="text-xs opacity-60">{subtext}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MonitorsPage() {
|
||||
const router = useRouter()
|
||||
const { plan, maxMonitors, minFrequency, canUseKeywords } = usePlan()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [checkingId, setCheckingId] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(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
|
||||
}>,
|
||||
})
|
||||
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 (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: []
|
||||
})
|
||||
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 || []
|
||||
})
|
||||
setEditingId(monitor.id)
|
||||
setShowAddForm(true)
|
||||
}
|
||||
|
||||
const handleCancelForm = () => {
|
||||
setShowAddForm(false)
|
||||
setEditingId(null)
|
||||
setNewMonitor({
|
||||
url: '',
|
||||
name: '',
|
||||
frequency: 60,
|
||||
ignoreSelector: '',
|
||||
selectedPreset: '',
|
||||
keywordRules: []
|
||||
})
|
||||
}
|
||||
|
||||
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[]
|
||||
})
|
||||
setShowTemplates(false)
|
||||
setShowAddForm(true)
|
||||
}
|
||||
|
||||
|
||||
const handleCheckNow = async (id: string) => {
|
||||
// Prevent multiple simultaneous checks
|
||||
if (checkingId !== null) return
|
||||
|
||||
setCheckingId(id)
|
||||
try {
|
||||
const result = await monitorAPI.check(id)
|
||||
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 monitor')
|
||||
} finally {
|
||||
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 = 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 (
|
||||
<DashboardLayout title="Monitors" description="Manage and monitor your websites">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
<p className="text-muted-foreground">Loading monitors...</p>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardLayout title="Monitors" description="Manage and monitor your websites">
|
||||
{/* Stats Overview */}
|
||||
<div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>}
|
||||
label="Total Monitors"
|
||||
value={stats.total}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>}
|
||||
label="Active"
|
||||
value={stats.active}
|
||||
subtext="Running smoothly"
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>}
|
||||
label="Errors"
|
||||
value={stats.errors}
|
||||
subtext="Needs attention"
|
||||
color="red"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>}
|
||||
label="Avg Uptime"
|
||||
value={`${stats.avgUptime}%`}
|
||||
subtext="Last 30 days"
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Template Selection Modal */}
|
||||
{
|
||||
showTemplates && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<Card className="max-h-[85vh] w-full max-w-4xl overflow-y-auto">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Choose a Template</CardTitle>
|
||||
<CardDescription>Start with a pre-configured monitor setup</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowTemplates(false)}>✕</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{monitorTemplates.map(template => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
className="flex flex-col items-start gap-2 rounded-lg border p-4 text-left shadow-sm transition-all hover:border-primary hover:bg-primary/5 hover:shadow-md"
|
||||
>
|
||||
<span className="text-2xl">{template.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">{template.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{template.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Visual Selector Modal */}
|
||||
{
|
||||
showVisualSelector && (
|
||||
<VisualSelector
|
||||
url={newMonitor.url}
|
||||
onSelect={(selector) => {
|
||||
setNewMonitor({ ...newMonitor, ignoreSelector: selector, selectedPreset: 'custom' })
|
||||
setShowVisualSelector(false)
|
||||
}}
|
||||
onClose={() => setShowVisualSelector(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex rounded-lg border bg-muted/30 p-1">
|
||||
{(['all', 'active', 'error'] as const).map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => setFilterStatus(status)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${filterStatus === status
|
||||
? 'bg-white text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{status === 'all' ? 'All' : status === 'active' ? 'Active' : 'Errors'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex rounded-lg border bg-muted/30 p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`rounded-md p-1.5 transition-colors ${viewMode === 'grid' ? 'bg-white shadow-sm' : 'text-muted-foreground'}`}
|
||||
title="Grid view"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`rounded-md p-1.5 transition-colors ${viewMode === 'list' ? 'bg-white shadow-sm' : 'text-muted-foreground'}`}
|
||||
title="List view"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowTemplates(true)}>
|
||||
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
Templates
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={monitors.length >= maxMonitors}>
|
||||
<svg className="mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Monitor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Add/Edit Monitor Form */}
|
||||
{
|
||||
showAddForm && (
|
||||
<Card className="mb-6 animate-fade-in border-primary/20 bg-gradient-to-br from-primary/5 to-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>{editingId ? 'Edit Monitor' : 'Add New Monitor'}</CardTitle>
|
||||
<CardDescription>
|
||||
{editingId ? 'Update your monitor settings' : 'Enter the details for your new website monitor'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="URL"
|
||||
type="url"
|
||||
value={newMonitor.url}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, url: e.target.value })}
|
||||
placeholder="https://example.com"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Name (optional)"
|
||||
type="text"
|
||||
value={newMonitor.name}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, name: e.target.value })}
|
||||
placeholder="My Monitor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Check Frequency"
|
||||
value={newMonitor.frequency}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, frequency: parseInt(e.target.value) })}
|
||||
options={FREQUENCY_OPTIONS.map(opt => ({
|
||||
...opt,
|
||||
disabled: opt.value < minFrequency,
|
||||
label: opt.value < minFrequency ? `${opt.label} (Pro)` : opt.label
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label="Ignore Content"
|
||||
value={newMonitor.selectedPreset}
|
||||
onChange={(e) => {
|
||||
const preset = e.target.value
|
||||
if (preset === 'custom') {
|
||||
setNewMonitor({ ...newMonitor, selectedPreset: preset, ignoreSelector: '' })
|
||||
} else {
|
||||
setNewMonitor({ ...newMonitor, selectedPreset: preset, ignoreSelector: preset })
|
||||
}
|
||||
}}
|
||||
options={IGNORE_PRESETS.map(p => ({ value: p.value, label: p.label }))}
|
||||
hint="Ignore dynamic content like timestamps"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{newMonitor.selectedPreset === 'custom' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
label="Custom CSS Selector"
|
||||
type="text"
|
||||
value={newMonitor.ignoreSelector}
|
||||
onChange={(e) => setNewMonitor({ ...newMonitor, ignoreSelector: e.target.value })}
|
||||
placeholder="e.g. .ad-banner, #timestamp"
|
||||
hint="Elements matching this selector will be ignored"
|
||||
/>
|
||||
{newMonitor.url && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowVisualSelector(true)}
|
||||
>
|
||||
🎯 Use Visual Selector
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyword Alerts Section */}
|
||||
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">Keyword Alerts</h4>
|
||||
<p className="text-xs text-muted-foreground">Get notified when specific keywords appear or disappear</p>
|
||||
</div>
|
||||
{canUseKeywords && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setNewMonitor({
|
||||
...newMonitor,
|
||||
keywordRules: [...newMonitor.keywordRules, { keyword: '', type: 'appears', caseSensitive: false }]
|
||||
})
|
||||
}}
|
||||
>
|
||||
+ Add Keyword
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canUseKeywords ? (
|
||||
<div className="flex flex-col items-center justify-center p-4 text-center">
|
||||
<div className="mb-2 rounded-full bg-muted p-2">
|
||||
<svg className="h-6 w-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-semibold text-sm">Pro Feature</p>
|
||||
<p className="text-xs text-muted-foreground">Upgrade to Pro to track specific keywords and content changes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{newMonitor.keywordRules.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No keyword alerts configured. Click "Add Keyword" to create one.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{newMonitor.keywordRules.map((rule, index) => (
|
||||
<div key={index} className="grid gap-2 rounded-md border bg-card p-3 sm:grid-cols-12">
|
||||
<div className="sm:col-span-4">
|
||||
<Input
|
||||
label=""
|
||||
type="text"
|
||||
value={rule.keyword}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].keyword = e.target.value
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
placeholder="e.g. hiring, sold out"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<Select
|
||||
label=""
|
||||
value={rule.type}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].type = e.target.value as any
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
options={[
|
||||
{ value: 'appears', label: 'Appears' },
|
||||
{ value: 'disappears', label: 'Disappears' },
|
||||
{ value: 'count', label: 'Count changes' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{rule.type === 'count' && (
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
label=""
|
||||
type="number"
|
||||
value={rule.threshold || 1}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].threshold = parseInt(e.target.value)
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
placeholder="Threshold"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 sm:col-span-2">
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.caseSensitive || false}
|
||||
onChange={(e) => {
|
||||
const updated = [...newMonitor.keywordRules]
|
||||
updated[index].caseSensitive = e.target.checked
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Case
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end sm:col-span-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = newMonitor.keywordRules.filter((_, i) => i !== index)
|
||||
setNewMonitor({ ...newMonitor, keywordRules: updated })
|
||||
}}
|
||||
className="rounded p-1 text-red-500 hover:bg-red-50"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit">
|
||||
{editingId ? 'Save Changes' : 'Create Monitor'}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleCancelForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card >
|
||||
)
|
||||
}
|
||||
|
||||
{/* Monitors Grid/List */}
|
||||
{
|
||||
filteredMonitors.length === 0 ? (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold">
|
||||
{filterStatus === 'all' ? 'No monitors yet' : `No ${filterStatus} monitors`}
|
||||
</h3>
|
||||
<p className="mb-6 text-muted-foreground">
|
||||
{filterStatus === 'all'
|
||||
? 'Start monitoring your first website to get notified of changes'
|
||||
: 'Try changing the filter to see other monitors'}
|
||||
</p>
|
||||
{filterStatus === 'all' && (
|
||||
<Button onClick={() => setShowAddForm(true)} disabled={monitors.length >= maxMonitors}>
|
||||
Create Your First Monitor
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="grid gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredMonitors.map((monitor: any) => (
|
||||
<Card
|
||||
key={monitor.id}
|
||||
hover
|
||||
className="group animate-fade-in overflow-hidden"
|
||||
>
|
||||
<div className={`h-1.5 ${monitor.status === 'active' ? 'bg-green-500' : monitor.status === 'error' ? 'bg-red-500' : 'bg-gray-300'}`} />
|
||||
<CardContent className="p-5">
|
||||
{/* Monitor Info */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{monitor.name || new URL(monitor.url).hostname}</h3>
|
||||
<Badge
|
||||
variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{monitor.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">{monitor.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-2 rounded-lg bg-muted/30 p-3 text-center text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{monitor.frequency}m</p>
|
||||
<p className="text-muted-foreground">Frequency</p>
|
||||
</div>
|
||||
<div>
|
||||
{monitor.last_changed_at ? (
|
||||
<>
|
||||
<p className="font-semibold text-foreground">
|
||||
{new Date(monitor.last_changed_at).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-muted-foreground">Last Change</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold text-foreground">-</p>
|
||||
<p className="text-muted-foreground">Last Change</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Checked */}
|
||||
{monitor.last_checked_at ? (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Last checked: {new Date(monitor.last_checked_at).toLocaleString()}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
Not checked yet
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Change Summary */}
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots[0]?.summary && (
|
||||
<p className="mb-4 text-xs text-muted-foreground italic border-l-2 border-blue-400 pl-2">
|
||||
"{monitor.recentSnapshots[0].summary}"
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sparkline & Importance */}
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && (
|
||||
<div className="mb-4 flex items-end justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Response Time</p>
|
||||
<Sparkline
|
||||
data={monitor.recentSnapshots.map((s: any) => s.responseTime).reverse()}
|
||||
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
|
||||
height={30}
|
||||
width={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<p className="mb-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Importance</p>
|
||||
<Badge variant="outline" className={`${(monitor.recentSnapshots[0].importanceScore || 0) > 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
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
loading={checkingId === monitor.id}
|
||||
disabled={checkingId !== null}
|
||||
>
|
||||
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/monitors/${monitor.id}`)}
|
||||
title="View History"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(monitor)}
|
||||
title="Edit Monitor"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
title="Delete Monitor"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<div className="space-y-3">
|
||||
{filteredMonitors.map((monitor: any) => {
|
||||
return (
|
||||
<Card key={monitor.id} hover className="animate-fade-in">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Status Indicator */}
|
||||
<div className={`h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center ${monitor.status === 'active' ? 'bg-green-100' : monitor.status === 'error' ? 'bg-red-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
<div className={`h-3 w-3 rounded-full ${monitor.status === 'active' ? 'bg-green-500' : monitor.status === 'error' ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* Monitor Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium truncate">{monitor.name || new URL(monitor.url).hostname}</h3>
|
||||
<Badge
|
||||
variant={monitor.status === 'active' ? 'success' : monitor.status === 'error' ? 'destructive' : 'secondary'}
|
||||
>
|
||||
{monitor.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">{monitor.url}</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hidden sm:flex items-center gap-6 text-sm text-muted-foreground mr-4">
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-foreground">{monitor.frequency}m</p>
|
||||
<p className="text-xs">Frequency</p>
|
||||
</div>
|
||||
<div className="text-center w-24">
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 0 && monitor.recentSnapshots[0].importanceScore !== undefined ? (
|
||||
<Badge variant="outline" className={`w-full justify-center ${(monitor.recentSnapshots[0].importanceScore || 0) > 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
|
||||
</Badge>
|
||||
) : (
|
||||
<p className="font-medium text-foreground">-</p>
|
||||
)}
|
||||
<p className="text-xs mt-1">Importance</p>
|
||||
</div>
|
||||
{monitor.recentSnapshots && monitor.recentSnapshots.length > 1 && (
|
||||
<div className="w-24">
|
||||
<Sparkline
|
||||
data={monitor.recentSnapshots.map((s: any) => s.responseTime).reverse()}
|
||||
color={monitor.status === 'error' ? '#ef4444' : '#22c55e'}
|
||||
height={24}
|
||||
width={96}
|
||||
/>
|
||||
<p className="text-xs text-center mt-1">Response Time</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<p className="font-medium text-foreground">
|
||||
{monitor.last_changed_at ? new Date(monitor.last_changed_at).toLocaleDateString() : '-'}
|
||||
</p>
|
||||
<p className="text-xs">Last Change</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCheckNow(monitor.id)}
|
||||
loading={checkingId === monitor.id}
|
||||
disabled={checkingId !== null}
|
||||
>
|
||||
{checkingId === monitor.id ? 'Checking...' : 'Check Now'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEdit(monitor)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push(`/monitors/${monitor.id}`)}>
|
||||
History
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDelete(monitor.id)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</DashboardLayout >
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user