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"
/>