598 lines
20 KiB
TypeScript
598 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import { useAuth } from '@/lib/auth/context'
|
|
import { useEffect, useState } from 'react'
|
|
import { useParams } from 'next/navigation'
|
|
import { useSite, useUptimeStatus } from '@/lib/swr/dashboard'
|
|
import { updateSite, type Site } from '@/lib/api/sites'
|
|
import {
|
|
getMonitorChecks,
|
|
type UptimeStatusResponse,
|
|
type MonitorStatus,
|
|
type UptimeCheck,
|
|
type UptimeDailyStat,
|
|
} from '@/lib/api/uptime'
|
|
import { toast } from '@ciphera-net/ui'
|
|
import { Button } from '@ciphera-net/ui'
|
|
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
|
import { formatDateFull, formatTime, formatDateTimeShort } from '@/lib/utils/formatDate'
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
} from 'recharts'
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/charts'
|
|
|
|
const responseTimeChartConfig = {
|
|
ms: {
|
|
label: 'Response Time',
|
|
color: 'var(--chart-1)',
|
|
},
|
|
} satisfies ChartConfig
|
|
|
|
// * Status color mapping
|
|
function getStatusColor(status: string): string {
|
|
switch (status) {
|
|
case 'up':
|
|
case 'operational':
|
|
return 'bg-emerald-500'
|
|
case 'degraded':
|
|
return 'bg-amber-500'
|
|
case 'down':
|
|
return 'bg-red-500'
|
|
default:
|
|
return 'bg-neutral-300 dark:bg-neutral-600'
|
|
}
|
|
}
|
|
|
|
function getStatusDotColor(status: string): string {
|
|
switch (status) {
|
|
case 'up':
|
|
case 'operational':
|
|
return 'bg-emerald-500'
|
|
case 'degraded':
|
|
return 'bg-amber-500'
|
|
case 'down':
|
|
return 'bg-red-500'
|
|
default:
|
|
return 'bg-neutral-400'
|
|
}
|
|
}
|
|
|
|
function getStatusLabel(status: string): string {
|
|
switch (status) {
|
|
case 'up':
|
|
case 'operational':
|
|
return 'Operational'
|
|
case 'degraded':
|
|
return 'Degraded'
|
|
case 'down':
|
|
return 'Down'
|
|
default:
|
|
return 'Unknown'
|
|
}
|
|
}
|
|
|
|
// * Overall status text for the top card
|
|
function getOverallStatusText(status: string): string {
|
|
switch (status) {
|
|
case 'up':
|
|
case 'operational':
|
|
return 'All Systems Operational'
|
|
case 'degraded':
|
|
return 'Partial Outage'
|
|
case 'down':
|
|
return 'Major Outage'
|
|
default:
|
|
return 'Unknown Status'
|
|
}
|
|
}
|
|
|
|
function getOverallStatusTextColor(status: string): string {
|
|
switch (status) {
|
|
case 'up':
|
|
case 'operational':
|
|
return 'text-emerald-600 dark:text-emerald-400'
|
|
case 'degraded':
|
|
return 'text-amber-600 dark:text-amber-400'
|
|
case 'down':
|
|
return 'text-red-600 dark:text-red-400'
|
|
default:
|
|
return 'text-neutral-400'
|
|
}
|
|
}
|
|
|
|
function getDayBarColor(stat: UptimeDailyStat | undefined): string {
|
|
if (!stat || stat.total_checks === 0) return 'bg-neutral-300 dark:bg-neutral-600'
|
|
if (stat.failed_checks > 0) return 'bg-red-500'
|
|
if (stat.degraded_checks > 0) return 'bg-amber-500'
|
|
return 'bg-emerald-500'
|
|
}
|
|
|
|
function formatUptime(pct: number): string {
|
|
return pct.toFixed(2) + '%'
|
|
}
|
|
|
|
function formatMs(ms: number | null): string {
|
|
if (ms === null) return '-'
|
|
if (ms < 1000) return `${ms}ms`
|
|
return `${(ms / 1000).toFixed(1)}s`
|
|
}
|
|
|
|
function formatTimeAgo(dateString: string | null): string {
|
|
if (!dateString) return 'Never'
|
|
const date = new Date(dateString)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffSec = Math.floor(diffMs / 1000)
|
|
|
|
if (diffSec < 60) return 'just now'
|
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
|
|
return `${Math.floor(diffSec / 86400)}d ago`
|
|
}
|
|
|
|
// * Generate array of dates for the last N days
|
|
function generateDateRange(days: number): string[] {
|
|
const dates: string[] = []
|
|
const now = new Date()
|
|
for (let i = days - 1; i >= 0; i--) {
|
|
const d = new Date(now)
|
|
d.setDate(d.getDate() - i)
|
|
dates.push(d.toISOString().split('T')[0])
|
|
}
|
|
return dates
|
|
}
|
|
|
|
// * Component: Styled tooltip for status bar
|
|
function StatusBarTooltip({
|
|
stat,
|
|
date,
|
|
visible,
|
|
position,
|
|
}: {
|
|
stat: UptimeDailyStat | undefined
|
|
date: string
|
|
visible: boolean
|
|
position: { x: number; y: number }
|
|
}) {
|
|
if (!visible) return null
|
|
|
|
const formattedDate = formatDateFull(new Date(date + 'T00:00:00'))
|
|
|
|
return (
|
|
<div
|
|
className="fixed z-50 pointer-events-none"
|
|
style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }}
|
|
>
|
|
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg transition-shadow duration-300 px-3 py-2.5 text-xs min-w-40">
|
|
<div className="font-semibold text-white mb-1.5">{formattedDate}</div>
|
|
{stat && stat.total_checks > 0 ? (
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-neutral-400">Uptime</span>
|
|
<span className="font-medium text-white">
|
|
{formatUptime(stat.uptime_percentage)}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-neutral-400">Checks</span>
|
|
<span className="font-medium text-white">{stat.total_checks}</span>
|
|
</div>
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-neutral-400">Avg Response</span>
|
|
<span className="font-medium text-white">
|
|
{formatMs(Math.round(stat.avg_response_time_ms))}
|
|
</span>
|
|
</div>
|
|
{stat.failed_checks > 0 && (
|
|
<div className="flex justify-between gap-4">
|
|
<span className="text-red-500">Failed</span>
|
|
<span className="font-medium text-red-500">{stat.failed_checks}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-neutral-400 dark:text-neutral-500">No data</div>
|
|
)}
|
|
{/* Tooltip arrow */}
|
|
<div className="absolute left-1/2 -translate-x-1/2 bottom-[-5px] w-2.5 h-2.5 bg-white dark:bg-neutral-800 border-r border-b border-neutral-200 dark:border-neutral-700 rotate-45" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// * Component: Uptime status bar (the colored bars visualization)
|
|
function UptimeStatusBar({
|
|
dailyStats,
|
|
days = 90,
|
|
}: {
|
|
dailyStats: UptimeDailyStat[] | null
|
|
days?: number
|
|
}) {
|
|
const dateRange = generateDateRange(days)
|
|
const statsMap = new Map<string, UptimeDailyStat>()
|
|
if (dailyStats) {
|
|
for (const s of dailyStats) {
|
|
statsMap.set(s.date, s)
|
|
}
|
|
}
|
|
|
|
const [hoveredDay, setHoveredDay] = useState<{ date: string; stat: UptimeDailyStat | undefined } | null>(null)
|
|
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
|
|
|
|
const handleMouseEnter = (e: React.MouseEvent, date: string, stat: UptimeDailyStat | undefined) => {
|
|
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
|
setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top })
|
|
setHoveredDay({ date, stat })
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="relative"
|
|
onMouseLeave={() => setHoveredDay(null)}
|
|
>
|
|
<div className="flex items-center gap-0.5 w-full">
|
|
{dateRange.map((date) => {
|
|
const stat = statsMap.get(date)
|
|
const barColor = getDayBarColor(stat)
|
|
|
|
return (
|
|
<div
|
|
key={date}
|
|
className={`flex-1 h-8 rounded-sm ${barColor} transition-all duration-150 hover:opacity-80 cursor-pointer min-w-[3px]`}
|
|
onMouseEnter={(e) => handleMouseEnter(e, date, stat)}
|
|
onMouseLeave={() => setHoveredDay(null)}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
<StatusBarTooltip
|
|
stat={hoveredDay?.stat}
|
|
date={hoveredDay?.date ?? ''}
|
|
visible={hoveredDay !== null}
|
|
position={tooltipPos}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// * Component: Response time chart (Recharts area chart)
|
|
function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
|
|
// * Prepare data in chronological order (oldest first)
|
|
const data = [...checks]
|
|
.reverse()
|
|
.filter((c) => c.response_time_ms !== null)
|
|
.map((c) => ({
|
|
time: formatTime(new Date(c.checked_at)),
|
|
ms: c.response_time_ms as number,
|
|
status: c.status,
|
|
}))
|
|
|
|
if (data.length < 2) return null
|
|
|
|
return (
|
|
<div className="mt-4">
|
|
<h4 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
|
|
Response Time
|
|
</h4>
|
|
<ChartContainer config={responseTimeChartConfig} className="h-40">
|
|
<AreaChart accessibilityLayer data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="var(--color-ms)" stopOpacity={0.3} />
|
|
<stop offset="100%" stopColor="var(--color-ms)" stopOpacity={0.02} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
stroke="var(--chart-grid)"
|
|
strokeOpacity={0.5}
|
|
vertical={false}
|
|
/>
|
|
<XAxis
|
|
dataKey="time"
|
|
tick={{ fontSize: 10, fill: 'var(--chart-axis)' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
interval="preserveStartEnd"
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 10, fill: 'var(--chart-axis)' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(v: number) => `${v}ms`}
|
|
/>
|
|
<ChartTooltip
|
|
content={
|
|
<ChartTooltipContent
|
|
className="text-xs"
|
|
labelKey="time"
|
|
formatter={(value) => <span className="font-semibold">{value}ms</span>}
|
|
/>
|
|
}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="ms"
|
|
stroke="var(--color-ms)"
|
|
strokeWidth={2}
|
|
fill="url(#responseTimeGradient)"
|
|
dot={false}
|
|
activeDot={{ r: 4, fill: 'var(--color-ms)', strokeWidth: 0 }}
|
|
/>
|
|
</AreaChart>
|
|
</ChartContainer>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// * Main uptime page
|
|
export default function UptimePage() {
|
|
const { user } = useAuth()
|
|
const canEdit = user?.role === 'owner' || user?.role === 'admin'
|
|
const params = useParams()
|
|
const siteId = params.id as string
|
|
|
|
const { data: site, mutate: mutateSite } = useSite(siteId)
|
|
const { data: uptimeData, isLoading, mutate: mutateUptime } = useUptimeStatus(siteId)
|
|
const [toggling, setToggling] = useState(false)
|
|
const [checks, setChecks] = useState<UptimeCheck[]>([])
|
|
const [loadingChecks, setLoadingChecks] = useState(false)
|
|
|
|
// * Single monitor from the auto-managed uptime system
|
|
const monitor = uptimeData?.monitors?.[0] ?? null
|
|
const overallUptime = uptimeData?.overall_uptime ?? 100
|
|
const overallStatus = uptimeData?.status ?? 'operational'
|
|
|
|
// * Fetch recent checks when we have a monitor
|
|
useEffect(() => {
|
|
if (!monitor) {
|
|
setChecks([])
|
|
return
|
|
}
|
|
const fetchChecks = async () => {
|
|
setLoadingChecks(true)
|
|
try {
|
|
const data = await getMonitorChecks(siteId, monitor.monitor.id, 20)
|
|
setChecks(data)
|
|
} catch {
|
|
// * Silent fail for check details
|
|
} finally {
|
|
setLoadingChecks(false)
|
|
}
|
|
}
|
|
fetchChecks()
|
|
}, [siteId, monitor?.monitor.id])
|
|
|
|
const handleToggleUptime = async (enabled: boolean) => {
|
|
if (!site) return
|
|
setToggling(true)
|
|
try {
|
|
await updateSite(site.id, {
|
|
name: site.name,
|
|
timezone: site.timezone,
|
|
is_public: site.is_public,
|
|
excluded_paths: site.excluded_paths,
|
|
uptime_enabled: enabled,
|
|
})
|
|
mutateSite()
|
|
mutateUptime()
|
|
toast.success(enabled ? 'Uptime monitoring enabled' : 'Uptime monitoring disabled')
|
|
} catch {
|
|
toast.error('Failed to update uptime monitoring')
|
|
} finally {
|
|
setToggling(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (site?.domain) document.title = `Uptime · ${site.domain} | Pulse`
|
|
}, [site?.domain])
|
|
|
|
const showSkeleton = useMinimumLoading(isLoading && !uptimeData)
|
|
const fadeClass = useSkeletonFade(showSkeleton)
|
|
|
|
if (showSkeleton) return <UptimeSkeleton />
|
|
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
|
|
|
|
const uptimeEnabled = site.uptime_enabled
|
|
|
|
// * Disabled state — show empty state with enable toggle
|
|
if (!uptimeEnabled) {
|
|
return (
|
|
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-white mb-1">
|
|
Uptime
|
|
</h1>
|
|
<p className="text-sm text-neutral-400">
|
|
Monitor your site's availability and response time
|
|
</p>
|
|
</div>
|
|
|
|
{/* Empty state */}
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
|
|
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
|
<svg className="w-8 h-8 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="font-semibold text-white mb-2">
|
|
Uptime monitoring is disabled
|
|
</h3>
|
|
<p className="text-sm text-neutral-400 mb-6 max-w-md mx-auto">
|
|
Enable uptime monitoring to track your site's availability and response time around the clock.
|
|
</p>
|
|
{canEdit && (
|
|
<Button
|
|
onClick={() => handleToggleUptime(true)}
|
|
disabled={toggling}
|
|
>
|
|
{toggling ? 'Enabling...' : 'Enable Uptime Monitoring'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// * Enabled state — show uptime dashboard
|
|
return (
|
|
<div className={`w-full max-w-7xl mx-auto px-4 sm:px-6 pb-8 ${fadeClass}`}>
|
|
{/* Header */}
|
|
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white mb-1">
|
|
Uptime
|
|
</h1>
|
|
<p className="text-sm text-neutral-400">
|
|
Monitor your site's availability and response time
|
|
</p>
|
|
</div>
|
|
{canEdit && (
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => handleToggleUptime(false)}
|
|
disabled={toggling}
|
|
className="text-sm"
|
|
>
|
|
{toggling ? 'Disabling...' : 'Disable Monitoring'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Overall status card */}
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-3.5 h-3.5 rounded-full ${getStatusDotColor(overallStatus)}`} />
|
|
<div>
|
|
<span className="font-semibold text-white text-lg">
|
|
{site.name}
|
|
</span>
|
|
<span className={`text-sm font-medium ml-3 ${getOverallStatusTextColor(overallStatus)}`}>
|
|
{getOverallStatusText(overallStatus)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="text-sm font-semibold text-white">
|
|
{formatUptime(overallUptime)} uptime
|
|
</span>
|
|
{monitor && (
|
|
<div className="text-xs text-neutral-400">
|
|
Last checked {formatTimeAgo(monitor.monitor.last_checked_at)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 90-day uptime bar */}
|
|
{monitor && (
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
|
|
<h3 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
|
|
90-Day Availability
|
|
</h3>
|
|
<UptimeStatusBar dailyStats={monitor.daily_stats} />
|
|
<div className="flex justify-between mt-1.5 text-xs text-neutral-400 dark:text-neutral-500">
|
|
<span>90 days ago</span>
|
|
<span>Today</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Response time chart + Recent checks */}
|
|
{monitor && (
|
|
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5">
|
|
{/* Monitor details grid */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5">
|
|
<div>
|
|
<div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
|
|
Status
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.monitor.last_status)}`} />
|
|
<span className="text-sm font-medium text-white">
|
|
{getStatusLabel(monitor.monitor.last_status)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
|
|
Response Time
|
|
</div>
|
|
<span className="text-sm font-medium text-white">
|
|
{formatMs(monitor.monitor.last_response_time_ms)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
|
|
Check Interval
|
|
</div>
|
|
<span className="text-sm font-medium text-white">
|
|
{monitor.monitor.check_interval_seconds >= 60
|
|
? `${Math.floor(monitor.monitor.check_interval_seconds / 60)}m`
|
|
: `${monitor.monitor.check_interval_seconds}s`}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-1">
|
|
Overall Uptime
|
|
</div>
|
|
<span className="text-sm font-medium text-white">
|
|
{formatUptime(monitor.overall_uptime)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Response time chart */}
|
|
{loadingChecks ? (
|
|
<ChecksSkeleton />
|
|
) : checks.length > 0 ? (
|
|
<>
|
|
<ResponseTimeChart checks={checks} />
|
|
|
|
{/* Recent checks */}
|
|
<div className="mt-5">
|
|
<h4 className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-3">
|
|
Recent Checks
|
|
</h4>
|
|
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
|
{checks.slice(0, 20).map((check) => (
|
|
<div
|
|
key={check.id}
|
|
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800 text-sm"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(check.status)}`} />
|
|
<span className="text-neutral-600 dark:text-neutral-300 text-xs">
|
|
{formatDateTimeShort(new Date(check.checked_at))}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{check.status_code && (
|
|
<span className="text-xs text-neutral-400">
|
|
{check.status_code}
|
|
</span>
|
|
)}
|
|
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
|
|
{formatMs(check.response_time_ms)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|