Files
skywatch/client/src/Notifications.js
T
nmemmertandClaude Sonnet 4.6 8d50d3cb61 Fix bugs, UX rough edges, add ntfy.sh + network discovery
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>
2026-08-06 13:24:47 -04:00

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;