Initial implementation of Website Change Detection Monitor MVP

Features implemented:
- Backend API with Express + TypeScript
- User authentication (register/login with JWT)
- Monitor CRUD operations with plan-based limits
- Automated change detection engine
- Email alert system
- Frontend with Next.js + TypeScript
- Dashboard with monitor management
- Login/register pages
- Monitor history viewer
- PostgreSQL database schema
- Docker setup for local development

Technical stack:
- Backend: Express, TypeScript, PostgreSQL, Redis (ready)
- Frontend: Next.js 14, React Query, Tailwind CSS
- Database: PostgreSQL with migrations
- Services: Page fetching, diff detection, email alerts

Documentation:
- README with full setup instructions
- SETUP guide for quick start
- PROJECT_STATUS with current capabilities
- Complete technical specifications

Ready for local testing and feature expansion.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-01-16 18:46:40 +01:00
commit 2c1ec69a79
45 changed files with 5941 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { isAuthenticated, clearAuth } from '@/lib/auth'
export default function DashboardPage() {
const router = useRouter()
const [showAddForm, setShowAddForm] = useState(false)
const [newMonitor, setNewMonitor] = useState({
url: '',
name: '',
frequency: 60,
})
useEffect(() => {
if (!isAuthenticated()) {
router.push('/login')
}
}, [router])
const { data, isLoading, refetch } = useQuery({
queryKey: ['monitors'],
queryFn: async () => {
const response = await monitorAPI.list()
return response.monitors
},
})
const handleLogout = () => {
clearAuth()
router.push('/login')
}
const handleAddMonitor = async (e: React.FormEvent) => {
e.preventDefault()
try {
await monitorAPI.create(newMonitor)
setNewMonitor({ url: '', name: '', frequency: 60 })
setShowAddForm(false)
refetch()
} catch (err) {
console.error('Failed to create monitor:', err)
}
}
const handleCheckNow = async (id: string) => {
try {
await monitorAPI.check(id)
alert('Check triggered! Results will appear shortly.')
setTimeout(() => refetch(), 2000)
} catch (err) {
console.error('Failed to trigger check:', err)
}
}
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this monitor?')) return
try {
await monitorAPI.delete(id)
refetch()
} catch (err) {
console.error('Failed to delete monitor:', err)
}
}
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<p>Loading...</p>
</div>
)
}
const monitors = data || []
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="border-b bg-white">
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Website Monitor</h1>
<button
onClick={handleLogout}
className="rounded-md border px-4 py-2 text-sm hover:bg-gray-50"
>
Logout
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Your Monitors</h2>
<p className="text-sm text-gray-600">
{monitors.length} monitor{monitors.length !== 1 ? 's' : ''} active
</p>
</div>
<button
onClick={() => setShowAddForm(true)}
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
>
+ Add Monitor
</button>
</div>
{/* Add Monitor Form */}
{showAddForm && (
<div className="mb-6 rounded-lg bg-white p-6 shadow">
<h3 className="mb-4 text-lg font-semibold">Add New Monitor</h3>
<form onSubmit={handleAddMonitor} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
URL
</label>
<input
type="url"
value={newMonitor.url}
onChange={(e) =>
setNewMonitor({ ...newMonitor, url: e.target.value })
}
placeholder="https://example.com"
required
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Name (optional)
</label>
<input
type="text"
value={newMonitor.name}
onChange={(e) =>
setNewMonitor({ ...newMonitor, name: e.target.value })
}
placeholder="My Monitor"
className="mt-1 block w-full rounded-md border px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Check Frequency (minutes)
</label>
<select
value={newMonitor.frequency}
onChange={(e) =>
setNewMonitor({
...newMonitor,
frequency: parseInt(e.target.value),
})
}
className="mt-1 block w-full rounded-md border px-3 py-2"
>
<option value={5}>Every 5 minutes</option>
<option value={30}>Every 30 minutes</option>
<option value={60}>Every hour</option>
<option value={360}>Every 6 hours</option>
<option value={1440}>Every 24 hours</option>
</select>
</div>
<div className="flex gap-2">
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
>
Create Monitor
</button>
<button
type="button"
onClick={() => setShowAddForm(false)}
className="rounded-md border px-4 py-2 hover:bg-gray-50"
>
Cancel
</button>
</div>
</form>
</div>
)}
{/* Monitors List */}
{monitors.length === 0 ? (
<div className="rounded-lg bg-white p-12 text-center shadow">
<p className="mb-4 text-gray-600">No monitors yet</p>
<button
onClick={() => setShowAddForm(true)}
className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary/90"
>
Create Your First Monitor
</button>
</div>
) : (
<div className="space-y-4">
{monitors.map((monitor: any) => (
<div
key={monitor.id}
className="rounded-lg bg-white p-6 shadow hover:shadow-md"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="font-semibold">{monitor.name}</h3>
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
<div className="mt-2 flex gap-4 text-xs text-gray-500">
<span>Every {monitor.frequency} min</span>
<span className="capitalize">Status: {monitor.status}</span>
{monitor.last_checked_at && (
<span>
Last checked:{' '}
{new Date(monitor.last_checked_at).toLocaleString()}
</span>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleCheckNow(monitor.id)}
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
>
Check Now
</button>
<button
onClick={() => router.push(`/monitors/${monitor.id}`)}
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
>
History
</button>
<button
onClick={() => handleDelete(monitor.id)}
className="rounded-md border border-red-200 px-3 py-1 text-sm text-red-600 hover:bg-red-50"
>
Delete
</button>
</div>
</div>
</div>
))}
</div>
)}
</main>
</div>
)
}

59
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

25
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Website Monitor - Track Changes on Any Website',
description: 'Monitor website changes with smart filtering and instant alerts',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const data = await authAPI.login(email, password)
saveAuth(data.token, data.user)
router.push('/dashboard')
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to login')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md">
<div className="rounded-lg bg-white p-8 shadow-lg">
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
<h2 className="mb-6 text-center text-xl text-gray-600">Sign In</h2>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
Don't have an account?{' '}
<Link href="/register" className="font-medium text-primary hover:underline">
Sign up
</Link>
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { isAuthenticated } from '@/lib/auth'
export default function MonitorHistoryPage() {
const router = useRouter()
const params = useParams()
const id = params?.id as string
useEffect(() => {
if (!isAuthenticated()) {
router.push('/login')
}
}, [router])
const { data: monitorData } = useQuery({
queryKey: ['monitor', id],
queryFn: async () => {
const response = await monitorAPI.get(id)
return response.monitor
},
})
const { data: historyData, isLoading } = useQuery({
queryKey: ['history', id],
queryFn: async () => {
const response = await monitorAPI.history(id)
return response.snapshots
},
})
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<p>Loading...</p>
</div>
)
}
const snapshots = historyData || []
const monitor = monitorData
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="border-b bg-white">
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center gap-4">
<button
onClick={() => router.push('/dashboard')}
className="text-gray-600 hover:text-gray-900"
>
Back
</button>
<div>
<h1 className="text-2xl font-bold">
{monitor?.name || 'Monitor History'}
</h1>
{monitor && (
<p className="text-sm text-gray-600 break-all">{monitor.url}</p>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<h2 className="mb-4 text-xl font-semibold">Check History</h2>
{snapshots.length === 0 ? (
<div className="rounded-lg bg-white p-12 text-center shadow">
<p className="text-gray-600">No history yet</p>
<p className="mt-2 text-sm text-gray-500">
The first check will happen soon
</p>
</div>
) : (
<div className="space-y-3">
{snapshots.map((snapshot: any) => (
<div
key={snapshot.id}
className={`rounded-lg bg-white p-4 shadow ${
snapshot.changed ? 'border-l-4 border-l-blue-500' : ''
}`}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<span
className={`rounded px-2 py-1 text-xs font-medium ${
snapshot.changed
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{snapshot.changed ? 'Changed' : 'No Change'}
</span>
{snapshot.error_message && (
<span className="rounded bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
Error
</span>
)}
<span className="text-sm text-gray-600">
{new Date(snapshot.created_at).toLocaleString()}
</span>
</div>
<div className="mt-2 flex gap-4 text-sm text-gray-600">
<span>HTTP {snapshot.http_status}</span>
<span>{snapshot.response_time}ms</span>
{snapshot.change_percentage && (
<span>{snapshot.change_percentage.toFixed(2)}% changed</span>
)}
</div>
{snapshot.error_message && (
<p className="mt-2 text-sm text-red-600">
{snapshot.error_message}
</p>
)}
</div>
{snapshot.html_content && (
<button
onClick={() =>
router.push(`/monitors/${id}/snapshot/${snapshot.id}`)
}
className="rounded-md border px-3 py-1 text-sm hover:bg-gray-50"
>
View Details
</button>
)}
</div>
</div>
))}
</div>
)}
</main>
</div>
)
}

26
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,26 @@
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { isAuthenticated } from '@/lib/auth'
export default function Home() {
const router = useRouter()
useEffect(() => {
if (isAuthenticated()) {
router.push('/dashboard')
} else {
router.push('/login')
}
}, [router])
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold">Website Monitor</h1>
<p className="mt-4 text-gray-600">Loading...</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
)
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authAPI } from '@/lib/api'
import { saveAuth } from '@/lib/auth'
export default function RegisterPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters')
return
}
setLoading(true)
try {
const data = await authAPI.register(email, password)
saveAuth(data.token, data.user)
router.push('/dashboard')
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to register'
const details = err.response?.data?.details
if (details && Array.isArray(details)) {
setError(details.join(', '))
} else {
setError(message)
}
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-md">
<div className="rounded-lg bg-white p-8 shadow-lg">
<h1 className="mb-6 text-center text-3xl font-bold">Website Monitor</h1>
<h2 className="mb-6 text-center text-xl text-gray-600">Create Account</h2>
{error && (
<div className="mb-4 rounded-md bg-red-50 p-4 text-red-800">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
<p className="mt-1 text-xs text-gray-500">
At least 8 characters with uppercase, lowercase, and number
</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-primary px-4 py-2 font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p className="mt-6 text-center text-sm text-gray-600">
Already have an account?{' '}
<Link href="/login" className="font-medium text-primary hover:underline">
Sign in
</Link>
</p>
</div>
</div>
</div>
)
}