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>
This commit is contained in:
1 parent
de4619992e
commit
8d50d3cb61
21 files changed
+668
-105
No files matched your search
@@ -1,3 +1,56 @@
|
||||
/* ── Skeleton loading ── */
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
.skeleton {
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
animation: skeleton-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skeleton-row { pointer-events: none; min-height: 72px; }
|
||||
.skeleton-name { width: 40%; height: 14px; margin-bottom: 8px; }
|
||||
.skeleton-url { width: 60%; height: 11px; }
|
||||
.skeleton-badge { width: 56px; height: 22px; border-radius: 12px; }
|
||||
|
||||
/* ── Inline delete confirm ── */
|
||||
.action-delete-confirm { color: #ef4444 !important; }
|
||||
|
||||
/* ── Network discovery modal ── */
|
||||
.discovery-scanning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.discovery-spinner {
|
||||
width: 18px; height: 18px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.discovery-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 360px; overflow-y: auto; }
|
||||
.discovery-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.discovery-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.discovery-ip { font-weight: 600; font-size: 0.9rem; color: var(--text); }
|
||||
.discovery-hostname { font-size: 0.8rem; color: var(--text-muted); }
|
||||
.discovery-url { font-size: 0.8rem; color: var(--accent); font-family: monospace; word-break: break-all; }
|
||||
.discovery-ports { font-size: 0.75rem; color: var(--text-dim); }
|
||||
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.82rem; white-space: nowrap; }
|
||||
|
||||
/* ── Theme variables ── */
|
||||
:root {
|
||||
--app-bg: #020617;
|
||||
|
||||
+145
-19
@@ -12,6 +12,9 @@ function Dashboard() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showGroupModal, setShowGroupModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDiscovery, setShowDiscovery] = useState(false);
|
||||
const [discoveryHosts, setDiscoveryHosts] = useState([]);
|
||||
const [discoveryLoading, setDiscoveryLoading] = useState(false);
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const [editData, setEditData] = useState({});
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
@@ -44,6 +47,10 @@ function Dashboard() {
|
||||
const [quickFilter, setQuickFilter] = useState('all');
|
||||
const [sortKey, setSortKey] = useState('severity');
|
||||
const [renderLimit, setRenderLimit] = useState(120);
|
||||
const [refreshInterval, setRefreshInterval] = useState(15000);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState(null);
|
||||
const [pendingBulkDelete, setPendingBulkDelete] = useState(false);
|
||||
const [groupData, setGroupData] = useState({ name: '', description: '' });
|
||||
const [selectedIds, setSelectedIds] = useState(new Set());
|
||||
const wsRef = useRef(null);
|
||||
@@ -65,8 +72,9 @@ function Dashboard() {
|
||||
const response = await axios.get('/api/dashboard');
|
||||
setResources(response.data.resources);
|
||||
setGroups(response.data.groups || []);
|
||||
setLoaded(true);
|
||||
} catch (error) {
|
||||
// Error loading resources handled
|
||||
setLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -87,6 +95,7 @@ function Dashboard() {
|
||||
if (message.type === 'dashboard') {
|
||||
setResources(message.data.resources || []);
|
||||
setGroups(message.data.groups || []);
|
||||
setLoaded(true);
|
||||
} else if (message.type === 'alert') {
|
||||
const alert = message.data;
|
||||
showNotification(`Alert: ${alert.resourceName}`, alert.message, 'error');
|
||||
@@ -130,14 +139,24 @@ function Dashboard() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
axios.get('/api/settings').then(res => {
|
||||
const s = res.data;
|
||||
if (s.items_per_page) setRenderLimit(parseInt(s.items_per_page));
|
||||
if (s.default_sort) setSortKey(s.default_sort);
|
||||
if (s.refresh_interval) setRefreshInterval(parseInt(s.refresh_interval));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadResources();
|
||||
connectWebSocket();
|
||||
const fallbackInterval = setInterval(loadResources, 15000);
|
||||
const fallbackInterval = setInterval(loadResources, refreshInterval);
|
||||
return () => {
|
||||
clearInterval(fallbackInterval);
|
||||
clearTimeout(wsReconnectTimer.current);
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
};
|
||||
}, [connectWebSocket, loadResources]);
|
||||
}, [connectWebSocket, loadResources, refreshInterval]);
|
||||
|
||||
const emptyForm = {
|
||||
name: '', url: '', type: 'http', check_interval: 60000, timeout: 5000,
|
||||
@@ -197,13 +216,13 @@ function Dashboard() {
|
||||
};
|
||||
|
||||
const handleDeleteResource = async (id) => {
|
||||
if (window.confirm('Delete this resource?')) {
|
||||
try {
|
||||
await axios.delete(`/api/resources/${id}`);
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Failed to delete resource', 'error');
|
||||
}
|
||||
try {
|
||||
await axios.delete(`/api/resources/${id}`);
|
||||
setPendingDeleteId(null);
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Failed to delete resource', 'error');
|
||||
setPendingDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -228,13 +247,14 @@ function Dashboard() {
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (!window.confirm(`Delete ${selectedIds.size} resource(s)?`)) return;
|
||||
try {
|
||||
await Promise.all([...selectedIds].map(id => axios.delete(`/api/resources/${id}`)));
|
||||
setSelectedIds(new Set());
|
||||
setPendingBulkDelete(false);
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Some deletes failed', 'error');
|
||||
setPendingBulkDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -266,6 +286,31 @@ function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = async () => {
|
||||
setShowDiscovery(true);
|
||||
setDiscoveryHosts([]);
|
||||
setDiscoveryLoading(true);
|
||||
try {
|
||||
const res = await axios.post('/api/network-discovery');
|
||||
setDiscoveryHosts(res.data.hosts || []);
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Network discovery failed', 'error');
|
||||
} finally {
|
||||
setDiscoveryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addDiscoveredHost = (host) => {
|
||||
setFormData({
|
||||
...emptyForm,
|
||||
name: host.hostname || host.ip,
|
||||
url: host.suggestedUrl || '',
|
||||
type: host.suggestedType || 'http',
|
||||
});
|
||||
setShowDiscovery(false);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/resources/export', { responseType: 'blob' });
|
||||
@@ -468,7 +513,14 @@ function Dashboard() {
|
||||
>
|
||||
{resource.maintenance_mode ? '✅' : '🛠'}
|
||||
</button>
|
||||
<button className="btn-icon action-delete" title="Delete" onClick={(e) => { e.stopPropagation(); handleDeleteResource(resource.id); }}>🗑</button>
|
||||
{pendingDeleteId === resource.id ? (
|
||||
<>
|
||||
<button className="btn-icon action-delete-confirm" title="Confirm delete" onClick={(e) => { e.stopPropagation(); handleDeleteResource(resource.id); }}>✓</button>
|
||||
<button className="btn-icon" title="Cancel" onClick={(e) => { e.stopPropagation(); setPendingDeleteId(null); }}>✕</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn-icon action-delete" title="Delete" onClick={(e) => { e.stopPropagation(); setPendingDeleteId(resource.id); }}>🗑</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="last-check">Last: {resource.lastCheck ? formatLocalTime(resource.lastCheck) : 'Never'}</p>
|
||||
</div>
|
||||
@@ -561,6 +613,7 @@ function Dashboard() {
|
||||
<div role="menu" className="actions-menu">
|
||||
<button className="btn btn-ghost" onClick={() => { setActionsOpen(false); setShowGroupModal(true); }} role="menuitem">+ New Group</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setActionsOpen(false); setShowModal(true); }} role="menuitem">+ Add Resource</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setActionsOpen(false); handleDiscover(); }} role="menuitem">🔍 Discover Network</button>
|
||||
<hr className="actions-menu-divider" />
|
||||
<button className="btn btn-ghost" onClick={() => { setActionsOpen(false); handleExportCSV(); }} role="menuitem">⬇ Export CSV</button>
|
||||
<button className="btn btn-ghost" onClick={() => { setActionsOpen(false); document.getElementById('csv-import').click(); }} role="menuitem">⬆ Import CSV</button>
|
||||
@@ -588,7 +641,17 @@ function Dashboard() {
|
||||
<div className="cc-metric-card"><p className="cc-metric-label">Avg Resp</p><p className="cc-metric-value">{avgResponse}ms</p></div>
|
||||
</div>
|
||||
|
||||
{resources.length === 0 ? (
|
||||
{!loaded ? (
|
||||
<div className="resource-list">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="resource-row skeleton-row">
|
||||
<div className="skeleton skeleton-name" />
|
||||
<div className="skeleton skeleton-url" />
|
||||
<div className="skeleton skeleton-badge" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : resources.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No resources yet</h3>
|
||||
<p>Add your first resource to start monitoring</p>
|
||||
@@ -605,8 +668,15 @@ function Dashboard() {
|
||||
<span className="bulk-count">{selectedIds.size} selected</span>
|
||||
<button className="btn btn-secondary" onClick={() => handleBulkMaintenance(true)}>Enable Maintenance</button>
|
||||
<button className="btn btn-secondary" onClick={() => handleBulkMaintenance(false)}>End Maintenance</button>
|
||||
<button className="btn btn-danger" onClick={handleBulkDelete}>Delete Selected</button>
|
||||
<button className="btn" onClick={() => setSelectedIds(new Set())}>Clear</button>
|
||||
{pendingBulkDelete ? (
|
||||
<>
|
||||
<button className="btn btn-danger" onClick={handleBulkDelete}>Confirm Delete {selectedIds.size}</button>
|
||||
<button className="btn" onClick={() => setPendingBulkDelete(false)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn-danger" onClick={() => setPendingBulkDelete(true)}>Delete Selected</button>
|
||||
)}
|
||||
<button className="btn" onClick={() => { setSelectedIds(new Set()); setPendingBulkDelete(false); }}>Clear</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -857,10 +927,12 @@ function Dashboard() {
|
||||
<input type="text" value={editData.name || ''} onChange={(e) => setEditData({ ...editData, name: e.target.value })} required placeholder="e.g., Production API" />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>URL/Address *</label>
|
||||
<input type="text" value={editData.url || ''} onChange={(e) => setEditData({ ...editData, url: e.target.value })} required placeholder="e.g., https://api.example.com" />
|
||||
</div>
|
||||
{editData.type !== 'heartbeat' && (
|
||||
<div className="form-group">
|
||||
<label>URL/Address *</label>
|
||||
<input type="text" value={editData.url || ''} onChange={(e) => setEditData({ ...editData, url: e.target.value })} required placeholder="e.g., https://api.example.com" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Check Type *</label>
|
||||
@@ -990,6 +1062,60 @@ function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDiscovery && (
|
||||
<div className="modal-overlay" onClick={() => setShowDiscovery(false)}>
|
||||
<div className="modal" style={{ maxWidth: '640px' }} onClick={(e) => e.stopPropagation()}>
|
||||
<h2>🔍 Network Discovery</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem', fontSize: '0.9rem' }}>
|
||||
Scanning your local network for reachable hosts and HTTP services…
|
||||
</p>
|
||||
|
||||
{discoveryLoading && (
|
||||
<div className="discovery-scanning">
|
||||
<div className="discovery-spinner" />
|
||||
<span>Scanning — this takes 10–20 seconds</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!discoveryLoading && discoveryHosts.length === 0 && (
|
||||
<div className="cc-empty">No hosts found on the local network.</div>
|
||||
)}
|
||||
|
||||
{!discoveryLoading && discoveryHosts.length > 0 && (
|
||||
<div className="discovery-list">
|
||||
{discoveryHosts.map((host) => (
|
||||
<div key={host.ip} className="discovery-row">
|
||||
<div className="discovery-info">
|
||||
<span className="discovery-ip">{host.ip}</span>
|
||||
{host.hostname && <span className="discovery-hostname">{host.hostname}</span>}
|
||||
{host.suggestedUrl && (
|
||||
<span className="discovery-url">{host.suggestedUrl}</span>
|
||||
)}
|
||||
{host.openPorts.length > 0 && (
|
||||
<span className="discovery-ports">ports: {host.openPorts.join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => addDiscoveredHost(host)}
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions" style={{ marginTop: '1rem' }}>
|
||||
{!discoveryLoading && (
|
||||
<button className="btn btn-secondary" onClick={handleDiscover}>↺ Rescan</button>
|
||||
)}
|
||||
<button className="btn" onClick={() => setShowDiscovery(false)}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+60
-55
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { formatLocalTime } from './utils/timeUtils';
|
||||
import './CommandCenterPages.css';
|
||||
@@ -10,50 +10,13 @@ function Notifications() {
|
||||
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);
|
||||
|
||||
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 () => {
|
||||
const loadNotifications = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
@@ -68,11 +31,51 @@ function Notifications() {
|
||||
setUnreadCount(response.data.unreadCount);
|
||||
setTotal(response.data.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notifications:', 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 {
|
||||
@@ -93,14 +96,13 @@ function Notifications() {
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
|
||||
|
||||
try {
|
||||
await axios.post('/api/notifications/clear', { type: filter });
|
||||
setCurrentPage(1);
|
||||
setPendingClear(false);
|
||||
loadNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear notifications:', error);
|
||||
setPendingClear(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,13 +137,16 @@ function Notifications() {
|
||||
</div>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleClearAll}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
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>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import { formatLocalTime } from './utils/timeUtils';
|
||||
import './Observability.css';
|
||||
@@ -9,7 +9,6 @@ function Observability() {
|
||||
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: '',
|
||||
@@ -17,16 +16,50 @@ function Observability() {
|
||||
userId: '',
|
||||
action: ''
|
||||
});
|
||||
const wsRef = useRef(null);
|
||||
const wsReconnectTimer = useRef(null);
|
||||
const wsReconnectDelay = useRef(1000);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, 30000); // Refresh every 30 seconds
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, filters]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
const connect = () => {
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'metrics' && activeTab === 'metrics') {
|
||||
setMetrics(msg.data);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
ws.onopen = () => { wsReconnectDelay.current = 1000; };
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
wsReconnectTimer.current = setTimeout(() => {
|
||||
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
|
||||
connect();
|
||||
}, wsReconnectDelay.current);
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
wsRef.current = ws;
|
||||
} catch (_) {}
|
||||
};
|
||||
connect();
|
||||
return () => {
|
||||
clearTimeout(wsReconnectTimer.current);
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (activeTab === 'metrics') {
|
||||
const resp = await axios.get('/api/observability/metrics');
|
||||
@@ -42,7 +75,6 @@ function Observability() {
|
||||
} catch (error) {
|
||||
// Error loading data
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -49,12 +49,14 @@ function ResourceDetail() {
|
||||
const [maintenanceForm, setMaintenanceForm] = useState({ start_time: '', end_time: '', reason: '' });
|
||||
|
||||
useEffect(() => {
|
||||
loadResource();
|
||||
loadChecks();
|
||||
loadIncidents();
|
||||
loadSla();
|
||||
loadMaintenanceWindows();
|
||||
loadChartChecks();
|
||||
Promise.all([
|
||||
loadResource(),
|
||||
loadChecks(),
|
||||
loadIncidents(),
|
||||
loadSla(),
|
||||
loadMaintenanceWindows(),
|
||||
loadChartChecks(),
|
||||
]).catch(() => {});
|
||||
const interval = setInterval(loadResource, 5000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -14,6 +14,9 @@ function SettingsWizard() {
|
||||
webhook_enabled: false,
|
||||
webhook_url: '',
|
||||
webhook_template: '',
|
||||
ntfy_enabled: false,
|
||||
ntfy_url: 'https://ntfy.sh',
|
||||
ntfy_topic: '',
|
||||
check_interval: 60000,
|
||||
timeout: 5000,
|
||||
timezone: 'UTC',
|
||||
@@ -101,9 +104,28 @@ function SettingsWizard() {
|
||||
});
|
||||
setTestResult({ success: true, message: response.data.message });
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error.response?.data?.error || 'Failed to send test webhook'
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error.response?.data?.error || 'Failed to send test webhook'
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNtfy = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const response = await axios.post('/api/test-ntfy', {
|
||||
ntfy_url: settings.ntfy_url,
|
||||
ntfy_topic: settings.ntfy_topic,
|
||||
});
|
||||
setTestResult({ success: true, message: response.data.message });
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error.response?.data?.error || 'Failed to send ntfy notification',
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
@@ -338,6 +360,79 @@ function SettingsWizard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ntfy.sh Settings */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-header">
|
||||
<h3>📲 ntfy.sh Push Notifications</h3>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.ntfy_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, ntfy_enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.ntfy_enabled && (
|
||||
<div className="settings-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>ntfy Server URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.ntfy_url}
|
||||
onChange={(e) => setSettings({ ...settings, ntfy_url: e.target.value })}
|
||||
placeholder="https://ntfy.sh"
|
||||
/>
|
||||
<small>Use <code>https://ntfy.sh</code> for the public server, or your self-hosted URL</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Topic</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ntfy_topic}
|
||||
onChange={(e) => setSettings({ ...settings, ntfy_topic: e.target.value })}
|
||||
placeholder="my-skywatch-alerts"
|
||||
/>
|
||||
<small>Subscribe to this topic in the ntfy app to receive push notifications</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<strong>💡 Setup:</strong>
|
||||
<ol>
|
||||
<li>Install the <strong>ntfy</strong> app on your phone (iOS or Android)</li>
|
||||
<li>Subscribe to your topic name above</li>
|
||||
<li>Send a test notification to confirm it works</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestNtfy}
|
||||
disabled={testing || !settings.ntfy_topic}
|
||||
>
|
||||
{testing ? 'Sending...' : '📲 Send Test Notification'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleSaveSection('ntfy', {
|
||||
ntfy_enabled: settings.ntfy_enabled,
|
||||
ntfy_url: settings.ntfy_url,
|
||||
ntfy_topic: settings.ntfy_topic,
|
||||
})}
|
||||
disabled={savingSection === 'ntfy'}
|
||||
>
|
||||
{savingSection === 'ntfy' ? 'Saving...' : '💾 Save ntfy Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div className={`test-result ${testResult.success ? 'success' : 'error'}`}>
|
||||
|
||||
@@ -12,9 +12,10 @@ function Status() {
|
||||
|
||||
useEffect(() => {
|
||||
loadStatusData();
|
||||
const interval = setInterval(loadStatusData, 15000); // Refresh every 15 seconds for critical status
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(loadStatusData, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [autoRefresh]);
|
||||
|
||||
const loadStatusData = async () => {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user