[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 1191 additions and 0 deletions
Showing only changes of commit 6040b5b27f - Show all commits

View File

@@ -1,7 +1,7 @@
'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 { 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 (
<div
className="fixed z-50 pointer-events-none"
style={{ left: position.x, top: position.y - 10, transform: 'translate(-50%, -100%)' }}
>
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-lg px-3 py-2.5 text-xs min-w-[160px]">
<div className="font-semibold text-neutral-900 dark:text-white mb-1.5">{formattedDate}</div>
{stat && stat.total_checks > 0 ? (
<div className="space-y-1">
<div className="flex justify-between gap-4">
<span className="text-neutral-500 dark:text-neutral-400">Uptime</span>
<span className="font-medium text-neutral-900 dark:text-white">
{formatUptime(stat.uptime_percentage)}
</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-neutral-500 dark:text-neutral-400">Checks</span>
<span className="font-medium text-neutral-900 dark:text-white">{stat.total_checks}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-neutral-500 dark:text-neutral-400">Avg Response</span>
<span className="font-medium text-neutral-900 dark:text-white">
{formatMs(Math.round(stat.avg_response_time_ms))}
</span>
</div>
{stat.failed_checks > 0 && (
<div className="flex justify-between gap-4">
<span className="text-red-500">Failed</span>
<span className="font-medium text-red-500">{stat.failed_checks}</span>
</div>
)}
</div>
) : (
<div className="text-neutral-400 dark:text-neutral-500">No data</div>
)}
{/* Tooltip arrow */}
<div className="absolute left-1/2 -translate-x-1/2 bottom-[-5px] w-2.5 h-2.5 bg-white dark:bg-neutral-800 border-r border-b border-neutral-200 dark:border-neutral-700 rotate-45" />
</div>
</div>
)
}
// * Component: Uptime status bar (the colored bars visualization)
function UptimeStatusBar({
dailyStats,
@@ -129,24 +242,127 @@ function UptimeStatusBar({
}
}
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`
const [hoveredDay, setHoveredDay] = useState<{ date: string; stat: UptimeDailyStat | undefined } | null>(null)
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 })
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}
/>
)
})}
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 })
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.
}
return (
<div className="relative">
<div className="flex items-center gap-[2px] w-full">
{dateRange.map((date) => {
const stat = statsMap.get(date)
const barColor = getDayBarColor(stat)
return (
<div
key={date}
className={`flex-1 h-8 rounded-[2px] ${barColor} transition-all duration-150 hover:opacity-80 cursor-pointer min-w-[3px]`}
onMouseEnter={(e) => handleMouseEnter(e, date, stat)}
onMouseLeave={() => setHoveredDay(null)}
/>
)
})}
</div>
<StatusBarTooltip
stat={hoveredDay?.stat}
date={hoveredDay?.date ?? ''}
visible={hoveredDay !== null}
position={tooltipPos}
/>
</div>
)
}
// * 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<number, string>) => {
if (!active || !payload?.length) return null
return (
<div
className="rounded-xl px-3 py-2 text-xs shadow-lg border"
style={{
background: colors.tooltipBg,
borderColor: colors.tooltipBorder,
color: colors.text,
}}
>
<div className="font-medium mb-0.5">{label}</div>
<div style={{ color: '#FD5E0F' }} className="font-semibold">
{payload[0].value}ms
</div>
</div>
)
}
return (
<div className="mt-4">
<h4 className="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tracking-wider mb-3">
Response Time
</h4>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="responseTimeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FD5E0F" stopOpacity={0.3} />
<stop offset="100%" stopColor="#FD5E0F" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke={colors.border}
strokeOpacity={0.5}
vertical={false}
/>
<XAxis
dataKey="time"
tick={{ fontSize: 10, fill: colors.axis }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: colors.axis }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v}ms`}
/>
<RechartsTooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="ms"
stroke="#FD5E0F"
strokeWidth={2}
fill="url(#responseTimeGradient)"
dot={false}
activeDot={{ r: 4, fill: '#FD5E0F', strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)
}
@@ -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({
</div>
</div>
{/* Recent checks */}
{/* Response time chart */}
{loadingChecks ? (
<div className="text-center py-4 text-neutral-500 dark:text-neutral-400 text-sm">
Loading recent checks...
Loading 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}
<>
<ResponseTimeChart checks={checks} />
{/* Recent checks */}
<div className="mt-5">
<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.slice(0, 20).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>
)}
<span className="text-xs font-medium text-neutral-700 dark:text-neutral-300">
{formatMs(check.response_time_ms)}
</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>
</div>
</div>
</>
) : null}
{/* Actions */}
@@ -535,14 +756,19 @@ export default function UptimePage() {
<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 className={`text-sm font-medium ml-3 ${getOverallStatusTextColor(overallStatus)}`}>
{getOverallStatusText(overallStatus)}
</span>
</div>
</div>
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{formatUptime(overallUptime)} uptime
</span>
<div className="text-right">
<span className="text-sm font-semibold text-neutral-900 dark:text-white">
{formatUptime(overallUptime)} uptime
</span>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{monitors.length} {monitors.length === 1 ? 'component' : 'components'}
</div>
</div>
</div>
</div>
)}
@@ -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<HTMLDivElement>(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<HTMLInputElement>) => {
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
greptile-apps[bot] commented 2026-02-07 21:59:03 +00:00 (Migrated from github.com)
Review

disabling exhaustive-deps can lead to stale closure bugs

  useEffect(() => {
    if (!formData.url) {
      setFormData({ ...formData, url: `${protocol}${siteDomain}` })
    }
  }, [formData, protocol, siteDomain, setFormData])
Prompt To Fix With AI
This is a comment left during a code review.
Path: app/sites/[id]/uptime/page.tsx
Line: 906:911

Comment:
disabling exhaustive-deps can lead to stale closure bugs

```suggestion
  useEffect(() => {
    if (!formData.url) {
      setFormData({ ...formData, url: `${protocol}${siteDomain}` })
    }
  }, [formData, protocol, siteDomain, setFormData])
```

How can I resolve this? If you propose a fix, please make it concise.
disabling exhaustive-deps can lead to stale closure bugs ```suggestion useEffect(() => { if (!formData.url) { setFormData({ ...formData, url: `${protocol}${siteDomain}` }) } }, [formData, protocol, siteDomain, setFormData]) ``` <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: 906:911 Comment: disabling exhaustive-deps can lead to stale closure bugs ```suggestion useEffect(() => { if (!formData.url) { setFormData({ ...formData, url: `${protocol}${siteDomain}` }) } }, [formData, protocol, siteDomain, setFormData]) ``` How can I resolve this? If you propose a fix, please make it concise. ````` </details>
uz1mani commented 2026-02-07 22:02:48 +00:00 (Migrated from github.com)
Review

Already fixed in the backend

Already fixed in the backend
}, [])
// * 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 (
<div className="space-y-4">
{/* Name */}
@@ -655,20 +937,58 @@ function MonitorForm({
/>
</div>
{/* URL */}
{/* URL with protocol dropdown + domain prefix */}
<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"
/>
<div className="flex rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 focus-within:ring-2 focus-within:ring-brand-orange focus-within:border-transparent overflow-hidden">
{/* Protocol dropdown */}
<div ref={dropdownRef} className="relative">
<button
type="button"
onClick={() => setShowProtocolDropdown(!showProtocolDropdown)}
className="h-full px-3 flex items-center gap-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-300 text-sm border-r border-neutral-300 dark:border-neutral-600 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors select-none whitespace-nowrap"
>
{protocol}
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{showProtocolDropdown && (
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg z-10 min-w-[100px]">
<button
type="button"
onClick={() => handleProtocolChange('https://')}
className={`w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors rounded-t-lg ${protocol === 'https://' ? 'text-brand-orange font-medium' : 'text-neutral-700 dark:text-neutral-300'}`}
>
https://
</button>
<button
type="button"
onClick={() => handleProtocolChange('http://')}
className={`w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors rounded-b-lg ${protocol === 'http://' ? 'text-brand-orange font-medium' : 'text-neutral-700 dark:text-neutral-300'}`}
>
http://
</button>
</div>
)}
</div>
{/* Domain prefix */}
<span className="flex items-center px-1.5 text-sm text-neutral-500 dark:text-neutral-400 select-none whitespace-nowrap bg-neutral-100 dark:bg-neutral-700 border-r border-neutral-300 dark:border-neutral-600">
{siteDomain}
</span>
{/* Path input */}
<input
type="text"
value={getPath()}
onChange={handlePathChange}
placeholder="/api/health"
className="flex-1 min-w-0 px-3 py-2 bg-transparent text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none text-sm"
/>
</div>
<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})
Add a specific path (e.g. /api/health) or leave empty for the root domain
</p>
</div>
@@ -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"
/>
</div>
@@ -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"
/>
</div>