Add HTTP method/body, alert escalation, baseline alerts, notification grouping, rate limiting, App.js split, and API tests

- HTTP method + request body: GET/POST/PUT/PATCH/DELETE/HEAD selector and body textarea for http/health checks; stored in http_method/http_body columns; monitorService sends correct axios method+data
- Alert escalation: incidents open longer than escalation_hours get forwarded to fallback_webhook and marked escalated=1; hourly cron in scheduler
- Baseline-relative slow alerts: effectiveThreshold = response_time_threshold || (baseline * 2) — adaptive threshold without manual config
- Notification grouping: pendingAlerts collected per check cycle; ≥2 alerts flush as one grouped email+webhook; individual in-app entries still saved per resource
- Rate limiting: in-memory per-IP limiter (20/min heartbeat, 60/min status-page); no external package
- App.js split: Dashboard.js (monitors list + modals) and ResourceDetail.js extracted; App.js reduced from ~1987 to ~80 lines
- formatDuration moved to utils/timeUtils.js; imported by both new components
- DB migrations: http_method, http_body, incidents.escalated columns
- 18 passing API/unit tests (node:test, zero dependencies)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-31 09:37:31 -04:00
1 parent a6cf6a9762
commit 4fea0a95f5
19 files changed
+1793 -1964

No files matched your search

+6 -6
View File
@@ -1,15 +1,15 @@
{
"files": {
"main.css": "/static/css/main.6034edfe.css",
"main.js": "/static/js/main.16dc004f.js",
"main.css": "/static/css/main.40bbff16.css",
"main.js": "/static/js/main.a387bcf0.js",
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
"index.html": "/index.html",
"main.6034edfe.css.map": "/static/css/main.6034edfe.css.map",
"main.16dc004f.js.map": "/static/js/main.16dc004f.js.map",
"main.40bbff16.css.map": "/static/css/main.40bbff16.css.map",
"main.a387bcf0.js.map": "/static/js/main.a387bcf0.js.map",
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
},
"entrypoints": [
"static/css/main.6034edfe.css",
"static/js/main.16dc004f.js"
"static/css/main.40bbff16.css",
"static/js/main.a387bcf0.js"
]
}
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.16dc004f.js"></script><link href="/static/css/main.6034edfe.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.a387bcf0.js"></script><link href="/static/css/main.40bbff16.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:#020617;color:#e2e8f0;font-family:Manrope,IBM Plex Sans,Segoe UI,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.App{background:radial-gradient(circle at 15% 0,#1e40af33 0,#0000 42%),radial-gradient(circle at 95% 10%,#0f766e24 0,#0000 36%),#020617;min-height:100vh}.navbar{backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);background:#0f172ad1;border-bottom:1px solid #334155;box-shadow:0 4px 18px #0f172a0f;color:#e2e8f0;padding:.85rem 2rem;position:-webkit-sticky;position:sticky;top:0;z-index:30}.navbar h1{font-size:1.5rem;letter-spacing:-.02em;margin:0}.navbar-inner{gap:1rem;justify-content:space-between;width:100%}.navbar-inner,.navbar-left{align-items:center;display:flex}.navbar-left{gap:1.5rem;min-width:0}.navbar-links{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.nav-link{border-radius:7px;color:#cbd5e1;font-size:.92rem;font-weight:600;padding:.4rem .7rem;text-decoration:none;transition:background .2s,color .2s,box-shadow .2s}.nav-link:hover{background:#111827;box-shadow:inset 0 0 0 1px #334155;color:#f8fafc}.settings-link{align-items:center;display:inline-flex;gap:.4rem}.setup-badge{background:#facc15;border-radius:999px;color:#111827;font-size:.68rem;font-weight:700;padding:.15rem .5rem}.notifications-pill{align-items:center;background:#111827;border:1px solid #334155;border-radius:999px;color:#e2e8f0;display:flex;font-size:.82rem;gap:.45rem;padding:.3rem .7rem}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-toolbar{align-items:center;display:flex;gap:1rem;justify-content:space-between;margin-bottom:2rem}.dashboard-title{color:#f8fafc;font-size:1.85rem;letter-spacing:-.02em;margin:0}.dashboard-subtitle{color:#94a3b8;font-size:.9rem;margin:.2rem 0 0}.dashboard-actions{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.quick-filter-row{display:flex;flex-wrap:wrap;gap:.45rem;margin:.2rem 0 .8rem}.quick-chip{background:#0f172a;border:1px solid #334155;border-radius:999px;color:#cbd5e1;cursor:pointer;font-size:.74rem;font-weight:700;letter-spacing:.02em;padding:.28rem .7rem}.quick-chip:hover{background:#111f34}.quick-chip.active{background:#14b8a6;border-color:#14b8a6;color:#052e2b}.tag-filter-input{background:#111827;border:1px solid #334155;border-radius:10px;color:#e2e8f0;min-width:180px;padding:.5rem .75rem}.actions-menu-wrapper{position:relative}.actions-menu{background:#fff;border:1px solid #dbe2ea;border-radius:8px;box-shadow:0 8px 20px #0f172a24;min-width:220px;padding:.35rem 0;position:absolute;right:0;top:110%;z-index:20}.actions-menu-divider{border:0;border-top:1px solid #eef2f6;margin:.25rem 0}.hidden-file-input{display:none}.toast-stack{position:fixed;right:20px;top:20px;z-index:9999}.toast-item{border:1px solid #0000;border-radius:8px;box-shadow:0 8px 20px #0f172a29;margin-bottom:10px;min-width:280px;padding:12px 16px}.toast-error{background:#fee2e2;border-color:#fecaca;color:#7f1d1d}.toast-success{background:#dcfce7;border-color:#bbf7d0;color:#14532d}.toast-info{background:#dbeafe;border-color:#bfdbfe;color:#1e3a8a}.cc-metrics-grid{grid-gap:.65rem;display:grid;gap:.65rem;grid-template-columns:repeat(6,minmax(0,1fr));margin-bottom:1rem}.cc-metric-card{background:linear-gradient(180deg,#0f172af5,#1e293bf5);border:1px solid #334155;border-radius:10px;color:#e2e8f0;padding:.75rem}.cc-metric-label{color:#94a3b8;font-size:.68rem;letter-spacing:.08em;margin:0;text-transform:uppercase}.cc-metric-value{font-size:1.2rem;font-weight:700;margin:.35rem 0 0}.metric-ok{border-color:#14532d}.metric-down{border-color:#7f1d1d}.metric-maint{border-color:#78350f}.cc-layout{grid-gap:.8rem;align-items:start;display:grid;gap:.8rem;grid-template-columns:minmax(0,1fr) 300px}.cc-main{min-width:0}.cc-rail{display:flex;flex-direction:column;gap:.75rem;position:-webkit-sticky;position:sticky;top:82px}.cc-panel{background:#0f172af0;border:1px solid #334155;border-radius:12px;color:#e2e8f0;padding:.75rem}.cc-panel h3{font-size:.9rem;letter-spacing:.03em;margin:0 0 .65rem}.cc-empty{color:#94a3b8;font-size:.85rem;margin:0}.cc-group-list,.cc-incident-list{display:flex;flex-direction:column;gap:.4rem}.cc-group-item,.cc-incident-item{align-items:center;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#e2e8f0;cursor:pointer;display:flex;justify-content:space-between;padding:.45rem .55rem;width:100%}.cc-group-item:hover,.cc-incident-item:hover{background:#111f34;border-color:#475569}.cc-incident-name{font-size:.85rem;font-weight:600;margin-right:.5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dashboard-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-bottom:1.5rem}.resource-list{display:flex;flex-direction:column;gap:.45rem}.resource-list-shell{background:#0f172ae6;border:1px solid #334155;border-radius:12px;max-height:calc(100vhLine truncated
/*# sourceMappingURL=main.6034edfe.css.map*/
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#020617;color:#e2e8f0;font-family:Manrope,IBM Plex Sans,Segoe UI,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.App{background:radial-gradient(circle at 15% 0,#1e40af33 0,#0000 42%),radial-gradient(circle at 95% 10%,#0f766e24 0,#0000 36%),#020617;min-height:100vh}.navbar{backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);background:#0f172ad1;border-bottom:1px solid #334155;box-shadow:0 4px 18px #0f172a0f;color:#e2e8f0;padding:.85rem 2rem;position:-webkit-sticky;position:sticky;top:0;z-index:30}.navbar h1{font-size:1.5rem;letter-spacing:-.02em;margin:0}.navbar-inner{gap:1rem;justify-content:space-between;width:100%}.navbar-inner,.navbar-left{align-items:center;display:flex}.navbar-left{gap:1.5rem;min-width:0}.navbar-links{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.nav-link{border-radius:7px;color:#cbd5e1;font-size:.92rem;font-weight:600;padding:.4rem .7rem;text-decoration:none;transition:background .2s,color .2s,box-shadow .2s}.nav-link:hover{background:#111827;box-shadow:inset 0 0 0 1px #334155;color:#f8fafc}.settings-link{align-items:center;display:inline-flex;gap:.4rem}.setup-badge{background:#facc15;border-radius:999px;color:#111827;font-size:.68rem;font-weight:700;padding:.15rem .5rem}.notifications-pill{align-items:center;background:#111827;border:1px solid #334155;border-radius:999px;color:#e2e8f0;display:flex;font-size:.82rem;gap:.45rem;padding:.3rem .7rem}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-toolbar{align-items:center;display:flex;gap:1rem;justify-content:space-between;margin-bottom:2rem}.dashboard-title{color:#f8fafc;font-size:1.85rem;letter-spacing:-.02em;margin:0}.dashboard-subtitle{color:#94a3b8;font-size:.9rem;margin:.2rem 0 0}.dashboard-actions{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.quick-filter-row{display:flex;flex-wrap:wrap;gap:.45rem;margin:.2rem 0 .8rem}.quick-chip{background:#0f172a;border:1px solid #334155;border-radius:999px;color:#cbd5e1;cursor:pointer;font-size:.74rem;font-weight:700;letter-spacing:.02em;padding:.28rem .7rem}.quick-chip:hover{background:#111f34}.quick-chip.active{background:#14b8a6;border-color:#14b8a6;color:#052e2b}.tag-filter-input{background:#111827;border:1px solid #334155;border-radius:10px;color:#e2e8f0;min-width:180px;padding:.5rem .75rem}.actions-menu-wrapper{position:relative}.actions-menu{background:#fff;border:1px solid #dbe2ea;border-radius:8px;box-shadow:0 8px 20px #0f172a24;min-width:220px;padding:.35rem 0;position:absolute;right:0;top:110%;z-index:20}.actions-menu-divider{border:0;border-top:1px solid #eef2f6;margin:.25rem 0}.hidden-file-input{display:none}.toast-stack{position:fixed;right:20px;top:20px;z-index:9999}.toast-item{border:1px solid #0000;border-radius:8px;box-shadow:0 8px 20px #0f172a29;margin-bottom:10px;min-width:280px;padding:12px 16px}.toast-error{background:#fee2e2;border-color:#fecaca;color:#7f1d1d}.toast-success{background:#dcfce7;border-color:#bbf7d0;color:#14532d}.toast-info{background:#dbeafe;border-color:#bfdbfe;color:#1e3a8a}.cc-metrics-grid{grid-gap:.65rem;display:grid;gap:.65rem;grid-template-columns:repeat(6,minmax(0,1fr));margin-bottom:1rem}.cc-metric-card{background:linear-gradient(180deg,#0f172af5,#1e293bf5);border:1px solid #334155;border-radius:10px;color:#e2e8f0;padding:.75rem}.cc-metric-label{color:#94a3b8;font-size:.68rem;letter-spacing:.08em;margin:0;text-transform:uppercase}.cc-metric-value{font-size:1.2rem;font-weight:700;margin:.35rem 0 0}.metric-ok{border-color:#14532d}.metric-down{border-color:#7f1d1d}.metric-maint{border-color:#78350f}.cc-layout{grid-gap:.8rem;align-items:start;display:grid;gap:.8rem;grid-template-columns:minmax(0,1fr) 300px}.cc-main{min-width:0}.cc-rail{display:flex;flex-direction:column;gap:.75rem;position:-webkit-sticky;position:sticky;top:82px}.cc-panel{background:#0f172af0;border:1px solid #334155;border-radius:12px;color:#e2e8f0;padding:.75rem}.cc-panel h3{font-size:.9rem;letter-spacing:.03em;margin:0 0 .65rem}.cc-empty{color:#94a3b8;font-size:.85rem;margin:0}.cc-group-list,.cc-incident-list{display:flex;flex-direction:column;gap:.4rem}.cc-group-item,.cc-incident-item{align-items:center;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#e2e8f0;cursor:pointer;display:flex;justify-content:space-between;padding:.45rem .55rem;width:100%}.cc-group-item:hover,.cc-incident-item:hover{background:#111f34;border-color:#475569}.cc-incident-name{font-size:.85rem;font-weight:600;margin-right:.5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dashboard-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-bottom:1.5rem}.resource-list{display:flex;flex-direction:column;gap:.45rem}.resource-list-shell{background:#0f172ae6;border:1px solid #334155;border-radius:12px;max-height:calc(100vhLine truncated
/*# sourceMappingURL=main.40bbff16.css.map*/
@@ -0,0 +1 @@
{"version":3,"file":"static/css/main.40bbff16.css","mappings":"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 truncated
@@ -1 +0,0 @@
{"version":3,"file":"static/css/main.6034edfe.css","mappings":"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 truncated
@@ -1 +0,0 @@
{"version":3,"file":"static/js/main.16dc004f.js","mappings":"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 truncated
@@ -1,3 +1,3 @@
/*! For license information please see main.16dc004f.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.16dc004f.js.map
/*! For license information please see main.a387bcf0.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.a387bcf0.js.map
@@ -0,0 +1 @@
{"version":3,"file":"static/js/main.a387bcf0.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
+5 -1909
View File
File diff suppressed because it is too large. Load diff
+909
View File
@@ -0,0 +1,909 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import { LineChart, Line, ResponsiveContainer } from 'recharts';
import NotificationCenter from './NotificationCenter';
import { formatLocalTime } from './utils/timeUtils';
const HTTP_METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH'];
function Dashboard() {
const [resources, setResources] = useState([]);
const [groups, setGroups] = useState([]);
const [showModal, setShowModal] = useState(false);
const [showGroupModal, setShowGroupModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [actionsOpen, setActionsOpen] = useState(false);
const [editData, setEditData] = useState({});
const [notifications, setNotifications] = useState([]);
const [formData, setFormData] = useState({
name: '',
url: '',
type: 'http',
check_interval: 60000,
timeout: 5000,
group_id: null,
http_keyword: '',
http_headers: '',
quiet_hours_start: '',
quiet_hours_end: '',
cert_expiry_days: 30,
sla_target: 99.9,
email_to: '',
maintenance_mode: false,
tags: '',
consecutive_failures_threshold: 1,
response_time_threshold: null,
is_public: true,
heartbeat_timeout: 300000,
http_method: 'GET',
http_body: '',
});
const [searchText, setSearchText] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [groupFilter, setGroupFilter] = useState('all');
const [quickFilter, setQuickFilter] = useState('all');
const [sortKey, setSortKey] = useState('severity');
const [renderLimit, setRenderLimit] = useState(120);
const [groupData, setGroupData] = useState({ name: '', description: '' });
const wsRef = useRef(null);
const wsReconnectTimer = useRef(null);
const wsReconnectDelay = useRef(1000);
const actionsRef = useRef(null);
const navigate = useNavigate();
const showNotification = (title, message, type = 'info') => {
const id = Date.now();
setNotifications(prev => [...prev, { id, title, message, type }]);
setTimeout(() => {
setNotifications(prev => prev.filter(n => n.id !== id));
}, 5000);
};
const loadResources = useCallback(async () => {
try {
const response = await axios.get('/api/dashboard');
setResources(response.data.resources);
setGroups(response.data.groups || []);
} catch (error) {
// Error loading resources handled
}
}, []);
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 = () => {
wsReconnectDelay.current = 1000;
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 || []);
} else if (message.type === 'alert') {
const alert = message.data;
showNotification(`Alert: ${alert.resourceName}`, alert.message, 'error');
} else if (message.type === 'incident') {
const incident = message.data;
if (incident.type === 'started') {
showNotification('Incident Started', `${incident.resourceName} is down`, 'error');
} else if (incident.type === 'resolved') {
showNotification('Incident Resolved', `${incident.resourceName} is back up`, 'success');
}
loadResources();
}
} catch (error) {
// Parse error handled silently
}
};
ws.onerror = () => {};
ws.onclose = () => {
wsRef.current = null;
wsReconnectTimer.current = setTimeout(() => {
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
connectWebSocket();
}, wsReconnectDelay.current);
};
wsRef.current = ws;
} catch (error) {
loadResources();
}
}, [loadResources]);
useEffect(() => {
const onDocClick = (e) => {
if (!actionsRef.current) return;
if (!actionsRef.current.contains(e.target)) setActionsOpen(false);
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
useEffect(() => {
connectWebSocket();
const fallbackInterval = setInterval(loadResources, 15000);
return () => {
clearInterval(fallbackInterval);
clearTimeout(wsReconnectTimer.current);
if (wsRef.current) wsRef.current.close();
};
}, [connectWebSocket, loadResources]);
const emptyForm = {
name: '', url: '', type: 'http', check_interval: 60000, timeout: 5000,
group_id: null, http_keyword: '', http_headers: '', quiet_hours_start: '',
quiet_hours_end: '', cert_expiry_days: 30, sla_target: 99.9, email_to: '',
maintenance_mode: false, tags: '', consecutive_failures_threshold: 1,
response_time_threshold: null, is_public: true, heartbeat_timeout: 300000,
http_method: 'GET', http_body: '',
};
const handleCreateGroup = async (e) => {
e.preventDefault();
try {
await axios.post('/api/groups', groupData);
setShowGroupModal(false);
setGroupData({ name: '', description: '' });
loadResources();
} catch (error) {
showNotification('Error', 'Failed to create group', 'error');
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const res = await axios.post('/api/resources', formData);
setShowModal(false);
setFormData(emptyForm);
if (res.data.heartbeat_token) {
const pingUrl = `${window.location.origin}/api/heartbeat/${res.data.heartbeat_token}`;
showNotification('Heartbeat Created', `Ping URL: ${pingUrl}`, 'success');
}
loadResources();
} catch (error) {
showNotification('Error', 'Failed to add resource', 'error');
}
};
const handleEditResource = async (id) => {
try {
const resource = resources.find(r => r.id === id);
if (editData.group_id !== resource?.group_id) {
await axios.patch(`/api/resources/${id}/group`, { group_id: editData.group_id || null });
}
await axios.put(`/api/resources/${id}`, editData);
setShowEditModal(false);
setEditData({});
loadResources();
} catch (error) {
showNotification('Error', 'Failed to update resource', 'error');
}
};
const openEditModal = (resource) => {
setEditData(resource);
setShowEditModal(true);
};
const handleDeleteResource = async (id) => {
if (window.confirm('Delete this resource?')) {
try {
await axios.delete(`/api/resources/${id}`);
loadResources();
} catch (error) {
showNotification('Error', 'Failed to delete resource', 'error');
}
}
};
const handleToggleMaintenance = async (resource) => {
try {
await axios.patch(`/api/resources/${resource.id}/maintenance-mode`, {
maintenance_mode: !resource.maintenance_mode,
});
loadResources();
} catch (error) {
showNotification('Error', 'Failed to update maintenance mode', 'error');
}
};
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.parentNode.removeChild(link);
} catch (error) {
showNotification('Error', 'Failed to export resources', 'error');
}
};
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' },
});
showNotification('Import Complete', `Imported ${response.data.count} resource(s)`, 'success');
if (response.data.errors.length > 0) showNotification('Import Warnings', response.data.errors[0], 'warning');
loadResources();
} catch (error) {
showNotification('Error', 'Failed to import resources', 'error');
}
e.target.value = '';
};
const groupNameById = groups.reduce((acc, g) => { acc[g.id] = g.name; return acc; }, {});
const getTrend = (recentChecks = []) => {
const withTimes = recentChecks.filter((c) => typeof c.response_time === 'number' && c.response_time > 0);
if (withTimes.length < 2) return 'flat';
const first = withTimes[0].response_time;
const last = withTimes[withTimes.length - 1].response_time;
if (first <= 0) return 'flat';
const deltaPct = ((last - first) / first) * 100;
if (deltaPct <= -12) return 'better';
if (deltaPct >= 12) return 'worse';
return 'flat';
};
const getSeverityScore = (r) => {
const statusScore = r.status === 'down' ? 1000 : r.status === 'unknown' ? 400 : 0;
const incidentScore = r.hasActiveIncident ? 350 : 0;
const maintenanceOffset = r.maintenance_mode ? -300 : 0;
const uptimePenalty = Math.max(0, 100 - parseFloat(r.uptime || 0)) * 3;
const responsePenalty = Math.min(250, Math.round((parseInt(r.avgResponseTime || 0, 10) || 0) / 20));
return statusScore + incidentScore + uptimePenalty + responsePenalty + maintenanceOffset;
};
const filteredResources = resources.filter((r) => {
if (searchText) {
const q = searchText.toLowerCase();
if (!r.name.toLowerCase().includes(q) && !r.url.toLowerCase().includes(q)) return false;
}
if (tagFilter) {
const tags = (r.tags || '').toLowerCase();
if (!tags.includes(tagFilter.toLowerCase())) return false;
}
if (groupFilter === 'ungrouped') return !r.group_id;
if (groupFilter !== 'all') return String(r.group_id || '') === groupFilter;
if (quickFilter === 'down' && r.status !== 'down') return false;
if (quickFilter === 'maintenance' && !r.maintenance_mode) return false;
if (quickFilter === 'nodata' && (!r.recentChecks || r.recentChecks.length === 0)) return false;
if (quickFilter === 'slow' && (parseInt(r.avgResponseTime || 0, 10) || 0) < 1500) return false;
return true;
});
const sortedResources = [...filteredResources].sort((a, b) => {
if (sortKey === 'name') return String(a.name || '').localeCompare(String(b.name || ''));
if (sortKey === 'status') {
const rank = { down: 0, unknown: 1, up: 2 };
return (rank[a.status] ?? 3) - (rank[b.status] ?? 3);
}
if (sortKey === 'uptime') return parseFloat(a.uptime || 0) - parseFloat(b.uptime || 0);
if (sortKey === 'response') return (parseInt(b.avgResponseTime || 0, 10) || 0) - (parseInt(a.avgResponseTime || 0, 10) || 0);
if (sortKey === 'lastcheck') {
const ta = a.lastCheck ? new Date(a.lastCheck).getTime() : 0;
const tb = b.lastCheck ? new Date(b.lastCheck).getTime() : 0;
return tb - ta;
}
const severityDiff = getSeverityScore(b) - getSeverityScore(a);
if (severityDiff !== 0) return severityDiff;
const groupA = (groupNameById[a.group_id] || 'Ungrouped').toLowerCase();
const groupB = (groupNameById[b.group_id] || 'Ungrouped').toLowerCase();
if (groupA < groupB) return -1;
if (groupA > groupB) return 1;
return String(a.name || '').localeCompare(String(b.name || ''));
});
useEffect(() => { setRenderLimit(120); }, [searchText, tagFilter, groupFilter, quickFilter, sortKey]);
const visibleResources = sortedResources.slice(0, renderLimit);
const totalCount = sortedResources.length;
const upCount = sortedResources.filter((r) => r.status === 'up').length;
const downCount = sortedResources.filter((r) => r.status === 'down').length;
const maintenanceCount = sortedResources.filter((r) => r.maintenance_mode).length;
const avgUptime = totalCount
? (sortedResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / totalCount).toFixed(2)
: '0.00';
const avgResponse = totalCount
? Math.round(sortedResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0, 10), 0) / totalCount)
: 0;
const activeIncidents = sortedResources.filter((r) => r.hasActiveIncident || r.status === 'down').slice(0, 8);
const groupCounts = groups
.map((g) => ({
id: g.id,
name: g.name,
count: sortedResources.filter((r) => String(r.group_id || '') === String(g.id)).length,
}))
.filter((g) => g.count > 0);
const ungroupedCount = sortedResources.filter((r) => !r.group_id).length;
const renderResourceRow = (resource) => {
const sparkData = (resource.recentChecks || []).map((c, idx) => ({
idx,
responseTime: c.response_time || 0,
statusValue: c.status === 'up' ? 1 : 0,
}));
const groupName = groupNameById[resource.group_id] || 'Ungrouped';
const trend = getTrend(resource.recentChecks || []);
return (
<div
key={resource.id}
className="resource-row"
onClick={(e) => !e.target.closest('.row-actions') && navigate(`/resource/${resource.id}`)}
>
<div className="row-main">
<div className="row-title-line">
<h3 className="resource-name row-name">{resource.name}</h3>
<span className="resource-group-pill">{groupName}</span>
{resource.hasActiveIncident && <span className="incident-inline">active incident</span>}
{resource.maintenance_mode && <span className="maintenance-inline">🛠 maintenance</span>}
{resource.type === 'tls' && resource.certDaysRemaining !== null && resource.certDaysRemaining <= 30 && (
<span
className="maintenance-inline"
style={{ background: resource.certDaysRemaining <= 7 ? '#d32f2f' : '#f57c00', color: '#fff' }}
title={`SSL cert expires in ${resource.certDaysRemaining} days`}
>
🔒 cert exp {resource.certDaysRemaining}d
</span>
)}
</div>
<p className="resource-url row-url">{resource.type === 'heartbeat' ? '(heartbeat)' : resource.url}</p>
<p className="resource-type row-type">{resource.type}</p>
</div>
<div className="row-metrics">
<div>
<p className="stat-value small">{resource.uptime}%</p>
<p className="stat-label">Uptime</p>
</div>
<div>
<p className="stat-value small">
{resource.avgResponseTime}ms
<span className={`trend-chip trend-${trend}`}>{trend === 'better' ? '↓' : trend === 'worse' ? '↑' : '→'}</span>
</p>
<p className="stat-label">Avg Resp</p>
</div>
<div className="row-sparkline">
{sparkData.length > 1 ? (
<ResponsiveContainer width="100%" height={44}>
<LineChart data={sparkData} margin={{ top: 2, bottom: 0, left: 0, right: 0 }}>
<Line type="monotone" dataKey="responseTime" stroke="#14b8a6" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<span className="last-check">No recent data</span>
)}
</div>
</div>
<div className="row-side">
<span className={`status-badge status-${resource.status}`}>{resource.status}</span>
<div className="row-actions">
<button className="btn-icon action-edit" title="Edit" onClick={(e) => { e.stopPropagation(); openEditModal(resource); }}>✎</button>
<button
className="btn-icon action-maint"
title={resource.maintenance_mode ? 'End maintenance' : 'Start maintenance'}
onClick={(e) => { e.stopPropagation(); handleToggleMaintenance(resource); }}
>
{resource.maintenance_mode ? '✅' : '🛠'}
</button>
<button className="btn-icon action-delete" title="Delete" onClick={(e) => { e.stopPropagation(); handleDeleteResource(resource.id); }}>🗑</button>
</div>
<p className="last-check">Last: {resource.lastCheck ? formatLocalTime(resource.lastCheck) : 'Never'}</p>
</div>
</div>
);
};
const HttpMethodBodyFields = ({ data, setData }) => {
const isHttpType = ['http', 'https', 'health'].includes(data.type);
if (!isHttpType) return null;
return (
<>
<div className="form-group">
<label>HTTP Method</label>
<select
value={data.http_method || 'GET'}
onChange={(e) => setData({ ...data, http_method: e.target.value, http_body: HTTP_METHODS_WITH_BODY.includes(e.target.value) ? (data.http_body || '') : '' })}
>
{['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
{HTTP_METHODS_WITH_BODY.includes(data.http_method || 'GET') && (
<div className="form-group">
<label>Request Body (JSON or plain text)</label>
<textarea
value={data.http_body || ''}
onChange={(e) => setData({ ...data, http_body: e.target.value })}
placeholder='{"key": "value"}'
rows="3"
/>
</div>
)}
</>
);
};
return (
<div className="container">
{notifications.length > 0 && (
<div className="toast-stack">
{notifications.map(notif => (
<div key={notif.id} className={`toast-item toast-${notif.type}`}>
<strong>{notif.title}</strong>
<div>{notif.message}</div>
</div>
))}
</div>
)}
<div className="dashboard-toolbar">
<div>
<h2 className="dashboard-title">Command Center</h2>
<p className="dashboard-subtitle">Live operational view of all monitors</p>
</div>
<div className="dashboard-actions">
<NotificationCenter />
<input
type="text"
placeholder="Search monitors..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="tag-filter-input"
/>
<input
type="text"
placeholder="Filter by tag"
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
className="tag-filter-input"
/>
<select value={groupFilter} onChange={(e) => setGroupFilter(e.target.value)} className="tag-filter-input">
<option value="all">All groups</option>
{groups.map((g) => <option key={g.id} value={String(g.id)}>{g.name}</option>)}
<option value="ungrouped">Ungrouped</option>
</select>
<select value={sortKey} onChange={(e) => setSortKey(e.target.value)} className="tag-filter-input">
<option value="severity">Sort: Severity</option>
<option value="status">Sort: Status</option>
<option value="uptime">Sort: Uptime</option>
<option value="response">Sort: Response</option>
<option value="lastcheck">Sort: Last Check</option>
<option value="name">Sort: Name</option>
</select>
<div ref={actionsRef} className="actions-menu-wrapper">
<button className="btn btn-secondary" onClick={() => setActionsOpen((o) => !o)} aria-haspopup="menu" aria-expanded={actionsOpen}>
Actions ▾
</button>
{actionsOpen && (
<div role="menu" className="actions-menu">
<button className="btn btn-ghost" onClick={() => { setActionsOpen(false); setShowGroupModal(true); }} role="menuitem">+ New Group</button>
<button className="btn btn-ghost" onClick={() => { setActionsOpen(false); setShowModal(true); }} role="menuitem">+ Add Resource</button>
<hr className="actions-menu-divider" />
<button className="btn btn-ghost" onClick={() => { setActionsOpen(false); handleExportCSV(); }} role="menuitem">⬇ Export CSV</button>
<button className="btn btn-ghost" onClick={() => { setActionsOpen(false); document.getElementById('csv-import').click(); }} role="menuitem">⬆ Import CSV</button>
</div>
)}
</div>
<input id="csv-import" type="file" accept=".csv" className="hidden-file-input" onChange={handleImportCSV} />
</div>
</div>
<div className="quick-filter-row">
<button className={`quick-chip ${quickFilter === 'all' ? 'active' : ''}`} onClick={() => setQuickFilter('all')}>All</button>
<button className={`quick-chip ${quickFilter === 'down' ? 'active' : ''}`} onClick={() => setQuickFilter('down')}>Down</button>
<button className={`quick-chip ${quickFilter === 'maintenance' ? 'active' : ''}`} onClick={() => setQuickFilter('maintenance')}>Maintenance</button>
<button className={`quick-chip ${quickFilter === 'slow' ? 'active' : ''}`} onClick={() => setQuickFilter('slow')}>High Latency</button>
<button className={`quick-chip ${quickFilter === 'nodata' ? 'active' : ''}`} onClick={() => setQuickFilter('nodata')}>No Data</button>
</div>
<div className="cc-metrics-grid">
<div className="cc-metric-card"><p className="cc-metric-label">Monitors</p><p className="cc-metric-value">{totalCount}</p></div>
<div className="cc-metric-card metric-ok"><p className="cc-metric-label">Up</p><p className="cc-metric-value">{upCount}</p></div>
<div className="cc-metric-card metric-down"><p className="cc-metric-label">Down</p><p className="cc-metric-value">{downCount}</p></div>
<div className="cc-metric-card metric-maint"><p className="cc-metric-label">Maintenance</p><p className="cc-metric-value">{maintenanceCount}</p></div>
<div className="cc-metric-card"><p className="cc-metric-label">Avg Uptime</p><p className="cc-metric-value">{avgUptime}%</p></div>
<div className="cc-metric-card"><p className="cc-metric-label">Avg Resp</p><p className="cc-metric-value">{avgResponse}ms</p></div>
</div>
{resources.length === 0 ? (
<div className="empty-state">
<h3>No resources yet</h3>
<p>Add your first resource to start monitoring</p>
</div>
) : sortedResources.length === 0 ? (
<div className="empty-state">
<h3>No resources match filter</h3>
<p>Try adjusting your tag/group filters</p>
</div>
) : (
<div className="cc-layout">
<section className="resource-list-shell cc-main">
<div className="resource-list-header">
<div>Resource</div>
<div>Metrics</div>
<div>Status</div>
</div>
<div className="resource-list-count">{visibleResources.length} of {sortedResources.length} monitors</div>
<div className="resource-list">
{visibleResources.map(renderResourceRow)}
{visibleResources.length < sortedResources.length && (
<button className="load-more-btn" onClick={() => setRenderLimit((prev) => prev + 120)}>Load 120 more</button>
)}
</div>
</section>
<aside className="cc-rail">
<div className="cc-panel">
<h3>Active Incidents</h3>
{activeIncidents.length === 0 ? (
<p className="cc-empty">No active incidents</p>
) : (
<div className="cc-incident-list">
{activeIncidents.map((incident) => (
<button key={incident.id} className="cc-incident-item" onClick={() => navigate(`/resource/${incident.id}`)}>
<span className="cc-incident-name">{incident.name}</span>
<span className={`status-badge status-${incident.status}`}>{incident.status}</span>
</button>
))}
</div>
)}
</div>
<div className="cc-panel">
<h3>Groups</h3>
<div className="cc-group-list">
{groupCounts.map((g) => (
<button key={g.id} className="cc-group-item" onClick={() => setGroupFilter(String(g.id))}>
<span>{g.name}</span>
<span>{g.count}</span>
</button>
))}
{ungroupedCount > 0 && (
<button className="cc-group-item" onClick={() => setGroupFilter('ungrouped')}>
<span>Ungrouped</span>
<span>{ungroupedCount}</span>
</button>
)}
</div>
</div>
</aside>
</div>
)}
{showGroupModal && (
<div className="modal-overlay" onClick={() => setShowGroupModal(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Create New Group</h2>
<form onSubmit={handleCreateGroup}>
<div className="form-group">
<label>Group Name *</label>
<input type="text" value={groupData.name} onChange={(e) => setGroupData({ ...groupData, name: e.target.value })} required placeholder="e.g., Production Servers" />
</div>
<div className="form-group">
<label>Description</label>
<input type="text" value={groupData.description} onChange={(e) => setGroupData({ ...groupData, description: e.target.value })} placeholder="Optional description" />
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowGroupModal(false)}>Cancel</button>
<button type="submit" className="btn btn-primary">Create Group</button>
</div>
</form>
</div>
</div>
)}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Add New Resource</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name *</label>
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required placeholder="e.g., ZimaOS Home Server" />
</div>
{formData.type !== 'heartbeat' && (
<div className="form-group">
<label>URL *</label>
<input type="url" value={formData.url} onChange={(e) => setFormData({ ...formData, url: e.target.value })} required={formData.type !== 'heartbeat'} placeholder="https://your-zima.example.com" />
</div>
)}
<div className="form-group">
<label>Group</label>
<select value={formData.group_id || ''} onChange={(e) => setFormData({ ...formData, group_id: e.target.value ? parseInt(e.target.value) : null })}>
<option value="">Ungrouped</option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Check Type</label>
<select value={formData.type} onChange={(e) => setFormData({ ...formData, type: e.target.value })}>
<option value="http">HTTP/HTTPS</option>
<option value="health">Service Health</option>
<option value="tcp">TCP Port</option>
<option value="tls">TLS/SSL</option>
<option value="dns">DNS Lookup</option>
<option value="websocket">WebSocket</option>
<option value="icmp">ICMP Ping</option>
<option value="heartbeat">Heartbeat (cron job)</option>
</select>
</div>
{formData.type === 'heartbeat' && (
<div className="form-group">
<label>Heartbeat Timeout (ms)</label>
<input type="number" value={formData.heartbeat_timeout} onChange={(e) => setFormData({ ...formData, heartbeat_timeout: parseInt(e.target.value) })} min="10000" step="1000" />
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert if no ping received within this window (default 5 min)</small>
</div>
)}
<div className="form-group">
<label>Check Interval (ms)</label>
<input type="number" value={formData.check_interval} onChange={(e) => setFormData({ ...formData, check_interval: parseInt(e.target.value) })} min="10000" />
</div>
{formData.type !== 'heartbeat' && (
<div className="form-group">
<label>Timeout (ms)</label>
<input type="number" value={formData.timeout} onChange={(e) => setFormData({ ...formData, timeout: parseInt(e.target.value) })} min="1000" />
</div>
)}
{(formData.type === 'http' || formData.type === 'https' || formData.type === 'health') && (
<>
<div className="form-group">
<label>Keyword to Match (optional)</label>
<input type="text" value={formData.http_keyword} onChange={(e) => setFormData({ ...formData, http_keyword: e.target.value })} placeholder="Text that must appear in response" />
</div>
<div className="form-group">
<label>Custom Headers (JSON, optional)</label>
<textarea value={formData.http_headers} onChange={(e) => setFormData({ ...formData, http_headers: e.target.value })} placeholder='{"Authorization": "Bearer token"}' rows="2" />
</div>
<HttpMethodBodyFields data={formData} setData={setFormData} />
</>
)}
{formData.type === 'tls' && (
<div className="form-group">
<label>Certificate Expiry Warning (days)</label>
<input type="number" value={formData.cert_expiry_days} onChange={(e) => setFormData({ ...formData, cert_expiry_days: parseInt(e.target.value) })} min="1" max="90" />
</div>
)}
<div className="form-row">
<div className="form-group">
<label>Quiet Hours Start (optional)</label>
<input type="time" value={formData.quiet_hours_start} onChange={(e) => setFormData({ ...formData, quiet_hours_start: e.target.value })} />
</div>
<div className="form-group">
<label>Quiet Hours End (optional)</label>
<input type="time" value={formData.quiet_hours_end} onChange={(e) => setFormData({ ...formData, quiet_hours_end: e.target.value })} />
</div>
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" id="maintenance_mode_new" checked={!!formData.maintenance_mode} onChange={(e) => setFormData({ ...formData, maintenance_mode: e.target.checked })} />
<label htmlFor="maintenance_mode_new" style={{ margin: 0 }}>Start in maintenance mode (no alerts sent)</label>
</div>
<div className="form-group">
<label>SLA Target (%)</label>
<input type="number" step="0.1" value={formData.sla_target} onChange={(e) => setFormData({ ...formData, sla_target: parseFloat(e.target.value) })} min="0" max="100" />
</div>
<div className="form-group">
<label>Alert Email Address (Optional)</label>
<input type="text" placeholder="user@example.com (leave empty to use global setting)" value={formData.email_to || ''} onChange={(e) => setFormData({ ...formData, email_to: e.target.value })} />
</div>
<div className="form-group">
<label>Tags (comma-separated)</label>
<input type="text" placeholder="frontend,production,critical" 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) })} />
</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 })} />
</div>
</div>
<div className="form-group">
<label>Data Retention (days)</label>
<input type="number" placeholder="Leave empty to use global setting" value={formData.retention_days || ''} onChange={(e) => setFormData({ ...formData, retention_days: e.target.value ? parseInt(e.target.value) : null })} min="1" max="365" />
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" id="is_public_new" checked={!!formData.is_public} onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })} />
<label htmlFor="is_public_new" style={{ margin: 0 }}>Show on public status page</label>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowModal(false)}>Cancel</button>
<button type="submit" className="btn btn-primary">Add Resource</button>
</div>
</form>
</div>
</div>
)}
{showEditModal && (
<div className="modal-overlay" onClick={() => setShowEditModal(false)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Edit Resource</h2>
<form onSubmit={(e) => { e.preventDefault(); handleEditResource(editData.id); }}>
<div className="form-group">
<label>Resource Name *</label>
<input type="text" value={editData.name || ''} onChange={(e) => setEditData({ ...editData, name: e.target.value })} required placeholder="e.g., Production API" />
</div>
<div className="form-group">
<label>URL/Address *</label>
<input type="text" value={editData.url || ''} onChange={(e) => setEditData({ ...editData, url: e.target.value })} required placeholder="e.g., https://api.example.com" />
</div>
<div className="form-group">
<label>Check Type *</label>
<select value={editData.type || 'http'} onChange={(e) => setEditData({ ...editData, type: e.target.value })}>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="tcp">TCP</option>
<option value="dns">DNS</option>
<option value="ping">Ping</option>
<option value="health">Health API</option>
<option value="tls">TLS Certificate</option>
<option value="heartbeat">Heartbeat (cron job)</option>
</select>
</div>
{editData.type === 'heartbeat' && (
<div className="form-group">
<label>Heartbeat Timeout (ms)</label>
<input type="number" value={editData.heartbeat_timeout || 300000} onChange={(e) => setEditData({ ...editData, heartbeat_timeout: parseInt(e.target.value) })} min="10000" step="1000" />
</div>
)}
{editData.type === 'heartbeat' && editData.heartbeat_token && (
<div className="form-group">
<label>Heartbeat Ping URL</label>
<input type="text" readOnly value={`${window.location.origin}/api/heartbeat/${editData.heartbeat_token}`} onClick={(e) => e.target.select()} style={{ cursor: 'pointer', fontFamily: 'monospace', fontSize: '0.85rem' }} />
<small style={{ color: '#666', fontSize: '0.85rem' }}>POST to this URL from your cron job or script</small>
</div>
)}
<div className="form-group">
<label>Check Interval (ms)</label>
<input type="number" value={editData.check_interval || 60000} onChange={(e) => setEditData({ ...editData, check_interval: parseInt(e.target.value) })} min="10000" />
</div>
<div className="form-group">
<label>Timeout (ms)</label>
<input type="number" value={editData.timeout || 5000} onChange={(e) => setEditData({ ...editData, timeout: parseInt(e.target.value) })} min="1000" />
</div>
{(editData.type === 'http' || editData.type === 'https' || editData.type === 'health') && (
<>
<div className="form-group">
<label>Keyword to Match (optional)</label>
<input type="text" value={editData.http_keyword || ''} onChange={(e) => setEditData({ ...editData, http_keyword: e.target.value })} placeholder="Text that must appear in response" />
</div>
<div className="form-group">
<label>Custom Headers (JSON, optional)</label>
<textarea value={editData.http_headers || ''} onChange={(e) => setEditData({ ...editData, http_headers: e.target.value })} placeholder='{"Authorization": "Bearer token"}' rows="2" />
</div>
<HttpMethodBodyFields data={editData} setData={setEditData} />
</>
)}
{editData.type === 'tls' && (
<div className="form-group">
<label>Certificate Expiry Warning (days)</label>
<input type="number" value={editData.cert_expiry_days || 30} onChange={(e) => setEditData({ ...editData, cert_expiry_days: parseInt(e.target.value) })} min="1" max="90" />
</div>
)}
<div className="form-row">
<div className="form-group">
<label>Quiet Hours Start (optional)</label>
<input type="time" value={editData.quiet_hours_start || ''} onChange={(e) => setEditData({ ...editData, quiet_hours_start: e.target.value })} />
</div>
<div className="form-group">
<label>Quiet Hours End (optional)</label>
<input type="time" value={editData.quiet_hours_end || ''} onChange={(e) => setEditData({ ...editData, quiet_hours_end: e.target.value })} />
</div>
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" id="maintenance_mode_edit" checked={!!editData.maintenance_mode} onChange={(e) => setEditData({ ...editData, maintenance_mode: e.target.checked })} />
<label htmlFor="maintenance_mode_edit" style={{ margin: 0 }}>Maintenance mode (suppress alerts)</label>
</div>
<div className="form-group">
<label>SLA Target (%)</label>
<input type="number" step="0.1" value={editData.sla_target || 99.9} onChange={(e) => setEditData({ ...editData, sla_target: parseFloat(e.target.value) })} min="0" max="100" />
</div>
<div className="form-group">
<label>Group</label>
<select value={editData.group_id || ''} onChange={(e) => setEditData({ ...editData, group_id: e.target.value ? parseInt(e.target.value) : null })}>
<option value="">Ungrouped</option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Alert Email Address (Optional)</label>
<input type="text" placeholder="user@example.com or alice@example.com,bob@example.com" value={editData.email_to || ''} onChange={(e) => setEditData({ ...editData, email_to: e.target.value })} />
</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) })} />
</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 })} />
</div>
</div>
<div className="form-group">
<label>Data Retention (days)</label>
<input type="number" min="1" max="365" placeholder="Leave empty to use global setting" value={editData.retention_days || ''} onChange={(e) => setEditData({ ...editData, retention_days: e.target.value ? parseInt(e.target.value) : null })} />
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input type="checkbox" id="is_public_edit" checked={editData.is_public !== false && editData.is_public !== 0} onChange={(e) => setEditData({ ...editData, is_public: e.target.checked })} />
<label htmlFor="is_public_edit" style={{ margin: 0 }}>Show on public status page</label>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowEditModal(false)}>Cancel</button>
<button type="submit" className="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
export default Dashboard;
+493
View File
@@ -0,0 +1,493 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
} from 'recharts';
import { formatLocalTime, formatChartTime, formatDuration } from './utils/timeUtils';
function ResourceDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [resource, setResource] = useState(null);
const [loading, setLoading] = useState(true);
const [checks, setChecks] = useState([]);
const [checksLoading, setChecksLoading] = useState(false);
const [checksStatus, setChecksStatus] = useState('');
const [checksPage, setChecksPage] = useState(0);
const checksLimit = 10;
const [checksSort, setChecksSort] = useState('desc');
const [incidents, setIncidents] = useState([]);
const [incidentsLoading, setIncidentsLoading] = useState(false);
const [incidentsStatus, setIncidentsStatus] = useState('all');
const [incidentsPage, setIncidentsPage] = useState(0);
const incidentsLimit = 10;
const [incidentsSort, setIncidentsSort] = useState('desc');
const [expandedIncidentId, setExpandedIncidentId] = useState(null);
const [showIncidentModal, setShowIncidentModal] = useState(false);
const [editingIncident, setEditingIncident] = useState(null);
const [incidentDescription, setIncidentDescription] = useState('');
const [updatingIncident, setUpdatingIncident] = useState(false);
const [sla, setSla] = useState(null);
const [slaLoading, setSlaLoading] = useState(false);
const slaWindow = 24;
const [notifications, setNotifications] = useState([]);
const showNotification = useCallback((title, message, type = 'info') => {
const id = Date.now();
setNotifications(prev => [...prev, { id, title, message, type }]);
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 5000);
}, []);
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const [maintenanceForm, setMaintenanceForm] = useState({ 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
}, [id]);
useEffect(() => {
loadChecks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [checksStatus, checksPage, checksSort]);
useEffect(() => {
loadIncidents();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [incidentsStatus, incidentsPage, incidentsSort]);
const loadResource = async () => {
try {
const response = await axios.get(`/api/resources/${id}`);
setResource(response.data);
setLoading(false);
} catch (error) {
setLoading(false);
}
};
const loadChecks = async () => {
try {
setChecksLoading(true);
const response = await axios.get(`/api/resources/${id}/checks`, {
params: { limit: checksLimit, offset: checksPage * checksLimit, status: checksStatus || undefined, sort: checksSort },
});
setChecks(response.data.checks || []);
} catch (error) {
// Checks load error handled
} finally {
setChecksLoading(false);
}
};
const loadIncidents = async () => {
try {
setIncidentsLoading(true);
const response = await axios.get(`/api/resources/${id}/incidents`, {
params: { limit: incidentsLimit, offset: incidentsPage * incidentsLimit, status: incidentsStatus, sort: incidentsSort },
});
setIncidents(response.data.incidents || []);
} catch (error) {
// Incidents load error handled
} finally {
setIncidentsLoading(false);
}
};
const handleEditIncident = (incident) => {
setEditingIncident(incident);
setIncidentDescription(incident.description || '');
setShowIncidentModal(true);
};
const handleUpdateIncident = async () => {
if (!editingIncident) return;
try {
setUpdatingIncident(true);
await axios.patch(`/api/incidents/${editingIncident.id}`, { description: incidentDescription });
setShowIncidentModal(false);
setEditingIncident(null);
setIncidentDescription('');
loadIncidents();
} catch (error) {
showNotification('Error', error.response?.data?.error || 'Failed to update incident', 'error');
} finally {
setUpdatingIncident(false);
}
};
const loadSla = async () => {
try {
setSlaLoading(true);
const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } });
setSla(response.data);
} catch (error) {
// SLA load error handled
} finally {
setSlaLoading(false);
}
};
const loadMaintenanceWindows = async () => {
try {
const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
setMaintenanceWindows(response.data.windows || []);
} catch (error) {
// Maintenance windows load error handled
}
};
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();
showNotification('Success', 'Maintenance window created', 'success');
} catch (error) {
showNotification('Error', 'Failed to create maintenance window', 'error');
}
};
const handleDeleteMaintenanceWindow = async (windowId) => {
if (window.confirm('Delete this maintenance window?')) {
try {
await axios.delete(`/api/maintenance-windows/${windowId}`);
loadMaintenanceWindows();
} catch (error) {
showNotification('Error', 'Failed to delete maintenance window', 'error');
}
}
};
const handleDelete = async () => {
if (window.confirm('Are you sure you want to delete this resource?')) {
try {
await axios.delete(`/api/resources/${id}`);
navigate('/');
} catch (error) {
showNotification('Error', 'Failed to delete resource', 'error');
}
}
};
const toggleEnabled = async () => {
try {
await axios.put(`/api/resources/${id}`, { ...resource, enabled: !resource.enabled });
loadResource();
} catch (error) {
// Update resource error handled
}
};
const toggleMaintenance = async () => {
try {
await axios.put(`/api/resources/${id}`, { ...resource, maintenance_mode: !resource.maintenance_mode });
loadResource();
} catch (error) {
// Update maintenance mode error handled
}
};
if (loading) return <div className="container">Loading...</div>;
if (!resource) return <div className="container">Resource not found</div>;
const toastStack = notifications.length > 0 && (
<div className="toast-stack">
{notifications.map(n => (
<div key={n.id} className={`toast-item toast-${n.type}`}>
<strong>{n.title}</strong>
<div>{n.message}</div>
</div>
))}
</div>
);
const chartData = resource.stats.checks.map((check) => ({
time: formatChartTime(check.checked_at),
responseTime: check.response_time,
status: check.status === 'up' ? 1 : 0,
}));
return (
<div className="container">
{toastStack}
<Link to="/" className="back-button">← Back to Dashboard</Link>
<div className="detail-section">
<div className="detail-header-row">
<div>
<h2>{resource.name}</h2>
<p className="resource-url-detail">{resource.url}</p>
<p className="resource-type">Type: {resource.type}</p>
</div>
<div className="detail-actions">
<button className="btn" onClick={toggleEnabled}>{resource.enabled ? 'Disable' : 'Enable'}</button>
<button className="btn btn-secondary" onClick={toggleMaintenance}>
{resource.maintenance_mode ? 'End Maintenance' : 'Start Maintenance'}
</button>
<button className="btn btn-danger" onClick={handleDelete}>Delete</button>
</div>
</div>
{resource.hasActiveIncident && (
<div className="incident-badge">⚠️ Active Incident - Resource is currently DOWN</div>
)}
{resource.maintenance_mode && (
<div className="incident-badge maintenance-badge">🛠️ Maintenance mode active — alerts are suppressed</div>
)}
<div className="stats-grid stats-grid-4 detail-stats-grid">
<div className="stat"><p className="stat-value">{resource.stats.uptime}%</p><p className="stat-label">Uptime (24h)</p></div>
<div className="stat"><p className="stat-value">{resource.stats.avgResponseTime}ms</p><p className="stat-label">Avg Response</p></div>
<div className="stat"><p className="stat-value">{resource.stats.totalChecks}</p><p className="stat-label">Total Checks</p></div>
<div className="stat">
<p className={`stat-value status-badge status-${resource.lastCheck?.status || 'unknown'}`}>{resource.lastCheck?.status || 'unknown'}</p>
<p className="stat-label">Current Status</p>
</div>
</div>
</div>
<div className="detail-section">
<h2>SLA / SLO (last {slaWindow}h)</h2>
{slaLoading ? <p>Loading SLA...</p> : !sla ? <p>No SLA data yet</p> : (
<div className="stats-grid stats-grid-4">
<div className="stat"><p className="stat-value">{sla.uptimePct}%</p><p className="stat-label">Uptime</p></div>
<div className="stat"><p className="stat-value">{formatDuration(sla.downtimeMinutes * 60000 || 0)}</p><p className="stat-label">Downtime</p></div>
<div className="stat"><p className="stat-value">{sla.p95LatencyMs != null ? `${sla.p95LatencyMs}ms` : '—'}</p><p className="stat-label">p95 Latency</p></div>
<div className="stat"><p className="stat-value">{sla.totalChecks}</p><p className="stat-label">Checks in window</p></div>
<div className="stat"><p className="stat-value">{sla.mttrMinutes != null ? `${sla.mttrMinutes}m` : '—'}</p><p className="stat-label">MTTR</p></div>
<div className="stat"><p className="stat-value">{sla.mtbfMinutes != null ? `${sla.mtbfMinutes}m` : '—'}</p><p className="stat-label">MTBF</p></div>
</div>
)}
</div>
<div className="detail-section">
<h2>Response Time (Last 50 Checks)</h2>
<div className="chart-container">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="time" tick={{ fill: '#94a3b8', fontSize: 12 }} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 12 }} />
<Tooltip
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px' }}
labelStyle={{ color: '#cbd5e1' }}
itemStyle={{ color: '#e2e8f0' }}
/>
<Line type="monotone" dataKey="responseTime" stroke="#60a5fa" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="detail-section">
<h2>Recent Checks</h2>
<div className="section-controls">
<div className="filter-control">
<label>Status</label>
<select value={checksStatus} onChange={(e) => { setChecksPage(0); setChecksStatus(e.target.value); }}>
<option value="">All</option>
<option value="up">Up</option>
<option value="down">Down</option>
</select>
</div>
<div className="filter-control">
<label>Sort</label>
<select value={checksSort} onChange={(e) => { setChecksPage(0); setChecksSort(e.target.value); }}>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
<div className="controls-pagination">
<button className="btn btn-secondary" disabled={checksPage === 0 || checksLoading} onClick={() => setChecksPage(Math.max(0, checksPage - 1))}>← Prev</button>
<button className="btn btn-secondary" disabled={checksLoading || checks.length < checksLimit} onClick={() => setChecksPage(checksPage + 1)}>Next →</button>
</div>
</div>
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr><th>Time</th><th>Status</th><th className="text-right">Response Time</th><th>Message</th></tr>
</thead>
<tbody>
{checksLoading ? (
<tr><td colSpan="4">Loading checks...</td></tr>
) : checks.length === 0 ? (
<tr><td colSpan="4">No checks found</td></tr>
) : checks.map((check) => (
<tr key={check.id}>
<td>{formatLocalTime(check.checked_at)}</td>
<td><span className={`status-badge status-${check.status}`}>{check.status}</span></td>
<td className="text-right">{check.response_time ? `${check.response_time}ms` : '-'}</td>
<td className="muted-cell">{check.error_message || 'OK'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="detail-section">
<h2>Incidents Timeline</h2>
<div className="section-controls">
<div className="filter-control">
<label>Status</label>
<select value={incidentsStatus} onChange={(e) => { setIncidentsPage(0); setIncidentsStatus(e.target.value); }}>
<option value="all">All</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
</div>
<div className="filter-control">
<label>Sort</label>
<select value={incidentsSort} onChange={(e) => { setIncidentsPage(0); setIncidentsSort(e.target.value); }}>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
<div className="controls-pagination">
<button className="btn btn-secondary" disabled={incidentsPage === 0 || incidentsLoading} onClick={() => setIncidentsPage(Math.max(0, incidentsPage - 1))}>← Prev</button>
<button className="btn btn-secondary" disabled={incidentsLoading || incidents.length < incidentsLimit} onClick={() => setIncidentsPage(incidentsPage + 1)}>Next →</button>
</div>
</div>
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr><th className="w-25">Started</th><th className="w-25">Resolved</th><th className="w-15">Duration</th><th className="w-35">Reason</th></tr>
</thead>
<tbody>
{incidentsLoading ? (
<tr><td colSpan="4">Loading incidents...</td></tr>
) : incidents.length === 0 ? (
<tr><td colSpan="4">No incidents found</td></tr>
) : incidents.map((incident) => {
const start = incident.started_at || incident.created_at;
const end = incident.resolved_at;
const durationMs = start && end ? (new Date(end).getTime() - new Date(start).getTime()) : null;
const durationText = durationMs != null ? formatDuration(durationMs) : 'Ongoing';
const isExpanded = expandedIncidentId === incident.id;
const description = incident.description || 'No description';
const isTruncated = description.length > 60;
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
return (
<React.Fragment key={incident.id}>
<tr>
<td>{start ? formatLocalTime(start) : '-'}</td>
<td>{end ? formatLocalTime(end) : 'Open'}</td>
<td>{durationText}</td>
<td className="incident-reason-cell">
<span className="incident-reason-toggle" onClick={() => setExpandedIncidentId(isExpanded ? null : incident.id)}>
{displayText}
{isTruncated && <span className="expand-indicator">{isExpanded ? '▼' : '▶'}</span>}
</span>
<button className="btn btn-secondary btn-compact" onClick={() => handleEditIncident(incident)}>Edit</button>
</td>
</tr>
{isExpanded && (
<tr className="expanded-row">
<td colSpan="4" className="expanded-cell">{description}</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
<div className="detail-section">
<div className="section-title-row">
<h2>Maintenance Windows</h2>
<button className="btn btn-primary" onClick={() => setShowMaintenanceModal(true)}>+ Schedule Maintenance</button>
</div>
{maintenanceWindows.length === 0 ? (
<p className="empty-copy">No maintenance windows scheduled</p>
) : (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr><th>Start Time</th><th>End Time</th><th>Reason</th><th className="text-right">Actions</th></tr>
</thead>
<tbody>
{maintenanceWindows.map(window => (
<tr key={window.id}>
<td>{formatLocalTime(window.start_time)}</td>
<td>{formatLocalTime(window.end_time)}</td>
<td className="muted-cell">{window.reason || '-'}</td>
<td className="text-right">
<button className="btn btn-danger btn-compact" onClick={() => handleDeleteMaintenanceWindow(window.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{showMaintenanceModal && (
<div className="modal-overlay" onClick={() => setShowMaintenanceModal(false)}>
<div className="modal" 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>
)}
{showIncidentModal && editingIncident && (
<div className="modal-overlay" onClick={() => setShowIncidentModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Edit Incident</h2>
<div className="form-group">
<label style={{ fontSize: '0.9rem', color: '#666' }}>Incident Started</label>
<div style={{ padding: '0.5rem', backgroundColor: '#f5f5f5', borderRadius: '4px', marginBottom: '1rem' }}>
{formatLocalTime(editingIncident.started_at || editingIncident.created_at)}
</div>
</div>
<div className="form-group">
<label>Description *</label>
<textarea rows="6" placeholder="Describe what happened and why..." value={incidentDescription} onChange={(e) => setIncidentDescription(e.target.value)} style={{ fontFamily: 'monospace', fontSize: '0.9rem' }} />
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => { setShowIncidentModal(false); setEditingIncident(null); setIncidentDescription(''); }}>Cancel</button>
<button type="submit" className="btn btn-primary" onClick={handleUpdateIncident} disabled={updatingIncident || !incidentDescription.trim()}>
{updatingIncident ? 'Updating...' : 'Update'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default ResourceDetail;
+9
View File
@@ -119,3 +119,12 @@ export const getTimezoneInfo = () => {
offset: now.getTimezoneOffset()
};
};
export function formatDuration(ms) {
if (!ms || ms < 0) return '0m';
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
+7
View File
@@ -311,6 +311,13 @@ try {
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run();
} catch (err) {}
// Feature: HTTP method + request body support
try { db.prepare("ALTER TABLE resources ADD COLUMN http_method TEXT DEFAULT 'GET'").run(); } catch(e) {}
try { db.prepare("ALTER TABLE resources ADD COLUMN http_body TEXT").run(); } catch(e) {}
// Feature: alert escalation tracking
try { db.prepare("ALTER TABLE incidents ADD COLUMN escalated INTEGER DEFAULT 0").run(); } catch(e) {}
// Feature: heartbeat / cron-job monitoring
try { db.prepare("ALTER TABLE resources ADD COLUMN heartbeat_token TEXT").run(); } catch(e) {}
try { db.prepare("ALTER TABLE resources ADD COLUMN heartbeat_timeout INTEGER DEFAULT 300000").run(); } catch(e) {}
+32 -8
View File
@@ -141,6 +141,24 @@ function buildSettingsFromDb(dbSettings = {}) {
};
}
// Simple in-memory per-IP rate limiter (no external package needed)
const _rlMap = new Map();
setInterval(() => {
const now = Date.now();
for (const [k, v] of _rlMap) { if (now - v.start > 60000) _rlMap.delete(k); }
}, 5 * 60 * 1000);
function rateLimit(maxReqs, windowMs = 60000) {
return (req, res, next) => {
const key = `${req.path}:${req.ip}`;
const now = Date.now();
const e = _rlMap.get(key);
if (!e || now - e.start > windowMs) { _rlMap.set(key, { start: now, count: 1 }); return next(); }
e.count++;
if (e.count > maxReqs) return res.status(429).json({ error: 'Too many requests' });
next();
};
}
// Build dashboard payload (shared between HTTP route and WebSocket broadcast)
function getDashboardPayload() {
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
@@ -194,6 +212,8 @@ function getDashboardPayload() {
heartbeat_timeout: resource.heartbeat_timeout,
consecutive_failures_threshold: resource.consecutive_failures_threshold,
response_time_threshold: resource.response_time_threshold,
http_method: resource.http_method || 'GET',
http_body: resource.http_body || null,
certDaysRemaining,
status: lastCheck?.status || 'unknown',
uptime: stats.uptime,
@@ -311,7 +331,7 @@ app.get('/api/resources/:id', (req, res) => {
// Create resource
app.post('/api/resources', (req, res) => {
const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold } = req.body;
const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body } = req.body;
const validationError = validateResourceBody(req.body);
if (validationError) return res.status(400).json({ error: validationError });
@@ -322,8 +342,8 @@ app.post('/api/resources', (req, res) => {
const effectiveUrl = isHeartbeat ? `heartbeat://${name}` : url;
const stmt = db.prepare(`
INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_token, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_token, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -346,7 +366,9 @@ app.post('/api/resources', (req, res) => {
token,
heartbeat_timeout || 300000,
consecutive_failures_threshold || 1,
response_time_threshold || null
response_time_threshold || null,
http_method || 'GET',
http_body || null
);
// Invalidate related cache entries
@@ -367,7 +389,7 @@ app.post('/api/resources', (req, res) => {
// Update resource
app.put('/api/resources/:id', (req, res) => {
const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold } = req.body;
const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body } = req.body;
const validationError = validateResourceBody(req.body);
if (validationError) return res.status(400).json({ error: validationError });
@@ -378,7 +400,7 @@ app.put('/api/resources/:id', (req, res) => {
const stmt = db.prepare(`
UPDATE resources
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?, retention_days = ?, is_public = ?, heartbeat_timeout = ?, consecutive_failures_threshold = ?, response_time_threshold = ?
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?, retention_days = ?, is_public = ?, heartbeat_timeout = ?, consecutive_failures_threshold = ?, response_time_threshold = ?, http_method = ?, http_body = ?
WHERE id = ?
`);
@@ -403,6 +425,8 @@ app.put('/api/resources/:id', (req, res) => {
heartbeat_timeout || 300000,
consecutive_failures_threshold || 1,
response_time_threshold || null,
http_method || 'GET',
http_body || null,
req.params.id
);
@@ -1715,7 +1739,7 @@ app.get('/api/resources/:id/alert-rules', (req, res) => {
});
// Heartbeat ping endpoint — cron jobs POST here to signal they're alive
app.post('/api/heartbeat/:token', (req, res) => {
app.post('/api/heartbeat/:token', rateLimit(20), (req, res) => {
const resource = db.prepare('SELECT id, name FROM resources WHERE heartbeat_token = ? AND enabled = 1').get(req.params.token);
if (!resource) return res.status(404).json({ error: 'Unknown heartbeat token' });
@@ -1731,7 +1755,7 @@ app.post('/api/heartbeat/:token', (req, res) => {
});
// Public status page — only returns resources with is_public = 1
app.get('/api/status-page', (req, res) => {
app.get('/api/status-page', rateLimit(60), (req, res) => {
try {
const resources = db.prepare('SELECT * FROM resources WHERE is_public = 1 AND enabled = 1 ORDER BY name').all();
const incidents = db.prepare(`
+14 -3
View File
@@ -145,15 +145,26 @@ class MonitorService {
}
}
const isHttps = resource.url.startsWith('https');
const response = await axios.get(resource.url, {
const method = (resource.http_method || 'GET').toUpperCase();
const requestConfig = {
method,
url: resource.url,
timeout: resource.timeout,
validateStatus: () => true,
maxRedirects: 5,
headers,
httpAgent: this.httpAgent,
httpsAgent: this.httpsAgent,
});
};
if (resource.http_body && ['POST', 'PUT', 'PATCH'].includes(method)) {
try {
requestConfig.data = JSON.parse(resource.http_body);
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
} catch {
requestConfig.data = resource.http_body;
}
}
const response = await axios(requestConfig);
const responseTime = Date.now() - startTime;
const isUp = response.status >= 200 && response.status < 400;
+93
View File
@@ -149,6 +149,99 @@ class NotificationService {
}
}
async flushAlerts(pendingAlerts) {
if (pendingAlerts.length === 0) return;
if (pendingAlerts.length === 1) {
const { resource, incident, stats } = pendingAlerts[0];
await this.sendAlert(resource, incident, stats);
return;
}
// Multiple alerts in one check cycle — save individual in-app entries but send one grouped email/webhook
for (const { resource, incident } of pendingAlerts) {
const status = incident.type === 'started' ? 'down' : 'up';
const message = `${resource.name} is ${status.toUpperCase()}. URL: ${resource.url}`;
this.saveNotification(resource, incident, status, message);
}
await this.sendGroupedEmail(pendingAlerts);
if (this.webhookEnabled) {
await this.sendGroupedWebhook(pendingAlerts);
}
}
async sendGroupedEmail(alerts) {
if (!this.transporter) return;
const targetEmail = this.config.email_to;
if (!targetEmail || !this.emailEnabled) return;
try {
const rows = alerts.map(({ resource, incident }) => {
const status = incident.type === 'started' ? 'DOWN' : 'UP';
const emoji = incident.type === 'started' ? '🔴' : '🟢';
return `<li>${emoji} <strong>${resource.name}</strong> is ${status} — ${resource.url}</li>`;
}).join('');
await this.transporter.sendMail({
from: this.config.email_from,
to: targetEmail,
subject: `⚠️ ${alerts.length} monitors changed status`,
text: alerts.map(({ resource, incident }) =>
`${resource.name} is ${incident.type === 'started' ? 'DOWN' : 'UP'} — ${resource.url}`
).join('\n'),
html: `<div style="font-family:Arial,sans-serif;max-width:600px">
<h2 style="color:#d32f2f">⚠️ ${alerts.length} Monitors Changed Status</h2>
<ul>${rows}</ul>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
</div>`,
});
} catch (error) {
// Grouped email error handled
}
}
async sendGroupedWebhook(alerts) {
const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
if (!webhookUrl) return;
try {
await axios.post(webhookUrl, {
type: 'grouped_alert',
count: alerts.length,
resources: alerts.map(({ resource, incident }) => ({
name: resource.name,
url: resource.url,
status: incident.type === 'started' ? 'down' : 'up',
})),
timestamp: new Date().toISOString(),
});
} catch (error) {
// Grouped webhook error handled
}
}
async sendEscalationAlert(incident, fallbackWebhookUrl) {
try {
const durationMin = Math.round((Date.now() - new Date(incident.started_at).getTime()) / 60000);
const message = `⚠️ ESCALATION: ${incident.resource_name} has been DOWN for ${durationMin} minutes without resolution.`;
if (fallbackWebhookUrl) {
await axios.post(fallbackWebhookUrl, {
resource: incident.resource_name,
url: incident.resource_url,
status: 'escalated',
duration_minutes: durationMin,
incident_id: incident.id,
started_at: incident.started_at,
message,
timestamp: new Date().toISOString(),
});
}
this.saveNotification(
{ id: incident.resource_id, name: incident.resource_name, url: incident.resource_url },
{ id: incident.id },
'escalated',
message
);
} catch (error) {
// Escalation alert error handled
}
}
isQuietHours(resource) {
if (!resource.quiet_hours_start || !resource.quiet_hours_end) {
return false;
+73 -30
View File
@@ -24,30 +24,30 @@ class Scheduler {
}
});
// Archive old checks daily at 2 AM
// Archive old checks + update baselines + check escalations daily at 2 AM
cron.schedule('0 2 * * *', () => {
this.archiveOldChecks();
this.updateBaselines();
});
// Escalation check runs every hour
cron.schedule('0 * * * *', async () => {
await this.checkEscalations();
});
// Also run archive on startup (with delay to let DB init)
setTimeout(() => {
this.archiveOldChecks();
this.updateBaselines();
}, 5000);
}
archiveOldChecks() {
try {
// Get global retention setting
const retentionSetting = db.prepare(`
SELECT value FROM settings WHERE key = 'retention_days'
`).get();
const retentionSetting = db.prepare(`SELECT value FROM settings WHERE key = 'retention_days'`).get();
const globalRetentionDays = retentionSetting ? parseInt(retentionSetting.value) : 30;
// Get all resources with their retention settings
const resources = db.prepare(`
SELECT id, retention_days FROM resources
`).all();
const resources = db.prepare(`SELECT id, retention_days FROM resources`).all();
const bulkArchive = db.prepare(`
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
@@ -73,16 +73,60 @@ class Scheduler {
}
}
updateBaselines() {
try {
const resources = db.prepare(`SELECT id, type FROM resources WHERE type != 'heartbeat'`).all();
for (const resource of resources) {
const baseline = monitorService.calculateResponseTimeBaseline(resource.id, 7);
if (baseline !== null) {
db.prepare(`UPDATE resources SET response_time_baseline = ? WHERE id = ?`).run(baseline, resource.id);
}
}
} catch (error) {
// Baseline update error handled silently
}
}
async checkEscalations() {
try {
const escalationSetting = db.prepare(`SELECT value FROM settings WHERE key = 'escalation_hours'`).get();
const escalationHours = parseInt(escalationSetting?.value || '4');
const fallbackSetting = db.prepare(`SELECT value FROM settings WHERE key = 'fallback_webhook'`).get();
const fallbackWebhook = fallbackSetting?.value || process.env.FALLBACK_WEBHOOK || '';
if (!fallbackWebhook) return;
const cutoff = new Date(Date.now() - escalationHours * 60 * 60 * 1000)
.toISOString().replace('T', ' ').split('.')[0];
const stalled = db.prepare(`
SELECT i.*, r.name AS resource_name, r.url AS resource_url, r.type AS resource_type,
r.maintenance_mode
FROM incidents i
JOIN resources r ON r.id = i.resource_id
WHERE i.resolved_at IS NULL AND i.escalated = 0 AND i.started_at < ?
AND r.maintenance_mode = 0
`).all(cutoff);
for (const incident of stalled) {
await notificationService.sendEscalationAlert(incident, fallbackWebhook);
db.prepare(`UPDATE incidents SET escalated = 1 WHERE id = ?`).run(incident.id);
}
} catch (error) {
// Escalation check error handled silently
}
}
async runChecks() {
const resources = db.prepare(`
SELECT * FROM resources WHERE enabled = 1
`).all();
const resources = db.prepare(`SELECT * FROM resources WHERE enabled = 1`).all();
// Stagger checks to avoid thundering herd
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const jitter = (maxMs = 1500) => Math.floor(Math.random() * maxMs);
const baseSpacingMs = 250;
// Collect incident alerts to flush as a group after all checks complete
const pendingAlerts = [];
const baseSpacingMs = 250; // spread starts in 250ms increments
const tasks = resources.map((resource, index) => (async () => {
// Honour per-resource check_interval — skip if not yet due
const now = Date.now();
@@ -115,30 +159,27 @@ class Scheduler {
// Non-fatal
}
const incident = monitorService.handleIncident(
resource.id,
result.status === 'down'
);
const incident = monitorService.handleIncident(resource.id, result.status === 'down');
if (incident.type !== 'none') {
if (incident.type !== 'none' && !resource.maintenance_mode) {
const stats = monitorService.getResourceStats(resource.id, 24);
const recentChecks = db.prepare(`
SELECT response_time, status FROM checks
WHERE resource_id = ?
ORDER BY checked_at DESC
LIMIT 12
ORDER BY checked_at DESC LIMIT 12
`).all(resource.id);
stats.recentChecks = recentChecks.reverse();
if (!resource.maintenance_mode) {
await notificationService.sendAlert(resource, incident, stats);
}
pendingAlerts.push({ resource, incident, stats });
}
// Response time threshold alert (only when up, with 1-hour cooldown)
// Slow alert: prefer explicit threshold, fall back to 2× computed baseline
const effectiveThreshold = resource.response_time_threshold ||
(resource.response_time_baseline ? resource.response_time_baseline * 2 : null);
if (
result.status === 'up' &&
resource.response_time_threshold &&
result.response_time > resource.response_time_threshold &&
effectiveThreshold &&
result.response_time > effectiveThreshold &&
!resource.maintenance_mode
) {
const lastSlowAlert = this.slowAlertCooldown.get(resource.id) || 0;
@@ -147,7 +188,7 @@ class Scheduler {
await notificationService.sendAlert(resource, {
type: 'slow',
responseTime: result.response_time,
threshold: resource.response_time_threshold,
threshold: effectiveThreshold,
id: null,
});
}
@@ -159,6 +200,9 @@ class Scheduler {
await Promise.allSettled(tasks);
// Flush collected alerts — grouped if multiple fired in the same cycle
await notificationService.flushAlerts(pendingAlerts);
// Broadcast updated dashboard to all connected WebSocket clients
if (global.broadcastDashboardUpdate) {
global.broadcastDashboardUpdate();
@@ -166,7 +210,6 @@ class Scheduler {
}
stop() {
// Stop the scheduler (cron tasks will continue running but we flag isRunning)
this.isRunning = false;
}
}
+144
View File
@@ -0,0 +1,144 @@
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
// ---------------------------------------------------------------------------
// validateResourceBody (inline copy — avoids spinning up Express)
// ---------------------------------------------------------------------------
function validateResourceBody(body, requireUrl = true) {
const { name, url, type, check_interval, timeout, sla_target, retention_days } = body;
const isHeartbeat = (type || 'http') === 'heartbeat';
if (!name) return 'Name is required';
if (!isHeartbeat && requireUrl && !url) return 'URL is required';
if (!isHeartbeat && url) {
try { new URL(url); } catch (e) { return 'Invalid URL format'; }
}
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) return 'Check interval must be at least 10000ms';
if (timeout && (isNaN(timeout) || timeout < 1000)) return 'Timeout must be at least 1000ms';
if (sla_target !== undefined && sla_target !== null && (isNaN(sla_target) || sla_target < 0 || sla_target > 100)) return 'SLA target must be between 0 and 100';
if (retention_days && (isNaN(retention_days) || retention_days < 1 || retention_days > 365)) return 'Retention days must be between 1 and 365';
return null;
}
// ---------------------------------------------------------------------------
// formatDuration (inline copy)
// ---------------------------------------------------------------------------
function formatDuration(ms) {
if (!ms || ms < 0) return '0m';
const minutes = Math.floor(ms / 60000);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('validateResourceBody', () => {
test('rejects missing name', () => {
const result = validateResourceBody({ url: 'https://example.com' });
assert.equal(result, 'Name is required');
});
test('rejects missing URL for non-heartbeat', () => {
const result = validateResourceBody({ name: 'Test' });
assert.equal(result, 'URL is required');
});
test('rejects invalid URL', () => {
const result = validateResourceBody({ name: 'Test', url: 'not-a-url' });
assert.equal(result, 'Invalid URL format');
});
test('accepts valid http resource', () => {
const result = validateResourceBody({ name: 'Test', url: 'https://example.com' });
assert.equal(result, null);
});
test('accepts heartbeat without URL', () => {
const result = validateResourceBody({ name: 'My Job', type: 'heartbeat' });
assert.equal(result, null);
});
test('rejects check_interval below 10000', () => {
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', check_interval: 5000 });
assert.equal(result, 'Check interval must be at least 10000ms');
});
test('rejects timeout below 1000', () => {
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', timeout: 500 });
assert.equal(result, 'Timeout must be at least 1000ms');
});
test('rejects sla_target above 100', () => {
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', sla_target: 101 });
assert.equal(result, 'SLA target must be between 0 and 100');
});
test('rejects retention_days above 365', () => {
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', retention_days: 400 });
assert.equal(result, 'Retention days must be between 1 and 365');
});
});
describe('formatDuration', () => {
test('returns 0m for null input', () => {
assert.equal(formatDuration(null), '0m');
});
test('returns 0m for negative input', () => {
assert.equal(formatDuration(-100), '0m');
});
test('formats minutes only', () => {
assert.equal(formatDuration(5 * 60000), '5m');
});
test('formats hours and minutes', () => {
assert.equal(formatDuration(90 * 60000), '1h 30m');
});
test('formats exactly 1 hour', () => {
assert.equal(formatDuration(60 * 60000), '1h 0m');
});
test('formats 0 minutes as 0m', () => {
assert.equal(formatDuration(0), '0m');
});
});
describe('rate limiter logic', () => {
// Inline the rate-limit counter logic
function makeRateLimiter(maxReqs, windowMs = 60000) {
const map = new Map();
return (ip, path) => {
const key = `${path}:${ip}`;
const now = Date.now();
const e = map.get(key);
if (!e || now - e.start > windowMs) { map.set(key, { start: now, count: 1 }); return true; }
e.count++;
return e.count <= maxReqs;
};
}
test('allows requests within limit', () => {
const check = makeRateLimiter(3);
assert.ok(check('127.0.0.1', '/api/heartbeat/tok'));
assert.ok(check('127.0.0.1', '/api/heartbeat/tok'));
assert.ok(check('127.0.0.1', '/api/heartbeat/tok'));
});
test('blocks requests exceeding limit', () => {
const check = makeRateLimiter(2);
check('127.0.0.1', '/test');
check('127.0.0.1', '/test');
const allowed = check('127.0.0.1', '/test');
assert.equal(allowed, false);
});
test('different IPs are tracked separately', () => {
const check = makeRateLimiter(1);
assert.ok(check('1.1.1.1', '/test'));
assert.ok(check('2.2.2.2', '/test'));
});
});