码桶
发现社区成员的开源项目
MonitorsModal.jsx27.4 KB
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Activity, Trash2, RefreshCw, X, AlertCircle, Plus, CheckCircle, HelpCircle } from 'lucide-react';
import { ApiClient, ApiError } from '../utils/api.js';
import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { useTheme } from '../contexts/ThemeContext.jsx';
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'CAA'];
const MONITOR_TYPES = [
{ value: 'dns_record', label: 'DNS Record' },
{ value: 'traffic_spike', label: 'Traffic Spike' },
{ value: 'error_rate', label: 'Error Rate' },
{ value: 'ssl_expiry', label: 'SSL Expiry' },
];
const StatusBadge = ({ status }) => {
const colors = {
ok: { bg: 'var(--success)', text: '#fff', label: 'OK' },
fail: { bg: 'var(--error)', text: '#fff', label: 'FAIL' },
unknown: { bg: 'var(--text-muted)', text: '#fff', label: '?' }
};
const c = colors[status] || colors.unknown;
return (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.6rem', fontWeight: 700, padding: '2px 8px',
borderRadius: '10px', background: c.bg, color: c.text,
lineHeight: 1.4, letterSpacing: '0.03em', minWidth: '36px', textAlign: 'center'
}}>
{c.label}
</span>
);
};
const TypeBadge = ({ type }) => {
const meta = MONITOR_TYPES.find(m => m.value === type) || { label: type || 'DNS' };
const colors = {
dns_record: { bg: 'var(--badge-blue-bg)', text: 'var(--badge-blue-text)' },
traffic_spike: { bg: 'var(--badge-orange-bg)', text: 'var(--badge-orange-text)' },
error_rate: { bg: 'var(--error-bg)', text: 'var(--error)' },
ssl_expiry: { bg: 'var(--badge-green-bg)', text: 'var(--badge-green-text)' },
};
const c = colors[type] || colors.dns_record;
return (
<span style={{
display: 'inline-flex', fontSize: '0.6rem', fontWeight: 600,
padding: '1px 6px', borderRadius: '4px',
background: c.bg, color: c.text
}}>
{meta.label}
</span>
);
};
const MonitorsModal = ({ show, onClose, zones }) => {
const { auth } = useAuth();
const { showToast } = useToast();
const { t } = useTheme();
const [monitors, setMonitors] = useState([]);
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
// Form state
const [formMonitorType, setFormMonitorType] = useState('dns_record');
const [formZoneId, setFormZoneId] = useState('');
const [formRecordType, setFormRecordType] = useState('A');
const [formRecordName, setFormRecordName] = useState('');
const [formExpectedContent, setFormExpectedContent] = useState('');
const [formThreshold, setFormThreshold] = useState('10000');
const [formTimeWindow, setFormTimeWindow] = useState('1h');
const [formStatusType, setFormStatusType] = useState('5xx');
const [formDaysBeforeExpiry, setFormDaysBeforeExpiry] = useState('30');
const [formSubmitting, setFormSubmitting] = useState(false);
const authRef = useRef(auth);
authRef.current = auth;
const apiRef = useRef(null);
if (!apiRef.current) {
apiRef.current = new ApiClient(() => authRef.current);
}
const fetchMonitors = useCallback(async () => {
setLoading(true);
try {
const data = await apiRef.current.get('/api/monitors');
setMonitors(data.monitors || []);
} catch (err) {
if (err instanceof ApiError) {
console.error(`Failed to fetch monitors (${err.status}):`, err.message);
} else {
console.error('Failed to fetch monitors:', err);
}
}
setLoading(false);
}, []);
useEffect(() => {
if (show) {
fetchMonitors();
}
}, [show, fetchMonitors]);
useEffect(() => {
if (!show) return;
const interval = setInterval(() => {
fetchMonitors();
}, 60000);
return () => clearInterval(interval);
}, [show, fetchMonitors]);
const handleDelete = async (id) => {
try {
await apiRef.current.del(`/api/monitors?id=${id}`);
showToast(t('monitorDeleted'));
setMonitors(prev => prev.filter(m => m.id !== id));
} catch (err) {
if (err instanceof ApiError) {
showToast(err.message || t('errorOccurred'), 'error');
} else {
showToast(t('errorOccurred'), 'error');
}
}
};
const resetForm = () => {
setFormMonitorType('dns_record');
setFormZoneId('');
setFormRecordType('A');
setFormRecordName('');
setFormExpectedContent('');
setFormThreshold('10000');
setFormTimeWindow('1h');
setFormStatusType('5xx');
setFormDaysBeforeExpiry('30');
};
const handleAdd = async (e) => {
e.preventDefault();
if (!formZoneId) return;
const selectedZone = (zones || []).find(z => z.id === formZoneId);
setFormSubmitting(true);
try {
const payload = {
zoneId: formZoneId,
zoneName: selectedZone?.name || '',
monitorType: formMonitorType,
};
if (formMonitorType === 'dns_record') {
if (!formRecordName || !formExpectedContent) { setFormSubmitting(false); return; }
payload.recordType = formRecordType;
payload.recordName = formRecordName;
payload.expectedContent = formExpectedContent;
} else if (formMonitorType === 'traffic_spike') {
payload.threshold = parseInt(formThreshold) || 10000;
payload.timeWindow = formTimeWindow;
} else if (formMonitorType === 'error_rate') {
payload.threshold = parseInt(formThreshold) || 5;
payload.statusType = formStatusType;
} else if (formMonitorType === 'ssl_expiry') {
payload.daysBeforeExpiry = parseInt(formDaysBeforeExpiry) || 30;
}
const data = await apiRef.current.post('/api/monitors', payload);
showToast(t('monitorAdded'));
setMonitors(prev => [...prev, data.monitor]);
setShowForm(false);
resetForm();
} catch (err) {
if (err instanceof ApiError) {
showToast(err.message || t('errorOccurred'), 'error');
} else {
showToast(t('errorOccurred'), 'error');
}
}
setFormSubmitting(false);
};
const formatDate = (iso) => {
if (!iso) return '-';
try {
const d = new Date(iso);
return d.toLocaleString();
} catch {
return iso;
}
};
const getMonitorDescription = (monitor) => {
const mt = monitor.monitorType || 'dns_record';
if (mt === 'dns_record') {
return `${monitor.zoneName || monitor.zoneId} — ${t('monitorExpected')}: ${monitor.expectedContent}`;
}
if (mt === 'traffic_spike') {
return `${monitor.zoneName || monitor.zoneId} — ${t('monitorThreshold') || 'Threshold'}: ${monitor.threshold?.toLocaleString()} / ${monitor.timeWindow || '1h'}`;
}
if (mt === 'error_rate') {
return `${monitor.zoneName || monitor.zoneId} — ${monitor.statusType || '5xx'} > ${monitor.threshold}%`;
}
if (mt === 'ssl_expiry') {
return `${monitor.zoneName || monitor.zoneId} — ${t('monitorDaysBefore') || 'Alert'} ${monitor.daysBeforeExpiry || 30} ${t('monitorDaysBeforeUnit') || 'days before expiry'}`;
}
return monitor.zoneName || monitor.zoneId;
};
const getMonitorTitle = (monitor) => {
const mt = monitor.monitorType || 'dns_record';
if (mt === 'dns_record') return `${monitor.recordType} ${monitor.recordName}`;
if (mt === 'traffic_spike') return t('monitorTrafficSpike') || 'Traffic Spike';
if (mt === 'error_rate') return t('monitorErrorRate') || 'Error Rate';
if (mt === 'ssl_expiry') return t('monitorSslExpiry') || 'SSL Expiry';
return mt;
};
const failedCount = monitors.filter(m => m.lastStatus === 'fail').length;
if (!show) return null;
return (
<div
className="modal-overlay"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="glass-card fade-in modal-content" role="dialog" aria-label={t('monitors')} style={{ padding: '2rem', maxWidth: '620px', width: '90%', position: 'relative' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '8px', fontSize: '1.1rem' }}>
<Activity size={20} />
{t('monitors')}
{failedCount > 0 && (
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: '0.65rem', fontWeight: 700, padding: '1px 7px',
borderRadius: '10px', background: 'var(--error)', color: '#fff',
marginLeft: '4px'
}}>
{failedCount}
</span>
)}
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={() => setShowForm(!showForm)}
style={{
border: 'none', background: 'var(--primary)', color: '#fff',
cursor: 'pointer', padding: '5px 10px', display: 'flex',
alignItems: 'center', gap: '4px', borderRadius: '6px',
fontSize: '0.75rem', fontWeight: 600, transition: 'opacity 0.2s'
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={14} />
{t('monitorAdd')}
</button>
<button
onClick={onClose}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', padding: '4px', display: 'flex' }}
aria-label="Close"
>
<X size={18} color="var(--text-muted)" />
</button>
</div>
</div>
{/* Add Monitor Form */}
{showForm && (
<form onSubmit={handleAdd} style={{
padding: '1rem', borderRadius: '8px', background: 'var(--hover-bg)',
marginBottom: '1rem', border: '1px solid var(--border)'
}}>
{/* Monitor Type Selector */}
<div style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
{MONITOR_TYPES.map(mt => (
<button key={mt.value} type="button"
className={`btn ${formMonitorType === mt.value ? 'btn-primary' : 'btn-outline'}`}
style={{ padding: '3px 10px', fontSize: '0.7rem', fontWeight: 600 }}
onClick={() => setFormMonitorType(mt.value)}>
{mt.label}
</button>
))}
</div>
{/* Zone selector - always needed */}
<div style={{ marginBottom: '0.5rem' }}>
<label style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '3px' }}>
{t('monitorZone')}
</label>
<select
value={formZoneId}
onChange={e => setFormZoneId(e.target.value)}
style={{ width: '100%', fontSize: '0.8rem', padding: '6px 8px' }}
required
>
<option value="">{t('monitorSelectZone')}</option>
{(zones || []).map(z => (
<option key={z.id} value={z.id}>{z.name}</option>
))}
</select>
</div>
{/* Conditional fields per monitor type */}
{formMonitorType === 'dns_record' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', marginBottom: '0.5rem' }}>
<div>
<label style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '3px' }}>
{t('type')}
</label>
<select
value={formRecordType}
onChange={e => setFormRecordType(e.target.value)}
style={{ width: '100%', fontSize: '0.8rem', padding: '6px 8px' }}
required
>
{RECORD_TYPES.map(rt => (
<option key={rt} value={rt}>{rt}</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '3px' }}>
{t('monitorRecordName')}
</label>
<input
type="text"
value={formRecordName}
onChange={e => setFormRecordName(e.target.value)}
placeholder="www.example.com"
style={{ width: '100%', fontSize: '0.8rem', padding: '6px 8px' }}
required
/>
</div>
</div>
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '3px' }}>
{t('monitorExpectedContent')}
</label>
<input
type="text"
value={formExpectedContent}
onChange={e => setFormExpectedContent(e.target.value)}
placeholder="1.2.3.4"
style={{ width: '100%', fontSize: '0.8rem', padding: '6px 8px' }}
required
/>
</div>
</>
)}
{formMonitorType === 'traffic_spike' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', marginBottom: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '3px' }}>
{t('monitorThreshold') || 'Threshold (requests)'}
</label>
<input type="number" value={formThreshold} onChange={e => setFormThreshold(e.target.value)}
placeholder="10000" min="1"
style={{ width: '100%', fontSize: '0.8rem', padding: '6px 8px' }} required />
</div>
<div>
<label style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '3px' }}>
{t('monitorTimeWindow') || 'Time Window'}
</label>
<select value={formTimeWindow} onChange={e => setFormTimeWindow(e.target.value)}
style={{ width: '100%', fontSize: '0.8rem', padding: '6px 8px' }}>
<option value="1h">1 hour</option>
<option value="24h">24 hours</option>
</select>
</div>
</div>
)}
{formMonitorType === 'error_rate' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem', marginBottom: '0.75rem' }}>
<div>
<label style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '3px' }}>
{t('monitorThreshold') || 'Threshold (%)'}
</label>
<input type="number" value={formThreshold} onChange={e => setFormThreshold(e.target.value)}
placeholder="5" min="1" max="100"
style={{ width: '100%', fontSize: '0.8rem', padding: '6px 8px' }} required />
</div>
<div>
<label style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '3px' }}>
{t('monitorStatusType') || 'Status Type'}
</label>
<select value={formStatusType} onChange={e => setFormStatusType(e.target.value)}
style={{ width: '100%', fontSize: '0.8rem', padding: '6px 8px' }}>
<option value="4xx">4xx</option>
<option value="5xx">5xx</option>
<option value="both">4xx + 5xx</option>
</select>
</div>
</div>
)}
{formMonitorType === 'ssl_expiry' && (
<div style={{ marginBottom: '0.75rem' }}>
<label style={{ fontSize: '0.7rem', fontWeight: 600, color: 'var(--text-muted)', display: 'block', marginBottom: '3px' }}>
{t('monitorDaysBefore') || 'Days before expiry'}
</label>
<input type="number" value={formDaysBeforeExpiry} onChange={e => setFormDaysBeforeExpiry(e.target.value)}
placeholder="30" min="1" max="365"
style={{ width: '100%', fontSize: '0.8rem', padding: '6px 8px' }} required />
</div>
)}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-outline" onClick={() => { setShowForm(false); resetForm(); }} style={{ fontSize: '0.75rem', padding: '5px 12px' }}>
{t('cancel')}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={formSubmitting || !formZoneId}
style={{ fontSize: '0.75rem', padding: '5px 12px', display: 'flex', alignItems: 'center', gap: '4px' }}
>
{formSubmitting ? <RefreshCw className="spin" size={13} /> : <Plus size={13} />}
{t('monitorAdd')}
</button>
</div>
</form>
)}
{/* Monitors List */}
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<RefreshCw className="spin" size={24} style={{ color: 'var(--primary)' }} />
</div>
) : monitors.length === 0 ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>
<HelpCircle size={32} style={{ marginBottom: '0.5rem', opacity: 0.5 }} />
<p style={{ fontSize: '0.875rem' }}>{t('monitorNone')}</p>
<p style={{ fontSize: '0.75rem', marginTop: '0.25rem' }}>{t('monitorNoneHint')}</p>
</div>
) : (
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
{monitors.map(monitor => (
<div key={monitor.id} style={{
padding: '0.75rem',
borderRadius: '8px',
background: 'var(--hover-bg)',
marginBottom: '0.5rem',
border: `1px solid ${monitor.lastStatus === 'fail' ? 'var(--error)' : 'var(--border)'}`,
opacity: monitor.enabled ? 1 : 0.6
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px', flexWrap: 'wrap' }}>
<StatusBadge status={monitor.lastStatus} />
<TypeBadge type={monitor.monitorType || 'dns_record'} />
{monitor.recordType && (
<span className="badge badge-blue" style={{ fontSize: '0.65rem', padding: '1px 6px' }}>
{monitor.recordType}
</span>
)}
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text)' }}>
{getMonitorTitle(monitor)}
</span>
</div>
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginBottom: '3px' }}>
{getMonitorDescription(monitor)}
</div>
{monitor.lastCheck && (
<div style={{ fontSize: '0.65rem', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: '4px' }}>
{monitor.lastStatus === 'ok' ? (
<CheckCircle size={10} color="var(--success)" />
) : monitor.lastStatus === 'fail' ? (
<AlertCircle size={10} color="var(--error)" />
) : null}
{t('monitorLastCheck')}: {formatDate(monitor.lastCheck)}
</div>
)}
{monitor.lastStatus === 'fail' && monitor.lastError && (
<div style={{
fontSize: '0.65rem', color: 'var(--error)',
marginTop: '3px', padding: '3px 6px',
background: 'var(--error-bg)', borderRadius: '4px'
}}>
{monitor.lastError}
</div>
)}
</div>
<button
onClick={() => handleDelete(monitor.id)}
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
padding: '4px', display: 'flex', flexShrink: 0,
color: 'var(--text-muted)', transition: 'color 0.2s'
}}
onMouseEnter={e => e.currentTarget.style.color = 'var(--error)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
title={t('monitorDelete')}
aria-label={t('monitorDelete')}
>
<Trash2 size={15} />
</button>
</div>
</div>
))}
</div>
)}
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)' }}>
{t('monitorAutoRefresh')}
</span>
<button className="btn btn-outline" onClick={onClose} style={{ fontSize: '0.75rem' }}>{t('cancel')}</button>
</div>
</div>
</div>
);
};
export default MonitorsModal;