Feature: Add public status page at /status showing incidents, maintenance, and system health

This commit is contained in:
nmemmert committed 2025-12-16 13:44:02 -05:00
1 parent 9dae22d696
commit 400953dfd7
14 files changed
+634 -17

No files matched your search

+2
View File
@@ -6,6 +6,7 @@ import './App.css';
import SettingsWizard from './SettingsWizard';
import History from './History';
import SLA from './SLA';
import Status from './Status';
import { formatLocalTime, formatChartTime } from './utils/timeUtils';
// Simple duration formatter for incident spans
@@ -1647,6 +1648,7 @@ function App() {
<Route path="/history" element={<History />} />
<Route path="/sla" element={<SLA />} />
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
<Route path="/status" element={<Status />} />
</Routes>
</div>
</Router>
+325
View File
@@ -0,0 +1,325 @@
.status-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.status-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
border-bottom: 2px solid #eee;
padding-bottom: 1.5rem;
}
.status-header h1 {
margin: 0 0 0.5rem 0;
font-size: 2.5rem;
color: #333;
}
.status-subtitle {
margin: 0;
color: #666;
font-size: 1rem;
}
.status-refresh {
text-align: right;
}
.status-refresh label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
cursor: pointer;
}
.status-refresh input[type='checkbox'] {
cursor: pointer;
}
.last-updated {
margin: 0;
font-size: 0.85rem;
color: #666;
}
.status-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.summary-value {
font-size: 2rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.summary-label {
font-size: 0.9rem;
color: #666;
margin: 0;
}
.status-section {
margin-bottom: 2rem;
}
.status-section h2 {
font-size: 1.5rem;
color: #333;
margin: 0 0 1rem 0;
border-bottom: 2px solid #f0f0f0;
padding-bottom: 0.5rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.status-card {
border: 2px solid #ddd;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.status-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.status-card-up {
border-color: #4caf50;
background-color: #f1f8f6;
}
.status-card-down {
border-color: #f44336;
background-color: #fdeaea;
}
.status-card-maintenance {
border-color: #ff9800;
background-color: #fff3e0;
}
.status-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.status-card h3 {
margin: 0;
font-size: 1.2rem;
color: #333;
flex: 1;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: bold;
white-space: nowrap;
}
.status-badge-up {
background-color: #4caf50;
color: white;
}
.status-badge-down {
background-color: #f44336;
color: white;
}
.status-badge-maintenance {
background-color: #ff9800;
color: white;
}
.resource-url {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.resource-url a {
color: #0066cc;
text-decoration: none;
}
.resource-url a:hover {
text-decoration: underline;
}
.incident-info {
background-color: rgba(244, 67, 54, 0.1);
border-left: 3px solid #f44336;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.incident-info p {
margin: 0.3rem 0;
font-size: 0.9rem;
}
.incident-title {
font-weight: bold;
color: #f44336;
margin-bottom: 0.5rem !important;
}
.incident-started {
color: #666;
}
.incident-description {
font-size: 0.85rem;
color: #333;
font-family: monospace;
white-space: pre-wrap;
word-break: break-word;
max-height: 100px;
overflow-y: auto;
}
.incident-duration {
color: #666;
font-weight: bold;
margin-top: 0.5rem !important;
}
.maintenance-info {
background-color: rgba(255, 152, 0, 0.1);
border-left: 3px solid #ff9800;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
.maintenance-info p {
margin: 0.3rem 0;
font-size: 0.9rem;
}
.maintenance-title {
font-weight: bold;
color: #ff9800;
margin-bottom: 0.5rem !important;
}
.maintenance-window {
color: #666;
}
.status-operational {
color: #4caf50;
font-weight: bold;
text-align: center;
padding: 1rem;
}
.maintenance-table,
.incidents-table {
width: 100%;
border-collapse: collapse;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.maintenance-table thead,
.incidents-table thead {
background-color: #f5f5f5;
border-bottom: 2px solid #ddd;
}
.maintenance-table th,
.incidents-table th {
padding: 1rem;
text-align: left;
font-weight: bold;
color: #333;
}
.maintenance-table td,
.incidents-table td {
padding: 1rem;
border-bottom: 1px solid #eee;
}
.maintenance-table tr:hover,
.incidents-table tr:hover {
background-color: #f9f9f9;
}
.incident-desc-cell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 0.9rem;
color: #555;
}
@media (max-width: 768px) {
.status-container {
padding: 1rem;
}
.status-header {
flex-direction: column;
}
.status-header h1 {
font-size: 1.8rem;
}
.status-refresh {
text-align: left;
margin-top: 1rem;
}
.status-grid {
grid-template-columns: 1fr;
}
.maintenance-table,
.incidents-table {
font-size: 0.85rem;
}
.maintenance-table td,
.incidents-table td,
.maintenance-table th,
.incidents-table th {
padding: 0.5rem;
}
}
+257
View File
@@ -0,0 +1,257 @@
import React, { useEffect, useState } from 'react';
import axios from 'axios';
import './Status.css';
function Status() {
const [resources, setResources] = useState([]);
const [incidents, setIncidents] = useState([]);
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [lastUpdated, setLastUpdated] = useState(new Date());
useEffect(() => {
loadStatusData();
const interval = setInterval(loadStatusData, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, []);
const loadStatusData = async () => {
try {
const [resourcesRes, incidentsRes, maintenanceRes] = await Promise.all([
axios.get('/api/resources'),
axios.get('/api/incidents'),
axios.get('/api/maintenance-windows'),
]);
setResources(resourcesRes.data.resources || []);
setIncidents(incidentsRes.data.incidents || []);
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
setLastUpdated(new Date());
} catch (error) {
console.error('Error loading status data:', error);
} finally {
setLoading(false);
}
};
const formatLocalTime = (timestamp) => {
try {
return new Date(timestamp).toLocaleString();
} catch {
return timestamp;
}
};
const formatDuration = (ms) => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m`;
return `${seconds}s`;
};
const getResourceStatus = (resourceId) => {
const activeIncident = incidents.find(
(inc) => inc.resource_id === resourceId && !inc.resolved_at
);
return activeIncident ? 'down' : 'up';
};
const upCount = resources.filter((r) => getResourceStatus(r.id) === 'up').length;
const downCount = resources.filter((r) => getResourceStatus(r.id) === 'down').length;
if (loading) {
return <div className="status-container"><p>Loading status...</p></div>;
}
return (
<div className="status-container">
<div className="status-header">
<div>
<h1>System Status</h1>
<p className="status-subtitle">Real-time monitoring status</p>
</div>
<div className="status-refresh">
<label>
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
Auto-refresh
</label>
<p className="last-updated">Last updated: {formatLocalTime(lastUpdated)}</p>
</div>
</div>
<div className="status-summary">
<div className="summary-card">
<div className="summary-value" style={{ color: '#4caf50' }}>
{upCount}
</div>
<div className="summary-label">Services Up</div>
</div>
<div className="summary-card">
<div className="summary-value" style={{ color: '#f44336' }}>
{downCount}
</div>
<div className="summary-label">Services Down</div>
</div>
<div className="summary-card">
<div className="summary-value" style={{ color: '#ff9800' }}>
{maintenanceWindows.filter((m) => {
const now = new Date();
const start = new Date(m.start_time);
const end = new Date(m.end_time);
return now >= start && now <= end;
}).length}
</div>
<div className="summary-label">Maintenance</div>
</div>
</div>
<div className="status-section">
<h2>Service Status</h2>
<div className="status-grid">
{resources.map((resource) => {
const status = getResourceStatus(resource.id);
const activeIncident = incidents.find(
(inc) => inc.resource_id === resource.id && !inc.resolved_at
);
const inMaintenance = maintenanceWindows.some((m) => {
const now = new Date();
const start = new Date(m.start_time);
const end = new Date(m.end_time);
return m.resource_id === resource.id && now >= start && now <= end;
});
return (
<div
key={resource.id}
className={`status-card status-${inMaintenance ? 'maintenance' : status}`}
>
<div className="status-card-header">
<h3>{resource.name}</h3>
<span className={`status-badge status-badge-${inMaintenance ? 'maintenance' : status}`}>
{inMaintenance ? 'Maintenance' : status === 'up' ? 'Operational' : 'Incident'}
</span>
</div>
{resource.url && (
<p className="resource-url">
<a href={resource.url} target="_blank" rel="noopener noreferrer">
{resource.url}
</a>
</p>
)}
{activeIncident && (
<div className="incident-info">
<p className="incident-title">Active Incident</p>
<p className="incident-started">
Started: {formatLocalTime(activeIncident.started_at)}
</p>
{activeIncident.description && (
<p className="incident-description">{activeIncident.description}</p>
)}
<p className="incident-duration">
Duration: {formatDuration(new Date() - new Date(activeIncident.started_at))}
</p>
</div>
)}
{inMaintenance && (
<div className="maintenance-info">
<p className="maintenance-title">Scheduled Maintenance</p>
<p className="maintenance-window">
{formatLocalTime(
maintenanceWindows.find(
(m) =>
m.resource_id === resource.id &&
new Date() >= new Date(m.start_time) &&
new Date() <= new Date(m.end_time)
)?.start_time
)}
</p>
</div>
)}
{status === 'up' && !inMaintenance && (
<div className="status-operational">
<p>✓ All systems operational</p>
</div>
)}
</div>
);
})}
</div>
</div>
{maintenanceWindows.length > 0 && (
<div className="status-section">
<h2>Scheduled Maintenance</h2>
<table className="maintenance-table">
<thead>
<tr>
<th>Service</th>
<th>Start Time</th>
<th>End Time</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{maintenanceWindows.map((maint) => (
<tr key={maint.id}>
<td>
{resources.find((r) => r.id === maint.resource_id)?.name || 'Unknown'}
</td>
<td>{formatLocalTime(maint.start_time)}</td>
<td>{formatLocalTime(maint.end_time)}</td>
<td>{maint.reason || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{incidents.filter((inc) => !inc.resolved_at).length > 0 && (
<div className="status-section">
<h2>Active Incidents</h2>
<table className="incidents-table">
<thead>
<tr>
<th>Service</th>
<th>Started</th>
<th>Duration</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{incidents
.filter((inc) => !inc.resolved_at)
.map((incident) => (
<tr key={incident.id}>
<td>
{resources.find((r) => r.id === incident.resource_id)?.name || 'Unknown'}
</td>
<td>{formatLocalTime(incident.started_at)}</td>
<td>
{formatDuration(new Date() - new Date(incident.started_at))}
</td>
<td className="incident-desc-cell">{incident.description || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
export default Status;