Bars were all grey for 24h view because SQL bucket keys (UTC) never matched frontend keys (local timezone). Added toUTCKey() helper to generate UTC keys for all ranges. Replaced check-counting uptime with time-interval method for 24h/7d: time between consecutive up checks counts as uptime, intervals involving down checks count as downtime, uncovered time is unknown.
669 lines
28 KiB
TypeScript
669 lines
28 KiB
TypeScript
'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}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|