Files
Admin_dash/components/widgets/UptimeCard.tsx
2026-02-09 17:56:49 -05:00

669 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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';
// --- 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<BarStatus, { base: string; compact: string }> = {
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<TimeRange, { bars: number; bucketMs: number }> = {
'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<string, HistoryBucket>();
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 (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 pointer-events-none">
<div className="bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-2 text-xs whitespace-nowrap shadow-xl">
<div className="text-neutral-200 font-medium mb-0.5">{bar.label}</div>
{bar.status === 'unknown' ? (
<div className="text-neutral-500">No data</div>
) : (
<>
<div className="text-neutral-400">{pct}% uptime</div>
<div className="text-neutral-500">{bar.upCount}/{bar.total} checks passed</div>
</>
)}
</div>
</div>
);
}
function HistoryBars({
history,
large,
range,
}: {
history?: HistoryBucket[];
large?: boolean;
range: TimeRange;
}) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const bars = buildBars(range, history);
const h = large ? 'h-6' : 'h-3';
const styleKey = large ? 'base' : 'compact';
return (
<div className={`flex gap-[2px] ${h} ${large ? '' : 'opacity-70'}`}>
{bars.map((b, i) => (
<div
key={i}
className="flex-1 relative"
onMouseEnter={() => setHoveredIndex(i)}
onMouseLeave={() => setHoveredIndex(null)}
>
<div
className={`w-full h-full rounded-sm transition-transform duration-150 hover:scale-y-[1.3] origin-bottom ${BAR_STYLES[b.status][styleKey]}`}
/>
<BarTooltip bar={b} visible={hoveredIndex === i} />
</div>
))}
</div>
);
}
function TimeRangeSelector({
value,
onChange,
}: {
value: TimeRange;
onChange: (range: TimeRange) => void;
}) {
return (
<div className="flex bg-neutral-800/60 rounded-lg p-0.5 gap-0.5">
{RANGE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={(e) => { e.stopPropagation(); onChange(opt.value); }}
className={`px-2 py-0.5 text-[10px] font-medium rounded-md transition-colors ${
value === opt.value
? 'bg-neutral-700 text-white'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
{opt.label}
</button>
))}
</div>
);
}
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<HTMLInputElement>(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 (
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-sm text-neutral-300 font-medium group-hover:text-white transition-colors truncate">{service.name}</span>
<button
onClick={(e) => { e.stopPropagation(); setEditing(true); }}
className="opacity-0 group-hover:opacity-60 hover:!opacity-100 transition-opacity text-neutral-400 hover:text-white shrink-0"
title="Rename"
>
<Pencil size={11} />
</button>
</div>
);
}
return (
<div className="flex items-center gap-1.5 min-w-0" onClick={(e) => e.stopPropagation()}>
<input
ref={inputRef}
type="text"
value={name}
onChange={(e) => 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"
/>
<button onClick={save} disabled={saving} className="text-emerald-500 hover:text-emerald-400 shrink-0">
<Check size={13} />
</button>
</div>
);
}
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<HTMLDivElement>(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 (
<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 shrink-0 ${service.status === 'up' ? 'bg-emerald-500 [box-shadow:0_0_8px_rgba(16,185,129,0.5)]' : 'bg-red-500 [box-shadow:0_0_8px_rgba(239,68,68,0.5)]'}`} />
<h2 className="text-lg font-semibold text-white">{service.name}</h2>
<button
onClick={() => {
const newName = prompt('Rename service:', service.name);
if (newName && newName.trim() && newName.trim() !== service.name) {
(async () => {
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: newName.trim() }),
});
if (res.ok) { onRenamed(); onClose(); }
})();
}
}}
className="text-neutral-500 hover:text-neutral-300 transition-colors"
title="Rename"
>
<Pencil size={14} />
</button>
</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} className="shrink-0" />
<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>
{/* History Bar (large) */}
<div className="mb-2">
<div className="flex items-center justify-between mb-2">
<div className="text-[10px] uppercase tracking-wider text-neutral-500">History</div>
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
</div>
<HistoryBars history={service.history} large range={timeRange} />
<div className="flex justify-between text-[9px] text-neutral-600 font-mono mt-1">
<span>{leftLabel}</span>
<span>{rightLabel}</span>
</div>
</div>
{/* Legend */}
<div className="flex gap-4 text-[9px] text-neutral-500 mb-2">
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-emerald-500 inline-block" /> Up</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-amber-500 inline-block" /> Degraded</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-red-500 inline-block" /> Down</span>
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-neutral-700 inline-block" /> No data</span>
</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 pt-2"
>
<Plus size={14} />
Add service
</button>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-2 pt-2">
<div className="flex gap-2">
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => 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"
/>
<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 [expanded, setExpanded] = useState(false);
const [timeRange, setTimeRange] = useState<TimeRange>('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 rowSpan = expanded ? 'row-span-2' : 'row-span-1';
const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange);
return (
<>
<div className={`col-span-1 md:col-span-2 lg:col-span-2 ${rowSpan} bg-neutral-900 border border-neutral-800 rounded-xl p-5 flex flex-col hover:border-neutral-700 transition-all duration-300 overflow-hidden`}>
{/* Header */}
<div className="flex justify-between items-center shrink-0 mb-3">
<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 className="flex items-center gap-2">
<TimeRangeSelector value={timeRange} onChange={handleTimeRangeChange} />
<button
onClick={() => setExpanded(!expanded)}
className="text-neutral-500 hover:text-neutral-300 transition-colors p-1 rounded-lg hover:bg-neutral-800"
title={expanded ? 'Collapse' : 'Expand'}
>
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
</div>
</div>
{error ? (
<div className="flex flex-col items-center justify-center flex-1 text-red-400 gap-2">
<AlertCircle size={24} />
<span className="text-xs">Failed to load status</span>
</div>
) : (
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 pr-1 scrollbar-thin">
{services.map((service) => (
<div
key={service.name}
className="flex flex-col gap-1 cursor-pointer group rounded-lg hover:bg-neutral-800/40 px-2 py-1.5 -mx-2 transition-colors"
onClick={() => setSelectedService(service)}
>
<div className="flex items-center justify-between gap-2">
<InlineNameEditor service={service} onRenamed={fetchStatus} />
<div className="flex items-center gap-2 shrink-0">
<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} range={timeRange} />
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
<span>{leftLabel}</span>
<span>{service.uptime24h ?? 100}% uptime (24h)</span>
<span>{rightLabel}</span>
</div>
</div>
))}
<AddServiceForm onAdded={fetchStatus} />
</div>
)}
</div>
{selectedService && (
<DetailModal
service={selectedService}
onClose={() => setSelectedService(null)}
onDelete={handleDelete}
onRenamed={fetchStatus}
timeRange={timeRange}
onTimeRangeChange={handleTimeRangeChange}
/>
)}
</>
);
}