Files
Charon/frontend/src/pages/Uptime.tsx
2026-01-26 19:22:05 +00:00

576 lines
26 KiB
TypeScript

import { useMemo, useState, type FC, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getMonitors, getMonitorHistory, updateMonitor, deleteMonitor, checkMonitor, createMonitor, syncMonitors, UptimeMonitor } from '../api/uptime';
import { Activity, ArrowUp, ArrowDown, Settings, X, Pause, RefreshCw, Plus } from 'lucide-react';
import { toast } from 'react-hot-toast'
import { formatDistanceToNow } from 'date-fns';
const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor) => void; t: (key: string, options?: Record<string, unknown>) => string }> = ({ monitor, onEdit, t }) => {
const { data: history } = useQuery({
queryKey: ['uptimeHistory', monitor.id],
queryFn: () => getMonitorHistory(monitor.id, 60),
refetchInterval: 60000,
});
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
return await deleteMonitor(id)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['monitors'] })
toast.success(t('uptime.monitorDeleted'))
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : t('uptime.failedToDeleteMonitor'))
}
})
const toggleMutation = useMutation({
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
return await updateMonitor(id, { enabled })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['monitors'] })
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : t('uptime.failedToUpdateMonitor'))
}
})
const checkMutation = useMutation({
mutationFn: async (id: string) => {
return await checkMonitor(id)
},
onSuccess: () => {
toast.success(t('uptime.healthCheckTriggered'))
// Refetch monitor and history after a short delay to get updated results
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['monitors'] })
queryClient.invalidateQueries({ queryKey: ['uptimeHistory', monitor.id] })
}, 2000)
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : t('uptime.failedToTriggerCheck'))
}
})
const [showMenu, setShowMenu] = useState(false)
// Determine current status from most recent heartbeat when available
const latestBeat = history && history.length > 0
? history.reduce((a, b) => new Date(a.created_at) > new Date(b.created_at) ? a : b)
: null
const isUp = latestBeat ? latestBeat.status === 'up' : monitor.status === 'up';
const isPaused = monitor.enabled === false;
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 border-l-4 ${isPaused ? 'border-l-yellow-400' : isUp ? 'border-l-green-500' : 'border-l-red-500'}`} data-testid="monitor-card">
{/* Top Row: Name (left), Badge (center-right), Settings (right) */}
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white flex-1 min-w-0 truncate">{monitor.name}</h3>
<div className="flex items-center gap-2 flex-shrink-0">
<div className={`flex items-center justify-center px-3 py-1 rounded-full text-sm font-medium min-w-[90px] ${
isPaused
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: isUp
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`} data-testid="status-badge" data-status={isPaused ? 'paused' : monitor.status}>
{isPaused ? <Pause className="w-4 h-4 mr-1" /> : isUp ? <ArrowUp className="w-4 h-4 mr-1" /> : <ArrowDown className="w-4 h-4 mr-1" />}
{isPaused ? t('uptime.paused') : monitor.status.toUpperCase()}
</div>
<button
onClick={async () => {
try {
await checkMutation.mutateAsync(monitor.id)
} catch {
// handled in onError
}
}}
disabled={checkMutation.isPending}
className="p-1 text-gray-400 hover:text-blue-400 transition-colors disabled:opacity-50"
title={t('uptime.triggerHealthCheck')}
>
<RefreshCw size={16} className={checkMutation.isPending ? 'animate-spin' : ''} />
</button>
<div className="relative">
<button
onClick={() => setShowMenu(prev => !prev)}
className="p-1 text-gray-400 hover:text-gray-200 transition-colors"
title={t('uptime.monitorSettings')}
aria-haspopup="menu"
aria-expanded={showMenu}
>
<Settings size={16} />
</button>
{showMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg z-20">
<button
onClick={() => { setShowMenu(false); onEdit(monitor) }}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-900"
>
{t('common.configure')}
</button>
<button
onClick={async () => {
setShowMenu(false)
try {
await toggleMutation.mutateAsync({ id: monitor.id, enabled: !monitor.enabled })
toast.success(monitor.enabled ? t('uptime.paused') : t('uptime.unpaused'))
} catch {
// handled in onError
}
}}
className="w-full text-left px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-gray-900 flex items-center gap-2"
>
<Pause className="w-4 h-4 mr-1" />
{monitor.enabled ? t('uptime.pause') : t('uptime.unpause')}
</button>
<button
onClick={async () => {
setShowMenu(false)
const confirmDelete = confirm(t('uptime.deleteConfirmation'))
if (!confirmDelete) return
try {
await deleteMutation.mutateAsync(monitor.id)
} catch {
// handled in onError
}
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100 dark:hover:bg-gray-900"
>
{t('common.delete')}
</button>
</div>
)}
</div>
</div>
</div>
{/* URL and Type */}
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2 mb-4">
<a href={monitor.url} target="_blank" rel="noreferrer" className="hover:underline">
{monitor.url}
</a>
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-xs">
{monitor.type.toUpperCase()}
</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('uptime.latency')}</div>
<div className="text-lg font-mono font-medium text-gray-900 dark:text-white">
{monitor.latency}ms
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg" data-testid="last-check">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{t('uptime.lastCheck')}</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{monitor.last_check ? formatDistanceToNow(new Date(monitor.last_check), { addSuffix: true }) : t('uptime.never')}
</div>
</div>
</div>
{/* Heartbeat Bar (Last 60 checks / 1 Hour) */}
<div className="flex gap-[2px] h-8 items-end relative" title={t('uptime.last60Checks')} data-testid="heartbeat-bar">
{/* Fill with empty bars if not enough history to keep alignment right-aligned */}
{Array.from({ length: Math.max(0, 60 - (history?.length || 0)) }).map((_, i) => (
<div key={`empty-${i}`} className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-sm h-full opacity-50" />
))}
{history?.slice().reverse().map((beat: { status: string; created_at: string; latency: number; message: string }, i: number) => (
<div
key={i}
className={`flex-1 rounded-sm transition-all duration-200 hover:scale-110 ${
beat.status === 'up'
? 'bg-green-400 dark:bg-green-500 hover:bg-green-300'
: 'bg-red-400 dark:bg-red-500 hover:bg-red-300'
}`}
style={{ height: '100%' }}
title={`${new Date(beat.created_at).toLocaleString()}
Status: ${beat.status.toUpperCase()}
Latency: ${beat.latency}ms
Message: ${beat.message}`}
/>
))}
{(!history || history.length === 0) && (
<div className="absolute w-full text-center text-xs text-gray-400">{t('uptime.noHistoryAvailable')}</div>
)}
</div>
</div>
);
};
const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void; t: (key: string) => string }> = ({ monitor, onClose, t }) => {
const queryClient = useQueryClient();
const [name, setName] = useState(monitor.name || '')
const [maxRetries, setMaxRetries] = useState(monitor.max_retries || 3);
const [interval, setInterval] = useState(monitor.interval || 60);
const mutation = useMutation({
mutationFn: (data: Partial<UptimeMonitor>) => updateMonitor(monitor.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['monitors'] });
onClose();
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate({ name, max_retries: maxRetries, interval });
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg border border-gray-700 max-w-md w-full p-6 shadow-xl">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-white">{t('uptime.configureMonitor')}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white">
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="monitor-name" className="block text-sm font-medium text-gray-300 mb-1">
{t('common.name')}
</label>
<input
id="monitor-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
{t('uptime.maxRetries')}
</label>
<input
type="number"
min="1"
max="10"
value={maxRetries}
onChange={(e) => {
const v = parseInt(e.target.value)
setMaxRetries(Number.isNaN(v) ? 0 : v)
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
{t('uptime.maxRetriesHelper')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
{t('uptime.checkInterval')}
</label>
<input
type="number"
min="10"
max="3600"
value={interval}
onChange={(e) => {
const v = parseInt(e.target.value)
setInterval(Number.isNaN(v) ? 0 : v)
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={mutation.isPending}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{mutation.isPending ? t('common.saving') : t('uptime.saveChanges')}
</button>
</div>
</form>
</div>
</div>
);
};
const CreateMonitorModal: FC<{ onClose: () => void; t: (key: string) => string }> = ({ onClose, t }) => {
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [type, setType] = useState<'http' | 'tcp'>('http');
const [interval, setInterval] = useState(60);
const [maxRetries, setMaxRetries] = useState(3);
const mutation = useMutation({
mutationFn: (data: { name: string; url: string; type: string; interval?: number; max_retries?: number }) =>
createMonitor(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['monitors'] });
toast.success(t('uptime.monitorCreated'));
onClose();
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : t('errors.genericError'));
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!name.trim() || !url.trim()) return;
mutation.mutate({ name: name.trim(), url: url.trim(), type, interval, max_retries: maxRetries });
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-gray-800 rounded-lg border border-gray-700 max-w-md w-full p-6 shadow-xl">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-white">{t('uptime.createMonitor')}</h2>
<button onClick={onClose} className="text-gray-400 hover:text-white" aria-label={t('common.close')}>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="create-monitor-name" className="block text-sm font-medium text-gray-300 mb-1">
{t('common.name')} *
</label>
<input
id="create-monitor-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="My Service"
/>
</div>
<div>
<label htmlFor="create-monitor-url" className="block text-sm font-medium text-gray-300 mb-1">
{t('uptime.monitorUrl')} *
</label>
<input
id="create-monitor-url"
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
required
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={t('uptime.urlPlaceholder')}
/>
</div>
<div>
<label htmlFor="create-monitor-type" className="block text-sm font-medium text-gray-300 mb-1">
{t('uptime.monitorType')} *
</label>
<select
id="create-monitor-type"
value={type}
onChange={(e) => setType(e.target.value as 'http' | 'tcp')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="http">{t('uptime.monitorTypeHttp')}</option>
<option value="tcp">{t('uptime.monitorTypeTcp')}</option>
</select>
</div>
<div>
<label htmlFor="create-monitor-interval" className="block text-sm font-medium text-gray-300 mb-1">
{t('uptime.checkInterval')}
</label>
<input
id="create-monitor-interval"
type="number"
min="10"
max="3600"
value={interval}
onChange={(e) => {
const v = parseInt(e.target.value);
setInterval(Number.isNaN(v) ? 60 : v);
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="create-monitor-retries" className="block text-sm font-medium text-gray-300 mb-1">
{t('uptime.maxRetries')}
</label>
<input
id="create-monitor-retries"
type="number"
min="1"
max="10"
value={maxRetries}
onChange={(e) => {
const v = parseInt(e.target.value);
setMaxRetries(Number.isNaN(v) ? 3 : v);
}}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
{t('uptime.maxRetriesHelper')}
</p>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition-colors"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={mutation.isPending || !name.trim() || !url.trim()}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
{mutation.isPending ? t('common.saving') : t('common.create')}
</button>
</div>
</form>
</div>
</div>
);
};
const Uptime: FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { data: monitors, isLoading } = useQuery({
queryKey: ['monitors'],
queryFn: getMonitors,
refetchInterval: 30000,
});
const [editingMonitor, setEditingMonitor] = useState<UptimeMonitor | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const syncMutation = useMutation({
mutationFn: () => syncMonitors(),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['monitors'] });
toast.success(data.message || t('uptime.syncComplete'));
},
onError: (err: unknown) => {
toast.error(err instanceof Error ? err.message : t('errors.genericError'));
},
});
// Sort monitors alphabetically by name
const sortedMonitors = useMemo(() => {
if (!monitors) return [];
return [...monitors].sort((a, b) =>
(a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase())
);
}, [monitors]);
const proxyHostMonitors = useMemo(() => sortedMonitors.filter(m => m.proxy_host_id), [sortedMonitors]);
const remoteServerMonitors = useMemo(() => sortedMonitors.filter(m => m.remote_server_id), [sortedMonitors]);
const otherMonitors = useMemo(() => sortedMonitors.filter(m => !m.proxy_host_id && !m.remote_server_id), [sortedMonitors]);
if (isLoading) {
return <div className="p-8 text-center">{t('uptime.loadingMonitors')}</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center" data-testid="uptime-summary">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Activity className="w-6 h-6" />
{t('uptime.title')}
</h1>
<div className="flex items-center gap-2">
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
data-testid="sync-button"
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 flex items-center gap-2"
>
<RefreshCw size={16} className={syncMutation.isPending ? 'animate-spin' : ''} />
{syncMutation.isPending ? t('uptime.syncing') : t('uptime.syncWithHosts')}
</button>
<button
onClick={() => setShowCreateModal(true)}
data-testid="add-monitor-button"
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
<Plus size={16} />
{t('uptime.addMonitor')}
</button>
<span className="text-sm text-gray-500">{t('uptime.autoRefreshing')}</span>
</div>
</div>
{sortedMonitors.length === 0 ? (
<div className="text-center py-12 text-gray-500">
{t('uptime.noMonitorsFound')}
</div>
) : (
<>
{proxyHostMonitors.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4 border-b border-gray-200 dark:border-gray-700 pb-2">{t('uptime.proxyHosts')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{proxyHostMonitors.map((monitor) => (
<MonitorCard key={monitor.id} monitor={monitor} onEdit={setEditingMonitor} t={t} />
))}
</div>
</div>
)}
{remoteServerMonitors.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4 border-b border-gray-200 dark:border-gray-700 pb-2">{t('uptime.remoteServers')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{remoteServerMonitors.map((monitor) => (
<MonitorCard key={monitor.id} monitor={monitor} onEdit={setEditingMonitor} t={t} />
))}
</div>
</div>
)}
{otherMonitors.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-800 dark:text-gray-200 mb-4 border-b border-gray-200 dark:border-gray-700 pb-2">{t('uptime.otherMonitors')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{otherMonitors.map((monitor) => (
<MonitorCard key={monitor.id} monitor={monitor} onEdit={setEditingMonitor} t={t} />
))}
</div>
</div>
)}
</>
)}
{editingMonitor && (
<EditMonitorModal monitor={editingMonitor} onClose={() => setEditingMonitor(null)} t={t} />
)}
{showCreateModal && (
<CreateMonitorModal onClose={() => setShowCreateModal(false)} t={t} />
)}
</div>
);
};
export default Uptime;