Overhaul uptime monitor: time ranges, bar glow, tooltips, degraded state, immediate ping
- Add 24H/7D/30D/1Y time range selector with dynamic SQL bucketing - Add bar glow (colored box-shadow) for up/degraded/down states - Replace native title tooltips with positioned tooltips showing uptime % and check counts - Add "degraded" (amber) state for buckets with mixed up/down checks - Immediate HEAD ping on service add so status appears without 60s wait - Taller bars (h-3 list, h-6 modal), rounded-sm, hover:scale-y-[1.3] - Increase list bar opacity from 50% to 70% - Add DB index on uptime_logs(service_name, timestamp) - Extend data retention from 90 to 400 days for yearly view
This commit is contained in:
@@ -30,9 +30,35 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
await db.run('INSERT INTO monitored_services (name, url) VALUES (?, ?)', name.trim(), url.trim());
|
const trimmedName = name.trim();
|
||||||
|
const trimmedUrl = url.trim();
|
||||||
|
await db.run('INSERT INTO monitored_services (name, url) VALUES (?, ?)', trimmedName, trimmedUrl);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
// Immediate ping so the service has data right away
|
||||||
|
let initialStatus = 'down';
|
||||||
|
let initialLatency = 0;
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
const start = performance.now();
|
||||||
|
const res = await fetch(trimmedUrl, {
|
||||||
|
method: 'HEAD',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
initialLatency = Math.round(performance.now() - start);
|
||||||
|
initialStatus = res.status < 500 ? 'up' : 'down';
|
||||||
|
} catch {
|
||||||
|
initialStatus = 'down';
|
||||||
|
initialLatency = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
'INSERT INTO uptime_logs (service_name, url, status, latency, timestamp) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
trimmedName, trimmedUrl, initialStatus, initialLatency, new Date().toISOString()
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, initialStatus, initialLatency });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const msg = error instanceof Error ? error.message : 'Unknown error';
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
if (msg.includes('UNIQUE constraint')) {
|
if (msg.includes('UNIQUE constraint')) {
|
||||||
|
|||||||
@@ -3,9 +3,21 @@ import { getDb } from '@/lib/db';
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
type TimeRange = '24h' | '7d' | '30d' | '365d';
|
||||||
|
|
||||||
|
const RANGE_CONFIG: Record<TimeRange, { sqlOffset: string; strftime: string; bars: number }> = {
|
||||||
|
'24h': { sqlOffset: '-24 hours', strftime: '%Y-%m-%d %H:00', bars: 24 },
|
||||||
|
'7d': { sqlOffset: '-7 days', strftime: '%Y-%m-%d', bars: 7 },
|
||||||
|
'30d': { sqlOffset: '-30 days', strftime: '%Y-%m-%d', bars: 30 },
|
||||||
|
'365d': { sqlOffset: '-365 days', strftime: '%Y-%m', bars: 12 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
try {
|
try {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const range = (searchParams.get('range') || '24h') as TimeRange;
|
||||||
|
const config = RANGE_CONFIG[range] || RANGE_CONFIG['24h'];
|
||||||
|
|
||||||
// Get live status (most recent log per service)
|
// Get live status (most recent log per service)
|
||||||
const live = await db.all(`
|
const live = await db.all(`
|
||||||
@@ -54,25 +66,26 @@ export async function GET() {
|
|||||||
GROUP BY service_name
|
GROUP BY service_name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get hourly history for last 24h (24 buckets per service)
|
// Get bucketed history for the requested range
|
||||||
const history = await db.all(`
|
const history = await db.all(`
|
||||||
SELECT service_name,
|
SELECT service_name,
|
||||||
strftime('%Y-%m-%d %H:00', timestamp) as hour,
|
strftime('${config.strftime}', timestamp) as bucket,
|
||||||
count(*) as total,
|
count(*) as total,
|
||||||
sum(case when status = 'up' then 1 else 0 end) as up_count
|
sum(case when status = 'up' then 1 else 0 end) as up_count
|
||||||
FROM uptime_logs
|
FROM uptime_logs
|
||||||
WHERE timestamp > datetime('now', '-24 hours')
|
WHERE timestamp > datetime('now', '${config.sqlOffset}')
|
||||||
GROUP BY service_name, hour
|
GROUP BY service_name, bucket
|
||||||
ORDER BY hour ASC
|
ORDER BY bucket ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Group history by service
|
// Group history by service — return up_count and total per bucket
|
||||||
const historyMap: Record<string, Array<{ hour: string; up: boolean }>> = {};
|
const historyMap: Record<string, Array<{ bucket: string; up_count: number; total: number }>> = {};
|
||||||
for (const row of history) {
|
for (const row of history) {
|
||||||
if (!historyMap[row.service_name]) historyMap[row.service_name] = [];
|
if (!historyMap[row.service_name]) historyMap[row.service_name] = [];
|
||||||
historyMap[row.service_name].push({
|
historyMap[row.service_name].push({
|
||||||
hour: row.hour,
|
bucket: row.bucket,
|
||||||
up: row.up_count === row.total,
|
up_count: row.up_count,
|
||||||
|
total: row.total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
import { Activity, RefreshCcw, AlertCircle, X, Plus, Trash2, ExternalLink, ChevronDown, ChevronUp, Pencil, Check } from 'lucide-react';
|
import { Activity, RefreshCcw, AlertCircle, X, Plus, Trash2, ExternalLink, ChevronDown, ChevronUp, Pencil, Check } from 'lucide-react';
|
||||||
import { useState, useEffect, useCallback, useRef } from '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 {
|
interface ServiceStatus {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -12,56 +24,191 @@ interface ServiceStatus {
|
|||||||
uptime7d?: number;
|
uptime7d?: number;
|
||||||
uptimeLifetime?: number;
|
uptimeLifetime?: number;
|
||||||
avgLatency24h?: number;
|
avgLatency24h?: number;
|
||||||
history?: Array<{ hour: string; up: boolean }>;
|
history?: HistoryBucket[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function build24Bars(history: Array<{ hour: string; up: boolean }> | undefined) {
|
interface BarData {
|
||||||
const bars: Array<{ hour: string; status: 'up' | 'down' | 'unknown' }> = [];
|
label: string;
|
||||||
const now = new Date();
|
status: BarStatus;
|
||||||
|
upCount: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
const hourMap = new Map<string, boolean>();
|
// --- Constants ---
|
||||||
|
|
||||||
|
const BAR_STYLES: Record<BarStatus, { base: string; compact: string }> = {
|
||||||
|
up: { base: 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.4)]', compact: 'bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.25)]' },
|
||||||
|
degraded: { base: 'bg-amber-500 shadow-[0_0_6px_rgba(245,158,11,0.4)]', compact: 'bg-amber-500 shadow-[0_0_4px_rgba(245,158,11,0.25)]' },
|
||||||
|
down: { base: 'bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.4)]', compact: 'bg-red-500 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 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) {
|
if (history) {
|
||||||
for (const h of history) {
|
for (const h of history) {
|
||||||
hourMap.set(h.hour, h.up);
|
bucketMap.set(h.bucket, h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 23; i >= 0; i--) {
|
if (range === '24h') {
|
||||||
const d = new Date(now);
|
for (let i = config.bars - 1; i >= 0; i--) {
|
||||||
d.setMinutes(0, 0, 0);
|
const d = subHours(now, i);
|
||||||
d.setHours(d.getHours() - i);
|
const key = format(d, 'yyyy-MM-dd HH') + ':00';
|
||||||
const key = d.getFullYear() + '-' +
|
const h = bucketMap.get(key);
|
||||||
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
const hourStart = new Date(d);
|
||||||
String(d.getDate()).padStart(2, '0') + ' ' +
|
hourStart.setMinutes(0, 0, 0);
|
||||||
String(d.getHours()).padStart(2, '0') + ':00';
|
|
||||||
|
|
||||||
const found = hourMap.get(key);
|
|
||||||
bars.push({
|
bars.push({
|
||||||
hour: key,
|
label: format(hourStart, 'MMM d, h a') + ' – ' + format(new Date(hourStart.getTime() + 3600_000), 'h a'),
|
||||||
status: found === undefined ? 'unknown' : found ? 'up' : 'down',
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return bars;
|
return bars;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryBars({ history, large }: { history?: Array<{ hour: string; up: boolean }>; large?: boolean }) {
|
function rangeEdgeLabels(range: TimeRange): [string, string] {
|
||||||
const bars = build24Bars(history);
|
switch (range) {
|
||||||
const h = large ? 'h-4' : 'h-1.5';
|
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 (
|
return (
|
||||||
<div className={`flex gap-[2px] ${h} ${large ? '' : 'opacity-50'}`}>
|
<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) => (
|
{bars.map((b, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`flex-1 rounded-full ${b.status === 'up'
|
className="flex-1 relative"
|
||||||
? 'bg-emerald-500'
|
onMouseEnter={() => setHoveredIndex(i)}
|
||||||
: b.status === 'down'
|
onMouseLeave={() => setHoveredIndex(null)}
|
||||||
? 'bg-red-500'
|
>
|
||||||
: 'bg-neutral-700'
|
<div
|
||||||
}`}
|
className={`w-full h-full rounded-sm transition-transform duration-150 hover:scale-y-[1.3] origin-bottom ${BAR_STYLES[b.status][styleKey]}`}
|
||||||
title={`${b.hour} — ${b.status}`}
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -155,11 +302,15 @@ function DetailModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRenamed,
|
onRenamed,
|
||||||
|
timeRange,
|
||||||
|
onTimeRangeChange,
|
||||||
}: {
|
}: {
|
||||||
service: ServiceStatus;
|
service: ServiceStatus;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDelete: (name: string) => void;
|
onDelete: (name: string) => void;
|
||||||
onRenamed: () => void;
|
onRenamed: () => void;
|
||||||
|
timeRange: TimeRange;
|
||||||
|
onTimeRangeChange: (range: TimeRange) => void;
|
||||||
}) {
|
}) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null);
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -171,6 +322,8 @@ function DetailModal({
|
|||||||
return () => document.removeEventListener('keydown', handleKey);
|
return () => document.removeEventListener('keydown', handleKey);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={overlayRef}
|
ref={overlayRef}
|
||||||
@@ -254,14 +407,25 @@ function DetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 24h History Bar (large) */}
|
{/* History Bar (large) */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-2">24-Hour History</div>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<HistoryBars history={service.history} large />
|
<div className="text-[10px] uppercase tracking-wider text-neutral-500">History</div>
|
||||||
<div className="flex justify-between text-[9px] text-neutral-600 font-mono mt-1">
|
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
|
||||||
<span>-24h</span>
|
|
||||||
<span>now</span>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Delete Button */}
|
{/* Delete Button */}
|
||||||
@@ -371,10 +535,12 @@ export function UptimeCard() {
|
|||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [selectedService, setSelectedService] = useState<ServiceStatus | null>(null);
|
const [selectedService, setSelectedService] = useState<ServiceStatus | null>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>('24h');
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async (range?: TimeRange) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/status');
|
const r = range ?? timeRange;
|
||||||
|
const res = await fetch(`/api/status?range=${r}`);
|
||||||
if (!res.ok) throw new Error('Failed to fetch');
|
if (!res.ok) throw new Error('Failed to fetch');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setServices(data);
|
setServices(data);
|
||||||
@@ -385,14 +551,19 @@ export function UptimeCard() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [timeRange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
const interval = setInterval(fetchStatus, 30000);
|
const interval = setInterval(() => fetchStatus(), 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchStatus]);
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
const handleTimeRangeChange = useCallback((range: TimeRange) => {
|
||||||
|
setTimeRange(range);
|
||||||
|
fetchStatus(range);
|
||||||
|
}, [fetchStatus]);
|
||||||
|
|
||||||
const handleDelete = async (serviceName: string) => {
|
const handleDelete = async (serviceName: string) => {
|
||||||
try {
|
try {
|
||||||
const listRes = await fetch('/api/services');
|
const listRes = await fetch('/api/services');
|
||||||
@@ -412,17 +583,20 @@ export function UptimeCard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rowSpan = expanded ? 'row-span-2' : 'row-span-1';
|
const rowSpan = expanded ? 'row-span-2' : 'row-span-1';
|
||||||
|
const [leftLabel, rightLabel] = rangeEdgeLabels(timeRange);
|
||||||
|
|
||||||
return (
|
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`}>
|
<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 — always visible, never scrolls */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-center shrink-0 mb-3">
|
<div className="flex justify-between items-center shrink-0 mb-3">
|
||||||
<div className="flex items-center gap-2 text-neutral-400">
|
<div className="flex items-center gap-2 text-neutral-400">
|
||||||
<Activity size={18} />
|
<Activity size={18} />
|
||||||
<span className="text-sm font-medium">Uptime Monitor</span>
|
<span className="text-sm font-medium">Uptime Monitor</span>
|
||||||
{loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />}
|
{loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TimeRangeSelector value={timeRange} onChange={handleTimeRangeChange} />
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
className="text-neutral-500 hover:text-neutral-300 transition-colors p-1 rounded-lg hover:bg-neutral-800"
|
className="text-neutral-500 hover:text-neutral-300 transition-colors p-1 rounded-lg hover:bg-neutral-800"
|
||||||
@@ -431,6 +605,7 @@ export function UptimeCard() {
|
|||||||
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
{expanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex flex-col items-center justify-center flex-1 text-red-400 gap-2">
|
<div className="flex flex-col items-center justify-center flex-1 text-red-400 gap-2">
|
||||||
@@ -438,7 +613,6 @@ export function UptimeCard() {
|
|||||||
<span className="text-xs">Failed to load status</span>
|
<span className="text-xs">Failed to load status</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Scrollable content area — fills remaining space */
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 pr-1 scrollbar-thin">
|
<div className="flex-1 overflow-y-auto min-h-0 space-y-3 pr-1 scrollbar-thin">
|
||||||
{services.map((service) => (
|
{services.map((service) => (
|
||||||
<div
|
<div
|
||||||
@@ -455,11 +629,11 @@ export function UptimeCard() {
|
|||||||
<span className="text-xs text-neutral-500 font-mono w-10 text-right">{service.latency}ms</span>
|
<span className="text-xs text-neutral-500 font-mono w-10 text-right">{service.latency}ms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HistoryBars history={service.history} />
|
<HistoryBars history={service.history} range={timeRange} />
|
||||||
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
|
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
|
||||||
<span>24h: {service.uptime24h ?? 100}%</span>
|
<span>{leftLabel}</span>
|
||||||
<span>lifetime: {service.uptimeLifetime ?? 100}%</span>
|
<span>{service.uptime24h ?? 100}% uptime (24h)</span>
|
||||||
<span>7d: {service.uptime7d ?? 100}%</span>
|
<span>{rightLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -475,6 +649,8 @@ export function UptimeCard() {
|
|||||||
onClose={() => setSelectedService(null)}
|
onClose={() => setSelectedService(null)}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRenamed={fetchStatus}
|
onRenamed={fetchStatus}
|
||||||
|
timeRange={timeRange}
|
||||||
|
onTimeRangeChange={handleTimeRangeChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export async function getDb() {
|
|||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_uptime_logs_service_timestamp
|
||||||
|
ON uptime_logs(service_name, timestamp);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ async function monitor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune old logs (keep 90 days for lifetime stats)
|
// Prune old logs (keep 400 days for yearly view)
|
||||||
try {
|
try {
|
||||||
await db.run(`DELETE FROM uptime_logs WHERE timestamp < datetime('now', '-90 days')`);
|
await db.run(`DELETE FROM uptime_logs WHERE timestamp < datetime('now', '-400 days')`);
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
|
|
||||||
}, 60000); // Run every minute
|
}, 60000); // Run every minute
|
||||||
|
|||||||
Reference in New Issue
Block a user