import React, { useState, useEffect, useRef, useCallback } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import './App.css';
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
function formatDuration(ms) {
if (!ms || ms < 0) return '0m';
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
function Dashboard() {
const [resources, setResources] = useState([]);
const [groups, setGroups] = useState([]);
const [showModal, setShowModal] = useState(false);
const [showGroupModal, setShowGroupModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [actionsOpen, setActionsOpen] = useState(false);
const [editData, setEditData] = useState({});
const [notifications, setNotifications] = useState([]);
const [formData, setFormData] = useState({
name: '',
url: '',
type: 'http',
check_interval: 60000,
timeout: 5000,
group_id: null,
http_keyword: '',
http_headers: '',
quiet_hours_start: '',
quiet_hours_end: '',
cert_expiry_days: 30,
sla_target: 99.9,
email_to: '',
maintenance_mode: false,
tags: '',
consecutive_failures_threshold: 1,
response_time_threshold: null,
});
const [tagFilter, setTagFilter] = useState('');
const [groupFilter, setGroupFilter] = useState('all');
const [groupData, setGroupData] = useState({ name: '', description: '' });
const wsRef = useRef(null);
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) {
// Error loading resources handled
}
}, []);
const connectWebSocket = useCallback(() => {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
// Send initial ping to keep alive
ws.send(JSON.stringify({ type: 'ping' }));
};
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) {
// Parse error handled silently
}
};
ws.onerror = (error) => {
// WebSocket error handled
};
ws.onclose = () => {
wsRef.current = null;
};
wsRef.current = ws;
} catch (error) {
loadResources();
}
}, [loadResources]);
// Close Actions menu on outside click
useEffect(() => {
const onDocClick = (e) => {
if (!actionsRef.current) return;
if (!actionsRef.current.contains(e.target)) {
setActionsOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
useEffect(() => {
// Try to connect via WebSocket
connectWebSocket();
// Fallback: poll every 15 seconds if WebSocket isn't available
const fallbackInterval = setInterval(loadResources, 15000);
return () => {
clearInterval(fallbackInterval);
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connectWebSocket, loadResources]);
const handleCreateGroup = async (e) => {
e.preventDefault();
try {
await axios.post('/api/groups', groupData);
setShowGroupModal(false);
setGroupData({ name: '', description: '' });
loadResources();
} catch (error) {
alert('Error creating group');
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await axios.post('/api/resources', formData);
setShowModal(false);
setFormData({
name: '',
url: '',
type: 'http',
check_interval: 60000,
timeout: 5000,
group_id: null,
http_keyword: '',
http_headers: '',
quiet_hours_start: '',
quiet_hours_end: '',
cert_expiry_days: 30,
sla_target: 99.9,
email_to: '',
maintenance_mode: false,
tags: '',
consecutive_failures_threshold: 1,
response_time_threshold: null,
});
loadResources();
} catch (error) {
alert('Error creating resource');
}
};
const handleEditResource = async (id) => {
try {
const resource = resources.find(r => r.id === id);
// Handle group change via PATCH if group_id changed
if (editData.group_id !== resource?.group_id) {
await axios.patch(`/api/resources/${id}/group`, {
group_id: editData.group_id || null
});
}
// Update other fields via PUT
await axios.put(`/api/resources/${id}`, editData);
setShowEditModal(false);
setEditData({});
loadResources();
} catch (error) {
alert('Error updating resource');
}
};
const openEditModal = (resource) => {
setEditData(resource);
setShowEditModal(true);
};
const handleDeleteResource = async (id) => {
if (window.confirm('Delete this resource?')) {
try {
await axios.delete(`/api/resources/${id}`);
loadResources();
} catch (error) {
alert('Error deleting resource');
}
}
};
const handleExportCSV = async () => {
try {
const response = await axios.get('/api/resources/export', {
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `resources-${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
link.parentChild.removeChild(link);
} catch (error) {
alert('Error exporting resources');
}
};
const handleImportCSV = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const response = await axios.post('/api/resources/import', text, {
headers: { 'Content-Type': 'text/csv' }
});
alert(`Imported ${response.data.count} resources successfully!`);
if (response.data.errors.length > 0) {
alert(`Errors:\n${response.data.errors.join('\n')}`);
}
loadResources();
} catch (error) {
alert('Error importing resources');
}
// Reset file input
e.target.value = '';
};
const groupNameById = groups.reduce((acc, g) => {
acc[g.id] = g.name;
return acc;
}, {});
// Filter resources by tag/group and sort for stable scanning
const filteredResources = resources
.filter((r) => {
if (tagFilter) {
const tags = (r.tags || '').toLowerCase();
if (!tags.includes(tagFilter.toLowerCase())) return false;
}
if (groupFilter === 'ungrouped') return !r.group_id;
if (groupFilter !== 'all') return String(r.group_id || '') === groupFilter;
return true;
})
.sort((a, b) => {
const groupA = (groupNameById[a.group_id] || 'Ungrouped').toLowerCase();
const groupB = (groupNameById[b.group_id] || 'Ungrouped').toLowerCase();
if (groupA < groupB) return -1;
if (groupA > groupB) return 1;
return String(a.name || '').localeCompare(String(b.name || ''));
});
const totalCount = filteredResources.length;
const upCount = filteredResources.filter((r) => r.status === 'up').length;
const downCount = filteredResources.filter((r) => r.status === 'down').length;
const maintenanceCount = filteredResources.filter((r) => r.maintenance_mode).length;
const avgUptime = totalCount
? (filteredResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / totalCount).toFixed(2)
: '0.00';
const avgResponse = totalCount
? Math.round(filteredResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0, 10), 0) / totalCount)
: 0;
const activeIncidents = filteredResources
.filter((r) => r.hasActiveIncident || r.status === 'down')
.slice(0, 8);
const groupCounts = groups
.map((g) => ({
id: g.id,
name: g.name,
count: filteredResources.filter((r) => String(r.group_id || '') === String(g.id)).length,
}))
.filter((g) => g.count > 0);
const ungroupedCount = filteredResources.filter((r) => !r.group_id).length;
const renderResourceRow = (resource) => {
const sparkData = (resource.recentChecks || []).map((c, idx) => ({
idx,
responseTime: c.response_time || 0,
statusValue: c.status === 'up' ? 1 : 0,
}));
const groupName = groupNameById[resource.group_id] || 'Ungrouped';
return (
!e.target.closest('.row-actions') && navigate(`/resource/${resource.id}`)}
>
{resource.name}
{groupName}
{resource.hasActiveIncident && active incident}
{resource.maintenance_mode && 🛠 maintenance}
{resource.url}
{resource.type}
{resource.uptime}%
Uptime
{resource.avgResponseTime}ms
Avg Resp
{sparkData.length > 1 ? (
) : (
No recent data
)}
{resource.status}
Last: {resource.lastCheck ? formatLocalTime(resource.lastCheck) : 'Never'}
);
};
return (
{/* Real-time Notifications */}
{notifications.length > 0 && (
{notifications.map(notif => (
{notif.title}
{notif.message}
))}
)}
Command Center
Live operational view of all monitors
Maintenance
{maintenanceCount}
{resources.length === 0 ? (
No resources yet
Add your first resource to start monitoring
) : filteredResources.length === 0 ? (
No resources match filter
Try adjusting your tag/group filters
) : (
{filteredResources.length} monitors
{filteredResources.map(renderResourceRow)}
)}
{showGroupModal && (
setShowGroupModal(false)}>
e.stopPropagation()}>
Create New Group
)}
{showModal && (
setShowModal(false)}>
e.stopPropagation()}>
Add New Resource
)}
{showEditModal && (
setShowEditModal(false)}>
e.stopPropagation()}>
Edit Resource
)}
);
}
function ResourceDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [resource, setResource] = useState(null);
const [loading, setLoading] = useState(true);
const [checks, setChecks] = useState([]);
const [checksLoading, setChecksLoading] = useState(false);
const [checksStatus, setChecksStatus] = useState('');
const [checksPage, setChecksPage] = useState(0);
const checksLimit = 10;
const [checksSort, setChecksSort] = useState('desc');
const [incidents, setIncidents] = useState([]);
const [incidentsLoading, setIncidentsLoading] = useState(false);
const [incidentsStatus, setIncidentsStatus] = useState('all');
const [incidentsPage, setIncidentsPage] = useState(0);
const incidentsLimit = 10;
const [incidentsSort, setIncidentsSort] = useState('desc');
const [expandedIncidentId, setExpandedIncidentId] = useState(null);
const [showIncidentModal, setShowIncidentModal] = useState(false);
const [editingIncident, setEditingIncident] = useState(null);
const [incidentDescription, setIncidentDescription] = useState('');
const [updatingIncident, setUpdatingIncident] = useState(false);
const [sla, setSla] = useState(null);
const [slaLoading, setSlaLoading] = useState(false);
const slaWindow = 24;
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const [maintenanceForm, setMaintenanceForm] = useState({
start_time: '',
end_time: '',
reason: ''
});
useEffect(() => {
loadResource();
loadChecks();
loadIncidents();
loadSla();
loadMaintenanceWindows();
const interval = setInterval(loadResource, 5000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
loadChecks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [checksStatus, checksPage, checksSort]);
useEffect(() => {
loadIncidents();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [incidentsStatus, incidentsPage, incidentsSort]);
const loadResource = async () => {
try {
const response = await axios.get(`/api/resources/${id}`);
setResource(response.data);
setLoading(false);
} catch (error) {
setLoading(false);
}
};
const loadChecks = async () => {
try {
setChecksLoading(true);
const response = await axios.get(`/api/resources/${id}/checks`, {
params: {
limit: checksLimit,
offset: checksPage * checksLimit,
status: checksStatus || undefined,
sort: checksSort,
},
});
setChecks(response.data.checks || []);
} catch (error) {
// Checks load error handled
} finally {
setChecksLoading(false);
}
};
const loadIncidents = async () => {
try {
setIncidentsLoading(true);
const response = await axios.get(`/api/resources/${id}/incidents`, {
params: {
limit: incidentsLimit,
offset: incidentsPage * incidentsLimit,
status: incidentsStatus,
sort: incidentsSort,
},
});
setIncidents(response.data.incidents || []);
} catch (error) {
// Incidents load error handled
} finally {
setIncidentsLoading(false);
}
};
const handleEditIncident = (incident) => {
setEditingIncident(incident);
setIncidentDescription(incident.description || '');
setShowIncidentModal(true);
};
const handleUpdateIncident = async () => {
if (!editingIncident) return;
try {
setUpdatingIncident(true);
await axios.patch(`/api/incidents/${editingIncident.id}`, {
description: incidentDescription,
});
setShowIncidentModal(false);
setEditingIncident(null);
setIncidentDescription('');
loadIncidents(); // Reload to show updated description
} catch (error) {
alert('Failed to update incident: ' + (error.response?.data?.error || error.message));
} finally {
setUpdatingIncident(false);
}
};
const loadSla = async () => {
try {
setSlaLoading(true);
const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } });
setSla(response.data);
} catch (error) {
// SLA load error handled
} finally {
setSlaLoading(false);
}
};
const loadMaintenanceWindows = async () => {
try {
const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
setMaintenanceWindows(response.data.windows || []);
} catch (error) {
// Maintenance windows load error handled
}
};
const handleCreateMaintenanceWindow = async (e) => {
e.preventDefault();
try {
await axios.post(`/api/resources/${id}/maintenance-windows`, maintenanceForm);
setShowMaintenanceModal(false);
setMaintenanceForm({ start_time: '', end_time: '', reason: '' });
loadMaintenanceWindows();
alert('Maintenance window created');
} catch (error) {
alert('Error creating maintenance window');
}
};
const handleDeleteMaintenanceWindow = async (windowId) => {
if (window.confirm('Delete this maintenance window?')) {
try {
await axios.delete(`/api/maintenance-windows/${windowId}`);
loadMaintenanceWindows();
} catch (error) {
alert('Error deleting maintenance window');
}
}
};
const handleDelete = async () => {
if (window.confirm('Are you sure you want to delete this resource?')) {
try {
await axios.delete(`/api/resources/${id}`);
navigate('/');
} catch (error) {
alert('Error deleting resource');
}
}
};
const toggleEnabled = async () => {
try {
await axios.put(`/api/resources/${id}`, {
...resource,
enabled: !resource.enabled,
});
loadResource();
} catch (error) {
// Update resource error handled
}
};
const toggleMaintenance = async () => {
try {
await axios.put(`/api/resources/${id}`, {
...resource,
maintenance_mode: !resource.maintenance_mode,
});
loadResource();
} catch (error) {
// Update maintenance mode error handled
}
};
if (loading) return Loading...
;
if (!resource) return Resource not found
;
const chartData = resource.stats.checks.map((check) => ({
time: formatChartTime(check.checked_at),
responseTime: check.response_time,
status: check.status === 'up' ? 1 : 0,
}));
return (
← Back to Dashboard
{resource.name}
{resource.url}
Type: {resource.type}
{resource.hasActiveIncident && (
⚠️ Active Incident - Resource is currently DOWN
)}
{resource.maintenance_mode && (
🛠️ Maintenance mode active — alerts are suppressed
)}
{resource.stats.uptime}%
Uptime (24h)
{resource.stats.avgResponseTime}ms
Avg Response
{resource.stats.totalChecks}
Total Checks
{resource.lastCheck?.status || 'unknown'}
Current Status
SLA / SLO (last {slaWindow}h)
{slaLoading ? (
Loading SLA...
) : !sla ? (
No SLA data yet
) : (
{formatDuration(sla.downtimeMinutes * 60000 || 0)}
Downtime
{sla.p95LatencyMs != null ? `${sla.p95LatencyMs}ms` : '—'}
p95 Latency
{sla.totalChecks}
Checks in window
{sla.mttrMinutes != null ? `${sla.mttrMinutes}m` : '—'}
MTTR
{sla.mtbfMinutes != null ? `${sla.mtbfMinutes}m` : '—'}
MTBF
)}
Response Time (Last 50 Checks)
Recent Checks
| Time |
Status |
Response Time |
Message |
{checksLoading ? (
| Loading checks... |
) : checks.length === 0 ? (
| No checks found |
) : checks.map((check) => (
|
{formatLocalTime(check.checked_at)}
|
{check.status}
|
{check.response_time ? `${check.response_time}ms` : '-'}
|
{check.error_message || 'OK'}
|
))}
Incidents Timeline
| Started |
Resolved |
Duration |
Reason |
{incidentsLoading ? (
| Loading incidents... |
) : incidents.length === 0 ? (
| No incidents found |
) : incidents.map((incident) => {
const start = incident.started_at || incident.created_at;
const end = incident.resolved_at;
const durationMs = start && end ? (new Date(end).getTime() - new Date(start).getTime()) : null;
const durationText = durationMs != null ? formatDuration(durationMs) : 'Ongoing';
const isExpanded = expandedIncidentId === incident.id;
const description = incident.description || 'No description';
const isTruncated = description.length > 60;
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
return (
<>
| {start ? formatLocalTime(start) : '-'} |
{end ? formatLocalTime(end) : 'Open'} |
{durationText} |
setExpandedIncidentId(isExpanded ? null : incident.id)}>
{displayText}
{isTruncated && (
{isExpanded ? '▼' : '▶'}
)}
|
{isExpanded && (
|
{description}
|
)}
>
);
})}
Maintenance Windows
{maintenanceWindows.length === 0 ? (
No maintenance windows scheduled
) : (
| Start Time |
End Time |
Reason |
Actions |
{maintenanceWindows.map(window => (
| {formatLocalTime(window.start_time)} |
{formatLocalTime(window.end_time)} |
{window.reason || '-'} |
|
))}
)}
{showMaintenanceModal && (
setShowMaintenanceModal(false)}>
)}
{showIncidentModal && editingIncident && (
setShowIncidentModal(false)}>
e.stopPropagation()}>
Edit Incident
{formatLocalTime(editingIncident.started_at || editingIncident.created_at)}
)}
);
}
function Navbar() {
const [notificationsConfigured, setNotificationsConfigured] = useState(false);
useEffect(() => {
checkNotifications();
}, []);
const checkNotifications = async () => {
try {
const response = await axios.get('/api/settings');
setNotificationsConfigured(response.data.email_enabled || response.data.webhook_enabled);
// 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) {
// Notifications check error handled
}
};
return (
);
}
function App() {
return (
} />
} />
} />
} />
} />
} />
} />
} />
);
}
export default App;