import { checkIntervalPresets, defaultUptimeThresholds, normalizeMonitorUrl, normalizePublicMonitorSlug, publicMonitorSlugSchema, timeoutConstraints, type MonitorCreate, type Badge, type NotificationService, } from '@uptime/contracts'; import { continentIds, continentLabels, regions, type RegionDefinition, type RegionId, } from '@uptime/regions'; import { useEffect, useRef, useState } from 'react'; import type { FormEvent } from 'react'; import { api, RequestError } from './api.js'; import { formatInterval } from './monitor-format.js'; import { checkFrequencySliderIndex, expectedChecksPerDay, expectedDnsSnapshotsPerDay, intervalSecondsForSliderIndex, } from './monitor-form.logic.js'; interface Props { monitor?: MonitorCreate & { id?: string; badge?: Badge | null | undefined }; onCancel: () => void; onSaved: () => void; onHistoryDeleted?: () => void; } const initial: MonitorCreate = { url: '', regionIds: ['us-east'], intervalSeconds: 300, timeoutMs: 10_000, enabled: true, dnsDiagnosticsEnabled: false, isPublic: false, publicSlug: null, notificationServiceIds: [], outageThreshold: 3, recoveryThreshold: 2, repeatNotificationMinutes: null, badgeId: null, uptimeThresholds: defaultUptimeThresholds, }; const frequencyMarks = [ { index: 0, label: '1m' }, { index: 14, label: '15m' }, { index: 17, label: '30m' }, { index: 20, label: '45m' }, { index: 23, label: '60m' }, ] as const; export function MonitorForm({ monitor, onCancel, onSaved, onHistoryDeleted }: Props) { const [value, setValue] = useState(() => ({ ...initial, ...monitor, notificationServiceIds: monitor?.notificationServiceIds ?? [], outageThreshold: monitor?.outageThreshold ?? 3, recoveryThreshold: monitor?.recoveryThreshold ?? 2, repeatNotificationMinutes: monitor?.repeatNotificationMinutes ?? null, badgeId: monitor?.badgeId ?? monitor?.badge?.id ?? null, uptimeThresholds: monitor?.uptimeThresholds ?? defaultUptimeThresholds, })); const [badges, setBadges] = useState([]); const [badgeName, setBadgeName] = useState(monitor?.badge?.name ?? ''); const [creatingBadge, setCreatingBadge] = useState(false); const [availableRegions, setAvailableRegions] = useState(regions); const [notificationServices, setNotificationServices] = useState( null, ); const [notificationServicesError, setNotificationServicesError] = useState(''); const [touched, setTouched] = useState>({}); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [urlError, setUrlError] = useState(''); const [historyDeleteOpen, setHistoryDeleteOpen] = useState(false); const [historyDeleted, setHistoryDeleted] = useState(false); useEffect(() => { let alive = true; api .badges() .then(({ badges: choices }) => { if (alive) setBadges(Array.isArray(choices) ? choices : []); }) .catch(() => undefined); return () => { alive = false; }; }, []); useEffect(() => { let alive = true; api .regions() .then(({ regions: enabledRegions }) => { if (!alive) return; setAvailableRegions(enabledRegions); const enabledIds = new Set(enabledRegions.map((region) => region.id)); setValue((current) => { const regionIds = current.regionIds.filter((regionId) => enabledIds.has(regionId)); return { ...current, regionIds: regionIds.length > 0 ? regionIds : enabledRegions[0] ? [enabledRegions[0].id] : [], }; }); }) .catch(() => undefined); return () => { alive = false; }; }, []); useEffect(() => { let alive = true; api .notificationServices() .then(({ services }) => { if (!alive) return; if (!Array.isArray(services)) throw new Error('Notification services response is invalid.'); setNotificationServices(services); setNotificationServicesError(''); }) .catch((reason) => { if (!alive) return; setNotificationServicesError( reason instanceof Error ? reason.message : 'Notification services could not be loaded.', ); }); return () => { alive = false; }; }, []); const slugError = value.publicSlug ? (publicMonitorSlugSchema.safeParse(value.publicSlug).error?.issues[0]?.message ?? '') : ''; const timeoutError = value.timeoutMs < timeoutConstraints.minimumMs || value.timeoutMs > timeoutConstraints.maximumMs ? 'Timeout must be between 1 and 30 seconds.' : value.timeoutMs >= value.intervalSeconds * 1000 ? 'Timeout must be shorter than the check interval.' : ''; const notificationRulesInvalid = !Number.isInteger(value.outageThreshold) || (value.outageThreshold ?? 0) < 1 || (value.outageThreshold ?? 0) > 100 || !Number.isInteger(value.recoveryThreshold) || (value.recoveryThreshold ?? 0) < 1 || (value.recoveryThreshold ?? 0) > 100 || (value.repeatNotificationMinutes !== null && (!Number.isInteger(value.repeatNotificationMinutes) || (value.repeatNotificationMinutes ?? 0) < 1 || (value.repeatNotificationMinutes ?? 0) > 10_080)); const uptimeThresholds = value.uptimeThresholds ?? defaultUptimeThresholds; const uptimeThresholdsInvalid = uptimeThresholds.orange < 0 || uptimeThresholds.green > 100 || uptimeThresholds.orange > uptimeThresholds.lightGreen || uptimeThresholds.lightGreen > uptimeThresholds.green; const matchingBadge = badges.find( (badge) => badge.name.toLocaleLowerCase() === badgeName.trim().toLocaleLowerCase(), ); const unresolvedBadge = Boolean(badgeName.trim() && !matchingBadge); const checksPerDay = expectedChecksPerDay(value.regionIds, value.intervalSeconds); const displayedChecksPerDay = Math.round(checksPerDay); const displayedChecksPerRegion = Math.round(86_400 / value.intervalSeconds); const frequencyMinutes = value.intervalSeconds / 60; const frequencySliderIndex = checkFrequencySliderIndex(value.intervalSeconds); const dnsSnapshotsPerDay = expectedDnsSnapshotsPerDay( value.regionIds, value.dnsDiagnosticsEnabled, ); function set(key: K, next: MonitorCreate[K]) { setValue((current) => ({ ...current, [key]: next })); } function validateUrl(raw = value.url) { let message = ''; if (!raw) message = 'A monitoring URL is required. Enter an absolute HTTP or HTTPS URL.'; else { try { const url = new URL(normalizeMonitorUrl(raw)); message = ['http:', 'https:'].includes(url.protocol) ? '' : 'URL must use HTTP or HTTPS.'; } catch { message = 'Enter an absolute HTTP or HTTPS URL.'; } } setUrlError(message); return message; } function toggle(region: RegionId) { set( 'regionIds', value.regionIds.includes(region) ? value.regionIds.filter((id) => id !== region) : [...value.regionIds, region], ); } function toggleNotificationService(serviceId: string) { const selected = value.notificationServiceIds ?? []; set( 'notificationServiceIds', selected.includes(serviceId) ? selected.filter((id) => id !== serviceId) : [...selected, serviceId], ); } async function createBadge() { const name = badgeName.trim(); if (!name || name.length > 40) return; setCreatingBadge(true); setError(''); try { const badge = await api.createBadge({ name }); setBadges((current) => current.some((item) => item.id === badge.id) ? current : [...current, badge], ); setBadgeName(badge.name); set('badgeId', badge.id); } catch (reason) { setError(reason instanceof Error ? reason.message : 'The badge could not be created.'); } finally { setCreatingBadge(false); } } async function submit(event: FormEvent) { event.preventDefault(); setTouched({ url: true, timeoutMs: true, regionIds: true, publicSlug: true }); const normalizedUrl = normalizeMonitorUrl(value.url); const nextUrlError = validateUrl(normalizedUrl); if ( nextUrlError || timeoutError || slugError || notificationRulesInvalid || uptimeThresholdsInvalid || unresolvedBadge || !value.url || value.regionIds.length === 0 ) return; const input = { ...value, url: normalizedUrl }; setValue(input); setBusy(true); setError(''); try { if (monitor?.id) await api.updateMonitor(monitor.id, input); else await api.createMonitor(input); onSaved(); } catch (reason) { setError( reason instanceof RequestError ? reason.message : 'The monitor could not be saved. Try again.', ); } finally { setBusy(false); } } return (

{monitor ? 'Edit monitor' : 'Add monitor'}

Every selected region performs one bounded GET for each scheduled interval.

Regions
{continentIds.map((continentId) => { const continentRegions = availableRegions.filter( (region) => region.continentId === continentId, ); return continentRegions.length > 0 ? (

{continentLabels[continentId]}

{continentRegions.map((region) => ( ))}
) : null; })}
{touched.regionIds && value.regionIds.length === 0 && (

Select at least one region before saving.

)}
Uptime color thresholds

Set the percentage bands used in uptime history. Values above green and light green use those colors; orange includes its boundary; lower values are red.

{( [ ['green', 'Green above'], ['lightGreen', 'Light green above'], ['orange', 'Orange from'], ] as const ).map(([key, label]) => ( ))}
{uptimeThresholdsInvalid && (

Thresholds must descend from green to orange.

)}
Network diagnostics {value.dnsDiagnosticsEnabled && ( )}
Outage notifications

A check counts as failed when any region returns a failure. Recovery requires every configured region to return success. A round with missing results and no reported failure breaks either streak.

{notificationServicesError && (

Notification services could not be loaded: {notificationServicesError} Existing selections are preserved.

)} {notificationServices === null && !notificationServicesError ? (

Loading notification services…

) : (
{(notificationServices ?? []).map((service) => ( ))} {(value.notificationServiceIds ?? []) .filter((id) => !(notificationServices ?? []).some((service) => service.id === id)) .map((id) => ( ))} {notificationServices?.length === 0 && (value.notificationServiceIds ?? []).length === 0 && (

No services configured. Add one from the Notifications section.

)}
)}
{value.repeatNotificationMinutes !== null && ( )} {notificationRulesInvalid && (

Outage and recovery thresholds must be 1–100. Reminder intervals must be 1–10,080 minutes.

)}
Public sharing {monitor?.id && ( )}
{monitor?.id && (

Monitor history

Permanently remove stored uptime, latency, request, DNS diagnostic, and notification delivery history without deleting this monitor.

{historyDeleted && (

History deleted. A fresh check has been scheduled.

)}
)} {error && (

{error}

)}
{historyDeleteOpen && monitor?.id && ( setHistoryDeleteOpen(false)} onConfirm={async () => { await api.deleteMonitorHistory(monitor.id!); setHistoryDeleteOpen(false); setHistoryDeleted(true); onHistoryDeleted?.(); }} /> )}
); } export function DeleteHistoryDialog({ onCancel, onConfirm, }: { onCancel: () => void; onConfirm: () => Promise; }) { const [typed, setTyped] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const dialog = useRef(null); useEffect(() => { dialog.current?.showModal(); return () => dialog.current?.close(); }, []); return ( { event.preventDefault(); if (!busy) onCancel(); }} aria-labelledby="delete-history-title" >
{ event.preventDefault(); if (typed !== 'DELETE' || busy) return; setBusy(true); setError(''); void onConfirm() .catch((reason) => { setError(reason instanceof Error ? reason.message : 'History could not be deleted.'); }) .finally(() => setBusy(false)); }} >

Delete all monitor history?

This cannot be undone. The monitor and its settings will remain, and a fresh check will be scheduled. Type DELETE to continue.

{error && ( )}
); }