From f382bab64795edb963e0a853361d31d718b084f1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 7 Feb 2026 12:20:06 +0100 Subject: [PATCH 1/6] feat: add 'Uptime' button to site dashboard for quick access to uptime metrics --- app/sites/[id]/page.tsx | 7 + app/sites/[id]/uptime/page.tsx | 737 +++++++++++++++++++++++++++++++++ lib/api/uptime.ts | 128 ++++++ 3 files changed, 872 insertions(+) create mode 100644 app/sites/[id]/uptime/page.tsx create mode 100644 lib/api/uptime.ts diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 65361be..0f8d072 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -306,6 +306,13 @@ export default function SiteDashboardPage() { { value: 'custom', label: 'Custom' }, ]} /> + + + {/* 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)} + +
+
+ + {/* Recent checks */} + {loadingChecks ? ( +
+ Loading recent 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} + + )} + + {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 data') + } finally { + setLoading(false) + } + }, [siteId]) + + useEffect(() => { + loadData() + }, [loadData]) + + // * Auto-refresh every 30 seconds + useEffect(() => { + const interval = setInterval(async () => { + try { + const statusData = await getUptimeStatus(siteId) + setUptimeData(statusData) + } catch { + // * Silent refresh failure + } + }, 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 (!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) + } + + if (loading) return + if (!site) return
Site not found
+ + const 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} + + + {monitors.length} {monitors.length === 1 ? 'component' : 'components'} + +
+
+ + {formatUptime(overallUptime)} uptime + +
+
+ )} + + {/* 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 +}) { + return ( +
+ {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g. API, Website, CDN" + 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" + /> +
+ + {/* URL */} +
+ + 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" + /> +

+ Must be on {siteDomain} or a subdomain (e.g. api.{siteDomain}) +

+
+ + {/* 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" + /> +
+ + {/* 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" + /> +
+ + {/* Actions */} +
+ + +
+
+ ) +} diff --git a/lib/api/uptime.ts b/lib/api/uptime.ts new file mode 100644 index 0000000..5f4ec6f --- /dev/null +++ b/lib/api/uptime.ts @@ -0,0 +1,128 @@ +import apiRequest from './client' + +// * Types for uptime monitoring + +export interface UptimeMonitor { + id: string + site_id: string + name: string + url: string + check_interval_seconds: number + expected_status_code: number + timeout_seconds: number + enabled: boolean + last_checked_at: string | null + last_status: 'up' | 'down' | 'degraded' | 'unknown' + last_response_time_ms: number | null + created_at: string + updated_at: string +} + +export interface UptimeCheck { + id: string + monitor_id: string + status: 'up' | 'down' | 'degraded' + response_time_ms: number | null + status_code: number | null + error_message: string | null + checked_at: string +} + +export interface UptimeDailyStat { + monitor_id: string + date: string + total_checks: number + successful_checks: number + failed_checks: number + degraded_checks: number + avg_response_time_ms: number + min_response_time_ms: number | null + max_response_time_ms: number | null + uptime_percentage: number +} + +export interface MonitorStatus { + monitor: UptimeMonitor + daily_stats: UptimeDailyStat[] | null + overall_uptime: number +} + +export interface UptimeStatusResponse { + monitors: MonitorStatus[] | null + overall_uptime: number + status: 'operational' | 'degraded' | 'down' + total_monitors: number +} + +export interface CreateMonitorRequest { + name: string + url: string + check_interval_seconds?: number + expected_status_code?: number + timeout_seconds?: number +} + +export interface UpdateMonitorRequest { + name: string + url: string + check_interval_seconds?: number + expected_status_code?: number + timeout_seconds?: number + enabled?: boolean +} + +/** + * Fetches the uptime status overview for all monitors of a site + */ +export async function getUptimeStatus(siteId: string, startDate?: string, endDate?: string): Promise { + const params = new URLSearchParams() + if (startDate) params.append('start_date', startDate) + if (endDate) params.append('end_date', endDate) + const query = params.toString() + return apiRequest(`/sites/${siteId}/uptime/status${query ? `?${query}` : ''}`) +} + +/** + * Lists all uptime monitors for a site + */ +export async function listUptimeMonitors(siteId: string): Promise { + const res = await apiRequest<{ monitors: UptimeMonitor[] }>(`/sites/${siteId}/uptime/monitors`) + return res?.monitors ?? [] +} + +/** + * Creates a new uptime monitor + */ +export async function createUptimeMonitor(siteId: string, data: CreateMonitorRequest): Promise { + return apiRequest(`/sites/${siteId}/uptime/monitors`, { + method: 'POST', + body: JSON.stringify(data), + }) +} + +/** + * Updates an existing uptime monitor + */ +export async function updateUptimeMonitor(siteId: string, monitorId: string, data: UpdateMonitorRequest): Promise { + return apiRequest(`/sites/${siteId}/uptime/monitors/${monitorId}`, { + method: 'PUT', + body: JSON.stringify(data), + }) +} + +/** + * Deletes an uptime monitor + */ +export async function deleteUptimeMonitor(siteId: string, monitorId: string): Promise { + await apiRequest(`/sites/${siteId}/uptime/monitors/${monitorId}`, { + method: 'DELETE', + }) +} + +/** + * Fetches recent checks for a specific monitor + */ +export async function getMonitorChecks(siteId: string, monitorId: string, limit = 50): Promise { + const res = await apiRequest<{ checks: UptimeCheck[] }>(`/sites/${siteId}/uptime/monitors/${monitorId}/checks?limit=${limit}`) + return res?.checks ?? [] +} From a26092d22711840b157077738b83988337209f01 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 7 Feb 2026 12:23:45 +0100 Subject: [PATCH 2/6] refactor: replace 'size' prop with 'className' for button styling consistency in MonitorCard --- app/sites/[id]/uptime/page.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index 25798fa..28336cc 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -338,15 +338,14 @@ function MonitorCard({ From 6040b5b27f331bfb11ce9b82d601a3e475ffe7b3 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 7 Feb 2026 22:46:07 +0100 Subject: [PATCH 3/6] feat: implement uptime status visualization with tooltip and response time chart components --- app/sites/[id]/uptime/page.tsx | 470 +++++++++++++++++++++++++++------ 1 file changed, 395 insertions(+), 75 deletions(-) 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" />
From 027d2f50b63aa34cfa9ff637a8a465a3d2596ebc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 7 Feb 2026 23:01:38 +0100 Subject: [PATCH 4/6] fix: improve uptime data refresh error handling and confirm dialog for monitor deletion --- app/sites/[id]/uptime/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index 53f95d7..cd83e91 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -623,14 +623,14 @@ export default function UptimePage() { loadData() }, [loadData]) - // * Auto-refresh every 30 seconds + // * 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 { - // * Silent refresh failure + toast.error('Could not refresh uptime data. Check your connection or sign in again.') } }, 30000) return () => clearInterval(interval) @@ -679,7 +679,7 @@ export default function UptimePage() { } const handleDeleteMonitor = async (monitorId: string) => { - if (!confirm('Are you sure you want to delete this monitor? All historical data will be lost.')) return + 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') From 0887e245468f1c0b9fa14f1e9d0de59c9feb11bc Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 7 Feb 2026 23:06:12 +0100 Subject: [PATCH 5/6] fix: handle non-array uptime data for monitors and improve mouse leave behavior in UptimeStatusBar --- app/sites/[id]/uptime/page.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index cd83e91..b905e01 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -252,7 +252,10 @@ function UptimeStatusBar({ } return ( -
+
setHoveredDay(null)} + >
{dateRange.map((date) => { const stat = statsMap.get(date) @@ -704,7 +707,7 @@ export default function UptimePage() { if (loading) return if (!site) return
Site not found
- const monitors = uptimeData?.monitors ?? [] + const monitors = Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : [] const overallUptime = uptimeData?.overall_uptime ?? 100 const overallStatus = uptimeData?.status ?? 'operational' From 8424863a1838fd6d21596f5b41f60f5638bab82a Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 7 Feb 2026 23:09:37 +0100 Subject: [PATCH 6/6] refactor: simplify protocol handling in MonitorForm by deriving protocol directly from formData.url --- app/sites/[id]/uptime/page.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index b905e01..cfff216 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -868,10 +868,8 @@ function MonitorForm({ submitLabel: string siteDomain: string }) { - const [protocol, setProtocol] = useState<'https://' | 'http://'>(() => { - if (formData.url.startsWith('http://')) return 'http://' - return 'https://' - }) + // * 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) @@ -898,9 +896,7 @@ function MonitorForm({ } const handleProtocolChange = (proto: 'https://' | 'http://') => { - setProtocol(proto) setShowProtocolDropdown(false) - // * Rebuild URL with new protocol const path = getPath() setFormData({ ...formData, url: `${proto}${siteDomain}${path}` }) }