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

setTagFilter(e.target.value)} className="tag-filter-input" />
{actionsOpen && (

)}

Monitors

{totalCount}

Up

{upCount}

Down

{downCount}

Maintenance

{maintenanceCount}

Avg Uptime

{avgUptime}%

Avg Resp

{avgResponse}ms

{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

) : (
Resource
Metrics
Status
{filteredResources.length} monitors
{filteredResources.map(renderResourceRow)}
)} {showGroupModal && (
setShowGroupModal(false)}>
e.stopPropagation()}>

Create New Group

setGroupData({ ...groupData, name: e.target.value })} required placeholder="e.g., Production Servers" />
setGroupData({ ...groupData, description: e.target.value })} placeholder="Optional description" />
)} {showModal && (
setShowModal(false)}>
e.stopPropagation()}>

Add New Resource

setFormData({ ...formData, name: e.target.value })} required placeholder="e.g., ZimaOS Home Server" />
setFormData({ ...formData, url: e.target.value })} required placeholder="https://your-zima.example.com" />
setFormData({ ...formData, check_interval: parseInt(e.target.value) })} min="10000" />
setFormData({ ...formData, timeout: parseInt(e.target.value) })} min="1000" />
{(formData.type === 'http' || formData.type === 'https' || formData.type === 'health') && ( <>
setFormData({ ...formData, http_keyword: e.target.value })} placeholder="Text that must appear in response" />