'use client'; import { Activity, RefreshCcw, AlertCircle, X, Plus, Trash2, ExternalLink, ChevronDown, ChevronUp, Pencil, Check } from 'lucide-react'; import { useState, useEffect, useCallback, useRef } from 'react'; import { format, subHours, subDays, subMonths } from 'date-fns'; import { useLayoutContext } from '@/components/dashboard/GridShell'; // --- Types --- type BarStatus = 'up' | 'degraded' | 'down' | 'unknown'; type TimeRange = '24h' | '7d' | '30d' | '365d'; interface HistoryBucket { bucket: string; up_count: number; total: number; } interface ServiceStatus { name: string; url: string; status: 'up' | 'down'; latency: number; uptime24h?: number; uptime7d?: number; uptimeLifetime?: number; avgLatency24h?: number; history?: HistoryBucket[]; } interface BarData { label: string; status: BarStatus; upCount: number; total: number; } // --- Constants --- const BAR_STYLES: Record = { up: { base: 'bg-emerald-500 [box-shadow:0_0_6px_rgba(16,185,129,0.4)]', compact: 'bg-emerald-500 [box-shadow:0_0_4px_rgba(16,185,129,0.25)]' }, degraded: { base: 'bg-amber-500 [box-shadow:0_0_6px_rgba(245,158,11,0.4)]', compact: 'bg-amber-500 [box-shadow:0_0_4px_rgba(245,158,11,0.25)]' }, down: { base: 'bg-red-500 [box-shadow:0_0_6px_rgba(239,68,68,0.4)]', compact: 'bg-red-500 [box-shadow:0_0_4px_rgba(239,68,68,0.25)]' }, unknown: { base: 'bg-neutral-700', compact: 'bg-neutral-700' }, }; const RANGE_OPTIONS: { value: TimeRange; label: string }[] = [ { value: '24h', label: '24H' }, { value: '7d', label: '7D' }, { value: '30d', label: '30D' }, { value: '365d', label: '1Y' }, ]; const RANGE_CONFIG: Record = { '24h': { bars: 24, bucketMs: 3600_000 }, '7d': { bars: 7, bucketMs: 86400_000 }, '30d': { bars: 30, bucketMs: 86400_000 }, '365d': { bars: 12, bucketMs: 0 }, // months handled specially }; // --- Helpers --- function toUTCKey(d: Date, mode: 'hour' | 'day' | 'month'): string { const Y = d.getUTCFullYear(); const M = String(d.getUTCMonth() + 1).padStart(2, '0'); if (mode === 'month') return `${Y}-${M}`; const D = String(d.getUTCDate()).padStart(2, '0'); if (mode === 'day') return `${Y}-${M}-${D}`; const H = String(d.getUTCHours()).padStart(2, '0'); return `${Y}-${M}-${D} ${H}:00`; } function bucketStatus(upCount: number, total: number): BarStatus { if (total === 0) return 'unknown'; if (upCount === total) return 'up'; if (upCount === 0) return 'down'; return 'degraded'; } function buildBars(range: TimeRange, history: HistoryBucket[] | undefined): BarData[] { const config = RANGE_CONFIG[range]; const now = new Date(); const bars: BarData[] = []; const bucketMap = new Map(); if (history) { for (const h of history) { bucketMap.set(h.bucket, h); } } if (range === '24h') { for (let i = config.bars - 1; i >= 0; i--) { const d = subHours(now, i); const key = toUTCKey(d, 'hour'); const h = bucketMap.get(key); const hourStart = new Date(d); hourStart.setMinutes(0, 0, 0); bars.push({ label: format(hourStart, 'MMM d, h a') + ' – ' + format(new Date(hourStart.getTime() + 3600_000), 'h a'), status: h ? bucketStatus(h.up_count, h.total) : 'unknown', upCount: h?.up_count ?? 0, total: h?.total ?? 0, }); } } else if (range === '7d' || range === '30d') { for (let i = config.bars - 1; i >= 0; i--) { const d = subDays(now, i); const key = toUTCKey(d, 'day'); const h = bucketMap.get(key); bars.push({ label: format(d, 'MMM d, yyyy'), status: h ? bucketStatus(h.up_count, h.total) : 'unknown', upCount: h?.up_count ?? 0, total: h?.total ?? 0, }); } } else { // 365d — monthly buckets for (let i = 11; i >= 0; i--) { const d = subMonths(now, i); const key = toUTCKey(d, 'month'); const h = bucketMap.get(key); bars.push({ label: format(d, 'MMM yyyy'), status: h ? bucketStatus(h.up_count, h.total) : 'unknown', upCount: h?.up_count ?? 0, total: h?.total ?? 0, }); } } return bars; } function rangeEdgeLabels(range: TimeRange): [string, string] { switch (range) { case '24h': return ['-24h', 'now']; case '7d': return ['-7d', 'today']; case '30d': return ['-30d', 'today']; case '365d': return ['-12mo', 'now']; } } // --- Components --- function BarTooltip({ bar, visible }: { bar: BarData; visible: boolean }) { if (!visible) return null; const pct = bar.total > 0 ? ((bar.upCount / bar.total) * 100).toFixed(1) : null; return (
{bar.label}
{bar.status === 'unknown' ? (
No data
) : ( <>
{pct}% uptime
{bar.upCount}/{bar.total} checks passed
)}
); } function HistoryBars({ history, large, range, }: { history?: HistoryBucket[]; large?: boolean; range: TimeRange; }) { const [hoveredIndex, setHoveredIndex] = useState(null); const bars = buildBars(range, history); const h = large ? 'h-6' : 'h-3'; const styleKey = large ? 'base' : 'compact'; return (
{bars.map((b, i) => (
setHoveredIndex(i)} onMouseLeave={() => setHoveredIndex(null)} >
))}
); } function TimeRangeSelector({ value, onChange, }: { value: TimeRange; onChange: (range: TimeRange) => void; }) { return (
{RANGE_OPTIONS.map((opt) => ( ))}
); } function InlineNameEditor({ service, onRenamed }: { service: ServiceStatus; onRenamed: () => void }) { const [editing, setEditing] = useState(false); const [name, setName] = useState(service.name); const [saving, setSaving] = useState(false); const inputRef = useRef(null); useEffect(() => { if (editing) inputRef.current?.focus(); }, [editing]); const save = async () => { const trimmed = name.trim(); if (!trimmed || trimmed === service.name) { setName(service.name); setEditing(false); return; } setSaving(true); try { const listRes = await fetch('/api/services'); const list = await listRes.json(); const svc = list.find((s: { name: string }) => s.name === service.name); if (!svc) return; const res = await fetch('/api/services', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: svc.id, name: trimmed }), }); if (res.ok) { setEditing(false); onRenamed(); } else { const data = await res.json(); alert(data.error || 'Rename failed'); setName(service.name); setEditing(false); } } catch { setName(service.name); setEditing(false); } finally { setSaving(false); } }; if (!editing) { return (
{service.name}
); } return (
e.stopPropagation()}> setName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setName(service.name); setEditing(false); } }} onBlur={save} disabled={saving} className="bg-neutral-800 border border-neutral-600 rounded px-1.5 py-0.5 text-sm text-white font-medium focus:outline-none focus:border-neutral-400 w-24 min-w-0" />
); } function DetailModal({ service, onClose, onDelete, onRenamed, timeRange, onTimeRangeChange, }: { service: ServiceStatus; onClose: () => void; onDelete: (name: string) => void; onRenamed: () => void; timeRange: TimeRange; onTimeRangeChange: (range: TimeRange) => void; }) { const overlayRef = useRef(null); useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [onClose]); const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange); return (
{ if (e.target === overlayRef.current) onClose(); }} >
{/* Header */}

{service.name}

{/* URL */}
{service.url}
{/* Status + Latency Row */}
Status
{service.status === 'up' ? 'Operational' : 'Down'}
Current Latency
{service.latency}ms
{/* Uptime Stats */}
24h
{service.uptime24h ?? 100}%
7d
{service.uptime7d ?? 100}%
Lifetime
{service.uptimeLifetime ?? 100}%
Avg Lat
{service.avgLatency24h ?? 0}ms
{/* History Bar (large) */}
History
{leftLabel} {rightLabel}
{/* Legend */}
Up Degraded Down No data
{/* Delete Button */}
); } function AddServiceForm({ onAdded }: { onAdded: () => void }) { const [open, setOpen] = useState(false); const [name, setName] = useState(''); const [url, setUrl] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setSubmitting(true); try { const res = await fetch('/api/services', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.trim(), url: url.trim() }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || 'Failed to add'); } setName(''); setUrl(''); setOpen(false); onAdded(); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Failed to add service'); } finally { setSubmitting(false); } }; if (!open) { return ( ); } return (
setName(e.target.value)} required autoFocus className="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500" /> setUrl(e.target.value)} required className="flex-[2] bg-neutral-800 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500" />
{error &&

{error}

}
); } export function UptimeCard() { const [services, setServices] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); const [selectedService, setSelectedService] = useState(null); const [expanded, setExpanded] = useState(false); const [timeRange, setTimeRange] = useState('24h'); const fetchStatus = useCallback(async (range?: TimeRange) => { try { const r = range ?? timeRange; const res = await fetch(`/api/status?range=${r}`); if (!res.ok) throw new Error('Failed to fetch'); const data = await res.json(); setServices(data); setError(false); } catch (e) { console.error(e); setError(true); } finally { setLoading(false); } }, [timeRange]); useEffect(() => { fetchStatus(); const interval = setInterval(() => fetchStatus(), 30000); return () => clearInterval(interval); }, [fetchStatus]); const handleTimeRangeChange = useCallback((range: TimeRange) => { setTimeRange(range); fetchStatus(range); }, [fetchStatus]); const handleDelete = async (serviceName: string) => { try { const listRes = await fetch('/api/services'); const list = await listRes.json(); const svc = list.find((s: { name: string }) => s.name === serviceName); if (!svc) return; await fetch('/api/services', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: svc.id }), }); fetchStatus(); } catch (e) { console.error('Delete failed:', e); } }; const { updateWidgetSize } = useLayoutContext(); const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange); const handleToggleExpand = useCallback(() => { const next = !expanded; setExpanded(next); updateWidgetSize('uptime', 2, next ? 2 : 1); }, [expanded, updateWidgetSize]); return ( <>
{/* Header */}
Uptime Monitor {loading && }
{error ? (
Failed to load status
) : (
{services.map((service) => (
setSelectedService(service)} >
{service.status === 'up' ? 'UP' : 'DOWN'} {service.latency}ms
{leftLabel} {service.uptime24h ?? 100}% uptime (24h) {rightLabel}
))}
)}
{selectedService && ( setSelectedService(null)} onDelete={handleDelete} onRenamed={fetchStatus} timeRange={timeRange} onTimeRangeChange={handleTimeRangeChange} /> )} ); }