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 { 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 [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, tags: '', consecutive_failures_threshold: 1, response_time_threshold: null, }); const [tagFilter, setTagFilter] = useState(''); const [groupData, setGroupData] = useState({ name: '', description: '' }); const wsRef = useRef(null); const actionsRef = useRef(null); const navigate = useNavigate(); const loadResources = useCallback(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 connectWebSocket = useCallback(() => { try { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}`; const ws = new WebSocket(wsUrl); ws.onopen = () => { console.log('WebSocket connected'); // 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 || []); } } catch (error) { console.error('Error parsing WebSocket message:', error); } }; ws.onerror = (error) => { console.error('WebSocket error:', error); }; ws.onclose = () => { console.log('WebSocket disconnected, will use fallback polling'); wsRef.current = null; }; wsRef.current = ws; } catch (error) { console.error('Error connecting WebSocket:', 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) { 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, tags: '', consecutive_failures_threshold: 1, response_time_threshold: null, }); loadResources(); } catch (error) { console.error('Error creating resource:', 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) { 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 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) { console.error('Error exporting resources:', 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) { console.error('Error importing resources:', error); alert('Error importing resources'); } // Reset file input e.target.value = ''; }; const toggleGroup = (groupId) => { setCollapsedGroups(prev => ({ ...prev, [groupId]: !prev[groupId] })); }; // Filter resources by tag const filteredResources = tagFilter ? resources.filter(r => { const tags = (r.tags || '').toLowerCase(); return tags.includes(tagFilter.toLowerCase()); }) : resources; const groupResourcesMap = filteredResources.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.maintenance_mode && ( 🛠️ )}
)}

{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

setTagFilter(e.target.value)} style={{ padding: '0.5rem 0.75rem', borderRadius: '4px', border: '1px solid #ddd', minWidth: '160px' }} />
{actionsOpen && (

)}
{resources.length === 0 ? (

No resources yet

Add your first resource to start monitoring

) : filteredResources.length === 0 ? (

No resources match filter

Try adjusting your tag filter

) : (
{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

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" />