From f382bab64795edb963e0a853361d31d718b084f1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 7 Feb 2026 12:20:06 +0100 Subject: [PATCH] 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 ?? [] +}