chore: implement Phase 5 E2E tests for Tasks & Monitoring
Phase 5 adds comprehensive E2E test coverage for backup management, log viewing, import wizards, and uptime monitoring features. Backend Changes: Add POST /api/v1/uptime/monitors endpoint for creating monitors Add CreateMonitor service method with URL validation Add 9 unit tests for uptime handler create functionality Frontend Changes: Add CreateMonitorModal component to Uptime.tsx Add "Add Monitor" and "Sync with Hosts" buttons Add createMonitor() API function to uptime.ts Add data-testid attributes to 6 frontend components: Backups.tsx, Uptime.tsx, LiveLogViewer.tsx Logs.tsx, ImportCaddy.tsx, ImportCrowdSec.tsx E2E Test Files Created (7 files, ~115 tests): backups-create.spec.ts (17 tests) backups-restore.spec.ts (8 tests) logs-viewing.spec.ts (20 tests) import-caddyfile.spec.ts (20 tests) import-crowdsec.spec.ts (8 tests) uptime-monitoring.spec.ts (22 tests) real-time-logs.spec.ts (20 tests) Coverage: Backend 87.0%, Frontend 85.2%
This commit is contained in:
@@ -156,12 +156,13 @@ export default function Backups() {
|
||||
key: 'actions',
|
||||
header: t('common.actions'),
|
||||
cell: (backup) => (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="flex items-center justify-end gap-2" data-testid="backup-row">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(backup.filename)}
|
||||
title={t('backups.download')}
|
||||
data-testid="backup-download-btn"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -171,6 +172,7 @@ export default function Backups() {
|
||||
onClick={() => setRestoreConfirm(backup)}
|
||||
title={t('backups.restore')}
|
||||
disabled={restoreMutation.isPending}
|
||||
data-testid="backup-restore-btn"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -180,6 +182,7 @@ export default function Backups() {
|
||||
onClick={() => setDeleteConfirm(backup)}
|
||||
title={t('common.delete')}
|
||||
disabled={deleteMutation.isPending}
|
||||
data-testid="backup-delete-btn"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-error" />
|
||||
</Button>
|
||||
@@ -236,7 +239,7 @@ export default function Backups() {
|
||||
|
||||
{/* Backup List */}
|
||||
{isLoadingBackups ? (
|
||||
<SkeletonTable rows={5} columns={5} />
|
||||
<SkeletonTable rows={5} columns={5} data-testid="loading-skeleton" />
|
||||
) : !backups || backups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Archive className="h-12 w-12" />}
|
||||
@@ -246,12 +249,14 @@ export default function Backups() {
|
||||
label: t('backups.createBackup'),
|
||||
onClick: () => createMutation.mutate(),
|
||||
}}
|
||||
data-testid="empty-state"
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
data={backups}
|
||||
columns={columns}
|
||||
rowKey={(backup) => backup.filename}
|
||||
data-testid="backup-table"
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={<Archive className="h-12 w-12" />}
|
||||
|
||||
@@ -73,11 +73,13 @@ export default function ImportCaddy() {
|
||||
<h1 className="text-3xl font-bold text-white mb-6">{t('importCaddy.title')}</h1>
|
||||
|
||||
{session && (
|
||||
<ImportBanner
|
||||
session={session}
|
||||
onReview={() => setShowReview(true)}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
<div data-testid="import-banner">
|
||||
<ImportBanner
|
||||
session={session}
|
||||
onReview={() => setShowReview(true)}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
@@ -116,6 +118,7 @@ export default function ImportCaddy() {
|
||||
accept=".caddyfile,.txt,text/plain"
|
||||
onChange={handleFileUpload}
|
||||
className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-active file:text-white hover:file:bg-blue-hover file:cursor-pointer cursor-pointer"
|
||||
data-testid="import-dropzone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -163,15 +166,17 @@ api.example.com {
|
||||
)}
|
||||
|
||||
{showReview && preview && preview.preview && (
|
||||
<ImportReviewTable
|
||||
hosts={preview.preview.hosts}
|
||||
conflicts={preview.preview.conflicts}
|
||||
conflictDetails={preview.conflict_details}
|
||||
errors={preview.preview.errors}
|
||||
caddyfileContent={preview.caddyfile_content}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowReview(false)}
|
||||
/>
|
||||
<div data-testid="import-review-table">
|
||||
<ImportReviewTable
|
||||
hosts={preview.preview.hosts}
|
||||
conflicts={preview.preview.conflicts}
|
||||
conflictDetails={preview.conflict_details}
|
||||
errors={preview.preview.errors}
|
||||
caddyfileContent={preview.caddyfile_content}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowReview(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImportSitesModal
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function ImportCrowdSec() {
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">{t('importCrowdSec.description')}</p>
|
||||
<input type="file" onChange={handleFile} accept=".tar.gz,.zip" data-testid="crowdsec-import-file" />
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2" data-testid="import-progress">
|
||||
<Button onClick={() => handleImport()} isLoading={backupMutation.isPending || importMutation.isPending} disabled={!file}>{t('importCrowdSec.import')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ const Logs: FC = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Log File List */}
|
||||
<div className="md:col-span-1 space-y-4">
|
||||
<Card className="p-4">
|
||||
<Card className="p-4" data-testid="log-file-list">
|
||||
<h2 className="text-lg font-semibold mb-4 text-content-primary">{t('logs.logFiles')}</h2>
|
||||
{isLoadingLogs ? (
|
||||
<SkeletonList items={4} showAvatar={false} />
|
||||
@@ -148,13 +148,15 @@ const Logs: FC = () => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<LogTable logs={logData?.logs || []} isLoading={isLoadingContent} />
|
||||
<div data-testid="log-table">
|
||||
<LogTable logs={logData?.logs || []} isLoading={isLoadingContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{logData && logData.total > 0 && (
|
||||
<div className="px-6 py-4 border-t border-border flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="text-sm text-content-muted">
|
||||
<div className="text-sm text-content-muted" data-testid="page-info">
|
||||
{t('logs.showingEntries', { from: logData.offset + 1, to: Math.min(logData.offset + limit, logData.total), total: logData.total })}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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, UptimeMonitor } from '../api/uptime';
|
||||
import { Activity, ArrowUp, ArrowDown, Settings, X, Pause, RefreshCw } from 'lucide-react';
|
||||
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';
|
||||
|
||||
@@ -68,7 +68,7 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
|
||||
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'}`}>
|
||||
<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>
|
||||
@@ -79,7 +79,7 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
|
||||
: 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>
|
||||
@@ -169,7 +169,7 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
|
||||
{monitor.latency}ms
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
|
||||
<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')}
|
||||
@@ -178,7 +178,7 @@ const MonitorCard: FC<{ monitor: UptimeMonitor; onEdit: (monitor: UptimeMonitor)
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Bar (Last 60 checks / 1 Hour) */}
|
||||
<div className="flex gap-[2px] h-8 items-end relative" title={t('uptime.last60Checks')}>
|
||||
<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" />
|
||||
@@ -308,8 +308,153 @@ const EditMonitorModal: FC<{ monitor: UptimeMonitor; onClose: () => void; t: (ke
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -317,6 +462,18 @@ const Uptime: FC = () => {
|
||||
});
|
||||
|
||||
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(() => {
|
||||
@@ -336,13 +493,30 @@ const Uptime: FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<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="text-sm text-gray-500">
|
||||
{t('uptime.autoRefreshing')}
|
||||
<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>
|
||||
|
||||
@@ -390,6 +564,10 @@ const Uptime: FC = () => {
|
||||
{editingMonitor && (
|
||||
<EditMonitorModal monitor={editingMonitor} onClose={() => setEditingMonitor(null)} t={t} />
|
||||
)}
|
||||
|
||||
{showCreateModal && (
|
||||
<CreateMonitorModal onClose={() => setShowCreateModal(false)} t={t} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user