Add heartbeat monitoring, alert rules, cert badges, webhook templates, and status page visibility

- Heartbeat type: cron jobs POST to /api/heartbeat/:token to stay alive; scheduler marks
  them down if no ping is received within the configured timeout window
- Alert rules: per-resource consecutive_failures_threshold now respected in handleIncident,
  overriding the global setting; GET /api/resources/:id/alert-rules endpoint added
- SSL cert expiry badge: dashboard rows surface days_remaining for TLS monitors, turning
  orange at ≤30 days and red at ≤7 days
- Webhook payload templates: {{name}}/{{status}}/{{url}}/{{type}}/{{message}}/{{timestamp}}
  variables interpolated before POST; Slack-compatible text wrapping as fallback
- Status page visibility: is_public column per resource; GET /api/status-page returns only
  public resources; Status.js updated to use the new endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-31 09:08:15 -04:00
1 parent 34986b5760
commit a6cf6a9762
16 files changed
+554 -422

No files matched your search

+149 -25
View File
@@ -49,7 +49,10 @@ function Dashboard() {
tags: '',
consecutive_failures_threshold: 1,
response_time_threshold: null,
is_public: true,
heartbeat_timeout: 300000,
});
const [searchText, setSearchText] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [groupFilter, setGroupFilter] = useState('all');
const [quickFilter, setQuickFilter] = useState('all');
@@ -57,6 +60,8 @@ function Dashboard() {
const [renderLimit, setRenderLimit] = useState(120);
const [groupData, setGroupData] = useState({ name: '', description: '' });
const wsRef = useRef(null);
const wsReconnectTimer = useRef(null);
const wsReconnectDelay = useRef(1000);
const actionsRef = useRef(null);
const navigate = useNavigate();
@@ -88,7 +93,7 @@ function Dashboard() {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
// Send initial ping to keep alive
wsReconnectDelay.current = 1000;
ws.send(JSON.stringify({ type: 'ping' }));
};
@@ -127,6 +132,10 @@ function Dashboard() {
ws.onclose = () => {
wsRef.current = null;
wsReconnectTimer.current = setTimeout(() => {
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
connectWebSocket();
}, wsReconnectDelay.current);
};
wsRef.current = ws;
@@ -156,6 +165,7 @@ function Dashboard() {
return () => {
clearInterval(fallbackInterval);
clearTimeout(wsReconnectTimer.current);
if (wsRef.current) {
wsRef.current.close();
}
@@ -170,14 +180,14 @@ function Dashboard() {
setGroupData({ name: '', description: '' });
loadResources();
} catch (error) {
alert('Error creating group');
showNotification('Error', 'Failed to create group', 'error');
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await axios.post('/api/resources', formData);
const res = await axios.post('/api/resources', formData);
setShowModal(false);
setFormData({
name: '',
@@ -197,10 +207,16 @@ function Dashboard() {
tags: '',
consecutive_failures_threshold: 1,
response_time_threshold: null,
is_public: true,
heartbeat_timeout: 300000,
});
if (res.data.heartbeat_token) {
const pingUrl = `${window.location.origin}/api/heartbeat/${res.data.heartbeat_token}`;
showNotification('Heartbeat Created', `Ping URL: ${pingUrl}`, 'success');
}
loadResources();
} catch (error) {
alert('Error creating resource');
showNotification('Error', 'Failed to add resource', 'error');
}
};
@@ -221,7 +237,7 @@ function Dashboard() {
setEditData({});
loadResources();
} catch (error) {
alert('Error updating resource');
showNotification('Error', 'Failed to update resource', 'error');
}
};
@@ -236,7 +252,7 @@ function Dashboard() {
await axios.delete(`/api/resources/${id}`);
loadResources();
} catch (error) {
alert('Error deleting resource');
showNotification('Error', 'Failed to delete resource', 'error');
}
}
};
@@ -248,7 +264,7 @@ function Dashboard() {
});
loadResources();
} catch (error) {
alert('Error updating maintenance mode');
showNotification('Error', 'Failed to update maintenance mode', 'error');
}
};
@@ -263,9 +279,9 @@ function Dashboard() {
link.setAttribute('download', `resources-${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
link.parentChild.removeChild(link);
link.parentNode.removeChild(link);
} catch (error) {
alert('Error exporting resources');
showNotification('Error', 'Failed to export resources', 'error');
}
};
@@ -279,13 +295,13 @@ function Dashboard() {
headers: { 'Content-Type': 'text/csv' }
});
alert(`Imported ${response.data.count} resources successfully!`);
showNotification('Import Complete', `Imported ${response.data.count} resource(s)`, 'success');
if (response.data.errors.length > 0) {
alert(`Errors:\n${response.data.errors.join('\n')}`);
showNotification('Import Warnings', response.data.errors[0], 'warning');
}
loadResources();
} catch (error) {
alert('Error importing resources');
showNotification('Error', 'Failed to import resources', 'error');
}
// Reset file input
@@ -318,9 +334,13 @@ function Dashboard() {
return statusScore + incidentScore + uptimePenalty + responsePenalty + maintenanceOffset;
};
// Filter resources by tag/group/quick filters
// Filter resources by search text, tag, group, and quick filters
const filteredResources = resources
.filter((r) => {
if (searchText) {
const q = searchText.toLowerCase();
if (!r.name.toLowerCase().includes(q) && !r.url.toLowerCase().includes(q)) return false;
}
if (tagFilter) {
const tags = (r.tags || '').toLowerCase();
if (!tags.includes(tagFilter.toLowerCase())) return false;
@@ -369,7 +389,7 @@ function Dashboard() {
useEffect(() => {
setRenderLimit(120);
}, [tagFilter, groupFilter, quickFilter, sortKey]);
}, [searchText, tagFilter, groupFilter, quickFilter, sortKey]);
const visibleResources = sortedResources.slice(0, renderLimit);
@@ -419,8 +439,17 @@ function Dashboard() {
<span className="resource-group-pill">{groupName}</span>
{resource.hasActiveIncident && <span className="incident-inline">active incident</span>}
{resource.maintenance_mode && <span className="maintenance-inline">🛠 maintenance</span>}
{resource.type === 'tls' && resource.certDaysRemaining !== null && resource.certDaysRemaining <= 30 && (
<span
className="maintenance-inline"
style={{ background: resource.certDaysRemaining <= 7 ? '#d32f2f' : '#f57c00', color: '#fff' }}
title={`SSL cert expires in ${resource.certDaysRemaining} days`}
>
🔒 cert exp {resource.certDaysRemaining}d
</span>
)}
</div>
<p className="resource-url row-url">{resource.url}</p>
<p className="resource-url row-url">{resource.type === 'heartbeat' ? '(heartbeat)' : resource.url}</p>
<p className="resource-type row-type">{resource.type}</p>
</div>
@@ -495,6 +524,13 @@ function Dashboard() {
</div>
<div className="dashboard-actions">
<NotificationCenter />
<input
type="text"
placeholder="Search monitors..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="tag-filter-input"
/>
<input
type="text"
placeholder="Filter by tag"
@@ -746,16 +782,18 @@ function Dashboard() {
/>
</div>
{formData.type !== 'heartbeat' && (
<div className="form-group">
<label>URL *</label>
<input
type="url"
value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
required
required={formData.type !== 'heartbeat'}
placeholder="https://your-zima.example.com"
/>
</div>
)}
<div className="form-group">
<label>Group</label>
@@ -783,9 +821,24 @@ function Dashboard() {
<option value="dns">DNS Lookup</option>
<option value="websocket">WebSocket</option>
<option value="icmp">ICMP Ping</option>
<option value="heartbeat">Heartbeat (cron job)</option>
</select>
</div>
{formData.type === 'heartbeat' && (
<div className="form-group">
<label>Heartbeat Timeout (ms)</label>
<input
type="number"
value={formData.heartbeat_timeout}
onChange={(e) => setFormData({ ...formData, heartbeat_timeout: parseInt(e.target.value) })}
min="10000"
step="1000"
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert if no ping received within this window (default 5 min)</small>
</div>
)}
<div className="form-group">
<label>Check Interval (ms)</label>
<input
@@ -796,6 +849,7 @@ function Dashboard() {
/>
</div>
{formData.type !== 'heartbeat' && (
<div className="form-group">
<label>Timeout (ms)</label>
<input
@@ -805,6 +859,7 @@ function Dashboard() {
min="1000"
/>
</div>
)}
{(formData.type === 'http' || formData.type === 'https' || formData.type === 'health') && (
<>
@@ -941,6 +996,16 @@ function Dashboard() {
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
id="is_public_new"
checked={!!formData.is_public}
onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })}
/>
<label htmlFor="is_public_new" style={{ margin: 0 }}>Show on public status page</label>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowModal(false)}>
Cancel
@@ -991,9 +1056,38 @@ function Dashboard() {
<option value="ping">Ping</option>
<option value="health">Health API</option>
<option value="tls">TLS Certificate</option>
<option value="heartbeat">Heartbeat (cron job)</option>
</select>
</div>
{editData.type === 'heartbeat' && (
<div className="form-group">
<label>Heartbeat Timeout (ms)</label>
<input
type="number"
value={editData.heartbeat_timeout || 300000}
onChange={(e) => setEditData({ ...editData, heartbeat_timeout: parseInt(e.target.value) })}
min="10000"
step="1000"
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert if no ping received within this window</small>
</div>
)}
{editData.type === 'heartbeat' && editData.heartbeat_token && (
<div className="form-group">
<label>Heartbeat Ping URL</label>
<input
type="text"
readOnly
value={`${window.location.origin}/api/heartbeat/${editData.heartbeat_token}`}
onClick={(e) => e.target.select()}
style={{ cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.85rem' }}
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>POST to this URL from your cron job or script</small>
</div>
)}
<div className="form-group">
<label>Check Interval (ms)</label>
<input
@@ -1163,6 +1257,16 @@ function Dashboard() {
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
id="is_public_edit"
checked={editData.is_public !== false && editData.is_public !== 0}
onChange={(e) => setEditData({ ...editData, is_public: e.target.checked })}
/>
<label htmlFor="is_public_edit" style={{ margin: 0 }}>Show on public status page</label>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowEditModal(false)}>
Cancel
@@ -1207,6 +1311,13 @@ function ResourceDetail() {
const [slaLoading, setSlaLoading] = useState(false);
const slaWindow = 24;
const [notifications, setNotifications] = useState([]);
const showNotification = useCallback((title, message, type = 'info') => {
const id = Date.now();
setNotifications(prev => [...prev, { id, title, message, type }]);
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 5000);
}, []);
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const [maintenanceForm, setMaintenanceForm] = useState({
@@ -1301,9 +1412,9 @@ function ResourceDetail() {
setShowIncidentModal(false);
setEditingIncident(null);
setIncidentDescription('');
loadIncidents(); // Reload to show updated description
loadIncidents();
} catch (error) {
alert('Failed to update incident: ' + (error.response?.data?.error || error.message));
showNotification('Error', error.response?.data?.error || 'Failed to update incident', 'error');
} finally {
setUpdatingIncident(false);
}
@@ -1337,9 +1448,9 @@ function ResourceDetail() {
setShowMaintenanceModal(false);
setMaintenanceForm({ start_time: '', end_time: '', reason: '' });
loadMaintenanceWindows();
alert('Maintenance window created');
showNotification('Success', 'Maintenance window created', 'success');
} catch (error) {
alert('Error creating maintenance window');
showNotification('Error', 'Failed to create maintenance window', 'error');
}
};
@@ -1349,7 +1460,7 @@ function ResourceDetail() {
await axios.delete(`/api/maintenance-windows/${windowId}`);
loadMaintenanceWindows();
} catch (error) {
alert('Error deleting maintenance window');
showNotification('Error', 'Failed to delete maintenance window', 'error');
}
}
};
@@ -1360,7 +1471,7 @@ function ResourceDetail() {
await axios.delete(`/api/resources/${id}`);
navigate('/');
} catch (error) {
alert('Error deleting resource');
showNotification('Error', 'Failed to delete resource', 'error');
}
}
};
@@ -1392,6 +1503,17 @@ function ResourceDetail() {
if (loading) return <div className="container">Loading...</div>;
if (!resource) return <div className="container">Resource not found</div>;
const toastStack = notifications.length > 0 && (
<div className="toast-stack">
{notifications.map(n => (
<div key={n.id} className={`toast-item toast-${n.type}`}>
<strong>{n.title}</strong>
<div>{n.message}</div>
</div>
))}
</div>
);
const chartData = resource.stats.checks.map((check) => ({
time: formatChartTime(check.checked_at),
responseTime: check.response_time,
@@ -1400,6 +1522,7 @@ function ResourceDetail() {
return (
<div className="container">
{toastStack}
<Link to="/" className="back-button">← Back to Dashboard</Link>
<div className="detail-section">
@@ -1617,8 +1740,8 @@ function ResourceDetail() {
const isTruncated = description.length > 60;
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
return (
<>
<tr key={incident.id}>
<React.Fragment key={incident.id}>
<tr>
<td>{start ? formatLocalTime(start) : '-'}</td>
<td>{end ? formatLocalTime(end) : 'Open'}</td>
<td>{durationText}</td>
@@ -1646,7 +1769,7 @@ function ResourceDetail() {
</td>
</tr>
)}
</>
</React.Fragment>
);
})}
</tbody>
@@ -1821,6 +1944,7 @@ function Navbar() {
<Link to="/sla" className="nav-link">SLA</Link>
<Link to="/observability" className="nav-link">Observability</Link>
<Link to="/notifications" className="nav-link">Notifications</Link>
<Link to="/status" className="nav-link" target="_blank" rel="noreferrer">Status</Link>
<Link to="/settings" className="nav-link settings-link">
Settings
{!notificationsConfigured && (
+16 -2
View File
@@ -13,6 +13,7 @@ function SettingsWizard() {
email_to: '',
webhook_enabled: false,
webhook_url: '',
webhook_template: '',
check_interval: 60000,
timeout: 5000,
timezone: 'UTC',
@@ -290,10 +291,22 @@ function SettingsWizard() {
<small>Receives POST requests with incident data when resources go offline/online</small>
</div>
<div className="form-group">
<label>Payload Template (optional)</label>
<textarea
value={settings.webhook_template}
onChange={(e) => setSettings({ ...settings, webhook_template: e.target.value })}
placeholder={'{"text": "{{name}} is {{status}} — {{url}}"}'}
rows="4"
style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
/>
<small>Variables: <code>{'{{name}}'}</code> <code>{'{{status}}'}</code> <code>{'{{url}}'}</code> <code>{'{{type}}'}</code> <code>{'{{message}}'}</code> <code>{'{{timestamp}}'}</code>. Leave blank for the default JSON payload.</small>
</div>
<div className="info-box">
<strong>💡 Webhook Payload Example:</strong>
<strong>Default payload (when no template is set):</strong>
<pre>{JSON.stringify({
resource: "ZimaOS Server",
resource: "My Server",
url: "https://example.com",
status: "down",
message: "Resource is DOWN",
@@ -314,6 +327,7 @@ function SettingsWizard() {
onClick={() => handleSaveSection('Webhook', {
webhook_enabled: settings.webhook_enabled,
webhook_url: settings.webhook_url,
webhook_template: settings.webhook_template,
})}
disabled={savingSection === 'Webhook'}
>
+4 -8
View File
@@ -18,14 +18,10 @@ function Status() {
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 || []);
const statusRes = await axios.get('/api/status-page');
setResources(statusRes.data.resources || []);
setIncidents(statusRes.data.incidents || []);
setMaintenanceWindows(statusRes.data.maintenanceWindows || []);
setLastUpdated(new Date());
} catch (error) {
// Status load error handled