From 372ff8cd2232758658221b972f95a5b94166d4e0 Mon Sep 17 00:00:00 2001 From: Shivam Patel Date: Mon, 9 Feb 2026 02:05:22 -0500 Subject: [PATCH] Add uptime monitor features: fix history bars, lifetime stats, detail modal, custom services - Fix 24-bar history rendering to always show 24 uniform segments with gray fill for missing hours - Add lifetime uptime % and avg latency to status API - Add clickable detail modal with expanded stats, large history bar, and service removal - Add monitored_services DB table with CRUD API (GET/POST/DELETE) - Monitor reads services from DB each interval, seeds defaults on first run - Add inline form to add custom services to track - Extend log retention from 7 days to 90 days for lifetime stats Co-Authored-By: Claude Opus 4.6 --- app/api/services/route.ts | 70 ++++++ app/api/status/route.ts | 24 ++ components/widgets/UptimeCard.tsx | 372 ++++++++++++++++++++++++++---- lib/db.ts | 7 + monitor.js | 48 +++- 5 files changed, 465 insertions(+), 56 deletions(-) create mode 100644 app/api/services/route.ts diff --git a/app/api/services/route.ts b/app/api/services/route.ts new file mode 100644 index 0000000..a40f427 --- /dev/null +++ b/app/api/services/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server'; +import { getDb } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const db = await getDb(); + const services = await db.all('SELECT id, name, url, created_at FROM monitored_services ORDER BY id ASC'); + return NextResponse.json(services); + } catch (error) { + console.error('Services list error:', error); + return NextResponse.json([], { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const { name, url } = await request.json(); + + if (!name || !url) { + return NextResponse.json({ error: 'Name and URL are required' }, { status: 400 }); + } + + // Basic URL validation + try { + new URL(url); + } catch { + return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 }); + } + + const db = await getDb(); + await db.run('INSERT INTO monitored_services (name, url) VALUES (?, ?)', name.trim(), url.trim()); + + return NextResponse.json({ success: true }); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + if (msg.includes('UNIQUE constraint')) { + return NextResponse.json({ error: 'A service with that name already exists' }, { status: 409 }); + } + console.error('Service add error:', error); + return NextResponse.json({ error: 'Failed to add service' }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + try { + const { id } = await request.json(); + + if (!id) { + return NextResponse.json({ error: 'Service ID is required' }, { status: 400 }); + } + + const db = await getDb(); + + // Get service name before deleting so we can clean up logs + const service = await db.get('SELECT name FROM monitored_services WHERE id = ?', id); + if (!service) { + return NextResponse.json({ error: 'Service not found' }, { status: 404 }); + } + + await db.run('DELETE FROM monitored_services WHERE id = ?', id); + await db.run('DELETE FROM uptime_logs WHERE service_name = ?', service.name); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Service delete error:', error); + return NextResponse.json({ error: 'Failed to delete service' }, { status: 500 }); + } +} diff --git a/app/api/status/route.ts b/app/api/status/route.ts index 622b762..05c48d8 100644 --- a/app/api/status/route.ts +++ b/app/api/status/route.ts @@ -34,6 +34,26 @@ export async function GET() { GROUP BY service_name `); + // Get lifetime stats (all available data) + const statsLifetime = await db.all(` + SELECT service_name, + count(*) as total, + sum(case when status = 'up' then 1 else 0 end) as up_count + FROM uptime_logs + GROUP BY service_name + `); + + // Get average latency over last 24h (only for 'up' checks) + const avgLatencyRows = await db.all(` + SELECT service_name, + ROUND(AVG(latency)) as avg_latency + FROM uptime_logs + WHERE timestamp > datetime('now', '-24 hours') + AND status = 'up' + AND latency > 0 + GROUP BY service_name + `); + // Get hourly history for last 24h (24 buckets per service) const history = await db.all(` SELECT service_name, @@ -60,11 +80,15 @@ export async function GET() { const results = live.map(l => { const s24 = stats24h.find(s => s.service_name === l.name); const s7d = stats7d.find(s => s.service_name === l.name); + const sLife = statsLifetime.find(s => s.service_name === l.name); + const avgLat = avgLatencyRows.find(s => s.service_name === l.name); return { ...l, uptime24h: s24 ? Math.round((s24.up_count / s24.total) * 100) : 100, uptime7d: s7d ? Math.round((s7d.up_count / s7d.total) * 100) : 100, + uptimeLifetime: sLife ? Math.round((sLife.up_count / sLife.total) * 100) : 100, + avgLatency24h: avgLat ? avgLat.avg_latency : 0, history: historyMap[l.name] || [], }; }); diff --git a/components/widgets/UptimeCard.tsx b/components/widgets/UptimeCard.tsx index 5814687..c9ddb5a 100644 --- a/components/widgets/UptimeCard.tsx +++ b/components/widgets/UptimeCard.tsx @@ -1,7 +1,7 @@ 'use client'; -import { Activity, RefreshCcw, AlertCircle } from 'lucide-react'; -import { useState, useEffect } from 'react'; +import { Activity, RefreshCcw, AlertCircle, X, Plus, Trash2, ExternalLink } from 'lucide-react'; +import { useState, useEffect, useCallback, useRef } from 'react'; interface ServiceStatus { name: string; @@ -10,15 +10,263 @@ interface ServiceStatus { latency: number; uptime24h?: number; uptime7d?: number; + uptimeLifetime?: number; + avgLatency24h?: number; history?: Array<{ hour: string; up: boolean }>; } +// Build exactly 24 bars from whatever history data exists +function build24Bars(history: Array<{ hour: string; up: boolean }> | undefined) { + const bars: Array<{ hour: string; status: 'up' | 'down' | 'unknown' }> = []; + const now = new Date(); + + // Create a map of hour-string -> up/down from available data + const hourMap = new Map(); + if (history) { + for (const h of history) { + hourMap.set(h.hour, h.up); + } + } + + // Generate 24 hour slots ending at current hour + for (let i = 23; i >= 0; i--) { + const d = new Date(now); + d.setMinutes(0, 0, 0); + d.setHours(d.getHours() - i); + // Format to match the API: "YYYY-MM-DD HH:00" + 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', + }); + } + + 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'; + + return ( +
+ {bars.map((b, i) => ( +
+ ))} +
+ ); +} + +function DetailModal({ + service, + onClose, + onDelete, +}: { + service: ServiceStatus; + onClose: () => void; + onDelete: (name: string) => 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]); + + 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 +
+
+ + {/* 24h History Bar (large) */} +
+
24-Hour History
+ +
+ -24h + now +
+
+ + {/* 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 + 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 fetchStatus = async () => { + const fetchStatus = useCallback(async () => { try { const res = await fetch('/api/status'); if (!res.ok) throw new Error('Failed to fetch'); @@ -31,65 +279,87 @@ export function UptimeCard() { } finally { setLoading(false); } - }; + }, []); useEffect(() => { fetchStatus(); const interval = setInterval(fetchStatus, 30000); return () => clearInterval(interval); - }, []); + }, [fetchStatus]); + + const handleDelete = async (serviceName: string) => { + try { + // Look up ID from the services API + 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); + } + }; return ( -
-
-
- - Uptime Monitor - {loading && } + <> +
+
+
+ + Uptime Monitor + {loading && } +
-
- {error ? ( -
- - Failed to load status -
- ) : ( -
- {services.map((service) => ( -
-
- {service.name} -
- - {service.status === 'up' ? 'UP' : 'DOWN'} - - {service.latency}ms + {error ? ( +
+ + Failed to load status +
+ ) : ( +
+ {services.map((service) => ( +
setSelectedService(service)} + > +
+ {service.name} +
+ + {service.status === 'up' ? 'UP' : 'DOWN'} + + {service.latency}ms +
+
+ +
+ 24h: {service.uptime24h ?? 100}% + lifetime: {service.uptimeLifetime ?? 100}% + 7d: {service.uptime7d ?? 100}%
-
- {service.history && service.history.length > 0 ? ( - service.history.slice(-24).map((h, i) => ( -
- )) - ) : ( - [...Array(24)].map((_, i) => ( -
- )) - )} -
-
- 24h: {service.uptime24h ?? 100}% - 7d: {service.uptime7d ?? 100}% -
-
- ))} -
+ ))} + + +
+ )} +
+ + {selectedService && ( + setSelectedService(null)} + onDelete={handleDelete} + /> )} -
+ ); } diff --git a/lib/db.ts b/lib/db.ts index d240268..20667a4 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -35,6 +35,13 @@ export async function getDb() { lon REAL, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP ); + + CREATE TABLE IF NOT EXISTS monitored_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + url TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); `); return db; diff --git a/monitor.js b/monitor.js index a54a393..f4486f2 100644 --- a/monitor.js +++ b/monitor.js @@ -2,12 +2,46 @@ const sqlite3 = require('sqlite3'); const { open } = require('sqlite'); // Node 18+ has global fetch built-in -const SERVICES = [ +const DEFAULT_SERVICES = [ { name: 'Website', url: 'https://akkolli.net' }, { name: 'Gitea', url: 'https://code.akkolli.net' }, { name: 'Nextcloud', url: 'http://host.docker.internal:6060' }, ]; +async function getServices(db) { + try { + const rows = await db.all('SELECT name, url FROM monitored_services'); + if (rows && rows.length > 0) return rows; + } catch (e) { + // Table might not exist yet + } + return DEFAULT_SERVICES; +} + +async function seedDefaults(db) { + // Ensure monitored_services table exists + await db.exec(` + CREATE TABLE IF NOT EXISTS monitored_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + url TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `); + + // Seed defaults if table is empty + const count = await db.get('SELECT COUNT(*) as cnt FROM monitored_services'); + if (count.cnt === 0) { + for (const s of DEFAULT_SERVICES) { + await db.run( + 'INSERT OR IGNORE INTO monitored_services (name, url) VALUES (?, ?)', + s.name, s.url + ); + } + console.log('Seeded default services into monitored_services'); + } +} + async function monitor() { console.log('Starting monitoring loop...'); @@ -30,11 +64,16 @@ async function monitor() { ); `); + await seedDefaults(db); + setInterval(async () => { console.log('Running checks...'); const now = new Date().toISOString(); - for (const service of SERVICES) { + // Re-read services each interval so new additions are picked up + const services = await getServices(db); + + for (const service of services) { const start = performance.now(); let status = 'down'; let latency = 0; @@ -57,7 +96,6 @@ async function monitor() { } catch (err) { status = 'down'; latency = 0; - // console.error(`Failed to reach ${service.name}:`, err.message); } try { @@ -70,9 +108,9 @@ async function monitor() { } } - // Prune old logs (keep 7 days) + // Prune old logs (keep 90 days for lifetime stats) try { - await db.run(`DELETE FROM uptime_logs WHERE timestamp < datetime('now', '-7 days')`); + await db.run(`DELETE FROM uptime_logs WHERE timestamp < datetime('now', '-90 days')`); } catch (e) { } }, 60000); // Run every minute