import React, { useState, useEffect } 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 { 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 [collapsedGroups, setCollapsedGroups] = useState({});
const [editingId, setEditingId] = useState(null);
const [editData, setEditData] = 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,
});
const [groupData, setGroupData] = useState({ name: '', description: '' });
const navigate = useNavigate();
useEffect(() => {
loadResources();
const interval = setInterval(loadResources, 5000);
return () => clearInterval(interval);
}, []);
const loadResources = async () => {
try {
const response = await axios.get('/api/dashboard');
setResources(response.data.resources);
setGroups(response.data.groups || []);
} catch (error) {
console.error('Error loading resources:', error);
}
};
const handleCreateGroup = async (e) => {
e.preventDefault();
try {
await axios.post('/api/groups', groupData);
setShowGroupModal(false);
setGroupData({ name: '', description: '' });
loadResources();
} catch (error) {
console.error('Error creating group:', 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,
});
loadResources();
} catch (error) {
console.error('Error creating resource:', error);
alert('Error creating resource');
}
};
const handleEditResource = async (id) => {
try {
await axios.put(`/api/resources/${id}`, editData);
setShowEditModal(false);
setEditData({});
loadResources();
} catch (error) {
console.error('Error updating resource:', 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) {
console.error('Error deleting resource:', error);
alert('Error deleting resource');
}
}
};
const toggleGroup = (groupId) => {
setCollapsedGroups(prev => ({
...prev,
[groupId]: !prev[groupId]
}));
};
const groupResourcesMap = resources.reduce((acc, r) => {
const gid = r.group_id || 'ungrouped';
if (!acc[gid]) acc[gid] = [];
acc[gid].push(r);
return acc;
}, {});
const getGroupStats = (groupResources) => {
if (!groupResources || groupResources.length === 0) return { uptime: 0, avgResponse: 0, total: 0 };
const uptime = (groupResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / groupResources.length).toFixed(2);
const avgResponse = (groupResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0), 0) / groupResources.length).toFixed(0);
return { uptime, avgResponse, total: groupResources.length };
};
const renderResourceCard = (resource) => {
const sparkData = (resource.recentChecks || []).map((c, idx) => ({
idx,
responseTime: c.response_time || 0,
statusValue: c.status === 'up' ? 1 : 0,
}));
const isEditing = editingId === resource.id;
return (
!isEditing && !e.target.closest('.card-actions') && navigate(`/resource/${resource.id}`)}
>
{isEditing ? (
setEditData({ ...editData, name: e.target.value })}
onClick={(e) => e.stopPropagation()}
className="edit-input"
placeholder="Resource name"
/>
) : (
{resource.name}
)}
{resource.url}
Type: {resource.type}
{resource.status}
{resource.hasActiveIncident && (
⚠️ Active Incident
)}
{ e.stopPropagation(); if (!isEditing) setEditingId(resource.id); }}>
{resource.uptime}%
Uptime (24h)
{ e.stopPropagation(); if (!isEditing) setEditingId(resource.id); }}>
{resource.avgResponseTime}ms
Avg Resp
{sparkData.length > 1 ? (
) : (
No recent data
)}
{!isEditing && resource.lastCheck && (
Last: {formatLocalTime(resource.lastCheck)}
)}
);
};
return (
SkyWatch Dashboard
{resources.length === 0 ? (
No resources yet
Add your first resource to start monitoring
) : (
{groups.map((group) => {
const groupStats = getGroupStats(groupResourcesMap[group.id]);
const isCollapsed = collapsedGroups[group.id];
return (
toggleGroup(group.id)}>
{isCollapsed ? '▶' : '▼'}
{group.name}
{groupStats.total} resources
{groupStats.uptime}% up
{groupStats.avgResponse}ms avg
{!isCollapsed && (
{groupResourcesMap[group.id]?.map(renderResourceCard)}
)}
);
})}
{groupResourcesMap['ungrouped']?.length > 0 && (
toggleGroup('ungrouped')}>
{collapsedGroups['ungrouped'] ? '▶' : '▼'}
Ungrouped
{groupResourcesMap['ungrouped'].length} resources
{getGroupStats(groupResourcesMap['ungrouped']).uptime}% up
{getGroupStats(groupResourcesMap['ungrouped']).avgResponse}ms avg
{!collapsedGroups['ungrouped'] && (
{groupResourcesMap['ungrouped'].map(renderResourceCard)}
)}
)}
)}
{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 [sla, setSla] = useState(null);
const [slaLoading, setSlaLoading] = useState(false);
const slaWindow = 24;
useEffect(() => {
loadResource();
loadChecks();
loadIncidents();
loadSla();
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) {
console.error('Error loading resource:', 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) {
console.error('Error loading checks:', error);
} 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) {
console.error('Error loading incidents:', error);
} finally {
setIncidentsLoading(false);
}
};
const loadSla = async () => {
try {
setSlaLoading(true);
const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } });
setSla(response.data);
} catch (error) {
console.error('Error loading SLA summary:', error);
} finally {
setSlaLoading(false);
}
};
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) {
console.error('Error deleting resource:', error);
alert('Error deleting resource');
}
}
};
const toggleEnabled = async () => {
try {
await axios.put(`/api/resources/${id}`, {
...resource,
enabled: !resource.enabled,
});
loadResource();
} catch (error) {
console.error('Error updating resource:', error);
}
};
const toggleMaintenance = async () => {
try {
await axios.put(`/api/resources/${id}`, {
...resource,
maintenance_mode: !resource.maintenance_mode,
});
loadResource();
} catch (error) {
console.error('Error updating maintenance mode:', error);
}
};
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 |
{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';
return (
| {start ? formatLocalTime(start) : '-'} |
{end ? formatLocalTime(end) : 'Open'} |
{durationText} |
);
})}
);
}
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
if (response.data.timezone) {
localStorage.setItem('serverTimezone', response.data.timezone);
}
} catch (error) {
console.error('Error checking notifications:', error);
}
};
return (
);
}
function App() {
return (
} />
} />
} />
} />
} />
);
}
export default App;