gitea
This commit is contained in:
266
frontend/app/incidents/page.tsx
Normal file
266
frontend/app/incidents/page.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user