Add major features: mobile responsive, alert acknowledgment, quiet hours, HTTP keyword matching, custom headers, certificate expiry warnings, and SLA dashboard
This commit is contained in:
1 parent
c8a8cc581a
commit
1844bccbd4
14 files changed
+586
-124
No files matched your search
@@ -391,3 +391,136 @@
|
||||
color: #333;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsive Styles */
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.navbar h1 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.navbar-links {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-links a {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-actions button,
|
||||
.dashboard-actions select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
padding: 0.875rem;
|
||||
}
|
||||
|
||||
.resource-meta {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.resource-meta > div {
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 300px !important;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 0rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95%;
|
||||
max-width: none;
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.group-stats {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.navbar h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
||||
import './App.css';
|
||||
import SettingsWizard from './SettingsWizard';
|
||||
import History from './History';
|
||||
import SLA from './SLA';
|
||||
|
||||
function Dashboard() {
|
||||
const [resources, setResources] = useState([]);
|
||||
@@ -21,6 +22,12 @@ function Dashboard() {
|
||||
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,
|
||||
});
|
||||
const [groupData, setGroupData] = useState({ name: '', description: '' });
|
||||
const navigate = useNavigate();
|
||||
@@ -392,6 +399,75 @@ function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(formData.type === 'http' || formData.type === 'https' || formData.type === 'health') && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>Keyword to Match (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.http_keyword}
|
||||
onChange={(e) => setFormData({ ...formData, http_keyword: e.target.value })}
|
||||
placeholder="Text that must appear in response"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Custom Headers (JSON, optional)</label>
|
||||
<textarea
|
||||
value={formData.http_headers}
|
||||
onChange={(e) => setFormData({ ...formData, http_headers: e.target.value })}
|
||||
placeholder='{"Authorization": "Bearer token"}'
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{formData.type === 'tls' && (
|
||||
<div className="form-group">
|
||||
<label>Certificate Expiry Warning (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.cert_expiry_days}
|
||||
onChange={(e) => setFormData({ ...formData, cert_expiry_days: parseInt(e.target.value) })}
|
||||
min="1"
|
||||
max="90"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Quiet Hours Start (optional)</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.quiet_hours_start}
|
||||
onChange={(e) => setFormData({ ...formData, quiet_hours_start: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Quiet Hours End (optional)</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.quiet_hours_end}
|
||||
onChange={(e) => setFormData({ ...formData, quiet_hours_end: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>SLA Target (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={formData.sla_target}
|
||||
onChange={(e) => setFormData({ ...formData, sla_target: parseFloat(e.target.value) })}
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
@@ -588,6 +664,7 @@ function Navbar() {
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<Link to="/" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Dashboard</Link>
|
||||
<Link to="/history" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>History</Link>
|
||||
<Link to="/sla" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>SLA</Link>
|
||||
<Link to="/settings" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
Settings
|
||||
{!notificationsConfigured && (
|
||||
@@ -623,6 +700,7 @@ function App() {
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/resource/:id" element={<ResourceDetail />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/sla" element={<SLA />} />
|
||||
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
|
||||
function SLA() {
|
||||
const [slaData, setSlaData] = useState([]);
|
||||
const [days, setDays] = useState(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSLAData();
|
||||
}, [days]);
|
||||
|
||||
const loadSLAData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await axios.get('/api/sla', { params: { days } });
|
||||
setSlaData(response.data || []);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error loading SLA data:', error);
|
||||
setError(error.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const acknowledgeIncident = async (incidentId) => {
|
||||
try {
|
||||
await axios.post(`/api/incidents/${incidentId}/acknowledge`, {
|
||||
acknowledged_by: 'User'
|
||||
});
|
||||
alert('Incident acknowledged');
|
||||
} catch (error) {
|
||||
console.error('Error acknowledging incident:', error);
|
||||
alert('Error acknowledging incident');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="container">Loading SLA data...</div>;
|
||||
if (error) return <div className="container">Error loading SLA data: {error}</div>;
|
||||
|
||||
const overallStats = slaData.length > 0 ? {
|
||||
totalResources: slaData.length,
|
||||
meetingTarget: slaData.filter(s => s.meets_target).length,
|
||||
avgUptime: (slaData.reduce((sum, s) => sum + parseFloat(s.actual_uptime), 0) / slaData.length).toFixed(2),
|
||||
totalIncidents: slaData.reduce((sum, s) => sum + s.incidents, 0),
|
||||
totalDowntime: slaData.reduce((sum, s) => sum + s.downtime_minutes, 0),
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<h2>SLA Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
{[7, 14, 30, 90].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setDays(d)}
|
||||
>
|
||||
{d} Days
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{overallStats && (
|
||||
<div className="stats-grid" style={{ marginBottom: '2rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.totalResources}</p>
|
||||
<p className="stat-label">Total Resources</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2.5rem' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
|
||||
<p className="stat-label">Meeting SLA Target</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.avgUptime}%</p>
|
||||
<p className="stat-label">Average Uptime</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#ff9800', fontSize: '2.5rem' }}>{overallStats.totalIncidents}</p>
|
||||
<p className="stat-label">Total Incidents</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#f44336', fontSize: '2.5rem' }}>{Math.round(overallStats.totalDowntime)}m</p>
|
||||
<p className="stat-label">Total Downtime</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slaData.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No SLA data available</h3>
|
||||
<p>Add resources to start tracking SLA metrics</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#667eea', color: 'white' }}>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Resource</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Target</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Actual</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Status</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Checks</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Incidents</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Downtime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{slaData.map((item, idx) => (
|
||||
<tr key={item.resource_id} style={{ borderBottom: '1px solid #eee', background: idx % 2 === 0 ? '#fafafa' : '#fff' }}>
|
||||
<td style={{ padding: '1rem', fontWeight: 'bold' }}>{item.resource_name}</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center' }}>{item.sla_target}%</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#4caf50' : '#f44336' }}>
|
||||
{item.actual_uptime}%
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '12px',
|
||||
background: item.meets_target ? '#e8f5e9' : '#ffebee',
|
||||
color: item.meets_target ? '#2e7d32' : '#c62828',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{item.meets_target ? '✓ PASS' : '✗ FAIL'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
||||
{item.successful_checks}/{item.total_checks}
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center', color: item.incidents > 0 ? '#ff9800' : '#666' }}>
|
||||
{item.incidents}
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
||||
{item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<h3 style={{ marginTop: 0, color: '#667eea' }}>SLA Report Summary</h3>
|
||||
<p style={{ color: '#666', lineHeight: '1.6' }}>
|
||||
This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{days} days</strong>.
|
||||
Resources meeting their target uptime are marked as <strong style={{ color: '#4caf50' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#f44336' }}>FAIL</strong>.
|
||||
</p>
|
||||
<p style={{ color: '#666', lineHeight: '1.6', marginBottom: 0 }}>
|
||||
Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SLA;
|
||||
Reference in new issue
Block a user