Add trends UI, cleanup logs, refresh build

This commit is contained in:
nmemmert committed 2025-12-16 08:48:48 -05:00
1 parent 71d1dbd5c1
commit d4e4467c0c
20 files changed
+1737 -88

No files matched your search

+283
View File
@@ -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)
+6 -6
View File
@@ -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
View File
@@ -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>
@@ -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":"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","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
@@ -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
+1 -22
View File
@@ -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",
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+112
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+25 -4
View File
@@ -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
View File
@@ -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();
}
}
}