Add heartbeat monitoring, alert rules, cert badges, webhook templates, and status page visibility
- Heartbeat type: cron jobs POST to /api/heartbeat/:token to stay alive; scheduler marks
them down if no ping is received within the configured timeout window
- Alert rules: per-resource consecutive_failures_threshold now respected in handleIncident,
overriding the global setting; GET /api/resources/:id/alert-rules endpoint added
- SSL cert expiry badge: dashboard rows surface days_remaining for TLS monitors, turning
orange at ≤30 days and red at ≤7 days
- Webhook payload templates: {{name}}/{{status}}/{{url}}/{{type}}/{{message}}/{{timestamp}}
variables interpolated before POST; Slack-compatible text wrapping as fallback
- Status page visibility: is_public column per resource; GET /api/status-page returns only
public resources; Status.js updated to use the new endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
34986b5760
commit
a6cf6a9762
16 files changed
+533
-401
No files matched your search
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.6034edfe.css",
|
||||
"main.js": "/static/js/main.243399c3.js",
|
||||
"main.js": "/static/js/main.16dc004f.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.243399c3.js.map": "/static/js/main.243399c3.js.map",
|
||||
"main.16dc004f.js.map": "/static/js/main.16dc004f.js.map",
|
||||
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.6034edfe.css",
|
||||
"static/js/main.243399c3.js"
|
||||
"static/js/main.16dc004f.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.243399c3.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.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>
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"static/css/main.6034edfe.css","mappings":"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 truncated
|
||||
{"version":3,"file":"static/css/main.6034edfe.css","mappings":"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 truncated
|
||||
@@ -1,3 +1,3 @@
|
||||
/*! For license information please see main.243399c3.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.243399c3.js.map
|
||||
/*! 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
|
||||
File renamed without changes.
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"static/js/main.16dc004f.js","mappings":"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 truncated
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"static/js/main.243399c3.js","mappings":"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 truncated
|
||||
Generated
-95
@@ -3390,36 +3390,6 @@
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
@@ -3490,13 +3460,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -6755,16 +6718,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
@@ -6879,13 +6832,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
||||
@@ -11406,16 +11352,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
|
||||
@@ -16198,23 +16134,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
@@ -16709,20 +16628,6 @@
|
||||
"is-typedarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
||||
+149
-25
@@ -49,7 +49,10 @@ function Dashboard() {
|
||||
tags: '',
|
||||
consecutive_failures_threshold: 1,
|
||||
response_time_threshold: null,
|
||||
is_public: true,
|
||||
heartbeat_timeout: 300000,
|
||||
});
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [tagFilter, setTagFilter] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState('all');
|
||||
const [quickFilter, setQuickFilter] = useState('all');
|
||||
@@ -57,6 +60,8 @@ function Dashboard() {
|
||||
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();
|
||||
|
||||
@@ -88,7 +93,7 @@ function Dashboard() {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send initial ping to keep alive
|
||||
wsReconnectDelay.current = 1000;
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
};
|
||||
|
||||
@@ -127,6 +132,10 @@ function Dashboard() {
|
||||
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
wsReconnectTimer.current = setTimeout(() => {
|
||||
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
|
||||
connectWebSocket();
|
||||
}, wsReconnectDelay.current);
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
@@ -156,6 +165,7 @@ function Dashboard() {
|
||||
|
||||
return () => {
|
||||
clearInterval(fallbackInterval);
|
||||
clearTimeout(wsReconnectTimer.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
@@ -170,14 +180,14 @@ function Dashboard() {
|
||||
setGroupData({ name: '', description: '' });
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
alert('Error creating group');
|
||||
showNotification('Error', 'Failed to create group', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await axios.post('/api/resources', formData);
|
||||
const res = await axios.post('/api/resources', formData);
|
||||
setShowModal(false);
|
||||
setFormData({
|
||||
name: '',
|
||||
@@ -197,10 +207,16 @@ function Dashboard() {
|
||||
tags: '',
|
||||
consecutive_failures_threshold: 1,
|
||||
response_time_threshold: null,
|
||||
is_public: true,
|
||||
heartbeat_timeout: 300000,
|
||||
});
|
||||
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) {
|
||||
alert('Error creating resource');
|
||||
showNotification('Error', 'Failed to add resource', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,7 +237,7 @@ function Dashboard() {
|
||||
setEditData({});
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
alert('Error updating resource');
|
||||
showNotification('Error', 'Failed to update resource', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -236,7 +252,7 @@ function Dashboard() {
|
||||
await axios.delete(`/api/resources/${id}`);
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
alert('Error deleting resource');
|
||||
showNotification('Error', 'Failed to delete resource', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -248,7 +264,7 @@ function Dashboard() {
|
||||
});
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
alert('Error updating maintenance mode');
|
||||
showNotification('Error', 'Failed to update maintenance mode', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -263,9 +279,9 @@ function Dashboard() {
|
||||
link.setAttribute('download', `resources-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentChild.removeChild(link);
|
||||
link.parentNode.removeChild(link);
|
||||
} catch (error) {
|
||||
alert('Error exporting resources');
|
||||
showNotification('Error', 'Failed to export resources', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -279,13 +295,13 @@ function Dashboard() {
|
||||
headers: { 'Content-Type': 'text/csv' }
|
||||
});
|
||||
|
||||
alert(`Imported ${response.data.count} resources successfully!`);
|
||||
showNotification('Import Complete', `Imported ${response.data.count} resource(s)`, 'success');
|
||||
if (response.data.errors.length > 0) {
|
||||
alert(`Errors:\n${response.data.errors.join('\n')}`);
|
||||
showNotification('Import Warnings', response.data.errors[0], 'warning');
|
||||
}
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
alert('Error importing resources');
|
||||
showNotification('Error', 'Failed to import resources', 'error');
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
@@ -318,9 +334,13 @@ function Dashboard() {
|
||||
return statusScore + incidentScore + uptimePenalty + responsePenalty + maintenanceOffset;
|
||||
};
|
||||
|
||||
// Filter resources by tag/group/quick filters
|
||||
// Filter resources by search text, tag, group, and quick filters
|
||||
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;
|
||||
@@ -369,7 +389,7 @@ function Dashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
setRenderLimit(120);
|
||||
}, [tagFilter, groupFilter, quickFilter, sortKey]);
|
||||
}, [searchText, tagFilter, groupFilter, quickFilter, sortKey]);
|
||||
|
||||
const visibleResources = sortedResources.slice(0, renderLimit);
|
||||
|
||||
@@ -419,8 +439,17 @@ function Dashboard() {
|
||||
<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.url}</p>
|
||||
<p className="resource-url row-url">{resource.type === 'heartbeat' ? '(heartbeat)' : resource.url}</p>
|
||||
<p className="resource-type row-type">{resource.type}</p>
|
||||
</div>
|
||||
|
||||
@@ -495,6 +524,13 @@ function Dashboard() {
|
||||
</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"
|
||||
@@ -746,16 +782,18 @@ function Dashboard() {
|
||||
/>
|
||||
</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
|
||||
required={formData.type !== 'heartbeat'}
|
||||
placeholder="https://your-zima.example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Group</label>
|
||||
@@ -783,9 +821,24 @@ function Dashboard() {
|
||||
<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
|
||||
@@ -796,6 +849,7 @@ function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.type !== 'heartbeat' && (
|
||||
<div className="form-group">
|
||||
<label>Timeout (ms)</label>
|
||||
<input
|
||||
@@ -805,6 +859,7 @@ function Dashboard() {
|
||||
min="1000"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(formData.type === 'http' || formData.type === 'https' || formData.type === 'health') && (
|
||||
<>
|
||||
@@ -941,6 +996,16 @@ function Dashboard() {
|
||||
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
|
||||
</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
|
||||
@@ -991,9 +1056,38 @@ function Dashboard() {
|
||||
<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"
|
||||
/>
|
||||
<small style={{ color: '#666', fontSize: '0.85rem' }}>Alert if no ping received within this window</small>
|
||||
</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
|
||||
@@ -1163,6 +1257,16 @@ function Dashboard() {
|
||||
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
|
||||
</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
|
||||
@@ -1207,6 +1311,13 @@ function ResourceDetail() {
|
||||
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({
|
||||
@@ -1301,9 +1412,9 @@ function ResourceDetail() {
|
||||
setShowIncidentModal(false);
|
||||
setEditingIncident(null);
|
||||
setIncidentDescription('');
|
||||
loadIncidents(); // Reload to show updated description
|
||||
loadIncidents();
|
||||
} catch (error) {
|
||||
alert('Failed to update incident: ' + (error.response?.data?.error || error.message));
|
||||
showNotification('Error', error.response?.data?.error || 'Failed to update incident', 'error');
|
||||
} finally {
|
||||
setUpdatingIncident(false);
|
||||
}
|
||||
@@ -1337,9 +1448,9 @@ function ResourceDetail() {
|
||||
setShowMaintenanceModal(false);
|
||||
setMaintenanceForm({ start_time: '', end_time: '', reason: '' });
|
||||
loadMaintenanceWindows();
|
||||
alert('Maintenance window created');
|
||||
showNotification('Success', 'Maintenance window created', 'success');
|
||||
} catch (error) {
|
||||
alert('Error creating maintenance window');
|
||||
showNotification('Error', 'Failed to create maintenance window', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1349,7 +1460,7 @@ function ResourceDetail() {
|
||||
await axios.delete(`/api/maintenance-windows/${windowId}`);
|
||||
loadMaintenanceWindows();
|
||||
} catch (error) {
|
||||
alert('Error deleting maintenance window');
|
||||
showNotification('Error', 'Failed to delete maintenance window', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1360,7 +1471,7 @@ function ResourceDetail() {
|
||||
await axios.delete(`/api/resources/${id}`);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
alert('Error deleting resource');
|
||||
showNotification('Error', 'Failed to delete resource', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1392,6 +1503,17 @@ function ResourceDetail() {
|
||||
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,
|
||||
@@ -1400,6 +1522,7 @@ function ResourceDetail() {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
{toastStack}
|
||||
<Link to="/" className="back-button">← Back to Dashboard</Link>
|
||||
|
||||
<div className="detail-section">
|
||||
@@ -1617,8 +1740,8 @@ function ResourceDetail() {
|
||||
const isTruncated = description.length > 60;
|
||||
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
|
||||
return (
|
||||
<>
|
||||
<tr key={incident.id}>
|
||||
<React.Fragment key={incident.id}>
|
||||
<tr>
|
||||
<td>{start ? formatLocalTime(start) : '-'}</td>
|
||||
<td>{end ? formatLocalTime(end) : 'Open'}</td>
|
||||
<td>{durationText}</td>
|
||||
@@ -1646,7 +1769,7 @@ function ResourceDetail() {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
@@ -1821,6 +1944,7 @@ function Navbar() {
|
||||
<Link to="/sla" className="nav-link">SLA</Link>
|
||||
<Link to="/observability" className="nav-link">Observability</Link>
|
||||
<Link to="/notifications" className="nav-link">Notifications</Link>
|
||||
<Link to="/status" className="nav-link" target="_blank" rel="noreferrer">Status</Link>
|
||||
<Link to="/settings" className="nav-link settings-link">
|
||||
Settings
|
||||
{!notificationsConfigured && (
|
||||
|
||||
@@ -13,6 +13,7 @@ function SettingsWizard() {
|
||||
email_to: '',
|
||||
webhook_enabled: false,
|
||||
webhook_url: '',
|
||||
webhook_template: '',
|
||||
check_interval: 60000,
|
||||
timeout: 5000,
|
||||
timezone: 'UTC',
|
||||
@@ -290,10 +291,22 @@ function SettingsWizard() {
|
||||
<small>Receives POST requests with incident data when resources go offline/online</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Payload Template (optional)</label>
|
||||
<textarea
|
||||
value={settings.webhook_template}
|
||||
onChange={(e) => setSettings({ ...settings, webhook_template: e.target.value })}
|
||||
placeholder={'{"text": "{{name}} is {{status}} — {{url}}"}'}
|
||||
rows="4"
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<small>Variables: <code>{'{{name}}'}</code> <code>{'{{status}}'}</code> <code>{'{{url}}'}</code> <code>{'{{type}}'}</code> <code>{'{{message}}'}</code> <code>{'{{timestamp}}'}</code>. Leave blank for the default JSON payload.</small>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<strong>💡 Webhook Payload Example:</strong>
|
||||
<strong>Default payload (when no template is set):</strong>
|
||||
<pre>{JSON.stringify({
|
||||
resource: "ZimaOS Server",
|
||||
resource: "My Server",
|
||||
url: "https://example.com",
|
||||
status: "down",
|
||||
message: "Resource is DOWN",
|
||||
@@ -314,6 +327,7 @@ function SettingsWizard() {
|
||||
onClick={() => handleSaveSection('Webhook', {
|
||||
webhook_enabled: settings.webhook_enabled,
|
||||
webhook_url: settings.webhook_url,
|
||||
webhook_template: settings.webhook_template,
|
||||
})}
|
||||
disabled={savingSection === 'Webhook'}
|
||||
>
|
||||
|
||||
@@ -18,14 +18,10 @@ function Status() {
|
||||
|
||||
const loadStatusData = async () => {
|
||||
try {
|
||||
const [resourcesRes, incidentsRes, maintenanceRes] = await Promise.all([
|
||||
axios.get('/api/resources'),
|
||||
axios.get('/api/incidents'),
|
||||
axios.get('/api/maintenance-windows'),
|
||||
]);
|
||||
setResources(resourcesRes.data.resources || []);
|
||||
setIncidents(incidentsRes.data.incidents || []);
|
||||
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
|
||||
const statusRes = await axios.get('/api/status-page');
|
||||
setResources(statusRes.data.resources || []);
|
||||
setIncidents(statusRes.data.incidents || []);
|
||||
setMaintenanceWindows(statusRes.data.maintenanceWindows || []);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
// Status load error handled
|
||||
|
||||
@@ -311,10 +311,19 @@ try {
|
||||
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run();
|
||||
} catch (err) {}
|
||||
|
||||
// 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) {}
|
||||
try { db.prepare("ALTER TABLE resources ADD COLUMN last_heartbeat_at DATETIME").run(); } catch(e) {}
|
||||
|
||||
// Feature: status page per-resource visibility
|
||||
try { db.prepare("ALTER TABLE resources ADD COLUMN is_public INTEGER DEFAULT 1").run(); } catch(e) {}
|
||||
|
||||
// Helpful indexes for time-range queries
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_resource_time ON incidents(resource_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_heartbeat_token ON resources(heartbeat_token);
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
+226
-208
@@ -3,6 +3,7 @@ const cors = require('cors');
|
||||
const path = require('path');
|
||||
const WebSocket = require('ws');
|
||||
const http = require('http');
|
||||
const { randomUUID } = require('crypto');
|
||||
require('dotenv').config();
|
||||
|
||||
const db = require('./database');
|
||||
@@ -105,6 +106,123 @@ function getTimezoneOffset() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build settings object merging DB overrides on top of env vars
|
||||
function buildSettingsFromDb(dbSettings = {}) {
|
||||
return {
|
||||
email_enabled: (dbSettings.email_enabled || process.env.EMAIL_ENABLED) === 'true',
|
||||
email_host: dbSettings.email_host || process.env.EMAIL_HOST || 'smtp.gmail.com',
|
||||
email_port: parseInt(dbSettings.email_port || process.env.EMAIL_PORT) || 587,
|
||||
email_user: dbSettings.email_user || process.env.EMAIL_USER || '',
|
||||
email_pass: '',
|
||||
email_from: dbSettings.email_from || process.env.EMAIL_FROM || '',
|
||||
email_to: dbSettings.email_to || process.env.EMAIL_TO || '',
|
||||
webhook_enabled: (dbSettings.webhook_enabled || process.env.WEBHOOK_ENABLED) === 'true',
|
||||
webhook_url: dbSettings.webhook_url || process.env.WEBHOOK_URL || '',
|
||||
check_interval: parseInt(dbSettings.check_interval || process.env.CHECK_INTERVAL) || 60000,
|
||||
timeout: parseInt(dbSettings.timeout || process.env.TIMEOUT) || 5000,
|
||||
timezone: dbSettings.timezone || process.env.TIMEZONE || 'UTC',
|
||||
retention_days: parseInt(dbSettings.retention_days || process.env.RETENTION_DAYS) || 7,
|
||||
auto_cleanup_enabled: (dbSettings.auto_cleanup_enabled || process.env.AUTO_CLEANUP_ENABLED) === 'true',
|
||||
consecutive_failures: parseInt(dbSettings.consecutive_failures || process.env.CONSECUTIVE_FAILURES) || 3,
|
||||
grace_period: parseInt(dbSettings.grace_period || process.env.GRACE_PERIOD) || 300,
|
||||
downtime_threshold: parseInt(dbSettings.downtime_threshold || process.env.DOWNTIME_THRESHOLD) || 600,
|
||||
alert_retry_count: parseInt(dbSettings.alert_retry_count || process.env.ALERT_RETRY_COUNT) || 3,
|
||||
alert_retry_delay: parseInt(dbSettings.alert_retry_delay || process.env.ALERT_RETRY_DELAY) || 60,
|
||||
fallback_webhook: dbSettings.fallback_webhook || process.env.FALLBACK_WEBHOOK || '',
|
||||
global_quiet_hours_start: dbSettings.global_quiet_hours_start || process.env.GLOBAL_QUIET_HOURS_START || '',
|
||||
global_quiet_hours_end: dbSettings.global_quiet_hours_end || process.env.GLOBAL_QUIET_HOURS_END || '',
|
||||
escalation_hours: parseInt(dbSettings.escalation_hours || process.env.ESCALATION_HOURS) || 4,
|
||||
default_sort: dbSettings.default_sort || process.env.DEFAULT_SORT || 'name',
|
||||
items_per_page: parseInt(dbSettings.items_per_page || process.env.ITEMS_PER_PAGE) || 20,
|
||||
refresh_interval: parseInt(dbSettings.refresh_interval || process.env.REFRESH_INTERVAL) || 5000,
|
||||
theme: dbSettings.theme || process.env.THEME || 'light',
|
||||
incident_failure_threshold: parseInt(dbSettings.incident_failure_threshold || process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
|
||||
webhook_template: dbSettings.webhook_template || process.env.WEBHOOK_TEMPLATE || '',
|
||||
};
|
||||
}
|
||||
|
||||
// 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();
|
||||
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
|
||||
|
||||
const overview = resources.map(resource => {
|
||||
const lastCheck = monitorService.getLastCheck(resource.id);
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents WHERE resource_id = ? AND resolved_at IS NULL
|
||||
`).get(resource.id);
|
||||
const recentChecks = db.prepare(`
|
||||
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
|
||||
FROM checks WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC LIMIT 15
|
||||
`).all(resource.id).reverse();
|
||||
|
||||
// Pull cert expiry from latest TLS check details
|
||||
let certDaysRemaining = null;
|
||||
if (resource.type === 'tls') {
|
||||
const lastTlsCheck = db.prepare(`
|
||||
SELECT details FROM checks WHERE resource_id = ? AND details IS NOT NULL ORDER BY checked_at DESC LIMIT 1
|
||||
`).get(resource.id);
|
||||
if (lastTlsCheck?.details) {
|
||||
try {
|
||||
const d = JSON.parse(lastTlsCheck.details);
|
||||
if (typeof d.days_remaining === 'number') certDaysRemaining = d.days_remaining;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
url: resource.url,
|
||||
type: resource.type,
|
||||
group_id: resource.group_id,
|
||||
enabled: resource.enabled,
|
||||
check_interval: resource.check_interval,
|
||||
timeout: resource.timeout,
|
||||
http_keyword: resource.http_keyword,
|
||||
http_headers: resource.http_headers,
|
||||
quiet_hours_start: resource.quiet_hours_start,
|
||||
quiet_hours_end: resource.quiet_hours_end,
|
||||
cert_expiry_days: resource.cert_expiry_days,
|
||||
sla_target: resource.sla_target,
|
||||
email_to: resource.email_to,
|
||||
maintenance_mode: resource.maintenance_mode,
|
||||
is_public: resource.is_public !== 0,
|
||||
heartbeat_token: resource.heartbeat_token,
|
||||
heartbeat_timeout: resource.heartbeat_timeout,
|
||||
consecutive_failures_threshold: resource.consecutive_failures_threshold,
|
||||
response_time_threshold: resource.response_time_threshold,
|
||||
certDaysRemaining,
|
||||
status: lastCheck?.status || 'unknown',
|
||||
uptime: stats.uptime,
|
||||
avgResponseTime: stats.avgResponseTime,
|
||||
lastCheck: lastCheck?.checked_at,
|
||||
hasActiveIncident: !!activeIncident,
|
||||
recentChecks,
|
||||
};
|
||||
});
|
||||
|
||||
return { resources: overview, groups };
|
||||
}
|
||||
|
||||
// Validate resource body fields (shared between POST and PUT)
|
||||
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;
|
||||
}
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
@@ -135,17 +253,6 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Error tracking middleware
|
||||
app.use((err, req, res, next) => {
|
||||
metrics.trackError(err, {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
ip: req.ip
|
||||
});
|
||||
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
|
||||
// Get all groups
|
||||
@@ -204,42 +311,24 @@ 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 } = 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 } = req.body;
|
||||
|
||||
if (!name || !url) {
|
||||
return res.status(400).json({ error: 'Name and URL are required' });
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: 'Invalid URL format' });
|
||||
}
|
||||
|
||||
// Validate numeric fields
|
||||
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) {
|
||||
return res.status(400).json({ error: 'Check interval must be at least 10000ms' });
|
||||
}
|
||||
if (timeout && (isNaN(timeout) || timeout < 1000)) {
|
||||
return res.status(400).json({ error: 'Timeout must be at least 1000ms' });
|
||||
}
|
||||
if (sla_target && (isNaN(sla_target) || sla_target < 0 || sla_target > 100)) {
|
||||
return res.status(400).json({ error: 'SLA target must be between 0 and 100' });
|
||||
}
|
||||
if (retention_days && (isNaN(retention_days) || retention_days < 1 || retention_days > 365)) {
|
||||
return res.status(400).json({ error: 'Retention days must be between 1 and 365' });
|
||||
}
|
||||
const validationError = validateResourceBody(req.body);
|
||||
if (validationError) return res.status(400).json({ error: validationError });
|
||||
|
||||
try {
|
||||
const isHeartbeat = (type || 'http') === 'heartbeat';
|
||||
const token = isHeartbeat ? randomUUID() : null;
|
||||
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)
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
name,
|
||||
url,
|
||||
effectiveUrl,
|
||||
type || 'http',
|
||||
check_interval || 60000,
|
||||
timeout || 5000,
|
||||
@@ -252,7 +341,12 @@ app.post('/api/resources', (req, res) => {
|
||||
sla_target || 99.9,
|
||||
email_to || null,
|
||||
maintenance_mode ? 1 : 0,
|
||||
retention_days || null
|
||||
retention_days || null,
|
||||
is_public !== false ? 1 : 0,
|
||||
token,
|
||||
heartbeat_timeout || 300000,
|
||||
consecutive_failures_threshold || 1,
|
||||
response_time_threshold || null
|
||||
);
|
||||
|
||||
// Invalidate related cache entries
|
||||
@@ -262,9 +356,8 @@ app.post('/api/resources', (req, res) => {
|
||||
// Audit log
|
||||
auditLog.logResourceChange('create', result.lastInsertRowid, { name, url, type, enabled: true }, 'system', req.ip);
|
||||
|
||||
res.json({ id: result.lastInsertRowid, message: 'Resource created' });
|
||||
res.json({ id: result.lastInsertRowid, message: 'Resource created', heartbeat_token: token });
|
||||
} catch (err) {
|
||||
// Helpful hint if migration missing
|
||||
const hint = err.message.includes('no column named maintenance_mode')
|
||||
? 'Database schema missing maintenance_mode. Restart server to run migrations.'
|
||||
: undefined;
|
||||
@@ -274,17 +367,24 @@ 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 } = 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 } = req.body;
|
||||
|
||||
const validationError = validateResourceBody(req.body);
|
||||
if (validationError) return res.status(400).json({ error: validationError });
|
||||
|
||||
const existing = db.prepare('SELECT url, type, heartbeat_token FROM resources WHERE id = ?').get(req.params.id);
|
||||
const isHeartbeat = (type || existing?.type || 'http') === 'heartbeat';
|
||||
const effectiveUrl = isHeartbeat ? (existing?.url || `heartbeat://${name}`) : url;
|
||||
|
||||
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 = ?
|
||||
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 = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
name,
|
||||
url,
|
||||
effectiveUrl,
|
||||
type,
|
||||
check_interval,
|
||||
timeout,
|
||||
@@ -299,10 +399,13 @@ app.put('/api/resources/:id', (req, res) => {
|
||||
email_to || null,
|
||||
maintenance_mode ? 1 : 0,
|
||||
retention_days || null,
|
||||
is_public !== false ? 1 : 0,
|
||||
heartbeat_timeout || 300000,
|
||||
consecutive_failures_threshold || 1,
|
||||
response_time_threshold || null,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
// Invalidate related cache entries
|
||||
cache.invalidatePattern('history:');
|
||||
cache.invalidatePattern('sla:');
|
||||
|
||||
@@ -382,68 +485,20 @@ app.patch('/api/resources/:id/maintenance-mode', (req, res) => {
|
||||
// Delete resource
|
||||
app.delete('/api/resources/:id', (req, res) => {
|
||||
const resource = db.prepare('SELECT name, url FROM resources WHERE id = ?').get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ error: 'Resource not found' });
|
||||
|
||||
db.prepare('DELETE FROM resources WHERE id = ?').run(req.params.id);
|
||||
|
||||
// Invalidate related cache entries
|
||||
cache.invalidatePattern('history:');
|
||||
cache.invalidatePattern('sla:');
|
||||
|
||||
// Audit log
|
||||
if (resource) {
|
||||
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
|
||||
}
|
||||
|
||||
res.json({ message: 'Resource deleted' });
|
||||
});
|
||||
|
||||
// Get dashboard overview (grouped)
|
||||
app.get('/api/dashboard', (req, res) => {
|
||||
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
|
||||
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
|
||||
|
||||
const overview = resources.map(resource => {
|
||||
const lastCheck = monitorService.getLastCheck(resource.id);
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents
|
||||
WHERE resource_id = ? AND resolved_at IS NULL
|
||||
`).get(resource.id);
|
||||
|
||||
const recentChecks = db.prepare(`
|
||||
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
|
||||
FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT 15
|
||||
`).all(resource.id).reverse();
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
url: resource.url,
|
||||
type: resource.type,
|
||||
group_id: resource.group_id,
|
||||
enabled: resource.enabled,
|
||||
check_interval: resource.check_interval,
|
||||
timeout: resource.timeout,
|
||||
http_keyword: resource.http_keyword,
|
||||
http_headers: resource.http_headers,
|
||||
quiet_hours_start: resource.quiet_hours_start,
|
||||
quiet_hours_end: resource.quiet_hours_end,
|
||||
cert_expiry_days: resource.cert_expiry_days,
|
||||
sla_target: resource.sla_target,
|
||||
email_to: resource.email_to,
|
||||
status: lastCheck?.status || 'unknown',
|
||||
uptime: stats.uptime,
|
||||
avgResponseTime: stats.avgResponseTime,
|
||||
lastCheck: lastCheck?.checked_at,
|
||||
hasActiveIncident: !!activeIncident,
|
||||
recentChecks,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ resources: overview, groups });
|
||||
res.json(getDashboardPayload());
|
||||
});
|
||||
|
||||
// Get incidents
|
||||
@@ -550,90 +605,20 @@ app.post('/api/notifications/clear', (req, res) => {
|
||||
|
||||
// Get settings
|
||||
app.get('/api/settings', (req, res) => {
|
||||
// Prevent browser caching - always fetch fresh data
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
res.set('Expires', '0');
|
||||
|
||||
// Try to get settings from database first, fallback to process.env
|
||||
try {
|
||||
const dbSettings = {};
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all();
|
||||
for (const row of rows) {
|
||||
dbSettings[row.key] = row.value;
|
||||
}
|
||||
|
||||
// Build response with database values taking precedence over env vars
|
||||
const settings = {
|
||||
email_enabled: (dbSettings.email_enabled || process.env.EMAIL_ENABLED) === 'true',
|
||||
email_host: dbSettings.email_host || process.env.EMAIL_HOST || 'smtp.gmail.com',
|
||||
email_port: parseInt(dbSettings.email_port || process.env.EMAIL_PORT) || 587,
|
||||
email_user: dbSettings.email_user || process.env.EMAIL_USER || '',
|
||||
email_pass: '', // Don't send password to client
|
||||
email_from: dbSettings.email_from || process.env.EMAIL_FROM || '',
|
||||
email_to: dbSettings.email_to || process.env.EMAIL_TO || '',
|
||||
webhook_enabled: (dbSettings.webhook_enabled || process.env.WEBHOOK_ENABLED) === 'true',
|
||||
webhook_url: dbSettings.webhook_url || process.env.WEBHOOK_URL || '',
|
||||
check_interval: parseInt(dbSettings.check_interval || process.env.CHECK_INTERVAL) || 60000,
|
||||
timeout: parseInt(dbSettings.timeout || process.env.TIMEOUT) || 5000,
|
||||
timezone: dbSettings.timezone || process.env.TIMEZONE || 'UTC',
|
||||
// Data retention settings
|
||||
retention_days: parseInt(dbSettings.retention_days || process.env.RETENTION_DAYS) || 7,
|
||||
auto_cleanup_enabled: (dbSettings.auto_cleanup_enabled || process.env.AUTO_CLEANUP_ENABLED) === 'true',
|
||||
// Incident thresholds
|
||||
consecutive_failures: parseInt(dbSettings.consecutive_failures || process.env.CONSECUTIVE_FAILURES) || 3,
|
||||
grace_period: parseInt(dbSettings.grace_period || process.env.GRACE_PERIOD) || 300,
|
||||
downtime_threshold: parseInt(dbSettings.downtime_threshold || process.env.DOWNTIME_THRESHOLD) || 600,
|
||||
// Alert retry logic
|
||||
alert_retry_count: parseInt(dbSettings.alert_retry_count || process.env.ALERT_RETRY_COUNT) || 3,
|
||||
alert_retry_delay: parseInt(dbSettings.alert_retry_delay || process.env.ALERT_RETRY_DELAY) || 60,
|
||||
fallback_webhook: dbSettings.fallback_webhook || process.env.FALLBACK_WEBHOOK || '',
|
||||
// Alert scheduling
|
||||
global_quiet_hours_start: dbSettings.global_quiet_hours_start || process.env.GLOBAL_QUIET_HOURS_START || '',
|
||||
global_quiet_hours_end: dbSettings.global_quiet_hours_end || process.env.GLOBAL_QUIET_HOURS_END || '',
|
||||
escalation_hours: parseInt(dbSettings.escalation_hours || process.env.ESCALATION_HOURS) || 4,
|
||||
// Dashboard customization
|
||||
default_sort: dbSettings.default_sort || process.env.DEFAULT_SORT || 'name',
|
||||
items_per_page: parseInt(dbSettings.items_per_page || process.env.ITEMS_PER_PAGE) || 20,
|
||||
refresh_interval: parseInt(dbSettings.refresh_interval || process.env.REFRESH_INTERVAL) || 5000,
|
||||
theme: dbSettings.theme || process.env.THEME || 'light',
|
||||
incident_failure_threshold: parseInt(dbSettings.incident_failure_threshold || process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
|
||||
};
|
||||
res.json(settings);
|
||||
res.json(buildSettingsFromDb(dbSettings));
|
||||
} catch (error) {
|
||||
console.error('Error reading settings:', error);
|
||||
// Fallback to env vars only if database read fails
|
||||
const settings = {
|
||||
email_enabled: process.env.EMAIL_ENABLED === 'true',
|
||||
email_host: process.env.EMAIL_HOST || 'smtp.gmail.com',
|
||||
email_port: parseInt(process.env.EMAIL_PORT) || 587,
|
||||
email_user: process.env.EMAIL_USER || '',
|
||||
email_pass: '', // Don't send password to client
|
||||
email_from: process.env.EMAIL_FROM || '',
|
||||
email_to: process.env.EMAIL_TO || '',
|
||||
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
|
||||
webhook_url: process.env.WEBHOOK_URL || '',
|
||||
check_interval: parseInt(process.env.CHECK_INTERVAL) || 60000,
|
||||
timeout: parseInt(process.env.TIMEOUT) || 5000,
|
||||
timezone: process.env.TIMEZONE || 'UTC',
|
||||
retention_days: parseInt(process.env.RETENTION_DAYS) || 7,
|
||||
auto_cleanup_enabled: process.env.AUTO_CLEANUP_ENABLED === 'true',
|
||||
consecutive_failures: parseInt(process.env.CONSECUTIVE_FAILURES) || 3,
|
||||
grace_period: parseInt(process.env.GRACE_PERIOD) || 300,
|
||||
downtime_threshold: parseInt(process.env.DOWNTIME_THRESHOLD) || 600,
|
||||
alert_retry_count: parseInt(process.env.ALERT_RETRY_COUNT) || 3,
|
||||
alert_retry_delay: parseInt(process.env.ALERT_RETRY_DELAY) || 60,
|
||||
fallback_webhook: process.env.FALLBACK_WEBHOOK || '',
|
||||
global_quiet_hours_start: process.env.GLOBAL_QUIET_HOURS_START || '',
|
||||
global_quiet_hours_end: process.env.GLOBAL_QUIET_HOURS_END || '',
|
||||
escalation_hours: parseInt(process.env.ESCALATION_HOURS) || 4,
|
||||
default_sort: process.env.DEFAULT_SORT || 'name',
|
||||
items_per_page: parseInt(process.env.ITEMS_PER_PAGE) || 20,
|
||||
refresh_interval: parseInt(process.env.REFRESH_INTERVAL) || 5000,
|
||||
theme: process.env.THEME || 'light',
|
||||
incident_failure_threshold: parseInt(process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
|
||||
};
|
||||
res.json(settings);
|
||||
res.json(buildSettingsFromDb({}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -672,6 +657,7 @@ app.post('/api/settings', (req, res) => {
|
||||
refresh_interval,
|
||||
theme,
|
||||
incident_failure_threshold,
|
||||
webhook_template,
|
||||
} = req.body;
|
||||
|
||||
// Validate email configuration if enabled
|
||||
@@ -738,6 +724,7 @@ ITEMS_PER_PAGE=${items_per_page || 20}
|
||||
REFRESH_INTERVAL=${refresh_interval || 5000}
|
||||
THEME=${theme || 'light'}
|
||||
INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
|
||||
WEBHOOK_TEMPLATE=${webhook_template || ''}
|
||||
`;
|
||||
|
||||
try {
|
||||
@@ -772,6 +759,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
|
||||
process.env.REFRESH_INTERVAL = String(refresh_interval || 5000);
|
||||
process.env.THEME = theme || 'light';
|
||||
process.env.INCIDENT_FAILURE_THRESHOLD = String(incident_failure_threshold || 10);
|
||||
process.env.WEBHOOK_TEMPLATE = webhook_template || '';
|
||||
|
||||
// Also save to database for persistence
|
||||
const settingsTable = [
|
||||
@@ -783,6 +771,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
|
||||
{ key: 'email_to', value: email_to },
|
||||
{ key: 'webhook_enabled', value: String(webhook_enabled) },
|
||||
{ key: 'webhook_url', value: webhook_url },
|
||||
{ key: 'webhook_template', value: webhook_template || '' },
|
||||
{ key: 'timezone', value: timezone || 'UTC' },
|
||||
{ key: 'retention_days', value: String(retention_days || 7) },
|
||||
{ key: 'check_interval', value: String(check_interval || 60000) },
|
||||
@@ -821,6 +810,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
|
||||
email_to,
|
||||
webhook_enabled: webhook_enabled === true || webhook_enabled === 'true',
|
||||
webhook_url,
|
||||
webhook_template: webhook_template || '',
|
||||
});
|
||||
|
||||
res.json({ message: 'Settings saved successfully' });
|
||||
@@ -1717,6 +1707,67 @@ app.post('/api/resources/:id/alert-rules', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get alert rules for a resource
|
||||
app.get('/api/resources/:id/alert-rules', (req, res) => {
|
||||
const resource = db.prepare('SELECT consecutive_failures_threshold, response_time_threshold FROM resources WHERE id = ?').get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ error: 'Resource not found' });
|
||||
res.json(resource);
|
||||
});
|
||||
|
||||
// Heartbeat ping endpoint — cron jobs POST here to signal they're alive
|
||||
app.post('/api/heartbeat/:token', (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' });
|
||||
|
||||
db.prepare('UPDATE resources SET last_heartbeat_at = CURRENT_TIMESTAMP WHERE id = ?').run(resource.id);
|
||||
|
||||
// Write an 'up' check so we have a record of the ping
|
||||
db.prepare(`
|
||||
INSERT INTO checks (resource_id, status, response_time, checked_at)
|
||||
VALUES (?, 'up', 0, CURRENT_TIMESTAMP)
|
||||
`).run(resource.id);
|
||||
|
||||
res.json({ ok: true, resource: resource.name });
|
||||
});
|
||||
|
||||
// Public status page — only returns resources with is_public = 1
|
||||
app.get('/api/status-page', (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(`
|
||||
SELECT i.*, r.name AS resource_name FROM incidents i
|
||||
JOIN resources r ON r.id = i.resource_id
|
||||
WHERE r.is_public = 1 AND i.resolved_at IS NULL
|
||||
ORDER BY i.started_at DESC
|
||||
`).all();
|
||||
const maintenanceWindows = db.prepare(`
|
||||
SELECT mw.*, r.name AS resource_name FROM maintenance_windows mw
|
||||
JOIN resources r ON r.id = mw.resource_id
|
||||
WHERE r.is_public = 1
|
||||
ORDER BY mw.start_time DESC
|
||||
`).all();
|
||||
|
||||
const overview = resources.map(resource => {
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const lastCheck = monitorService.getLastCheck(resource.id);
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
url: resource.type === 'heartbeat' ? null : resource.url,
|
||||
type: resource.type,
|
||||
status: lastCheck?.status || 'unknown',
|
||||
uptime: stats.uptime,
|
||||
avgResponseTime: stats.avgResponseTime,
|
||||
lastCheck: lastCheck?.checked_at,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ resources: overview, incidents, maintenanceWindows });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to load status page data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get transaction steps for a resource
|
||||
app.get('/api/resources/:id/transaction-steps', (req, res) => {
|
||||
const { id } = req.params;
|
||||
@@ -2061,6 +2112,12 @@ app.use(express.static(path.join(__dirname, '../client/build'), {
|
||||
}
|
||||
}));
|
||||
|
||||
// Error tracking middleware — must be after all routes
|
||||
app.use((err, req, res, next) => {
|
||||
metrics.trackError(err, { method: req.method, url: req.url, ip: req.ip });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Only serve index.html for non-file routes (no extension)
|
||||
app.get('*', (req, res, next) => {
|
||||
// If the request has a file extension, let static middleware handle it or 404
|
||||
@@ -2073,46 +2130,7 @@ app.get('*', (req, res, next) => {
|
||||
// WebSocket handler function
|
||||
function broadcastDashboardUpdate() {
|
||||
try {
|
||||
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
|
||||
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
|
||||
|
||||
const overview = resources.map(resource => {
|
||||
const lastCheck = monitorService.getLastCheck(resource.id);
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents
|
||||
WHERE resource_id = ? AND resolved_at IS NULL
|
||||
`).get(resource.id);
|
||||
|
||||
const recentChecks = db.prepare(`
|
||||
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
|
||||
FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT 15
|
||||
`).all(resource.id).reverse();
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
url: resource.url,
|
||||
type: resource.type,
|
||||
group_id: resource.group_id,
|
||||
enabled: resource.enabled,
|
||||
status: lastCheck?.status || 'unknown',
|
||||
uptime: stats.uptime,
|
||||
avgResponseTime: stats.avgResponseTime,
|
||||
lastCheck: lastCheck?.checked_at,
|
||||
hasActiveIncident: !!activeIncident,
|
||||
recentChecks,
|
||||
};
|
||||
});
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'dashboard',
|
||||
data: { resources: overview, groups }
|
||||
});
|
||||
|
||||
const message = JSON.stringify({ type: 'dashboard', data: getDashboardPayload() });
|
||||
wsClients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
|
||||
@@ -98,12 +98,36 @@ class MonitorService {
|
||||
case 'icmp':
|
||||
case 'ping':
|
||||
return this.checkIcmp(resource, startTime, base);
|
||||
case 'heartbeat':
|
||||
return this.checkHeartbeat(resource, startTime, base);
|
||||
default:
|
||||
// Fallback to HTTP if unknown
|
||||
return this.checkHttp(resource, startTime, base);
|
||||
}
|
||||
}
|
||||
|
||||
checkHeartbeat(resource, startTime, base) {
|
||||
const now = Date.now();
|
||||
const lastPing = resource.last_heartbeat_at
|
||||
? new Date(resource.last_heartbeat_at).getTime()
|
||||
: null;
|
||||
const timeout = resource.heartbeat_timeout || 300000;
|
||||
|
||||
if (!lastPing) {
|
||||
return { ...base, status: 'down', response_time: null, error_message: 'No heartbeat received yet' };
|
||||
}
|
||||
const elapsed = now - lastPing;
|
||||
if (elapsed > timeout) {
|
||||
return {
|
||||
...base,
|
||||
status: 'down',
|
||||
response_time: elapsed,
|
||||
error_message: `Missed heartbeat — ${Math.round(elapsed / 1000)}s since last ping (timeout: ${timeout / 1000}s)`,
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'up', response_time: elapsed };
|
||||
}
|
||||
|
||||
async checkHttp(resource, startTime, base) {
|
||||
let result = { ...base };
|
||||
const circuitBreaker = this.getResourceCircuitBreaker(resource.id);
|
||||
@@ -353,9 +377,12 @@ class MonitorService {
|
||||
handleIncident(resourceId, isDown) {
|
||||
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(resourceId);
|
||||
|
||||
// Get incident failure threshold from settings (default 10)
|
||||
// Per-resource threshold overrides global setting
|
||||
const setting = db.prepare("SELECT value FROM settings WHERE key = 'incident_failure_threshold'").get();
|
||||
const threshold = parseInt(setting?.value || '10');
|
||||
const globalThreshold = parseInt(setting?.value || '10');
|
||||
const threshold = (resource?.consecutive_failures_threshold > 0)
|
||||
? resource.consecutive_failures_threshold
|
||||
: globalThreshold;
|
||||
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents
|
||||
@@ -371,8 +398,8 @@ class MonitorService {
|
||||
LIMIT ?
|
||||
`).all(resourceId, threshold);
|
||||
|
||||
const consecutiveFailures = recentChecks.every(c => c.status === 'down') ? recentChecks.length :
|
||||
recentChecks.findIndex(c => c.status === 'up') + 1;
|
||||
const firstUpIdx = recentChecks.findIndex(c => c.status === 'up');
|
||||
const consecutiveFailures = firstUpIdx === -1 ? recentChecks.length : firstUpIdx;
|
||||
|
||||
// Only trigger incident if we've hit the threshold
|
||||
if (consecutiveFailures >= threshold && !activeIncident) {
|
||||
|
||||
@@ -20,13 +20,14 @@ class NotificationService {
|
||||
email_to: process.env.EMAIL_TO,
|
||||
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
|
||||
webhook_url: process.env.WEBHOOK_URL,
|
||||
webhook_template: process.env.WEBHOOK_TEMPLATE || '',
|
||||
});
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
this.emailEnabled = !!config.email_enabled;
|
||||
this.webhookEnabled = !!config.webhook_enabled;
|
||||
this.config = config;
|
||||
this.config = { ...this.config, ...config };
|
||||
|
||||
// Recreate transporter when email config changes; skip if incomplete
|
||||
if (
|
||||
@@ -69,10 +70,12 @@ class NotificationService {
|
||||
}
|
||||
|
||||
const isDown = incident.type === 'started';
|
||||
const statusEmoji = isDown ? '🔴' : '🟢';
|
||||
const statusText = isDown ? 'DOWN' : 'UP';
|
||||
const isSlow = incident.type === 'slow';
|
||||
const statusEmoji = isDown ? '🔴' : isSlow ? '🟡' : '🟢';
|
||||
const statusText = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
|
||||
const slowDetail = isSlow ? `\nResponse Time: ${incident.responseTime}ms (threshold: ${incident.threshold}ms)` : '';
|
||||
|
||||
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}`;
|
||||
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}${slowDetail}`;
|
||||
|
||||
const promises = [];
|
||||
|
||||
@@ -103,16 +106,18 @@ class NotificationService {
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
// Save notification to in-app notification center
|
||||
const status = isDown ? 'down' : 'up';
|
||||
const status = isDown ? 'down' : isSlow ? 'slow' : 'up';
|
||||
this.saveNotification(resource, incident, status, message);
|
||||
}
|
||||
|
||||
saveNotification(resource, incident, status, message) {
|
||||
try {
|
||||
const db = require('./database');
|
||||
const title = status === 'down' ?
|
||||
`🔴 ${resource.name} is DOWN` :
|
||||
`🟢 ${resource.name} is UP`;
|
||||
const title = status === 'down'
|
||||
? `🔴 ${resource.name} is DOWN`
|
||||
: status === 'slow'
|
||||
? `🟡 ${resource.name} is SLOW`
|
||||
: `🟢 ${resource.name} is UP`;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO notifications (resource_id, incident_id, type, title, message, read)
|
||||
@@ -179,12 +184,18 @@ class NotificationService {
|
||||
? `<div style="margin-bottom: 12px;"><img src="cid:${logoCid}" alt="SkyWatch" style="height: 40px; width: 40px; border-radius: 8px;" /></div>`
|
||||
: '';
|
||||
|
||||
const isSlow = type === 'slow';
|
||||
const isDown = type === 'started';
|
||||
const headingColor = isDown ? '#d32f2f' : isSlow ? '#f57c00' : '#388e3c';
|
||||
const headingText = isDown ? '🔴 Alert' : isSlow ? '🟡 Slow Response' : '🟢 Recovered';
|
||||
const statusLabel = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
|
||||
|
||||
let htmlContent = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
${logoBlock}
|
||||
<h2 style="color: ${type === 'started' ? '#d32f2f' : '#388e3c'};">${type === 'started' ? '🔴 Alert' : '🟢 Recovered'}</h2>
|
||||
<h2 style="color: ${headingColor};">${headingText}</h2>
|
||||
<p><strong>Resource:</strong> ${resource.name}</p>
|
||||
<p><strong>Status:</strong> ${type === 'started' ? 'DOWN' : 'UP'}</p>
|
||||
<p><strong>Status:</strong> ${statusLabel}</p>
|
||||
<p><strong>URL:</strong> <a href="${resource.url}">${resource.url}</a></p>
|
||||
<p><strong>Check Type:</strong> ${resource.type || 'http'}</p>
|
||||
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||
@@ -221,7 +232,7 @@ class NotificationService {
|
||||
await this.transporter.sendMail({
|
||||
from: this.config.email_from,
|
||||
to: emailTo,
|
||||
subject: `Alert: ${resource.name} is ${type === 'started' ? 'DOWN' : 'UP'}`,
|
||||
subject: `Alert: ${resource.name} is ${type === 'started' ? 'DOWN' : type === 'slow' ? 'SLOW' : 'UP'}`,
|
||||
text: message,
|
||||
html: htmlContent,
|
||||
attachments: hasLogo
|
||||
@@ -282,13 +293,36 @@ class NotificationService {
|
||||
|
||||
async sendWebhook(resource, message, type) {
|
||||
try {
|
||||
await axios.post(this.config.webhook_url || process.env.WEBHOOK_URL, {
|
||||
const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
|
||||
const status = type === 'started' ? 'down' : type === 'slow' ? 'slow' : 'up';
|
||||
const template = this.config.webhook_template;
|
||||
|
||||
let payload;
|
||||
if (template) {
|
||||
const interpolated = template
|
||||
.replace(/\{\{name\}\}/g, resource.name)
|
||||
.replace(/\{\{status\}\}/g, status)
|
||||
.replace(/\{\{url\}\}/g, resource.url)
|
||||
.replace(/\{\{type\}\}/g, resource.type || 'http')
|
||||
.replace(/\{\{message\}\}/g, message)
|
||||
.replace(/\{\{timestamp\}\}/g, new Date().toISOString());
|
||||
try {
|
||||
payload = JSON.parse(interpolated);
|
||||
} catch {
|
||||
// Template is plain text (e.g. Slack text field), wrap it
|
||||
payload = { text: interpolated };
|
||||
}
|
||||
} else {
|
||||
payload = {
|
||||
resource: resource.name,
|
||||
url: resource.url,
|
||||
status: type === 'started' ? 'down' : 'up',
|
||||
status,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, payload);
|
||||
} catch (error) {
|
||||
// Webhook error handled silently
|
||||
}
|
||||
|
||||
+41
-36
@@ -7,6 +7,8 @@ class Scheduler {
|
||||
constructor() {
|
||||
this.jobs = new Map();
|
||||
this.isRunning = false;
|
||||
this.lastCheckedAt = new Map();
|
||||
this.slowAlertCooldown = new Map();
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -47,40 +49,21 @@ class Scheduler {
|
||||
SELECT id, retention_days FROM resources
|
||||
`).all();
|
||||
|
||||
const insertArchived = db.prepare(`
|
||||
const bulkArchive = db.prepare(`
|
||||
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const deleteOld = db.prepare(`
|
||||
DELETE FROM checks WHERE id = ?
|
||||
SELECT resource_id, status, response_time, status_code, error_message, details, checked_at
|
||||
FROM checks WHERE resource_id = ? AND checked_at < ?
|
||||
`);
|
||||
const bulkDelete = db.prepare(`DELETE FROM checks WHERE resource_id = ? AND checked_at < ?`);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
resources.forEach(resource => {
|
||||
// Use per-resource retention if set, otherwise use global
|
||||
const retentionDays = resource.retention_days || globalRetentionDays;
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
|
||||
|
||||
// Get old checks for this resource
|
||||
const oldChecks = db.prepare(`
|
||||
SELECT * FROM checks WHERE resource_id = ? AND checked_at < ?
|
||||
`).all(resource.id, cutoffString);
|
||||
|
||||
oldChecks.forEach(check => {
|
||||
insertArchived.run(
|
||||
check.resource_id,
|
||||
check.status,
|
||||
check.response_time,
|
||||
check.status_code,
|
||||
check.error_message,
|
||||
check.details,
|
||||
check.checked_at
|
||||
);
|
||||
deleteOld.run(check.id);
|
||||
});
|
||||
bulkArchive.run(resource.id, cutoffString);
|
||||
bulkDelete.run(resource.id, cutoffString);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +84,13 @@ class Scheduler {
|
||||
|
||||
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();
|
||||
const lastChecked = this.lastCheckedAt.get(resource.id) || 0;
|
||||
const interval = resource.check_interval || 60000;
|
||||
if (now - lastChecked < interval) return;
|
||||
this.lastCheckedAt.set(resource.id, now);
|
||||
|
||||
await sleep(index * baseSpacingMs + jitter());
|
||||
try {
|
||||
const result = await monitorService.checkResource(resource);
|
||||
@@ -111,19 +101,15 @@ class Scheduler {
|
||||
const capSetting = db.prepare(`SELECT value FROM settings WHERE key = 'checks_cap_per_resource'`).get();
|
||||
const cap = capSetting ? parseInt(capSetting.value) : 10000;
|
||||
if (Number.isFinite(cap) && cap > 0) {
|
||||
// Delete oldest rows beyond cap, using id ordering per resource
|
||||
const rowCount = db.prepare('SELECT COUNT(*) as cnt FROM checks WHERE resource_id = ?').get(resource.id).cnt;
|
||||
const excess = rowCount - cap;
|
||||
if (excess > 0) {
|
||||
db.prepare(`
|
||||
DELETE FROM checks
|
||||
WHERE id IN (
|
||||
SELECT id FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY id ASC
|
||||
LIMIT (
|
||||
SELECT MAX(COUNT(*) - ?, 0)
|
||||
FROM checks WHERE resource_id = ?
|
||||
DELETE FROM checks WHERE id IN (
|
||||
SELECT id FROM checks WHERE resource_id = ? ORDER BY id ASC LIMIT ?
|
||||
)
|
||||
)
|
||||
`).run(resource.id, cap, resource.id);
|
||||
`).run(resource.id, excess);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal
|
||||
@@ -147,6 +133,25 @@ class Scheduler {
|
||||
await notificationService.sendAlert(resource, incident, stats);
|
||||
}
|
||||
}
|
||||
|
||||
// Response time threshold alert (only when up, with 1-hour cooldown)
|
||||
if (
|
||||
result.status === 'up' &&
|
||||
resource.response_time_threshold &&
|
||||
result.response_time > resource.response_time_threshold &&
|
||||
!resource.maintenance_mode
|
||||
) {
|
||||
const lastSlowAlert = this.slowAlertCooldown.get(resource.id) || 0;
|
||||
if (now - lastSlowAlert > 60 * 60 * 1000) {
|
||||
this.slowAlertCooldown.set(resource.id, now);
|
||||
await notificationService.sendAlert(resource, {
|
||||
type: 'slow',
|
||||
responseTime: result.response_time,
|
||||
threshold: resource.response_time_threshold,
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Check error handled silently
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user