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:
1 parent
a6cf6a9762
commit
4fea0a95f5
19 files changed
+1793
-1964
No files matched your search
@@ -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 @@
|
||||
<!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>
|
||||
+2
-2
@@ -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
|
||||
File renamed without changes.
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"static/js/main.a387bcf0.js","mappings":"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 truncated
|
||||
+5
-1909
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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
@@ -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(`
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user