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:
nmemmertandClaude Sonnet 4.6 committed 2026-08-06 13:24:47 -04:00
1 parent de4619992e
commit 8d50d3cb61
21 files changed
+668 -105

No files matched your search

+53
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+38 -6
View File
@@ -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 (
+8 -6
View File
@@ -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
+98 -3
View File
@@ -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'}`}>
+3 -2
View File
@@ -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 {