[PULSE-47] Add uptime monitoring dashboard #15

Merged
uz1mani merged 6 commits from staging into main 2026-02-07 22:38:13 +00:00
3 changed files with 872 additions and 0 deletions
Showing only changes of commit f382bab647 - Show all commits

View File

@@ -306,6 +306,13 @@ export default function SiteDashboardPage() {
{ value: 'custom', label: 'Custom' },
]}
/>
<Button
onClick={() => router.push(`/sites/${siteId}/uptime`)}
variant="secondary"
className="text-sm"
>
Uptime
</Button>
<Button
onClick={() => router.push(`/sites/${siteId}/funnels`)}
variant="secondary"

View File

@@ -0,0 +1,737 @@
'use client'
greptile-apps[bot] commented 2026-02-07 21:59:05 +00:00 (Migrated from github.com)
Review

use window.confirm instead of native confirm for better compatibility and clarity

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/uptime/page.tsx
Line: 682:682

Comment:
use `window.confirm` instead of native `confirm` for better compatibility and clarity

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.
use `window.confirm` instead of native `confirm` for better compatibility and clarity <sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub> <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/uptime/page.tsx Line: 682:682 Comment: use `window.confirm` instead of native `confirm` for better compatibility and clarity <sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub> How can I resolve this? If you propose a fix, please make it concise. ````` </details>
greptile-apps[bot] commented 2026-02-07 22:03:09 +00:00 (Migrated from github.com)
Review

Null monitors breaks render

UptimeStatusResponse.monitors is typed as MonitorStatus[] | null (see lib/api/uptime.ts), but the page does const monitors = uptimeData?.monitors ?? [] and then calls monitors.length / monitors.map(...). If the API returns monitors: null, this becomes null (because ?? doesn’t coalesce inside the optional chain) and will throw at render.

Also appears at app/sites/[id]/uptime/page.tsx:749 (uses monitors.length > 0).

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/uptime/page.tsx
Line: 707:709

Comment:
**Null monitors breaks render**

`UptimeStatusResponse.monitors` is typed as `MonitorStatus[] | null` (see `lib/api/uptime.ts`), but the page does `const monitors = uptimeData?.monitors ?? []` and then calls `monitors.length` / `monitors.map(...)`. If the API returns `monitors: null`, this becomes `null` (because `??` doesn’t coalesce inside the optional chain) and will throw at render.

Also appears at `app/sites/[id]/uptime/page.tsx:749` (uses `monitors.length > 0`).

How can I resolve this? If you propose a fix, please make it concise.
**Null monitors breaks render** `UptimeStatusResponse.monitors` is typed as `MonitorStatus[] | null` (see `lib/api/uptime.ts`), but the page does `const monitors = uptimeData?.monitors ?? []` and then calls `monitors.length` / `monitors.map(...)`. If the API returns `monitors: null`, this becomes `null` (because `??` doesn’t coalesce inside the optional chain) and will throw at render. Also appears at `app/sites/[id]/uptime/page.tsx:749` (uses `monitors.length > 0`). <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/uptime/page.tsx Line: 707:709 Comment: **Null monitors breaks render** `UptimeStatusResponse.monitors` is typed as `MonitorStatus[] | null` (see `lib/api/uptime.ts`), but the page does `const monitors = uptimeData?.monitors ?? []` and then calls `monitors.length` / `monitors.map(...)`. If the API returns `monitors: null`, this becomes `null` (because `??` doesn’t coalesce inside the optional chain) and will throw at render. Also appears at `app/sites/[id]/uptime/page.tsx:749` (uses `monitors.length > 0`). How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-07 22:03:14 +00:00 (Migrated from github.com)
Review

Issue: Using bare confirm was flagged for compatibility/clarity.
Fix: Replaced with window.confirm(...) so the global is explicit.
Why: Explicit window.confirm is clearer and avoids any non-browser or strict global assumptions.

Issue: Using bare confirm was flagged for compatibility/clarity. Fix: Replaced with window.confirm(...) so the global is explicit. Why: Explicit window.confirm is clearer and avoids any non-browser or strict global assumptions.
uz1mani commented 2026-02-07 22:05:57 +00:00 (Migrated from github.com)
Review

Issue: monitors can be null; using ?? [] was flagged as potentially leaving a non-array.
Fix: Use Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : [] so we always render with an array.
Why: Ensures .length and .map() are safe regardless of API response.

Issue: monitors can be null; using ?? [] was flagged as potentially leaving a non-array. Fix: Use Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : [] so we always render with an array. Why: Ensures .length and .map() are safe regardless of API response.
greptile-apps[bot] commented 2026-02-07 22:07:39 +00:00 (Migrated from github.com)
Review

Protocol state can desync

protocol is initialized from formData.url once, but when opening the edit modal you set formData.url to an existing monitor URL without updating protocol. If the saved monitor URL is http://... and protocol remains https://, any path edit or protocol toggle rebuild will silently rewrite the URL with the wrong scheme. Derive protocol from formData.url (or sync it in an effect when formData.url changes) so editing preserves the monitor’s actual scheme.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/uptime/page.tsx
Line: 868:903

Comment:
**Protocol state can desync**

`protocol` is initialized from `formData.url` once, but when opening the edit modal you set `formData.url` to an existing monitor URL without updating `protocol`. If the saved monitor URL is `http://...` and `protocol` remains `https://`, any path edit or protocol toggle rebuild will silently rewrite the URL with the wrong scheme. Derive `protocol` from `formData.url` (or sync it in an effect when `formData.url` changes) so editing preserves the monitor’s actual scheme.


How can I resolve this? If you propose a fix, please make it concise.
**Protocol state can desync** `protocol` is initialized from `formData.url` once, but when opening the edit modal you set `formData.url` to an existing monitor URL without updating `protocol`. If the saved monitor URL is `http://...` and `protocol` remains `https://`, any path edit or protocol toggle rebuild will silently rewrite the URL with the wrong scheme. Derive `protocol` from `formData.url` (or sync it in an effect when `formData.url` changes) so editing preserves the monitor’s actual scheme. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/uptime/page.tsx Line: 868:903 Comment: **Protocol state can desync** `protocol` is initialized from `formData.url` once, but when opening the edit modal you set `formData.url` to an existing monitor URL without updating `protocol`. If the saved monitor URL is `http://...` and `protocol` remains `https://`, any path edit or protocol toggle rebuild will silently rewrite the URL with the wrong scheme. Derive `protocol` from `formData.url` (or sync it in an effect when `formData.url` changes) so editing preserves the monitor’s actual scheme. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-07 22:09:45 +00:00 (Migrated from github.com)
Review

Issue: protocol was initialized once from formData.url, so when opening edit with a monitor that had http://, protocol could still be https:// and path edits used the wrong scheme.
Fix: Protocol is now derived from formData.url at render time (formData.url.startsWith('http://') ? 'http://' : 'https://'); protocol state was removed and handleProtocolChange only updates formData.url.
Why: Editing must reflect the saved monitor’s scheme; deriving from formData.url keeps the dropdown and URL in sync whenever the parent sets formData (e.g. when opening edit).

Issue: protocol was initialized once from formData.url, so when opening edit with a monitor that had http://, protocol could still be https:// and path edits used the wrong scheme. Fix: Protocol is now derived from formData.url at render time (formData.url.startsWith('http://') ? 'http://' : 'https://'); protocol state was removed and handleProtocolChange only updates formData.url. Why: Editing must reflect the saved monitor’s scheme; deriving from formData.url keeps the dropdown and URL in sync whenever the parent sets formData (e.g. when opening edit).
import { useAuth } from '@/lib/auth/context'
import { useEffect, useState, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { motion, AnimatePresence } from 'framer-motion'
import { getSite, type Site } from '@/lib/api/sites'
import {
getUptimeStatus,
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 { getAuthErrorMessage } from '@/lib/utils/authErrors'
import { LoadingOverlay, Button, Modal } from '@ciphera-net/ui'
// * Status color mapping
function getStatusColor(status: string): string {
switch (status) {
case 'up':
case 'operational':
return 'bg-emerald-500'
case 'degraded':
return 'bg-amber-500'
case 'down':
return 'bg-red-500'
default:
return 'bg-neutral-300 dark:bg-neutral-600'
}
}
function getStatusDotColor(status: string): string {
switch (status) {
case 'up':
case 'operational':
return 'bg-emerald-500'
case 'degraded':
return 'bg-amber-500'
case 'down':
return 'bg-red-500'
default:
return 'bg-neutral-400'
}
}
function getStatusLabel(status: string): string {
switch (status) {
case 'up':
case 'operational':
return 'Operational'
case 'degraded':
return 'Degraded'
case 'down':
return 'Down'
default:
return 'Unknown'
}
}
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'
}
function getDayBarColor(stat: UptimeDailyStat | undefined): string {
if (!stat) return 'bg-neutral-200 dark:bg-neutral-700'
if (stat.failed_checks > 0) return 'bg-red-500'
if (stat.degraded_checks > 0) return 'bg-amber-500'
return 'bg-emerald-500'
}
function formatUptime(pct: number): string {
return pct.toFixed(2) + '%'
}
function formatMs(ms: number | null): string {
if (ms === null) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function formatTimeAgo(dateString: string | null): string {
if (!dateString) return 'Never'
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
if (diffSec < 60) return 'just now'
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
return `${Math.floor(diffSec / 86400)}d ago`
}
// * Generate array of dates for the last N days
function generateDateRange(days: number): string[] {
const dates: string[] = []
const now = new Date()
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now)
d.setDate(d.getDate() - i)
dates.push(d.toISOString().split('T')[0])
}
return dates
}
// * Component: Uptime status bar (the colored bars visualization)
function UptimeStatusBar({
dailyStats,
days = 90,
}: {
dailyStats: UptimeDailyStat[] | null
days?: number
}) {
const dateRange = generateDateRange(days)
const statsMap = new Map<string, UptimeDailyStat>()
if (dailyStats) {
for (const s of dailyStats) {
statsMap.set(s.date, s)
}
}
return (
<div className="flex items-center gap-[2px] w-full">
{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`
return (
<div
key={date}
className={`flex-1 h-8 rounded-[2px] ${barColor} transition-all duration-150 hover:opacity-80 cursor-pointer group relative min-w-[3px]`}
title={tooltipText}
/>
)
})}
</div>
)
}
// * 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<UptimeCheck[]>([])
const [loadingChecks, setLoadingChecks] = useState(false)
useEffect(() => {
if (expanded && checks.length === 0) {
const fetchChecks = async () => {
setLoadingChecks(true)
try {
const data = await getMonitorChecks(siteId, monitor.id, 20)
setChecks(data)
} catch {
// * Silent fail for check details
} finally {
setLoadingChecks(false)
}
}
fetchChecks()
}
}, [expanded, siteId, monitor.id, checks.length])
return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden">
{/* Header */}
<button
onClick={onToggle}
className="w-full p-5 flex items-center justify-between hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
>
<div className="flex items-center gap-3">
{/* Status indicator */}
<div className={`w-3 h-3 rounded-full ${getStatusDotColor(monitor.last_status)} shrink-0`} />
<span className="font-semibold text-neutral-900 dark:text-white">
{monitor.name}
</span>
<span className="text-sm text-neutral-500 dark:text-neutral-400 hidden sm:inline">
{monitor.url}
</span>
</div>
<div className="flex items-center gap-4">
{monitor.last_response_time_ms !== null && (
<span className="text-sm text-neutral-500 dark:text-neutral-400 hidden sm:inline">
{formatMs(monitor.last_response_time_ms)}
</span>
)}
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{formatUptime(overall_uptime)} uptime
</span>
<svg
className={`w-4 h-4 text-neutral-400 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* Status bar */}
<div className="px-5 pb-4">
<UptimeStatusBar dailyStats={daily_stats} />
<div className="flex justify-between mt-1.5 text-xs text-neutral-400 dark:text-neutral-500">
<span>90 days ago</span>
<span>Today</span>
</div>
</div>
{/* Expanded details */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 border-t border-neutral-200 dark:border-neutral-800 pt-4">
{/* Monitor details grid */}
greptile-apps[bot] commented 2026-02-07 22:03:10 +00:00 (Migrated from github.com)
Review

Tooltip never clears

onMouseLeave only clears hoveredDay, but StatusBarTooltip uses visible={hoveredDay !== null} so the tooltip stays stuck open after hover ends. This will be reproducible whenever you move the mouse off a day bar.

Also appears at app/sites/[id]/uptime/page.tsx:266-276 (same hover state).

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/uptime/page.tsx
Line: 245:251

Comment:
**Tooltip never clears**

`onMouseLeave` only clears `hoveredDay`, but `StatusBarTooltip` uses `visible={hoveredDay !== null}` so the tooltip stays stuck open after hover ends. This will be reproducible whenever you move the mouse off a day bar.

Also appears at `app/sites/[id]/uptime/page.tsx:266-276` (same hover state).

How can I resolve this? If you propose a fix, please make it concise.
**Tooltip never clears** `onMouseLeave` only clears `hoveredDay`, but `StatusBarTooltip` uses `visible={hoveredDay !== null}` so the tooltip stays stuck open after hover ends. This will be reproducible whenever you move the mouse off a day bar. Also appears at `app/sites/[id]/uptime/page.tsx:266-276` (same hover state). <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/uptime/page.tsx Line: 245:251 Comment: **Tooltip never clears** `onMouseLeave` only clears `hoveredDay`, but `StatusBarTooltip` uses `visible={hoveredDay !== null}` so the tooltip stays stuck open after hover ends. This will be reproducible whenever you move the mouse off a day bar. Also appears at `app/sites/[id]/uptime/page.tsx:266-276` (same hover state). How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-07 22:06:07 +00:00 (Migrated from github.com)
Review

Issue: Tooltip could stay visible after hover ended.
Fix: Added onMouseLeave on the status bar container to clear hoveredDay when the pointer leaves the whole block; bars still clear on their own onMouseLeave.
Why: Clearing at the container level guarantees the tooltip hides when the cursor leaves the component, including when moving over gaps or the fixed tooltip.

Issue: Tooltip could stay visible after hover ended. Fix: Added onMouseLeave on the status bar container to clear hoveredDay when the pointer leaves the whole block; bars still clear on their own onMouseLeave. Why: Clearing at the container level guarantees the tooltip hides when the cursor leaves the component, including when moving over gaps or the fixed tooltip.
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-5">
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Status
</div>
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(monitor.last_status)}`} />
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{getStatusLabel(monitor.last_status)}
</span>
</div>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Response Time
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{formatMs(monitor.last_response_time_ms)}
</span>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Check Interval
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{monitor.check_interval_seconds >= 60
? `${Math.floor(monitor.check_interval_seconds / 60)}m`
: `${monitor.check_interval_seconds}s`}
</span>
</div>
<div>
<div className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-1">
Last Checked
</div>
<span className="text-sm font-medium text-neutral-900 dark:text-white">
{formatTimeAgo(monitor.last_checked_at)}
</span>
</div>
</div>
{/* Recent checks */}
{loadingChecks ? (
<div className="text-center py-4 text-neutral-500 dark:text-neutral-400 text-sm">
Loading recent checks...
</div>
) : checks.length > 0 ? (
<div>
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
Recent Checks
</h4>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{checks.map((check) => (
<div
key={check.id}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800 text-sm"
>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(check.status)}`} />
<span className="text-neutral-600 dark:text-neutral-300 text-xs">
{new Date(check.checked_at).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className="flex items-center gap-3">
{check.status_code && (
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{check.status_code}
</span>
)}
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
{formatMs(check.response_time_ms)}
</span>
</div>
</div>
))}
</div>
</div>
) : null}
{/* Actions */}
{canEdit && (
<div className="flex gap-2 mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800">
<Button
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onEdit() }}
variant="secondary"
size="sm"
>
Edit
</Button>
<Button
onClick={(e: React.MouseEvent) => { e.stopPropagation(); onDelete() }}
variant="secondary"
size="sm"
className="text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Delete
</Button>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
// * 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<Site | null>(null)
const [loading, setLoading] = useState(true)
const [uptimeData, setUptimeData] = useState<UptimeStatusResponse | null>(null)
const [expandedMonitor, setExpandedMonitor] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [editingMonitor, setEditingMonitor] = useState<MonitorStatus | null>(null)
const [formData, setFormData] = useState<CreateMonitorRequest>({
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 <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Uptime" />
if (!site) return <div className="p-8 text-neutral-500">Site not found</div>
const monitors = uptimeData?.monitors ?? []
const overallUptime = uptimeData?.overall_uptime ?? 100
const overallStatus = uptimeData?.status ?? 'operational'
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
className="w-full max-w-6xl mx-auto px-4 sm:px-6 py-8"
>
{/* Header */}
<div className="mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<button
onClick={() => router.push(`/sites/${siteId}`)}
className="text-sm text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
>
{site.name}
</button>
<span className="text-neutral-300 dark:text-neutral-600">/</span>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white">
Uptime
</h1>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Monitor your endpoints and track availability over time
</p>
</div>
{canEdit && (
<Button
onClick={() => {
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
setShowAddModal(true)
}}
>
Add Monitor
</Button>
)}
</div>
{/* Overall status card */}
{monitors.length > 0 && (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-5 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3.5 h-3.5 rounded-full ${getStatusDotColor(overallStatus)}`} />
<div>
<span className="font-semibold text-neutral-900 dark:text-white text-lg">
{site.name}
</span>
<span className="text-sm text-neutral-500 dark:text-neutral-400 ml-3">
{monitors.length} {monitors.length === 1 ? 'component' : 'components'}
</span>
</div>
</div>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{formatUptime(overallUptime)} uptime
</span>
</div>
</div>
)}
{/* Monitor list */}
{monitors.length > 0 ? (
<div className="space-y-4">
{monitors.map((ms) => (
<MonitorCard
key={ms.monitor.id}
monitorStatus={ms}
expanded={expandedMonitor === ms.monitor.id}
onToggle={() => setExpandedMonitor(
expandedMonitor === ms.monitor.id ? null : ms.monitor.id
)}
onEdit={() => openEditModal(ms)}
onDelete={() => handleDeleteMonitor(ms.monitor.id)}
canEdit={canEdit}
siteId={siteId}
/>
))}
</div>
) : (
/* Empty state */
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl p-12 text-center">
<div className="rounded-full bg-neutral-100 dark:bg-neutral-800 p-4 w-16 h-16 mx-auto mb-4 flex items-center justify-center">
<svg className="w-8 h-8 text-neutral-500 dark:text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="font-semibold text-neutral-900 dark:text-white mb-2">
No monitors yet
</h3>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6 max-w-md mx-auto">
Add a monitor to start tracking the uptime and response time of your endpoints. You can monitor APIs, websites, and any HTTP endpoint.
</p>
{canEdit && (
<Button
onClick={() => {
setFormData({ name: '', url: '', check_interval_seconds: 300, expected_status_code: 200, timeout_seconds: 30 })
setShowAddModal(true)
}}
>
Add Your First Monitor
</Button>
)}
</div>
)}
{/* Add Monitor Modal */}
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title="Add Monitor">
<MonitorForm
formData={formData}
setFormData={setFormData}
onSubmit={handleAddMonitor}
onCancel={() => setShowAddModal(false)}
saving={saving}
submitLabel="Create Monitor"
siteDomain={site.domain}
/>
</Modal>
{/* Edit Monitor Modal */}
<Modal isOpen={showEditModal} onClose={() => setShowEditModal(false)} title="Edit Monitor">
<MonitorForm
formData={formData}
setFormData={setFormData}
onSubmit={handleEditMonitor}
onCancel={() => setShowEditModal(false)}
saving={saving}
submitLabel="Save Changes"
siteDomain={site.domain}
/>
</Modal>
</motion.div>
)
}
// * 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
greptile-apps[bot] commented 2026-02-07 21:59:04 +00:00 (Migrated from github.com)
Review

auto-refresh will silently fail if user loses network connection or auth token expires, leaving stale data visible without indication to user. Consider adding visual feedback or error handling

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/uptime/page.tsx
Line: 627:637

Comment:
auto-refresh will silently fail if user loses network connection or auth token expires, leaving stale data visible without indication to user. Consider adding visual feedback or error handling

How can I resolve this? If you propose a fix, please make it concise.
auto-refresh will silently fail if user loses network connection or auth token expires, leaving stale data visible without indication to user. Consider adding visual feedback or error handling <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/uptime/page.tsx Line: 627:637 Comment: auto-refresh will silently fail if user loses network connection or auth token expires, leaving stale data visible without indication to user. Consider adding visual feedback or error handling How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-07 22:03:07 +00:00 (Migrated from github.com)
Review

Issue: Auto-refresh could fail silently on network loss or auth expiry, leaving stale data with no indication.
Fix: On refresh failure we now call toast.error(...) so the user sees “Could not refresh uptime data. Check your connection or sign in again.”
Why: Users need feedback when background refresh fails so they know data may be stale and can reconnect or re-auth.

Issue: Auto-refresh could fail silently on network loss or auth expiry, leaving stale data with no indication. Fix: On refresh failure we now call toast.error(...) so the user sees “Could not refresh uptime data. Check your connection or sign in again.” Why: Users need feedback when background refresh fails so they know data may be stale and can reconnect or re-auth.
siteDomain: string
}) {
return (
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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"
/>
</div>
{/* URL */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
URL
</label>
<input
type="url"
value={formData.url}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
Must be on <span className="font-medium">{siteDomain}</span> or a subdomain (e.g. api.{siteDomain})
</p>
</div>
{/* Check interval */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Check Interval
</label>
<select
value={formData.check_interval_seconds}
onChange={(e) => setFormData({ ...formData, check_interval_seconds: parseInt(e.target.value) })}
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"
>
<option value={60}>Every 1 minute</option>
<option value={120}>Every 2 minutes</option>
<option value={300}>Every 5 minutes</option>
<option value={600}>Every 10 minutes</option>
<option value={900}>Every 15 minutes</option>
<option value={1800}>Every 30 minutes</option>
<option value={3600}>Every 1 hour</option>
</select>
</div>
{/* Expected status code */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Expected Status Code
</label>
<input
type="number"
value={formData.expected_status_code}
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"
/>
</div>
greptile-apps[bot] commented 2026-02-07 22:07:38 +00:00 (Migrated from github.com)
Review

monitors can be null

UptimeStatusResponse.monitors is typed as MonitorStatus[] | null (lib/api/uptime.ts), but this page still does const monitors = uptimeData?.monitors ?? []. If the API returns monitors: null, this expression evaluates to null (because the optional chain returns null, which ?? does not replace), and the subsequent monitors.length / monitors.map(...) will throw during render. Use an explicit array guard (e.g., Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []) before reading .length / .map.

Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/uptime/page.tsx
Line: 707:709

Comment:
**`monitors` can be `null`**

`UptimeStatusResponse.monitors` is typed as `MonitorStatus[] | null` (`lib/api/uptime.ts`), but this page still does `const monitors = uptimeData?.monitors ?? []`. If the API returns `monitors: null`, this expression evaluates to `null` (because the optional chain returns `null`, which `??` does not replace), and the subsequent `monitors.length` / `monitors.map(...)` will throw during render. Use an explicit array guard (e.g., `Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []`) before reading `.length` / `.map`.


How can I resolve this? If you propose a fix, please make it concise.
**`monitors` can be `null`** `UptimeStatusResponse.monitors` is typed as `MonitorStatus[] | null` (`lib/api/uptime.ts`), but this page still does `const monitors = uptimeData?.monitors ?? []`. If the API returns `monitors: null`, this expression evaluates to `null` (because the optional chain returns `null`, which `??` does not replace), and the subsequent `monitors.length` / `monitors.map(...)` will throw during render. Use an explicit array guard (e.g., `Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []`) before reading `.length` / `.map`. <details><summary>Prompt To Fix With AI</summary> `````markdown This is a comment left during a code review. Path: app/sites/[id]/uptime/page.tsx Line: 707:709 Comment: **`monitors` can be `null`** `UptimeStatusResponse.monitors` is typed as `MonitorStatus[] | null` (`lib/api/uptime.ts`), but this page still does `const monitors = uptimeData?.monitors ?? []`. If the API returns `monitors: null`, this expression evaluates to `null` (because the optional chain returns `null`, which `??` does not replace), and the subsequent `monitors.length` / `monitors.map(...)` will throw during render. Use an explicit array guard (e.g., `Array.isArray(uptimeData?.monitors) ? uptimeData.monitors : []`) before reading `.length` / `.map`. How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-07 22:09:52 +00:00 (Migrated from github.com)
Review

alreayd fixed

alreayd fixed
{/* Timeout */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
Timeout (seconds)
</label>
<input
type="number"
value={formData.timeout_seconds}
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"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={saving || !formData.name || !formData.url}>
{saving ? 'Saving...' : submitLabel}
</Button>
</div>
</div>
)
}

128
lib/api/uptime.ts Normal file
View File

@@ -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<UptimeStatusResponse> {
const params = new URLSearchParams()
if (startDate) params.append('start_date', startDate)
if (endDate) params.append('end_date', endDate)
const query = params.toString()
return apiRequest<UptimeStatusResponse>(`/sites/${siteId}/uptime/status${query ? `?${query}` : ''}`)
}
/**
* Lists all uptime monitors for a site
*/
export async function listUptimeMonitors(siteId: string): Promise<UptimeMonitor[]> {
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<UptimeMonitor> {
return apiRequest<UptimeMonitor>(`/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<UptimeMonitor> {
return apiRequest<UptimeMonitor>(`/sites/${siteId}/uptime/monitors/${monitorId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
/**
* Deletes an uptime monitor
*/
export async function deleteUptimeMonitor(siteId: string, monitorId: string): Promise<void> {
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<UptimeCheck[]> {
const res = await apiRequest<{ checks: UptimeCheck[] }>(`/sites/${siteId}/uptime/monitors/${monitorId}/checks?limit=${limit}`)
return res?.checks ?? []
}