Add uptime monitor features: fix history bars, lifetime stats, detail modal, custom services
- Fix 24-bar history rendering to always show 24 uniform segments with gray fill for missing hours - Add lifetime uptime % and avg latency to status API - Add clickable detail modal with expanded stats, large history bar, and service removal - Add monitored_services DB table with CRUD API (GET/POST/DELETE) - Monitor reads services from DB each interval, seeds defaults on first run - Add inline form to add custom services to track - Extend log retention from 7 days to 90 days for lifetime stats Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
70
app/api/services/route.ts
Normal file
70
app/api/services/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const db = await getDb();
|
||||||
|
const services = await db.all('SELECT id, name, url, created_at FROM monitored_services ORDER BY id ASC');
|
||||||
|
return NextResponse.json(services);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Services list error:', error);
|
||||||
|
return NextResponse.json([], { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { name, url } = await request.json();
|
||||||
|
|
||||||
|
if (!name || !url) {
|
||||||
|
return NextResponse.json({ error: 'Name and URL are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
await db.run('INSERT INTO monitored_services (name, url) VALUES (?, ?)', name.trim(), url.trim());
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const msg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
if (msg.includes('UNIQUE constraint')) {
|
||||||
|
return NextResponse.json({ error: 'A service with that name already exists' }, { status: 409 });
|
||||||
|
}
|
||||||
|
console.error('Service add error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to add service' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
try {
|
||||||
|
const { id } = await request.json();
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: 'Service ID is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = await getDb();
|
||||||
|
|
||||||
|
// Get service name before deleting so we can clean up logs
|
||||||
|
const service = await db.get('SELECT name FROM monitored_services WHERE id = ?', id);
|
||||||
|
if (!service) {
|
||||||
|
return NextResponse.json({ error: 'Service not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.run('DELETE FROM monitored_services WHERE id = ?', id);
|
||||||
|
await db.run('DELETE FROM uptime_logs WHERE service_name = ?', service.name);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service delete error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to delete service' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,26 @@ export async function GET() {
|
|||||||
GROUP BY service_name
|
GROUP BY service_name
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Get lifetime stats (all available data)
|
||||||
|
const statsLifetime = await db.all(`
|
||||||
|
SELECT service_name,
|
||||||
|
count(*) as total,
|
||||||
|
sum(case when status = 'up' then 1 else 0 end) as up_count
|
||||||
|
FROM uptime_logs
|
||||||
|
GROUP BY service_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get average latency over last 24h (only for 'up' checks)
|
||||||
|
const avgLatencyRows = await db.all(`
|
||||||
|
SELECT service_name,
|
||||||
|
ROUND(AVG(latency)) as avg_latency
|
||||||
|
FROM uptime_logs
|
||||||
|
WHERE timestamp > datetime('now', '-24 hours')
|
||||||
|
AND status = 'up'
|
||||||
|
AND latency > 0
|
||||||
|
GROUP BY service_name
|
||||||
|
`);
|
||||||
|
|
||||||
// Get hourly history for last 24h (24 buckets per service)
|
// Get hourly history for last 24h (24 buckets per service)
|
||||||
const history = await db.all(`
|
const history = await db.all(`
|
||||||
SELECT service_name,
|
SELECT service_name,
|
||||||
@@ -60,11 +80,15 @@ export async function GET() {
|
|||||||
const results = live.map(l => {
|
const results = live.map(l => {
|
||||||
const s24 = stats24h.find(s => s.service_name === l.name);
|
const s24 = stats24h.find(s => s.service_name === l.name);
|
||||||
const s7d = stats7d.find(s => s.service_name === l.name);
|
const s7d = stats7d.find(s => s.service_name === l.name);
|
||||||
|
const sLife = statsLifetime.find(s => s.service_name === l.name);
|
||||||
|
const avgLat = avgLatencyRows.find(s => s.service_name === l.name);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...l,
|
...l,
|
||||||
uptime24h: s24 ? Math.round((s24.up_count / s24.total) * 100) : 100,
|
uptime24h: s24 ? Math.round((s24.up_count / s24.total) * 100) : 100,
|
||||||
uptime7d: s7d ? Math.round((s7d.up_count / s7d.total) * 100) : 100,
|
uptime7d: s7d ? Math.round((s7d.up_count / s7d.total) * 100) : 100,
|
||||||
|
uptimeLifetime: sLife ? Math.round((sLife.up_count / sLife.total) * 100) : 100,
|
||||||
|
avgLatency24h: avgLat ? avgLat.avg_latency : 0,
|
||||||
history: historyMap[l.name] || [],
|
history: historyMap[l.name] || [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Activity, RefreshCcw, AlertCircle } from 'lucide-react';
|
import { Activity, RefreshCcw, AlertCircle, X, Plus, Trash2, ExternalLink } from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
interface ServiceStatus {
|
interface ServiceStatus {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,15 +10,263 @@ interface ServiceStatus {
|
|||||||
latency: number;
|
latency: number;
|
||||||
uptime24h?: number;
|
uptime24h?: number;
|
||||||
uptime7d?: number;
|
uptime7d?: number;
|
||||||
|
uptimeLifetime?: number;
|
||||||
|
avgLatency24h?: number;
|
||||||
history?: Array<{ hour: string; up: boolean }>;
|
history?: Array<{ hour: string; up: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build exactly 24 bars from whatever history data exists
|
||||||
|
function build24Bars(history: Array<{ hour: string; up: boolean }> | undefined) {
|
||||||
|
const bars: Array<{ hour: string; status: 'up' | 'down' | 'unknown' }> = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Create a map of hour-string -> up/down from available data
|
||||||
|
const hourMap = new Map<string, boolean>();
|
||||||
|
if (history) {
|
||||||
|
for (const h of history) {
|
||||||
|
hourMap.set(h.hour, h.up);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 24 hour slots ending at current hour
|
||||||
|
for (let i = 23; i >= 0; i--) {
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setMinutes(0, 0, 0);
|
||||||
|
d.setHours(d.getHours() - i);
|
||||||
|
// Format to match the API: "YYYY-MM-DD HH:00"
|
||||||
|
const key = d.getFullYear() + '-' +
|
||||||
|
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||||
|
String(d.getDate()).padStart(2, '0') + ' ' +
|
||||||
|
String(d.getHours()).padStart(2, '0') + ':00';
|
||||||
|
|
||||||
|
const found = hourMap.get(key);
|
||||||
|
bars.push({
|
||||||
|
hour: key,
|
||||||
|
status: found === undefined ? 'unknown' : found ? 'up' : 'down',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return bars;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryBars({ history, large }: { history?: Array<{ hour: string; up: boolean }>; large?: boolean }) {
|
||||||
|
const bars = build24Bars(history);
|
||||||
|
const h = large ? 'h-4' : 'h-1.5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex gap-[2px] ${h} ${large ? '' : 'opacity-50'}`}>
|
||||||
|
{bars.map((b, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex-1 rounded-full ${b.status === 'up'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: b.status === 'down'
|
||||||
|
? 'bg-red-500'
|
||||||
|
: 'bg-neutral-700'
|
||||||
|
}`}
|
||||||
|
title={`${b.hour} — ${b.status}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailModal({
|
||||||
|
service,
|
||||||
|
onClose,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
service: ServiceStatus;
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKey);
|
||||||
|
return () => document.removeEventListener('keydown', handleKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === overlayRef.current) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bg-neutral-900 border border-neutral-700 rounded-2xl p-6 w-full max-w-lg mx-4 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${service.status === 'up' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]'}`} />
|
||||||
|
<h2 className="text-lg font-semibold text-white">{service.name}</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-neutral-500 hover:text-white transition-colors p-1 rounded-lg hover:bg-neutral-800">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL */}
|
||||||
|
<div className="flex items-center gap-2 mb-5 text-xs text-neutral-500 font-mono bg-neutral-800/50 rounded-lg px-3 py-2">
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
<span className="truncate">{service.url}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status + Latency Row */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||||
|
<div className="bg-neutral-800/50 rounded-xl p-3">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">Status</div>
|
||||||
|
<span className={`text-sm font-semibold ${service.status === 'up' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{service.status === 'up' ? 'Operational' : 'Down'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-800/50 rounded-xl p-3">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">Current Latency</div>
|
||||||
|
<span className="text-sm font-semibold text-white font-mono">{service.latency}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uptime Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||||
|
<div className="bg-neutral-800/50 rounded-xl p-3 text-center">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">24h</div>
|
||||||
|
<span className="text-base font-bold text-white">{service.uptime24h ?? 100}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-800/50 rounded-xl p-3 text-center">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">7d</div>
|
||||||
|
<span className="text-base font-bold text-white">{service.uptime7d ?? 100}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-800/50 rounded-xl p-3 text-center">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">Lifetime</div>
|
||||||
|
<span className="text-base font-bold text-white">{service.uptimeLifetime ?? 100}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-800/50 rounded-xl p-3 text-center">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-1">Avg Lat</div>
|
||||||
|
<span className="text-base font-bold text-white font-mono">{service.avgLatency24h ?? 0}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 24h History Bar (large) */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-neutral-500 mb-2">24-Hour History</div>
|
||||||
|
<HistoryBars history={service.history} large />
|
||||||
|
<div className="flex justify-between text-[9px] text-neutral-600 font-mono mt-1">
|
||||||
|
<span>-24h</span>
|
||||||
|
<span>now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<div className="mt-5 pt-4 border-t border-neutral-800">
|
||||||
|
<button
|
||||||
|
onClick={() => { onDelete(service.name); onClose(); }}
|
||||||
|
className="flex items-center gap-2 text-xs text-red-400/70 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
Remove service
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddServiceForm({ onAdded }: { onAdded: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/services', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name.trim(), url: url.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'Failed to add');
|
||||||
|
}
|
||||||
|
setName('');
|
||||||
|
setUrl('');
|
||||||
|
setOpen(false);
|
||||||
|
onAdded();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to add service');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-300 transition-colors mt-3"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add service
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-3 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="flex-1 bg-neutral-800 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
required
|
||||||
|
className="flex-[2] bg-neutral-800 border border-neutral-700 rounded-lg px-2.5 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[10px] text-red-400">{error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="text-xs bg-neutral-700 hover:bg-neutral-600 text-white px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Adding...' : 'Add'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setOpen(false); setError(''); }}
|
||||||
|
className="text-xs text-neutral-500 hover:text-neutral-300 px-2 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UptimeCard() {
|
export function UptimeCard() {
|
||||||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [selectedService, setSelectedService] = useState<ServiceStatus | null>(null);
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/status');
|
const res = await fetch('/api/status');
|
||||||
if (!res.ok) throw new Error('Failed to fetch');
|
if (!res.ok) throw new Error('Failed to fetch');
|
||||||
@@ -31,65 +279,87 @@ export function UptimeCard() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
const interval = setInterval(fetchStatus, 30000);
|
const interval = setInterval(fetchStatus, 30000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
const handleDelete = async (serviceName: string) => {
|
||||||
|
try {
|
||||||
|
// Look up ID from the services API
|
||||||
|
const listRes = await fetch('/api/services');
|
||||||
|
const list = await listRes.json();
|
||||||
|
const svc = list.find((s: { name: string }) => s.name === serviceName);
|
||||||
|
if (!svc) return;
|
||||||
|
|
||||||
|
await fetch('/api/services', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: svc.id }),
|
||||||
|
});
|
||||||
|
fetchStatus();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Delete failed:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex flex-col justify-between hover:border-neutral-700 transition-colors">
|
<>
|
||||||
<div className="flex justify-between items-start">
|
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex flex-col justify-between hover:border-neutral-700 transition-colors">
|
||||||
<div className="flex items-center gap-2 text-neutral-400">
|
<div className="flex justify-between items-start">
|
||||||
<Activity size={18} />
|
<div className="flex items-center gap-2 text-neutral-400">
|
||||||
<span className="text-sm font-medium">Uptime Monitor</span>
|
<Activity size={18} />
|
||||||
{loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />}
|
<span className="text-sm font-medium">Uptime Monitor</span>
|
||||||
|
{loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-red-400 gap-2">
|
<div className="flex flex-col items-center justify-center h-full text-red-400 gap-2">
|
||||||
<AlertCircle size={24} />
|
<AlertCircle size={24} />
|
||||||
<span className="text-xs">Failed to load status</span>
|
<span className="text-xs">Failed to load status</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 mt-4">
|
<div className="space-y-4 mt-4">
|
||||||
{services.map((service) => (
|
{services.map((service) => (
|
||||||
<div key={service.name} className="flex flex-col gap-1">
|
<div
|
||||||
<div className="flex items-center justify-between group">
|
key={service.name}
|
||||||
<span className="text-sm text-neutral-300 font-medium group-hover:text-white transition-colors">{service.name}</span>
|
className="flex flex-col gap-1 cursor-pointer group"
|
||||||
<div className="flex items-center gap-2">
|
onClick={() => setSelectedService(service)}
|
||||||
<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'}
|
<div className="flex items-center justify-between">
|
||||||
</span>
|
<span className="text-sm text-neutral-300 font-medium group-hover:text-white transition-colors">{service.name}</span>
|
||||||
<span className="text-xs text-neutral-500 font-mono w-10 text-right">{service.latency}ms</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-xs px-1.5 py-0.5 rounded ${service.status === 'up' ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
|
||||||
|
{service.status === 'up' ? 'UP' : 'DOWN'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neutral-500 font-mono w-10 text-right">{service.latency}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<HistoryBars history={service.history} />
|
||||||
|
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
|
||||||
|
<span>24h: {service.uptime24h ?? 100}%</span>
|
||||||
|
<span>lifetime: {service.uptimeLifetime ?? 100}%</span>
|
||||||
|
<span>7d: {service.uptime7d ?? 100}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-[2px] h-1.5 opacity-50">
|
))}
|
||||||
{service.history && service.history.length > 0 ? (
|
|
||||||
service.history.slice(-24).map((h, i) => (
|
<AddServiceForm onAdded={fetchStatus} />
|
||||||
<div
|
</div>
|
||||||
key={i}
|
)}
|
||||||
className={`flex-1 rounded-full ${h.up ? 'bg-emerald-500' : 'bg-red-500'}`}
|
</div>
|
||||||
title={h.hour}
|
|
||||||
/>
|
{selectedService && (
|
||||||
))
|
<DetailModal
|
||||||
) : (
|
service={selectedService}
|
||||||
[...Array(24)].map((_, i) => (
|
onClose={() => setSelectedService(null)}
|
||||||
<div key={i} className="flex-1 rounded-full bg-neutral-700" />
|
onDelete={handleDelete}
|
||||||
))
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-[10px] text-neutral-600 font-mono">
|
|
||||||
<span>24h: {service.uptime24h ?? 100}%</span>
|
|
||||||
<span>7d: {service.uptime7d ?? 100}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ export async function getDb() {
|
|||||||
lon REAL,
|
lon REAL,
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS monitored_services (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return db;
|
return db;
|
||||||
|
|||||||
48
monitor.js
48
monitor.js
@@ -2,12 +2,46 @@ const sqlite3 = require('sqlite3');
|
|||||||
const { open } = require('sqlite');
|
const { open } = require('sqlite');
|
||||||
// Node 18+ has global fetch built-in
|
// Node 18+ has global fetch built-in
|
||||||
|
|
||||||
const SERVICES = [
|
const DEFAULT_SERVICES = [
|
||||||
{ name: 'Website', url: 'https://akkolli.net' },
|
{ name: 'Website', url: 'https://akkolli.net' },
|
||||||
{ name: 'Gitea', url: 'https://code.akkolli.net' },
|
{ name: 'Gitea', url: 'https://code.akkolli.net' },
|
||||||
{ name: 'Nextcloud', url: 'http://host.docker.internal:6060' },
|
{ name: 'Nextcloud', url: 'http://host.docker.internal:6060' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function getServices(db) {
|
||||||
|
try {
|
||||||
|
const rows = await db.all('SELECT name, url FROM monitored_services');
|
||||||
|
if (rows && rows.length > 0) return rows;
|
||||||
|
} catch (e) {
|
||||||
|
// Table might not exist yet
|
||||||
|
}
|
||||||
|
return DEFAULT_SERVICES;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDefaults(db) {
|
||||||
|
// Ensure monitored_services table exists
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS monitored_services (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Seed defaults if table is empty
|
||||||
|
const count = await db.get('SELECT COUNT(*) as cnt FROM monitored_services');
|
||||||
|
if (count.cnt === 0) {
|
||||||
|
for (const s of DEFAULT_SERVICES) {
|
||||||
|
await db.run(
|
||||||
|
'INSERT OR IGNORE INTO monitored_services (name, url) VALUES (?, ?)',
|
||||||
|
s.name, s.url
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('Seeded default services into monitored_services');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function monitor() {
|
async function monitor() {
|
||||||
console.log('Starting monitoring loop...');
|
console.log('Starting monitoring loop...');
|
||||||
|
|
||||||
@@ -30,11 +64,16 @@ async function monitor() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await seedDefaults(db);
|
||||||
|
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
console.log('Running checks...');
|
console.log('Running checks...');
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
for (const service of SERVICES) {
|
// Re-read services each interval so new additions are picked up
|
||||||
|
const services = await getServices(db);
|
||||||
|
|
||||||
|
for (const service of services) {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
let status = 'down';
|
let status = 'down';
|
||||||
let latency = 0;
|
let latency = 0;
|
||||||
@@ -57,7 +96,6 @@ async function monitor() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
status = 'down';
|
status = 'down';
|
||||||
latency = 0;
|
latency = 0;
|
||||||
// console.error(`Failed to reach ${service.name}:`, err.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -70,9 +108,9 @@ async function monitor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune old logs (keep 7 days)
|
// Prune old logs (keep 90 days for lifetime stats)
|
||||||
try {
|
try {
|
||||||
await db.run(`DELETE FROM uptime_logs WHERE timestamp < datetime('now', '-7 days')`);
|
await db.run(`DELETE FROM uptime_logs WHERE timestamp < datetime('now', '-90 days')`);
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
|
|
||||||
}, 60000); // Run every minute
|
}, 60000); // Run every minute
|
||||||
|
|||||||
Reference in New Issue
Block a user