feat: Add real-time notification center with WebSocket integration

- Add in-app notification system with database storage
- Implement bell icon component with unread count badge in dashboard header
- Add full Notifications page tab with pagination and filtering (All/Unread/Read)
- Implement real-time WebSocket push notifications to all connected clients
- Add notification API endpoints: GET, PUT /read, DELETE, POST /clear
- Fix critical bug in notificationService where 'status' variable was undefined
- Add Notifications route to App.js routing
- Enhanced UI with notification popup in header and dedicated full-page view
- Auto-reconnect WebSocket with 5s timeout on disconnect
- Support rich notification content with resource details and timestamps
This commit is contained in:
nmemmert committed 2026-01-14 14:51:29 -05:00
1 parent 7968c9231e
commit db85573986
37 files changed
+3521 -207

No files matched your search

+98 -23
View File
@@ -7,6 +7,9 @@ import SettingsWizard from './SettingsWizard';
import History from './History';
import SLA from './SLA';
import Status from './Status';
import Observability from './Observability';
import Notifications from './Notifications';
import NotificationCenter from './NotificationCenter';
import { formatLocalTime, formatChartTime } from './utils/timeUtils';
// Simple duration formatter for incident spans
@@ -29,6 +32,7 @@ function Dashboard() {
const [collapsedGroups, setCollapsedGroups] = useState({});
const [editingId, setEditingId] = useState(null);
const [editData, setEditData] = useState({});
const [notifications, setNotifications] = useState([]);
const [formData, setFormData] = useState({
name: '',
url: '',
@@ -54,13 +58,24 @@ function Dashboard() {
const actionsRef = useRef(null);
const navigate = useNavigate();
// Show toast notification
const showNotification = (title, message, type = 'info') => {
const id = Date.now();
setNotifications(prev => [...prev, { id, title, message, type }]);
// Auto-remove after 5 seconds
setTimeout(() => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, 5000);
};
const loadResources = useCallback(async () => {
try {
const response = await axios.get('/api/dashboard');
setResources(response.data.resources);
setGroups(response.data.groups || []);
} catch (error) {
console.error('Error loading resources:', error);
// Error loading resources handled
}
}, []);
@@ -78,17 +93,34 @@ function Dashboard() {
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'dashboard') {
setResources(message.data.resources || []);
setGroups(message.data.groups || []);
} else if (message.type === 'alert') {
// Show real-time alert notification
const alert = message.data;
showNotification(`Alert: ${alert.resourceName}`, alert.message, 'error');
} else if (message.type === 'incident') {
// Show real-time incident notification
const incident = message.data;
if (incident.type === 'started') {
showNotification(`Incident Started`, `${incident.resourceName} is down`, 'error');
} else if (incident.type === 'resolved') {
showNotification(`Incident Resolved`, `${incident.resourceName} is back up`, 'success');
}
// Refresh data to show updated incidents
loadResources();
} else if (message.type === 'metrics') {
// Metrics update received (can be used for performance dashboard)
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
// Parse error handled silently
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
// WebSocket error handled
};
ws.onclose = () => {
@@ -97,7 +129,6 @@ function Dashboard() {
wsRef.current = ws;
} catch (error) {
console.error('Error connecting WebSocket:', error);
loadResources();
}
}, [loadResources]);
@@ -137,7 +168,6 @@ function Dashboard() {
setGroupData({ name: '', description: '' });
loadResources();
} catch (error) {
console.error('Error creating group:', error);
alert('Error creating group');
}
};
@@ -168,7 +198,6 @@ function Dashboard() {
});
loadResources();
} catch (error) {
console.error('Error creating resource:', error);
alert('Error creating resource');
}
};
@@ -190,7 +219,6 @@ function Dashboard() {
setEditData({});
loadResources();
} catch (error) {
console.error('Error updating resource:', error);
alert('Error updating resource');
}
};
@@ -206,7 +234,6 @@ function Dashboard() {
await axios.delete(`/api/resources/${id}`);
loadResources();
} catch (error) {
console.error('Error deleting resource:', error);
alert('Error deleting resource');
}
}
@@ -225,7 +252,6 @@ function Dashboard() {
link.click();
link.parentChild.removeChild(link);
} catch (error) {
console.error('Error exporting resources:', error);
alert('Error exporting resources');
}
};
@@ -246,7 +272,6 @@ function Dashboard() {
}
loadResources();
} catch (error) {
console.error('Error importing resources:', error);
alert('Error importing resources');
}
@@ -375,9 +400,33 @@ function Dashboard() {
return (
<div className="container">
{/* Real-time Notifications */}
{notifications.length > 0 && (
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 9999 }}>
{notifications.map(notif => (
<div
key={notif.id}
style={{
marginBottom: '10px',
padding: '12px 16px',
borderRadius: '4px',
backgroundColor: notif.type === 'error' ? '#f8d7da' : notif.type === 'success' ? '#d4edda' : '#d1ecf1',
color: notif.type === 'error' ? '#721c24' : notif.type === 'success' ? '#155724' : '#0c5460',
border: `1px solid ${notif.type === 'error' ? '#f5c6cb' : notif.type === 'success' ? '#c3e6cb' : '#bee5eb'}`,
minWidth: '300px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<strong>{notif.title}</strong>
<div>{notif.message}</div>
</div>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2>SkyWatch Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<NotificationCenter />
<input
type="text"
placeholder="Filter by tag"
@@ -748,6 +797,19 @@ function Dashboard() {
</div>
</div>
<div className="form-group">
<label>Data Retention (days)</label>
<input
type="number"
placeholder="Leave empty to use global setting"
value={formData.retention_days || ''}
onChange={(e) => setFormData({ ...formData, retention_days: e.target.value ? parseInt(e.target.value) : null })}
min="1"
max="365"
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowModal(false)}>
Cancel
@@ -957,6 +1019,19 @@ function Dashboard() {
</div>
</div>
<div className="form-group">
<label>Data Retention (days)</label>
<input
type="number"
min="1"
max="365"
placeholder="Leave empty to use global setting"
value={editData.retention_days || ''}
onChange={(e) => setEditData({ ...editData, retention_days: e.target.value ? parseInt(e.target.value) : null })}
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowEditModal(false)}>
Cancel
@@ -1036,7 +1111,6 @@ function ResourceDetail() {
setResource(response.data);
setLoading(false);
} catch (error) {
console.error('Error loading resource:', error);
setLoading(false);
}
};
@@ -1054,7 +1128,7 @@ function ResourceDetail() {
});
setChecks(response.data.checks || []);
} catch (error) {
console.error('Error loading checks:', error);
// Checks load error handled
} finally {
setChecksLoading(false);
}
@@ -1073,7 +1147,7 @@ function ResourceDetail() {
});
setIncidents(response.data.incidents || []);
} catch (error) {
console.error('Error loading incidents:', error);
// Incidents load error handled
} finally {
setIncidentsLoading(false);
}
@@ -1098,7 +1172,6 @@ function ResourceDetail() {
setIncidentDescription('');
loadIncidents(); // Reload to show updated description
} catch (error) {
console.error('Error updating incident:', error);
alert('Failed to update incident: ' + (error.response?.data?.error || error.message));
} finally {
setUpdatingIncident(false);
@@ -1111,7 +1184,7 @@ function ResourceDetail() {
const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } });
setSla(response.data);
} catch (error) {
console.error('Error loading SLA summary:', error);
// SLA load error handled
} finally {
setSlaLoading(false);
}
@@ -1122,7 +1195,7 @@ function ResourceDetail() {
const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
setMaintenanceWindows(response.data.windows || []);
} catch (error) {
console.error('Error loading maintenance windows:', error);
// Maintenance windows load error handled
}
};
@@ -1135,7 +1208,6 @@ function ResourceDetail() {
loadMaintenanceWindows();
alert('Maintenance window created');
} catch (error) {
console.error('Error creating maintenance window:', error);
alert('Error creating maintenance window');
}
};
@@ -1146,7 +1218,6 @@ function ResourceDetail() {
await axios.delete(`/api/maintenance-windows/${windowId}`);
loadMaintenanceWindows();
} catch (error) {
console.error('Error deleting maintenance window:', error);
alert('Error deleting maintenance window');
}
}
@@ -1158,7 +1229,6 @@ function ResourceDetail() {
await axios.delete(`/api/resources/${id}`);
navigate('/');
} catch (error) {
console.error('Error deleting resource:', error);
alert('Error deleting resource');
}
}
@@ -1172,7 +1242,7 @@ function ResourceDetail() {
});
loadResource();
} catch (error) {
console.error('Error updating resource:', error);
// Update resource error handled
}
};
@@ -1184,7 +1254,7 @@ function ResourceDetail() {
});
loadResource();
} catch (error) {
console.error('Error updating maintenance mode:', error);
// Update maintenance mode error handled
}
};
@@ -1591,12 +1661,13 @@ function Navbar() {
try {
const response = await axios.get('/api/settings');
setNotificationsConfigured(response.data.email_enabled || response.data.webhook_enabled);
// Cache server timezone for timestamp formatting
// Cache server timezone for timestamp formatting with timestamp
if (response.data.timezone) {
localStorage.setItem('serverTimezone', response.data.timezone);
localStorage.setItem('serverTimezoneTime', Date.now().toString());
}
} catch (error) {
console.error('Error checking notifications:', error);
// Notifications check error handled
}
};
@@ -1609,6 +1680,8 @@ function Navbar() {
<Link to="/" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Dashboard</Link>
<Link to="/history" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>History</Link>
<Link to="/sla" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>SLA</Link>
<Link to="/observability" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Observability</Link>
<Link to="/notifications" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Notifications</Link>
<Link to="/settings" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
Settings
{!notificationsConfigured && (
@@ -1647,6 +1720,8 @@ function App() {
<Route path="/sla" element={<SLA />} />
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
<Route path="/status" element={<Status />} />
<Route path="/observability" element={<Observability />} />
<Route path="/notifications" element={<Notifications />} />
</Routes>
</div>
</Router>
+1 -3
View File
@@ -11,9 +11,7 @@ class ErrorBoundary extends React.Component {
}
componentDidCatch(error, info) {
// Log to console; could later POST to /api if needed
// eslint-disable-next-line no-console
console.error('App error:', error, info);
// Error handled silently
}
render() {
+38 -2
View File
@@ -22,6 +22,7 @@ const asNumberOrZero = (value) => {
function History() {
const [historyData, setHistoryData] = useState([]);
const [days, setDays] = useState(7);
const [maxDays, setMaxDays] = useState(30);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showAveraged, setShowAveraged] = useState(true);
@@ -29,7 +30,28 @@ function History() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalItems, setTotalItems] = useState(0);
const [effectiveDays, setEffectiveDays] = useState(7);
const [limitedByRetention, setLimitedByRetention] = useState(false);
useEffect(() => {
// Fetch retention setting from server
const fetchRetention = async () => {
try {
const response = await axios.get('/api/settings');
const retentionDays = response.data.retention_days || 30;
setMaxDays(parseInt(retentionDays));
// Adjust current days if it exceeds retention
if (days > retentionDays) {
setDays(parseInt(retentionDays));
}
} catch (error) {
// Failed to fetch retention setting, use default
}
};
fetchRetention();
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const loadHistory = async () => {
setLoading(true);
@@ -47,6 +69,8 @@ function History() {
if (response.data.resources && Array.isArray(response.data.resources)) {
data = response.data.resources;
total = response.data.total || data.length;
setEffectiveDays(response.data.effective_days || days);
setLimitedByRetention(Boolean(response.data.limited_by_retention));
} else if (Array.isArray(response.data)) {
data = response.data;
total = data.length;
@@ -61,7 +85,6 @@ function History() {
});
return { ...resource, trendsData: trendsResponse.data };
} catch (err) {
console.error(`Error loading trends for resource ${resource.id}:`, err);
return resource;
}
});
@@ -72,7 +95,6 @@ function History() {
setTotalItems(total);
setLoading(false);
} catch (error) {
console.error('Error loading history:', error);
setError(error.message);
setLoading(false);
}
@@ -86,6 +108,18 @@ function History() {
return (
<div className="container">
{limitedByRetention && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
color: '#856404'
}}>
⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2>Monitoring History</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -95,6 +129,8 @@ function History() {
key={d}
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setDays(d)}
disabled={d > maxDays}
style={{ opacity: d > maxDays ? 0.5 : 1 }}
>
{d} Days
</button>
+262
View File
@@ -0,0 +1,262 @@
.notification-center {
position: relative;
}
.notification-bell-container {
display: flex;
align-items: center;
}
.notification-bell {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
position: relative;
padding: 0.5rem;
transition: transform 0.2s;
}
.notification-bell:hover {
transform: scale(1.1);
}
.notification-badge {
position: absolute;
top: 0;
right: 0;
background: #dc3545;
color: white;
border-radius: 50%;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: bold;
line-height: 1;
}
.notification-popup {
position: fixed;
top: 60px;
right: 20px;
width: 400px;
max-height: 600px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
background: #f9f9f9;
}
.notification-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
}
.notification-filters {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #eee;
background: #fafafa;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.4rem 0.75rem;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.filter-btn:hover {
border-color: #007bff;
color: #007bff;
}
.filter-btn.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.clear-btn {
margin-left: auto;
background: #dc3545;
color: white;
border-color: #dc3545;
}
.clear-btn:hover {
background: #c82333;
border-color: #c82333;
}
.notification-list {
flex: 1;
overflow-y: auto;
max-height: 450px;
}
.notification-item {
padding: 1rem;
border-bottom: 1px solid #eee;
display: flex;
gap: 0.75rem;
background: white;
transition: background 0.2s;
}
.notification-item:hover {
background: #f9f9f9;
}
.notification-item.unread {
background: #f0f8ff;
font-weight: 500;
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-title {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.95rem;
color: #333;
}
.notification-message {
font-size: 0.85rem;
color: #666;
margin-bottom: 0.5rem;
word-break: break-word;
}
.notification-meta {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: #999;
flex-wrap: wrap;
}
.notification-resource {
background: #f0f0f0;
padding: 0.2rem 0.5rem;
border-radius: 3px;
}
.notification-time {
white-space: nowrap;
}
.notification-actions {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem 0.5rem;
color: #999;
transition: color 0.2s;
flex-shrink: 0;
}
.action-btn:hover {
color: #333;
}
.action-btn.mark-read:hover {
color: #28a745;
}
.action-btn.delete:hover {
color: #dc3545;
}
/* Scrollbar styling */
.notification-list::-webkit-scrollbar {
width: 6px;
}
.notification-list::-webkit-scrollbar-track {
background: #f1f1f1;
}
.notification-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
.notification-list::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Mobile responsive */
@media (max-width: 600px) {
.notification-popup {
position: fixed;
top: 60px;
right: 10px;
left: 10px;
width: auto;
max-height: 70vh;
}
.notification-filters {
flex-direction: column;
}
.filter-btn {
flex: 1;
}
.clear-btn {
margin-left: 0;
}
}
+232
View File
@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils';
import './NotificationCenter.css';
function NotificationCenter() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showCenter, setShowCenter] = useState(false);
const [filter, setFilter] = useState('all'); // all, unread, read
const [loading, setLoading] = useState(false);
const [wsConnected, setWsConnected] = useState(false);
useEffect(() => {
loadNotifications();
// Refresh every 10 seconds
const interval = setInterval(loadNotifications, 10000);
// Connect to WebSocket for real-time updates
connectWebSocket();
return () => {
clearInterval(interval);
if (window.notificationWebSocket) {
window.notificationWebSocket.close();
}
};
}, [filter]);
const connectWebSocket = () => {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}`);
ws.onopen = () => {
console.log('WebSocket connected for real-time notifications');
setWsConnected(true);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'notification') {
// New notification received - reload notifications
loadNotifications();
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setWsConnected(false);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setWsConnected(false);
// Attempt to reconnect after 5 seconds
setTimeout(connectWebSocket, 5000);
};
window.notificationWebSocket = ws;
} catch (error) {
console.error('Failed to connect WebSocket:', error);
}
};
const loadNotifications = async () => {
setLoading(true);
try {
const params = {};
if (filter === 'unread') params.read = 'unread';
if (filter === 'read') params.read = 'read';
params.limit = 100;
const response = await axios.get('/api/notifications', { params });
setNotifications(response.data.notifications);
setUnreadCount(response.data.unreadCount);
} catch (error) {
console.error('Failed to load notifications:', error);
} finally {
setLoading(false);
}
};
const handleMarkAsRead = async (notificationId) => {
try {
await axios.put(`/api/notifications/${notificationId}/read`);
loadNotifications();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleDelete = async (notificationId) => {
try {
await axios.delete(`/api/notifications/${notificationId}`);
loadNotifications();
} catch (error) {
console.error('Failed to delete notification:', error);
}
};
const handleClearAll = async () => {
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
try {
await axios.post('/api/notifications/clear', { type: filter });
loadNotifications();
} catch (error) {
console.error('Failed to clear notifications:', error);
}
};
return (
<div className="notification-center">
{/* Notification Bell Icon */}
<div className="notification-bell-container">
<button
className="notification-bell"
onClick={() => setShowCenter(!showCenter)}
title={`${unreadCount} unread notifications`}
>
🔔
{unreadCount > 0 && (
<span className="notification-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</button>
</div>
{/* Notification Center Popup */}
{showCenter && (
<div className="notification-popup">
<div className="notification-header">
<h3>Notifications</h3>
<button
className="close-btn"
onClick={() => setShowCenter(false)}
title="Close"
>
✕
</button>
</div>
<div className="notification-filters">
<button
className={`filter-btn ${filter === 'all' ? 'active' : ''}`}
onClick={() => setFilter('all')}
>
All
</button>
<button
className={`filter-btn ${filter === 'unread' ? 'active' : ''}`}
onClick={() => setFilter('unread')}
>
Unread ({unreadCount})
</button>
<button
className={`filter-btn ${filter === 'read' ? 'active' : ''}`}
onClick={() => setFilter('read')}
>
Read
</button>
{notifications.length > 0 && (
<button
className="filter-btn clear-btn"
onClick={handleClearAll}
>
Clear All
</button>
)}
</div>
<div className="notification-list">
{loading ? (
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>Loading...</p>
) : notifications.length === 0 ? (
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>
No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications
</p>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={`notification-item ${notification.read ? 'read' : 'unread'}`}
>
<div className="notification-content">
<div className="notification-title">{notification.title}</div>
<div className="notification-message">{notification.message}</div>
<div className="notification-meta">
{notification.resource_name && (
<span className="notification-resource">
Resource: {notification.resource_name}
</span>
)}
<span className="notification-time">
{formatLocalTime(notification.created_at)}
</span>
</div>
</div>
<div className="notification-actions">
{!notification.read && (
<button
className="action-btn mark-read"
onClick={() => handleMarkAsRead(notification.id)}
title="Mark as read"
>
✓
</button>
)}
<button
className="action-btn delete"
onClick={() => handleDelete(notification.id)}
title="Delete"
>
✕
</button>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}
export default NotificationCenter;
+228
View File
@@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils';
function Notifications() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [filter, setFilter] = useState('all');
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const itemsPerPage = 50;
useEffect(() => {
loadNotifications();
const interval = setInterval(loadNotifications, 10000);
// Connect to WebSocket for real-time updates
connectWebSocket();
return () => {
clearInterval(interval);
if (window.notificationWebSocket) {
window.notificationWebSocket.close();
}
};
}, [filter, currentPage]);
const connectWebSocket = () => {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}`);
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'notification') {
loadNotifications();
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onclose = () => {
setTimeout(connectWebSocket, 5000);
};
window.notificationWebSocket = ws;
} catch (error) {
console.error('Failed to connect WebSocket:', error);
}
};
const loadNotifications = async () => {
setLoading(true);
try {
const params = {
limit: itemsPerPage,
offset: (currentPage - 1) * itemsPerPage
};
if (filter === 'unread') params.read = 'unread';
if (filter === 'read') params.read = 'read';
const response = await axios.get('/api/notifications', { params });
setNotifications(response.data.notifications);
setUnreadCount(response.data.unreadCount);
setTotal(response.data.total);
} catch (error) {
console.error('Failed to load notifications:', error);
} finally {
setLoading(false);
}
};
const handleMarkAsRead = async (notificationId) => {
try {
await axios.put(`/api/notifications/${notificationId}/read`);
loadNotifications();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleDelete = async (notificationId) => {
try {
await axios.delete(`/api/notifications/${notificationId}`);
loadNotifications();
} catch (error) {
console.error('Failed to delete notification:', error);
}
};
const handleClearAll = async () => {
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
try {
await axios.post('/api/notifications/clear', { type: filter });
setCurrentPage(1);
loadNotifications();
} catch (error) {
console.error('Failed to clear notifications:', error);
}
};
const totalPages = Math.ceil(total / itemsPerPage);
return (
<div className="container" style={{ paddingTop: '2rem' }}>
<h2>📬 Notifications</h2>
<div style={{ marginBottom: '2rem', display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilter('all'); setCurrentPage(1); }}
>
All ({total})
</button>
<button
className={`btn ${filter === 'unread' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilter('unread'); setCurrentPage(1); }}
>
Unread ({unreadCount})
</button>
<button
className={`btn ${filter === 'read' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilter('read'); setCurrentPage(1); }}
>
Read ({total - unreadCount})
</button>
</div>
{notifications.length > 0 && (
<button
className="btn btn-danger"
onClick={handleClearAll}
style={{ marginLeft: 'auto' }}
>
Clear All
</button>
)}
</div>
{loading ? (
<p style={{ textAlign: 'center', color: '#999' }}>Loading...</p>
) : notifications.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#999' }}>
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}>
{notifications.map((notification) => (
<div
key={notification.id}
style={{
padding: '1rem',
border: '1px solid #ddd',
borderRadius: '6px',
backgroundColor: notification.read ? 'white' : '#f0f8ff',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem'
}}
>
<div style={{ flex: 1 }}>
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '1rem' }}>{notification.title}</h4>
<p style={{ margin: '0 0 0.5rem 0', color: '#666', fontSize: '0.95rem' }}>
{notification.message}
</p>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', color: '#999', flexWrap: 'wrap' }}>
{notification.resource_name && (
<span style={{ backgroundColor: '#f0f0f0', padding: '0.25rem 0.5rem', borderRadius: '3px' }}>
📌 {notification.resource_name}
</span>
)}
<span>⏰ {formatLocalTime(notification.created_at)}</span>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
{!notification.read && (
<button
className="btn btn-sm"
onClick={() => handleMarkAsRead(notification.id)}
title="Mark as read"
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem' }}
>
✓ Read
</button>
)}
<button
className="btn btn-sm"
onClick={() => handleDelete(notification.id)}
title="Delete"
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem', background: '#dc3545' }}
>
✕ Delete
</button>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
className={`btn ${currentPage === page ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setCurrentPage(page)}
style={{ minWidth: '40px' }}
>
{page}
</button>
))}
</div>
)}
</>
)}
</div>
);
}
export default Notifications;
+302
View File
@@ -0,0 +1,302 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils';
function Observability() {
const [activeTab, setActiveTab] = useState('metrics');
const [metrics, setMetrics] = useState(null);
const [errors, setErrors] = useState([]);
const [auditLogs, setAuditLogs] = useState([]);
const [auditSummary, setAuditSummary] = useState(null);
const [loading, setLoading] = useState(false);
const [expandedAuditRow, setExpandedAuditRow] = useState(null);
const [filters, setFilters] = useState({
entityType: '',
entityId: '',
userId: '',
action: ''
});
useEffect(() => {
loadData();
const interval = setInterval(loadData, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [activeTab, filters]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const loadData = async () => {
setLoading(true);
try {
if (activeTab === 'metrics') {
const resp = await axios.get('/api/observability/metrics');
setMetrics(resp.data);
} else if (activeTab === 'errors') {
const resp = await axios.get('/api/observability/errors?limit=50');
setErrors(resp.data.errors);
} else if (activeTab === 'audit') {
const auditResp = await axios.get('/api/observability/audit-logs', { params: filters });
setAuditLogs(auditResp.data.logs);
setAuditSummary(auditResp.data.summary);
}
} catch (error) {
// Error loading data
}
setLoading(false);
};
return (
<div className="container" style={{ paddingTop: '2rem' }}>
<h2>System Observability</h2>
<div style={{ marginBottom: '2rem', borderBottom: '1px solid #ddd', display: 'flex', gap: '1rem' }}>
<button
onClick={() => setActiveTab('metrics')}
style={{
padding: '0.75rem 1.5rem',
border: 'none',
borderBottom: activeTab === 'metrics' ? '3px solid #007bff' : 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
fontWeight: activeTab === 'metrics' ? 'bold' : 'normal',
color: activeTab === 'metrics' ? '#007bff' : '#666'
}}
>
API Metrics
</button>
<button
onClick={() => setActiveTab('errors')}
style={{
padding: '0.75rem 1.5rem',
border: 'none',
borderBottom: activeTab === 'errors' ? '3px solid #007bff' : 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
fontWeight: activeTab === 'errors' ? 'bold' : 'normal',
color: activeTab === 'errors' ? '#007bff' : '#666'
}}
>
Errors
</button>
<button
onClick={() => setActiveTab('audit')}
style={{
padding: '0.75rem 1.5rem',
border: 'none',
borderBottom: activeTab === 'audit' ? '3px solid #007bff' : 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
fontWeight: activeTab === 'audit' ? 'bold' : 'normal',
color: activeTab === 'audit' ? '#007bff' : '#666'
}}
>
Audit Logs
</button>
</div>
{/* Metrics Tab */}
{activeTab === 'metrics' && metrics && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Server Uptime</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
{metrics.uptimeFormatted || `${Math.floor(metrics.uptime / 3600)}h`}
</div>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Total API Requests</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
{metrics.apiStats.reduce((sum, s) => sum + s.totalRequests, 0)}
</div>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Errors Logged</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem', color: metrics.errorCount > 0 ? '#dc3545' : '#28a745' }}>
{metrics.errorCount}
</div>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Unique Errors</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
{metrics.uniqueErrors}
</div>
</div>
</div>
<h3>API Endpoint Performance</h3>
<div style={{ overflowX: 'auto', marginBottom: '2rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Endpoint</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Requests</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Success Rate</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Avg Response</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Min/Max</th>
</tr>
</thead>
<tbody>
{metrics.apiStats.map((stat, i) => (
<tr key={i} style={{ borderBottom: '1px solid #ddd' }}>
<td style={{ padding: '0.75rem' }}>
<code>{stat.method} {stat.endpoint}</code>
</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.totalRequests}</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
<span style={{
padding: '0.25rem 0.5rem',
borderRadius: '3px',
backgroundColor: stat.successRate >= 95 ? '#d4edda' : '#fff3cd',
color: stat.successRate >= 95 ? '#155724' : '#856404'
}}>
{stat.successRate}%
</span>
</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.avgResponseTime}ms</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
{stat.minResponseTime}ms / {stat.maxResponseTime}ms
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Errors Tab */}
{activeTab === 'errors' && (
<div>
{errors.length === 0 ? (
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>No errors logged</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Error Message</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Context</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Occurred At</th>
</tr>
</thead>
<tbody>
{errors.map((error, i) => (
<tr key={i} style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white' }}>
<td style={{ padding: '0.75rem' }}>
<code style={{ fontSize: '0.85rem', color: '#d9534f' }}>{error.error_message}</code>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem', color: '#666' }}>
{error.context ? JSON.stringify(JSON.parse(error.context)).substring(0, 50) : '-'}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
{formatLocalTime(error.occurred_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Audit Logs Tab */}
{activeTab === 'audit' && (
<div>
{auditSummary && (
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: '#f9f9f9', borderRadius: '4px' }}>
<p><strong>Summary (Last 7 Days):</strong> {auditSummary.totalEvents} events</p>
{auditSummary.actionBreakdown.map(action => (
<p key={action.action} style={{ margin: '0.25rem 0', fontSize: '0.9rem', color: '#666' }}>
{action.action}: {action.count}
</p>
))}
</div>
)}
<div style={{ marginBottom: '1rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.5rem' }}>
<input
type="text"
placeholder="Entity Type"
value={filters.entityType}
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Entity ID"
value={filters.entityId}
onChange={(e) => setFilters({ ...filters, entityId: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="User ID"
value={filters.userId}
onChange={(e) => setFilters({ ...filters, userId: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Action"
value={filters.action}
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
{auditLogs.length === 0 ? (
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>No audit logs found</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Action</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Entity</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>User</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Changes</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Time</th>
</tr>
</thead>
<tbody>
{auditLogs.map((log, i) => (
<React.Fragment key={i}>
<tr style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white', cursor: 'pointer' }} onClick={() => setExpandedAuditRow(expandedAuditRow === i ? null : i)}>
<td style={{ padding: '0.75rem' }}><strong>{log.action}</strong></td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem', color: '#666' }}>
{log.entity_type} #{log.entity_id}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>{log.user_id}</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
<code style={{ cursor: 'pointer', color: '#007bff' }}>
{expandedAuditRow === i ? '▼ Hide' : '▶ Show'} Changes
</code>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
{formatLocalTime(log.created_at)}
</td>
</tr>
{expandedAuditRow === i && (
<tr style={{ backgroundColor: '#f0f8ff', borderBottom: '1px solid #ddd' }}>
<td colSpan="5" style={{ padding: '1rem' }}>
<strong>Full Changes:</strong>
<pre style={{ marginTop: '0.5rem', backgroundColor: 'white', padding: '0.75rem', borderRadius: '4px', overflowX: 'auto', border: '1px solid #ddd' }}>
{JSON.stringify(log.changes, null, 2)}
</pre>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
export default Observability;
+35 -2
View File
@@ -5,6 +5,7 @@ import './App.css';
function SLA() {
const [slaData, setSlaData] = useState([]);
const [days, setDays] = useState(30);
const [maxDays, setMaxDays] = useState(30);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
@@ -13,6 +14,25 @@ function SLA() {
const [effectiveDays, setEffectiveDays] = useState(30);
const [limitedByRetention, setLimitedByRetention] = useState(false);
useEffect(() => {
// Fetch retention setting from server
const fetchRetention = async () => {
try {
const response = await axios.get('/api/settings');
const retentionDays = response.data.retention_days || 30;
setMaxDays(parseInt(retentionDays));
// Adjust current days if it exceeds retention
if (days > retentionDays) {
setDays(parseInt(retentionDays));
}
} catch (error) {
// Failed to fetch retention setting, use default
}
};
fetchRetention();
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const loadSLAData = React.useCallback(async () => {
setLoading(true);
setError(null);
@@ -25,7 +45,6 @@ function SLA() {
setLimitedByRetention(Boolean(response.data.limited_by_retention));
setLoading(false);
} catch (error) {
console.error('Error loading SLA data:', error);
setError(error.message);
setLoading(false);
}
@@ -43,11 +62,23 @@ function SLA() {
meetingTarget: slaData.filter(s => s.meets_target).length,
avgUptime: (slaData.reduce((sum, s) => sum + parseFloat(s.actual_uptime), 0) / slaData.length).toFixed(2),
totalIncidents: slaData.reduce((sum, s) => sum + s.incidents, 0),
totalDowntime: slaData.reduce((sum, s) => sum + s.downtime_minutes, 0),
totalDowntime: (slaData.reduce((sum, s) => sum + s.downtime_minutes, 0) / 60).toFixed(2), // Convert to hours
} : null;
return (
<div className="container">
{limitedByRetention && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
color: '#856404'
}}>
⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2>SLA Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem' }}>
@@ -56,6 +87,8 @@ function SLA() {
key={d}
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setDays(d)}
disabled={d > maxDays}
style={{ opacity: d > maxDays ? 0.5 : 1 }}
>
{d} Days
</button>
+218 -9
View File
@@ -30,7 +30,6 @@ function SettingsWizard() {
default_sort: 'name',
items_per_page: 20,
refresh_interval: 5000,
theme: 'light',
incident_failure_threshold: 10,
});
@@ -48,7 +47,7 @@ function SettingsWizard() {
const response = await axios.get('/api/settings');
setSettings(response.data);
} catch (error) {
console.error('Error loading settings:', error);
// Settings load error handled
}
};
@@ -62,7 +61,6 @@ function SettingsWizard() {
// Clear message after 3 seconds
setTimeout(() => setSavedMessage(null), 3000);
} catch (error) {
console.error('Error saving settings:', error);
setSavedMessage({ section: sectionName, success: false, error: error.response?.data?.error || 'Failed to save' });
setTimeout(() => setSavedMessage(null), 5000);
} finally {
@@ -115,6 +113,7 @@ function SettingsWizard() {
{ name: 'Gmail', host: 'smtp.gmail.com', port: 587 },
{ name: 'Outlook', host: 'smtp-mail.outlook.com', port: 587 },
{ name: 'Yahoo', host: 'smtp.mail.yahoo.com', port: 587 },
{ name: 'iCloud', host: 'smtp.mail.me.com', port: 587 },
{ name: 'Custom', host: '', port: 587 },
];
@@ -363,6 +362,20 @@ function SettingsWizard() {
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Global Data Retention (days)</label>
<input
type="number"
value={settings.retention_days}
onChange={(e) => setSettings({ ...settings, retention_days: parseInt(e.target.value) })}
min="1"
max="365"
/>
<small>Default retention period for all monitors (1-365 days). Individual monitors can override this.</small>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Timezone</label>
@@ -391,6 +404,18 @@ function SettingsWizard() {
</div>
<div className="form-row">
<div className="form-group">
<label>Consecutive Failures Threshold</label>
<input
type="number"
value={settings.consecutive_failures || 3}
onChange={(e) => setSettings({ ...settings, consecutive_failures: parseInt(e.target.value) })}
min="1"
max="100"
/>
<small>Number of consecutive failures before triggering an alert (default: 3)</small>
</div>
<div className="form-group">
<label>Incident Failure Threshold</label>
<input
@@ -400,7 +425,33 @@ function SettingsWizard() {
min="1"
max="100"
/>
<small>Number of consecutive failed checks before automatically creating an incident (default: 10)</small>
<small>Number of failures before creating an incident (default: 10)</small>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Grace Period (seconds)</label>
<input
type="number"
value={settings.grace_period || 300}
onChange={(e) => setSettings({ ...settings, grace_period: parseInt(e.target.value) })}
min="0"
step="60"
/>
<small>Wait time before alerting after first failure (default: 300s = 5 minutes)</small>
</div>
<div className="form-group">
<label>Downtime Threshold (seconds)</label>
<input
type="number"
value={settings.downtime_threshold || 600}
onChange={(e) => setSettings({ ...settings, downtime_threshold: parseInt(e.target.value) })}
min="0"
step="60"
/>
<small>Minimum downtime before creating incident (default: 600s = 10 minutes)</small>
</div>
</div>
@@ -411,25 +462,183 @@ function SettingsWizard() {
timeout: settings.timeout,
timezone: settings.timezone,
retention_days: settings.retention_days,
auto_cleanup_enabled: settings.auto_cleanup_enabled,
consecutive_failures: settings.consecutive_failures,
grace_period: settings.grace_period,
downtime_threshold: settings.downtime_threshold,
incident_failure_threshold: settings.incident_failure_threshold,
})}
disabled={savingSection === 'Monitoring'}
>
{savingSection === 'Monitoring' ? 'Saving...' : '💾 Save Monitoring Settings'}
</button>
</div>
</div>
{/* Alert Settings */}
<div className="settings-section">
<h3>🔔 Alert Settings</h3>
<div className="settings-form">
<div className="form-row">
<div className="form-group">
<label>Alert Retry Count</label>
<input
type="number"
value={settings.alert_retry_count || 3}
onChange={(e) => setSettings({ ...settings, alert_retry_count: parseInt(e.target.value) })}
min="0"
max="10"
/>
<small>Number of times to retry sending alert if it fails (default: 3)</small>
</div>
<div className="form-group">
<label>Alert Retry Delay (seconds)</label>
<input
type="number"
value={settings.alert_retry_delay || 60}
onChange={(e) => setSettings({ ...settings, alert_retry_delay: parseInt(e.target.value) })}
min="0"
step="30"
/>
<small>Wait time between alert retries (default: 60s)</small>
</div>
</div>
<div className="form-group">
<label>Fallback Webhook URL (optional)</label>
<input
type="url"
value={settings.fallback_webhook || ''}
onChange={(e) => setSettings({ ...settings, fallback_webhook: e.target.value })}
placeholder="https://backup-webhook.example.com"
/>
<small>Backup webhook to use if primary alert methods fail</small>
</div>
<div className="form-row">
<div className="form-group">
<label>Global Quiet Hours Start</label>
<input
type="time"
value={settings.global_quiet_hours_start || ''}
onChange={(e) => setSettings({ ...settings, global_quiet_hours_start: e.target.value })}
/>
<small>Suppress non-critical alerts starting at this time</small>
</div>
<div className="form-group">
<label>Global Quiet Hours End</label>
<input
type="time"
value={settings.global_quiet_hours_end || ''}
onChange={(e) => setSettings({ ...settings, global_quiet_hours_end: e.target.value })}
/>
<small>Resume normal alerting at this time</small>
</div>
</div>
<div className="form-group">
<label>Escalation Hours</label>
<input
type="number"
value={settings.escalation_hours || 4}
onChange={(e) => setSettings({ ...settings, escalation_hours: parseInt(e.target.value) })}
min="1"
max="48"
/>
<small>Hours after which unresolved incidents are escalated (default: 4)</small>
</div>
<button
className="btn btn-primary"
onClick={() => handleSaveSection('Alerts', {
alert_retry_count: settings.alert_retry_count,
alert_retry_delay: settings.alert_retry_delay,
fallback_webhook: settings.fallback_webhook,
global_quiet_hours_start: settings.global_quiet_hours_start,
global_quiet_hours_end: settings.global_quiet_hours_end,
escalation_hours: settings.escalation_hours,
})}
disabled={savingSection === 'Alerts'}
>
{savingSection === 'Alerts' ? 'Saving...' : '💾 Save Alert Settings'}
</button>
</div>
</div>
{/* Dashboard Settings */}
<div className="settings-section">
<h3>📊 Dashboard Settings</h3>
<div className="settings-form">
<div className="form-row">
<div className="form-group">
<label>Default Sort Order</label>
<select
value={settings.default_sort || 'name'}
onChange={(e) => setSettings({ ...settings, default_sort: e.target.value })}
>
<option value="name">Name</option>
<option value="status">Status</option>
<option value="created_at">Created Date</option>
<option value="group">Group</option>
</select>
<small>Default sorting for resources list</small>
</div>
<div className="form-group">
<label>Items Per Page</label>
<input
type="number"
value={settings.items_per_page || 20}
onChange={(e) => setSettings({ ...settings, items_per_page: parseInt(e.target.value) })}
min="10"
max="100"
step="10"
/>
<small>Number of resources to display per page</small>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Dashboard Refresh Interval (ms)</label>
<input
type="number"
value={settings.refresh_interval || 5000}
onChange={(e) => setSettings({ ...settings, refresh_interval: parseInt(e.target.value) })}
min="1000"
step="1000"
/>
<small>How often to refresh dashboard data (default: 5000ms = 5 seconds)</small>
</div>
</div>
<div className="form-group">
<label className="toggle-switch-row">
<span>Auto Cleanup Enabled</span>
<label className="toggle-switch">
<input
type="checkbox"
checked={settings.auto_cleanup_enabled || false}
onChange={(e) => setSettings({ ...settings, auto_cleanup_enabled: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</label>
<small>Automatically archive old check data based on retention period</small>
</div>
<button
className="btn btn-primary"
onClick={() => handleSaveSection('Dashboard', {
default_sort: settings.default_sort,
items_per_page: settings.items_per_page,
refresh_interval: settings.refresh_interval,
theme: settings.theme,
incident_failure_threshold: settings.incident_failure_threshold,
auto_cleanup_enabled: settings.auto_cleanup_enabled,
})}
disabled={savingSection === 'Monitoring'}
disabled={savingSection === 'Dashboard'}
>
{savingSection === 'Monitoring' ? 'Saving...' : '💾 Save Monitoring Settings'}
{savingSection === 'Dashboard' ? 'Saving...' : '💾 Save Dashboard Settings'}
</button>
</div>
</div>
+2 -2
View File
@@ -12,7 +12,7 @@ function Status() {
useEffect(() => {
loadStatusData();
const interval = setInterval(loadStatusData, 30000); // Refresh every 30 seconds
const interval = setInterval(loadStatusData, 15000); // Refresh every 15 seconds for critical status
return () => clearInterval(interval);
}, []);
@@ -28,7 +28,7 @@ function Status() {
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
setLastUpdated(new Date());
} catch (error) {
console.error('Error loading status data:', error);
// Status load error handled
} finally {
setLoading(false);
}
+25 -3
View File
@@ -28,12 +28,19 @@ export const formatLocalTime = (timestamp, options = {}) => {
let timeZone;
try {
const cachedSettings = localStorage.getItem('serverTimezone');
if (cachedSettings && cachedSettings !== 'null' && cachedSettings !== 'undefined' && cachedSettings.trim() !== '') {
const cacheTime = localStorage.getItem('serverTimezoneTime');
const now = Date.now();
// Invalidate cache every 1 hour
if (cachedSettings && cacheTime && (now - parseInt(cacheTime)) < 3600000 && cachedSettings !== 'null' && cachedSettings !== 'undefined' && cachedSettings.trim() !== '') {
// Remove any quotes that might be in the string
timeZone = cachedSettings.replace(/^["']|["']$/g, '').trim();
} else if (cachedSettings) {
// Clear stale cache
localStorage.removeItem('serverTimezone');
localStorage.removeItem('serverTimezoneTime');
}
} catch (e) {
console.error('Error reading serverTimezone from localStorage:', e);
// Timezone load error handled silently
}
const defaultOptions = {
@@ -72,12 +79,27 @@ export const formatLocalTimeOnly = (timestamp, options = {}) => {
/**
* Format timestamp for chart display (shorter format)
* Shows date and time for better context
*/
export const formatChartTime = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
// If within last 24 hours, show time only
if (diffDays === 0) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
// Otherwise show date and time
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});