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
+3473 -159

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 - 🔔 **Alert Notifications** - Email and webhook alerts when resources go offline
- 📝 **Incident Tracking** - Track when resources go down and recover - 📝 **Incident Tracking** - Track when resources go down and recover
- ⚙️ **Configurable Checks** - Set custom check intervals and timeouts - ⚙️ **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 ## 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. 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 ## API Endpoints
- `GET /api/resources` - List all resources - `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": { "files": {
"main.css": "/static/css/main.b6a66622.css", "main.css": "/static/css/main.4f505a72.css",
"main.js": "/static/js/main.0a56784a.js", "main.js": "/static/js/main.29f97b36.js",
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js", "static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
"index.html": "/index.html", "index.html": "/index.html",
"main.b6a66622.css.map": "/static/css/main.b6a66622.css.map", "main.4f505a72.css.map": "/static/css/main.4f505a72.css.map",
"main.0a56784a.js.map": "/static/js/main.0a56784a.js.map", "main.29f97b36.js.map": "/static/js/main.29f97b36.js.map",
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map" "453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
}, },
"entrypoints": [ "entrypoints": [
"static/css/main.b6a66622.css", "static/css/main.4f505a72.css",
"static/js/main.0a56784a.js" "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 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*/ /*# 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":"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 truncated
@@ -1 +0,0 @@
{"version":3,"file":"static/js/main.0a56784a.js","mappings":"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 truncated
@@ -1,3 +1,3 @@
/*! For license information please see main.0a56784a.js.LICENSE.txt */ /*! 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 (()=>{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 //# 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", "name": "client",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"axios": "^1.13.2", "axios": "^1.13.2",
@@ -3396,6 +3395,7 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.10.4", "@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.5",
@@ -3415,6 +3415,7 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
@@ -3493,7 +3494,8 @@
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -6758,6 +6760,7 @@
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -6880,7 +6883,8 @@
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/dom-converter": { "node_modules/dom-converter": {
"version": "0.2.0", "version": "0.2.0",
@@ -11407,6 +11411,7 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
-1
View File
@@ -3,7 +3,6 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"axios": "^1.13.2", "axios": "^1.13.2",
+98 -23
View File
@@ -7,6 +7,9 @@ import SettingsWizard from './SettingsWizard';
import History from './History'; import History from './History';
import SLA from './SLA'; import SLA from './SLA';
import Status from './Status'; import Status from './Status';
import Observability from './Observability';
import Notifications from './Notifications';
import NotificationCenter from './NotificationCenter';
import { formatLocalTime, formatChartTime } from './utils/timeUtils'; import { formatLocalTime, formatChartTime } from './utils/timeUtils';
// Simple duration formatter for incident spans // Simple duration formatter for incident spans
@@ -29,6 +32,7 @@ function Dashboard() {
const [collapsedGroups, setCollapsedGroups] = useState({}); const [collapsedGroups, setCollapsedGroups] = useState({});
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState(null);
const [editData, setEditData] = useState({}); const [editData, setEditData] = useState({});
const [notifications, setNotifications] = useState([]);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
url: '', url: '',
@@ -54,13 +58,24 @@ function Dashboard() {
const actionsRef = useRef(null); const actionsRef = useRef(null);
const navigate = useNavigate(); 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 () => { const loadResources = useCallback(async () => {
try { try {
const response = await axios.get('/api/dashboard'); const response = await axios.get('/api/dashboard');
setResources(response.data.resources); setResources(response.data.resources);
setGroups(response.data.groups || []); setGroups(response.data.groups || []);
} catch (error) { } catch (error) {
console.error('Error loading resources:', error); // Error loading resources handled
} }
}, []); }, []);
@@ -78,17 +93,34 @@ function Dashboard() {
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (message.type === 'dashboard') { if (message.type === 'dashboard') {
setResources(message.data.resources || []); setResources(message.data.resources || []);
setGroups(message.data.groups || []); 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) { } catch (error) {
console.error('Error parsing WebSocket message:', error); // Parse error handled silently
} }
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket error:', error); // WebSocket error handled
}; };
ws.onclose = () => { ws.onclose = () => {
@@ -97,7 +129,6 @@ function Dashboard() {
wsRef.current = ws; wsRef.current = ws;
} catch (error) { } catch (error) {
console.error('Error connecting WebSocket:', error);
loadResources(); loadResources();
} }
}, [loadResources]); }, [loadResources]);
@@ -137,7 +168,6 @@ function Dashboard() {
setGroupData({ name: '', description: '' }); setGroupData({ name: '', description: '' });
loadResources(); loadResources();
} catch (error) { } catch (error) {
console.error('Error creating group:', error);
alert('Error creating group'); alert('Error creating group');
} }
}; };
@@ -168,7 +198,6 @@ function Dashboard() {
}); });
loadResources(); loadResources();
} catch (error) { } catch (error) {
console.error('Error creating resource:', error);
alert('Error creating resource'); alert('Error creating resource');
} }
}; };
@@ -190,7 +219,6 @@ function Dashboard() {
setEditData({}); setEditData({});
loadResources(); loadResources();
} catch (error) { } catch (error) {
console.error('Error updating resource:', error);
alert('Error updating resource'); alert('Error updating resource');
} }
}; };
@@ -206,7 +234,6 @@ function Dashboard() {
await axios.delete(`/api/resources/${id}`); await axios.delete(`/api/resources/${id}`);
loadResources(); loadResources();
} catch (error) { } catch (error) {
console.error('Error deleting resource:', error);
alert('Error deleting resource'); alert('Error deleting resource');
} }
} }
@@ -225,7 +252,6 @@ function Dashboard() {
link.click(); link.click();
link.parentChild.removeChild(link); link.parentChild.removeChild(link);
} catch (error) { } catch (error) {
console.error('Error exporting resources:', error);
alert('Error exporting resources'); alert('Error exporting resources');
} }
}; };
@@ -246,7 +272,6 @@ function Dashboard() {
} }
loadResources(); loadResources();
} catch (error) { } catch (error) {
console.error('Error importing resources:', error);
alert('Error importing resources'); alert('Error importing resources');
} }
@@ -375,9 +400,33 @@ function Dashboard() {
return ( return (
<div className="container"> <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' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2>SkyWatch Dashboard</h2> <h2>SkyWatch Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<NotificationCenter />
<input <input
type="text" type="text"
placeholder="Filter by tag" placeholder="Filter by tag"
@@ -748,6 +797,19 @@ function Dashboard() {
</div> </div>
</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"> <div className="form-actions">
<button type="button" className="btn" onClick={() => setShowModal(false)}> <button type="button" className="btn" onClick={() => setShowModal(false)}>
Cancel Cancel
@@ -957,6 +1019,19 @@ function Dashboard() {
</div> </div>
</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"> <div className="form-actions">
<button type="button" className="btn" onClick={() => setShowEditModal(false)}> <button type="button" className="btn" onClick={() => setShowEditModal(false)}>
Cancel Cancel
@@ -1036,7 +1111,6 @@ function ResourceDetail() {
setResource(response.data); setResource(response.data);
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error('Error loading resource:', error);
setLoading(false); setLoading(false);
} }
}; };
@@ -1054,7 +1128,7 @@ function ResourceDetail() {
}); });
setChecks(response.data.checks || []); setChecks(response.data.checks || []);
} catch (error) { } catch (error) {
console.error('Error loading checks:', error); // Checks load error handled
} finally { } finally {
setChecksLoading(false); setChecksLoading(false);
} }
@@ -1073,7 +1147,7 @@ function ResourceDetail() {
}); });
setIncidents(response.data.incidents || []); setIncidents(response.data.incidents || []);
} catch (error) { } catch (error) {
console.error('Error loading incidents:', error); // Incidents load error handled
} finally { } finally {
setIncidentsLoading(false); setIncidentsLoading(false);
} }
@@ -1098,7 +1172,6 @@ function ResourceDetail() {
setIncidentDescription(''); setIncidentDescription('');
loadIncidents(); // Reload to show updated description loadIncidents(); // Reload to show updated description
} catch (error) { } catch (error) {
console.error('Error updating incident:', error);
alert('Failed to update incident: ' + (error.response?.data?.error || error.message)); alert('Failed to update incident: ' + (error.response?.data?.error || error.message));
} finally { } finally {
setUpdatingIncident(false); setUpdatingIncident(false);
@@ -1111,7 +1184,7 @@ function ResourceDetail() {
const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } }); const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } });
setSla(response.data); setSla(response.data);
} catch (error) { } catch (error) {
console.error('Error loading SLA summary:', error); // SLA load error handled
} finally { } finally {
setSlaLoading(false); setSlaLoading(false);
} }
@@ -1122,7 +1195,7 @@ function ResourceDetail() {
const response = await axios.get(`/api/resources/${id}/maintenance-windows`); const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
setMaintenanceWindows(response.data.windows || []); setMaintenanceWindows(response.data.windows || []);
} catch (error) { } catch (error) {
console.error('Error loading maintenance windows:', error); // Maintenance windows load error handled
} }
}; };
@@ -1135,7 +1208,6 @@ function ResourceDetail() {
loadMaintenanceWindows(); loadMaintenanceWindows();
alert('Maintenance window created'); alert('Maintenance window created');
} catch (error) { } catch (error) {
console.error('Error creating maintenance window:', error);
alert('Error creating maintenance window'); alert('Error creating maintenance window');
} }
}; };
@@ -1146,7 +1218,6 @@ function ResourceDetail() {
await axios.delete(`/api/maintenance-windows/${windowId}`); await axios.delete(`/api/maintenance-windows/${windowId}`);
loadMaintenanceWindows(); loadMaintenanceWindows();
} catch (error) { } catch (error) {
console.error('Error deleting maintenance window:', error);
alert('Error deleting maintenance window'); alert('Error deleting maintenance window');
} }
} }
@@ -1158,7 +1229,6 @@ function ResourceDetail() {
await axios.delete(`/api/resources/${id}`); await axios.delete(`/api/resources/${id}`);
navigate('/'); navigate('/');
} catch (error) { } catch (error) {
console.error('Error deleting resource:', error);
alert('Error deleting resource'); alert('Error deleting resource');
} }
} }
@@ -1172,7 +1242,7 @@ function ResourceDetail() {
}); });
loadResource(); loadResource();
} catch (error) { } catch (error) {
console.error('Error updating resource:', error); // Update resource error handled
} }
}; };
@@ -1184,7 +1254,7 @@ function ResourceDetail() {
}); });
loadResource(); loadResource();
} catch (error) { } catch (error) {
console.error('Error updating maintenance mode:', error); // Update maintenance mode error handled
} }
}; };
@@ -1591,12 +1661,13 @@ function Navbar() {
try { try {
const response = await axios.get('/api/settings'); const response = await axios.get('/api/settings');
setNotificationsConfigured(response.data.email_enabled || response.data.webhook_enabled); 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) { if (response.data.timezone) {
localStorage.setItem('serverTimezone', response.data.timezone); localStorage.setItem('serverTimezone', response.data.timezone);
localStorage.setItem('serverTimezoneTime', Date.now().toString());
} }
} catch (error) { } 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="/" 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="/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="/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' }}> <Link to="/settings" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
Settings Settings
{!notificationsConfigured && ( {!notificationsConfigured && (
@@ -1647,6 +1720,8 @@ function App() {
<Route path="/sla" element={<SLA />} /> <Route path="/sla" element={<SLA />} />
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} /> <Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
<Route path="/status" element={<Status />} /> <Route path="/status" element={<Status />} />
<Route path="/observability" element={<Observability />} />
<Route path="/notifications" element={<Notifications />} />
</Routes> </Routes>
</div> </div>
</Router> </Router>
+1 -3
View File
@@ -11,9 +11,7 @@ class ErrorBoundary extends React.Component {
} }
componentDidCatch(error, info) { componentDidCatch(error, info) {
// Log to console; could later POST to /api if needed // Error handled silently
// eslint-disable-next-line no-console
console.error('App error:', error, info);
} }
render() { render() {
+38 -2
View File
@@ -22,6 +22,7 @@ const asNumberOrZero = (value) => {
function History() { function History() {
const [historyData, setHistoryData] = useState([]); const [historyData, setHistoryData] = useState([]);
const [days, setDays] = useState(7); const [days, setDays] = useState(7);
const [maxDays, setMaxDays] = useState(30);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [showAveraged, setShowAveraged] = useState(true); const [showAveraged, setShowAveraged] = useState(true);
@@ -29,7 +30,28 @@ function History() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [totalItems, setTotalItems] = useState(0); 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(() => { useEffect(() => {
const loadHistory = async () => { const loadHistory = async () => {
setLoading(true); setLoading(true);
@@ -47,6 +69,8 @@ function History() {
if (response.data.resources && Array.isArray(response.data.resources)) { if (response.data.resources && Array.isArray(response.data.resources)) {
data = response.data.resources; data = response.data.resources;
total = response.data.total || data.length; 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)) { } else if (Array.isArray(response.data)) {
data = response.data; data = response.data;
total = data.length; total = data.length;
@@ -61,7 +85,6 @@ function History() {
}); });
return { ...resource, trendsData: trendsResponse.data }; return { ...resource, trendsData: trendsResponse.data };
} catch (err) { } catch (err) {
console.error(`Error loading trends for resource ${resource.id}:`, err);
return resource; return resource;
} }
}); });
@@ -72,7 +95,6 @@ function History() {
setTotalItems(total); setTotalItems(total);
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error('Error loading history:', error);
setError(error.message); setError(error.message);
setLoading(false); setLoading(false);
} }
@@ -86,6 +108,18 @@ function History() {
return ( return (
<div className="container"> <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' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2>Monitoring History</h2> <h2>Monitoring History</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -95,6 +129,8 @@ function History() {
key={d} key={d}
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setDays(d)} onClick={() => setDays(d)}
disabled={d > maxDays}
style={{ opacity: d > maxDays ? 0.5 : 1 }}
> >
{d} Days {d} Days
</button> </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() { function SLA() {
const [slaData, setSlaData] = useState([]); const [slaData, setSlaData] = useState([]);
const [days, setDays] = useState(30); const [days, setDays] = useState(30);
const [maxDays, setMaxDays] = useState(30);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -13,6 +14,25 @@ function SLA() {
const [effectiveDays, setEffectiveDays] = useState(30); const [effectiveDays, setEffectiveDays] = useState(30);
const [limitedByRetention, setLimitedByRetention] = useState(false); 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 () => { const loadSLAData = React.useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@@ -25,7 +45,6 @@ function SLA() {
setLimitedByRetention(Boolean(response.data.limited_by_retention)); setLimitedByRetention(Boolean(response.data.limited_by_retention));
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error('Error loading SLA data:', error);
setError(error.message); setError(error.message);
setLoading(false); setLoading(false);
} }
@@ -43,11 +62,23 @@ function SLA() {
meetingTarget: slaData.filter(s => s.meets_target).length, meetingTarget: slaData.filter(s => s.meets_target).length,
avgUptime: (slaData.reduce((sum, s) => sum + parseFloat(s.actual_uptime), 0) / slaData.length).toFixed(2), avgUptime: (slaData.reduce((sum, s) => sum + parseFloat(s.actual_uptime), 0) / slaData.length).toFixed(2),
totalIncidents: slaData.reduce((sum, s) => sum + s.incidents, 0), 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; } : null;
return ( return (
<div className="container"> <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' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2>SLA Dashboard</h2> <h2>SLA Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem' }}>
@@ -56,6 +87,8 @@ function SLA() {
key={d} key={d}
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setDays(d)} onClick={() => setDays(d)}
disabled={d > maxDays}
style={{ opacity: d > maxDays ? 0.5 : 1 }}
> >
{d} Days {d} Days
</button> </button>
+218 -9
View File
@@ -30,7 +30,6 @@ function SettingsWizard() {
default_sort: 'name', default_sort: 'name',
items_per_page: 20, items_per_page: 20,
refresh_interval: 5000, refresh_interval: 5000,
theme: 'light',
incident_failure_threshold: 10, incident_failure_threshold: 10,
}); });
@@ -48,7 +47,7 @@ function SettingsWizard() {
const response = await axios.get('/api/settings'); const response = await axios.get('/api/settings');
setSettings(response.data); setSettings(response.data);
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); // Settings load error handled
} }
}; };
@@ -62,7 +61,6 @@ function SettingsWizard() {
// Clear message after 3 seconds // Clear message after 3 seconds
setTimeout(() => setSavedMessage(null), 3000); setTimeout(() => setSavedMessage(null), 3000);
} catch (error) { } catch (error) {
console.error('Error saving settings:', error);
setSavedMessage({ section: sectionName, success: false, error: error.response?.data?.error || 'Failed to save' }); setSavedMessage({ section: sectionName, success: false, error: error.response?.data?.error || 'Failed to save' });
setTimeout(() => setSavedMessage(null), 5000); setTimeout(() => setSavedMessage(null), 5000);
} finally { } finally {
@@ -115,6 +113,7 @@ function SettingsWizard() {
{ name: 'Gmail', host: 'smtp.gmail.com', port: 587 }, { name: 'Gmail', host: 'smtp.gmail.com', port: 587 },
{ name: 'Outlook', host: 'smtp-mail.outlook.com', port: 587 }, { name: 'Outlook', host: 'smtp-mail.outlook.com', port: 587 },
{ name: 'Yahoo', host: 'smtp.mail.yahoo.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 }, { name: 'Custom', host: '', port: 587 },
]; ];
@@ -363,6 +362,20 @@ function SettingsWizard() {
</div> </div>
</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-row">
<div className="form-group"> <div className="form-group">
<label>Timezone</label> <label>Timezone</label>
@@ -391,6 +404,18 @@ function SettingsWizard() {
</div> </div>
<div className="form-row"> <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"> <div className="form-group">
<label>Incident Failure Threshold</label> <label>Incident Failure Threshold</label>
<input <input
@@ -400,7 +425,33 @@ function SettingsWizard() {
min="1" min="1"
max="100" 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>
</div> </div>
@@ -411,25 +462,183 @@ function SettingsWizard() {
timeout: settings.timeout, timeout: settings.timeout,
timezone: settings.timezone, timezone: settings.timezone,
retention_days: settings.retention_days, retention_days: settings.retention_days,
auto_cleanup_enabled: settings.auto_cleanup_enabled,
consecutive_failures: settings.consecutive_failures, consecutive_failures: settings.consecutive_failures,
grace_period: settings.grace_period, grace_period: settings.grace_period,
downtime_threshold: settings.downtime_threshold, 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_count: settings.alert_retry_count,
alert_retry_delay: settings.alert_retry_delay, alert_retry_delay: settings.alert_retry_delay,
fallback_webhook: settings.fallback_webhook, fallback_webhook: settings.fallback_webhook,
global_quiet_hours_start: settings.global_quiet_hours_start, global_quiet_hours_start: settings.global_quiet_hours_start,
global_quiet_hours_end: settings.global_quiet_hours_end, global_quiet_hours_end: settings.global_quiet_hours_end,
escalation_hours: settings.escalation_hours, 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, default_sort: settings.default_sort,
items_per_page: settings.items_per_page, items_per_page: settings.items_per_page,
refresh_interval: settings.refresh_interval, refresh_interval: settings.refresh_interval,
theme: settings.theme, auto_cleanup_enabled: settings.auto_cleanup_enabled,
incident_failure_threshold: settings.incident_failure_threshold,
})} })}
disabled={savingSection === 'Monitoring'} disabled={savingSection === 'Dashboard'}
> >
{savingSection === 'Monitoring' ? 'Saving...' : '💾 Save Monitoring Settings'} {savingSection === 'Dashboard' ? 'Saving...' : '💾 Save Dashboard Settings'}
</button> </button>
</div> </div>
</div> </div>
+2 -2
View File
@@ -12,7 +12,7 @@ function Status() {
useEffect(() => { useEffect(() => {
loadStatusData(); 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); return () => clearInterval(interval);
}, []); }, []);
@@ -28,7 +28,7 @@ function Status() {
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []); setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
setLastUpdated(new Date()); setLastUpdated(new Date());
} catch (error) { } catch (error) {
console.error('Error loading status data:', error); // Status load error handled
} finally { } finally {
setLoading(false); setLoading(false);
} }
+24 -2
View File
@@ -28,12 +28,19 @@ export const formatLocalTime = (timestamp, options = {}) => {
let timeZone; let timeZone;
try { try {
const cachedSettings = localStorage.getItem('serverTimezone'); 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 // Remove any quotes that might be in the string
timeZone = cachedSettings.replace(/^["']|["']$/g, '').trim(); timeZone = cachedSettings.replace(/^["']|["']$/g, '').trim();
} else if (cachedSettings) {
// Clear stale cache
localStorage.removeItem('serverTimezone');
localStorage.removeItem('serverTimezoneTime');
} }
} catch (e) { } catch (e) {
console.error('Error reading serverTimezone from localStorage:', e); // Timezone load error handled silently
} }
const defaultOptions = { const defaultOptions = {
@@ -72,15 +79,30 @@ export const formatLocalTimeOnly = (timestamp, options = {}) => {
/** /**
* Format timestamp for chart display (shorter format) * Format timestamp for chart display (shorter format)
* Shows date and time for better context
*/ */
export const formatChartTime = (timestamp) => { export const formatChartTime = (timestamp) => {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp); const date = new Date(timestamp);
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', { return date.toLocaleTimeString('en-US', {
hour: '2-digit', hour: '2-digit',
minute: '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", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.3",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"nodemailer": "^6.9.7", "nodemailer": "^6.9.7",
"ping": "^0.4.4", "ping": "^0.4.4",
@@ -253,6 +254,12 @@
"ieee754": "^1.1.13" "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": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -596,6 +603,15 @@
"node": ">= 0.4" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1136,6 +1152,55 @@
"node": ">=0.12.0" "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": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -1143,6 +1208,48 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "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", "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" "install-all": "npm install && cd client && npm install"
}, },
"keywords": ["monitoring", "uptime", "alerts", "dashboard"], "keywords": [
"monitoring",
"uptime",
"alerts",
"dashboard"
],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^9.2.2",
"node-cron": "^3.0.3",
"axios": "^1.6.2", "axios": "^1.6.2",
"nodemailer": "^6.9.7", "better-sqlite3": "^9.2.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1", "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", "ping": "^0.4.4",
"ws": "^8.17.1" "ws": "^8.17.1"
}, },
"devDependencies": { "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, enabled INTEGER DEFAULT 1,
maintenance_mode INTEGER DEFAULT 0, maintenance_mode INTEGER DEFAULT 0,
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
retention_days INTEGER DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
@@ -111,6 +112,20 @@ db.exec(`
UNIQUE(resource_id, tag) 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_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_checked_at ON checks(checked_at);
CREATE INDEX IF NOT EXISTS idx_checks_resource_checked ON checks(resource_id, 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_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_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_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_resource ON resource_tags(resource_id);
CREATE INDEX IF NOT EXISTS idx_resource_tags_tag ON resource_tags(tag); 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 // Lightweight migrations - for existing databases
@@ -141,10 +182,9 @@ try {
try { try {
db.prepare("ALTER TABLE resources ADD COLUMN maintenance_mode INTEGER DEFAULT 0").run(); db.prepare("ALTER TABLE resources ADD COLUMN maintenance_mode INTEGER DEFAULT 0").run();
console.log('[DB] Added maintenance_mode column to resources');
} catch (err) { } catch (err) {
if (err.message.includes('duplicate')) { if (err.message.includes('duplicate')) {
console.log('[DB] maintenance_mode column already exists'); // Column already exists
} }
} }
@@ -174,12 +214,11 @@ try {
try { try {
db.prepare("ALTER TABLE resources ADD COLUMN email_to TEXT").run(); db.prepare("ALTER TABLE resources ADD COLUMN email_to TEXT").run();
console.log('[DB] Added email_to column to resources');
} catch (err) { } catch (err) {
if (err.message.includes('duplicate')) { if (err.message.includes('duplicate')) {
console.log('[DB] email_to column already exists'); // email_to column already exists
} else { } 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 // New columns for features
try { try {
db.prepare("ALTER TABLE resources ADD COLUMN tags TEXT").run(); db.prepare("ALTER TABLE resources ADD COLUMN tags TEXT").run();
console.log('[DB] Added tags column to resources');
} catch (err) { } catch (err) {
if (err.message.includes('duplicate')) { if (err.message.includes('duplicate')) {
console.log('[DB] tags column already exists'); // tags column already exists
} }
} }
try { try {
db.prepare("ALTER TABLE resources ADD COLUMN consecutive_failures_threshold INTEGER DEFAULT 1").run(); 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) { } catch (err) {
if (err.message.includes('duplicate')) { if (err.message.includes('duplicate')) {
console.log('[DB] consecutive_failures_threshold column already exists'); // consecutive_failures_threshold column already exists
} }
} }
try { try {
db.prepare("ALTER TABLE resources ADD COLUMN response_time_threshold INTEGER").run(); db.prepare("ALTER TABLE resources ADD COLUMN response_time_threshold INTEGER").run();
console.log('[DB] Added response_time_threshold column to resources');
} catch (err) { } catch (err) {
if (err.message.includes('duplicate')) { if (err.message.includes('duplicate')) {
console.log('[DB] response_time_threshold column already exists'); // response_time_threshold column already exists
} }
} }
try { try {
db.prepare("ALTER TABLE resources ADD COLUMN response_time_baseline INTEGER").run(); db.prepare("ALTER TABLE resources ADD COLUMN response_time_baseline INTEGER").run();
console.log('[DB] Added response_time_baseline column to resources');
} catch (err) { } catch (err) {
if (err.message.includes('duplicate')) { if (err.message.includes('duplicate')) {
console.log('[DB] response_time_baseline column already exists'); // response_time_baseline column already exists
} }
} }
try { try {
db.prepare("ALTER TABLE resources ADD COLUMN is_transaction INTEGER DEFAULT 0").run(); db.prepare("ALTER TABLE resources ADD COLUMN is_transaction INTEGER DEFAULT 0").run();
console.log('[DB] Added is_transaction column to resources');
} catch (err) { } catch (err) {
if (err.message.includes('duplicate')) { 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 // Add description column to incidents for incident summaries
try { try {
db.prepare("ALTER TABLE incidents ADD COLUMN description TEXT").run(); db.prepare("ALTER TABLE incidents ADD COLUMN description TEXT").run();
console.log('[DB] Added description column to incidents');
} catch (err) { } catch (err) {
if (err.message.includes('duplicate')) { 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 // Add failed_check_count to incidents to track consecutive failures at creation time
try { try {
db.prepare("ALTER TABLE incidents ADD COLUMN failed_check_count INTEGER DEFAULT 0").run(); 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) { } catch (err) {
if (err.message.includes('duplicate')) { 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 monitorService = require('./monitorService');
const notificationService = require('./notificationService'); const notificationService = require('./notificationService');
const cache = require('./cache'); 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 app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -81,6 +108,44 @@ function getTimezoneOffset() {
app.use(cors()); app.use(cors());
app.use(express.json()); 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 // API Routes
// Get all groups // Get all groups
@@ -139,16 +204,37 @@ app.get('/api/resources/:id', (req, res) => {
// Create resource // Create resource
app.post('/api/resources', (req, res) => { 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) { if (!name || !url) {
return res.status(400).json({ error: 'Name and URL are required' }); 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 { try {
const stmt = db.prepare(` 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const result = stmt.run( const result = stmt.run(
@@ -165,16 +251,19 @@ app.post('/api/resources', (req, res) => {
cert_expiry_days || 30, cert_expiry_days || 30,
sla_target || 99.9, sla_target || 99.9,
email_to || null, email_to || null,
maintenance_mode ? 1 : 0 maintenance_mode ? 1 : 0,
retention_days || null
); );
// Invalidate related cache entries // Invalidate related cache entries
cache.invalidatePattern('history:'); cache.invalidatePattern('history:');
cache.invalidatePattern('sla:'); 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' }); res.json({ id: result.lastInsertRowid, message: 'Resource created' });
} catch (err) { } catch (err) {
console.error('[API] Error creating resource:', err.message);
// Helpful hint if migration missing // Helpful hint if migration missing
const hint = err.message.includes('no column named maintenance_mode') const hint = err.message.includes('no column named maintenance_mode')
? 'Database schema missing maintenance_mode. Restart server to run migrations.' ? 'Database schema missing maintenance_mode. Restart server to run migrations.'
@@ -185,11 +274,11 @@ app.post('/api/resources', (req, res) => {
// Update resource // Update resource
app.put('/api/resources/:id', (req, res) => { 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(` const stmt = db.prepare(`
UPDATE resources 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 = ? WHERE id = ?
`); `);
@@ -209,6 +298,7 @@ app.put('/api/resources/:id', (req, res) => {
sla_target || 99.9, sla_target || 99.9,
email_to || null, email_to || null,
maintenance_mode ? 1 : 0, maintenance_mode ? 1 : 0,
retention_days || null,
req.params.id req.params.id
); );
@@ -257,12 +347,19 @@ app.patch('/api/resources/:id/group', (req, res) => {
// Delete resource // Delete resource
app.delete('/api/resources/:id', (req, res) => { 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); db.prepare('DELETE FROM resources WHERE id = ?').run(req.params.id);
// Invalidate related cache entries // Invalidate related cache entries
cache.invalidatePattern('history:'); cache.invalidatePattern('history:');
cache.invalidatePattern('sla:'); 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' }); res.json({ message: 'Resource deleted' });
}); });
@@ -339,6 +436,84 @@ app.get('/api/incidents', (req, res) => {
res.json({ incidents }); 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 // Get settings
app.get('/api/settings', (req, res) => { app.get('/api/settings', (req, res) => {
const settings = { const settings = {
@@ -414,8 +589,43 @@ app.post('/api/settings', (req, res) => {
theme, theme,
} = req.body; } = req.body;
const envContent = `PORT=${process.env.PORT || 3001} // Validate email configuration if enabled
EMAIL_ENABLED=${email_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_HOST=${email_host}
EMAIL_PORT=${email_port} EMAIL_PORT=${email_port}
EMAIL_USER=${email_user} EMAIL_USER=${email_user}
@@ -490,7 +700,6 @@ THEME=${theme || 'light'}
res.json({ message: 'Settings saved successfully' }); res.json({ message: 'Settings saved successfully' });
} catch (error) { } catch (error) {
console.error('Error saving settings:', error);
res.status(500).json({ error: 'Failed to save settings' }); 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 retention_days: setting ? parseInt(setting.value) : 30
}); });
} catch (error) { } catch (error) {
console.error('Error getting retention settings:', error);
res.status(500).json({ error: 'Failed to get retention settings' }); 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 }); res.json({ message: 'Retention settings updated', retention_days });
} catch (error) { } catch (error) {
console.error('Error updating retention settings:', error);
res.status(500).json({ error: 'Failed to update retention settings' }); 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 // Get archived checks for a resource
app.get('/api/resources/:id/archived', (req, res) => { app.get('/api/resources/:id/archived', (req, res) => {
const { id } = req.params; const { id } = req.params;
@@ -607,7 +931,6 @@ app.get('/api/resources/:id/archived', (req, res) => {
}, },
}); });
} catch (error) { } catch (error) {
console.error('Error fetching archived checks:', error);
res.status(500).json({ error: 'Failed to fetch archived checks' }); res.status(500).json({ error: 'Failed to fetch archived checks' });
} }
}); });
@@ -791,7 +1114,6 @@ app.get('/api/resources/:id/trends', (req, res) => {
} }
}); });
} catch (error) { } catch (error) {
console.error('Error fetching trends:', error);
res.status(500).json({ error: 'Failed to fetch trends' }); 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 currentPage = Math.max(1, parseInt(pageParam || page || 1));
const offset = (currentPage - 1) * pageLimit; 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 // 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 // Check cache first
const cachedResult = cache.get(cacheKey); const cachedResult = cache.get(cacheKey);
@@ -832,7 +1161,7 @@ app.get('/api/history/overview', (req, res) => {
MAX(response_time) as max_response MAX(response_time) as max_response
FROM checks FROM checks
WHERE resource_id = ? AND checked_at > datetime('now', ?) 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 uptime = stats.total > 0 ? (stats.up_count / stats.total * 100) : 0;
const avgResponseTime = stats.avg_response || 0; const avgResponseTime = stats.avg_response || 0;
@@ -842,7 +1171,7 @@ app.get('/api/history/overview', (req, res) => {
if (isAveraged) { if (isAveraged) {
// Compute interval hours for bucketing (1h for 7 days, 3h for 14, 6h for 30+) // 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 // Use a reliable bucketing approach with Julian Day Numbers
// Convert to julian day, multiply by 24 for hours, divide by intervalHours and round // 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', ?) WHERE resource_id = ? AND checked_at > datetime('now', ?)
GROUP BY ${bucketExpr} GROUP BY ${bucketExpr}
ORDER BY checked_at ASC 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', status: row.up_count >= Math.ceil(row.total_count/2) ? 'up' : 'down',
response_time: Math.round(row.avg_up_response || 0), response_time: Math.round(row.avg_up_response || 0),
checked_at: row.checked_at, checked_at: row.checked_at,
@@ -871,7 +1200,7 @@ app.get('/api/history/overview', (req, res) => {
WHERE resource_id = ? AND checked_at > datetime('now', ?) WHERE resource_id = ? AND checked_at > datetime('now', ?)
ORDER BY checked_at DESC ORDER BY checked_at DESC
LIMIT 600 LIMIT 600
`).all(resource.id, `-${days} days`).reverse(); `).all(resource.id, `-${effectiveDays} days`).reverse();
} }
return { 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 result for 2 minutes (120 seconds)
cache.set(cacheKey, result, 120); 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.' }); res.json({ message: 'Test email sent successfully! Check your inbox.' });
} catch (error) { } catch (error) {
console.error('Test email error:', error);
res.status(500).json({ error: error.message }); 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!' }); res.json({ message: 'Test webhook sent successfully!' });
} catch (error) { } catch (error) {
console.error('Test webhook error:', error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@@ -1123,7 +1458,6 @@ app.get('/api/resources/:id/maintenance-windows', (req, res) => {
res.json({ windows }); res.json({ windows });
} catch (error) { } catch (error) {
console.error('Error fetching maintenance windows:', error);
res.status(500).json({ error: 'Failed to fetch maintenance windows' }); 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' message: 'Maintenance window created'
}); });
} catch (error) { } catch (error) {
console.error('Error creating maintenance window:', error);
res.status(500).json({ error: 'Failed to create maintenance window' }); 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); db.prepare(`DELETE FROM maintenance_windows WHERE id = ?`).run(id);
res.json({ message: 'Maintenance window deleted' }); res.json({ message: 'Maintenance window deleted' });
} catch (error) { } catch (error) {
console.error('Error deleting maintenance window:', error);
res.status(500).json({ error: 'Failed to delete maintenance window' }); 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 }); res.json({ maintenanceWindows: windows });
} catch (error) { } catch (error) {
console.error('Error getting maintenance windows:', error);
res.status(500).json({ error: 'Failed to get maintenance windows' }); 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 reason: window?.reason || null
}); });
} catch (error) { } catch (error) {
console.error('Error checking maintenance status:', error);
res.status(500).json({ error: 'Failed to check maintenance status' }); 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` message: `Baseline calculated: ${baseline}ms`
}); });
} catch (error) { } catch (error) {
console.error('Error calculating baseline:', error);
res.status(500).json({ error: 'Failed to calculate baseline' }); 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' }); res.json({ message: 'Alert rules updated' });
} catch (error) { } catch (error) {
console.error('Error updating alert rules:', error);
res.status(500).json({ error: 'Failed to update alert rules' }); 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 }); res.json({ steps });
} catch (error) { } catch (error) {
console.error('Error fetching transaction steps:', error);
res.status(500).json({ error: 'Failed to fetch transaction steps' }); 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' }); res.json({ id: result.lastInsertRowid, message: 'Transaction step created' });
} catch (error) { } catch (error) {
console.error('Error creating transaction step:', error);
res.status(500).json({ error: 'Failed to create transaction step' }); 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); db.prepare('DELETE FROM transaction_checks WHERE id = ?').run(id);
res.json({ message: 'Transaction step deleted' }); res.json({ message: 'Transaction step deleted' });
} catch (error) { } catch (error) {
console.error('Error deleting transaction step:', error);
res.status(500).json({ error: 'Failed to delete transaction step' }); 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'}` }); res.json({ message: `Transaction mode ${enabled ? 'enabled' : 'disabled'}` });
} catch (error) { } catch (error) {
console.error('Error toggling transaction mode:', error);
res.status(500).json({ error: 'Failed to toggle transaction mode' }); 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.setHeader('Content-Disposition', 'attachment; filename="resources-export.csv"');
res.send(csv); res.send(csv);
} catch (error) { } catch (error) {
console.error('Error exporting resources:', error);
res.status(500).json({ error: 'Failed to export resources' }); 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 // Import resources from CSV
app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res) => { app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res) => {
try { try {
@@ -1432,6 +1847,40 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
continue; 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(` const stmt = db.prepare(`
INSERT INTO resources (name, url, type, check_interval, timeout, sla_target, tags, group_id, email_to) INSERT INTO resources (name, url, type, check_interval, timeout, sla_target, tags, group_id, email_to)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -1441,9 +1890,9 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
row.name, row.name,
row.url, row.url,
row.type || 'http', row.type || 'http',
parseInt(row.check_interval) || 60000, checkInterval,
parseInt(row.timeout) || 5000, timeout,
parseFloat(row.sla_target) || 99.9, slaTarget,
row.tags || '', row.tags || '',
row.group_id ? parseInt(row.group_id) : null, row.group_id ? parseInt(row.group_id) : null,
row.email_to || '' 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 }); res.json({ imported, errors, count: imported.length });
} catch (error) { } catch (error) {
console.error('Error importing resources:', error);
res.status(500).json({ error: 'Failed to import resources' }); res.status(500).json({ error: 'Failed to import resources' });
} }
}); });
@@ -1547,19 +1995,165 @@ function broadcastDashboardUpdate() {
} }
}); });
} catch (error) { } 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.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 // Start server with HTTP and WebSocket
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocket.Server({ server }); const wss = new WebSocket.Server({ server });
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
console.log('WebSocket client connected');
wsClients.add(ws); wsClients.add(ws);
// Send initial dashboard data // Send initial dashboard data
@@ -1572,23 +2166,19 @@ wss.on('connection', (ws) => {
ws.send(JSON.stringify({ type: 'pong' })); ws.send(JSON.stringify({ type: 'pong' }));
} }
} catch (error) { } catch (error) {
console.error('Error handling WebSocket message:', error.message); // Handle JSON parse errors silently
} }
}); });
ws.on('close', () => { ws.on('close', () => {
console.log('WebSocket client disconnected');
wsClients.delete(ws); wsClients.delete(ws);
}); });
ws.on('error', (error) => { ws.on('error', (error) => {
console.error('WebSocket error:', error.message);
wsClients.delete(ws); wsClients.delete(ws);
}); });
}); });
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`WebSocket available at ws://localhost:${PORT}`);
scheduler.start(); 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();
+41 -3
View File
@@ -7,6 +7,8 @@ const dns = require('dns').promises;
const WebSocket = require('ws'); const WebSocket = require('ws');
const ping = require('ping'); const ping = require('ping');
const db = require('./database'); const db = require('./database');
const RetryPolicy = require('./retryPolicy');
const CircuitBreaker = require('./circuitBreaker');
const DEFAULT_PORTS = { const DEFAULT_PORTS = {
http: 80, http: 80,
@@ -32,6 +34,33 @@ class MonitorService {
// Keep-alive agents for pooled HTTP/S connections // Keep-alive agents for pooled HTTP/S connections
this.httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 }); this.httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 });
this.httpsAgent = new https.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) { async checkResource(resource) {
const startTime = Date.now(); const startTime = Date.now();
@@ -77,7 +106,11 @@ class MonitorService {
async checkHttp(resource, startTime, base) { async checkHttp(resource, startTime, base) {
let result = { ...base }; let result = { ...base };
const circuitBreaker = this.getResourceCircuitBreaker(resource.id);
try { try {
await circuitBreaker.execute(async () => {
return await this.retryPolicy.execute(async () => {
const headers = {}; const headers = {};
if (resource.http_headers) { if (resource.http_headers) {
try { try {
@@ -117,9 +150,15 @@ class MonitorService {
status_code: response.status, status_code: response.status,
error_message: isUp && keywordMatch ? null : (result.error_message || `HTTP ${response.status}`), error_message: isUp && keywordMatch ? null : (result.error_message || `HTTP ${response.status}`),
}; };
return result;
}, `HTTP check for ${resource.name}`);
});
} catch (error) { } catch (error) {
result.error_message = error.message; const responseTime = Date.now() - startTime;
result.response_time = Date.now() - startTime; result.error_message = error.originalError?.message || error.message;
result.response_time = responseTime;
result.status = 'down';
} }
return result; return result;
} }
@@ -706,7 +745,6 @@ class MonitorService {
return currentResponseTime > threshold; return currentResponseTime > threshold;
} catch (error) { } catch (error) {
console.error('Error checking response time anomaly:', error.message);
return false; return false;
} }
} }
+45 -4
View File
@@ -44,6 +44,8 @@ class NotificationService {
user: config.email_user, user: config.email_user,
pass: config.email_pass, pass: config.email_pass,
}, },
connectionTimeout: 10000,
socketTimeout: 10000,
}); });
} else { } else {
this.transporter = null; this.transporter = null;
@@ -99,6 +101,47 @@ class NotificationService {
} }
await Promise.allSettled(promises); 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) { isQuietHours(resource) {
@@ -125,7 +168,6 @@ class NotificationService {
async sendEmail(resource, message, type, stats = null, emailOverride = null) { async sendEmail(resource, message, type, stats = null, emailOverride = null) {
if (!this.transporter) { if (!this.transporter) {
console.error(`[Email] Config incomplete; skipping email (enabled=${this.emailEnabled}, host=${this.config.email_host})`);
return; return;
} }
@@ -187,7 +229,7 @@ class NotificationService {
: [], : [],
}); });
} catch (error) { } catch (error) {
console.error('Email error:', error.message); // Email error handled
} }
} }
@@ -234,7 +276,6 @@ class NotificationService {
return !!window; return !!window;
} catch (error) { } catch (error) {
console.error('Error checking maintenance window:', error.message);
return false; return false;
} }
} }
@@ -249,7 +290,7 @@ class NotificationService {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} catch (error) { } 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;
+34 -17
View File
@@ -6,12 +6,20 @@ const notificationService = require('./notificationService');
class Scheduler { class Scheduler {
constructor() { constructor() {
this.jobs = new Map(); this.jobs = new Map();
this.isRunning = false;
} }
start() { start() {
// Run checks every minute // Run checks every minute
cron.schedule('* * * * *', async () => { cron.schedule('* * * * *', async () => {
if (!this.isRunning) {
this.isRunning = true;
try {
await this.runChecks(); await this.runChecks();
} finally {
this.isRunning = false;
}
}
}); });
// Archive old checks daily at 2 AM // Archive old checks daily at 2 AM
@@ -23,27 +31,22 @@ class Scheduler {
setTimeout(() => { setTimeout(() => {
this.archiveOldChecks(); this.archiveOldChecks();
}, 5000); }, 5000);
console.log('Scheduler started');
} }
archiveOldChecks() { archiveOldChecks() {
try { try {
// Get global retention setting
const retentionSetting = db.prepare(` const retentionSetting = db.prepare(`
SELECT value FROM settings WHERE key = 'retention_days' SELECT value FROM settings WHERE key = 'retention_days'
`).get(); `).get();
const retentionDays = retentionSetting ? parseInt(retentionSetting.value) : 30; const globalRetentionDays = retentionSetting ? parseInt(retentionSetting.value) : 30;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
// Move old checks to archived_checks // Get all resources with their retention settings
const oldChecks = db.prepare(` const resources = db.prepare(`
SELECT * FROM checks WHERE checked_at < ? SELECT id, retention_days FROM resources
`).all(cutoffString); `).all();
if (oldChecks.length > 0) {
const insertArchived = db.prepare(` const insertArchived = db.prepare(`
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at) INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -54,6 +57,18 @@ class Scheduler {
`); `);
const transaction = db.transaction(() => { 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);
oldChecks.forEach(check => { oldChecks.forEach(check => {
insertArchived.run( insertArchived.run(
check.resource_id, check.resource_id,
@@ -67,12 +82,11 @@ class Scheduler {
deleteOld.run(check.id); deleteOld.run(check.id);
}); });
}); });
});
transaction(); transaction();
console.log(`[Archival] Archived ${oldChecks.length} checks older than ${retentionDays} days`);
}
} catch (error) { } 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); await notificationService.sendAlert(resource, incident, stats);
} }
} }
console.log(`Checked ${resource.name}: ${result.status} (${result.response_time}ms)`);
} catch (error) { } catch (error) {
console.error(`Error checking ${resource.name}:`, error.message); // Check error handled silently
} }
})()); })());
@@ -147,6 +159,11 @@ class Scheduler {
global.broadcastDashboardUpdate(); global.broadcastDashboardUpdate();
} }
} }
stop() {
// Stop the scheduler (cron tasks will continue running but we flag isRunning)
this.isRunning = false;
}
} }
module.exports = new Scheduler(); 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();