website-monitor/frontend/app/incidents/page.tsx

267 lines
13 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { monitorAPI } from '@/lib/api'
import { DashboardLayout } from '@/components/layout/dashboard-layout'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useRouter } from 'next/navigation'
type FilterType = 'all' | 'errors' | 'changes'
interface Incident {
id: string
monitorId: string
monitorName: string
monitorUrl: string
type: 'error' | 'change'
timestamp: Date
details?: string
}
export default function IncidentsPage() {
const router = useRouter()
const [filter, setFilter] = useState<FilterType>('all')
const [resolvedIds, setResolvedIds] = useState<Set<string>>(new Set())
const [showResolved, setShowResolved] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['monitors'],
queryFn: async () => {
const response = await monitorAPI.list()
return response.monitors
},
})
if (isLoading) {
return (
<DashboardLayout title="Incidents" description="View detected changes and errors">
<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...</p>
</div>
</div>
</DashboardLayout>
)
}
const monitors = data || []
// Build incidents list from monitors
const incidents: Incident[] = monitors.flatMap((m: any) => {
const result: Incident[] = []
if (m.status === 'error') {
result.push({
id: `error-${m.id}`,
monitorId: m.id,
monitorName: m.name || m.url,
monitorUrl: m.url,
type: 'error',
timestamp: new Date(m.updated_at || m.created_at),
details: m.last_error || 'Connection failed'
})
}
if (m.last_change_at) {
result.push({
id: `change-${m.id}`,
monitorId: m.id,
monitorName: m.name || m.url,
monitorUrl: m.url,
type: 'change',
timestamp: new Date(m.last_change_at),
details: 'Content changed'
})
}
return result
}).sort((a: Incident, b: Incident) => b.timestamp.getTime() - a.timestamp.getTime())
// Apply filters
const filteredIncidents = incidents.filter(incident => {
if (!showResolved && resolvedIds.has(incident.id)) return false
if (filter === 'errors') return incident.type === 'error'
if (filter === 'changes') return incident.type === 'change'
return true
})
const errorCount = incidents.filter(i => i.type === 'error').length
const changeCount = incidents.filter(i => i.type === 'change').length
const toggleResolved = (id: string) => {
setResolvedIds(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const formatTimeAgo = (date: Date) => {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
return (
<DashboardLayout title="Incidents" description="View detected changes and errors">
{/* Filter Tabs */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex gap-2">
<Button
variant={filter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('all')}
>
All ({incidents.length})
</Button>
<Button
variant={filter === 'errors' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('errors')}
className={filter !== 'errors' && errorCount > 0 ? 'border-red-200 text-red-600' : ''}
>
🔴 Errors ({errorCount})
</Button>
<Button
variant={filter === 'changes' ? 'default' : 'outline'}
size="sm"
onClick={() => setFilter('changes')}
className={filter !== 'changes' && changeCount > 0 ? 'border-blue-200 text-blue-600' : ''}
>
🔵 Changes ({changeCount})
</Button>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowResolved(!showResolved)}
>
{showResolved ? 'Hide Resolved' : 'Show Resolved'}
</Button>
</div>
{filteredIncidents.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-green-100">
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold">All Clear!</h3>
<p className="text-muted-foreground">
{filter === 'all'
? 'No incidents or changes detected'
: filter === 'errors'
? 'No errors to show'
: 'No changes to show'
}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredIncidents.map((incident) => (
<Card
key={incident.id}
className={`transition-all ${resolvedIds.has(incident.id) ? 'opacity-50' : ''}`}
>
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-4">
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${incident.type === 'error' ? 'bg-red-100' : 'bg-blue-100'
}`}>
{incident.type === 'error' ? (
<svg className="h-5 w-5 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
) : (
<svg className="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold truncate">{incident.monitorName}</h3>
<Badge variant={incident.type === 'error' ? 'destructive' : 'default'}>
{incident.type === 'error' ? 'Error' : 'Changed'}
</Badge>
{resolvedIds.has(incident.id) && (
<Badge variant="outline" className="text-green-600 border-green-200">
Resolved
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground truncate">{incident.monitorUrl}</p>
{incident.details && (
<p className="mt-1 text-sm text-muted-foreground">{incident.details}</p>
)}
<p className="mt-1 text-xs text-muted-foreground">{formatTimeAgo(incident.timestamp)}</p>
</div>
</div>
<div className="flex gap-2 sm:flex-col sm:items-end">
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/monitors/${incident.monitorId}`)}
>
View Details
</Button>
<Button
variant={resolvedIds.has(incident.id) ? 'default' : 'ghost'}
size="sm"
onClick={() => toggleResolved(incident.id)}
>
{resolvedIds.has(incident.id) ? 'Unresolve' : 'Resolve'}
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Summary Stats */}
{incidents.length > 0 && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">Summary</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-3">
<div className="text-center">
<p className="text-2xl font-bold">{incidents.length}</p>
<p className="text-sm text-muted-foreground">Total Incidents</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-red-600">{errorCount}</p>
<p className="text-sm text-muted-foreground">Errors</p>
</div>
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">{changeCount}</p>
<p className="text-sm text-muted-foreground">Changes</p>
</div>
</div>
</CardContent>
</Card>
)}
</DashboardLayout>
)
}