diff --git a/app/api/services/route.ts b/app/api/services/route.ts index e8ec359..296890e 100644 --- a/app/api/services/route.ts +++ b/app/api/services/route.ts @@ -30,9 +30,35 @@ export async function POST(request: Request) { } const db = await getDb(); - await db.run('INSERT INTO monitored_services (name, url) VALUES (?, ?)', name.trim(), url.trim()); + const trimmedName = name.trim(); + const trimmedUrl = url.trim(); + await db.run('INSERT INTO monitored_services (name, url) VALUES (?, ?)', trimmedName, trimmedUrl); - return NextResponse.json({ success: true }); + // Immediate ping so the service has data right away + let initialStatus = 'down'; + let initialLatency = 0; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const start = performance.now(); + const res = await fetch(trimmedUrl, { + method: 'HEAD', + signal: controller.signal, + }); + clearTimeout(timeout); + initialLatency = Math.round(performance.now() - start); + initialStatus = res.status < 500 ? 'up' : 'down'; + } catch { + initialStatus = 'down'; + initialLatency = 0; + } + + await db.run( + 'INSERT INTO uptime_logs (service_name, url, status, latency, timestamp) VALUES (?, ?, ?, ?, ?)', + trimmedName, trimmedUrl, initialStatus, initialLatency, new Date().toISOString() + ); + + return NextResponse.json({ success: true, initialStatus, initialLatency }); } catch (error: unknown) { const msg = error instanceof Error ? error.message : 'Unknown error'; if (msg.includes('UNIQUE constraint')) { diff --git a/app/api/status/route.ts b/app/api/status/route.ts index 05c48d8..e4a4fd1 100644 --- a/app/api/status/route.ts +++ b/app/api/status/route.ts @@ -3,9 +3,21 @@ import { getDb } from '@/lib/db'; export const dynamic = 'force-dynamic'; -export async function GET() { +type TimeRange = '24h' | '7d' | '30d' | '365d'; + +const RANGE_CONFIG: Record = { + '24h': { sqlOffset: '-24 hours', strftime: '%Y-%m-%d %H:00', bars: 24 }, + '7d': { sqlOffset: '-7 days', strftime: '%Y-%m-%d', bars: 7 }, + '30d': { sqlOffset: '-30 days', strftime: '%Y-%m-%d', bars: 30 }, + '365d': { sqlOffset: '-365 days', strftime: '%Y-%m', bars: 12 }, +}; + +export async function GET(request: Request) { try { const db = await getDb(); + const { searchParams } = new URL(request.url); + const range = (searchParams.get('range') || '24h') as TimeRange; + const config = RANGE_CONFIG[range] || RANGE_CONFIG['24h']; // Get live status (most recent log per service) const live = await db.all(` @@ -54,25 +66,26 @@ export async function GET() { GROUP BY service_name `); - // Get hourly history for last 24h (24 buckets per service) + // Get bucketed history for the requested range const history = await db.all(` SELECT service_name, - strftime('%Y-%m-%d %H:00', timestamp) as hour, + strftime('${config.strftime}', timestamp) as bucket, count(*) as total, sum(case when status = 'up' then 1 else 0 end) as up_count FROM uptime_logs - WHERE timestamp > datetime('now', '-24 hours') - GROUP BY service_name, hour - ORDER BY hour ASC + WHERE timestamp > datetime('now', '${config.sqlOffset}') + GROUP BY service_name, bucket + ORDER BY bucket ASC `); - // Group history by service - const historyMap: Record> = {}; + // Group history by service — return up_count and total per bucket + const historyMap: Record> = {}; for (const row of history) { if (!historyMap[row.service_name]) historyMap[row.service_name] = []; historyMap[row.service_name].push({ - hour: row.hour, - up: row.up_count === row.total, + bucket: row.bucket, + up_count: row.up_count, + total: row.total, }); } diff --git a/components/widgets/UptimeCard.tsx b/components/widgets/UptimeCard.tsx index b07cbdc..d7eb0d5 100644 --- a/components/widgets/UptimeCard.tsx +++ b/components/widgets/UptimeCard.tsx @@ -2,6 +2,18 @@ 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'; + +// --- 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; @@ -12,56 +24,191 @@ interface ServiceStatus { uptime7d?: number; uptimeLifetime?: number; avgLatency24h?: number; - history?: Array<{ hour: string; up: boolean }>; + history?: HistoryBucket[]; } -function build24Bars(history: Array<{ hour: string; up: boolean }> | undefined) { - const bars: Array<{ hour: string; status: 'up' | 'down' | 'unknown' }> = []; - const now = new Date(); +interface BarData { + label: string; + status: BarStatus; + upCount: number; + total: number; +} - const hourMap = new Map(); +// --- Constants --- + +const BAR_STYLES: Record = { + up: { base: 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.4)]', compact: 'bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.25)]' }, + degraded: { base: 'bg-amber-500 shadow-[0_0_6px_rgba(245,158,11,0.4)]', compact: 'bg-amber-500 shadow-[0_0_4px_rgba(245,158,11,0.25)]' }, + down: { base: 'bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.4)]', compact: 'bg-red-500 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 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) { - hourMap.set(h.hour, h.up); + bucketMap.set(h.bucket, h); } } - for (let i = 23; i >= 0; i--) { - const d = new Date(now); - d.setMinutes(0, 0, 0); - d.setHours(d.getHours() - i); - const key = d.getFullYear() + '-' + - String(d.getMonth() + 1).padStart(2, '0') + '-' + - String(d.getDate()).padStart(2, '0') + ' ' + - String(d.getHours()).padStart(2, '0') + ':00'; - - const found = hourMap.get(key); - bars.push({ - hour: key, - status: found === undefined ? 'unknown' : found ? 'up' : 'down', - }); + if (range === '24h') { + for (let i = config.bars - 1; i >= 0; i--) { + const d = subHours(now, i); + const key = format(d, 'yyyy-MM-dd HH') + ':00'; + 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 = format(d, 'yyyy-MM-dd'); + 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 = format(d, 'yyyy-MM'); + 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 HistoryBars({ history, large }: { history?: Array<{ hour: string; up: boolean }>; large?: boolean }) { - const bars = build24Bars(history); - const h = large ? 'h-4' : 'h-1.5'; +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) => (
+ className="flex-1 relative" + onMouseEnter={() => setHoveredIndex(i)} + onMouseLeave={() => setHoveredIndex(null)} + > +
+ +
+ ))} +
+ ); +} + +function TimeRangeSelector({ + value, + onChange, +}: { + value: TimeRange; + onChange: (range: TimeRange) => void; +}) { + return ( +
+ {RANGE_OPTIONS.map((opt) => ( + ))}
); @@ -155,11 +302,15 @@ function DetailModal({ 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); @@ -171,6 +322,8 @@ function DetailModal({ return () => document.removeEventListener('keydown', handleKey); }, [onClose]); + const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange); + return (
- {/* 24h History Bar (large) */} + {/* History Bar (large) */}
-
24-Hour History
- -
- -24h - now +
+
History
+
+ +
+ {leftLabel} + {rightLabel} +
+
+ + {/* Legend */} +
+ Up + Degraded + Down + No data
{/* Delete Button */} @@ -371,10 +535,12 @@ export function UptimeCard() { const [error, setError] = useState(false); const [selectedService, setSelectedService] = useState(null); const [expanded, setExpanded] = useState(false); + const [timeRange, setTimeRange] = useState('24h'); - const fetchStatus = useCallback(async () => { + const fetchStatus = useCallback(async (range?: TimeRange) => { try { - const res = await fetch('/api/status'); + 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); @@ -385,14 +551,19 @@ export function UptimeCard() { } finally { setLoading(false); } - }, []); + }, [timeRange]); useEffect(() => { fetchStatus(); - const interval = setInterval(fetchStatus, 30000); + 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'); @@ -412,24 +583,28 @@ export function UptimeCard() { }; const rowSpan = expanded ? 'row-span-2' : 'row-span-1'; + const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange); return ( <>
- {/* Header — always visible, never scrolls */} + {/* Header */}
Uptime Monitor {loading && }
- +
+ + +
{error ? ( @@ -438,7 +613,6 @@ export function UptimeCard() { Failed to load status
) : ( - /* Scrollable content area — fills remaining space */
{services.map((service) => (
{service.latency}ms
- +
- 24h: {service.uptime24h ?? 100}% - lifetime: {service.uptimeLifetime ?? 100}% - 7d: {service.uptime7d ?? 100}% + {leftLabel} + {service.uptime24h ?? 100}% uptime (24h) + {rightLabel}
))} @@ -475,6 +649,8 @@ export function UptimeCard() { onClose={() => setSelectedService(null)} onDelete={handleDelete} onRenamed={fetchStatus} + timeRange={timeRange} + onTimeRangeChange={handleTimeRangeChange} /> )} diff --git a/lib/db.ts b/lib/db.ts index 20667a4..54ddc56 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -42,6 +42,9 @@ export async function getDb() { url TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); + + CREATE INDEX IF NOT EXISTS idx_uptime_logs_service_timestamp + ON uptime_logs(service_name, timestamp); `); return db; diff --git a/monitor.js b/monitor.js index f4486f2..0d41f93 100644 --- a/monitor.js +++ b/monitor.js @@ -108,9 +108,9 @@ async function monitor() { } } - // Prune old logs (keep 90 days for lifetime stats) + // Prune old logs (keep 400 days for yearly view) try { - await db.run(`DELETE FROM uptime_logs WHERE timestamp < datetime('now', '-90 days')`); + await db.run(`DELETE FROM uptime_logs WHERE timestamp < datetime('now', '-400 days')`); } catch (e) { } }, 60000); // Run every minute