diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index 28336cc..53f95d7 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useAuth } from '@/lib/auth/context' -import { useEffect, useState, useCallback } from 'react' +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' @@ -18,8 +18,37 @@ import { type CreateMonitorRequest, } from '@/lib/api/uptime' import { toast } from '@ciphera-net/ui' +import { useTheme } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay, Button, Modal } from '@ciphera-net/ui' +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip as RechartsTooltip, + ResponsiveContainer, +} from 'recharts' +import type { TooltipProps } from 'recharts' + +// * Chart theme colors (consistent with main Pulse chart) +const CHART_COLORS_LIGHT = { + border: '#E5E5E5', + text: '#171717', + textMuted: '#737373', + axis: '#A3A3A3', + tooltipBg: '#ffffff', + tooltipBorder: '#E5E5E5', +} +const CHART_COLORS_DARK = { + border: '#404040', + text: '#fafafa', + textMuted: '#a3a3a3', + axis: '#737373', + tooltipBg: '#262626', + tooltipBorder: '#404040', +} // * Status color mapping function getStatusColor(status: string): string { @@ -64,15 +93,37 @@ function getStatusLabel(status: string): string { } } -function getDayStatus(stat: UptimeDailyStat | undefined): string { - if (!stat) return 'no_data' - if (stat.failed_checks > 0) return 'down' - if (stat.degraded_checks > 0) return 'degraded' - return 'up' +// * 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) return 'bg-neutral-200 dark:bg-neutral-700' + 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' @@ -113,6 +164,68 @@ function generateDateRange(days: number): string[] { 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, @@ -129,24 +242,127 @@ function UptimeStatusBar({ } } - return ( -
- {dateRange.map((date) => { - const stat = statsMap.get(date) - const barColor = getDayBarColor(stat) - const dayStatus = getDayStatus(stat) - const tooltipText = stat - ? `${new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}: ${formatUptime(stat.uptime_percentage)} uptime (${stat.total_checks} checks)` - : `${new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}: No data` + const [hoveredDay, setHoveredDay] = useState<{ date: string; stat: UptimeDailyStat | undefined } | null>(null) + const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }) - return ( -
- ) - })} + 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 ( +
+
+ {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[] }) { + const { theme } = useTheme() + const colors = theme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT + + // * 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 + + const CustomTooltip = ({ active, payload, label }: TooltipProps) => { + if (!active || !payload?.length) return null + return ( +
+
{label}
+
+ {payload[0].value}ms +
+
+ ) + } + + return ( +
+

+ Response Time +

+
+ + + + + + + + + + + `${v}ms`} + /> + } /> + + + +
) } @@ -178,7 +394,7 @@ function MonitorCard({ const fetchChecks = async () => { setLoadingChecks(true) try { - const data = await getMonitorChecks(siteId, monitor.id, 20) + const data = await getMonitorChecks(siteId, monitor.id, 50) setChecks(data) } catch { // * Silent fail for check details @@ -289,47 +505,52 @@ function MonitorCard({
- {/* Recent checks */} + {/* Response time chart */} {loadingChecks ? (
- Loading recent checks... + Loading checks...
) : checks.length > 0 ? ( -
-

- Recent Checks -

-
- {checks.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} + <> + + + {/* 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', + })} - )} - - {formatMs(check.response_time_ms)} - +
+
+ {check.status_code && ( + + {check.status_code} + + )} + + {formatMs(check.response_time_ms)} + +
-
- ))} + ))} +
-
+ ) : null} {/* Actions */} @@ -535,14 +756,19 @@ export default function UptimePage() { {site.name} - - {monitors.length} {monitors.length === 1 ? 'component' : 'components'} + + {getOverallStatusText(overallStatus)}
- - {formatUptime(overallUptime)} uptime - +
+ + {formatUptime(overallUptime)} uptime + +
+ {monitors.length} {monitors.length === 1 ? 'component' : 'components'} +
+
)} @@ -639,6 +865,62 @@ function MonitorForm({ submitLabel: string siteDomain: string }) { + const [protocol, setProtocol] = useState<'https://' | 'http://'>(() => { + if (formData.url.startsWith('http://')) return 'http://' + return '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://') => { + setProtocol(proto) + setShowProtocolDropdown(false) + // * Rebuild URL with new protocol + 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 */} @@ -655,20 +937,58 @@ function MonitorForm({ />
- {/* URL */} + {/* URL with protocol dropdown + domain prefix */}
- setFormData({ ...formData, url: e.target.value })} - placeholder={`https://${siteDomain}`} - 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" - /> +
+ {/* Protocol dropdown */} +
+ + {showProtocolDropdown && ( +
+ + +
+ )} +
+ {/* Domain prefix */} + + {siteDomain} + + {/* Path input */} + +

- Must be on {siteDomain} or a subdomain (e.g. api.{siteDomain}) + Add a specific path (e.g. /api/health) or leave empty for the root domain

@@ -703,7 +1023,7 @@ function MonitorForm({ onChange={(e) => 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" + 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" />
@@ -718,7 +1038,7 @@ function MonitorForm({ onChange={(e) => 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" + 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" />