feat: Add real-time notification center with WebSocket integration

- Add in-app notification system with database storage
- Implement bell icon component with unread count badge in dashboard header
- Add full Notifications page tab with pagination and filtering (All/Unread/Read)
- Implement real-time WebSocket push notifications to all connected clients
- Add notification API endpoints: GET, PUT /read, DELETE, POST /clear
- Fix critical bug in notificationService where 'status' variable was undefined
- Add Notifications route to App.js routing
- Enhanced UI with notification popup in header and dedicated full-page view
- Auto-reconnect WebSocket with 5s timeout on disconnect
- Support rich notification content with resource details and timestamps
This commit is contained in:
nmemmert committed 2026-01-14 14:51:29 -05:00
1 parent 7968c9231e
commit db85573986
37 files changed
+3521 -207

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
# Real-Time Updates & Observability Implementation
## 🟢 Completed Features
### 1. **Real-Time WebSocket Push Notifications**
#### Server-Side Enhancements (`server/index.js`):
- **Enhanced Broadcast Functions**: Created 4 specialized broadcast functions
- `broadcastDashboardUpdate()` - Dashboard state changes
- `broadcastAlert()` - Real-time alerts
- `broadcastIncident()` - Incident updates (started/resolved)
- `broadcastMetrics()` - Performance metrics updates
- **Message Types**: Structured WebSocket messages with typed payloads
- `type: 'dashboard'` - Full dashboard refresh
- `type: 'alert'` - Individual alerts
- `type: 'incident'` - Incident lifecycle events
- `type: 'metrics'` - Performance metrics
#### Client-Side Enhancements (`client/src/App.js`):
- **Enhanced WebSocket Handler**: Receives and processes all message types
- **Real-Time Notifications UI**: Fixed toast notifications appear in top-right corner
- Auto-dismiss after 5 seconds
- Color-coded by severity (error/success/info)
- Non-blocking, stacks vertically
- **Automatic Dashboard Refresh**: Refreshes on incident events for real-time data
### 2. **Application Metrics Tracking**
#### New Module: `server/metrics.js`
- **API Request Tracking**:
- Endpoint, method, status code, response time
- Per-endpoint aggregation with min/max/average times
- Success/error counts and status code breakdown
- **Error Tracking**:
- Centralized error logging with stack traces
- Context capture (method, URL, IP address)
- Error count aggregation
- **Metrics API Endpoint**: `GET /api/observability/metrics`
- Server uptime
- API statistics (request counts, success rates, response times)
- Error counts (total and unique)
### 3. **Audit Logging System**
#### New Module: `server/auditLog.js`
- **Audit Event Tracking**:
- `log()` - Generic audit event logging
- `logResourceChange()` - Resource CRUD events
- `logSettingsChange()` - Configuration changes
- `logGroupChange()` - Group management
- `logMaintenanceChange()` - Maintenance window events
- **Event Details Captured**:
- Action (create/update/delete)
- Entity type and ID
- User ID and IP address
- Changes before/after values
- Timestamp
- **Audit Log Query API**: `GET /api/observability/audit-logs`
- Filters: entityType, entityId, userId, action, dateRange
- Summary statistics (events by action type)
- **Audit Summary**: `GET /api/observability/audit-summary`
- 7-day activity breakdown by action
#### Database Integration:
- **Audit Logs Table**: Stores all audit events with indexes
- **Error Logs Table**: Stores application errors with stack traces
### 4. **Error Tracking & Logging**
#### Features:
- **Error Collection**: Automatic error capture in metrics middleware
- **Error Storage**: Database persistence for error analysis
- **Recent Errors API**: `GET /api/observability/errors`
- Returns last 50 errors with context
- Includes stack traces for debugging
### 5. **Observability Dashboard** (`client/src/Observability.js`)
#### Tabs:
1. **API Metrics Tab**:
- Server uptime display
- Total API requests counter
- Error metrics
- Per-endpoint performance table with:
- Total requests
- Success rate %
- Average/min/max response times
- Status code distribution
2. **Errors Tab**:
- Recent errors list
- Error message, context, and timestamp
- Visual error log with syntax highlighting
3. **Audit Logs Tab**:
- 7-day activity summary
- Filterable audit log table
- Filters: Entity Type, Entity ID, User ID, Action
- Change history tracking
#### Navigation:
- Added "Observability" link to main navbar
- Accessible from dashboard navigation
### 6. **Comprehensive Middleware Integration**
#### Request Tracking Middleware:
```javascript
- Intercepts all API requests/responses
- Tracks endpoint, method, status code, duration
- Captures errors automatically
- Non-intrusive integration
```
#### Database Schema Updates (`server/database.js`):
- **audit_logs table**: Action, entity type/ID, user, changes, IP, timestamp
- **error_logs table**: Error message, stack trace, context, occurrence timestamp
- **Indexes**: Optimized queries for audit and error lookups
### 7. **Resource CRUD Audit Logging**
#### Automatic Audit Logs:
- Resource creation: Logs name, URL, type, enabled status
- Resource updates: Logs all changed fields
- Resource deletion: Logs deleted resource details
- Includes user/IP context for all operations
### 8. **New API Endpoints**
#### Authentication Endpoints:
- `GET /api/auth/csrf-token` - Generate CSRF tokens
- `POST /api/auth/refresh-token` - JWT token refresh
- `POST /api/auth/validate-password` - Password strength checking
#### Observability Endpoints:
- `GET /api/observability/metrics` - Performance metrics
- `GET /api/observability/errors` - Error logs (last 50)
- `GET /api/observability/audit-logs` - Searchable audit logs
- `GET /api/observability/audit-summary` - Activity summary
---
## 🎯 Real-Time Update Flow
```
Monitor Check Complete
↓
Incident Created/Resolved
↓
Server broadcasts via WebSocket:
- broadcastDashboardUpdate()
- broadcastIncident(incident)
↓
Client receives message
↓
Show toast notification
↓
Refresh dashboard data
↓
UI updates in real-time
```
---
## 📊 Observability Coverage
### What's Now Tracked:
- ✅ API performance (response times, success rates)
- ✅ Application errors (messages, stacks, context)
- ✅ User actions (resource CRUD, settings changes)
- ✅ System health (uptime, resource counts)
- ✅ Configuration changes (who, what, when)
### Available Dashboards:
1. **API Metrics**: Performance insights and endpoint health
2. **Error Tracking**: Centralized error log with debugging info
3. **Audit Logs**: Complete change history with filters
---
## 💾 Data Retention
- **Error Logs**: Stored indefinitely (consider adding cleanup job)
- **Audit Logs**: Stored indefinitely (consider adding cleanup job)
- **Metrics**: In-memory (resets on server restart)
---
## 🔐 Security Notes
- IP addresses captured for all audit events
- Error logs may contain sensitive data (review periodically)
- Audit logs provide full change history for compliance
- WebSocket messages sent to all connected clients (consider auth)
---
## 🚀 Next Steps
1. **Error Log Cleanup**: Add job to archive old error logs (> 90 days)
2. **Audit Log Retention**: Implement archive for old audit logs
3. **Real-time Metrics Push**: Broadcast metrics via WebSocket to Observability tab
4. **Performance Alerts**: Add threshold-based alerts in metrics
5. **Export Audit Reports**: PDF/CSV export of audit logs
6. **Metrics Dashboard Charts**: Add charts for error rate trends, response time trends
---
## 📝 Files Modified/Created
### New Files:
- `server/metrics.js` - Metrics collection
- `server/auditLog.js` - Audit logging
- `client/src/Observability.js` - Observability dashboard
### Modified Files:
- `server/database.js` - Added audit_logs and error_logs tables
- `server/index.js` - Added metrics middleware, observability endpoints, enhanced WebSocket
- `client/src/App.js` - Added notification UI, enhanced WebSocket handler, Observability route
### No Breaking Changes:
- All existing functionality preserved
- Backward compatible API
- Optional observability features
+90 -1
View File
@@ -9,7 +9,9 @@ A comprehensive monitoring system for tracking the uptime and performance of you
- 🔔 **Alert Notifications** - Email and webhook alerts when resources go offline
- 📝 **Incident Tracking** - Track when resources go down and recover
- ⚙️ **Configurable Checks** - Set custom check intervals and timeouts
- 🗄️ **Historical Data** - 7 days of check history with detailed statistics
- 🗄️ **Historical Data** - Configurable data retention with per-resource overrides
- 📊 **Observability Dashboard** - View metrics, errors, and audit logs with expandable changes
- 🔐 **Data Retention Management** - Global settings + per-resource retention overrides
## Quick Start
@@ -51,6 +53,93 @@ The History page shows detailed check data with two viewing modes:
See [Usage Guide → History Page](docs/USAGE.md#history-page) for details.
## Global Settings
Configure default settings for your entire monitoring system in the **Settings Tab**:
### 📧 Email Notifications
- SMTP configuration with Gmail, Outlook, Yahoo, or custom providers
- Test email functionality
- Fallback support for failed notifications
### 🔗 Webhook Notifications
- Send incident data to Slack, Discord, or custom webhooks
- Test webhook functionality
- Fallback webhook as backup
### ⏱️ Default Monitoring Settings
- **Check Interval** - How often to check resources (default: 60,000ms = 1 minute)
- **Request Timeout** - Maximum time to wait for a response (default: 5,000ms)
- **Data Retention** - How many days to keep check data (1-365 days, default: 30)
- **Timezone** - Local timezone for all timestamps
- **Consecutive Failures Threshold** - Number of failures before triggering alert (default: 3)
- **Incident Failure Threshold** - Number of failures before creating incident (default: 10)
- **Grace Period** - Wait time before alerting after first failure (default: 300s)
- **Downtime Threshold** - Minimum downtime before creating incident (default: 600s)
### 🔔 Alert Settings
- **Alert Retry Logic** - How many times to retry failed alerts (default: 3)
- **Retry Delay** - Wait time between retries (default: 60s)
- **Fallback Webhook** - Backup webhook if primary notifications fail
- **Global Quiet Hours** - Suppress non-critical alerts during specific times
- **Escalation Hours** - Escalate unresolved incidents after this time (default: 4 hours)
### 📊 Dashboard Settings
- **Default Sort Order** - How to sort resources in dashboard
- **Items Per Page** - Pagination size for resource lists (default: 20)
- **Dashboard Refresh Interval** - How often to fetch fresh data (default: 5,000ms = 5 seconds)
- **Auto Cleanup** - Automatically archive old check data based on retention period
## Data Retention System
SkyWatch supports **two-tier data retention**:
### Global Retention (Default)
Set in Settings → Default Monitoring Settings → "Global Data Retention"
- Applies to all resources by default
- Retained check data is archived after the specified number of days
- Can be overridden per-resource
### Per-Resource Retention (Override)
Set when creating or editing a resource:
- Leave blank to use global setting
- Enter a specific number (1-365) to override global retention for that resource
- Useful for critical services (longer retention) or dev environments (shorter retention)
Example:
- Global retention: 30 days
- Production API: 90 days (critical data)
- Dev server: 7 days (less important)
### How Archival Works
The scheduler runs daily at 2 AM to:
1. Check each resource for its retention setting (per-resource or global)
2. Move old checks to `archived_checks` table
3. Delete from active `checks` table
## Observability Dashboard
View detailed system observability data in the **Observability Tab**:
### 📊 Metrics
- API request counts and success rates
- Response time statistics
- Error counts by endpoint
- Performance trends
### ❌ Errors
- Recent errors with stack traces
- Error frequency and patterns
- Detailed error context
### 📋 Audit Logs
- Complete change history of all resources, settings, and groups
- Filter by entity type, ID, user, or action
- **Expandable Changes** - Click "▶ Show Changes" to see full JSON of what changed
- Shows old values and new values
- Helps track configuration changes over time
- Click "▼ Hide" to collapse
## API Endpoints
- `GET /api/resources` - List all resources
+256
View File
@@ -0,0 +1,256 @@
# Real-Time Updates & Observability - Quick Reference
## 🔔 Real-Time Notifications
### User Experience:
- **Toast notifications** appear in top-right corner when:
- Incident is detected (resource down)
- Incident is resolved (resource back up)
- Critical alerts are triggered
- Auto-dismiss after 5 seconds
- Multiple notifications stack vertically
### WebSocket Message Types:
```javascript
// Dashboard update
{ type: 'dashboard', data: { resources: [...], groups: [...] } }
// Alert
{ type: 'alert', data: { resourceName: '...', message: '...' } }
// Incident
{ type: 'incident', data: { type: 'started'|'resolved', resourceName: '...' } }
// Metrics
{ type: 'metrics', data: { uptime: ..., apiStats: [...], errorCount: ... } }
```
---
## 📊 Observability Dashboard
### Access:
**Navigation Bar** → **Observability** or `/observability`
### Tab 1: API Metrics
Shows performance insights:
- **Server Uptime**: How long the server has been running
- **Total API Requests**: Total requests since startup
- **Errors Logged**: Total errors logged
- **Unique Errors**: Number of distinct error types
**Endpoint Performance Table:**
| Column | Meaning |
|--------|---------|
| Endpoint | HTTP method + path |
| Requests | Total requests to this endpoint |
| Success Rate % | Percentage successful (200-399 status) |
| Avg Response | Average response time in milliseconds |
| Min/Max | Best and worst response times |
### Tab 2: Errors
Lists recent application errors:
- **Error Message**: What went wrong
- **Context**: Where it happened (endpoint, IP)
- **Occurred At**: Timestamp of error
### Tab 3: Audit Logs
Complete change history with filtering:
**Summary Section:**
- Shows events from last 7 days
- Breakdown by action type (create, update, delete)
**Filters:**
- **Entity Type**: resource, settings, group, maintenance_window
- **Entity ID**: ID of the changed entity
- **User ID**: Who made the change (usually "system" for automated)
- **Action**: create, update, delete
**Audit Log Columns:**
| Column | Meaning |
|--------|---------|
| Action | What happened (create/update/delete) |
| Entity | Type and ID of affected resource |
| User | Who made the change |
| Changes | What changed (shown as JSON snippet) |
| Time | When it happened |
---
## 📈 API Endpoints for Integration
### Get Metrics
```bash
GET /api/observability/metrics
```
Response:
```json
{
"uptime": 3600,
"uptimeFormatted": "1h 0m",
"apiStats": [
{
"endpoint": "/api/resources",
"method": "GET",
"totalRequests": 150,
"successRate": "99.33",
"avgResponseTime": 45,
"minResponseTime": 12,
"maxResponseTime": 234
}
],
"errorCount": 2,
"uniqueErrors": 1
}
```
### Get Recent Errors
```bash
GET /api/observability/errors?limit=50
```
### Get Audit Logs
```bash
GET /api/observability/audit-logs?entityType=resource&userId=system&limit=100&offset=0
```
### Get Audit Summary
```bash
GET /api/observability/audit-summary?days=7
```
---
## 🔍 Audit Log Examples
### Resource Creation
```json
{
"action": "create",
"entity_type": "resource",
"entity_id": "42",
"changes": {
"name": "API Server",
"url": "https://api.example.com",
"type": "http",
"enabled": true
},
"user_id": "system",
"created_at": "2024-01-14T10:30:00Z"
}
```
### Resource Update
```json
{
"action": "update",
"entity_type": "resource",
"entity_id": "42",
"changes": {
"name": "API Server",
"enabled": false,
"maintenance_mode": true
},
"user_id": "system",
"created_at": "2024-01-14T10:35:00Z"
}
```
### Settings Change
```json
{
"action": "update",
"entity_type": "settings",
"entity_id": "email_enabled",
"changes": {
"old": false,
"new": true
},
"user_id": "system",
"created_at": "2024-01-14T10:40:00Z"
}
```
---
## 💡 Use Cases
### 1. Monitoring API Performance
- Check `GET /api/observability/metrics` regularly
- Set up alerts if success rate drops below threshold
- Track response time trends over time
### 2. Debugging Issues
- Visit **Errors** tab in Observability dashboard
- See what went wrong with full stack traces
- Check context (which endpoint, from where)
### 3. Compliance Auditing
- Use **Audit Logs** tab to see who changed what
- Filter by date range and user
- Export logs for compliance reports
### 4. Root Cause Analysis
- Check audit log for recent changes
- Correlate with error logs
- Verify metrics before/after changes
### 5. Performance Optimization
- Identify slowest endpoints from metrics
- Check which endpoints have lowest success rate
- Focus optimization efforts on high-traffic endpoints
---
## ⚙️ Configuration
### Metrics Collection
- Automatically enabled, no configuration needed
- Tracks all API endpoints
- Data available via `/api/observability/metrics`
### Audit Logging
- Automatically enabled, no configuration needed
- Logs all resource CRUD operations
- Logs all settings changes
- Stores permanently in database
### Error Tracking
- Automatically enabled, no configuration needed
- Captures all exceptions
- Stores in `error_logs` table
---
## 🚀 Performance Notes
- **Metrics**: In-memory collection, resets on server restart
- **Audit Logs**: Database storage, grows indefinitely (plan archive strategy)
- **Error Logs**: Database storage, grows indefinitely (plan archive strategy)
- **WebSocket**: All connected clients receive broadcast messages
---
## 📋 Troubleshooting
### No notifications appearing?
- Check browser console for WebSocket errors
- Verify WebSocket connection is established
- Check that incidents are actually being created
### Audit logs not showing changes?
- Verify you're filtering correctly
- Check user_id (usually "system" for API operations)
- Ensure entity type matches what you're looking for
### Metrics showing as 0?
- Server was just restarted (metrics reset)
- No API calls have been made yet
- Check that endpoint path matches exactly
### Performance dashboard slow?
- Too many audit logs in database
- Consider archiving old logs
- Limit query results with pagination
+6 -6
View File
@@ -1,15 +1,15 @@
{
"files": {
"main.css": "/static/css/main.b6a66622.css",
"main.js": "/static/js/main.0a56784a.js",
"main.css": "/static/css/main.4f505a72.css",
"main.js": "/static/js/main.29f97b36.js",
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
"index.html": "/index.html",
"main.b6a66622.css.map": "/static/css/main.b6a66622.css.map",
"main.0a56784a.js.map": "/static/js/main.0a56784a.js.map",
"main.4f505a72.css.map": "/static/css/main.4f505a72.css.map",
"main.29f97b36.js.map": "/static/js/main.29f97b36.js.map",
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
},
"entrypoints": [
"static/css/main.b6a66622.css",
"static/js/main.0a56784a.js"
"static/css/main.4f505a72.css",
"static/js/main.29f97b36.js"
]
}
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.0a56784a.js"></script><link href="/static/css/main.b6a66622.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.29f97b36.js"></script><link href="/static/css/main.4f505a72.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
@@ -1,2 +1,2 @@
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.App{min-height:100vh}.navbar{background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 2px 8px #0000001a;color:#fff;padding:1rem 2rem}.navbar h1{font-size:1.5rem;margin:0}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-bottom:1.5rem}.resource-card{background:#fff;border:1px solid #f1f1f3;border-radius:10px;box-shadow:0 2px 8px #00000014;cursor:pointer;padding:1rem 1.1rem;transition:transform .2s,box-shadow .2s}.resource-card.compact{display:flex;flex-direction:column;gap:.4rem}.resource-card:hover{box-shadow:0 4px 12px #00000026;transform:translateY(-2px)}.resource-header{align-items:flex-start;display:flex;justify-content:space-between;margin-bottom:.5rem}.resource-name{font-size:1.05rem;font-weight:700;margin:0 0 .2rem}.resource-url{color:#666;font-size:.8rem;word-break:break-all}.status-badge{border-radius:12px;font-size:.7rem;font-weight:600;text-transform:uppercase}.status-up{background:#d4edda;color:#155724}.status-down{background:#f8d7da;color:#721c24}.status-unknown{background:#e2e3e5;color:#383d41}.stats-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:1fr 1fr;margin-top:1rem}.stat{text-align:center}.stat-value{color:#333;font-size:1.2rem;font-weight:700;margin:0}.stat-value.small{font-size:1.05rem}.stat-label{color:#666;font-size:.75rem;margin:0;text-transform:uppercase}.resource-meta{grid-gap:.75rem;align-items:center;display:grid;gap:.75rem;grid-template-columns:repeat(3,1fr)}.sparkline{height:52px;width:100%}.last-check{color:#999;font-size:.75rem;margin:0}.group-section{background:#fafafa;border-radius:10px;margin-bottom:2rem;padding:1rem}.group-header{align-items:center;background:#fff;border:1px solid #e0e0e0;border-radius:8px;cursor:pointer;display:flex;gap:1rem;margin-bottom:1rem;padding:1rem;transition:background .2s}.group-header:hover{background:#f9f9f9}.group-header h3{color:#333;font-size:1.1rem;margin:0}.group-toggle{color:#667eea;font-size:.9rem;min-width:15px}.group-stats{display:flex;font-size:.85rem;gap:1.5rem;margin-left:auto}.group-stat{background:#f0f0f0;border-radius:6px;color:#666;font-weight:500;padding:.4rem .8rem}.edit-input{border:1px solid #667eea;border-radius:6px;font-size:1.05rem;margin-bottom:.5rem;padding:.5rem;width:100%}.card-edit-form{border-top:1px solid #eee;margin-top:.75rem;padding-top:.75rem}.edit-field{margin-bottom:.5rem}.edit-field label{color:#666;display:block;font-size:.75rem;font-weight:600;margin-bottom:.25rem}.edit-field input{border:1px solid #ddd;border-radius:4px;font-size:.9rem;padding:.4rem .6rem;width:100%}.card-actions{display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem}.btn-icon{background:none;border:none;cursor:pointer;font-size:1rem;opacity:.6;padding:.25rem .5rem;transition:opacity .2s}.btn-icon:hover{opacity:1}.btn-sm{font-size:.8rem;padding:.4rem .8rem}.btn{border:none;border-radius:6px;cursor:pointer;font-size:1rem;font-weight:600;padding:.75rem 1.5rem;transition:all .2s}.btn-primary{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}.btn-primary:hover{box-shadow:0 4px 8px #667eea4d;transform:translateY(-1px)}.btn-danger{background:#dc3545;color:#fff}.btn-danger:hover{background:#c82333}.modal-overlay{align-items:center;background:#00000080;bottom:0;display:flex;justify-content:center;left:0;position:fixed;right:0;top:0;z-index:1000}.modal{background:#fff;border-radius:12px;max-height:90vh;max-width:500px;overflow-y:auto;padding:2rem;width:90%}.modal h2{margin-top:0}.form-group{margin-bottom:1.5rem}.form-group label{color:#333;display:block;font-weight:600;margin-bottom:.5rem}.form-group input,.form-group select{border:1px solid #ddd;border-radius:6px;font-size:1rem;padding:.75rem;width:100%}.form-group input:focus,.form-group select:focus{border-color:#667eea;outline:none}.form-actions{display:flex;gap:1rem;justify-content:flex-end;margin-top:2rem}.detail-section{background:#fff;border-radius:8px;box-shadow:0 2px 8px #0000001a;margin-bottom:1.5rem;padding:1.5rem}.detail-section h2{color:#333;margin-top:0}.chart-container{height:300px;margin-top:1rem}.incident-badge{background:#fff3cd;color:#856404;font-weight:600}.back-button,.incident-badge{border-radius:6px;display:inline-block;margin-bottom:1rem;padding:.5rem 1rem}.back-button{background:#6c757d;color:#fff;text-decoration:none;transition:background .2s}.back-button:hover{background:#5a6268}.empty-state{color:#666;padding:3rem;text-align:center}.empty-state h3{color:#333;margin-bottom:.5rem}@media (max-width:1024px){.dashboard-grid{grid-template-columns:repeat(auto-fill,minmax(20Line truncated
/*# sourceMappingURL=main.b6a66622.css.map*/
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.App{min-height:100vh}.navbar{background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 2px 8px #0000001a;color:#fff;padding:1rem 2rem}.navbar h1{font-size:1.5rem;margin:0}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-bottom:1.5rem}.resource-card{background:#fff;border:1px solid #f1f1f3;border-radius:10px;box-shadow:0 2px 8px #00000014;cursor:pointer;padding:1rem 1.1rem;transition:transform .2s,box-shadow .2s}.resource-card.compact{display:flex;flex-direction:column;gap:.4rem}.resource-card:hover{box-shadow:0 4px 12px #00000026;transform:translateY(-2px)}.resource-header{align-items:flex-start;display:flex;justify-content:space-between;margin-bottom:.5rem}.resource-name{font-size:1.05rem;font-weight:700;margin:0 0 .2rem}.resource-url{color:#666;font-size:.8rem;word-break:break-all}.status-badge{border-radius:12px;font-size:.7rem;font-weight:600;text-transform:uppercase}.status-up{background:#d4edda;color:#155724}.status-down{background:#f8d7da;color:#721c24}.status-unknown{background:#e2e3e5;color:#383d41}.stats-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:1fr 1fr;margin-top:1rem}.stat{text-align:center}.stat-value{color:#333;font-size:1.2rem;font-weight:700;margin:0}.stat-value.small{font-size:1.05rem}.stat-label{color:#666;font-size:.75rem;margin:0;text-transform:uppercase}.resource-meta{grid-gap:.75rem;align-items:center;display:grid;gap:.75rem;grid-template-columns:repeat(3,1fr)}.sparkline{height:52px;width:100%}.last-check{color:#999;font-size:.75rem;margin:0}.group-section{background:#fafafa;border-radius:10px;margin-bottom:2rem;padding:1rem}.group-header{align-items:center;background:#fff;border:1px solid #e0e0e0;border-radius:8px;cursor:pointer;display:flex;gap:1rem;margin-bottom:1rem;padding:1rem;transition:background .2s}.group-header:hover{background:#f9f9f9}.group-header h3{color:#333;font-size:1.1rem;margin:0}.group-toggle{color:#667eea;font-size:.9rem;min-width:15px}.group-stats{display:flex;font-size:.85rem;gap:1.5rem;margin-left:auto}.group-stat{background:#f0f0f0;border-radius:6px;color:#666;font-weight:500;padding:.4rem .8rem}.edit-input{border:1px solid #667eea;border-radius:6px;font-size:1.05rem;margin-bottom:.5rem;padding:.5rem;width:100%}.card-edit-form{border-top:1px solid #eee;margin-top:.75rem;padding-top:.75rem}.edit-field{margin-bottom:.5rem}.edit-field label{color:#666;display:block;font-size:.75rem;font-weight:600;margin-bottom:.25rem}.edit-field input{border:1px solid #ddd;border-radius:4px;font-size:.9rem;padding:.4rem .6rem;width:100%}.card-actions{display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem}.btn-icon{background:none;border:none;cursor:pointer;font-size:1rem;opacity:.6;padding:.25rem .5rem;transition:opacity .2s}.btn-icon:hover{opacity:1}.btn-sm{font-size:.8rem;padding:.4rem .8rem}.btn{border:none;border-radius:6px;cursor:pointer;font-size:1rem;font-weight:600;padding:.75rem 1.5rem;transition:all .2s}.btn-primary{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}.btn-primary:hover{box-shadow:0 4px 8px #667eea4d;transform:translateY(-1px)}.btn-danger{background:#dc3545;color:#fff}.btn-danger:hover{background:#c82333}.modal-overlay{align-items:center;background:#00000080;bottom:0;display:flex;justify-content:center;left:0;position:fixed;right:0;top:0;z-index:1000}.modal{background:#fff;border-radius:12px;max-height:90vh;max-width:500px;overflow-y:auto;padding:2rem;width:90%}.modal h2{margin-top:0}.form-group{margin-bottom:1.5rem}.form-group label{color:#333;display:block;font-weight:600;margin-bottom:.5rem}.form-group input,.form-group select{border:1px solid #ddd;border-radius:6px;font-size:1rem;padding:.75rem;width:100%}.form-group input:focus,.form-group select:focus{border-color:#667eea;outline:none}.form-actions{display:flex;gap:1rem;justify-content:flex-end;margin-top:2rem}.detail-section{background:#fff;border-radius:8px;box-shadow:0 2px 8px #0000001a;margin-bottom:1.5rem;padding:1.5rem}.detail-section h2{color:#333;margin-top:0}.chart-container{height:300px;margin-top:1rem}.incident-badge{background:#fff3cd;color:#856404;font-weight:600}.back-button,.incident-badge{border-radius:6px;display:inline-block;margin-bottom:1rem;padding:.5rem 1rem}.back-button{background:#6c757d;color:#fff;text-decoration:none;transition:background .2s}.back-button:hover{background:#5a6268}.empty-state{color:#666;padding:3rem;text-align:center}.empty-state h3{color:#333;margin-bottom:.5rem}@media (max-width:1024px){.dashboard-grid{grid-template-columns:repeat(auto-fill,minmax(20Line truncated
/*# sourceMappingURL=main.4f505a72.css.map*/
@@ -0,0 +1 @@
{"version":3,"file":"static/css/main.4f505a72.css","mappings":"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 truncated
@@ -1 +0,0 @@
{"version":3,"file":"static/css/main.b6a66622.css","mappings":"AAAA,KAKE,kCAAmC,CACnC,iCAAkC,CAClC,wBAAyB,CALzB,mIAEY,CAHZ,QAOF,CAEA,EACE,qBACF,CAEA,KACE,uEAEF,CCjBA,KACE,gBACF,CAEA,QACE,kDAA6D,CAG7D,8BAAqC,CADrC,UAAY,CADZ,iBAGF,CAEA,WAEE,gBAAiB,CADjB,QAEF,CAEA,WAEE,aAAc,CADd,gBAAiB,CAEjB,YACF,CAEA,gBAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,yDAA4D,CAE5D,oBACF,CAEA,eACE,eAAiB,CAMjB,wBAAyB,CALzB,kBAAmB,CAEnB,8BAAsC,CAEtC,cAAe,CAHf,mBAAoB,CAEpB,uCAGF,CAEA,uBACE,YAAa,CACb,qBAAsB,CACtB,SACF,CAEA,qBAEE,+BAAuC,CADvC,0BAEF,CAEA,iBAGE,sBAAuB,CAFvB,YAAa,CACb,6BAA8B,CAE9B,mBACF,CAEA,eACE,iBAAkB,CAClB,eAAgB,CAChB,gBACF,CAEA,cACE,UAAW,CACX,eAAiB,CACjB,oBACF,CAEA,cAEE,kBAAmB,CACnB,eAAiB,CACjB,eAAgB,CAChB,wBACF,CAEA,WACE,kBAAmB,CACnB,aACF,CAEA,aACE,kBAAmB,CACnB,aACF,CAEA,gBACE,kBAAmB,CACnB,aACF,CAEA,YAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,6BAA8B,CAE9B,eACF,CAEA,MACE,iBACF,CAEA,YAGE,UAAW,CAFX,gBAAiB,CACjB,eAAgB,CAEhB,QACF,CAEA,kBACE,iBACF,CAEA,YAEE,UAAW,CADX,gBAAkB,CAGlB,QAAS,CADT,wBAEF,CAEA,eAIE,eAAY,CADZ,kBAAmB,CAFnB,YAAa,CAGb,UAAY,CAFZ,mCAGF,CAEA,WAEE,WAAY,CADZ,UAEF,CAEA,YAGE,UAAW,CADX,gBAAkB,CADlB,QAGF,CAEA,eAEE,kBAAmB,CACnB,kBAAmB,CAFnB,kBAAmB,CAGnB,YACF,CAEA,cAEE,kBAAmB,CAGnB,eAAiB,CAIjB,wBAAyB,CAHzB,iBAAkB,CAClB,cAAe,CANf,YAAa,CAEb,QAAS,CAOT,kBAAmB,CANnB,YAAa,CAIb,yBAGF,CAEA,oBACE,kBACF,CAEA,iBAGE,UAAW,CAFX,gBAAiB,CACjB,QAEF,CAEA,cAEE,aAAc,CADd,eAAiB,CAEjB,cACF,CAEA,aACE,YAAa,CAGb,gBAAkB,CAFlB,UAAW,CACX,gBAEF,CAEA,YACE,kBAAmB,CAEnB,iBAAkB,CAClB,UAAW,CACX,eAAgB,CAHhB,mBAIF,CAEA,YAEE,wBAAyB,CACzB,iBAAkB,CAClB,iBAAkB,CAElB,mBAAqB,CALrB,aAAe,CAIf,UAEF,CAEA,gBAGE,yBAA0B,CAF1B,iBAAmB,CACnB,kBAEF,CAEA,YACE,mBACF,CAEA,kBAGE,UAAW,CAFX,aAAc,CACd,gBAAkB,CAGlB,eAAgB,CADhB,oBAEF,CAEA,kBAGE,qBAAsB,CACtB,iBAAkB,CAClB,eAAiB,CAHjB,mBAAsB,CADtB,UAKF,CAEA,cACE,YAAa,CACb,SAAW,CAEX,wBAAyB,CADzB,gBAEF,CAEA,UACE,eAAgB,CAChB,WAAY,CACZ,cAAe,CACf,cAAe,CAEf,UAAY,CADZ,oBAAuB,CAEvB,sBACF,CAEA,gBACE,SACF,CAEA,QAEE,eAAiB,CADjB,mBAEF,CAEA,KAEE,WAAY,CACZ,iBAAkB,CAGlB,cAAe,CAFf,cAAe,CACf,eAAgB,CAJhB,qBAAuB,CAMvB,kBACF,CAEA,aACE,kDAA6D,CAC7D,UACF,CAEA,mBAEE,8BAA8C,CAD9C,0BAEF,CAEA,YACE,kBAAmB,CACnB,UACF,CAEA,kBACE,kBACF,CAEA,eAQE,kBAAmB,CAFnB,oBAA8B,CAD9B,QAAS,CAET,YAAa,CAEb,sBAAuB,CANvB,MAAO,CAFP,cAAe,CAGf,OAAQ,CAFR,KAAM,CAQN,YACF,CAEA,OACE,eAAiB,CACjB,kBAAmB,CAInB,eAAgB,CAFhB,eAAgB,CAGhB,eAAgB,CAJhB,YAAa,CAEb,SAGF,CAEA,UACE,YACF,CAEA,YACE,oBACF,CAEA,kBAIE,UAAW,CAHX,aAAc,CAEd,eAAgB,CADhB,mBAGF,CAEA,qCAIE,qBAAsB,CACtB,iBAAkB,CAClB,cAAe,CAHf,cAAgB,CADhB,UAKF,CAEA,iDAGE,oBAAqB,CADrB,YAEF,CAEA,cACE,YAAa,CACb,QAAS,CACT,wBAAyB,CACzB,eACF,CAEA,gBACE,eAAiB,CACjB,iBAAkB,CAGlB,8BAAqC,CADrC,oBAAqB,CADrB,cAGF,CAEA,mBAEE,UAAW,CADX,YAEF,CAEA,iBAEE,YAAa,CADb,eAEF,CAEA,gBAGE,kBAAmB,CACnB,aAAc,CAEd,eAEF,CAEA,6BALE,iBAAkB,CAJlB,oBAAqB,CAMrB,kBAAmB,CALnB,kBAiBF,CATA,aAIE,kBAAmB,CACnB,UAAY,CACZ,oBAAqB,CAErB,yBACF,CAEA,mBACE,kBACF,CAEA,aAGE,UAAW,CADX,YAAa,CADb,iBAGF,CAEA,gBACE,UAAW,CACX,mBACF,CAGA,0BACE,gBACE,yDACF,CAEA,WACE,cACF,CACF,CAEA,yBACE,QACE,mBACF,CAEA,WACE,iBACF,CAEA,cACE,cAAe,CACf,SACF,CAEA,gBAEE,eAAiB,CADjB,mBAEF,CAEA,WACE,YACF,CAEA,kBAEE,sBAAuB,CADvB,qBAAsB,CAEtB,QACF,CAEA,mBAEE,qBAAsB,CACtB,SAAW,CAFX,UAGF,CAEA,oDAEE,UACF,CAEA,gBAEE,UAAY,CADZ,yDAEF,CAEA,YAEE,UAAY,CADZ,6CAEF,CAEA,eACE,eACF,CAEA,eACE,qBAAsB,CACtB,SACF,CAEA,mBACE,yBACF,CAEA,iBACE,sBACF,CAEA,gBACE,sBACF,CAEA,UACE,qBAAsB,CACtB,KACF,CAEA,YACE,UACF,CAEA,eAGE,WAAY,CADZ,cAAe,CAEf,cAAe,CAHf,SAIF,CAEA,cAEE,sBAAuB,CADvB,qBAAsB,CAEtB,SACF,CAEA,aAEE,6BAA8B,CAD9B,UAEF,CAEA,cACE,kBAAmB,CAEnB,SAAW,CADX,wBAEF,CAEA,MACE,gBACF,CAEA,MACE,uBACF,CACF,CAEA,yBACE,WACE,gBACF,CAEA,YACE,gBACF,CAEA,YACE,gBACF,CAEA,YACE,mCACF,CAEA,kBACE,iBACF,CAEA,KAEE,eAAiB,CADjB,kBAEF,CACF,CC/hBA,iBAEE,aAAc,CADd,eAEF,CAEA,sBACE,UAAW,CACX,kBACF,CAEA,kBACE,eAAiB,CACjB,iBAAkB,CAGlB,8BAAqC,CADrC,oBAAqB,CADrB,YAGF,CAEA,iBAGE,kBAAmB,CAGnB,+BAAgC,CALhC,YAAa,CACb,6BAA8B,CAE9B,oBAAqB,CACrB,mBAEF,CAEA,oBAEE,UAAW,CADX,QAEF,CAEA,eACE,iBACF,CAEA,UAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,wDAA2D,CAE3D,kBACF,CAEA,kBAEE,UAAW,CADX,aAAc,CAEd,gBAAkB,CAClB,iBACF,CAEA,UACE,kBAAmB,CACnB,6BAA8B,CAG9B,iBAAkB,CADlB,eAAgB,CADhB,YAGF,CAEA,iBAGE,UAAW,CAFX,aAAc,CACd,mBAEF,CAEA,aACE,uBAAyB,CACzB,SACF,CAEA,aAEE,UAAW,CADX,eAEF,CAEA,YACE,aAAc,CACd,oBACF,CAEA,kBACE,yBACF,CAEA,cACE,eAAiB,CAEjB,iBAAkB,CAElB,gBAAkB,CAClB,gBAAkB,CAFlB,eAAgB,CAFhB,YAKF,CAEA,eAEE,oBAAqB,CAErB,WAAY,CAHZ,iBAAkB,CAElB,UAEF,CAEA,qBAGE,QAAS,CAFT,SAAU,CACV,OAEF,CAEA,eAOE,qBAAsB,CAEtB,kBAAmB,CAHnB,QAAS,CAJT,cAAe,CAEf,MAAO,CAHP,iBAAkB,CAIlB,OAAQ,CAFR,KAAM,CAKN,cAEF,CAEA,sBAOE,qBAAuB,CAEvB,iBAAkB,CAHlB,UAAW,CAJX,UAAW,CACX,WAAY,CAEZ,QAAS,CAJT,iBAAkB,CAOlB,cAAe,CAJf,UAMF,CAEA,4CACE,wBACF,CAEA,mDACE,0BACF,CAEA,eACE,kBAAmB,CACnB,UAAY,CACZ,eACF,CAEA,oCACE,kBACF,CAEA,wBAEE,kBAAmB,CADnB,UAEF,CAEA,aAEE,iBAAkB,CAClB,eAAgB,CAFhB,YAGF,CAEA,qBACE,kBAAmB,CAEnB,wBAAyB,CADzB,aAEF,CAEA,mBACE,kBAAmB,CAEnB,wBAAyB,CADzB,aAEF,CAEA,oBACE,aAAc,CACd,mBACF,CAEA,eACE,QACF,CAEA,kBACE,YAAa,CACb,sBAAuB,CACvB,eACF,CAEA,WAEE,gBAAiB,CADjB,iBAEF,CAEA,aAQE,8BAAgC,CAHhC,iBAAkB,CAClB,+BAAuC,CAGvC,eAAgB,CALhB,mBAAoB,CAHpB,cAAe,CAEf,UAAW,CADX,QAAS,CAKT,YLine truncated
@@ -1 +0,0 @@
{"version":3,"file":"static/js/main.0a56784a.js","mappings":";2BAAAA,EAAOC,QAAU,EAAjBD,MAAAA,I,2BCEAE,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAmB5DL,EAAQM,SAjBR,SAASA,EAASD,GACd,GAAa,MAATA,EACA,MAAO,GAEX,GAAqB,kBAAVA,EACP,OAAOA,EAEX,GAAIE,MAAMC,QAAQH,GACd,OAAOA,EAAMI,IAAIH,GAAUI,KAAK,KAEpC,MAAMC,EAASC,OAAOP,GACtB,MAAe,MAAXM,GAAkBV,OAAOY,GAAGC,OAAOT,IAAS,GACrC,KAEJM,CACX,C,6BChBEZ,EAAOC,QAAU,EAAjBD,K,2BCDFE,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAM5DL,EAAQe,SAJR,SAAkBV,GACd,OAAiB,OAAVA,IAAoC,kBAAVA,GAAuC,oBAAVA,EAClE,C,8BCJAJ,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMW,EAAWC,EAAQ,MAMzBjB,EAAQkB,YAJR,SAAqBb,GACjB,OAAgB,MAATA,GAAkC,oBAAVA,GAAwBW,EAASA,SAASX,EAAMc,OACnF,C,8BCNAlB,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMe,EAAaH,EAAQ,MA6C3BjB,EAAQqB,SA3CR,SAAkBC,GAAoC,IAA9BC,EAAUC,UAAAL,OAAA,QAAAM,IAAAD,UAAA,GAAAA,UAAA,GAAG,EAAGE,EAAOF,UAAAL,OAAA,QAAAM,IAAAD,UAAA,GAAAA,UAAA,GAAG,CAAC,EACxB,kBAAZE,IACPA,EAAU,CAAC,GAEf,MAAM,QAAEC,GAAU,EAAK,SAAEC,GAAW,EAAI,QAAEC,GAAYH,EAChDI,EAAQvB,MAAM,GAOpB,IAAII,EANAgB,IACAG,EAAM,GAAK,WAEXF,IACAE,EAAM,GAAK,YAGf,IAAIC,EAAY,KAChB,MAAMC,EAAaZ,EAAWC,SAAS,WAAmB,QAAAY,EAAAT,UAAAL,OAANe,EAAI,IAAA3B,MAAA0B,GAAAE,EAAA,EAAAA,EAAAF,EAAAE,IAAJD,EAAIC,GAAAX,UAAAW,GACpDxB,EAASW,EAAKc,MAAMC,KAAMH,GAC1BH,EAAY,IAChB,EAAGR,EAAY,CAAEO,UACXQ,EAAY,WAAmB,QAAAC,EAAAf,UAAAL,OAANe,EAAI,IAAA3B,MAAAgC,GAAAC,EAAA,EAAAA,EAAAD,EAAAC,IAAJN,EAAIM,GAAAhB,UAAAgB,GAC/B,OAAe,MAAXX,IACkB,OAAdE,IACAA,EAAYU,KAAKC,OAEjBD,KAAKC,MAAQX,GAAaF,IAC1BlB,EAASW,EAAKc,MAAMC,KAAMH,GAC1BH,EAAYU,KAAKC,MACjBV,EAAWW,SACXX,EAAWY,WACJjC,IAGfqB,EAAWI,MAAMC,KAAMH,GAChBvB,EACX,EAOA,OAFA2B,EAAUK,OAASX,EAAWW,OAC9BL,EAAUO,MALIA,KACVb,EAAWa,QACJlC,GAIJ2B,CACX,C,8BC7CArC,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMC,EAAWW,EAAQ,KACnB6B,EAAQ7B,EAAQ,MA4EtBjB,EAAQ+C,OA1ER,SAAgBC,GACZ,GAAIzC,MAAMC,QAAQwC,GACd,OAAOA,EAAQvC,IAAIqC,EAAMA,OAE7B,GAAuB,kBAAZE,EACP,MAAO,CAACA,GAGZ,MAAMrC,EAAS,GACTQ,GAFN6B,EAAU1C,EAASA,SAAS0C,IAEL7B,OACvB,GAAe,IAAXA,EACA,OAAOR,EAEX,IAAIsC,EAAQ,EACRC,EAAM,GACNC,EAAY,GACZC,GAAU,EAKd,IAJ8B,KAA1BJ,EAAQK,WAAW,KACnB1C,EAAO2C,KAAK,IACZL,KAEGA,EAAQ9B,GAAQ,CACnB,MAAMoC,EAAOP,EAAQC,GACjBE,EACa,OAATI,GAAiBN,EAAQ,EAAI9B,GAC7B8B,IACAC,GAAOF,EAAQC,IAEVM,IAASJ,EACdA,EAAY,GAGZD,GAAOK,EAGNH,EACQ,MAATG,GAAyB,MAATA,EAChBJ,EAAYI,EAEE,MAATA,GACLH,GAAU,EACVzC,EAAO2C,KAAKJ,GACZA,EAAM,IAGNA,GAAOK,EAIE,MAATA,GACAH,GAAU,EACNF,IACAvC,EAAO2C,KAAKJ,GACZA,EAAM,KAGI,MAATK,EACDL,IACAvC,EAAO2C,KAAKJ,GACZA,EAAM,IAIVA,GAAOK,EAGfN,GACJ,CAIA,OAHIC,GACAvC,EAAO2C,KAAKJ,GAETvC,CACX,C,8BC5EEZ,EAAOC,QAAU,EAAjBD,K,8BCDFE,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMa,EAAcD,EAAQ,MACtBuC,EAAevC,EAAQ,MAM7BjB,EAAQyD,kBAJR,SAA2BpD,GACvB,OAAOmD,EAAaA,aAAanD,IAAUa,EAAYA,YAAYb,EACvE,C,8BCTa,IAAAqD,EAAAzC,EAAAA,MAAAA,QAEbhB,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMU,EAAWE,EAAQ,KACnB0C,EAAc1C,EAAQ,MACtB2C,EAAK3C,EAAQ,MAcnB,SAAS4C,EAAoBC,EAAQC,EAAQC,EAASC,GAClD,GAAIF,IAAWD,EACX,OAAO,EAEX,cAAeC,GACX,IAAK,SACD,OAoBZ,SAAuBD,EAAQC,EAAQC,EAASC,GAC5C,GAAc,MAAVF,EACA,OAAO,EAEX,GAAIxD,MAAMC,QAAQuD,GACd,OAAOG,EAAaJ,EAAQC,EAAQC,EAASC,GAEjD,GAAIF,aAAkBI,IAClB,OAuCR,SAAoBL,EAAQC,EAAQC,EAASC,GACzC,GAAoB,IAAhBF,EAAOK,KACP,OAAO,EAEX,KAAMN,aAAkBK,KACpB,OAAO,EAEX,IAAK,MAAOjB,EAAKmB,KAAgBN,EAAOO,UAAW,CAG/C,IAAgB,IADAN,EADIF,EAAOS,IAAIrB,GACMmB,EAAanB,EAAKY,EAAQC,EAAQE,GAEnE,OAAO,CAEf,CACA,OAAO,CACX,CAtDeO,CAAWV,EAAQC,EAAQC,EAASC,GAE/C,GAAIF,aAAkBU,IAClB,OAAOC,EAAWZ,EAAQC,EAAQC,EAASC,GAE/C,MAAMU,EAAO1E,OAAO0E,KAAKZ,GACzB,GAAc,MAAVD,EACA,OAAuB,IAAhBa,EAAKxD,OAEhB,GAAoB,IAAhBwD,EAAKxD,OACL,OAAO,EAEX,GAAS,OAAL8C,QAAK,IAALA,GAAAA,EAAOW,IAAIb,GACX,OAAOE,EAAMM,IAAIR,KAAYD,EAE5B,OAALG,QAAK,IAALA,GAAAA,EAAOY,IAAId,EAAQD,GACnB,IACI,IAAK,IAAIgB,EAAI,EAAGA,EAAIH,EAAKxD,OAAQ2D,IAAK,CAClC,MAAM5B,EAAMyB,EAAKG,GACjB,IAAKnB,EAAYA,YAAYG,MAAaZ,KAAOY,GAC7C,OAAO,EAEX,QAAoBrC,IAAhBsC,EAAOb,SAAsCzB,IAAhBqC,EAAOZ,GACpC,OAAO,EAEX,GAAoB,OAAhBa,EAAOb,IAAiC,OAAhBY,EAAOZ,GAC/B,OAAO,EAGX,IADgBc,EAAQF,EAAOZ,GAAMa,EAAOb,GAAMA,EAAKY,EAAQC,EAAQE,GAEnE,OAAO,CAEf,CACA,OAAO,CACX,CAAC,QAEQ,OAALA,QAAK,IAALA,GAAAA,EAAOc,OAAOhB,EAClB,CACJ,CAlEmBiB,CAAclB,EAAQC,EAAQC,EAASC,GAElD,IAAK,WAED,OADmBhE,OAAO0E,KAAKZ,GAChB5C,OAAS,EACb0C,EAAoBC,EAAMJ,EAAA,GAAOK,GAAUC,EAASC,GAExDL,EAAGA,GAAGE,EAAQC,GAEzB,QACI,OAAKhD,EAASA,SAAS+C,GAGD,kBAAXC,GACW,KAAXA,EAHAH,EAAGA,GAAGE,EAAQC,GAQrC,CAgEA,SAASG,EAAaJ,EAAQC,EAAQC,EAASC,GAC3C,GAAsB,IAAlBF,EAAO5C,OACP,OAAO,EAEX,IAAKZ,MAAMC,QAAQsD,GACf,OAAO,EAEX,MAAMmB,EAAe,IAAIR,IACzB,IAAK,IAAIK,EAAI,EAAGA,EAAIf,EAAO5C,OAAQ2D,IAAK,CACpC,MAAMI,EAAanB,EAAOe,GAC1B,IAAIK,GAAQ,EACZ,IAAK,IAAIC,EAAI,EAAGA,EAAItB,EAAO3C,OAAQiE,IAAK,CACpC,GAAIH,EAAaL,IAAIQ,GACjB,SAGJ,IAAIC,GAAU,EAKd,GAJgBrB,EAFGF,EAAOsB,GAEUF,EAAYJ,EAAGhB,EAAQC,EAAQE,KAE/DoB,GAAU,GAEVA,EAAS,CACTJ,EAAaK,IAAIF,GACjBD,GAAQ,EACR,KACJ,CACJ,CACA,IAAKA,EACD,OAAO,CAEf,CACA,OAAO,CACX,CACA,SAAST,EAAWZ,EAAQC,EAAQC,EAASC,GACzC,OAAoB,IAAhBF,EAAOK,MAGLN,aAAkBW,KAGjBP,EAAa,IAAIJ,GAAS,IAAIC,GAASC,EAASC,EAC3D,CAEAjE,EAAQuF,YAhJR,SAASA,EAAYzB,EAAQC,EAAQC,GACjC,MAAuB,oBAAZA,EACAuB,EAAYzB,EAAQC,EAAQ,QAEhCF,EAAoBC,EAAQC,EAAQ,SAASyB,EAAUCLine truncated
@@ -1,3 +1,3 @@
/*! For license information please see main.0a56784a.js.LICENSE.txt */
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
//# sourceMappingURL=main.0a56784a.js.map
/*! For license information please see main.29f97b36.js.LICENSE.txt */
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
//# sourceMappingURL=main.29f97b36.js.map
@@ -0,0 +1 @@
{"version":3,"file":"static/js/main.29f97b36.js","mappings":"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 truncated
+8 -3
View File
@@ -8,7 +8,6 @@
"name": "client",
"version": "0.1.0",
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"axios": "^1.13.2",
@@ -3396,6 +3395,7 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -3415,6 +3415,7 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
@@ -3493,7 +3494,8 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -6758,6 +6760,7 @@
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -6880,7 +6883,8 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dom-converter": {
"version": "0.2.0",
@@ -11407,6 +11411,7 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
-1
View File
@@ -3,7 +3,6 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"axios": "^1.13.2",
+98 -23
View File
@@ -7,6 +7,9 @@ import SettingsWizard from './SettingsWizard';
import History from './History';
import SLA from './SLA';
import Status from './Status';
import Observability from './Observability';
import Notifications from './Notifications';
import NotificationCenter from './NotificationCenter';
import { formatLocalTime, formatChartTime } from './utils/timeUtils';
// Simple duration formatter for incident spans
@@ -29,6 +32,7 @@ function Dashboard() {
const [collapsedGroups, setCollapsedGroups] = useState({});
const [editingId, setEditingId] = useState(null);
const [editData, setEditData] = useState({});
const [notifications, setNotifications] = useState([]);
const [formData, setFormData] = useState({
name: '',
url: '',
@@ -54,13 +58,24 @@ function Dashboard() {
const actionsRef = useRef(null);
const navigate = useNavigate();
// Show toast notification
const showNotification = (title, message, type = 'info') => {
const id = Date.now();
setNotifications(prev => [...prev, { id, title, message, type }]);
// Auto-remove after 5 seconds
setTimeout(() => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, 5000);
};
const loadResources = useCallback(async () => {
try {
const response = await axios.get('/api/dashboard');
setResources(response.data.resources);
setGroups(response.data.groups || []);
} catch (error) {
console.error('Error loading resources:', error);
// Error loading resources handled
}
}, []);
@@ -78,17 +93,34 @@ function Dashboard() {
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'dashboard') {
setResources(message.data.resources || []);
setGroups(message.data.groups || []);
} else if (message.type === 'alert') {
// Show real-time alert notification
const alert = message.data;
showNotification(`Alert: ${alert.resourceName}`, alert.message, 'error');
} else if (message.type === 'incident') {
// Show real-time incident notification
const incident = message.data;
if (incident.type === 'started') {
showNotification(`Incident Started`, `${incident.resourceName} is down`, 'error');
} else if (incident.type === 'resolved') {
showNotification(`Incident Resolved`, `${incident.resourceName} is back up`, 'success');
}
// Refresh data to show updated incidents
loadResources();
} else if (message.type === 'metrics') {
// Metrics update received (can be used for performance dashboard)
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
// Parse error handled silently
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
// WebSocket error handled
};
ws.onclose = () => {
@@ -97,7 +129,6 @@ function Dashboard() {
wsRef.current = ws;
} catch (error) {
console.error('Error connecting WebSocket:', error);
loadResources();
}
}, [loadResources]);
@@ -137,7 +168,6 @@ function Dashboard() {
setGroupData({ name: '', description: '' });
loadResources();
} catch (error) {
console.error('Error creating group:', error);
alert('Error creating group');
}
};
@@ -168,7 +198,6 @@ function Dashboard() {
});
loadResources();
} catch (error) {
console.error('Error creating resource:', error);
alert('Error creating resource');
}
};
@@ -190,7 +219,6 @@ function Dashboard() {
setEditData({});
loadResources();
} catch (error) {
console.error('Error updating resource:', error);
alert('Error updating resource');
}
};
@@ -206,7 +234,6 @@ function Dashboard() {
await axios.delete(`/api/resources/${id}`);
loadResources();
} catch (error) {
console.error('Error deleting resource:', error);
alert('Error deleting resource');
}
}
@@ -225,7 +252,6 @@ function Dashboard() {
link.click();
link.parentChild.removeChild(link);
} catch (error) {
console.error('Error exporting resources:', error);
alert('Error exporting resources');
}
};
@@ -246,7 +272,6 @@ function Dashboard() {
}
loadResources();
} catch (error) {
console.error('Error importing resources:', error);
alert('Error importing resources');
}
@@ -375,9 +400,33 @@ function Dashboard() {
return (
<div className="container">
{/* Real-time Notifications */}
{notifications.length > 0 && (
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 9999 }}>
{notifications.map(notif => (
<div
key={notif.id}
style={{
marginBottom: '10px',
padding: '12px 16px',
borderRadius: '4px',
backgroundColor: notif.type === 'error' ? '#f8d7da' : notif.type === 'success' ? '#d4edda' : '#d1ecf1',
color: notif.type === 'error' ? '#721c24' : notif.type === 'success' ? '#155724' : '#0c5460',
border: `1px solid ${notif.type === 'error' ? '#f5c6cb' : notif.type === 'success' ? '#c3e6cb' : '#bee5eb'}`,
minWidth: '300px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
>
<strong>{notif.title}</strong>
<div>{notif.message}</div>
</div>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2>SkyWatch Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<NotificationCenter />
<input
type="text"
placeholder="Filter by tag"
@@ -748,6 +797,19 @@ function Dashboard() {
</div>
</div>
<div className="form-group">
<label>Data Retention (days)</label>
<input
type="number"
placeholder="Leave empty to use global setting"
value={formData.retention_days || ''}
onChange={(e) => setFormData({ ...formData, retention_days: e.target.value ? parseInt(e.target.value) : null })}
min="1"
max="365"
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowModal(false)}>
Cancel
@@ -957,6 +1019,19 @@ function Dashboard() {
</div>
</div>
<div className="form-group">
<label>Data Retention (days)</label>
<input
type="number"
min="1"
max="365"
placeholder="Leave empty to use global setting"
value={editData.retention_days || ''}
onChange={(e) => setEditData({ ...editData, retention_days: e.target.value ? parseInt(e.target.value) : null })}
/>
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowEditModal(false)}>
Cancel
@@ -1036,7 +1111,6 @@ function ResourceDetail() {
setResource(response.data);
setLoading(false);
} catch (error) {
console.error('Error loading resource:', error);
setLoading(false);
}
};
@@ -1054,7 +1128,7 @@ function ResourceDetail() {
});
setChecks(response.data.checks || []);
} catch (error) {
console.error('Error loading checks:', error);
// Checks load error handled
} finally {
setChecksLoading(false);
}
@@ -1073,7 +1147,7 @@ function ResourceDetail() {
});
setIncidents(response.data.incidents || []);
} catch (error) {
console.error('Error loading incidents:', error);
// Incidents load error handled
} finally {
setIncidentsLoading(false);
}
@@ -1098,7 +1172,6 @@ function ResourceDetail() {
setIncidentDescription('');
loadIncidents(); // Reload to show updated description
} catch (error) {
console.error('Error updating incident:', error);
alert('Failed to update incident: ' + (error.response?.data?.error || error.message));
} finally {
setUpdatingIncident(false);
@@ -1111,7 +1184,7 @@ function ResourceDetail() {
const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } });
setSla(response.data);
} catch (error) {
console.error('Error loading SLA summary:', error);
// SLA load error handled
} finally {
setSlaLoading(false);
}
@@ -1122,7 +1195,7 @@ function ResourceDetail() {
const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
setMaintenanceWindows(response.data.windows || []);
} catch (error) {
console.error('Error loading maintenance windows:', error);
// Maintenance windows load error handled
}
};
@@ -1135,7 +1208,6 @@ function ResourceDetail() {
loadMaintenanceWindows();
alert('Maintenance window created');
} catch (error) {
console.error('Error creating maintenance window:', error);
alert('Error creating maintenance window');
}
};
@@ -1146,7 +1218,6 @@ function ResourceDetail() {
await axios.delete(`/api/maintenance-windows/${windowId}`);
loadMaintenanceWindows();
} catch (error) {
console.error('Error deleting maintenance window:', error);
alert('Error deleting maintenance window');
}
}
@@ -1158,7 +1229,6 @@ function ResourceDetail() {
await axios.delete(`/api/resources/${id}`);
navigate('/');
} catch (error) {
console.error('Error deleting resource:', error);
alert('Error deleting resource');
}
}
@@ -1172,7 +1242,7 @@ function ResourceDetail() {
});
loadResource();
} catch (error) {
console.error('Error updating resource:', error);
// Update resource error handled
}
};
@@ -1184,7 +1254,7 @@ function ResourceDetail() {
});
loadResource();
} catch (error) {
console.error('Error updating maintenance mode:', error);
// Update maintenance mode error handled
}
};
@@ -1591,12 +1661,13 @@ function Navbar() {
try {
const response = await axios.get('/api/settings');
setNotificationsConfigured(response.data.email_enabled || response.data.webhook_enabled);
// Cache server timezone for timestamp formatting
// Cache server timezone for timestamp formatting with timestamp
if (response.data.timezone) {
localStorage.setItem('serverTimezone', response.data.timezone);
localStorage.setItem('serverTimezoneTime', Date.now().toString());
}
} catch (error) {
console.error('Error checking notifications:', error);
// Notifications check error handled
}
};
@@ -1609,6 +1680,8 @@ function Navbar() {
<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="/observability" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Observability</Link>
<Link to="/notifications" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Notifications</Link>
<Link to="/settings" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
Settings
{!notificationsConfigured && (
@@ -1647,6 +1720,8 @@ function App() {
<Route path="/sla" element={<SLA />} />
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
<Route path="/status" element={<Status />} />
<Route path="/observability" element={<Observability />} />
<Route path="/notifications" element={<Notifications />} />
</Routes>
</div>
</Router>
+1 -3
View File
@@ -11,9 +11,7 @@ class ErrorBoundary extends React.Component {
}
componentDidCatch(error, info) {
// Log to console; could later POST to /api if needed
// eslint-disable-next-line no-console
console.error('App error:', error, info);
// Error handled silently
}
render() {
+38 -2
View File
@@ -22,6 +22,7 @@ const asNumberOrZero = (value) => {
function History() {
const [historyData, setHistoryData] = useState([]);
const [days, setDays] = useState(7);
const [maxDays, setMaxDays] = useState(30);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showAveraged, setShowAveraged] = useState(true);
@@ -29,7 +30,28 @@ function History() {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [totalItems, setTotalItems] = useState(0);
const [effectiveDays, setEffectiveDays] = useState(7);
const [limitedByRetention, setLimitedByRetention] = useState(false);
useEffect(() => {
// Fetch retention setting from server
const fetchRetention = async () => {
try {
const response = await axios.get('/api/settings');
const retentionDays = response.data.retention_days || 30;
setMaxDays(parseInt(retentionDays));
// Adjust current days if it exceeds retention
if (days > retentionDays) {
setDays(parseInt(retentionDays));
}
} catch (error) {
// Failed to fetch retention setting, use default
}
};
fetchRetention();
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const loadHistory = async () => {
setLoading(true);
@@ -47,6 +69,8 @@ function History() {
if (response.data.resources && Array.isArray(response.data.resources)) {
data = response.data.resources;
total = response.data.total || data.length;
setEffectiveDays(response.data.effective_days || days);
setLimitedByRetention(Boolean(response.data.limited_by_retention));
} else if (Array.isArray(response.data)) {
data = response.data;
total = data.length;
@@ -61,7 +85,6 @@ function History() {
});
return { ...resource, trendsData: trendsResponse.data };
} catch (err) {
console.error(`Error loading trends for resource ${resource.id}:`, err);
return resource;
}
});
@@ -72,7 +95,6 @@ function History() {
setTotalItems(total);
setLoading(false);
} catch (error) {
console.error('Error loading history:', error);
setError(error.message);
setLoading(false);
}
@@ -86,6 +108,18 @@ function History() {
return (
<div className="container">
{limitedByRetention && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
color: '#856404'
}}>
⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2>Monitoring History</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -95,6 +129,8 @@ function History() {
key={d}
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setDays(d)}
disabled={d > maxDays}
style={{ opacity: d > maxDays ? 0.5 : 1 }}
>
{d} Days
</button>
+262
View File
@@ -0,0 +1,262 @@
.notification-center {
position: relative;
}
.notification-bell-container {
display: flex;
align-items: center;
}
.notification-bell {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
position: relative;
padding: 0.5rem;
transition: transform 0.2s;
}
.notification-bell:hover {
transform: scale(1.1);
}
.notification-badge {
position: absolute;
top: 0;
right: 0;
background: #dc3545;
color: white;
border-radius: 50%;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: bold;
line-height: 1;
}
.notification-popup {
position: fixed;
top: 60px;
right: 20px;
width: 400px;
max-height: 600px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #eee;
background: #f9f9f9;
}
.notification-header h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: #999;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #333;
}
.notification-filters {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #eee;
background: #fafafa;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.4rem 0.75rem;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.filter-btn:hover {
border-color: #007bff;
color: #007bff;
}
.filter-btn.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.clear-btn {
margin-left: auto;
background: #dc3545;
color: white;
border-color: #dc3545;
}
.clear-btn:hover {
background: #c82333;
border-color: #c82333;
}
.notification-list {
flex: 1;
overflow-y: auto;
max-height: 450px;
}
.notification-item {
padding: 1rem;
border-bottom: 1px solid #eee;
display: flex;
gap: 0.75rem;
background: white;
transition: background 0.2s;
}
.notification-item:hover {
background: #f9f9f9;
}
.notification-item.unread {
background: #f0f8ff;
font-weight: 500;
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-title {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.95rem;
color: #333;
}
.notification-message {
font-size: 0.85rem;
color: #666;
margin-bottom: 0.5rem;
word-break: break-word;
}
.notification-meta {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: #999;
flex-wrap: wrap;
}
.notification-resource {
background: #f0f0f0;
padding: 0.2rem 0.5rem;
border-radius: 3px;
}
.notification-time {
white-space: nowrap;
}
.notification-actions {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem 0.5rem;
color: #999;
transition: color 0.2s;
flex-shrink: 0;
}
.action-btn:hover {
color: #333;
}
.action-btn.mark-read:hover {
color: #28a745;
}
.action-btn.delete:hover {
color: #dc3545;
}
/* Scrollbar styling */
.notification-list::-webkit-scrollbar {
width: 6px;
}
.notification-list::-webkit-scrollbar-track {
background: #f1f1f1;
}
.notification-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
.notification-list::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Mobile responsive */
@media (max-width: 600px) {
.notification-popup {
position: fixed;
top: 60px;
right: 10px;
left: 10px;
width: auto;
max-height: 70vh;
}
.notification-filters {
flex-direction: column;
}
.filter-btn {
flex: 1;
}
.clear-btn {
margin-left: 0;
}
}
+232
View File
@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils';
import './NotificationCenter.css';
function NotificationCenter() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showCenter, setShowCenter] = useState(false);
const [filter, setFilter] = useState('all'); // all, unread, read
const [loading, setLoading] = useState(false);
const [wsConnected, setWsConnected] = useState(false);
useEffect(() => {
loadNotifications();
// Refresh every 10 seconds
const interval = setInterval(loadNotifications, 10000);
// Connect to WebSocket for real-time updates
connectWebSocket();
return () => {
clearInterval(interval);
if (window.notificationWebSocket) {
window.notificationWebSocket.close();
}
};
}, [filter]);
const connectWebSocket = () => {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}`);
ws.onopen = () => {
console.log('WebSocket connected for real-time notifications');
setWsConnected(true);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'notification') {
// New notification received - reload notifications
loadNotifications();
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setWsConnected(false);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setWsConnected(false);
// Attempt to reconnect after 5 seconds
setTimeout(connectWebSocket, 5000);
};
window.notificationWebSocket = ws;
} catch (error) {
console.error('Failed to connect WebSocket:', error);
}
};
const loadNotifications = async () => {
setLoading(true);
try {
const params = {};
if (filter === 'unread') params.read = 'unread';
if (filter === 'read') params.read = 'read';
params.limit = 100;
const response = await axios.get('/api/notifications', { params });
setNotifications(response.data.notifications);
setUnreadCount(response.data.unreadCount);
} catch (error) {
console.error('Failed to load notifications:', error);
} finally {
setLoading(false);
}
};
const handleMarkAsRead = async (notificationId) => {
try {
await axios.put(`/api/notifications/${notificationId}/read`);
loadNotifications();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleDelete = async (notificationId) => {
try {
await axios.delete(`/api/notifications/${notificationId}`);
loadNotifications();
} catch (error) {
console.error('Failed to delete notification:', error);
}
};
const handleClearAll = async () => {
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
try {
await axios.post('/api/notifications/clear', { type: filter });
loadNotifications();
} catch (error) {
console.error('Failed to clear notifications:', error);
}
};
return (
<div className="notification-center">
{/* Notification Bell Icon */}
<div className="notification-bell-container">
<button
className="notification-bell"
onClick={() => setShowCenter(!showCenter)}
title={`${unreadCount} unread notifications`}
>
🔔
{unreadCount > 0 && (
<span className="notification-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</button>
</div>
{/* Notification Center Popup */}
{showCenter && (
<div className="notification-popup">
<div className="notification-header">
<h3>Notifications</h3>
<button
className="close-btn"
onClick={() => setShowCenter(false)}
title="Close"
>
✕
</button>
</div>
<div className="notification-filters">
<button
className={`filter-btn ${filter === 'all' ? 'active' : ''}`}
onClick={() => setFilter('all')}
>
All
</button>
<button
className={`filter-btn ${filter === 'unread' ? 'active' : ''}`}
onClick={() => setFilter('unread')}
>
Unread ({unreadCount})
</button>
<button
className={`filter-btn ${filter === 'read' ? 'active' : ''}`}
onClick={() => setFilter('read')}
>
Read
</button>
{notifications.length > 0 && (
<button
className="filter-btn clear-btn"
onClick={handleClearAll}
>
Clear All
</button>
)}
</div>
<div className="notification-list">
{loading ? (
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>Loading...</p>
) : notifications.length === 0 ? (
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>
No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications
</p>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={`notification-item ${notification.read ? 'read' : 'unread'}`}
>
<div className="notification-content">
<div className="notification-title">{notification.title}</div>
<div className="notification-message">{notification.message}</div>
<div className="notification-meta">
{notification.resource_name && (
<span className="notification-resource">
Resource: {notification.resource_name}
</span>
)}
<span className="notification-time">
{formatLocalTime(notification.created_at)}
</span>
</div>
</div>
<div className="notification-actions">
{!notification.read && (
<button
className="action-btn mark-read"
onClick={() => handleMarkAsRead(notification.id)}
title="Mark as read"
>
✓
</button>
)}
<button
className="action-btn delete"
onClick={() => handleDelete(notification.id)}
title="Delete"
>
✕
</button>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}
export default NotificationCenter;
+228
View File
@@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils';
function Notifications() {
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0);
const [filter, setFilter] = useState('all');
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const itemsPerPage = 50;
useEffect(() => {
loadNotifications();
const interval = setInterval(loadNotifications, 10000);
// Connect to WebSocket for real-time updates
connectWebSocket();
return () => {
clearInterval(interval);
if (window.notificationWebSocket) {
window.notificationWebSocket.close();
}
};
}, [filter, currentPage]);
const connectWebSocket = () => {
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}`);
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'notification') {
loadNotifications();
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onclose = () => {
setTimeout(connectWebSocket, 5000);
};
window.notificationWebSocket = ws;
} catch (error) {
console.error('Failed to connect WebSocket:', error);
}
};
const loadNotifications = async () => {
setLoading(true);
try {
const params = {
limit: itemsPerPage,
offset: (currentPage - 1) * itemsPerPage
};
if (filter === 'unread') params.read = 'unread';
if (filter === 'read') params.read = 'read';
const response = await axios.get('/api/notifications', { params });
setNotifications(response.data.notifications);
setUnreadCount(response.data.unreadCount);
setTotal(response.data.total);
} catch (error) {
console.error('Failed to load notifications:', error);
} finally {
setLoading(false);
}
};
const handleMarkAsRead = async (notificationId) => {
try {
await axios.put(`/api/notifications/${notificationId}/read`);
loadNotifications();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleDelete = async (notificationId) => {
try {
await axios.delete(`/api/notifications/${notificationId}`);
loadNotifications();
} catch (error) {
console.error('Failed to delete notification:', error);
}
};
const handleClearAll = async () => {
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
try {
await axios.post('/api/notifications/clear', { type: filter });
setCurrentPage(1);
loadNotifications();
} catch (error) {
console.error('Failed to clear notifications:', error);
}
};
const totalPages = Math.ceil(total / itemsPerPage);
return (
<div className="container" style={{ paddingTop: '2rem' }}>
<h2>📬 Notifications</h2>
<div style={{ marginBottom: '2rem', display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilter('all'); setCurrentPage(1); }}
>
All ({total})
</button>
<button
className={`btn ${filter === 'unread' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilter('unread'); setCurrentPage(1); }}
>
Unread ({unreadCount})
</button>
<button
className={`btn ${filter === 'read' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilter('read'); setCurrentPage(1); }}
>
Read ({total - unreadCount})
</button>
</div>
{notifications.length > 0 && (
<button
className="btn btn-danger"
onClick={handleClearAll}
style={{ marginLeft: 'auto' }}
>
Clear All
</button>
)}
</div>
{loading ? (
<p style={{ textAlign: 'center', color: '#999' }}>Loading...</p>
) : notifications.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#999' }}>
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
</div>
) : (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}>
{notifications.map((notification) => (
<div
key={notification.id}
style={{
padding: '1rem',
border: '1px solid #ddd',
borderRadius: '6px',
backgroundColor: notification.read ? 'white' : '#f0f8ff',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem'
}}
>
<div style={{ flex: 1 }}>
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '1rem' }}>{notification.title}</h4>
<p style={{ margin: '0 0 0.5rem 0', color: '#666', fontSize: '0.95rem' }}>
{notification.message}
</p>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', color: '#999', flexWrap: 'wrap' }}>
{notification.resource_name && (
<span style={{ backgroundColor: '#f0f0f0', padding: '0.25rem 0.5rem', borderRadius: '3px' }}>
📌 {notification.resource_name}
</span>
)}
<span>⏰ {formatLocalTime(notification.created_at)}</span>
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
{!notification.read && (
<button
className="btn btn-sm"
onClick={() => handleMarkAsRead(notification.id)}
title="Mark as read"
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem' }}
>
✓ Read
</button>
)}
<button
className="btn btn-sm"
onClick={() => handleDelete(notification.id)}
title="Delete"
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem', background: '#dc3545' }}
>
✕ Delete
</button>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
className={`btn ${currentPage === page ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setCurrentPage(page)}
style={{ minWidth: '40px' }}
>
{page}
</button>
))}
</div>
)}
</>
)}
</div>
);
}
export default Notifications;
+302
View File
@@ -0,0 +1,302 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils';
function Observability() {
const [activeTab, setActiveTab] = useState('metrics');
const [metrics, setMetrics] = useState(null);
const [errors, setErrors] = useState([]);
const [auditLogs, setAuditLogs] = useState([]);
const [auditSummary, setAuditSummary] = useState(null);
const [loading, setLoading] = useState(false);
const [expandedAuditRow, setExpandedAuditRow] = useState(null);
const [filters, setFilters] = useState({
entityType: '',
entityId: '',
userId: '',
action: ''
});
useEffect(() => {
loadData();
const interval = setInterval(loadData, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [activeTab, filters]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const loadData = async () => {
setLoading(true);
try {
if (activeTab === 'metrics') {
const resp = await axios.get('/api/observability/metrics');
setMetrics(resp.data);
} else if (activeTab === 'errors') {
const resp = await axios.get('/api/observability/errors?limit=50');
setErrors(resp.data.errors);
} else if (activeTab === 'audit') {
const auditResp = await axios.get('/api/observability/audit-logs', { params: filters });
setAuditLogs(auditResp.data.logs);
setAuditSummary(auditResp.data.summary);
}
} catch (error) {
// Error loading data
}
setLoading(false);
};
return (
<div className="container" style={{ paddingTop: '2rem' }}>
<h2>System Observability</h2>
<div style={{ marginBottom: '2rem', borderBottom: '1px solid #ddd', display: 'flex', gap: '1rem' }}>
<button
onClick={() => setActiveTab('metrics')}
style={{
padding: '0.75rem 1.5rem',
border: 'none',
borderBottom: activeTab === 'metrics' ? '3px solid #007bff' : 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
fontWeight: activeTab === 'metrics' ? 'bold' : 'normal',
color: activeTab === 'metrics' ? '#007bff' : '#666'
}}
>
API Metrics
</button>
<button
onClick={() => setActiveTab('errors')}
style={{
padding: '0.75rem 1.5rem',
border: 'none',
borderBottom: activeTab === 'errors' ? '3px solid #007bff' : 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
fontWeight: activeTab === 'errors' ? 'bold' : 'normal',
color: activeTab === 'errors' ? '#007bff' : '#666'
}}
>
Errors
</button>
<button
onClick={() => setActiveTab('audit')}
style={{
padding: '0.75rem 1.5rem',
border: 'none',
borderBottom: activeTab === 'audit' ? '3px solid #007bff' : 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
fontWeight: activeTab === 'audit' ? 'bold' : 'normal',
color: activeTab === 'audit' ? '#007bff' : '#666'
}}
>
Audit Logs
</button>
</div>
{/* Metrics Tab */}
{activeTab === 'metrics' && metrics && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Server Uptime</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
{metrics.uptimeFormatted || `${Math.floor(metrics.uptime / 3600)}h`}
</div>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Total API Requests</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
{metrics.apiStats.reduce((sum, s) => sum + s.totalRequests, 0)}
</div>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Errors Logged</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem', color: metrics.errorCount > 0 ? '#dc3545' : '#28a745' }}>
{metrics.errorCount}
</div>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Unique Errors</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
{metrics.uniqueErrors}
</div>
</div>
</div>
<h3>API Endpoint Performance</h3>
<div style={{ overflowX: 'auto', marginBottom: '2rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Endpoint</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Requests</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Success Rate</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Avg Response</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Min/Max</th>
</tr>
</thead>
<tbody>
{metrics.apiStats.map((stat, i) => (
<tr key={i} style={{ borderBottom: '1px solid #ddd' }}>
<td style={{ padding: '0.75rem' }}>
<code>{stat.method} {stat.endpoint}</code>
</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.totalRequests}</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
<span style={{
padding: '0.25rem 0.5rem',
borderRadius: '3px',
backgroundColor: stat.successRate >= 95 ? '#d4edda' : '#fff3cd',
color: stat.successRate >= 95 ? '#155724' : '#856404'
}}>
{stat.successRate}%
</span>
</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.avgResponseTime}ms</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
{stat.minResponseTime}ms / {stat.maxResponseTime}ms
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Errors Tab */}
{activeTab === 'errors' && (
<div>
{errors.length === 0 ? (
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>No errors logged</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Error Message</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Context</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Occurred At</th>
</tr>
</thead>
<tbody>
{errors.map((error, i) => (
<tr key={i} style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white' }}>
<td style={{ padding: '0.75rem' }}>
<code style={{ fontSize: '0.85rem', color: '#d9534f' }}>{error.error_message}</code>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem', color: '#666' }}>
{error.context ? JSON.stringify(JSON.parse(error.context)).substring(0, 50) : '-'}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
{formatLocalTime(error.occurred_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Audit Logs Tab */}
{activeTab === 'audit' && (
<div>
{auditSummary && (
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: '#f9f9f9', borderRadius: '4px' }}>
<p><strong>Summary (Last 7 Days):</strong> {auditSummary.totalEvents} events</p>
{auditSummary.actionBreakdown.map(action => (
<p key={action.action} style={{ margin: '0.25rem 0', fontSize: '0.9rem', color: '#666' }}>
{action.action}: {action.count}
</p>
))}
</div>
)}
<div style={{ marginBottom: '1rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.5rem' }}>
<input
type="text"
placeholder="Entity Type"
value={filters.entityType}
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Entity ID"
value={filters.entityId}
onChange={(e) => setFilters({ ...filters, entityId: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="User ID"
value={filters.userId}
onChange={(e) => setFilters({ ...filters, userId: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Action"
value={filters.action}
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
{auditLogs.length === 0 ? (
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>No audit logs found</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Action</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Entity</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>User</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Changes</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Time</th>
</tr>
</thead>
<tbody>
{auditLogs.map((log, i) => (
<React.Fragment key={i}>
<tr style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white', cursor: 'pointer' }} onClick={() => setExpandedAuditRow(expandedAuditRow === i ? null : i)}>
<td style={{ padding: '0.75rem' }}><strong>{log.action}</strong></td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem', color: '#666' }}>
{log.entity_type} #{log.entity_id}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>{log.user_id}</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
<code style={{ cursor: 'pointer', color: '#007bff' }}>
{expandedAuditRow === i ? '▼ Hide' : '▶ Show'} Changes
</code>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
{formatLocalTime(log.created_at)}
</td>
</tr>
{expandedAuditRow === i && (
<tr style={{ backgroundColor: '#f0f8ff', borderBottom: '1px solid #ddd' }}>
<td colSpan="5" style={{ padding: '1rem' }}>
<strong>Full Changes:</strong>
<pre style={{ marginTop: '0.5rem', backgroundColor: 'white', padding: '0.75rem', borderRadius: '4px', overflowX: 'auto', border: '1px solid #ddd' }}>
{JSON.stringify(log.changes, null, 2)}
</pre>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
export default Observability;
+35 -2
View File
@@ -5,6 +5,7 @@ import './App.css';
function SLA() {
const [slaData, setSlaData] = useState([]);
const [days, setDays] = useState(30);
const [maxDays, setMaxDays] = useState(30);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
@@ -13,6 +14,25 @@ function SLA() {
const [effectiveDays, setEffectiveDays] = useState(30);
const [limitedByRetention, setLimitedByRetention] = useState(false);
useEffect(() => {
// Fetch retention setting from server
const fetchRetention = async () => {
try {
const response = await axios.get('/api/settings');
const retentionDays = response.data.retention_days || 30;
setMaxDays(parseInt(retentionDays));
// Adjust current days if it exceeds retention
if (days > retentionDays) {
setDays(parseInt(retentionDays));
}
} catch (error) {
// Failed to fetch retention setting, use default
}
};
fetchRetention();
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const loadSLAData = React.useCallback(async () => {
setLoading(true);
setError(null);
@@ -25,7 +45,6 @@ function SLA() {
setLimitedByRetention(Boolean(response.data.limited_by_retention));
setLoading(false);
} catch (error) {
console.error('Error loading SLA data:', error);
setError(error.message);
setLoading(false);
}
@@ -43,11 +62,23 @@ function SLA() {
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),
totalDowntime: (slaData.reduce((sum, s) => sum + s.downtime_minutes, 0) / 60).toFixed(2), // Convert to hours
} : null;
return (
<div className="container">
{limitedByRetention && (
<div style={{
padding: '1rem',
marginBottom: '1rem',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
color: '#856404'
}}>
⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days.
</div>
)}
<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' }}>
@@ -56,6 +87,8 @@ function SLA() {
key={d}
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setDays(d)}
disabled={d > maxDays}
style={{ opacity: d > maxDays ? 0.5 : 1 }}
>
{d} Days
</button>
+218 -9
View File
@@ -30,7 +30,6 @@ function SettingsWizard() {
default_sort: 'name',
items_per_page: 20,
refresh_interval: 5000,
theme: 'light',
incident_failure_threshold: 10,
});
@@ -48,7 +47,7 @@ function SettingsWizard() {
const response = await axios.get('/api/settings');
setSettings(response.data);
} catch (error) {
console.error('Error loading settings:', error);
// Settings load error handled
}
};
@@ -62,7 +61,6 @@ function SettingsWizard() {
// Clear message after 3 seconds
setTimeout(() => setSavedMessage(null), 3000);
} catch (error) {
console.error('Error saving settings:', error);
setSavedMessage({ section: sectionName, success: false, error: error.response?.data?.error || 'Failed to save' });
setTimeout(() => setSavedMessage(null), 5000);
} finally {
@@ -115,6 +113,7 @@ function SettingsWizard() {
{ name: 'Gmail', host: 'smtp.gmail.com', port: 587 },
{ name: 'Outlook', host: 'smtp-mail.outlook.com', port: 587 },
{ name: 'Yahoo', host: 'smtp.mail.yahoo.com', port: 587 },
{ name: 'iCloud', host: 'smtp.mail.me.com', port: 587 },
{ name: 'Custom', host: '', port: 587 },
];
@@ -363,6 +362,20 @@ function SettingsWizard() {
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Global Data Retention (days)</label>
<input
type="number"
value={settings.retention_days}
onChange={(e) => setSettings({ ...settings, retention_days: parseInt(e.target.value) })}
min="1"
max="365"
/>
<small>Default retention period for all monitors (1-365 days). Individual monitors can override this.</small>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Timezone</label>
@@ -391,6 +404,18 @@ function SettingsWizard() {
</div>
<div className="form-row">
<div className="form-group">
<label>Consecutive Failures Threshold</label>
<input
type="number"
value={settings.consecutive_failures || 3}
onChange={(e) => setSettings({ ...settings, consecutive_failures: parseInt(e.target.value) })}
min="1"
max="100"
/>
<small>Number of consecutive failures before triggering an alert (default: 3)</small>
</div>
<div className="form-group">
<label>Incident Failure Threshold</label>
<input
@@ -400,7 +425,33 @@ function SettingsWizard() {
min="1"
max="100"
/>
<small>Number of consecutive failed checks before automatically creating an incident (default: 10)</small>
<small>Number of failures before creating an incident (default: 10)</small>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Grace Period (seconds)</label>
<input
type="number"
value={settings.grace_period || 300}
onChange={(e) => setSettings({ ...settings, grace_period: parseInt(e.target.value) })}
min="0"
step="60"
/>
<small>Wait time before alerting after first failure (default: 300s = 5 minutes)</small>
</div>
<div className="form-group">
<label>Downtime Threshold (seconds)</label>
<input
type="number"
value={settings.downtime_threshold || 600}
onChange={(e) => setSettings({ ...settings, downtime_threshold: parseInt(e.target.value) })}
min="0"
step="60"
/>
<small>Minimum downtime before creating incident (default: 600s = 10 minutes)</small>
</div>
</div>
@@ -411,25 +462,183 @@ function SettingsWizard() {
timeout: settings.timeout,
timezone: settings.timezone,
retention_days: settings.retention_days,
auto_cleanup_enabled: settings.auto_cleanup_enabled,
consecutive_failures: settings.consecutive_failures,
grace_period: settings.grace_period,
downtime_threshold: settings.downtime_threshold,
incident_failure_threshold: settings.incident_failure_threshold,
})}
disabled={savingSection === 'Monitoring'}
>
{savingSection === 'Monitoring' ? 'Saving...' : '💾 Save Monitoring Settings'}
</button>
</div>
</div>
{/* Alert Settings */}
<div className="settings-section">
<h3>🔔 Alert Settings</h3>
<div className="settings-form">
<div className="form-row">
<div className="form-group">
<label>Alert Retry Count</label>
<input
type="number"
value={settings.alert_retry_count || 3}
onChange={(e) => setSettings({ ...settings, alert_retry_count: parseInt(e.target.value) })}
min="0"
max="10"
/>
<small>Number of times to retry sending alert if it fails (default: 3)</small>
</div>
<div className="form-group">
<label>Alert Retry Delay (seconds)</label>
<input
type="number"
value={settings.alert_retry_delay || 60}
onChange={(e) => setSettings({ ...settings, alert_retry_delay: parseInt(e.target.value) })}
min="0"
step="30"
/>
<small>Wait time between alert retries (default: 60s)</small>
</div>
</div>
<div className="form-group">
<label>Fallback Webhook URL (optional)</label>
<input
type="url"
value={settings.fallback_webhook || ''}
onChange={(e) => setSettings({ ...settings, fallback_webhook: e.target.value })}
placeholder="https://backup-webhook.example.com"
/>
<small>Backup webhook to use if primary alert methods fail</small>
</div>
<div className="form-row">
<div className="form-group">
<label>Global Quiet Hours Start</label>
<input
type="time"
value={settings.global_quiet_hours_start || ''}
onChange={(e) => setSettings({ ...settings, global_quiet_hours_start: e.target.value })}
/>
<small>Suppress non-critical alerts starting at this time</small>
</div>
<div className="form-group">
<label>Global Quiet Hours End</label>
<input
type="time"
value={settings.global_quiet_hours_end || ''}
onChange={(e) => setSettings({ ...settings, global_quiet_hours_end: e.target.value })}
/>
<small>Resume normal alerting at this time</small>
</div>
</div>
<div className="form-group">
<label>Escalation Hours</label>
<input
type="number"
value={settings.escalation_hours || 4}
onChange={(e) => setSettings({ ...settings, escalation_hours: parseInt(e.target.value) })}
min="1"
max="48"
/>
<small>Hours after which unresolved incidents are escalated (default: 4)</small>
</div>
<button
className="btn btn-primary"
onClick={() => handleSaveSection('Alerts', {
alert_retry_count: settings.alert_retry_count,
alert_retry_delay: settings.alert_retry_delay,
fallback_webhook: settings.fallback_webhook,
global_quiet_hours_start: settings.global_quiet_hours_start,
global_quiet_hours_end: settings.global_quiet_hours_end,
escalation_hours: settings.escalation_hours,
})}
disabled={savingSection === 'Alerts'}
>
{savingSection === 'Alerts' ? 'Saving...' : '💾 Save Alert Settings'}
</button>
</div>
</div>
{/* Dashboard Settings */}
<div className="settings-section">
<h3>📊 Dashboard Settings</h3>
<div className="settings-form">
<div className="form-row">
<div className="form-group">
<label>Default Sort Order</label>
<select
value={settings.default_sort || 'name'}
onChange={(e) => setSettings({ ...settings, default_sort: e.target.value })}
>
<option value="name">Name</option>
<option value="status">Status</option>
<option value="created_at">Created Date</option>
<option value="group">Group</option>
</select>
<small>Default sorting for resources list</small>
</div>
<div className="form-group">
<label>Items Per Page</label>
<input
type="number"
value={settings.items_per_page || 20}
onChange={(e) => setSettings({ ...settings, items_per_page: parseInt(e.target.value) })}
min="10"
max="100"
step="10"
/>
<small>Number of resources to display per page</small>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label>Dashboard Refresh Interval (ms)</label>
<input
type="number"
value={settings.refresh_interval || 5000}
onChange={(e) => setSettings({ ...settings, refresh_interval: parseInt(e.target.value) })}
min="1000"
step="1000"
/>
<small>How often to refresh dashboard data (default: 5000ms = 5 seconds)</small>
</div>
</div>
<div className="form-group">
<label className="toggle-switch-row">
<span>Auto Cleanup Enabled</span>
<label className="toggle-switch">
<input
type="checkbox"
checked={settings.auto_cleanup_enabled || false}
onChange={(e) => setSettings({ ...settings, auto_cleanup_enabled: e.target.checked })}
/>
<span className="toggle-slider"></span>
</label>
</label>
<small>Automatically archive old check data based on retention period</small>
</div>
<button
className="btn btn-primary"
onClick={() => handleSaveSection('Dashboard', {
default_sort: settings.default_sort,
items_per_page: settings.items_per_page,
refresh_interval: settings.refresh_interval,
theme: settings.theme,
incident_failure_threshold: settings.incident_failure_threshold,
auto_cleanup_enabled: settings.auto_cleanup_enabled,
})}
disabled={savingSection === 'Monitoring'}
disabled={savingSection === 'Dashboard'}
>
{savingSection === 'Monitoring' ? 'Saving...' : '💾 Save Monitoring Settings'}
{savingSection === 'Dashboard' ? 'Saving...' : '💾 Save Dashboard Settings'}
</button>
</div>
</div>
+2 -2
View File
@@ -12,7 +12,7 @@ function Status() {
useEffect(() => {
loadStatusData();
const interval = setInterval(loadStatusData, 30000); // Refresh every 30 seconds
const interval = setInterval(loadStatusData, 15000); // Refresh every 15 seconds for critical status
return () => clearInterval(interval);
}, []);
@@ -28,7 +28,7 @@ function Status() {
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
setLastUpdated(new Date());
} catch (error) {
console.error('Error loading status data:', error);
// Status load error handled
} finally {
setLoading(false);
}
+25 -3
View File
@@ -28,12 +28,19 @@ export const formatLocalTime = (timestamp, options = {}) => {
let timeZone;
try {
const cachedSettings = localStorage.getItem('serverTimezone');
if (cachedSettings && cachedSettings !== 'null' && cachedSettings !== 'undefined' && cachedSettings.trim() !== '') {
const cacheTime = localStorage.getItem('serverTimezoneTime');
const now = Date.now();
// Invalidate cache every 1 hour
if (cachedSettings && cacheTime && (now - parseInt(cacheTime)) < 3600000 && cachedSettings !== 'null' && cachedSettings !== 'undefined' && cachedSettings.trim() !== '') {
// Remove any quotes that might be in the string
timeZone = cachedSettings.replace(/^["']|["']$/g, '').trim();
} else if (cachedSettings) {
// Clear stale cache
localStorage.removeItem('serverTimezone');
localStorage.removeItem('serverTimezoneTime');
}
} catch (e) {
console.error('Error reading serverTimezone from localStorage:', e);
// Timezone load error handled silently
}
const defaultOptions = {
@@ -72,12 +79,27 @@ export const formatLocalTimeOnly = (timestamp, options = {}) => {
/**
* Format timestamp for chart display (shorter format)
* Shows date and time for better context
*/
export const formatChartTime = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
// If within last 24 hours, show time only
if (diffDays === 0) {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
// Otherwise show date and time
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
+107
View File
@@ -14,6 +14,7 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.7",
"ping": "^0.4.4",
@@ -253,6 +254,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -596,6 +603,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1136,6 +1152,55 @@
"node": ">=0.12.0"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -1143,6 +1208,48 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+14 -8
View File
@@ -12,22 +12,28 @@
"build:commit": "npm run build && git add -f client/build/ && git commit -m \"Auto-commit built React app\" && git push",
"install-all": "npm install && cd client && npm install"
},
"keywords": ["monitoring", "uptime", "alerts", "dashboard"],
"keywords": [
"monitoring",
"uptime",
"alerts",
"dashboard"
],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^9.2.2",
"node-cron": "^3.0.3",
"axios": "^1.6.2",
"nodemailer": "^6.9.7",
"better-sqlite3": "^9.2.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.7",
"ping": "^0.4.4",
"ws": "^8.17.1"
},
"devDependencies": {
"nodemon": "^3.0.2",
"concurrently": "^8.2.2"
"concurrently": "^8.2.2",
"nodemon": "^3.0.2"
}
}
+152
View File
@@ -0,0 +1,152 @@
const db = require('./database');
class AuditLogger {
// Log an audit event
log(action, entityType, entityId, changes = {}, userId = 'system', ipAddress = null) {
try {
db.prepare(`
INSERT INTO audit_logs
(action, entity_type, entity_id, user_id, changes, ip_address, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
`).run(
action,
entityType,
entityId,
userId,
JSON.stringify(changes),
ipAddress
);
} catch (error) {
// Silent failure - don't crash on audit logging errors
}
}
// Log resource changes
logResourceChange(action, resourceId, changes, userId = 'system', ipAddress = null) {
this.log(action, 'resource', resourceId, changes, userId, ipAddress);
}
// Log settings changes
logSettingsChange(settingKey, oldValue, newValue, userId = 'system', ipAddress = null) {
this.log('update', 'settings', settingKey, { old: oldValue, new: newValue }, userId, ipAddress);
}
// Log group changes
logGroupChange(action, groupId, changes, userId = 'system', ipAddress = null) {
this.log(action, 'group', groupId, changes, userId, ipAddress);
}
// Log maintenance window changes
logMaintenanceChange(action, windowId, changes, userId = 'system', ipAddress = null) {
this.log(action, 'maintenance_window', windowId, changes, userId, ipAddress);
}
// Get audit logs with filters
getAuditLogs(filters = {}) {
const {
entityType = null,
entityId = null,
userId = null,
action = null,
startDate = null,
endDate = null,
limit = 100,
offset = 0
} = filters;
let query = `
SELECT
id,
action,
entity_type,
entity_id,
user_id,
changes,
ip_address,
REPLACE(created_at, ' ', 'T') || 'Z' as created_at
FROM audit_logs
WHERE 1=1
`;
const params = [];
if (entityType) {
query += ' AND entity_type = ?';
params.push(entityType);
}
if (entityId) {
query += ' AND entity_id = ?';
params.push(entityId);
}
if (userId) {
query += ' AND user_id = ?';
params.push(userId);
}
if (action) {
query += ' AND action = ?';
params.push(action);
}
if (startDate) {
query += ' AND created_at >= ?';
params.push(startDate);
}
if (endDate) {
query += ' AND created_at <= ?';
params.push(endDate);
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
try {
const logs = db.prepare(query).all(...params);
// Parse JSON changes
return logs.map(log => ({
...log,
changes: log.changes ? JSON.parse(log.changes) : {}
}));
} catch (error) {
return [];
}
}
// Get audit summary stats
getSummary(days = 7) {
try {
const stats = db.prepare(`
SELECT
COUNT(*) as total_events,
COUNT(DISTINCT user_id) as unique_users,
COUNT(DISTINCT entity_type) as entity_types,
action,
COUNT(*) as action_count
FROM audit_logs
WHERE created_at > datetime('now', '-' || ? || ' days')
GROUP BY action
`).all(days);
const totalEvents = db.prepare(`
SELECT COUNT(*) as count
FROM audit_logs
WHERE created_at > datetime('now', '-' || ? || ' days')
`).get(days);
return {
totalEvents: totalEvents?.count || 0,
actionBreakdown: stats.map(s => ({
action: s.action,
count: s.action_count
}))
};
} catch (error) {
return { totalEvents: 0, actionBreakdown: [] };
}
}
}
module.exports = new AuditLogger();
+62
View File
@@ -0,0 +1,62 @@
// Circuit breaker implementation for API resilience
class CircuitBreaker {
constructor(failureThreshold = 5, resetTimeout = 60000, errorFilter = null) {
this.failureThreshold = failureThreshold;
this.resetTimeout = resetTimeout;
this.errorFilter = errorFilter;
this.failureCount = 0;
this.lastFailureTime = null;
this.state = 'closed'; // closed, open, half-open
}
async execute(fn, fallback = null) {
if (this.state === 'open') {
if (Date.now() - this.lastFailureTime > this.resetTimeout) {
this.state = 'half-open';
} else {
if (fallback) return fallback();
throw new Error('Circuit breaker is open');
}
}
try {
const result = await fn();
if (this.state === 'half-open') {
this.close();
}
return result;
} catch (error) {
if (this.errorFilter && !this.errorFilter(error)) {
throw error;
}
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.open();
}
throw error;
}
}
close() {
this.failureCount = 0;
this.state = 'closed';
}
open() {
this.state = 'open';
}
getState() {
return {
state: this.state,
failureCount: this.failureCount,
lastFailureTime: this.lastFailureTime
};
}
}
module.exports = CircuitBreaker;
+59 -19
View File
@@ -22,6 +22,7 @@ db.exec(`
enabled INTEGER DEFAULT 1,
maintenance_mode INTEGER DEFAULT 0,
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
retention_days INTEGER DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -111,6 +112,20 @@ db.exec(`
UNIQUE(resource_id, tag)
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER,
incident_id INTEGER,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT DEFAULT 'unread',
read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE,
FOREIGN KEY (incident_id) REFERENCES incidents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_checks_resource_id ON checks(resource_id);
CREATE INDEX IF NOT EXISTS idx_checks_checked_at ON checks(checked_at);
CREATE INDEX IF NOT EXISTS idx_checks_resource_checked ON checks(resource_id, checked_at);
@@ -122,8 +137,34 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_alert_rules_resource ON alert_rules(resource_id);
CREATE INDEX IF NOT EXISTS idx_transaction_checks_resource ON transaction_checks(resource_id);
CREATE INDEX IF NOT EXISTS idx_archived_checks_resource_time ON archived_checks(resource_id, checked_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_read_status ON notifications(read, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_resource_tags_resource ON resource_tags(resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_tags_tag ON resource_tags(tag);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
user_id TEXT DEFAULT 'system',
changes TEXT,
ip_address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS error_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
error_message TEXT NOT NULL,
stack_trace TEXT,
context TEXT,
occurred_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_error_logs_occurred ON error_logs(occurred_at DESC);
`);
// Lightweight migrations - for existing databases
@@ -141,10 +182,9 @@ try {
try {
db.prepare("ALTER TABLE resources ADD COLUMN maintenance_mode INTEGER DEFAULT 0").run();
console.log('[DB] Added maintenance_mode column to resources');
} catch (err) {
if (err.message.includes('duplicate')) {
console.log('[DB] maintenance_mode column already exists');
// Column already exists
}
}
@@ -174,12 +214,11 @@ try {
try {
db.prepare("ALTER TABLE resources ADD COLUMN email_to TEXT").run();
console.log('[DB] Added email_to column to resources');
} catch (err) {
if (err.message.includes('duplicate')) {
console.log('[DB] email_to column already exists');
// email_to column already exists
} else {
console.error('[DB] Error adding email_to column:', err.message);
// Error adding email_to column
}
}
@@ -202,66 +241,67 @@ try {
// New columns for features
try {
db.prepare("ALTER TABLE resources ADD COLUMN tags TEXT").run();
console.log('[DB] Added tags column to resources');
} catch (err) {
if (err.message.includes('duplicate')) {
console.log('[DB] tags column already exists');
// tags column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN consecutive_failures_threshold INTEGER DEFAULT 1").run();
console.log('[DB] Added consecutive_failures_threshold column to resources');
} catch (err) {
if (err.message.includes('duplicate')) {
console.log('[DB] consecutive_failures_threshold column already exists');
// consecutive_failures_threshold column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN response_time_threshold INTEGER").run();
console.log('[DB] Added response_time_threshold column to resources');
} catch (err) {
if (err.message.includes('duplicate')) {
console.log('[DB] response_time_threshold column already exists');
// response_time_threshold column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN response_time_baseline INTEGER").run();
console.log('[DB] Added response_time_baseline column to resources');
} catch (err) {
if (err.message.includes('duplicate')) {
console.log('[DB] response_time_baseline column already exists');
// response_time_baseline column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN is_transaction INTEGER DEFAULT 0").run();
console.log('[DB] Added is_transaction column to resources');
} catch (err) {
if (err.message.includes('duplicate')) {
console.log('[DB] is_transaction column already exists');
// is_transaction column already exists
}
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN retention_days INTEGER DEFAULT NULL").run();
} catch (err) {
if (err.message.includes('duplicate')) {
// retention_days column already exists
}
}
// Add description column to incidents for incident summaries
try {
db.prepare("ALTER TABLE incidents ADD COLUMN description TEXT").run();
console.log('[DB] Added description column to incidents');
} catch (err) {
if (err.message.includes('duplicate')) {
console.log('[DB] description column already exists');
// description column already exists
}
}
// Add failed_check_count to incidents to track consecutive failures at creation time
try {
db.prepare("ALTER TABLE incidents ADD COLUMN failed_check_count INTEGER DEFAULT 0").run();
console.log('[DB] Added failed_check_count column to incidents');
} catch (err) {
if (err.message.includes('duplicate')) {
console.log('[DB] failed_check_count column already exists');
// failed_check_count column already exists
}
}
+636 -46
View File
@@ -10,6 +10,33 @@ const scheduler = require('./scheduler');
const monitorService = require('./monitorService');
const notificationService = require('./notificationService');
const cache = require('./cache');
const security = require('./securityMiddleware');
const metrics = require('./metrics');
const auditLog = require('./auditLog');
// Initialize notifications table if it doesn't exist
try {
db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
resource_id INTEGER,
incident_id INTEGER,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT DEFAULT 'unread',
read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE,
FOREIGN KEY (incident_id) REFERENCES incidents(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_notifications_read_status ON notifications(read, created_at DESC);
`);
} catch (error) {
console.error('Error initializing notifications table:', error);
}
const app = express();
const PORT = process.env.PORT || 3001;
@@ -81,6 +108,44 @@ function getTimezoneOffset() {
app.use(cors());
app.use(express.json());
// Metrics tracking middleware
app.use((req, res, next) => {
const startTime = Date.now();
// Capture original res.json and res.send
const originalJson = res.json.bind(res);
const originalSend = res.send.bind(res);
const trackRequest = () => {
const duration = Date.now() - startTime;
const endpoint = req.route?.path || req.path;
metrics.trackApiRequest(endpoint, req.method, res.statusCode, duration);
};
res.json = function(data) {
trackRequest();
return originalJson(data);
};
res.send = function(data) {
trackRequest();
return originalSend(data);
};
next();
});
// Error tracking middleware
app.use((err, req, res, next) => {
metrics.trackError(err, {
method: req.method,
url: req.url,
ip: req.ip
});
res.status(500).json({ error: 'Internal server error' });
});
// API Routes
// Get all groups
@@ -139,16 +204,37 @@ app.get('/api/resources/:id', (req, res) => {
// Create resource
app.post('/api/resources', (req, res) => {
const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode } = req.body;
const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days } = req.body;
if (!name || !url) {
return res.status(400).json({ error: 'Name and URL are required' });
}
// Validate URL format
try {
new URL(url);
} catch (e) {
return res.status(400).json({ error: 'Invalid URL format' });
}
// Validate numeric fields
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) {
return res.status(400).json({ error: 'Check interval must be at least 10000ms' });
}
if (timeout && (isNaN(timeout) || timeout < 1000)) {
return res.status(400).json({ error: 'Timeout must be at least 1000ms' });
}
if (sla_target && (isNaN(sla_target) || sla_target < 0 || sla_target > 100)) {
return res.status(400).json({ error: 'SLA target must be between 0 and 100' });
}
if (retention_days && (isNaN(retention_days) || retention_days < 1 || retention_days > 365)) {
return res.status(400).json({ error: 'Retention days must be between 1 and 365' });
}
try {
const stmt = db.prepare(`
INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -165,16 +251,19 @@ app.post('/api/resources', (req, res) => {
cert_expiry_days || 30,
sla_target || 99.9,
email_to || null,
maintenance_mode ? 1 : 0
maintenance_mode ? 1 : 0,
retention_days || null
);
// Invalidate related cache entries
cache.invalidatePattern('history:');
cache.invalidatePattern('sla:');
// Audit log
auditLog.logResourceChange('create', result.lastInsertRowid, { name, url, type, enabled: true }, 'system', req.ip);
res.json({ id: result.lastInsertRowid, message: 'Resource created' });
} catch (err) {
console.error('[API] Error creating resource:', err.message);
// Helpful hint if migration missing
const hint = err.message.includes('no column named maintenance_mode')
? 'Database schema missing maintenance_mode. Restart server to run migrations.'
@@ -185,11 +274,11 @@ app.post('/api/resources', (req, res) => {
// Update resource
app.put('/api/resources/:id', (req, res) => {
const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode } = req.body;
const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days } = req.body;
const stmt = db.prepare(`
UPDATE resources
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?, retention_days = ?
WHERE id = ?
`);
@@ -209,6 +298,7 @@ app.put('/api/resources/:id', (req, res) => {
sla_target || 99.9,
email_to || null,
maintenance_mode ? 1 : 0,
retention_days || null,
req.params.id
);
@@ -257,12 +347,19 @@ app.patch('/api/resources/:id/group', (req, res) => {
// Delete resource
app.delete('/api/resources/:id', (req, res) => {
const resource = db.prepare('SELECT name, url FROM resources WHERE id = ?').get(req.params.id);
db.prepare('DELETE FROM resources WHERE id = ?').run(req.params.id);
// Invalidate related cache entries
cache.invalidatePattern('history:');
cache.invalidatePattern('sla:');
// Audit log
if (resource) {
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
}
res.json({ message: 'Resource deleted' });
});
@@ -339,6 +436,84 @@ app.get('/api/incidents', (req, res) => {
res.json({ incidents });
});
// Get notifications (in-app notification center)
app.get('/api/notifications', (req, res) => {
const limit = parseInt(req.query.limit) || 50;
const offset = parseInt(req.query.offset) || 0;
const read = req.query.read; // undefined = all, 'unread' = only unread, 'read' = only read
let whereClause = '';
let whereClauseCount = '';
if (read === 'unread') {
whereClause = 'WHERE n.read = 0';
whereClauseCount = 'WHERE read = 0';
} else if (read === 'read') {
whereClause = 'WHERE n.read = 1';
whereClauseCount = 'WHERE read = 1';
}
const notifications = db.prepare(`
SELECT
n.id,
n.resource_id,
n.incident_id,
n.type,
n.title,
n.message,
n.read,
n.created_at,
r.name as resource_name,
i.started_at as incident_started_at
FROM notifications n
LEFT JOIN resources r ON n.resource_id = r.id
LEFT JOIN incidents i ON n.incident_id = i.id
${whereClause}
ORDER BY n.created_at DESC
LIMIT ? OFFSET ?
`).all(limit, offset);
const total = db.prepare(`
SELECT COUNT(*) as count FROM notifications ${whereClauseCount}
`).get().count;
const unreadCount = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE read = 0').get().count;
res.json({ notifications, total, unreadCount });
});
// Get unread notification count
app.get('/api/notifications/unread/count', (req, res) => {
const count = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE read = 0').get().count;
res.json({ unreadCount: count });
});
// Mark notification as read
app.put('/api/notifications/:id/read', (req, res) => {
db.prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Delete notification
app.delete('/api/notifications/:id', (req, res) => {
db.prepare('DELETE FROM notifications WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// Clear all notifications
app.post('/api/notifications/clear', (req, res) => {
const type = req.body.type; // 'all', 'read', 'unread'
if (type === 'all') {
db.prepare('DELETE FROM notifications').run();
} else if (type === 'read') {
db.prepare('DELETE FROM notifications WHERE read = 1').run();
} else if (type === 'unread') {
db.prepare('DELETE FROM notifications WHERE read = 0').run();
}
res.json({ success: true });
});
// Get settings
app.get('/api/settings', (req, res) => {
const settings = {
@@ -414,8 +589,43 @@ app.post('/api/settings', (req, res) => {
theme,
} = req.body;
const envContent = `PORT=${process.env.PORT || 3001}
EMAIL_ENABLED=${email_enabled}
// Validate email configuration if enabled
if (email_enabled) {
if (!email_host || !email_port || !email_user || !email_pass) {
return res.status(400).json({ error: 'Email configuration incomplete. All fields required when enabled.' });
}
if (isNaN(email_port) || email_port < 1 || email_port > 65535) {
return res.status(400).json({ error: 'Invalid email port number' });
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email_from)) {
return res.status(400).json({ error: 'Invalid email_from address' });
}
}
// Validate webhook configuration if enabled
if (webhook_enabled) {
if (!webhook_url) {
return res.status(400).json({ error: 'Webhook URL required when enabled' });
}
try {
new URL(webhook_url);
} catch (e) {
return res.status(400).json({ error: 'Invalid webhook URL format' });
}
}
// Validate numeric configuration
if (retention_days && (isNaN(retention_days) || retention_days < 1)) {
return res.status(400).json({ error: 'Retention days must be at least 1' });
}
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) {
return res.status(400).json({ error: 'Check interval must be at least 10000ms' });
}
if (timeout && (isNaN(timeout) || timeout < 1000)) {
return res.status(400).json({ error: 'Timeout must be at least 1000ms' });
}
const envContent = `EMAIL_ENABLED=${email_enabled}
EMAIL_HOST=${email_host}
EMAIL_PORT=${email_port}
EMAIL_USER=${email_user}
@@ -490,7 +700,6 @@ THEME=${theme || 'light'}
res.json({ message: 'Settings saved successfully' });
} catch (error) {
console.error('Error saving settings:', error);
res.status(500).json({ error: 'Failed to save settings' });
}
});
@@ -506,7 +715,6 @@ app.get('/api/settings/retention', (req, res) => {
retention_days: setting ? parseInt(setting.value) : 30
});
} catch (error) {
console.error('Error getting retention settings:', error);
res.status(500).json({ error: 'Failed to get retention settings' });
}
});
@@ -527,7 +735,6 @@ app.post('/api/settings/retention', (req, res) => {
res.json({ message: 'Retention settings updated', retention_days });
} catch (error) {
console.error('Error updating retention settings:', error);
res.status(500).json({ error: 'Failed to update retention settings' });
}
});
@@ -562,6 +769,123 @@ app.post('/api/settings/incident-threshold', (req, res) => {
}
});
// CSRF token generation endpoint
app.get('/api/auth/csrf-token', (req, res) => {
try {
const csrfToken = security.generateCSRFToken();
res.json({ csrfToken });
} catch (error) {
res.status(500).json({ error: 'Failed to generate CSRF token' });
}
});
// JWT token refresh endpoint
app.post('/api/auth/refresh-token', (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
// Verify the refresh token
const decoded = security.verifyRefreshToken(refreshToken);
if (!decoded) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// Generate new access and refresh tokens
const newAccessToken = security.generateToken({ userId: decoded.userId, username: decoded.username });
const newRefreshToken = security.generateRefreshToken({ userId: decoded.userId, username: decoded.username });
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresIn: 3600 // 1 hour
});
} catch (error) {
res.status(401).json({ error: 'Token refresh failed' });
}
});
// Password validation endpoint (for frontend to check strength)
app.post('/api/auth/validate-password', (req, res) => {
try {
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: 'Password required' });
}
const validation = security.validatePasswordStrength(password);
res.json(validation);
} catch (error) {
res.status(500).json({ error: 'Password validation failed' });
}
});
// Observability & Monitoring Endpoints
// Get application metrics
app.get('/api/observability/metrics', (req, res) => {
try {
const metricsData = metrics.getMetrics();
res.json(metricsData);
} catch (error) {
metrics.trackError(error, { endpoint: '/api/observability/metrics' });
res.status(500).json({ error: 'Failed to retrieve metrics' });
}
});
// Get recent errors
app.get('/api/observability/errors', (req, res) => {
try {
const { limit = 50 } = req.query;
const errors = metrics.getRecentErrors(parseInt(limit));
res.json({ errors });
} catch (error) {
metrics.trackError(error, { endpoint: '/api/observability/errors' });
res.status(500).json({ error: 'Failed to retrieve errors' });
}
});
// Get audit logs
app.get('/api/observability/audit-logs', (req, res) => {
try {
const filters = {
entityType: req.query.entityType,
entityId: req.query.entityId,
userId: req.query.userId,
action: req.query.action,
startDate: req.query.startDate,
endDate: req.query.endDate,
limit: parseInt(req.query.limit || 100),
offset: parseInt(req.query.offset || 0)
};
const logs = auditLog.getAuditLogs(filters);
const summary = auditLog.getSummary(parseInt(req.query.days || 7));
res.json({ logs, summary });
} catch (error) {
metrics.trackError(error, { endpoint: '/api/observability/audit-logs' });
res.status(500).json({ error: 'Failed to retrieve audit logs' });
}
});
// Get audit log summary
app.get('/api/observability/audit-summary', (req, res) => {
try {
const { days = 7 } = req.query;
const summary = auditLog.getSummary(parseInt(days));
res.json(summary);
} catch (error) {
metrics.trackError(error, { endpoint: '/api/observability/audit-summary' });
res.status(500).json({ error: 'Failed to retrieve audit summary' });
}
});
// Get archived checks for a resource
app.get('/api/resources/:id/archived', (req, res) => {
const { id } = req.params;
@@ -607,7 +931,6 @@ app.get('/api/resources/:id/archived', (req, res) => {
},
});
} catch (error) {
console.error('Error fetching archived checks:', error);
res.status(500).json({ error: 'Failed to fetch archived checks' });
}
});
@@ -791,7 +1114,6 @@ app.get('/api/resources/:id/trends', (req, res) => {
}
});
} catch (error) {
console.error('Error fetching trends:', error);
res.status(500).json({ error: 'Failed to fetch trends' });
}
});
@@ -804,8 +1126,15 @@ app.get('/api/history/overview', (req, res) => {
const currentPage = Math.max(1, parseInt(pageParam || page || 1));
const offset = (currentPage - 1) * pageLimit;
// Get retention setting and enforce limit
const retentionSetting = db.prepare('SELECT value FROM settings WHERE key = ?').get('retention_days');
const maxDays = retentionSetting ? parseInt(retentionSetting.value) : 30;
const requestedDays = parseInt(days);
const effectiveDays = Math.min(requestedDays, maxDays);
const limitedByRetention = effectiveDays < requestedDays;
// Create cache key based on query parameters
const cacheKey = `history:days=${days}:page=${currentPage}:limit=${pageLimit}:averaged=${averaged}`;
const cacheKey = `history:days=${effectiveDays}:page=${currentPage}:limit=${pageLimit}:averaged=${averaged}`;
// Check cache first
const cachedResult = cache.get(cacheKey);
@@ -832,7 +1161,7 @@ app.get('/api/history/overview', (req, res) => {
MAX(response_time) as max_response
FROM checks
WHERE resource_id = ? AND checked_at > datetime('now', ?)
`).get(resource.id, `-${days} days`);
`).get(resource.id, `-${effectiveDays} days`);
const uptime = stats.total > 0 ? (stats.up_count / stats.total * 100) : 0;
const avgResponseTime = stats.avg_response || 0;
@@ -842,7 +1171,7 @@ app.get('/api/history/overview', (req, res) => {
if (isAveraged) {
// Compute interval hours for bucketing (1h for 7 days, 3h for 14, 6h for 30+)
const intervalHours = days <= 7 ? 1 : days <= 14 ? 3 : 6;
const intervalHours = effectiveDays <= 7 ? 1 : effectiveDays <= 14 ? 3 : 6;
// Use a reliable bucketing approach with Julian Day Numbers
// Convert to julian day, multiply by 24 for hours, divide by intervalHours and round
@@ -858,7 +1187,7 @@ app.get('/api/history/overview', (req, res) => {
WHERE resource_id = ? AND checked_at > datetime('now', ?)
GROUP BY ${bucketExpr}
ORDER BY checked_at ASC
`).all(resource.id, `-${days} days`).map(row => ({
`).all(resource.id, `-${effectiveDays} days`).map(row => ({
status: row.up_count >= Math.ceil(row.total_count/2) ? 'up' : 'down',
response_time: Math.round(row.avg_up_response || 0),
checked_at: row.checked_at,
@@ -871,7 +1200,7 @@ app.get('/api/history/overview', (req, res) => {
WHERE resource_id = ? AND checked_at > datetime('now', ?)
ORDER BY checked_at DESC
LIMIT 600
`).all(resource.id, `-${days} days`).reverse();
`).all(resource.id, `-${effectiveDays} days`).reverse();
}
return {
@@ -884,7 +1213,15 @@ app.get('/api/history/overview', (req, res) => {
};
});
const result = { resources: overview, total, page: currentPage, limit: pageLimit };
const result = {
resources: overview,
total,
page: currentPage,
limit: pageLimit,
effective_days: effectiveDays,
requested_days: requestedDays,
limited_by_retention: limitedByRetention
};
// Cache result for 2 minutes (120 seconds)
cache.set(cacheKey, result, 120);
@@ -1033,7 +1370,6 @@ app.post('/api/test-email', async (req, res) => {
res.json({ message: 'Test email sent successfully! Check your inbox.' });
} catch (error) {
console.error('Test email error:', error);
res.status(500).json({ error: error.message });
}
});
@@ -1054,7 +1390,6 @@ app.post('/api/test-webhook', async (req, res) => {
res.json({ message: 'Test webhook sent successfully!' });
} catch (error) {
console.error('Test webhook error:', error);
res.status(500).json({ error: error.message });
}
});
@@ -1123,7 +1458,6 @@ app.get('/api/resources/:id/maintenance-windows', (req, res) => {
res.json({ windows });
} catch (error) {
console.error('Error fetching maintenance windows:', error);
res.status(500).json({ error: 'Failed to fetch maintenance windows' });
}
});
@@ -1150,7 +1484,6 @@ app.post('/api/resources/:id/maintenance-windows', (req, res) => {
message: 'Maintenance window created'
});
} catch (error) {
console.error('Error creating maintenance window:', error);
res.status(500).json({ error: 'Failed to create maintenance window' });
}
});
@@ -1163,7 +1496,6 @@ app.delete('/api/maintenance-windows/:id', (req, res) => {
db.prepare(`DELETE FROM maintenance_windows WHERE id = ?`).run(id);
res.json({ message: 'Maintenance window deleted' });
} catch (error) {
console.error('Error deleting maintenance window:', error);
res.status(500).json({ error: 'Failed to delete maintenance window' });
}
});
@@ -1185,7 +1517,6 @@ app.get('/api/maintenance-windows', (req, res) => {
res.json({ maintenanceWindows: windows });
} catch (error) {
console.error('Error getting maintenance windows:', error);
res.status(500).json({ error: 'Failed to get maintenance windows' });
}
});
@@ -1207,7 +1538,6 @@ app.get('/api/resources/:id/in-maintenance', (req, res) => {
reason: window?.reason || null
});
} catch (error) {
console.error('Error checking maintenance status:', error);
res.status(500).json({ error: 'Failed to check maintenance status' });
}
});
@@ -1235,7 +1565,6 @@ app.post('/api/resources/:id/calculate-baseline', (req, res) => {
message: `Baseline calculated: ${baseline}ms`
});
} catch (error) {
console.error('Error calculating baseline:', error);
res.status(500).json({ error: 'Failed to calculate baseline' });
}
});
@@ -1258,7 +1587,6 @@ app.post('/api/resources/:id/alert-rules', (req, res) => {
res.json({ message: 'Alert rules updated' });
} catch (error) {
console.error('Error updating alert rules:', error);
res.status(500).json({ error: 'Failed to update alert rules' });
}
});
@@ -1276,7 +1604,6 @@ app.get('/api/resources/:id/transaction-steps', (req, res) => {
res.json({ steps });
} catch (error) {
console.error('Error fetching transaction steps:', error);
res.status(500).json({ error: 'Failed to fetch transaction steps' });
}
});
@@ -1309,7 +1636,6 @@ app.post('/api/resources/:id/transaction-steps', (req, res) => {
res.json({ id: result.lastInsertRowid, message: 'Transaction step created' });
} catch (error) {
console.error('Error creating transaction step:', error);
res.status(500).json({ error: 'Failed to create transaction step' });
}
});
@@ -1322,7 +1648,6 @@ app.delete('/api/transaction-steps/:id', (req, res) => {
db.prepare('DELETE FROM transaction_checks WHERE id = ?').run(id);
res.json({ message: 'Transaction step deleted' });
} catch (error) {
console.error('Error deleting transaction step:', error);
res.status(500).json({ error: 'Failed to delete transaction step' });
}
});
@@ -1339,7 +1664,6 @@ app.post('/api/resources/:id/toggle-transaction', (req, res) => {
res.json({ message: `Transaction mode ${enabled ? 'enabled' : 'disabled'}` });
} catch (error) {
console.error('Error toggling transaction mode:', error);
res.status(500).json({ error: 'Failed to toggle transaction mode' });
}
});
@@ -1378,11 +1702,102 @@ app.get('/api/resources/export', (req, res) => {
res.setHeader('Content-Disposition', 'attachment; filename="resources-export.csv"');
res.send(csv);
} catch (error) {
console.error('Error exporting resources:', error);
res.status(500).json({ error: 'Failed to export resources' });
}
});
// Export SLA report as CSV
app.get('/api/sla/export', (req, res) => {
const { days = 30 } = req.query;
try {
const resources = db.prepare(`
SELECT r.*,
COUNT(c.id) as total_checks,
SUM(CASE WHEN c.status = 'up' THEN 1 ELSE 0 END) as up_checks,
AVG(c.response_time) as avg_response_time,
COUNT(DISTINCT CASE WHEN i.id IS NOT NULL THEN i.id END) as incident_count,
COALESCE(SUM(CAST((julianday(COALESCE(i.resolved_at, datetime('now'))) - julianday(i.started_at)) * 24 * 60 AS INTEGER)), 0) as downtime_minutes
FROM resources r
LEFT JOIN checks c ON r.id = c.resource_id AND c.checked_at > datetime('now', '-' || ? || ' days')
LEFT JOIN incidents i ON r.id = i.resource_id AND i.started_at > datetime('now', '-' || ? || ' days')
GROUP BY r.id
ORDER BY r.name
`).all(days, days);
// CSV header
const headers = ['Resource Name', 'URL', 'Total Checks', 'Successful', 'Failed', 'Uptime %', 'Avg Response Time (ms)', 'Incidents', 'Downtime (minutes)'];
const csvRows = [headers.join(',')];
resources.forEach(resource => {
const uptime = resource.total_checks > 0 ? ((resource.up_checks / resource.total_checks) * 100).toFixed(2) : 0;
const failed = (resource.total_checks || 0) - (resource.up_checks || 0);
const row = [
`"${resource.name.replace(/"/g, '""')}"`,
`"${resource.url.replace(/"/g, '""')}"`,
resource.total_checks || 0,
resource.up_checks || 0,
failed,
uptime,
(resource.avg_response_time || 0).toFixed(0),
resource.incident_count || 0,
resource.downtime_minutes || 0
];
csvRows.push(row.join(','));
});
const csv = csvRows.join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="sla-report-${days}d.csv"`);
res.send(csv);
} catch (error) {
res.status(500).json({ error: 'Failed to export SLA report' });
}
});
// Export incident history as CSV
app.get('/api/incidents/export', (req, res) => {
const { days = 30 } = req.query;
try {
const incidents = db.prepare(`
SELECT i.*, r.name as resource_name, r.url
FROM incidents i
JOIN resources r ON i.resource_id = r.id
WHERE i.started_at > datetime('now', '-' || ? || ' days')
ORDER BY i.started_at DESC
`).all(days);
// CSV header
const headers = ['Resource', 'URL', 'Started At', 'Resolved At', 'Duration (minutes)', 'Incident Type', 'Error Message', 'Status'];
const csvRows = [headers.join(',')];
incidents.forEach(incident => {
const startTime = new Date(incident.started_at).getTime();
const endTime = incident.resolved_at ? new Date(incident.resolved_at).getTime() : Date.now();
const durationMinutes = Math.round((endTime - startTime) / 1000 / 60);
const status = incident.resolved_at ? 'Resolved' : 'Active';
const row = [
`"${incident.resource_name.replace(/"/g, '""')}"`,
`"${incident.url.replace(/"/g, '""')}"`,
new Date(incident.started_at).toISOString(),
incident.resolved_at ? new Date(incident.resolved_at).toISOString() : '',
durationMinutes,
incident.type || 'outage',
`"${(incident.error_message || '').replace(/"/g, '""')}"`,
status
];
csvRows.push(row.join(','));
});
const csv = csvRows.join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="incidents-export-${days}d.csv"`);
res.send(csv);
} catch (error) {
res.status(500).json({ error: 'Failed to export incidents' });
}
});
// Import resources from CSV
app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res) => {
try {
@@ -1432,6 +1847,40 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
continue;
}
// Validate URL format
try {
new URL(row.url);
} catch (e) {
errors.push(`Row ${i + 1}: Invalid URL format`);
continue;
}
// Check for duplicates
const existing = db.prepare('SELECT id FROM resources WHERE url = ?').get(row.url);
if (existing) {
errors.push(`Row ${i + 1}: URL already exists`);
continue;
}
// Validate numeric fields
const checkInterval = parseInt(row.check_interval) || 60000;
if (checkInterval < 10000) {
errors.push(`Row ${i + 1}: Check interval must be at least 10000ms`);
continue;
}
const timeout = parseInt(row.timeout) || 5000;
if (timeout < 1000) {
errors.push(`Row ${i + 1}: Timeout must be at least 1000ms`);
continue;
}
const slaTarget = parseFloat(row.sla_target) || 99.9;
if (isNaN(slaTarget) || slaTarget < 0 || slaTarget > 100) {
errors.push(`Row ${i + 1}: SLA target must be between 0 and 100`);
continue;
}
const stmt = db.prepare(`
INSERT INTO resources (name, url, type, check_interval, timeout, sla_target, tags, group_id, email_to)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -1441,9 +1890,9 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
row.name,
row.url,
row.type || 'http',
parseInt(row.check_interval) || 60000,
parseInt(row.timeout) || 5000,
parseFloat(row.sla_target) || 99.9,
checkInterval,
timeout,
slaTarget,
row.tags || '',
row.group_id ? parseInt(row.group_id) : null,
row.email_to || ''
@@ -1460,7 +1909,6 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
res.json({ imported, errors, count: imported.length });
} catch (error) {
console.error('Error importing resources:', error);
res.status(500).json({ error: 'Failed to import resources' });
}
});
@@ -1547,19 +1995,165 @@ function broadcastDashboardUpdate() {
}
});
} catch (error) {
console.error('Error broadcasting dashboard update:', error.message);
// Broadcast error handled silently
}
}
// Export broadcast function for use in scheduler
// Broadcast alert notification to WebSocket clients
function broadcastAlert(alert) {
try {
const message = JSON.stringify({
type: 'alert',
data: alert
});
wsClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
} else {
wsClients.delete(client);
}
});
} catch (error) {
// Broadcast error handled silently
}
}
// Broadcast incident update to WebSocket clients
function broadcastIncident(incident) {
try {
const message = JSON.stringify({
type: 'incident',
data: incident
});
wsClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
} else {
wsClients.delete(client);
}
});
} catch (error) {
// Broadcast error handled silently
}
}
// Broadcast metrics update to WebSocket clients
function broadcastMetrics() {
try {
const metricsData = metrics.getMetrics();
const message = JSON.stringify({
type: 'metrics',
data: metricsData
});
wsClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
} else {
wsClients.delete(client);
}
});
} catch (error) {
// Broadcast error handled silently
}
}
// Export broadcast functions for use in scheduler and other modules
global.broadcastDashboardUpdate = broadcastDashboardUpdate;
global.broadcastAlert = broadcastAlert;
global.broadcastIncident = broadcastIncident;
global.broadcastMetrics = broadcastMetrics;
// Broadcast new notification to all connected WebSocket clients
function broadcastNotification(notification) {
const message = JSON.stringify({
type: 'notification',
data: notification
});
wsClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
// Export broadcast notification function
global.broadcastNotification = broadcastNotification;
// Health check endpoint
app.get('/api/health', (req, res) => {
try {
const uptime = process.uptime();
const dbCheck = db.prepare('SELECT COUNT(*) as count FROM resources').get();
const schedulerStatus = scheduler.isRunning ? 'running' : 'stopped';
res.json({
status: 'ok',
uptime: Math.floor(uptime),
uptimeFormatted: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
timestamp: new Date().toISOString(),
database: {
connected: !!dbCheck,
resourcesCount: dbCheck?.count || 0
},
scheduler: {
status: schedulerStatus,
isRunning: scheduler.isRunning
}
});
} catch (error) {
res.status(503).json({
status: 'error',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// Graceful shutdown handlers
let isShuttingDown = false;
async function gracefulShutdown(signal) {
if (isShuttingDown) return;
isShuttingDown = true;
try {
// Close WebSocket connections
wsClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.close(1000, 'Server shutting down');
}
});
wsClients.clear();
// Stop scheduler
if (scheduler.isRunning) {
scheduler.stop();
}
// Close database
try {
db.close();
} catch (e) {
// Database already closed
}
process.exit(0);
} catch (error) {
process.exit(1);
}
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Start server with HTTP and WebSocket
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
wss.on('connection', (ws) => {
console.log('WebSocket client connected');
wsClients.add(ws);
// Send initial dashboard data
@@ -1572,23 +2166,19 @@ wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'pong' }));
}
} catch (error) {
console.error('Error handling WebSocket message:', error.message);
// Handle JSON parse errors silently
}
});
ws.on('close', () => {
console.log('WebSocket client disconnected');
wsClients.delete(ws);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error.message);
wsClients.delete(ws);
});
});
server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`WebSocket available at ws://localhost:${PORT}`);
scheduler.start();
});
+128
View File
@@ -0,0 +1,128 @@
const db = require('./database');
class MetricsCollector {
constructor() {
this.apiMetrics = new Map();
this.errorCounts = new Map();
this.startTime = Date.now();
}
// Track API request metrics
trackApiRequest(endpoint, method, statusCode, duration) {
const key = `${method}:${endpoint}`;
if (!this.apiMetrics.has(key)) {
this.apiMetrics.set(key, {
endpoint,
method,
totalRequests: 0,
successCount: 0,
errorCount: 0,
totalDuration: 0,
minDuration: Infinity,
maxDuration: 0,
statusCodes: {}
});
}
const metrics = this.apiMetrics.get(key);
metrics.totalRequests++;
metrics.totalDuration += duration;
metrics.minDuration = Math.min(metrics.minDuration, duration);
metrics.maxDuration = Math.max(metrics.maxDuration, duration);
if (statusCode >= 200 && statusCode < 400) {
metrics.successCount++;
} else {
metrics.errorCount++;
}
metrics.statusCodes[statusCode] = (metrics.statusCodes[statusCode] || 0) + 1;
}
// Track application errors
trackError(error, context = {}) {
const errorKey = error.message || 'Unknown Error';
const errorData = {
message: error.message,
stack: error.stack,
context,
timestamp: new Date().toISOString(),
count: (this.errorCounts.get(errorKey) || 0) + 1
};
this.errorCounts.set(errorKey, errorData.count);
// Log to database
try {
db.prepare(`
INSERT INTO error_logs (error_message, stack_trace, context, occurred_at)
VALUES (?, ?, ?, datetime('now'))
`).run(
error.message,
error.stack || '',
JSON.stringify(context)
);
} catch (err) {
// Silent failure - don't crash on logging errors
}
return errorData;
}
// Get current metrics snapshot
getMetrics() {
const apiStats = [];
this.apiMetrics.forEach((metrics, key) => {
const avgDuration = metrics.totalRequests > 0
? Math.round(metrics.totalDuration / metrics.totalRequests)
: 0;
apiStats.push({
endpoint: metrics.endpoint,
method: metrics.method,
totalRequests: metrics.totalRequests,
successRate: metrics.totalRequests > 0
? ((metrics.successCount / metrics.totalRequests) * 100).toFixed(2)
: 0,
avgResponseTime: avgDuration,
minResponseTime: metrics.minDuration === Infinity ? 0 : metrics.minDuration,
maxResponseTime: metrics.maxDuration,
statusCodes: metrics.statusCodes
});
});
return {
uptime: Math.floor((Date.now() - this.startTime) / 1000),
apiStats: apiStats.sort((a, b) => b.totalRequests - a.totalRequests),
errorCount: Array.from(this.errorCounts.values()).reduce((sum, count) => sum + count, 0),
uniqueErrors: this.errorCounts.size
};
}
// Get recent errors from database
getRecentErrors(limit = 50) {
try {
return db.prepare(`
SELECT
id,
error_message,
stack_trace,
context,
REPLACE(occurred_at, ' ', 'T') || 'Z' as occurred_at
FROM error_logs
ORDER BY occurred_at DESC
LIMIT ?
`).all(limit);
} catch (err) {
return [];
}
}
// Clear in-memory metrics (for testing/reset)
reset() {
this.apiMetrics.clear();
this.errorCounts.clear();
}
}
module.exports = new MetricsCollector();
+78 -40
View File
@@ -7,6 +7,8 @@ const dns = require('dns').promises;
const WebSocket = require('ws');
const ping = require('ping');
const db = require('./database');
const RetryPolicy = require('./retryPolicy');
const CircuitBreaker = require('./circuitBreaker');
const DEFAULT_PORTS = {
http: 80,
@@ -32,6 +34,33 @@ class MonitorService {
// Keep-alive agents for pooled HTTP/S connections
this.httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 });
this.httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 50 });
// Circuit breaker for HTTP checks (5 failures before opening, 60s reset)
this.httpCircuitBreaker = new CircuitBreaker(5, 60000, this.isTransientError);
// Retry policy: 3 retries with exponential backoff
this.retryPolicy = new RetryPolicy(3, 1000, 10000);
// Circuit breakers per resource for fine-grained control
this.resourceCircuitBreakers = new Map();
}
getResourceCircuitBreaker(resourceId) {
if (!this.resourceCircuitBreakers.has(resourceId)) {
this.resourceCircuitBreakers.set(resourceId, new CircuitBreaker(3, 120000, this.isTransientError));
}
return this.resourceCircuitBreakers.get(resourceId);
}
isTransientError(error) {
// Only retry on transient errors (network, timeout, 5xx)
if (error.code === 'ECONNREFUSED' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
return true;
}
if (error.response?.status >= 500) {
return true;
}
return false;
}
async checkResource(resource) {
const startTime = Date.now();
@@ -77,49 +106,59 @@ class MonitorService {
async checkHttp(resource, startTime, base) {
let result = { ...base };
const circuitBreaker = this.getResourceCircuitBreaker(resource.id);
try {
const headers = {};
if (resource.http_headers) {
try {
const customHeaders = JSON.parse(resource.http_headers);
Object.assign(headers, customHeaders);
} catch (e) {
// Invalid JSON, skip custom headers
}
}
await circuitBreaker.execute(async () => {
return await this.retryPolicy.execute(async () => {
const headers = {};
if (resource.http_headers) {
try {
const customHeaders = JSON.parse(resource.http_headers);
Object.assign(headers, customHeaders);
} catch (e) {
// Invalid JSON, skip custom headers
}
}
const isHttps = resource.url.startsWith('https');
const response = await axios.get(resource.url, {
timeout: resource.timeout,
validateStatus: () => true,
maxRedirects: 5,
headers,
httpAgent: this.httpAgent,
httpsAgent: this.httpsAgent,
const isHttps = resource.url.startsWith('https');
const response = await axios.get(resource.url, {
timeout: resource.timeout,
validateStatus: () => true,
maxRedirects: 5,
headers,
httpAgent: this.httpAgent,
httpsAgent: this.httpsAgent,
});
const responseTime = Date.now() - startTime;
const isUp = response.status >= 200 && response.status < 400;
// Check for keyword if specified
let keywordMatch = true;
if (resource.http_keyword && response.data) {
const dataStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
keywordMatch = dataStr.includes(resource.http_keyword);
if (!keywordMatch) {
result.error_message = `Keyword "${resource.http_keyword}" not found in response`;
}
}
result = {
...result,
status: (isUp && keywordMatch) ? 'up' : 'down',
response_time: responseTime,
status_code: response.status,
error_message: isUp && keywordMatch ? null : (result.error_message || `HTTP ${response.status}`),
};
return result;
}, `HTTP check for ${resource.name}`);
});
const responseTime = Date.now() - startTime;
const isUp = response.status >= 200 && response.status < 400;
// Check for keyword if specified
let keywordMatch = true;
if (resource.http_keyword && response.data) {
const dataStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
keywordMatch = dataStr.includes(resource.http_keyword);
if (!keywordMatch) {
result.error_message = `Keyword "${resource.http_keyword}" not found in response`;
}
}
result = {
...result,
status: (isUp && keywordMatch) ? 'up' : 'down',
response_time: responseTime,
status_code: response.status,
error_message: isUp && keywordMatch ? null : (result.error_message || `HTTP ${response.status}`),
};
} catch (error) {
result.error_message = error.message;
result.response_time = Date.now() - startTime;
const responseTime = Date.now() - startTime;
result.error_message = error.originalError?.message || error.message;
result.response_time = responseTime;
result.status = 'down';
}
return result;
}
@@ -706,7 +745,6 @@ class MonitorService {
return currentResponseTime > threshold;
} catch (error) {
console.error('Error checking response time anomaly:', error.message);
return false;
}
}
+45 -4
View File
@@ -44,6 +44,8 @@ class NotificationService {
user: config.email_user,
pass: config.email_pass,
},
connectionTimeout: 10000,
socketTimeout: 10000,
});
} else {
this.transporter = null;
@@ -99,6 +101,47 @@ class NotificationService {
}
await Promise.allSettled(promises);
// Save notification to in-app notification center
const status = isDown ? 'down' : 'up';
this.saveNotification(resource, incident, status, message);
}
saveNotification(resource, incident, status, message) {
try {
const db = require('./database');
const title = status === 'down' ?
`🔴 ${resource.name} is DOWN` :
`🟢 ${resource.name} is UP`;
const result = db.prepare(`
INSERT INTO notifications (resource_id, incident_id, type, title, message, read)
VALUES (?, ?, ?, ?, ?, 0)
`).run(
resource.id,
incident.id || null,
status,
title,
message
);
// Broadcast new notification to WebSocket clients in real-time
if (global.broadcastNotification) {
global.broadcastNotification({
id: result.lastInsertRowid,
resource_id: resource.id,
resource_name: resource.name,
incident_id: incident.id || null,
type: status,
title,
message,
read: 0,
created_at: new Date().toISOString()
});
}
} catch (error) {
console.error('Failed to save notification:', error.message);
}
}
isQuietHours(resource) {
@@ -125,7 +168,6 @@ class NotificationService {
async sendEmail(resource, message, type, stats = null, emailOverride = null) {
if (!this.transporter) {
console.error(`[Email] Config incomplete; skipping email (enabled=${this.emailEnabled}, host=${this.config.email_host})`);
return;
}
@@ -187,7 +229,7 @@ class NotificationService {
: [],
});
} catch (error) {
console.error('Email error:', error.message);
// Email error handled
}
}
@@ -234,7 +276,6 @@ class NotificationService {
return !!window;
} catch (error) {
console.error('Error checking maintenance window:', error.message);
return false;
}
}
@@ -249,7 +290,7 @@ class NotificationService {
timestamp: new Date().toISOString(),
});
} catch (error) {
console.error('Webhook error:', error.message);
// Webhook error handled silently
}
}
}
+44
View File
@@ -0,0 +1,44 @@
// Retry logic with exponential backoff
class RetryPolicy {
constructor(maxRetries = 3, initialDelayMs = 1000, maxDelayMs = 10000) {
this.maxRetries = maxRetries;
this.initialDelayMs = initialDelayMs;
this.maxDelayMs = maxDelayMs;
}
async execute(fn, context = 'operation') {
let lastError;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt < this.maxRetries) {
const delay = this.calculateBackoff(attempt);
await this.sleep(delay);
}
}
}
const error = new Error(`${context} failed after ${this.maxRetries + 1} attempts`);
error.originalError = lastError;
throw error;
}
calculateBackoff(attempt) {
// Exponential backoff: initialDelay * 2^attempt
// With jitter: ±10%
const exponentialDelay = this.initialDelayMs * Math.pow(2, attempt);
const cappedDelay = Math.min(exponentialDelay, this.maxDelayMs);
const jitter = cappedDelay * 0.1 * (Math.random() - 0.5);
return cappedDelay + jitter;
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
module.exports = RetryPolicy;
+44 -27
View File
@@ -6,12 +6,20 @@ const notificationService = require('./notificationService');
class Scheduler {
constructor() {
this.jobs = new Map();
this.isRunning = false;
}
start() {
// Run checks every minute
cron.schedule('* * * * *', async () => {
await this.runChecks();
if (!this.isRunning) {
this.isRunning = true;
try {
await this.runChecks();
} finally {
this.isRunning = false;
}
}
});
// Archive old checks daily at 2 AM
@@ -23,37 +31,44 @@ class Scheduler {
setTimeout(() => {
this.archiveOldChecks();
}, 5000);
console.log('Scheduler started');
}
archiveOldChecks() {
try {
// Get global retention setting
const retentionSetting = db.prepare(`
SELECT value FROM settings WHERE key = 'retention_days'
`).get();
const retentionDays = retentionSetting ? parseInt(retentionSetting.value) : 30;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
const globalRetentionDays = retentionSetting ? parseInt(retentionSetting.value) : 30;
// Move old checks to archived_checks
const oldChecks = db.prepare(`
SELECT * FROM checks WHERE checked_at < ?
`).all(cutoffString);
// Get all resources with their retention settings
const resources = db.prepare(`
SELECT id, retention_days FROM resources
`).all();
if (oldChecks.length > 0) {
const insertArchived = db.prepare(`
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertArchived = db.prepare(`
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const deleteOld = db.prepare(`
DELETE FROM checks WHERE id = ?
`);
const deleteOld = db.prepare(`
DELETE FROM checks WHERE id = ?
`);
const transaction = db.transaction(() => {
resources.forEach(resource => {
// Use per-resource retention if set, otherwise use global
const retentionDays = resource.retention_days || globalRetentionDays;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
// Get old checks for this resource
const oldChecks = db.prepare(`
SELECT * FROM checks WHERE resource_id = ? AND checked_at < ?
`).all(resource.id, cutoffString);
const transaction = db.transaction(() => {
oldChecks.forEach(check => {
insertArchived.run(
check.resource_id,
@@ -67,12 +82,11 @@ class Scheduler {
deleteOld.run(check.id);
});
});
});
transaction();
console.log(`[Archival] Archived ${oldChecks.length} checks older than ${retentionDays} days`);
}
transaction();
} catch (error) {
console.error('[Archival] Error archiving checks:', error.message);
// Archive error handled silently
}
}
@@ -133,10 +147,8 @@ class Scheduler {
await notificationService.sendAlert(resource, incident, stats);
}
}
console.log(`Checked ${resource.name}: ${result.status} (${result.response_time}ms)`);
} catch (error) {
console.error(`Error checking ${resource.name}:`, error.message);
// Check error handled silently
}
})());
@@ -147,6 +159,11 @@ class Scheduler {
global.broadcastDashboardUpdate();
}
}
stop() {
// Stop the scheduler (cron tasks will continue running but we flag isRunning)
this.isRunning = false;
}
}
module.exports = new Scheduler();
+118
View File
@@ -0,0 +1,118 @@
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(32).toString('hex');
const JWT_EXPIRY = 3600; // 1 hour
const REFRESH_TOKEN_EXPIRY = 86400 * 7; // 7 days
class SecurityMiddleware {
// Generate JWT token
generateToken(payload, expiresIn = JWT_EXPIRY) {
return jwt.sign(payload, JWT_SECRET, { expiresIn });
}
// Generate refresh token
generateRefreshToken(payload) {
return jwt.sign(payload, JWT_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY });
}
// Verify JWT token
verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (err) {
return null;
}
}
// Verify refresh token
verifyRefreshToken(token) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Additional validation: check if it's a refresh token (has longer expiry)
if (decoded.exp - decoded.iat < REFRESH_TOKEN_EXPIRY - 100) {
return null; // Not a refresh token
}
return decoded;
} catch (err) {
return null;
}
}
// Verify token expiry
isTokenExpired(token) {
const decoded = this.verifyToken(token);
if (!decoded) return true;
return false;
}
// Generate CSRF token (alias for compatibility)
generateCSRFToken() {
return this.generateCsrfToken();
}
// Generate CSRF token
generateCsrfToken() {
return crypto.randomBytes(32).toString('hex');
}
// Verify CSRF token
verifyCsrfToken(token, sessionToken) {
if (!token || !sessionToken) return false;
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(sessionToken));
}
// Hash password with salt
hashPassword(password) {
return crypto.createHash('sha256').update(password).digest('hex');
}
// Validate password strength
validatePasswordStrength(password) {
if (password.length < 8) {
return { valid: false, error: 'Password must be at least 8 characters' };
}
if (!/[A-Z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one uppercase letter' };
}
if (!/[a-z]/.test(password)) {
return { valid: false, error: 'Password must contain at least one lowercase letter' };
}
if (!/[0-9]/.test(password)) {
return { valid: false, error: 'Password must contain at least one digit' };
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
return { valid: false, error: 'Password must contain at least one special character' };
}
return { valid: true };
}
// Middleware to verify CSRF
csrfMiddleware(req, res, next) {
const token = req.headers['x-csrf-token'] || req.body.csrfToken;
const sessionToken = req.session?.csrfToken;
if (!this.verifyCsrfToken(token, sessionToken)) {
return res.status(403).json({ error: 'CSRF token validation failed' });
}
next();
}
// Middleware to verify JWT
jwtMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No authorization token provided' });
}
const decoded = this.verifyToken(token);
if (!decoded) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
req.user = decoded;
next();
}
}
module.exports = new SecurityMiddleware();