From 1d268197278c1aaf9a14d991663b5d96938afdfd Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 22 Mar 2026 16:51:42 +0100 Subject: [PATCH] feat: simplify uptime page to single auto-managed monitor with toggle Rewrites uptime page from 978 to ~370 lines. Removes all monitor CRUD UI (modals, monitor list, selection state). Adds enable/disable toggle and empty state. Reads the single auto-managed monitor. --- app/sites/[id]/uptime/page.tsx | 791 +++++++++------------------------ lib/api/sites.ts | 4 + 2 files changed, 209 insertions(+), 586 deletions(-) diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx index 497f95b..67dda0b 100644 --- a/app/sites/[id]/uptime/page.tsx +++ b/app/sites/[id]/uptime/page.tsx @@ -1,25 +1,19 @@ 'use client' import { useAuth } from '@/lib/auth/context' -import { useEffect, useState, useRef } from 'react' -import { useParams, useRouter } from 'next/navigation' -import { motion, AnimatePresence } from 'framer-motion' +import { useEffect, useState } from 'react' +import { useParams } from 'next/navigation' import { useSite, useUptimeStatus } from '@/lib/swr/dashboard' +import { updateSite, type Site } from '@/lib/api/sites' import { - 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 { Button } from '@ciphera-net/ui' import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import { formatDateFull, formatTime, formatDateTimeShort } from '@/lib/utils/formatDate' import { @@ -335,300 +329,65 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) { ) } -// * 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) => ( -
-
-
- - {formatDateTimeShort(new Date(check.checked_at))} - -
-
- {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 { data: site } = useSite(siteId) + const { data: site, mutate: mutateSite } = useSite(siteId) const { data: uptimeData, isLoading, mutate: mutateUptime } = useUptimeStatus(siteId) - 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 [toggling, setToggling] = useState(false) + const [checks, setChecks] = useState([]) + const [loadingChecks, setLoadingChecks] = useState(false) - const handleAddMonitor = async () => { - if (!formData.name || !formData.url) { - toast.error('Name and URL are required') + // * Single monitor from the auto-managed uptime system + const monitor = uptimeData?.monitors?.[0] ?? null + const overallUptime = uptimeData?.overall_uptime ?? 100 + const overallStatus = uptimeData?.status ?? 'operational' + + // * Fetch recent checks when we have a monitor + useEffect(() => { + if (!monitor) { + setChecks([]) 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 }) - mutateUptime() - } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to create monitor') - } finally { - setSaving(false) + const fetchChecks = async () => { + setLoadingChecks(true) + try { + const data = await getMonitorChecks(siteId, monitor.monitor.id, 20) + setChecks(data) + } catch { + // * Silent fail for check details + } finally { + setLoadingChecks(false) + } } - } + fetchChecks() + }, [siteId, monitor?.monitor.id]) - const handleEditMonitor = async () => { - if (!editingMonitor || !formData.name || !formData.url) return - setSaving(true) + const handleToggleUptime = async (enabled: boolean) => { + if (!site) return + setToggling(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, + await updateSite(site.id, { + name: site.name, + timezone: site.timezone, + is_public: site.is_public, + excluded_paths: site.excluded_paths, + uptime_enabled: enabled, }) - toast.success('Monitor updated successfully') - setShowEditModal(false) - setEditingMonitor(null) + mutateSite() mutateUptime() - } catch (error: unknown) { - toast.error(getAuthErrorMessage(error) || 'Failed to update monitor') + toast.success(enabled ? 'Uptime monitoring enabled' : 'Uptime monitoring disabled') + } catch { + toast.error('Failed to update uptime monitoring') } finally { - setSaving(false) + setToggling(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') - mutateUptime() - } 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]) @@ -639,10 +398,49 @@ export default function UptimePage() { 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' + const uptimeEnabled = site.uptime_enabled + // * Disabled state — show empty state with enable toggle + if (!uptimeEnabled) { + return ( +
+ {/* Header */} +
+

+ Uptime +

+

+ Monitor your site's availability and response time +

+
+ + {/* Empty state */} +
+
+ + + +
+

+ Uptime monitoring is disabled +

+

+ Enable uptime monitoring to track your site's availability and response time around the clock. +

+ {canEdit && ( + + )} +
+
+ ) + } + + // * Enabled state — show uptime dashboard return (
{/* Header */} @@ -652,327 +450,148 @@ export default function UptimePage() { Uptime

- Monitor your endpoints and track availability over time + Monitor your site's availability and response time

{canEdit && ( )}
{/* Overall status card */} - {monitors.length > 0 && ( -
-
-
-
-
- - {site.name} - - - {getOverallStatusText(overallStatus)} - -
-
-
- - {formatUptime(overallUptime)} uptime +
+
+
+
+
+ + {site.name} + + + {getOverallStatusText(overallStatus)} -
- {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 && ( -
- - +
+ + {formatUptime(overallUptime)} uptime + + {monitor && ( +
+ Last checked {formatTimeAgo(monitor.monitor.last_checked_at)}
)}
- {/* Domain prefix */} - - {siteDomain} - - {/* Path input */} -
-

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

- {/* Check interval */} -
- - -
+ {/* 90-day uptime bar */} + {monitor && ( +
+

+ 90-Day Availability +

+ +
+ 90 days ago + Today +
+
+ )} - {/* 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" - /> -
+ {/* Response time chart + Recent checks */} + {monitor && ( +
+ {/* Monitor details grid */} +
+
+
+ Status +
+
+
+ + {getStatusLabel(monitor.monitor.last_status)} + +
+
+
+
+ Response Time +
+ + {formatMs(monitor.monitor.last_response_time_ms)} + +
+
+
+ Check Interval +
+ + {monitor.monitor.check_interval_seconds >= 60 + ? `${Math.floor(monitor.monitor.check_interval_seconds / 60)}m` + : `${monitor.monitor.check_interval_seconds}s`} + +
+
+
+ Overall Uptime +
+ + {formatUptime(monitor.overall_uptime)} + +
+
- {/* 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" - /> -
+ {/* Response time chart */} + {loadingChecks ? ( + + ) : checks.length > 0 ? ( + <> + - {/* Actions */} -
- - -
+ {/* Recent checks */} +
+

+ Recent Checks +

+
+ {checks.slice(0, 20).map((check) => ( +
+
+
+ + {formatDateTimeShort(new Date(check.checked_at))} + +
+
+ {check.status_code && ( + + {check.status_code} + + )} + + {formatMs(check.response_time_ms)} + +
+
+ ))} +
+
+ + ) : null} +
+ )}
) } diff --git a/lib/api/sites.ts b/lib/api/sites.ts index 5cdf284..55992f9 100644 --- a/lib/api/sites.ts +++ b/lib/api/sites.ts @@ -25,6 +25,8 @@ export interface Site { data_retention_months?: number // Script feature toggles script_features?: Record + // Uptime monitoring toggle + uptime_enabled: boolean is_verified?: boolean created_at: string updated_at: string @@ -53,6 +55,8 @@ export interface UpdateSiteRequest { filter_bots?: boolean // Script feature toggles script_features?: Record + // Uptime monitoring toggle + uptime_enabled?: boolean // Hide unknown locations from stats hide_unknown_locations?: boolean // Data retention (months); 0 = keep forever