Add trends UI, cleanup logs, refresh build

This commit is contained in:
nmemmert committed 2025-12-16 08:48:48 -05:00
1 parent 71d1dbd5c1
commit d4e4467c0c
20 files changed
+1739 -90

No files matched your search

+19 -1
View File
@@ -393,6 +393,16 @@
}
/* Mobile Responsive Styles */
@media (max-width: 1024px) {
.dashboard-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.container {
padding: 1.5rem;
}
}
@media (max-width: 768px) {
.navbar {
padding: 0.75rem 1rem;
@@ -434,7 +444,7 @@
}
.dashboard-grid {
grid-template-columns: 1fr;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
@@ -496,6 +506,14 @@
justify-content: flex-end;
gap: 0.5rem;
}
table {
font-size: 0.85rem;
}
th, td {
padding: 0.5rem !important;
}
}
@media (max-width: 480px) {
+339 -11
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
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';
@@ -42,17 +42,16 @@ function Dashboard() {
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 navigate = useNavigate();
useEffect(() => {
loadResources();
const interval = setInterval(loadResources, 5000);
return () => clearInterval(interval);
}, []);
const loadResources = async () => {
const loadResources = useCallback(async () => {
try {
const response = await axios.get('/api/dashboard');
setResources(response.data.resources);
@@ -60,7 +59,62 @@ function Dashboard() {
} 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]);
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();
@@ -95,6 +149,9 @@ function Dashboard() {
sla_target: 99.9,
email_to: '',
maintenance_mode: false,
tags: '',
consecutive_failures_threshold: 1,
response_time_threshold: null,
});
loadResources();
} catch (error) {
@@ -132,6 +189,48 @@ function Dashboard() {
}
};
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,
@@ -139,7 +238,15 @@ function Dashboard() {
}));
};
const groupResourcesMap = resources.reduce((acc, r) => {
// 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);
@@ -247,13 +354,33 @@ function Dashboard() {
<div className="container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2>SkyWatch Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Filter by tag"
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
style={{ padding: '0.5rem 0.75rem', borderRadius: '4px', border: '1px solid #ddd', minWidth: '160px' }}
/>
<button className="btn btn-secondary" onClick={() => setShowGroupModal(true)}>
+ New Group
</button>
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
+ Add Resource
</button>
<button className="btn btn-secondary" onClick={handleExportCSV}>
⬇ Export CSV
</button>
<button className="btn btn-secondary" onClick={() => document.getElementById('csv-import').click()}>
⬆ Import CSV
</button>
<input
id="csv-import"
type="file"
accept=".csv"
style={{ display: 'none' }}
onChange={handleImportCSV}
/>
</div>
</div>
@@ -262,6 +389,11 @@ function Dashboard() {
<h3>No resources yet</h3>
<p>Add your first resource to start monitoring</p>
</div>
) : filteredResources.length === 0 ? (
<div className="empty-state">
<h3>No resources match filter</h3>
<p>Try adjusting your tag filter</p>
</div>
) : (
<div>
{groups.map((group) => {
@@ -511,6 +643,39 @@ function Dashboard() {
/>
</div>
<div className="form-group">
<label>Tags (comma-separated, e.g., frontend,production,critical)</label>
<input
type="text"
placeholder="Enter tags to organize resources"
value={formData.tags || ''}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>Consecutive Failures Before Alert</label>
<input
type="number"
min="1"
value={formData.consecutive_failures_threshold || 1}
onChange={(e) => setFormData({ ...formData, consecutive_failures_threshold: parseInt(e.target.value) })}
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert only after this many consecutive failures</small>
</div>
<div className="form-group">
<label>Response Time Threshold (ms)</label>
<input
type="number"
placeholder="e.g., 2000"
value={formData.response_time_threshold || ''}
onChange={(e) => setFormData({ ...formData, response_time_threshold: e.target.value ? parseInt(e.target.value) : null })}
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert if response time exceeds this value</small>
</div>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowModal(false)}>
Cancel
@@ -673,6 +838,39 @@ function Dashboard() {
/>
</div>
<div className="form-group">
<label>Tags (comma-separated)</label>
<input
type="text"
placeholder="Enter tags to organize resources"
value={editData.tags || ''}
onChange={(e) => setEditData({ ...editData, tags: e.target.value })}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>Consecutive Failures Before Alert</label>
<input
type="number"
min="1"
value={editData.consecutive_failures_threshold || 1}
onChange={(e) => setEditData({ ...editData, consecutive_failures_threshold: parseInt(e.target.value) })}
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert only after this many consecutive failures</small>
</div>
<div className="form-group">
<label>Response Time Threshold (ms)</label>
<input
type="number"
placeholder="e.g., 2000"
value={editData.response_time_threshold || ''}
onChange={(e) => setEditData({ ...editData, response_time_threshold: e.target.value ? parseInt(e.target.value) : null })}
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert if response time exceeds this value</small>
</div>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowEditModal(false)}>
Cancel
@@ -712,11 +910,20 @@ function ResourceDetail() {
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
@@ -793,6 +1000,41 @@ function ResourceDetail() {
}
};
const loadMaintenanceWindows = async () => {
try {
const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
setMaintenanceWindows(response.data.windows || []);
} catch (error) {
console.error('Error loading maintenance windows:', error);
}
};
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) {
console.error('Error creating maintenance window:', 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) {
console.error('Error deleting maintenance window:', error);
alert('Error deleting maintenance window');
}
}
};
const handleDelete = async () => {
if (window.confirm('Are you sure you want to delete this resource?')) {
try {
@@ -1055,6 +1297,92 @@ function ResourceDetail() {
</tbody>
</table>
</div>
<div className="detail-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2>Maintenance Windows</h2>
<button className="btn btn-primary" onClick={() => setShowMaintenanceModal(true)}>
+ Schedule Maintenance
</button>
</div>
{maintenanceWindows.length === 0 ? (
<p style={{ color: '#666', textAlign: 'center', padding: '2rem' }}>No maintenance windows scheduled</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Start Time</th>
<th style={{ padding: '0.75rem', textAlign: 'left' }}>End Time</th>
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Reason</th>
<th style={{ padding: '0.75rem', textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{maintenanceWindows.map(window => (
<tr key={window.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '0.75rem' }}>{formatLocalTime(window.start_time)}</td>
<td style={{ padding: '0.75rem' }}>{formatLocalTime(window.end_time)}</td>
<td style={{ padding: '0.75rem', color: '#666' }}>{window.reason || '-'}</td>
<td style={{ padding: '0.75rem', textAlign: 'right' }}>
<button
className="btn btn-danger"
style={{ padding: '0.3rem 0.6rem', fontSize: '0.85rem' }}
onClick={() => handleDeleteMaintenanceWindow(window.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{showMaintenanceModal && (
<div className="modal-overlay" onClick={() => setShowMaintenanceModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Schedule Maintenance Window</h2>
<form onSubmit={handleCreateMaintenanceWindow}>
<div className="form-group">
<label>Start Time *</label>
<input
type="datetime-local"
required
value={maintenanceForm.start_time}
onChange={(e) => setMaintenanceForm({ ...maintenanceForm, start_time: e.target.value })}
/>
</div>
<div className="form-group">
<label>End Time *</label>
<input
type="datetime-local"
required
value={maintenanceForm.end_time}
onChange={(e) => setMaintenanceForm({ ...maintenanceForm, end_time: e.target.value })}
/>
</div>
<div className="form-group">
<label>Reason (Optional)</label>
<textarea
rows="3"
placeholder="Planned maintenance, server upgrade, etc."
value={maintenanceForm.reason}
onChange={(e) => setMaintenanceForm({ ...maintenanceForm, reason: e.target.value })}
/>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowMaintenanceModal(false)}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Schedule
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+118 -4
View File
@@ -20,6 +20,7 @@ function History() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showAveraged, setShowAveraged] = useState(true);
const [showTrends, setShowTrends] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalItems, setTotalItems] = useState(0);
@@ -33,7 +34,6 @@ function History() {
const response = await axios.get('/api/history/overview', {
params: { days, page: currentPage, limit: pageSize, averaged: showAveraged }
});
console.log('History API Response:', response.data);
let data = [];
let total = 0;
@@ -47,7 +47,22 @@ function History() {
total = data.length;
}
console.log('Processed history data:', { data, total });
// Fetch trends data if enabled
if (showTrends && data.length > 0) {
const trendsPromises = data.map(async (resource) => {
try {
const trendsResponse = await axios.get(`/api/resources/${resource.id}/trends`, {
params: { days }
});
return { ...resource, trendsData: trendsResponse.data };
} catch (err) {
console.error(`Error loading trends for resource ${resource.id}:`, err);
return resource;
}
});
data = await Promise.all(trendsPromises);
}
setHistoryData(data);
setTotalItems(total);
setLoading(false);
@@ -59,7 +74,7 @@ function History() {
};
loadHistory();
}, [days, currentPage, pageSize, showAveraged]);
}, [days, currentPage, pageSize, showAveraged, showTrends]);
if (loading) return <div className="container">Loading history...</div>;
if (error) return <div className="container">Error loading history: {error}</div>;
@@ -89,6 +104,15 @@ function History() {
/>
<span>Show Averages</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
<input
type="checkbox"
checked={showTrends}
onChange={(e) => setShowTrends(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
<span>Show Week-over-Week Trends</span>
</label>
</div>
</div>
@@ -102,7 +126,6 @@ function History() {
{historyData.map((resource) => {
// Server already handles averaging when averaged=true is passed
const chartData = prepareChartData(resource.checks || []);
console.log(`${resource.name} - Total data points:`, chartData.length, 'Averaged mode:', showAveraged);
return (
<div key={resource.id} className="detail-section" style={{ marginBottom: '3rem', background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
@@ -188,6 +211,74 @@ function History() {
<div style={{ color: '#999', padding: '2rem', textAlign: 'center', background: '#fafafa', borderRadius: '6px' }}>No check data available</div>
)}
{showTrends && resource.trendsData && (
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
<h4 style={{ marginBottom: '1rem', color: '#667eea' }}>Week-over-Week Comparison</h4>
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.9rem' }}>
<div>
<strong>Response Time Change:</strong>{' '}
<span style={{ color: resource.trendsData.comparison.response_time_change >= 0 ? '#f44336' : '#4caf50' }}>
{resource.trendsData.comparison.response_time_change >= 0 ? '+' : ''}
{resource.trendsData.comparison.response_time_change.toFixed(1)}ms
</span>
</div>
<div>
<strong>Uptime Change:</strong>{' '}
<span style={{ color: resource.trendsData.comparison.uptime_change >= 0 ? '#4caf50' : '#f44336' }}>
{resource.trendsData.comparison.uptime_change >= 0 ? '+' : ''}
{resource.trendsData.comparison.uptime_change.toFixed(1)}%
</span>
</div>
</div>
<ResponsiveContainer width="100%" height="90%">
<ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
<XAxis
dataKey="day"
tick={{ fontSize: 12, fill: '#666' }}
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
tick={{ fontSize: 12, fill: '#666' }}
/>
<Tooltip
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
formatter={(value, name) => {
if (name === 'Current Period') return `${value}ms`;
if (name === 'Previous Period') return `${value}ms`;
return value;
}}
/>
<Legend wrapperStyle={{ paddingTop: '20px' }} />
<Area
type="monotone"
dataKey="currentResponseTime"
fill="#667eea"
stroke="#667eea"
name="Current Period"
fillOpacity={0.3}
strokeWidth={2}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="previousResponseTime"
fill="#95a5a6"
stroke="#95a5a6"
strokeDasharray="5 5"
name="Previous Period"
fillOpacity={0.1}
strokeWidth={2}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.checks?.length || 0}</p>
@@ -269,4 +360,27 @@ function prepareChartData(checks) {
});
}
function prepareTrendsChartData(trendsData) {
if (!trendsData || !trendsData.current || !trendsData.previous) return [];
const maxLength = Math.max(
trendsData.current.data.length,
trendsData.previous.data.length
);
const result = [];
for (let i = 0; i < maxLength; i++) {
const currentDay = trendsData.current.data[i];
const previousDay = trendsData.previous.data[i];
result.push({
day: `Day ${i + 1}`,
currentResponseTime: currentDay ? currentDay.avg_response_time : null,
previousResponseTime: previousDay ? previousDay.avg_response_time : null,
});
}
return result;
}
export default History;