Add trends UI, cleanup logs, refresh build
This commit is contained in:
1 parent
71d1dbd5c1
commit
d4e4467c0c
20 files changed
+1739
-90
No files matched your search
+19
-1
@@ -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
@@ -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
@@ -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;
|
||||
Reference in new issue
Block a user