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 <noreply@anthropic.com>
This commit is contained in:
Shivam Patel
2026-02-09 02:05:22 -05:00
parent 032fdc1b12
commit 372ff8cd22
5 changed files with 465 additions and 56 deletions

View File

@@ -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<string, boolean>();
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 (
<div className={`flex gap-[2px] ${h} ${large ? '' : 'opacity-50'}`}>
{bars.map((b, i) => (
<div
key={i}
className={`flex-1 rounded-full ${b.status === 'up'
? 'bg-emerald-500'
: b.status === 'down'
? 'bg-red-500'
: 'bg-neutral-700'
}`}
title={`${b.hour}${b.status}`}
/>
))}
</div>
);
}
function DetailModal({
service,
onClose,
onDelete,
}: {
service: ServiceStatus;
onClose: () => void;
onDelete: (name: string) => void;
}) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
>
<div className="bg-neutral-900 border border-neutral-700 rounded-2xl p-6 w-full max-w-lg mx-4 shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className={`w-2.5 h-2.5 rounded-full ${service.status === 'up' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]'}`} />
<h2 className="text-lg font-semibold text-white">{service.name}</h2>
</div>
<button onClick={onClose} className="text-neutral-500 hover:text-white transition-colors p-1 rounded-lg hover:bg-neutral-800">
<X size={18} />
</button>
</div>
{/* URL */}
<div className="flex items-center gap-2 mb-5 text-xs text-neutral-500 font-mono bg-neutral-800/50 rounded-lg px-3 py-2">
<ExternalLink size={12} />
<span className="truncate">{service.url}</span>
</div>
{/* Status + Latency Row */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="bg-neutral-800/50 rounded-xl p-3">
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">Status</div>
<span className={`text-sm font-semibold ${service.status === 'up' ? 'text-emerald-400' : 'text-red-400'}`}>
{service.status === 'up' ? 'Operational' : 'Down'}
</span>
</div>
<div className="bg-neutral-800/50 rounded-xl p-3">
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">Current Latency</div>
<span className="text-sm font-semibold text-white font-mono">{service.latency}ms</span>
</div>
</div>
{/* Uptime Stats */}
<div className="grid grid-cols-4 gap-3 mb-5">
<div className="bg-neutral-800/50 rounded-xl p-3 text-center">
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">24h</div>
<span className="text-base font-bold text-white">{service.uptime24h ?? 100}%</span>
</div>
<div className="bg-neutral-800/50 rounded-xl p-3 text-center">
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">7d</div>
<span className="text-base font-bold text-white">{service.uptime7d ?? 100}%</span>
</div>
<div className="bg-neutral-800/50 rounded-xl p-3 text-center">
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">Lifetime</div>
<span className="text-base font-bold text-white">{service.uptimeLifetime ?? 100}%</span>
</div>
<div className="bg-neutral-800/50 rounded-xl p-3 text-center">
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">Avg Lat</div>
<span className="text-base font-bold text-white font-mono">{service.avgLatency24h ?? 0}ms</span>
</div>
</div>
{/* 24h History Bar (large) */}
<div className="mb-2">
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-2">24-Hour History</div>
<HistoryBars history={service.history} large />
<div className="flex justify-between text-[9px] text-neutral-600 font-mono mt-1">
<span>-24h</span>
<span>now</span>
</div>
</div>
{/* Delete Button */}
<div className="mt-5 pt-4 border-t border-neutral-800">
<button
onClick={() => { onDelete(service.name); onClose(); }}
className="flex items-center gap-2 text-xs text-red-400/70 hover:text-red-400 transition-colors"
>
<Trash2 size={13} />
Remove service
</button>
</div>
</div>
</div>
);
}
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 (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-300 transition-colors mt-3"
>
<Plus size={14} />
Add service
</button>
);
}
return (
<form onSubmit={handleSubmit} className="mt-3 space-y-2">
<div className="flex gap-2">
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => 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"
/>
<input
type="url"
placeholder="https://example.com"
value={url}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-[10px] text-red-400">{error}</p>}
<div className="flex gap-2">
<button
type="submit"
disabled={submitting}
className="text-xs bg-neutral-700 hover:bg-neutral-600 text-white px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
>
{submitting ? 'Adding...' : 'Add'}
</button>
<button
type="button"
onClick={() => { setOpen(false); setError(''); }}
className="text-xs text-neutral-500 hover:text-neutral-300 px-2 py-1.5 transition-colors"
>
Cancel
</button>
</div>
</form>
);
}
export function UptimeCard() {
const [services, setServices] = useState<ServiceStatus[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [selectedService, setSelectedService] = useState<ServiceStatus | null>(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 (
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex flex-col justify-between hover:border-neutral-700 transition-colors">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2 text-neutral-400">
<Activity size={18} />
<span className="text-sm font-medium">Uptime Monitor</span>
{loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />}
<>
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex flex-col justify-between hover:border-neutral-700 transition-colors">
<div className="flex justify-between items-start">
<div className="flex items-center gap-2 text-neutral-400">
<Activity size={18} />
<span className="text-sm font-medium">Uptime Monitor</span>
{loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />}
</div>
</div>
</div>
{error ? (
<div className="flex flex-col items-center justify-center h-full text-red-400 gap-2">
<AlertCircle size={24} />
<span className="text-xs">Failed to load status</span>
</div>
) : (
<div className="space-y-4 mt-4">
{services.map((service) => (
<div key={service.name} className="flex flex-col gap-1">
<div className="flex items-center justify-between group">
<span className="text-sm text-neutral-300 font-medium group-hover:text-white transition-colors">{service.name}</span>
<div className="flex items-center gap-2">
<span className={`text-xs px-1.5 py-0.5 rounded ${service.status === 'up' ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
{service.status === 'up' ? 'UP' : 'DOWN'}
</span>
<span className="text-xs text-neutral-500 font-mono w-10 text-right">{service.latency}ms</span>
{error ? (
<div className="flex flex-col items-center justify-center h-full text-red-400 gap-2">
<AlertCircle size={24} />
<span className="text-xs">Failed to load status</span>
</div>
) : (
<div className="space-y-4 mt-4">
{services.map((service) => (
<div
key={service.name}
className="flex flex-col gap-1 cursor-pointer group"
onClick={() => setSelectedService(service)}
>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-300 font-medium group-hover:text-white transition-colors">{service.name}</span>
<div className="flex items-center gap-2">
<span className={`text-xs px-1.5 py-0.5 rounded ${service.status === 'up' ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
{service.status === 'up' ? 'UP' : 'DOWN'}
</span>
<span className="text-xs text-neutral-500 font-mono w-10 text-right">{service.latency}ms</span>
</div>
</div>
<HistoryBars history={service.history} />
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
<span>24h: {service.uptime24h ?? 100}%</span>
<span>lifetime: {service.uptimeLifetime ?? 100}%</span>
<span>7d: {service.uptime7d ?? 100}%</span>
</div>
</div>
<div className="flex gap-[2px] h-1.5 opacity-50">
{service.history && service.history.length > 0 ? (
service.history.slice(-24).map((h, i) => (
<div
key={i}
className={`flex-1 rounded-full ${h.up ? 'bg-emerald-500' : 'bg-red-500'}`}
title={h.hour}
/>
))
) : (
[...Array(24)].map((_, i) => (
<div key={i} className="flex-1 rounded-full bg-neutral-700" />
))
)}
</div>
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
<span>24h: {service.uptime24h ?? 100}%</span>
<span>7d: {service.uptime7d ?? 100}%</span>
</div>
</div>
))}
</div>
))}
<AddServiceForm onAdded={fetchStatus} />
</div>
)}
</div>
{selectedService && (
<DetailModal
service={selectedService}
onClose={() => setSelectedService(null)}
onDelete={handleDelete}
/>
)}
</div>
</>
);
}