Bugs fixed: - Status page: autoRefresh checkbox now actually pauses the interval - Dashboard edit modal: hide URL field for heartbeat resources (was always shown as required) - Dashboard: apply items_per_page, default_sort, and refresh_interval from settings on mount - Dashboard: call loadResources() immediately on mount (was waiting 15s for fallback) - Observability: listen for WS 'metrics' messages so tab updates live, not just on poll UX improvements: - Dashboard: replaced window.confirm() with inline Confirm/Cancel buttons for single and bulk delete - Dashboard: skeleton rows shown while initial data loads (no more flash of empty state) - Notifications: WebSocket moved from window global to useRef; exponential backoff on reconnect - Notifications: replaced window.confirm() with inline Confirm/Cancel for Clear All - ResourceDetail: initial API calls run in parallel via Promise.all New features: - ntfy.sh push notification channel: enable in Settings, enter topic, get mobile push alerts - Network Discovery: Actions → Discover Network scans LAN for reachable hosts, probes HTTP ports, and lets you add any found host to monitoring in one click Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
227 lines
7.4 KiB
JavaScript
227 lines
7.4 KiB
JavaScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import axios from 'axios';
|
|
import { formatLocalTime } from './utils/timeUtils';
|
|
import './CommandCenterPages.css';
|
|
|
|
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 [pendingClear, setPendingClear] = useState(false);
|
|
const itemsPerPage = 50;
|
|
const wsRef = useRef(null);
|
|
const wsReconnectTimer = useRef(null);
|
|
const wsReconnectDelay = useRef(1000);
|
|
|
|
const loadNotifications = useCallback(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) {
|
|
// Notifications load error handled
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [filter, currentPage]);
|
|
|
|
const connectWebSocket = useCallback(() => {
|
|
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 (_) {}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
wsRef.current = null;
|
|
wsReconnectTimer.current = setTimeout(() => {
|
|
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
|
|
connectWebSocket();
|
|
}, wsReconnectDelay.current);
|
|
};
|
|
|
|
ws.onopen = () => { wsReconnectDelay.current = 1000; };
|
|
ws.onerror = () => {};
|
|
|
|
wsRef.current = ws;
|
|
} catch (_) {}
|
|
}, [loadNotifications]);
|
|
|
|
useEffect(() => {
|
|
loadNotifications();
|
|
const interval = setInterval(loadNotifications, 10000);
|
|
connectWebSocket();
|
|
return () => {
|
|
clearInterval(interval);
|
|
clearTimeout(wsReconnectTimer.current);
|
|
if (wsRef.current) wsRef.current.close();
|
|
};
|
|
}, [filter, currentPage, loadNotifications, connectWebSocket]);
|
|
|
|
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 () => {
|
|
try {
|
|
await axios.post('/api/notifications/clear', { type: filter });
|
|
setCurrentPage(1);
|
|
setPendingClear(false);
|
|
loadNotifications();
|
|
} catch (error) {
|
|
setPendingClear(false);
|
|
}
|
|
};
|
|
|
|
const totalPages = Math.ceil(total / itemsPerPage);
|
|
|
|
return (
|
|
<div className="container cc-page">
|
|
<div className="cc-page-header">
|
|
<h2 className="cc-page-title">Notifications</h2>
|
|
</div>
|
|
|
|
<div className="cc-controls" style={{ marginBottom: '1rem' }}>
|
|
<div className="cc-controls">
|
|
<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 && (
|
|
pendingClear ? (
|
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
|
<button className="btn btn-danger" onClick={handleClearAll}>Confirm Clear</button>
|
|
<button className="btn btn-secondary" onClick={() => setPendingClear(false)}>Cancel</button>
|
|
</div>
|
|
) : (
|
|
<button className="btn btn-danger" onClick={() => setPendingClear(true)} style={{ marginLeft: 'auto' }}>
|
|
Clear All
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p className="cc-empty">Loading...</p>
|
|
) : notifications.length === 0 ? (
|
|
<div className="cc-empty">
|
|
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="notifications-list">
|
|
{notifications.map((notification) => (
|
|
<div
|
|
key={notification.id}
|
|
className={`notification-row ${notification.read ? 'is-read' : 'is-unread'}`}
|
|
>
|
|
<div className="notification-content">
|
|
<h4 className="notification-title">{notification.title}</h4>
|
|
<p className="notification-message">
|
|
{notification.message}
|
|
</p>
|
|
<div className="notification-meta">
|
|
{notification.resource_name && (
|
|
<span className="notification-chip">
|
|
{notification.resource_name}
|
|
</span>
|
|
)}
|
|
<span>{formatLocalTime(notification.created_at)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="notification-actions">
|
|
{!notification.read && (
|
|
<button
|
|
className="btn btn-sm"
|
|
onClick={() => handleMarkAsRead(notification.id)}
|
|
title="Mark as read"
|
|
>
|
|
✓ Read
|
|
</button>
|
|
)}
|
|
<button
|
|
className="btn btn-sm"
|
|
onClick={() => handleDelete(notification.id)}
|
|
title="Delete"
|
|
style={{ background: '#b91c1c' }}
|
|
>
|
|
✕ Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="cc-pagination">
|
|
{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;
|