2026-02-08 02:32:45 -05:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
2026-02-09 02:23:19 -05:00
|
|
|
|
import { Activity, RefreshCcw, AlertCircle, X, Plus, Trash2, ExternalLink, ChevronDown, ChevronUp, Pencil, Check } from 'lucide-react';
|
2026-02-09 02:05:22 -05:00
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
2026-02-09 03:28:24 -05:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-08 02:32:45 -05:00
|
|
|
|
|
|
|
|
|
|
interface ServiceStatus {
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
url: string;
|
|
|
|
|
|
status: 'up' | 'down';
|
|
|
|
|
|
latency: number;
|
2026-02-08 03:03:53 -05:00
|
|
|
|
uptime24h?: number;
|
|
|
|
|
|
uptime7d?: number;
|
2026-02-09 02:05:22 -05:00
|
|
|
|
uptimeLifetime?: number;
|
|
|
|
|
|
avgLatency24h?: number;
|
2026-02-09 03:28:24 -05:00
|
|
|
|
history?: HistoryBucket[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface BarData {
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
status: BarStatus;
|
|
|
|
|
|
upCount: number;
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- Constants ---
|
|
|
|
|
|
|
|
|
|
|
|
const BAR_STYLES: Record<BarStatus, { base: string; compact: string }> = {
|
2026-02-09 03:42:04 -05:00
|
|
|
|
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' },
|
2026-02-09 03:28:24 -05:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 bucketStatus(upCount: number, total: number): BarStatus {
|
|
|
|
|
|
if (total === 0) return 'unknown';
|
|
|
|
|
|
if (upCount === total) return 'up';
|
|
|
|
|
|
if (upCount === 0) return 'down';
|
|
|
|
|
|
return 'degraded';
|
2026-02-08 02:32:45 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 03:28:24 -05:00
|
|
|
|
function buildBars(range: TimeRange, history: HistoryBucket[] | undefined): BarData[] {
|
|
|
|
|
|
const config = RANGE_CONFIG[range];
|
2026-02-09 02:05:22 -05:00
|
|
|
|
const now = new Date();
|
2026-02-09 03:28:24 -05:00
|
|
|
|
const bars: BarData[] = [];
|
2026-02-09 02:05:22 -05:00
|
|
|
|
|
2026-02-09 03:28:24 -05:00
|
|
|
|
const bucketMap = new Map<string, HistoryBucket>();
|
2026-02-09 02:05:22 -05:00
|
|
|
|
if (history) {
|
|
|
|
|
|
for (const h of history) {
|
2026-02-09 03:28:24 -05:00
|
|
|
|
bucketMap.set(h.bucket, h);
|
2026-02-09 02:05:22 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 03:28:24 -05:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-09 02:05:22 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return bars;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 03:28:24 -05:00
|
|
|
|
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';
|
2026-02-09 02:05:22 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-09 03:28:24 -05:00
|
|
|
|
<div className={`flex gap-[2px] ${h} ${large ? '' : 'opacity-70'}`}>
|
2026-02-09 02:05:22 -05:00
|
|
|
|
{bars.map((b, i) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={i}
|
2026-02-09 03:28:24 -05:00
|
|
|
|
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>
|
2026-02-09 02:05:22 -05:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 02:23:19 -05:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-09 02:05:22 -05:00
|
|
|
|
function DetailModal({
|
|
|
|
|
|
service,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onDelete,
|
2026-02-09 02:23:19 -05:00
|
|
|
|
onRenamed,
|
2026-02-09 03:28:24 -05:00
|
|
|
|
timeRange,
|
|
|
|
|
|
onTimeRangeChange,
|
2026-02-09 02:05:22 -05:00
|
|
|
|
}: {
|
|
|
|
|
|
service: ServiceStatus;
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onDelete: (name: string) => void;
|
2026-02-09 02:23:19 -05:00
|
|
|
|
onRenamed: () => void;
|
2026-02-09 03:28:24 -05:00
|
|
|
|
timeRange: TimeRange;
|
|
|
|
|
|
onTimeRangeChange: (range: TimeRange) => void;
|
2026-02-09 02:05:22 -05:00
|
|
|
|
}) {
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-02-09 03:28:24 -05:00
|
|
|
|
const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange);
|
|
|
|
|
|
|
2026-02-09 02:05:22 -05:00
|
|
|
|
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">
|
2026-02-09 03:42:04 -05:00
|
|
|
|
<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)]'}`} />
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<h2 className="text-lg font-semibold text-white">{service.name}</h2>
|
2026-02-09 02:23:19 -05:00
|
|
|
|
<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>
|
2026-02-09 02:05:22 -05:00
|
|
|
|
</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">
|
2026-02-09 02:23:19 -05:00
|
|
|
|
<ExternalLink size={12} className="shrink-0" />
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-02-09 03:28:24 -05:00
|
|
|
|
{/* History Bar (large) */}
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<div className="mb-2">
|
2026-02-09 03:28:24 -05:00
|
|
|
|
<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} />
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<div className="flex justify-between text-[9px] text-neutral-600 font-mono mt-1">
|
2026-02-09 03:28:24 -05:00
|
|
|
|
<span>{leftLabel}</span>
|
|
|
|
|
|
<span>{rightLabel}</span>
|
2026-02-09 02:05:22 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-09 03:28:24 -05:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-02-09 02:05:22 -05:00
|
|
|
|
{/* 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)}
|
2026-02-09 02:23:19 -05:00
|
|
|
|
className="flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-300 transition-colors pt-2"
|
2026-02-09 02:05:22 -05:00
|
|
|
|
>
|
|
|
|
|
|
<Plus size={14} />
|
|
|
|
|
|
Add service
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-09 02:23:19 -05:00
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-2 pt-2">
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="Name"
|
|
|
|
|
|
value={name}
|
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
|
|
|
|
|
required
|
2026-02-09 02:23:19 -05:00
|
|
|
|
autoFocus
|
2026-02-09 02:05:22 -05:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 02:32:45 -05:00
|
|
|
|
export function UptimeCard() {
|
|
|
|
|
|
const [services, setServices] = useState<ServiceStatus[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState(false);
|
2026-02-09 02:05:22 -05:00
|
|
|
|
const [selectedService, setSelectedService] = useState<ServiceStatus | null>(null);
|
2026-02-09 02:23:19 -05:00
|
|
|
|
const [expanded, setExpanded] = useState(false);
|
2026-02-09 03:28:24 -05:00
|
|
|
|
const [timeRange, setTimeRange] = useState<TimeRange>('24h');
|
2026-02-08 02:32:45 -05:00
|
|
|
|
|
2026-02-09 03:28:24 -05:00
|
|
|
|
const fetchStatus = useCallback(async (range?: TimeRange) => {
|
2026-02-08 02:32:45 -05:00
|
|
|
|
try {
|
2026-02-09 03:28:24 -05:00
|
|
|
|
const r = range ?? timeRange;
|
|
|
|
|
|
const res = await fetch(`/api/status?range=${r}`);
|
2026-02-08 02:32:45 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-09 03:28:24 -05:00
|
|
|
|
}, [timeRange]);
|
2026-02-08 02:32:45 -05:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchStatus();
|
2026-02-09 03:28:24 -05:00
|
|
|
|
const interval = setInterval(() => fetchStatus(), 30000);
|
2026-02-08 02:32:45 -05:00
|
|
|
|
return () => clearInterval(interval);
|
2026-02-09 02:05:22 -05:00
|
|
|
|
}, [fetchStatus]);
|
|
|
|
|
|
|
2026-02-09 03:28:24 -05:00
|
|
|
|
const handleTimeRangeChange = useCallback((range: TimeRange) => {
|
|
|
|
|
|
setTimeRange(range);
|
|
|
|
|
|
fetchStatus(range);
|
|
|
|
|
|
}, [fetchStatus]);
|
|
|
|
|
|
|
2026-02-09 02:05:22 -05:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-02-08 02:32:45 -05:00
|
|
|
|
|
2026-02-09 02:23:19 -05:00
|
|
|
|
const rowSpan = expanded ? 'row-span-2' : 'row-span-1';
|
2026-02-09 03:28:24 -05:00
|
|
|
|
const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange);
|
2026-02-09 02:23:19 -05:00
|
|
|
|
|
2026-02-08 02:32:45 -05:00
|
|
|
|
return (
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<>
|
2026-02-09 02:23:19 -05:00
|
|
|
|
<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`}>
|
2026-02-09 03:28:24 -05:00
|
|
|
|
{/* Header */}
|
2026-02-09 02:23:19 -05:00
|
|
|
|
<div className="flex justify-between items-center shrink-0 mb-3">
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<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>
|
2026-02-09 03:28:24 -05:00
|
|
|
|
<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>
|
2026-02-08 02:32:45 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-02-09 02:05:22 -05:00
|
|
|
|
{error ? (
|
2026-02-09 02:23:19 -05:00
|
|
|
|
<div className="flex flex-col items-center justify-center flex-1 text-red-400 gap-2">
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<AlertCircle size={24} />
|
|
|
|
|
|
<span className="text-xs">Failed to load status</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-02-09 02:23:19 -05:00
|
|
|
|
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 pr-1 scrollbar-thin">
|
2026-02-09 02:05:22 -05:00
|
|
|
|
{services.map((service) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={service.name}
|
2026-02-09 02:23:19 -05:00
|
|
|
|
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"
|
2026-02-09 02:05:22 -05:00
|
|
|
|
onClick={() => setSelectedService(service)}
|
|
|
|
|
|
>
|
2026-02-09 02:23:19 -05:00
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
|
<InlineNameEditor service={service} onRenamed={fetchStatus} />
|
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<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>
|
2026-02-09 03:28:24 -05:00
|
|
|
|
<HistoryBars history={service.history} range={timeRange} />
|
2026-02-09 02:05:22 -05:00
|
|
|
|
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
|
2026-02-09 03:28:24 -05:00
|
|
|
|
<span>{leftLabel}</span>
|
|
|
|
|
|
<span>{service.uptime24h ?? 100}% uptime (24h)</span>
|
|
|
|
|
|
<span>{rightLabel}</span>
|
2026-02-08 02:32:45 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-02-09 02:05:22 -05:00
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
<AddServiceForm onAdded={fetchStatus} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{selectedService && (
|
|
|
|
|
|
<DetailModal
|
|
|
|
|
|
service={selectedService}
|
|
|
|
|
|
onClose={() => setSelectedService(null)}
|
|
|
|
|
|
onDelete={handleDelete}
|
2026-02-09 02:23:19 -05:00
|
|
|
|
onRenamed={fetchStatus}
|
2026-02-09 03:28:24 -05:00
|
|
|
|
timeRange={timeRange}
|
|
|
|
|
|
onTimeRangeChange={handleTimeRangeChange}
|
2026-02-09 02:05:22 -05:00
|
|
|
|
/>
|
2026-02-08 02:32:45 -05:00
|
|
|
|
)}
|
2026-02-09 02:05:22 -05:00
|
|
|
|
</>
|
2026-02-08 02:32:45 -05:00
|
|
|
|
);
|
|
|
|
|
|
}
|