'use client' import { useAuth } from '@/lib/auth/context' import { useEffect, useState, useCallback, useRef } from 'react' import { useParams, useRouter } from 'next/navigation' import { motion, AnimatePresence } from 'framer-motion' import { getSite, type Site } from '@/lib/api/sites' import { getUptimeStatus, createUptimeMonitor, updateUptimeMonitor, deleteUptimeMonitor, getMonitorChecks, type UptimeStatusResponse, type MonitorStatus, type UptimeCheck, type UptimeDailyStat, type CreateMonitorRequest, } from '@/lib/api/uptime' import { toast } from '@ciphera-net/ui' import { useTheme } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@ciphera-net/ui' import { Button, Modal } from '@ciphera-net/ui' import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading } from '@/components/skeletons' 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-500 dark: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 = new Date(date + 'T00:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', }) return (
{formattedDate}
{stat && stat.total_checks > 0 ? (
Uptime {formatUptime(stat.uptime_percentage)}
Checks {stat.total_checks}
Avg Response {formatMs(Math.round(stat.avg_response_time_ms))}
{stat.failed_checks > 0 && (
Failed {stat.failed_checks}
)}
) : (
No data
)} {/* Tooltip arrow */}
) } // * 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() 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 (
setHoveredDay(null)} >
{dateRange.map((date) => { const stat = statsMap.get(date) const barColor = getDayBarColor(stat) return (
handleMouseEnter(e, date, stat)} onMouseLeave={() => setHoveredDay(null)} /> ) })}
) } // * 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: new Date(c.checked_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', }), ms: c.response_time_ms as number, status: c.status, })) if (data.length < 2) return null return (

Response Time

`${v}ms`} /> {value}ms} /> } />
) } // * Component: Monitor card (matches the reference image design) function MonitorCard({ monitorStatus, expanded, onToggle, onEdit, onDelete, canEdit, siteId, }: { monitorStatus: MonitorStatus expanded: boolean onToggle: () => void onEdit: () => void onDelete: () => void canEdit: boolean siteId: string }) { const { monitor, daily_stats, overall_uptime } = monitorStatus const [checks, setChecks] = useState([]) const [loadingChecks, setLoadingChecks] = useState(false) useEffect(() => { if (expanded && checks.length === 0) { const fetchChecks = async () => { setLoadingChecks(true) try { const data = await getMonitorChecks(siteId, monitor.id, 50) setChecks(data) } catch { // * Silent fail for check details } finally { setLoadingChecks(false) } } fetchChecks() } }, [expanded, siteId, monitor.id, checks.length]) return (
{/* Header */} {/* Status bar */}
90 days ago Today
{/* Expanded details */} {expanded && (
{/* Monitor details grid */}
Status
{getStatusLabel(monitor.last_status)}
Response Time
{formatMs(monitor.last_response_time_ms)}
Check Interval
{monitor.check_interval_seconds >= 60 ? `${Math.floor(monitor.check_interval_seconds / 60)}m` : `${monitor.check_interval_seconds}s`}
Last Checked
{formatTimeAgo(monitor.last_checked_at)}
{/* Response time chart */} {loadingChecks ? ( ) : checks.length > 0 ? ( <> {/* Recent checks */}

Recent Checks

{checks.slice(0, 20).map((check) => (
{new Date(check.checked_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', })}
{check.status_code && ( {check.status_code} )} {formatMs(check.response_time_ms)}
))}
) : null} {/* Actions */} {canEdit && (
)}
)}
) } // * Main uptime page export default function UptimePage() { const { user } = useAuth() const canEdit = user?.role === 'owner' || user?.role === 'admin' const params = useParams() const router = useRouter() const siteId = params.id as string const [site, setSite] = useState(null) const [loading, setLoading] = useState(true) const [uptimeData, setUptimeData] = useState(null) const [expandedMonitor, setExpandedMonitor] = useState(null) const [showAddModal, setShowAddModal] = useState(false) const [showEditModal, setShowEditModal] = useState(false) const [editingMonitor, setEditingMonitor] = useState(null) const [formData, setFormData] = useState({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30, }) const [saving, setSaving] = useState(false) const loadData = useCallback(async () => { try { const [siteData, statusData] = await Promise.all([ getSite(siteId), getUptimeStatus(siteId), ]) setSite(siteData) setUptimeData(statusData) } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to load uptime monitors') } finally { setLoading(false) } }, [siteId]) useEffect(() => { loadData() }, [loadData]) // * Auto-refresh every 30 seconds; show toast on failure (e.g. network loss or auth expiry) useEffect(() => { const interval = setInterval(async () => { try { const statusData = await getUptimeStatus(siteId) setUptimeData(statusData) } catch { toast.error('Could not refresh uptime data. Check your connection or sign in again.') } }, 30000) return () => clearInterval(interval) }, [siteId]) const handleAddMonitor = async () => { if (!formData.name || !formData.url) { toast.error('Name and URL are required') return } setSaving(true) try { await createUptimeMonitor(siteId, formData) toast.success('Monitor created successfully') setShowAddModal(false) setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 }) await loadData() } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to create monitor') } finally { setSaving(false) } } const handleEditMonitor = async () => { if (!editingMonitor || !formData.name || !formData.url) return setSaving(true) try { await updateUptimeMonitor(siteId, editingMonitor.monitor.id, { name: formData.name, url: formData.url, check_interval_seconds: formData.check_interval_seconds, expected_status_code: formData.expected_status_code, timeout_seconds: formData.timeout_seconds, enabled: editingMonitor.monitor.enabled, }) toast.success('Monitor updated successfully') setShowEditModal(false) setEditingMonitor(null) await loadData() } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to update monitor') } finally { setSaving(false) } } const handleDeleteMonitor = async (monitorId: string) => { if (!window.confirm('Are you sure you want to delete this monitor? All historical data will be lost.')) return try { await deleteUptimeMonitor(siteId, monitorId) toast.success('Monitor deleted') await loadData() } catch (error: unknown) { toast.error(getAuthErrorMessage(error) || 'Failed to delete monitor') } } const openEditModal = (ms: MonitorStatus) => { setEditingMonitor(ms) setFormData({ name: ms.monitor.name, url: ms.monitor.url, check_interval_seconds: ms.monitor.check_interval_seconds, expected_status_code: ms.monitor.expected_status_code, timeout_seconds: ms.monitor.timeout_seconds, }) setShowEditModal(true) } useEffect(() => { if (site?.domain) document.title = `Uptime ยท ${site.domain} | Pulse` }, [site?.domain]) const showSkeleton = useMinimumLoading(loading) if (showSkeleton) return if (!site) return
Site not found
const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : [] const overallUptime = uptimeData?.overall_uptime ?? 100 const overallStatus = uptimeData?.status ?? 'operational' return (
{/* Header */}

Uptime

Monitor your endpoints and track availability over time

{canEdit && ( )}
{/* Overall status card */} {monitors.length > 0 && (
{site.name} {getOverallStatusText(overallStatus)}
{formatUptime(overallUptime)} uptime
{monitors.length} {monitors.length === 1 ? 'component' : 'components'}
)} {/* Monitor list */} {monitors.length > 0 ? (
{monitors.map((ms) => ( setExpandedMonitor( expandedMonitor === ms.monitor.id ? null : ms.monitor.id )} onEdit={() => openEditModal(ms)} onDelete={() => handleDeleteMonitor(ms.monitor.id)} canEdit={canEdit} siteId={siteId} /> ))}
) : ( /* Empty state */

No monitors yet

Add a monitor to start tracking the uptime and response time of your endpoints. You can monitor APIs, websites, and any HTTP endpoint.

{canEdit && ( )}
)} {/* Add Monitor Modal */} setShowAddModal(false)} title="Add Monitor"> setShowAddModal(false)} saving={saving} submitLabel="Create Monitor" siteDomain={site.domain} /> {/* Edit Monitor Modal */} setShowEditModal(false)} title="Edit Monitor"> setShowEditModal(false)} saving={saving} submitLabel="Save Changes" siteDomain={site.domain} />
) } // * Monitor creation/edit form function MonitorForm({ formData, setFormData, onSubmit, onCancel, saving, submitLabel, siteDomain, }: { formData: CreateMonitorRequest setFormData: (data: CreateMonitorRequest) => void onSubmit: () => void onCancel: () => void saving: boolean submitLabel: string siteDomain: string }) { // * Derive protocol from formData.url so edit modal shows the monitor's actual scheme (no desync) const protocol: 'https://' | 'http://' = formData.url.startsWith('http://') ? 'http://' : 'https://' const [showProtocolDropdown, setShowProtocolDropdown] = useState(false) const dropdownRef = useRef(null) // * Extract the path portion from the full URL const getPath = (): string => { const url = formData.url if (!url) return '' try { const parsed = new URL(url) const pathAndRest = parsed.pathname + parsed.search + parsed.hash return pathAndRest === '/' ? '' : pathAndRest } catch { // ? If not a valid full URL, try stripping the protocol prefix if (url.startsWith('https://')) return url.slice(8 + siteDomain.length) if (url.startsWith('http://')) return url.slice(7 + siteDomain.length) return url } } const handlePathChange = (e: React.ChangeEvent) => { const path = e.target.value const safePath = path.startsWith('/') || path === '' ? path : `/${path}` setFormData({ ...formData, url: `${protocol}${siteDomain}${safePath}` }) } const handleProtocolChange = (proto: 'https://' | 'http://') => { setShowProtocolDropdown(false) const path = getPath() setFormData({ ...formData, url: `${proto}${siteDomain}${path}` }) } // * Initialize URL if empty useEffect(() => { if (!formData.url) { setFormData({ ...formData, url: `${protocol}${siteDomain}` }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // * Close dropdown on outside click useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setShowProtocolDropdown(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, []) return (
{/* Name */}
setFormData({ ...formData, name: e.target.value })} placeholder="e.g. API, Website, CDN" autoFocus maxLength={100} className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm" /> {formData.name.length > 80 && ( 90 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/100 )}
{/* URL with protocol dropdown + domain prefix */}
{/* Protocol dropdown */}
{showProtocolDropdown && (
)}
{/* Domain prefix */} {siteDomain} {/* Path input */}

Add a specific path (e.g. /api/health) or leave empty for the root domain

{/* Check interval */}
{/* Expected status code */}
setFormData({ ...formData, expected_status_code: parseInt(e.target.value) || 200 })} min={100} max={599} className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
{/* Timeout */}
setFormData({ ...formData, timeout_seconds: parseInt(e.target.value) || 30 })} min={5} max={60} className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
{/* Actions */}
) }