Add trends UI, cleanup logs, refresh build
This commit is contained in:
1 parent
71d1dbd5c1
commit
d4e4467c0c
20 files changed
+1737
-88
No files matched your search
@@ -0,0 +1,283 @@
|
||||
# Code Cleanup Report - SkyWatch
|
||||
|
||||
Generated: Phase 4 of Feature Implementation
|
||||
|
||||
## Summary
|
||||
This report identifies areas for potential cleanup in the SkyWatch codebase after implementing 11 new features.
|
||||
|
||||
---
|
||||
|
||||
## 1. Console Statements (Debugging)
|
||||
|
||||
### Recommendation: Remove or wrap in DEBUG flag
|
||||
|
||||
**Total Found:** 100+ console.log/error statements across the codebase
|
||||
|
||||
### Server Files (server/):
|
||||
- **server/index.js:** ~40 console statements
|
||||
- Lines 74, 80: Timezone offset debugging
|
||||
- Lines 728, 736, 741, 744, 772, 775, 778, 799, 801, 810: History API debugging
|
||||
- Lines 864, 869, 872, 900: SLA query debugging
|
||||
- Lines 1483-1484: Server startup messages (KEEP THESE)
|
||||
- Recommendation: Remove timezone/query debug logs, keep server startup and critical errors
|
||||
|
||||
- **server/database.js:** ~20 console statements
|
||||
- Lines 143, 146, 176, 179, 204, 207, 213, 216, 222, 225, 231, 234, 240, 243: Migration confirmations
|
||||
- Recommendation: Keep these for database migration tracking
|
||||
|
||||
- **server/monitorService.js:** 1 console.error (line 686)
|
||||
- Recommendation: Keep error logging
|
||||
|
||||
- **server/notificationService.js:** ~10 console.log statements
|
||||
- Lines 54, 60, 66, 81, 85, 87, 172: Alert logic debugging
|
||||
- Recommendation: Keep critical alerts, remove verbose debugging
|
||||
|
||||
### Client Files (client/src/):
|
||||
- **client/src/App.js:** ~20 console.error statements
|
||||
- All are error handlers in catch blocks
|
||||
- Recommendation: Keep all - useful for user troubleshooting
|
||||
|
||||
- **client/src/History.js:** 5 console statements
|
||||
- Lines 37, 67: API response debugging
|
||||
- Line 131: Chart data debugging
|
||||
- Recommendation: Remove debug logs (37, 67, 131), keep error handling (72, 60)
|
||||
|
||||
- **client/src/SettingsWizard.js:** 2 console.error
|
||||
- Recommendation: Keep error logging
|
||||
|
||||
- **client/src/ErrorBoundary.js:** 1 console.error (line 16)
|
||||
- Recommendation: Keep error logging
|
||||
|
||||
- **client/src/SLA.js:** 1 console.error (line 28)
|
||||
- Recommendation: Keep error logging
|
||||
|
||||
### Action Items:
|
||||
```
|
||||
REMOVE (Non-critical debug logs):
|
||||
- server/index.js: Lines 74, 80, 728, 736, 741, 744, 772, 775, 778, 799, 801, 810, 864, 869, 872, 900
|
||||
- server/notificationService.js: Lines 81, 85, 87 (verbose email debugging)
|
||||
- client/src/History.js: Lines 37, 67, 131
|
||||
|
||||
KEEP (Critical logs):
|
||||
- All console.error statements in error handlers
|
||||
- server/index.js: Lines 1483-1484 (server startup)
|
||||
- server/database.js: All migration logs
|
||||
- server/notificationService.js: Lines 54, 60, 66, 172 (alert skip reasons)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Commented Code
|
||||
|
||||
### Analysis: Minimal commented code found
|
||||
- client/src/History.js line 129: Helpful comment explaining server-side averaging
|
||||
- Build files contain source maps (expected)
|
||||
|
||||
**Recommendation:** No action needed - comments are descriptive, not dead code
|
||||
|
||||
---
|
||||
|
||||
## 3. Unused Dependencies
|
||||
|
||||
### Server (package.json)
|
||||
✅ **All dependencies are used:**
|
||||
- express: ✓ Used in server/index.js
|
||||
- cors: ✓ Used in server/index.js
|
||||
- better-sqlite3: ✓ Used in server/database.js
|
||||
- node-cron: ✓ Used in server/scheduler.js
|
||||
- axios: ✓ Used in server/monitorService.js
|
||||
- nodemailer: ✓ Used in server/notificationService.js
|
||||
- dotenv: ✓ Used in server/index.js
|
||||
- ping: ✓ Used in server/monitorService.js (line 252)
|
||||
- ws: ✓ Used in server/index.js (WebSocket server)
|
||||
|
||||
### Client (client/package.json)
|
||||
⚠️ **Potentially unused dependencies:**
|
||||
|
||||
1. **web-vitals** (^2.1.4)
|
||||
- Used in: client/src/reportWebVitals.js
|
||||
- Called in: client/src/index.js line 20 as `reportWebVitals()`
|
||||
- Purpose: React performance monitoring
|
||||
- **Recommendation:** KEEP if you plan to monitor performance, REMOVE if not needed
|
||||
|
||||
2. **@testing-library/** packages (^10.4.1, ^6.9.1, ^16.3.0)
|
||||
- Used in: client/src/setupTests.js, client/src/App.test.js
|
||||
- Purpose: React testing utilities
|
||||
- **Recommendation:** KEEP if you plan to write tests, REMOVE if not running tests
|
||||
|
||||
3. **@testing-library/user-event** (^13.5.0)
|
||||
- Not imported anywhere in src/
|
||||
- **Recommendation:** REMOVE (unused testing utility)
|
||||
|
||||
4. **skywatch** (file:..)
|
||||
- Self-reference to parent package
|
||||
- **Recommendation:** REMOVE (circular dependency, not needed)
|
||||
|
||||
**Action Items:**
|
||||
```bash
|
||||
# Optional removals (if not using tests/vitals):
|
||||
cd client
|
||||
npm uninstall web-vitals @testing-library/user-event skywatch
|
||||
|
||||
# If removing web-vitals, also delete:
|
||||
# - client/src/reportWebVitals.js
|
||||
# - import/call in client/src/index.js lines 6, 20
|
||||
|
||||
# If removing testing libraries, also delete:
|
||||
# - client/src/setupTests.js
|
||||
# - client/src/App.test.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Unused Files
|
||||
|
||||
### Server
|
||||
✅ **All files are actively used:**
|
||||
- cache.js: Used in server/index.js
|
||||
- database.js: Used in server/index.js, scheduler.js, monitorService.js, notificationService.js
|
||||
- index.js: Main server entry point
|
||||
- monitorService.js: Used in server/scheduler.js
|
||||
- notificationService.js: Used in server/monitorService.js
|
||||
- scheduler.js: Used in server/index.js
|
||||
|
||||
### Client
|
||||
⚠️ **Potentially unused files:**
|
||||
|
||||
1. **client/src/reportWebVitals.js**
|
||||
- Purpose: Performance monitoring
|
||||
- Used: Yes (called in index.js)
|
||||
- **Recommendation:** Remove if not analyzing metrics
|
||||
|
||||
2. **client/src/setupTests.js**
|
||||
- Purpose: Jest/testing configuration
|
||||
- Used: Only if running tests
|
||||
- **Recommendation:** Remove if not writing tests
|
||||
|
||||
3. **client/src/App.test.js**
|
||||
- Purpose: Basic React component test
|
||||
- Used: Only if running `npm test`
|
||||
- **Recommendation:** Remove if not writing tests
|
||||
|
||||
---
|
||||
|
||||
## 5. Dead Code Patterns
|
||||
|
||||
### Analysis: No major dead code found
|
||||
|
||||
**Checked for:**
|
||||
- ✅ Unreachable functions
|
||||
- ✅ Unused exports
|
||||
- ✅ Orphaned modules
|
||||
- ✅ Duplicate logic
|
||||
|
||||
**Result:** All implemented features are connected and functional
|
||||
|
||||
---
|
||||
|
||||
## 6. Optimization Opportunities
|
||||
|
||||
### 1. Consolidate console.log debugging
|
||||
**Current:** Scattered debug logs throughout server/index.js
|
||||
**Improvement:** Add DEBUG environment variable flag
|
||||
```javascript
|
||||
const DEBUG = process.env.DEBUG === 'true';
|
||||
if (DEBUG) console.log('...');
|
||||
```
|
||||
|
||||
### 2. Consider log levels
|
||||
**Current:** Mix of console.log and console.error
|
||||
**Improvement:** Use logging library (winston, pino) for structured logging
|
||||
```bash
|
||||
npm install winston
|
||||
```
|
||||
|
||||
### 3. Frontend error reporting
|
||||
**Current:** Console errors only
|
||||
**Improvement:** Add error reporting service (Sentry, LogRocket) for production
|
||||
|
||||
---
|
||||
|
||||
## Cleanup Priority
|
||||
|
||||
### High Priority (Do Now):
|
||||
1. ✅ Remove debugging console.log statements from:
|
||||
- server/index.js (timezone, history, SLA debug lines)
|
||||
- server/notificationService.js (verbose email logs)
|
||||
- client/src/History.js (API debug logs)
|
||||
|
||||
### Medium Priority (Consider):
|
||||
2. Remove unused client dependencies if not using:
|
||||
- `@testing-library/user-event`
|
||||
- `skywatch` (circular dependency)
|
||||
- Testing files if not writing tests
|
||||
- web-vitals if not monitoring performance
|
||||
|
||||
### Low Priority (Future Improvement):
|
||||
3. Add structured logging with log levels
|
||||
4. Add DEBUG environment flag
|
||||
5. Consider production error monitoring
|
||||
|
||||
---
|
||||
|
||||
## Implementation Commands
|
||||
|
||||
### Immediate Cleanup (Non-Breaking):
|
||||
```bash
|
||||
# Remove debug console logs from server
|
||||
# (Apply file edits as listed in Action Items section above)
|
||||
|
||||
# Remove unused client dependencies
|
||||
cd client
|
||||
npm uninstall @testing-library/user-event skywatch
|
||||
```
|
||||
|
||||
### Optional Cleanup (If not using tests/vitals):
|
||||
```bash
|
||||
cd client
|
||||
npm uninstall web-vitals @testing-library/dom @testing-library/jest-dom @testing-library/react
|
||||
rm src/reportWebVitals.js src/setupTests.js src/App.test.js
|
||||
# Then remove imports from src/index.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
After cleanup, verify:
|
||||
- [ ] Server starts without errors: `npm start`
|
||||
- [ ] Client builds successfully: `cd client && npm run build`
|
||||
- [ ] All features still work:
|
||||
- [ ] CSV import/export
|
||||
- [ ] Data retention settings
|
||||
- [ ] WebSocket real-time updates
|
||||
- [ ] Mobile responsive design
|
||||
- [ ] Anomaly detection
|
||||
- [ ] Alert rules (consecutive failures)
|
||||
- [ ] Maintenance windows
|
||||
- [ ] Resource tagging
|
||||
- [ ] Transaction checks
|
||||
- [ ] Trend graphs
|
||||
|
||||
---
|
||||
|
||||
## Files Requiring Changes
|
||||
|
||||
### High Priority Edits:
|
||||
1. `server/index.js` - Remove 16 debug console.log lines
|
||||
2. `server/notificationService.js` - Remove 3 verbose email logs
|
||||
3. `client/src/History.js` - Remove 3 debug console.log lines
|
||||
4. `client/package.json` - Remove 2 unused dependencies
|
||||
|
||||
### Optional Edits (if removing tests/vitals):
|
||||
5. `client/src/index.js` - Remove reportWebVitals import/call
|
||||
6. Delete 3 client files: reportWebVitals.js, setupTests.js, App.test.js
|
||||
7. `client/package.json` - Remove testing dependencies
|
||||
|
||||
---
|
||||
|
||||
**Total Impact:**
|
||||
- Lines to remove: ~20 console statements
|
||||
- Dependencies to remove: 2-6 packages
|
||||
- Files to delete: 0-3 files (optional)
|
||||
- Risk level: LOW (no functional changes, only cleanup)
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.44e40ac2.css",
|
||||
"main.js": "/static/js/main.ca674b04.js",
|
||||
"main.css": "/static/css/main.2e6b09f5.css",
|
||||
"main.js": "/static/js/main.7f40b053.js",
|
||||
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
|
||||
"index.html": "/index.html",
|
||||
"main.44e40ac2.css.map": "/static/css/main.44e40ac2.css.map",
|
||||
"main.ca674b04.js.map": "/static/js/main.ca674b04.js.map",
|
||||
"main.2e6b09f5.css.map": "/static/css/main.2e6b09f5.css.map",
|
||||
"main.7f40b053.js.map": "/static/js/main.7f40b053.js.map",
|
||||
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.44e40ac2.css",
|
||||
"static/js/main.ca674b04.js"
|
||||
"static/css/main.2e6b09f5.css",
|
||||
"static/js/main.7f40b053.js"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><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="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.ca674b04.js"></script><link href="/static/css/main.44e40ac2.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" href="/favicon.ico"/><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="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.7f40b053.js"></script><link href="/static/css/main.2e6b09f5.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||
+2
-2
@@ -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;padding:.25rem .75rem;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:768px){.navbar{padding:.75rem 1rem}.navbar h1{fontLine truncated
|
||||
/*# sourceMappingURL=main.44e40ac2.css.map*/
|
||||
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.App{min-height:100vh}.navbar{background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 2px 8px #0000001a;color:#fff;padding:1rem 2rem}.navbar h1{font-size:1.5rem;margin:0}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-bottom:1.5rem}.resource-card{background:#fff;border:1px solid #f1f1f3;border-radius:10px;box-shadow:0 2px 8px #00000014;cursor:pointer;padding:1rem 1.1rem;transition:transform .2s,box-shadow .2s}.resource-card.compact{display:flex;flex-direction:column;gap:.4rem}.resource-card:hover{box-shadow:0 4px 12px #00000026;transform:translateY(-2px)}.resource-header{align-items:flex-start;display:flex;justify-content:space-between;margin-bottom:.5rem}.resource-name{font-size:1.05rem;font-weight:700;margin:0 0 .2rem}.resource-url{color:#666;font-size:.8rem;word-break:break-all}.status-badge{border-radius:12px;font-size:.7rem;font-weight:600;padding:.25rem .75rem;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:repeLine truncated
|
||||
/*# sourceMappingURL=main.2e6b09f5.css.map*/
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"static/css/main.2e6b09f5.css","mappings":"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","sources":["index.css","App.css","SettingsWizard.css"],"sourcesContent":["body {\n margin:Line truncated
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"static/css/main.44e40ac2.css","mappings":"AAAA,KAKE,kCAAmC,CACnC,iCAAkC,CAClC,wBAAyB,CALzB,mIAEY,CAHZ,QAOF,CAEA,EACE,qBACF,CAEA,KACE,uEAEF,CCjBA,KACE,gBACF,CAEA,QACE,kDAA6D,CAG7D,8BAAqC,CADrC,UAAY,CADZ,iBAGF,CAEA,WAEE,gBAAiB,CADjB,QAEF,CAEA,WAEE,aAAc,CADd,gBAAiB,CAEjB,YACF,CAEA,gBAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,yDAA4D,CAE5D,oBACF,CAEA,eACE,eAAiB,CAMjB,wBAAyB,CALzB,kBAAmB,CAEnB,8BAAsC,CAEtC,cAAe,CAHf,mBAAoB,CAEpB,uCAGF,CAEA,uBACE,YAAa,CACb,qBAAsB,CACtB,SACF,CAEA,qBAEE,+BAAuC,CADvC,0BAEF,CAEA,iBAGE,sBAAuB,CAFvB,YAAa,CACb,6BAA8B,CAE9B,mBACF,CAEA,eACE,iBAAkB,CAClB,eAAgB,CAChB,gBACF,CAEA,cACE,UAAW,CACX,eAAiB,CACjB,oBACF,CAEA,cAEE,kBAAmB,CACnB,eAAiB,CACjB,eAAgB,CAHhB,qBAAwB,CAIxB,wBACF,CAEA,WACE,kBAAmB,CACnB,aACF,CAEA,aACE,kBAAmB,CACnB,aACF,CAEA,gBACE,kBAAmB,CACnB,aACF,CAEA,YAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,6BAA8B,CAE9B,eACF,CAEA,MACE,iBACF,CAEA,YAGE,UAAW,CAFX,gBAAiB,CACjB,eAAgB,CAEhB,QACF,CAEA,kBACE,iBACF,CAEA,YAEE,UAAW,CADX,gBAAkB,CAGlB,QAAS,CADT,wBAEF,CAEA,eAIE,eAAY,CADZ,kBAAmB,CAFnB,YAAa,CAGb,UAAY,CAFZ,mCAGF,CAEA,WAEE,WAAY,CADZ,UAEF,CAEA,YAGE,UAAW,CADX,gBAAkB,CADlB,QAGF,CAEA,eAEE,kBAAmB,CACnB,kBAAmB,CAFnB,kBAAmB,CAGnB,YACF,CAEA,cAEE,kBAAmB,CAGnB,eAAiB,CAIjB,wBAAyB,CAHzB,iBAAkB,CAClB,cAAe,CANf,YAAa,CAEb,QAAS,CAOT,kBAAmB,CANnB,YAAa,CAIb,yBAGF,CAEA,oBACE,kBACF,CAEA,iBAGE,UAAW,CAFX,gBAAiB,CACjB,QAEF,CAEA,cAEE,aAAc,CADd,eAAiB,CAEjB,cACF,CAEA,aACE,YAAa,CAGb,gBAAkB,CAFlB,UAAW,CACX,gBAEF,CAEA,YACE,kBAAmB,CAEnB,iBAAkB,CAClB,UAAW,CACX,eAAgB,CAHhB,mBAIF,CAEA,YAEE,wBAAyB,CACzB,iBAAkB,CAClB,iBAAkB,CAElB,mBAAqB,CALrB,aAAe,CAIf,UAEF,CAEA,gBAGE,yBAA0B,CAF1B,iBAAmB,CACnB,kBAEF,CAEA,YACE,mBACF,CAEA,kBAGE,UAAW,CAFX,aAAc,CACd,gBAAkB,CAGlB,eAAgB,CADhB,oBAEF,CAEA,kBAGE,qBAAsB,CACtB,iBAAkB,CAClB,eAAiB,CAHjB,mBAAsB,CADtB,UAKF,CAEA,cACE,YAAa,CACb,SAAW,CAEX,wBAAyB,CADzB,gBAEF,CAEA,UACE,eAAgB,CAChB,WAAY,CACZ,cAAe,CACf,cAAe,CAEf,UAAY,CADZ,oBAAuB,CAEvB,sBACF,CAEA,gBACE,SACF,CAEA,QAEE,eAAiB,CADjB,mBAEF,CAEA,KAEE,WAAY,CACZ,iBAAkB,CAGlB,cAAe,CAFf,cAAe,CACf,eAAgB,CAJhB,qBAAuB,CAMvB,kBACF,CAEA,aACE,kDAA6D,CAC7D,UACF,CAEA,mBAEE,8BAA8C,CAD9C,0BAEF,CAEA,YACE,kBAAmB,CACnB,UACF,CAEA,kBACE,kBACF,CAEA,eAQE,kBAAmB,CAFnB,oBAA8B,CAD9B,QAAS,CAET,YAAa,CAEb,sBAAuB,CANvB,MAAO,CAFP,cAAe,CAGf,OAAQ,CAFR,KAAM,CAQN,YACF,CAEA,OACE,eAAiB,CACjB,kBAAmB,CAInB,eAAgB,CAFhB,eAAgB,CAGhB,eAAgB,CAJhB,YAAa,CAEb,SAGF,CAEA,UACE,YACF,CAEA,YACE,oBACF,CAEA,kBAIE,UAAW,CAHX,aAAc,CAEd,eAAgB,CADhB,mBAGF,CAEA,qCAIE,qBAAsB,CACtB,iBAAkB,CAClB,cAAe,CAHf,cAAgB,CADhB,UAKF,CAEA,iDAGE,oBAAqB,CADrB,YAEF,CAEA,cACE,YAAa,CACb,QAAS,CACT,wBAAyB,CACzB,eACF,CAEA,gBACE,eAAiB,CACjB,iBAAkB,CAGlB,8BAAqC,CADrC,oBAAqB,CADrB,cAGF,CAEA,mBAEE,UAAW,CADX,YAEF,CAEA,iBAEE,YAAa,CADb,eAEF,CAEA,gBAGE,kBAAmB,CACnB,aAAc,CAEd,eAEF,CAEA,6BALE,iBAAkB,CAJlB,oBAAqB,CAMrB,kBAAmB,CALnB,kBAiBF,CATA,aAIE,kBAAmB,CACnB,UAAY,CACZ,oBAAqB,CAErB,yBACF,CAEA,mBACE,kBACF,CAEA,aAGE,UAAW,CADX,YAAa,CADb,iBAGF,CAEA,gBACE,UAAW,CACX,mBACF,CAGA,yBACE,QACE,mBACF,CAEA,WACE,iBACF,CAEA,cACE,cAAe,CACf,SACF,CAEA,gBAEE,eAAiB,CADjB,mBAEF,CAEA,WACE,YACF,CAEA,kBAEE,sBAAuB,CADvB,qBAAsB,CAEtB,QACF,CAEA,mBAEE,qBAAsB,CACtB,SAAW,CAFX,UAGF,CAEA,oDAEE,UACF,CAEA,gBAEE,UAAY,CADZ,yBAEF,CAEA,YAEE,UAAY,CADZ,6CAEF,CAEA,eACE,eACF,CAEA,eACE,qBAAsB,CACtB,SACF,CAEA,mBACE,yBACF,CAEA,iBACE,sBACF,CAEA,gBACE,sBACF,CAEA,UACE,qBAAsB,CACtB,KACF,CAEA,YACE,UACF,CAEA,eAGE,WAAY,CADZ,cAAe,CAEf,cAAe,CAHf,SAIF,CAEA,cAEE,sBAAuB,CADvB,qBAAsB,CAEtB,SACF,CAEA,aAEE,6BAA8B,CAD9B,UAEF,CAEA,cACE,kBAAmB,CAEnB,SAAW,CADX,wBAEF,CACF,CAEA,yBACE,WACE,gBACF,CAEA,YACE,gBACF,CAEA,YACE,gBACF,CAEA,YACE,mCACF,CAEA,kBACE,iBACF,CAEA,KAEE,eAAiB,CADjB,kBAEF,CACF,CC7gBA,iBAEE,aAAc,CADd,eAEF,CAEA,sBACE,UAAW,CACX,kBACF,CAEA,kBACE,eAAiB,CACjB,iBAAkB,CAGlB,8BAAqC,CADrC,oBAAqB,CADrB,YAGF,CAEA,iBAGE,kBAAmB,CAGnB,+BAAgC,CALhC,YAAa,CACb,6BAA8B,CAE9B,oBAAqB,CACrB,mBAEF,CAEA,oBAEE,UAAW,CADX,QAEF,CAEA,eACE,iBACF,CAEA,UAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,wDAA2D,CAE3D,kBACF,CAEA,kBAEE,UAAW,CADX,aAAc,CAEd,gBAAkB,CAClB,iBACF,CAEA,UACE,kBAAmB,CACnB,6BAA8B,CAG9B,iBAAkB,CADlB,eAAgB,CADhB,YAGF,CAEA,iBAGE,UAAW,CAFX,aAAc,CACd,mBAEF,CAEA,aACE,uBAAyB,CACzB,SACF,CAEA,aAEE,UAAW,CADX,eAEF,CAEA,YACE,aAAc,CACd,oBACF,CAEA,kBACE,yBACF,CAEA,cACE,eAAiB,CAEjB,iBAAkB,CAElB,gBAAkB,CAClB,gBAAkB,CAFlB,eAAgB,CAFhB,YAKF,CAEA,eAEE,oBAAqB,CAErB,WAAY,CAHZ,iBAAkB,CAElB,UAEF,CAEA,qBAGE,QAAS,CAFT,SAAU,CACV,OAEF,CAEA,eAOE,qBAAsB,CAEtB,kBAAmB,CAHnB,QAAS,CAJT,cAAe,CAEf,MAAO,CAHP,iBAAkB,CAIlB,OAAQ,CAFR,KAAM,CAKN,cAEF,CAEA,sBAOE,qBAAuB,CAEvB,iBAAkB,CAHlB,UAAW,CAJX,UAAW,CACX,WAAY,CAEZ,QAAS,CAJT,iBAAkB,CAOlB,cAAe,CAJf,UAMF,CAEA,4CACE,wBACF,CAEA,mDACE,0BACF,CAEA,eACE,kBAAmB,CACnB,UAAY,CACZ,eACF,CAEA,oCACE,kBACF,CAEA,wBAEE,kBAAmB,CADnB,UAEF,CAEA,aAEE,iBAAkB,CAClB,eAAgB,CAFhB,YAGF,CAEA,qBACE,kBAAmB,CAEnB,wBAAyB,CADzB,aAEF,CAEA,mBACE,kBAAmB,CAEnB,wBAAyB,CADzB,aAEF,CAEA,oBACE,aAAc,CACd,mBACF,CAEA,eACE,QACF,CAEA,kBACE,YAAa,CACb,sBAAuB,CACvB,eACF,CAEA,WAEE,gBAAiB,CADjB,iBAEF","sources":["index.css","App.css","SettingsWizard.css"],"sourcesContent":["body {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto'Line truncated
|
||||
@@ -1,3 +1,3 @@
|
||||
/*! For license information please see main.ca674b04.js.LICENSE.txt */
|
||||
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
|
||||
//# sourceMappingURL=main.ca674b04.js.map
|
||||
/*! For license information please see main.7f40b053.js.LICENSE.txt */
|
||||
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
|
||||
//# sourceMappingURL=main.7f40b053.js.map
|
||||
File renamed without changes.
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"static/js/main.7f40b053.js","mappings":";2BAAAA,EAAOC,QAAU,EAAjBD,MAAAA,I,2BCEAE,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAmB5DL,EAAQM,SAjBR,SAASA,EAASD,GACd,GAAa,MAATA,EACA,MAAO,GAEX,GAAqB,kBAAVA,EACP,OAAOA,EAEX,GAAIE,MAAMC,QAAQH,GACd,OAAOA,EAAMI,IAAIH,GAAUI,KAAK,KAEpC,MAAMC,EAASC,OAAOP,GACtB,MAAe,MAAXM,GAAkBV,OAAOY,GAAGC,OAAOT,IAAS,GACrC,KAEJM,CACX,C,6BChBEZ,EAAOC,QAAU,EAAjBD,K,2BCDFE,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAM5DL,EAAQe,SAJR,SAAkBV,GACd,OAAiB,OAAVA,IAAoC,kBAAVA,GAAuC,oBAAVA,EAClE,C,8BCJAJ,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMW,EAAWC,EAAQ,MAMzBjB,EAAQkB,YAJR,SAAqBb,GACjB,OAAgB,MAATA,GAAkC,oBAAVA,GAAwBW,EAASA,SAASX,EAAMc,OACnF,C,8BCNAlB,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMe,EAAaH,EAAQ,MA6C3BjB,EAAQqB,SA3CR,SAAkBC,GAAoC,IAA9BC,EAAUC,UAAAL,OAAA,QAAAM,IAAAD,UAAA,GAAAA,UAAA,GAAG,EAAGE,EAAOF,UAAAL,OAAA,QAAAM,IAAAD,UAAA,GAAAA,UAAA,GAAG,CAAC,EACxB,kBAAZE,IACPA,EAAU,CAAC,GAEf,MAAM,QAAEC,GAAU,EAAK,SAAEC,GAAW,EAAI,QAAEC,GAAYH,EAChDI,EAAQvB,MAAM,GAOpB,IAAII,EANAgB,IACAG,EAAM,GAAK,WAEXF,IACAE,EAAM,GAAK,YAGf,IAAIC,EAAY,KAChB,MAAMC,EAAaZ,EAAWC,SAAS,WAAmB,QAAAY,EAAAT,UAAAL,OAANe,EAAI,IAAA3B,MAAA0B,GAAAE,EAAA,EAAAA,EAAAF,EAAAE,IAAJD,EAAIC,GAAAX,UAAAW,GACpDxB,EAASW,EAAKc,MAAMC,KAAMH,GAC1BH,EAAY,IAChB,EAAGR,EAAY,CAAEO,UACXQ,EAAY,WAAmB,QAAAC,EAAAf,UAAAL,OAANe,EAAI,IAAA3B,MAAAgC,GAAAC,EAAA,EAAAA,EAAAD,EAAAC,IAAJN,EAAIM,GAAAhB,UAAAgB,GAC/B,OAAe,MAAXX,IACkB,OAAdE,IACAA,EAAYU,KAAKC,OAEjBD,KAAKC,MAAQX,GAAaF,IAC1BlB,EAASW,EAAKc,MAAMC,KAAMH,GAC1BH,EAAYU,KAAKC,MACjBV,EAAWW,SACXX,EAAWY,WACJjC,IAGfqB,EAAWI,MAAMC,KAAMH,GAChBvB,EACX,EAOA,OAFA2B,EAAUK,OAASX,EAAWW,OAC9BL,EAAUO,MALIA,KACVb,EAAWa,QACJlC,GAIJ2B,CACX,C,8BC7CArC,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMC,EAAWW,EAAQ,KACnB6B,EAAQ7B,EAAQ,MA4EtBjB,EAAQ+C,OA1ER,SAAgBC,GACZ,GAAIzC,MAAMC,QAAQwC,GACd,OAAOA,EAAQvC,IAAIqC,EAAMA,OAE7B,GAAuB,kBAAZE,EACP,MAAO,CAACA,GAGZ,MAAMrC,EAAS,GACTQ,GAFN6B,EAAU1C,EAASA,SAAS0C,IAEL7B,OACvB,GAAe,IAAXA,EACA,OAAOR,EAEX,IAAIsC,EAAQ,EACRC,EAAM,GACNC,EAAY,GACZC,GAAU,EAKd,IAJ8B,KAA1BJ,EAAQK,WAAW,KACnB1C,EAAO2C,KAAK,IACZL,KAEGA,EAAQ9B,GAAQ,CACnB,MAAMoC,EAAOP,EAAQC,GACjBE,EACa,OAATI,GAAiBN,EAAQ,EAAI9B,GAC7B8B,IACAC,GAAOF,EAAQC,IAEVM,IAASJ,EACdA,EAAY,GAGZD,GAAOK,EAGNH,EACQ,MAATG,GAAyB,MAATA,EAChBJ,EAAYI,EAEE,MAATA,GACLH,GAAU,EACVzC,EAAO2C,KAAKJ,GACZA,EAAM,IAGNA,GAAOK,EAIE,MAATA,GACAH,GAAU,EACNF,IACAvC,EAAO2C,KAAKJ,GACZA,EAAM,KAGI,MAATK,EACDL,IACAvC,EAAO2C,KAAKJ,GACZA,EAAM,IAIVA,GAAOK,EAGfN,GACJ,CAIA,OAHIC,GACAvC,EAAO2C,KAAKJ,GAETvC,CACX,C,8BC5EEZ,EAAOC,QAAU,EAAjBD,K,8BCDFE,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMa,EAAcD,EAAQ,MACtBuC,EAAevC,EAAQ,MAM7BjB,EAAQyD,kBAJR,SAA2BpD,GACvB,OAAOmD,EAAaA,aAAanD,IAAUa,EAAYA,YAAYb,EACvE,C,8BCTa,IAAAqD,EAAAzC,EAAAA,MAAAA,QAEbhB,OAAOC,eAAeF,EAASG,OAAOC,YAAa,CAAEC,MAAO,WAE5D,MAAMU,EAAWE,EAAQ,KACnB0C,EAAc1C,EAAQ,MACtB2C,EAAK3C,EAAQ,MAcnB,SAAS4C,EAAoBC,EAAQC,EAAQC,EAASC,GAClD,GAAIF,IAAWD,EACX,OAAO,EAEX,cAAeC,GACX,IAAK,SACD,OAoBZ,SAAuBD,EAAQC,EAAQC,EAASC,GAC5C,GAAc,MAAVF,EACA,OAAO,EAEX,GAAIxD,MAAMC,QAAQuD,GACd,OAAOG,EAAaJ,EAAQC,EAAQC,EAASC,GAEjD,GAAIF,aAAkBI,IAClB,OAuCR,SAAoBL,EAAQC,EAAQC,EAASC,GACzC,GAAoB,IAAhBF,EAAOK,KACP,OAAO,EAEX,KAAMN,aAAkBK,KACpB,OAAO,EAEX,IAAK,MAAOjB,EAAKmB,KAAgBN,EAAOO,UAAW,CAG/C,IAAgB,IADAN,EADIF,EAAOS,IAAIrB,GACMmB,EAAanB,EAAKY,EAAQC,EAAQE,GAEnE,OAAO,CAEf,CACA,OAAO,CACX,CAtDeO,CAAWV,EAAQC,EAAQC,EAASC,GAE/C,GAAIF,aAAkBU,IAClB,OAAOC,EAAWZ,EAAQC,EAAQC,EAASC,GAE/C,MAAMU,EAAO1E,OAAO0E,KAAKZ,GACzB,GAAc,MAAVD,EACA,OAAuB,IAAhBa,EAAKxD,OAEhB,GAAoB,IAAhBwD,EAAKxD,OACL,OAAO,EAEX,GAAS,OAAL8C,QAAK,IAALA,GAAAA,EAAOW,IAAIb,GACX,OAAOE,EAAMM,IAAIR,KAAYD,EAE5B,OAALG,QAAK,IAALA,GAAAA,EAAOY,IAAId,EAAQD,GACnB,IACI,IAAK,IAAIgB,EAAI,EAAGA,EAAIH,EAAKxD,OAAQ2D,IAAK,CAClC,MAAM5B,EAAMyB,EAAKG,GACjB,IAAKnB,EAAYA,YAAYG,MAAaZ,KAAOY,GAC7C,OAAO,EAEX,QAAoBrC,IAAhBsC,EAAOb,SAAsCzB,IAAhBqC,EAAOZ,GACpC,OAAO,EAEX,GAAoB,OAAhBa,EAAOb,IAAiC,OAAhBY,EAAOZ,GAC/B,OAAO,EAGX,IADgBc,EAAQF,EAAOZ,GAAMa,EAAOb,GAAMA,EAAKY,EAAQC,EAAQE,GAEnE,OAAO,CAEf,CACA,OAAO,CACX,CAAC,QAEQ,OAALA,QAAK,IAALA,GAAAA,EAAOc,OAAOhB,EAClB,CACJ,CAlEmBiB,CAAclB,EAAQC,EAAQC,EAASC,GAElD,IAAK,WAED,OADmBhE,OAAO0E,KAAKZ,GAChB5C,OAAS,EACb0C,EAAoBC,EAAMJ,EAAA,GAAOK,GAAUC,EAASC,GAExDL,EAAGA,GAAGE,EAAQC,GAEzB,QACI,OAAKhD,EAASA,SAAS+C,GAGD,kBAAXC,GACW,KAAXA,EAHAH,EAAGA,GAAGE,EAAQC,GAQrC,CAgEA,SAASG,EAAaJ,EAAQC,EAAQC,EAASC,GAC3C,GAAsB,IAAlBF,EAAO5C,OACP,OAAO,EAEX,IAAKZ,MAAMC,QAAQsD,GACf,OAAO,EAEX,MAAMmB,EAAe,IAAIR,IACzB,IAAK,IAAIK,EAAI,EAAGA,EAAIf,EAAO5C,OAAQ2D,IAAK,CACpC,MAAMI,EAAanB,EAAOe,GAC1B,IAAIK,GAAQ,EACZ,IAAK,IAAIC,EAAI,EAAGA,EAAItB,EAAO3C,OAAQiE,IAAK,CACpC,GAAIH,EAAaL,IAAIQ,GACjB,SAGJ,IAAIC,GAAU,EAKd,GAJgBrB,EAFGF,EAAOsB,GAEUF,EAAYJ,EAAGhB,EAAQC,EAAQE,KAE/DoB,GAAU,GAEVA,EAAS,CACTJ,EAAaK,IAAIF,GACjBD,GAAQ,EACR,KACJ,CACJ,CACA,IAAKA,EACD,OAAO,CAEf,CACA,OAAO,CACX,CACA,SAAST,EAAWZ,EAAQC,EAAQC,EAASC,GACzC,OAAoB,IAAhBF,EAAOK,MAGLN,aAAkBW,KAGjBP,EAAa,IAAIJ,GAAS,IAAIC,GAASC,EAASC,EAC3D,CAEAjE,EAAQuF,YAhJR,SAASA,EAAYzB,EAAQC,EAAQC,GACjC,MAAuB,oBAAZA,EACAuB,EAAYzB,EAAQC,EAAQ,QAEhCF,EAAoBC,EAAQC,EAAQ,SAASyB,EAAUCLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"static/js/main.ca674b04.js","mappings":"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 truncated
|
||||
Generated
+1
-22
@@ -11,20 +11,19 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-router-dom": "^7.10.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.5.1",
|
||||
"skywatch": "file:..",
|
||||
"web-vitals": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"..": {
|
||||
"name": "skywatch",
|
||||
"version": "1.0.0",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
@@ -3472,22 +3471,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "13.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
|
||||
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@@ -15334,10 +15317,6 @@
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/skywatch": {
|
||||
"resolved": "..",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
|
||||
@@ -6,14 +6,12 @@
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-router-dom": "^7.10.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.5.1",
|
||||
"skywatch": "file:..",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
+19
-1
@@ -393,6 +393,16 @@
|
||||
}
|
||||
|
||||
/* Mobile Responsive Styles */
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 0.75rem 1rem;
|
||||
@@ -434,7 +444,7 @@
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -496,6 +506,14 @@
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
+338
-10
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
@@ -42,17 +42,16 @@ function Dashboard() {
|
||||
sla_target: 99.9,
|
||||
email_to: '',
|
||||
maintenance_mode: false,
|
||||
tags: '',
|
||||
consecutive_failures_threshold: 1,
|
||||
response_time_threshold: null,
|
||||
});
|
||||
const [tagFilter, setTagFilter] = useState('');
|
||||
const [groupData, setGroupData] = useState({ name: '', description: '' });
|
||||
const wsRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
loadResources();
|
||||
const interval = setInterval(loadResources, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadResources = async () => {
|
||||
const loadResources = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/dashboard');
|
||||
setResources(response.data.resources);
|
||||
@@ -60,8 +59,63 @@ function Dashboard() {
|
||||
} catch (error) {
|
||||
console.error('Error loading resources:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connectWebSocket = useCallback(() => {
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
// Send initial ping to keep alive
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'dashboard') {
|
||||
setResources(message.data.resources || []);
|
||||
setGroups(message.data.groups || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected, will use fallback polling');
|
||||
wsRef.current = null;
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
} catch (error) {
|
||||
console.error('Error connecting WebSocket:', error);
|
||||
loadResources();
|
||||
}
|
||||
}, [loadResources]);
|
||||
|
||||
useEffect(() => {
|
||||
// Try to connect via WebSocket
|
||||
connectWebSocket();
|
||||
|
||||
// Fallback: poll every 15 seconds if WebSocket isn't available
|
||||
const fallbackInterval = setInterval(loadResources, 15000);
|
||||
|
||||
return () => {
|
||||
clearInterval(fallbackInterval);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connectWebSocket, loadResources]);
|
||||
|
||||
const handleCreateGroup = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -95,6 +149,9 @@ function Dashboard() {
|
||||
sla_target: 99.9,
|
||||
email_to: '',
|
||||
maintenance_mode: false,
|
||||
tags: '',
|
||||
consecutive_failures_threshold: 1,
|
||||
response_time_threshold: null,
|
||||
});
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
@@ -132,6 +189,48 @@ function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCSV = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/resources/export', {
|
||||
responseType: 'blob'
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `resources-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentChild.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error('Error exporting resources:', error);
|
||||
alert('Error exporting resources');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCSV = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const response = await axios.post('/api/resources/import', text, {
|
||||
headers: { 'Content-Type': 'text/csv' }
|
||||
});
|
||||
|
||||
alert(`Imported ${response.data.count} resources successfully!`);
|
||||
if (response.data.errors.length > 0) {
|
||||
alert(`Errors:\n${response.data.errors.join('\n')}`);
|
||||
}
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
console.error('Error importing resources:', error);
|
||||
alert('Error importing resources');
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId) => {
|
||||
setCollapsedGroups(prev => ({
|
||||
...prev,
|
||||
@@ -139,7 +238,15 @@ function Dashboard() {
|
||||
}));
|
||||
};
|
||||
|
||||
const groupResourcesMap = resources.reduce((acc, r) => {
|
||||
// Filter resources by tag
|
||||
const filteredResources = tagFilter
|
||||
? resources.filter(r => {
|
||||
const tags = (r.tags || '').toLowerCase();
|
||||
return tags.includes(tagFilter.toLowerCase());
|
||||
})
|
||||
: resources;
|
||||
|
||||
const groupResourcesMap = filteredResources.reduce((acc, r) => {
|
||||
const gid = r.group_id || 'ungrouped';
|
||||
if (!acc[gid]) acc[gid] = [];
|
||||
acc[gid].push(r);
|
||||
@@ -247,13 +354,33 @@ function Dashboard() {
|
||||
<div className="container">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h2>SkyWatch Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by tag"
|
||||
value={tagFilter}
|
||||
onChange={(e) => setTagFilter(e.target.value)}
|
||||
style={{ padding: '0.5rem 0.75rem', borderRadius: '4px', border: '1px solid #ddd', minWidth: '160px' }}
|
||||
/>
|
||||
<button className="btn btn-secondary" onClick={() => setShowGroupModal(true)}>
|
||||
+ New Group
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
|
||||
+ Add Resource
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleExportCSV}>
|
||||
⬇ Export CSV
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => document.getElementById('csv-import').click()}>
|
||||
⬆ Import CSV
|
||||
</button>
|
||||
<input
|
||||
id="csv-import"
|
||||
type="file"
|
||||
accept=".csv"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleImportCSV}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -262,6 +389,11 @@ function Dashboard() {
|
||||
<h3>No resources yet</h3>
|
||||
<p>Add your first resource to start monitoring</p>
|
||||
</div>
|
||||
) : filteredResources.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<h3>No resources match filter</h3>
|
||||
<p>Try adjusting your tag filter</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{groups.map((group) => {
|
||||
@@ -511,6 +643,39 @@ function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Tags (comma-separated, e.g., frontend,production,critical)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter tags to organize resources"
|
||||
value={formData.tags || ''}
|
||||
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Consecutive Failures Before Alert</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.consecutive_failures_threshold || 1}
|
||||
onChange={(e) => setFormData({ ...formData, consecutive_failures_threshold: parseInt(e.target.value) })}
|
||||
/>
|
||||
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert only after this many consecutive failures</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Response Time Threshold (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="e.g., 2000"
|
||||
value={formData.response_time_threshold || ''}
|
||||
onChange={(e) => setFormData({ ...formData, response_time_threshold: e.target.value ? parseInt(e.target.value) : null })}
|
||||
/>
|
||||
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert if response time exceeds this value</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
@@ -673,6 +838,39 @@ function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Tags (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter tags to organize resources"
|
||||
value={editData.tags || ''}
|
||||
onChange={(e) => setEditData({ ...editData, tags: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Consecutive Failures Before Alert</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={editData.consecutive_failures_threshold || 1}
|
||||
onChange={(e) => setEditData({ ...editData, consecutive_failures_threshold: parseInt(e.target.value) })}
|
||||
/>
|
||||
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert only after this many consecutive failures</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Response Time Threshold (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="e.g., 2000"
|
||||
value={editData.response_time_threshold || ''}
|
||||
onChange={(e) => setEditData({ ...editData, response_time_threshold: e.target.value ? parseInt(e.target.value) : null })}
|
||||
/>
|
||||
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert if response time exceeds this value</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={() => setShowEditModal(false)}>
|
||||
Cancel
|
||||
@@ -712,11 +910,20 @@ function ResourceDetail() {
|
||||
const [slaLoading, setSlaLoading] = useState(false);
|
||||
const slaWindow = 24;
|
||||
|
||||
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
|
||||
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
||||
const [maintenanceForm, setMaintenanceForm] = useState({
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
reason: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadResource();
|
||||
loadChecks();
|
||||
loadIncidents();
|
||||
loadSla();
|
||||
loadMaintenanceWindows();
|
||||
const interval = setInterval(loadResource, 5000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -793,6 +1000,41 @@ function ResourceDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadMaintenanceWindows = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
|
||||
setMaintenanceWindows(response.data.windows || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading maintenance windows:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateMaintenanceWindow = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await axios.post(`/api/resources/${id}/maintenance-windows`, maintenanceForm);
|
||||
setShowMaintenanceModal(false);
|
||||
setMaintenanceForm({ start_time: '', end_time: '', reason: '' });
|
||||
loadMaintenanceWindows();
|
||||
alert('Maintenance window created');
|
||||
} catch (error) {
|
||||
console.error('Error creating maintenance window:', error);
|
||||
alert('Error creating maintenance window');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMaintenanceWindow = async (windowId) => {
|
||||
if (window.confirm('Delete this maintenance window?')) {
|
||||
try {
|
||||
await axios.delete(`/api/maintenance-windows/${windowId}`);
|
||||
loadMaintenanceWindows();
|
||||
} catch (error) {
|
||||
console.error('Error deleting maintenance window:', error);
|
||||
alert('Error deleting maintenance window');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (window.confirm('Are you sure you want to delete this resource?')) {
|
||||
try {
|
||||
@@ -1055,6 +1297,92 @@ function ResourceDetail() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h2>Maintenance Windows</h2>
|
||||
<button className="btn btn-primary" onClick={() => setShowMaintenanceModal(true)}>
|
||||
+ Schedule Maintenance
|
||||
</button>
|
||||
</div>
|
||||
{maintenanceWindows.length === 0 ? (
|
||||
<p style={{ color: '#666', textAlign: 'center', padding: '2rem' }}>No maintenance windows scheduled</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Start Time</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>End Time</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Reason</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'right' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{maintenanceWindows.map(window => (
|
||||
<tr key={window.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.75rem' }}>{formatLocalTime(window.start_time)}</td>
|
||||
<td style={{ padding: '0.75rem' }}>{formatLocalTime(window.end_time)}</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666' }}>{window.reason || '-'}</td>
|
||||
<td style={{ padding: '0.75rem', textAlign: 'right' }}>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ padding: '0.3rem 0.6rem', fontSize: '0.85rem' }}
|
||||
onClick={() => handleDeleteMaintenanceWindow(window.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMaintenanceModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowMaintenanceModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Schedule Maintenance Window</h2>
|
||||
<form onSubmit={handleCreateMaintenanceWindow}>
|
||||
<div className="form-group">
|
||||
<label>Start Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
required
|
||||
value={maintenanceForm.start_time}
|
||||
onChange={(e) => setMaintenanceForm({ ...maintenanceForm, start_time: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>End Time *</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
required
|
||||
value={maintenanceForm.end_time}
|
||||
onChange={(e) => setMaintenanceForm({ ...maintenanceForm, end_time: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Reason (Optional)</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
placeholder="Planned maintenance, server upgrade, etc."
|
||||
value={maintenanceForm.reason}
|
||||
onChange={(e) => setMaintenanceForm({ ...maintenanceForm, reason: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={() => setShowMaintenanceModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Schedule
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+118
-4
@@ -20,6 +20,7 @@ function History() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAveraged, setShowAveraged] = useState(true);
|
||||
const [showTrends, setShowTrends] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
@@ -33,7 +34,6 @@ function History() {
|
||||
const response = await axios.get('/api/history/overview', {
|
||||
params: { days, page: currentPage, limit: pageSize, averaged: showAveraged }
|
||||
});
|
||||
console.log('History API Response:', response.data);
|
||||
|
||||
let data = [];
|
||||
let total = 0;
|
||||
@@ -47,7 +47,22 @@ function History() {
|
||||
total = data.length;
|
||||
}
|
||||
|
||||
console.log('Processed history data:', { data, total });
|
||||
// Fetch trends data if enabled
|
||||
if (showTrends && data.length > 0) {
|
||||
const trendsPromises = data.map(async (resource) => {
|
||||
try {
|
||||
const trendsResponse = await axios.get(`/api/resources/${resource.id}/trends`, {
|
||||
params: { days }
|
||||
});
|
||||
return { ...resource, trendsData: trendsResponse.data };
|
||||
} catch (err) {
|
||||
console.error(`Error loading trends for resource ${resource.id}:`, err);
|
||||
return resource;
|
||||
}
|
||||
});
|
||||
data = await Promise.all(trendsPromises);
|
||||
}
|
||||
|
||||
setHistoryData(data);
|
||||
setTotalItems(total);
|
||||
setLoading(false);
|
||||
@@ -59,7 +74,7 @@ function History() {
|
||||
};
|
||||
|
||||
loadHistory();
|
||||
}, [days, currentPage, pageSize, showAveraged]);
|
||||
}, [days, currentPage, pageSize, showAveraged, showTrends]);
|
||||
|
||||
if (loading) return <div className="container">Loading history...</div>;
|
||||
if (error) return <div className="container">Error loading history: {error}</div>;
|
||||
@@ -89,6 +104,15 @@ function History() {
|
||||
/>
|
||||
<span>Show Averages</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTrends}
|
||||
onChange={(e) => setShowTrends(e.target.checked)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span>Show Week-over-Week Trends</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +126,6 @@ function History() {
|
||||
{historyData.map((resource) => {
|
||||
// Server already handles averaging when averaged=true is passed
|
||||
const chartData = prepareChartData(resource.checks || []);
|
||||
console.log(`${resource.name} - Total data points:`, chartData.length, 'Averaged mode:', showAveraged);
|
||||
|
||||
return (
|
||||
<div key={resource.id} className="detail-section" style={{ marginBottom: '3rem', background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
||||
@@ -188,6 +211,74 @@ function History() {
|
||||
<div style={{ color: '#999', padding: '2rem', textAlign: 'center', background: '#fafafa', borderRadius: '6px' }}>No check data available</div>
|
||||
)}
|
||||
|
||||
{showTrends && resource.trendsData && (
|
||||
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
|
||||
<h4 style={{ marginBottom: '1rem', color: '#667eea' }}>Week-over-Week Comparison</h4>
|
||||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.9rem' }}>
|
||||
<div>
|
||||
<strong>Response Time Change:</strong>{' '}
|
||||
<span style={{ color: resource.trendsData.comparison.response_time_change >= 0 ? '#f44336' : '#4caf50' }}>
|
||||
{resource.trendsData.comparison.response_time_change >= 0 ? '+' : ''}
|
||||
{resource.trendsData.comparison.response_time_change.toFixed(1)}ms
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Uptime Change:</strong>{' '}
|
||||
<span style={{ color: resource.trendsData.comparison.uptime_change >= 0 ? '#4caf50' : '#f44336' }}>
|
||||
{resource.trendsData.comparison.uptime_change >= 0 ? '+' : ''}
|
||||
{resource.trendsData.comparison.uptime_change.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
<ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#666' }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
|
||||
tick={{ fontSize: 12, fill: '#666' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
|
||||
formatter={(value, name) => {
|
||||
if (name === 'Current Period') return `${value}ms`;
|
||||
if (name === 'Previous Period') return `${value}ms`;
|
||||
return value;
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ paddingTop: '20px' }} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="currentResponseTime"
|
||||
fill="#667eea"
|
||||
stroke="#667eea"
|
||||
name="Current Period"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="previousResponseTime"
|
||||
fill="#95a5a6"
|
||||
stroke="#95a5a6"
|
||||
strokeDasharray="5 5"
|
||||
name="Previous Period"
|
||||
fillOpacity={0.1}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.checks?.length || 0}</p>
|
||||
@@ -269,4 +360,27 @@ function prepareChartData(checks) {
|
||||
});
|
||||
}
|
||||
|
||||
function prepareTrendsChartData(trendsData) {
|
||||
if (!trendsData || !trendsData.current || !trendsData.previous) return [];
|
||||
|
||||
const maxLength = Math.max(
|
||||
trendsData.current.data.length,
|
||||
trendsData.previous.data.length
|
||||
);
|
||||
|
||||
const result = [];
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const currentDay = trendsData.current.data[i];
|
||||
const previousDay = trendsData.previous.data[i];
|
||||
|
||||
result.push({
|
||||
day: `Day ${i + 1}`,
|
||||
currentResponseTime: currentDay ? currentDay.avg_response_time : null,
|
||||
previousResponseTime: previousDay ? previousDay.avg_response_time : null,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default History;
|
||||
@@ -56,12 +56,73 @@ db.exec(`
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maintenance_windows (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_id INTEGER NOT NULL,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
reason TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_id INTEGER NOT NULL,
|
||||
consecutive_failures_threshold INTEGER DEFAULT 1,
|
||||
response_time_threshold INTEGER,
|
||||
response_time_baseline INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transaction_checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_id INTEGER NOT NULL,
|
||||
step_order INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
method TEXT DEFAULT 'GET',
|
||||
headers TEXT,
|
||||
body TEXT,
|
||||
expected_status INTEGER DEFAULT 200,
|
||||
keyword TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archived_checks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
response_time INTEGER,
|
||||
status_code INTEGER,
|
||||
error_message TEXT,
|
||||
details TEXT,
|
||||
checked_at DATETIME NOT NULL,
|
||||
archived_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE,
|
||||
UNIQUE(resource_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_resource_id ON checks(resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_checked_at ON checks(checked_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_resource_checked ON checks(resource_id, checked_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_resource_id ON incidents(resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_resolved ON incidents(resource_id, resolved_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_group_id ON resources(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_maintenance_windows_resource ON maintenance_windows(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_archived_checks_resource_time ON archived_checks(resource_id, checked_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_tags_resource ON resource_tags(resource_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_tags_tag ON resource_tags(tag);
|
||||
`);
|
||||
|
||||
// Lightweight migrations - for existing databases
|
||||
@@ -137,6 +198,57 @@ try {
|
||||
db.prepare("ALTER TABLE checks ADD COLUMN cert_expiry_date TEXT").run();
|
||||
} catch (err) {}
|
||||
|
||||
// New columns for features
|
||||
try {
|
||||
db.prepare("ALTER TABLE resources ADD COLUMN tags TEXT").run();
|
||||
console.log('[DB] Added tags column to resources');
|
||||
} catch (err) {
|
||||
if (err.message.includes('duplicate')) {
|
||||
console.log('[DB] tags column already exists');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare("ALTER TABLE resources ADD COLUMN consecutive_failures_threshold INTEGER DEFAULT 1").run();
|
||||
console.log('[DB] Added consecutive_failures_threshold column to resources');
|
||||
} catch (err) {
|
||||
if (err.message.includes('duplicate')) {
|
||||
console.log('[DB] consecutive_failures_threshold column already exists');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare("ALTER TABLE resources ADD COLUMN response_time_threshold INTEGER").run();
|
||||
console.log('[DB] Added response_time_threshold column to resources');
|
||||
} catch (err) {
|
||||
if (err.message.includes('duplicate')) {
|
||||
console.log('[DB] response_time_threshold column already exists');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare("ALTER TABLE resources ADD COLUMN response_time_baseline INTEGER").run();
|
||||
console.log('[DB] Added response_time_baseline column to resources');
|
||||
} catch (err) {
|
||||
if (err.message.includes('duplicate')) {
|
||||
console.log('[DB] response_time_baseline column already exists');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare("ALTER TABLE resources ADD COLUMN is_transaction INTEGER DEFAULT 0").run();
|
||||
console.log('[DB] Added is_transaction column to resources');
|
||||
} catch (err) {
|
||||
if (err.message.includes('duplicate')) {
|
||||
console.log('[DB] is_transaction column already exists');
|
||||
}
|
||||
}
|
||||
|
||||
// Settings for data retention
|
||||
try {
|
||||
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('retention_days', '30')").run();
|
||||
} catch (err) {}
|
||||
|
||||
// Helpful indexes for time-range queries
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC);
|
||||
|
||||
+596
-19
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const WebSocket = require('ws');
|
||||
const http = require('http');
|
||||
require('dotenv').config();
|
||||
|
||||
const db = require('./database');
|
||||
@@ -12,6 +14,9 @@ const cache = require('./cache');
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// WebSocket clients
|
||||
const wsClients = new Set();
|
||||
|
||||
// Helper to get timezone offset for SQL queries
|
||||
function getTimezoneOffset() {
|
||||
const tz = process.env.TIMEZONE || 'UTC';
|
||||
@@ -66,13 +71,10 @@ function getTimezoneOffset() {
|
||||
if (offsetHours > 12) offsetHours -= 24;
|
||||
if (offsetHours < -12) offsetHours += 24;
|
||||
|
||||
console.log(`Timezone offset for ${tz}: ${offsetHours} hours (UTC ${utcHour}:00, TZ ${tzHour}:00)`);
|
||||
|
||||
// SQL datetime() function: we need the OPPOSITE sign
|
||||
// If local is 5 hours behind UTC, we need to ADD 5 hours to go backwards (counterintuitive but correct for SQLite)
|
||||
const sqlOffset = -offsetHours;
|
||||
const result = sqlOffset > 0 ? `+${sqlOffset} hours` : `${sqlOffset} hours`;
|
||||
console.log(`SQL offset calculation: offsetHours=${offsetHours}, sqlOffset=${sqlOffset}, result="${result}"`);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -446,6 +448,93 @@ THEME=${theme || 'light'}
|
||||
}
|
||||
});
|
||||
|
||||
// Get retention settings
|
||||
app.get('/api/settings/retention', (req, res) => {
|
||||
try {
|
||||
const setting = db.prepare(`
|
||||
SELECT value FROM settings WHERE key = 'retention_days'
|
||||
`).get();
|
||||
|
||||
res.json({
|
||||
retention_days: setting ? parseInt(setting.value) : 30
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting retention settings:', error);
|
||||
res.status(500).json({ error: 'Failed to get retention settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update retention settings
|
||||
app.post('/api/settings/retention', (req, res) => {
|
||||
const { retention_days } = req.body;
|
||||
|
||||
if (!retention_days || retention_days < 1 || retention_days > 365) {
|
||||
return res.status(400).json({ error: 'Retention days must be between 1 and 365' });
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES ('retention_days', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = ?
|
||||
`).run(String(retention_days), String(retention_days));
|
||||
|
||||
res.json({ message: 'Retention settings updated', retention_days });
|
||||
} catch (error) {
|
||||
console.error('Error updating retention settings:', error);
|
||||
res.status(500).json({ error: 'Failed to update retention settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get archived checks for a resource
|
||||
app.get('/api/resources/:id/archived', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { days = 30, page = 1, limit = 100 } = req.query;
|
||||
const pageNum = Math.max(1, parseInt(page));
|
||||
const pageLimit = Math.min(500, Math.max(10, parseInt(limit)));
|
||||
const offset = (pageNum - 1) * pageLimit;
|
||||
|
||||
try {
|
||||
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(id);
|
||||
if (!resource) {
|
||||
return res.status(404).json({ error: 'Resource not found' });
|
||||
}
|
||||
|
||||
const countResult = db.prepare(`
|
||||
SELECT COUNT(*) as total FROM archived_checks
|
||||
WHERE resource_id = ? AND archived_at > datetime('now', ?)
|
||||
`).get(id, `-${days} days`);
|
||||
|
||||
const archived = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
status,
|
||||
response_time,
|
||||
status_code,
|
||||
error_message,
|
||||
details,
|
||||
REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at,
|
||||
REPLACE(archived_at, ' ', 'T') || 'Z' as archived_at
|
||||
FROM archived_checks
|
||||
WHERE resource_id = ? AND archived_at > datetime('now', ?)
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(id, `-${days} days`, pageLimit, offset);
|
||||
|
||||
res.json({
|
||||
archived,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: pageLimit,
|
||||
total: countResult.total,
|
||||
totalPages: Math.ceil(countResult.total / pageLimit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching archived checks:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch archived checks' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get historical check data for a resource (paginated)
|
||||
app.get('/api/resources/:id/history', (req, res) => {
|
||||
const { id } = req.params;
|
||||
@@ -559,10 +648,80 @@ app.get('/api/resources/:id/sla', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get trends data (week-over-week comparison)
|
||||
app.get('/api/resources/:id/trends', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { days = 7 } = req.query;
|
||||
|
||||
try {
|
||||
// Get current period checks
|
||||
const currentChecks = db.prepare(`
|
||||
SELECT
|
||||
AVG(response_time) as avg_response_time,
|
||||
COUNT(*) as total_checks,
|
||||
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as successful_checks,
|
||||
date(checked_at) as check_date
|
||||
FROM checks
|
||||
WHERE resource_id = ? AND checked_at > datetime('now', ?)
|
||||
GROUP BY date(checked_at)
|
||||
ORDER BY check_date ASC
|
||||
`).all(id, `-${days} days`);
|
||||
|
||||
// Get previous period checks (for comparison)
|
||||
const previousChecks = db.prepare(`
|
||||
SELECT
|
||||
AVG(response_time) as avg_response_time,
|
||||
COUNT(*) as total_checks,
|
||||
SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END) as successful_checks,
|
||||
date(checked_at) as check_date
|
||||
FROM checks
|
||||
WHERE resource_id = ? AND checked_at BETWEEN datetime('now', ?) AND datetime('now', ?)
|
||||
GROUP BY date(checked_at)
|
||||
ORDER BY check_date ASC
|
||||
`).all(id, `-${days * 2} days`, `-${days} days`);
|
||||
|
||||
// Calculate summary statistics
|
||||
const currentAvg = currentChecks.length > 0
|
||||
? currentChecks.reduce((sum, c) => sum + (c.avg_response_time || 0), 0) / currentChecks.length
|
||||
: 0;
|
||||
|
||||
const previousAvg = previousChecks.length > 0
|
||||
? previousChecks.reduce((sum, c) => sum + (c.avg_response_time || 0), 0) / previousChecks.length
|
||||
: 0;
|
||||
|
||||
const currentUptime = currentChecks.length > 0
|
||||
? (currentChecks.reduce((sum, c) => sum + c.successful_checks, 0) / currentChecks.reduce((sum, c) => sum + c.total_checks, 0)) * 100
|
||||
: 0;
|
||||
|
||||
const previousUptime = previousChecks.length > 0
|
||||
? (previousChecks.reduce((sum, c) => sum + c.successful_checks, 0) / previousChecks.reduce((sum, c) => sum + c.total_checks, 0)) * 100
|
||||
: 0;
|
||||
|
||||
res.json({
|
||||
current: {
|
||||
data: currentChecks,
|
||||
avg_response_time: Math.round(currentAvg),
|
||||
uptime: currentUptime.toFixed(2)
|
||||
},
|
||||
previous: {
|
||||
data: previousChecks,
|
||||
avg_response_time: Math.round(previousAvg),
|
||||
uptime: previousUptime.toFixed(2)
|
||||
},
|
||||
comparison: {
|
||||
response_time_change: previousAvg > 0 ? ((currentAvg - previousAvg) / previousAvg * 100).toFixed(1) : 0,
|
||||
uptime_change: (currentUptime - previousUptime).toFixed(2)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching trends:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch trends' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all resources' check history for dashboard (optimized with aggregation)
|
||||
app.get('/api/history/overview', (req, res) => {
|
||||
const { days = 7, page = 1, page: pageParam, averaged = 'false' } = req.query;
|
||||
console.log('History overview request:', { days, averaged, type: typeof averaged, timestamp: new Date().toISOString() });
|
||||
// Resource pagination (number of resources per page)
|
||||
const pageLimit = parseInt(req.query.limit || 10);
|
||||
const currentPage = Math.max(1, parseInt(pageParam || page || 1));
|
||||
@@ -570,15 +729,12 @@ app.get('/api/history/overview', (req, res) => {
|
||||
|
||||
// Create cache key based on query parameters
|
||||
const cacheKey = `history:days=${days}:page=${currentPage}:limit=${pageLimit}:averaged=${averaged}`;
|
||||
console.log('Cache key:', cacheKey);
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = cache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
console.log('Returning cached result');
|
||||
return res.json(cachedResult);
|
||||
}
|
||||
console.log('No cache hit, querying database');
|
||||
|
||||
// Get total count of enabled resources
|
||||
const totalCount = db.prepare('SELECT COUNT(*) as count FROM resources WHERE enabled = 1').get();
|
||||
@@ -606,13 +762,10 @@ app.get('/api/history/overview', (req, res) => {
|
||||
|
||||
let recentChecks = [];
|
||||
const isAveraged = String(averaged).toLowerCase() === 'true';
|
||||
console.log(`Resource ${resource.name}: averaged=${averaged}, isAveraged=${isAveraged}`);
|
||||
|
||||
if (isAveraged) {
|
||||
console.log(`Using averaged mode for ${resource.name}`);
|
||||
// Compute interval hours for bucketing (1h for 7 days, 3h for 14, 6h for 30+)
|
||||
const intervalHours = days <= 7 ? 1 : days <= 14 ? 3 : 6;
|
||||
console.log(`Bucketing with ${intervalHours} hour intervals`);
|
||||
|
||||
// Use a reliable bucketing approach with Julian Day Numbers
|
||||
// Convert to julian day, multiply by 24 for hours, divide by intervalHours and round
|
||||
@@ -633,9 +786,7 @@ app.get('/api/history/overview', (req, res) => {
|
||||
response_time: Math.round(row.avg_up_response || 0),
|
||||
checked_at: row.checked_at,
|
||||
}));
|
||||
console.log(`${resource.name}: Returned ${recentChecks.length} averaged data points`);
|
||||
} else {
|
||||
console.log(`Using non-averaged mode for ${resource.name}`);
|
||||
// Non-averaged: filter to window but cap to last 600 checks
|
||||
recentChecks = db.prepare(`
|
||||
SELECT status, response_time, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
|
||||
@@ -644,7 +795,6 @@ app.get('/api/history/overview', (req, res) => {
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT 600
|
||||
`).all(resource.id, `-${days} days`).reverse();
|
||||
console.log(`${resource.name}: Returned ${recentChecks.length} non-averaged data points`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -698,15 +848,12 @@ app.get('/api/sla', (req, res) => {
|
||||
const effectiveDays = Math.min(daysNum, retentionDays);
|
||||
const limitedByRetention = effectiveDays < daysNum;
|
||||
const cacheKey = `sla:days=${daysNum}:eff=${effectiveDays}:page=${currentPage}:limit=${pageLimit}`;
|
||||
console.log('SLA request:', { days: daysNum, effectiveDays, limitedByRetention, page: currentPage, limit: pageLimit, cacheKey });
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = cache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
console.log('SLA: Cache hit');
|
||||
return res.json(cachedResult);
|
||||
}
|
||||
console.log('SLA: Cache miss, querying database');
|
||||
|
||||
// Get total count
|
||||
const totalCount = db.prepare('SELECT COUNT(*) as count FROM resources WHERE enabled = 1').get();
|
||||
@@ -734,7 +881,6 @@ app.get('/api/sla', (req, res) => {
|
||||
FROM incidents
|
||||
WHERE resource_id = ? AND started_at > datetime('now', ?)
|
||||
`).get(resource.id, `-${effectiveDays} days`);
|
||||
console.log(`SLA ${resource.name}: ${checks.length} checks in ${effectiveDays} days (requested ${daysNum})`);
|
||||
|
||||
return {
|
||||
resource_id: resource.id,
|
||||
@@ -854,6 +1000,346 @@ app.get('/api/debug/timezone', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Get maintenance windows for a resource
|
||||
app.get('/api/resources/:id/maintenance-windows', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const windows = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
resource_id,
|
||||
REPLACE(start_time, ' ', 'T') || 'Z' as start_time,
|
||||
REPLACE(end_time, ' ', 'T') || 'Z' as end_time,
|
||||
reason,
|
||||
REPLACE(created_at, ' ', 'T') || 'Z' as created_at
|
||||
FROM maintenance_windows
|
||||
WHERE resource_id = ?
|
||||
ORDER BY start_time DESC
|
||||
`).all(id);
|
||||
|
||||
res.json({ windows });
|
||||
} catch (error) {
|
||||
console.error('Error fetching maintenance windows:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch maintenance windows' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create maintenance window
|
||||
app.post('/api/resources/:id/maintenance-windows', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { start_time, end_time, reason } = req.body;
|
||||
|
||||
if (!start_time || !end_time) {
|
||||
return res.status(400).json({ error: 'start_time and end_time are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO maintenance_windows (resource_id, start_time, end_time, reason)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(id, start_time, end_time, reason || '');
|
||||
|
||||
res.json({
|
||||
id: result.lastInsertRowid,
|
||||
message: 'Maintenance window created'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating maintenance window:', error);
|
||||
res.status(500).json({ error: 'Failed to create maintenance window' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete maintenance window
|
||||
app.delete('/api/maintenance-windows/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
db.prepare(`DELETE FROM maintenance_windows WHERE id = ?`).run(id);
|
||||
res.json({ message: 'Maintenance window deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting maintenance window:', error);
|
||||
res.status(500).json({ error: 'Failed to delete maintenance window' });
|
||||
}
|
||||
});
|
||||
|
||||
// Check if resource is in maintenance window
|
||||
app.get('/api/resources/:id/in-maintenance', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString().split('.')[0];
|
||||
const window = db.prepare(`
|
||||
SELECT id, reason FROM maintenance_windows
|
||||
WHERE resource_id = ? AND start_time <= ? AND end_time > ?
|
||||
LIMIT 1
|
||||
`).get(id, now, now);
|
||||
|
||||
res.json({
|
||||
in_maintenance: !!window,
|
||||
reason: window?.reason || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking maintenance status:', error);
|
||||
res.status(500).json({ error: 'Failed to check maintenance status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate and update response time baseline
|
||||
app.post('/api/resources/:id/calculate-baseline', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { days = 7 } = req.body;
|
||||
|
||||
try {
|
||||
const baseline = monitorService.calculateResponseTimeBaseline(id, days);
|
||||
|
||||
if (baseline === null) {
|
||||
return res.status(400).json({ error: 'Not enough data to calculate baseline' });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE resources
|
||||
SET response_time_baseline = ?
|
||||
WHERE id = ?
|
||||
`).run(baseline, id);
|
||||
|
||||
res.json({
|
||||
baseline,
|
||||
message: `Baseline calculated: ${baseline}ms`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error calculating baseline:', error);
|
||||
res.status(500).json({ error: 'Failed to calculate baseline' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update alert rules for a resource
|
||||
app.post('/api/resources/:id/alert-rules', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { consecutive_failures_threshold, response_time_threshold } = req.body;
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
UPDATE resources
|
||||
SET consecutive_failures_threshold = ?, response_time_threshold = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
consecutive_failures_threshold || 1,
|
||||
response_time_threshold || null,
|
||||
id
|
||||
);
|
||||
|
||||
res.json({ message: 'Alert rules updated' });
|
||||
} catch (error) {
|
||||
console.error('Error updating alert rules:', error);
|
||||
res.status(500).json({ error: 'Failed to update alert rules' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get transaction steps for a resource
|
||||
app.get('/api/resources/:id/transaction-steps', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const steps = db.prepare(`
|
||||
SELECT * FROM transaction_checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY step_order ASC
|
||||
`).all(id);
|
||||
|
||||
res.json({ steps });
|
||||
} catch (error) {
|
||||
console.error('Error fetching transaction steps:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch transaction steps' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create or update transaction step
|
||||
app.post('/api/resources/:id/transaction-steps', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { step_order, url, method, headers, body, expected_status, keyword } = req.body;
|
||||
|
||||
if (!step_order || !url) {
|
||||
return res.status(400).json({ error: 'step_order and url are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO transaction_checks (resource_id, step_order, url, method, headers, body, expected_status, keyword)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
id,
|
||||
step_order,
|
||||
url,
|
||||
method || 'GET',
|
||||
headers || null,
|
||||
body || null,
|
||||
expected_status || 200,
|
||||
keyword || null
|
||||
);
|
||||
|
||||
res.json({ id: result.lastInsertRowid, message: 'Transaction step created' });
|
||||
} catch (error) {
|
||||
console.error('Error creating transaction step:', error);
|
||||
res.status(500).json({ error: 'Failed to create transaction step' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete transaction step
|
||||
app.delete('/api/transaction-steps/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
db.prepare('DELETE FROM transaction_checks WHERE id = ?').run(id);
|
||||
res.json({ message: 'Transaction step deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting transaction step:', error);
|
||||
res.status(500).json({ error: 'Failed to delete transaction step' });
|
||||
}
|
||||
});
|
||||
|
||||
// Enable/disable transaction mode for a resource
|
||||
app.post('/api/resources/:id/toggle-transaction', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { enabled } = req.body;
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
UPDATE resources SET is_transaction = ? WHERE id = ?
|
||||
`).run(enabled ? 1 : 0, id);
|
||||
|
||||
res.json({ message: `Transaction mode ${enabled ? 'enabled' : 'disabled'}` });
|
||||
} catch (error) {
|
||||
console.error('Error toggling transaction mode:', error);
|
||||
res.status(500).json({ error: 'Failed to toggle transaction mode' });
|
||||
}
|
||||
});
|
||||
|
||||
// Export resources as CSV
|
||||
app.get('/api/resources/export', (req, res) => {
|
||||
try {
|
||||
const resources = db.prepare('SELECT * FROM resources ORDER BY name').all();
|
||||
|
||||
if (resources.length === 0) {
|
||||
return res.json({ csv: 'name,url,type,check_interval,timeout,sla_target\n' });
|
||||
}
|
||||
|
||||
// CSV header
|
||||
const headers = ['name', 'url', 'type', 'check_interval', 'timeout', 'sla_target', 'tags', 'group_id', 'email_to'];
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
// CSV rows
|
||||
resources.forEach(resource => {
|
||||
const row = [
|
||||
`"${(resource.name || '').replace(/"/g, '""')}"`,
|
||||
`"${(resource.url || '').replace(/"/g, '""')}"`,
|
||||
resource.type || 'http',
|
||||
resource.check_interval || 60000,
|
||||
resource.timeout || 5000,
|
||||
resource.sla_target || 99.9,
|
||||
`"${(resource.tags || '').replace(/"/g, '""')}"`,
|
||||
resource.group_id || '',
|
||||
`"${(resource.email_to || '').replace(/"/g, '""')}"`,
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
});
|
||||
|
||||
const csv = csvRows.join('\n');
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="resources-export.csv"');
|
||||
res.send(csv);
|
||||
} catch (error) {
|
||||
console.error('Error exporting resources:', error);
|
||||
res.status(500).json({ error: 'Failed to export resources' });
|
||||
}
|
||||
});
|
||||
|
||||
// Import resources from CSV
|
||||
app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res) => {
|
||||
try {
|
||||
const csv = req.body;
|
||||
const lines = csv.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lines.length < 2) {
|
||||
return res.status(400).json({ error: 'CSV must have header and at least one resource' });
|
||||
}
|
||||
|
||||
const headers = lines[0].split(',').map(h => h.trim().toLowerCase());
|
||||
const required = ['name', 'url'];
|
||||
if (!required.every(h => headers.includes(h))) {
|
||||
return res.status(400).json({ error: `CSV must include columns: ${required.join(', ')}` });
|
||||
}
|
||||
|
||||
const imported = [];
|
||||
const errors = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
try {
|
||||
// Simple CSV parsing (handles basic quoted fields)
|
||||
const values = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let j = 0; j < lines[i].length; j++) {
|
||||
const char = lines[i][j];
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(current.trim().replace(/^"|"$/g, ''));
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current.trim().replace(/^"|"$/g, ''));
|
||||
|
||||
const row = {};
|
||||
headers.forEach((header, idx) => {
|
||||
row[header] = values[idx] || '';
|
||||
});
|
||||
|
||||
if (!row.name || !row.url) {
|
||||
errors.push(`Row ${i + 1}: Missing name or URL`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO resources (name, url, type, check_interval, timeout, sla_target, tags, group_id, email_to)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
row.name,
|
||||
row.url,
|
||||
row.type || 'http',
|
||||
parseInt(row.check_interval) || 60000,
|
||||
parseInt(row.timeout) || 5000,
|
||||
parseFloat(row.sla_target) || 99.9,
|
||||
row.tags || '',
|
||||
row.group_id ? parseInt(row.group_id) : null,
|
||||
row.email_to || ''
|
||||
);
|
||||
|
||||
imported.push(row.name);
|
||||
} catch (err) {
|
||||
errors.push(`Row ${i + 1}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
cache.invalidatePattern('history:');
|
||||
cache.invalidatePattern('sla:');
|
||||
|
||||
res.json({ imported, errors, count: imported.length });
|
||||
} catch (error) {
|
||||
console.error('Error importing resources:', error);
|
||||
res.status(500).json({ error: 'Failed to import resources' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve React app with proper MIME types
|
||||
const mimeTypes = {
|
||||
'.js': 'application/javascript',
|
||||
@@ -885,8 +1371,99 @@ app.get('*', (req, res, next) => {
|
||||
res.sendFile(path.join(__dirname, '../client/build/index.html'));
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
// WebSocket handler function
|
||||
function broadcastDashboardUpdate() {
|
||||
try {
|
||||
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
|
||||
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
|
||||
|
||||
const overview = resources.map(resource => {
|
||||
const lastCheck = monitorService.getLastCheck(resource.id);
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents
|
||||
WHERE resource_id = ? AND resolved_at IS NULL
|
||||
`).get(resource.id);
|
||||
|
||||
const recentChecks = db.prepare(`
|
||||
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
|
||||
FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT 15
|
||||
`).all(resource.id).reverse();
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
url: resource.url,
|
||||
type: resource.type,
|
||||
group_id: resource.group_id,
|
||||
enabled: resource.enabled,
|
||||
status: lastCheck?.status || 'unknown',
|
||||
uptime: stats.uptime,
|
||||
avgResponseTime: stats.avgResponseTime,
|
||||
lastCheck: lastCheck?.checked_at,
|
||||
hasActiveIncident: !!activeIncident,
|
||||
recentChecks,
|
||||
};
|
||||
});
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'dashboard',
|
||||
data: { resources: overview, groups }
|
||||
});
|
||||
|
||||
wsClients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
} else {
|
||||
wsClients.delete(client);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error broadcasting dashboard update:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Export broadcast function for use in scheduler
|
||||
global.broadcastDashboardUpdate = broadcastDashboardUpdate;
|
||||
|
||||
// Start server with HTTP and WebSocket
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocket.Server({ server });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('WebSocket client connected');
|
||||
wsClients.add(ws);
|
||||
|
||||
// Send initial dashboard data
|
||||
broadcastDashboardUpdate();
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
if (data.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling WebSocket message:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket client disconnected');
|
||||
wsClients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error.message);
|
||||
wsClients.delete(ws);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
console.log(`WebSocket available at ws://localhost:${PORT}`);
|
||||
scheduler.start();
|
||||
});
|
||||
+169
-6
@@ -27,7 +27,6 @@ function parseHostPort(urlString, fallbackPort) {
|
||||
|
||||
class MonitorService {
|
||||
async checkResource(resource) {
|
||||
const type = (resource.type || 'http').toLowerCase();
|
||||
const startTime = Date.now();
|
||||
const base = {
|
||||
resource_id: resource.id,
|
||||
@@ -38,6 +37,13 @@ class MonitorService {
|
||||
details: null,
|
||||
};
|
||||
|
||||
// Check if this is a transaction
|
||||
if (resource.is_transaction) {
|
||||
return this.checkTransaction(resource, startTime, base);
|
||||
}
|
||||
|
||||
const type = (resource.type || 'http').toLowerCase();
|
||||
|
||||
switch (type) {
|
||||
case 'http':
|
||||
case 'https':
|
||||
@@ -296,17 +302,38 @@ class MonitorService {
|
||||
}
|
||||
|
||||
handleIncident(resourceId, isDown) {
|
||||
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(resourceId);
|
||||
const threshold = resource?.consecutive_failures_threshold || 1;
|
||||
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents
|
||||
WHERE resource_id = ? AND resolved_at IS NULL
|
||||
`).get(resourceId);
|
||||
|
||||
if (isDown && !activeIncident) {
|
||||
// Start new incident
|
||||
db.prepare(`
|
||||
if (isDown) {
|
||||
// Check consecutive failures
|
||||
const recentChecks = db.prepare(`
|
||||
SELECT status FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT ?
|
||||
`).all(resourceId, threshold);
|
||||
|
||||
const consecutiveFailures = recentChecks.every(c => c.status === 'down') ? recentChecks.length :
|
||||
recentChecks.findIndex(c => c.status === 'up') + 1;
|
||||
|
||||
// Only trigger incident if we've hit the threshold
|
||||
if (consecutiveFailures >= threshold && !activeIncident) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO incidents (resource_id) VALUES (?)
|
||||
`).run(resourceId);
|
||||
return { type: 'started', incident: true };
|
||||
`);
|
||||
const result = stmt.run(resourceId);
|
||||
return {
|
||||
type: 'started',
|
||||
incident: true,
|
||||
consecutiveFailures
|
||||
};
|
||||
}
|
||||
} else if (!isDown && activeIncident) {
|
||||
// Resolve incident
|
||||
db.prepare(`
|
||||
@@ -524,6 +551,142 @@ class MonitorService {
|
||||
totalChecks: checks.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Check multi-step transaction
|
||||
async checkTransaction(resource, startTime, base) {
|
||||
try {
|
||||
const steps = db.prepare(`
|
||||
SELECT * FROM transaction_checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY step_order ASC
|
||||
`).all(resource.id);
|
||||
|
||||
if (steps.length === 0) {
|
||||
return {
|
||||
...base,
|
||||
status: 'down',
|
||||
error_message: 'No transaction steps defined'
|
||||
};
|
||||
}
|
||||
|
||||
let totalTime = 0;
|
||||
const results = [];
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const stepStart = Date.now();
|
||||
|
||||
try {
|
||||
const headers = {};
|
||||
if (step.headers) {
|
||||
try {
|
||||
Object.assign(headers, JSON.parse(step.headers));
|
||||
} catch (e) {
|
||||
// Invalid JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
const body = step.body ? JSON.parse(step.body) : undefined;
|
||||
const response = await axios({
|
||||
method: step.method || 'GET',
|
||||
url: step.url,
|
||||
headers,
|
||||
data: body,
|
||||
timeout: resource.timeout,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
|
||||
const stepTime = Date.now() - stepStart;
|
||||
const statusOk = response.status === (step.expected_status || 200);
|
||||
|
||||
let keywordMatch = true;
|
||||
if (step.keyword && response.data) {
|
||||
const dataStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
|
||||
keywordMatch = dataStr.includes(step.keyword);
|
||||
}
|
||||
|
||||
if (!statusOk || !keywordMatch) {
|
||||
return {
|
||||
...base,
|
||||
status: 'down',
|
||||
response_time: Date.now() - startTime,
|
||||
status_code: response.status,
|
||||
error_message: `Transaction failed at step ${i + 1}: Expected ${step.expected_status || 200}, got ${response.status}${!keywordMatch ? ' (keyword not found)' : ''}`
|
||||
};
|
||||
}
|
||||
|
||||
results.push({
|
||||
step: i + 1,
|
||||
status: response.status,
|
||||
time: stepTime
|
||||
});
|
||||
|
||||
totalTime += stepTime;
|
||||
} catch (error) {
|
||||
return {
|
||||
...base,
|
||||
status: 'down',
|
||||
response_time: Date.now() - startTime,
|
||||
error_message: `Transaction failed at step ${i + 1}: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// All steps passed
|
||||
const responseTime = Date.now() - startTime;
|
||||
return {
|
||||
...base,
|
||||
status: 'up',
|
||||
response_time: responseTime,
|
||||
status_code: 200,
|
||||
details: JSON.stringify({ steps: steps.length, results })
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...base,
|
||||
status: 'down',
|
||||
response_time: Date.now() - startTime,
|
||||
error_message: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate baseline response time for anomaly detection
|
||||
calculateResponseTimeBaseline(resourceId, days = 7) {
|
||||
const checks = db.prepare(`
|
||||
SELECT response_time FROM checks
|
||||
WHERE resource_id = ? AND status = 'up' AND response_time IS NOT NULL
|
||||
AND checked_at > datetime('now', ?)
|
||||
ORDER BY response_time
|
||||
`).all(resourceId, `-${days} days`);
|
||||
|
||||
if (checks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return average of response times
|
||||
const sum = checks.reduce((acc, c) => acc + (c.response_time || 0), 0);
|
||||
return Math.round(sum / checks.length);
|
||||
}
|
||||
|
||||
// Check if response time is anomalous
|
||||
isResponseTimeAnomaly(resourceId, currentResponseTime, deviationPercent = 50) {
|
||||
try {
|
||||
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(resourceId);
|
||||
if (!resource || !resource.response_time_baseline) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseline = resource.response_time_baseline;
|
||||
const threshold = (baseline * (100 + deviationPercent)) / 100;
|
||||
|
||||
return currentResponseTime > threshold;
|
||||
} catch (error) {
|
||||
console.error('Error checking response time anomaly:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MonitorService();
|
||||
@@ -55,6 +55,12 @@ class NotificationService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check maintenance windows
|
||||
if (this.isInMaintenanceWindow(resource.id)) {
|
||||
console.log(`Resource ${resource.name} is in maintenance window, skipping alert`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
if (this.isQuietHours(resource)) {
|
||||
console.log(`Resource ${resource.name} is in quiet hours, skipping alert`);
|
||||
@@ -71,14 +77,12 @@ class NotificationService {
|
||||
|
||||
// Use resource-specific email if provided, otherwise fall back to global config
|
||||
if (this.emailEnabled && (resource.email_to || this.config.email_to)) {
|
||||
const chosenEmail = resource.email_to || this.config.email_to;
|
||||
console.log(`[Alert] Email target for ${resource.name}: ${chosenEmail || 'none'} (enabled=${this.emailEnabled})`);
|
||||
promises.push(this.sendEmail(resource, message, incident.type, stats));
|
||||
} else {
|
||||
if (!this.emailEnabled) {
|
||||
console.log(`[Alert] Email disabled via config; skipping email for ${resource.name}`);
|
||||
// Email globally disabled
|
||||
} else {
|
||||
console.log(`[Alert] No email target for ${resource.name}; skipping email`);
|
||||
// No email target configured
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +204,23 @@ class NotificationService {
|
||||
return graph;
|
||||
}
|
||||
|
||||
isInMaintenanceWindow(resourceId) {
|
||||
try {
|
||||
const db = require('./database');
|
||||
const now = new Date().toISOString().split('.')[0];
|
||||
const window = db.prepare(`
|
||||
SELECT id FROM maintenance_windows
|
||||
WHERE resource_id = ? AND start_time <= ? AND end_time > ?
|
||||
LIMIT 1
|
||||
`).get(resourceId, now, now);
|
||||
|
||||
return !!window;
|
||||
} catch (error) {
|
||||
console.error('Error checking maintenance window:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendWebhook(resource, message, type) {
|
||||
try {
|
||||
await axios.post(this.config.webhook_url || process.env.WEBHOOK_URL, {
|
||||
|
||||
+62
-6
@@ -14,17 +14,68 @@ class Scheduler {
|
||||
await this.runChecks();
|
||||
});
|
||||
|
||||
// Clean old checks every hour (keep last 7 days)
|
||||
cron.schedule('0 * * * *', () => {
|
||||
db.prepare(`
|
||||
DELETE FROM checks
|
||||
WHERE checked_at < datetime('now', '-7 days')
|
||||
`).run();
|
||||
// Archive old checks daily at 2 AM
|
||||
cron.schedule('0 2 * * *', () => {
|
||||
this.archiveOldChecks();
|
||||
});
|
||||
|
||||
// Also run archive on startup (with delay to let DB init)
|
||||
setTimeout(() => {
|
||||
this.archiveOldChecks();
|
||||
}, 5000);
|
||||
|
||||
console.log('Scheduler started');
|
||||
}
|
||||
|
||||
archiveOldChecks() {
|
||||
try {
|
||||
const retentionSetting = db.prepare(`
|
||||
SELECT value FROM settings WHERE key = 'retention_days'
|
||||
`).get();
|
||||
|
||||
const retentionDays = retentionSetting ? parseInt(retentionSetting.value) : 30;
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
|
||||
|
||||
// Move old checks to archived_checks
|
||||
const oldChecks = db.prepare(`
|
||||
SELECT * FROM checks WHERE checked_at < ?
|
||||
`).all(cutoffString);
|
||||
|
||||
if (oldChecks.length > 0) {
|
||||
const insertArchived = db.prepare(`
|
||||
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const deleteOld = db.prepare(`
|
||||
DELETE FROM checks WHERE id = ?
|
||||
`);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
oldChecks.forEach(check => {
|
||||
insertArchived.run(
|
||||
check.resource_id,
|
||||
check.status,
|
||||
check.response_time,
|
||||
check.status_code,
|
||||
check.error_message,
|
||||
check.details,
|
||||
check.checked_at
|
||||
);
|
||||
deleteOld.run(check.id);
|
||||
});
|
||||
});
|
||||
|
||||
transaction();
|
||||
console.log(`[Archival] Archived ${oldChecks.length} checks older than ${retentionDays} days`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Archival] Error archiving checks:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async runChecks() {
|
||||
const resources = db.prepare(`
|
||||
SELECT * FROM resources WHERE enabled = 1
|
||||
@@ -61,6 +112,11 @@ class Scheduler {
|
||||
console.error(`Error checking ${resource.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast updated dashboard to all connected WebSocket clients
|
||||
if (global.broadcastDashboardUpdate) {
|
||||
global.broadcastDashboardUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user