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:
nmemmertandClaude Sonnet 4.6 committed 2026-07-31 09:08:15 -04:00
1 parent 34986b5760
commit a6cf6a9762
16 files changed
+554 -422

No files matched your search

+3 -3
View File
@@ -1,15 +1,15 @@
{ {
"files": { "files": {
"main.css": "/static/css/main.6034edfe.css", "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", "static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
"index.html": "/index.html", "index.html": "/index.html",
"main.6034edfe.css.map": "/static/css/main.6034edfe.css.map", "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" "453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
}, },
"entrypoints": [ "entrypoints": [
"static/css/main.6034edfe.css", "static/css/main.6034edfe.css",
"static/js/main.243399c3.js" "static/js/main.16dc004f.js"
] ]
} }
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.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 */ /*! 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 (()=>{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 //# sourceMappingURL=main.16dc004f.js.map
@@ -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
-95
View File
@@ -3390,36 +3390,6 @@
"url": "https://github.com/sponsors/gregberge" "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": { "node_modules/@testing-library/jest-dom": {
"version": "6.9.1", "version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -3490,13 +3460,6 @@
"node": ">=10.13.0" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -6755,16 +6718,6 @@
"node": ">= 0.8" "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": { "node_modules/destroy": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -6879,13 +6832,6 @@
"node": ">=6.0.0" "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": { "node_modules/dom-converter": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -11406,16 +11352,6 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.25.9", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", "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": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
@@ -16709,20 +16628,6 @@
"is-typedarray": "^1.0.0" "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": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+149 -25
View File
@@ -49,7 +49,10 @@ function Dashboard() {
tags: '', tags: '',
consecutive_failures_threshold: 1, consecutive_failures_threshold: 1,
response_time_threshold: null, response_time_threshold: null,
is_public: true,
heartbeat_timeout: 300000,
}); });
const [searchText, setSearchText] = useState('');
const [tagFilter, setTagFilter] = useState(''); const [tagFilter, setTagFilter] = useState('');
const [groupFilter, setGroupFilter] = useState('all'); const [groupFilter, setGroupFilter] = useState('all');
const [quickFilter, setQuickFilter] = useState('all'); const [quickFilter, setQuickFilter] = useState('all');
@@ -57,6 +60,8 @@ function Dashboard() {
const [renderLimit, setRenderLimit] = useState(120); const [renderLimit, setRenderLimit] = useState(120);
const [groupData, setGroupData] = useState({ name: '', description: '' }); const [groupData, setGroupData] = useState({ name: '', description: '' });
const wsRef = useRef(null); const wsRef = useRef(null);
const wsReconnectTimer = useRef(null);
const wsReconnectDelay = useRef(1000);
const actionsRef = useRef(null); const actionsRef = useRef(null);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -88,7 +93,7 @@ function Dashboard() {
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
ws.onopen = () => { ws.onopen = () => {
// Send initial ping to keep alive wsReconnectDelay.current = 1000;
ws.send(JSON.stringify({ type: 'ping' })); ws.send(JSON.stringify({ type: 'ping' }));
}; };
@@ -127,6 +132,10 @@ function Dashboard() {
ws.onclose = () => { ws.onclose = () => {
wsRef.current = null; wsRef.current = null;
wsReconnectTimer.current = setTimeout(() => {
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
connectWebSocket();
}, wsReconnectDelay.current);
}; };
wsRef.current = ws; wsRef.current = ws;
@@ -156,6 +165,7 @@ function Dashboard() {
return () => { return () => {
clearInterval(fallbackInterval); clearInterval(fallbackInterval);
clearTimeout(wsReconnectTimer.current);
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close(); wsRef.current.close();
} }
@@ -170,14 +180,14 @@ function Dashboard() {
setGroupData({ name: '', description: '' }); setGroupData({ name: '', description: '' });
loadResources(); loadResources();
} catch (error) { } catch (error) {
alert('Error creating group'); showNotification('Error', 'Failed to create group', 'error');
} }
}; };
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
await axios.post('/api/resources', formData); const res = await axios.post('/api/resources', formData);
setShowModal(false); setShowModal(false);
setFormData({ setFormData({
name: '', name: '',
@@ -197,10 +207,16 @@ function Dashboard() {
tags: '', tags: '',
consecutive_failures_threshold: 1, consecutive_failures_threshold: 1,
response_time_threshold: null, 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(); loadResources();
} catch (error) { } catch (error) {
alert('Error creating resource'); showNotification('Error', 'Failed to add resource', 'error');
} }
}; };
@@ -221,7 +237,7 @@ function Dashboard() {
setEditData({}); setEditData({});
loadResources(); loadResources();
} catch (error) { } 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}`); await axios.delete(`/api/resources/${id}`);
loadResources(); loadResources();
} catch (error) { } catch (error) {
alert('Error deleting resource'); showNotification('Error', 'Failed to delete resource', 'error');
} }
} }
}; };
@@ -248,7 +264,7 @@ function Dashboard() {
}); });
loadResources(); loadResources();
} catch (error) { } 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`); link.setAttribute('download', `resources-${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.parentChild.removeChild(link); link.parentNode.removeChild(link);
} catch (error) { } catch (error) {
alert('Error exporting resources'); showNotification('Error', 'Failed to export resources', 'error');
} }
}; };
@@ -279,13 +295,13 @@ function Dashboard() {
headers: { 'Content-Type': 'text/csv' } 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) { if (response.data.errors.length > 0) {
alert(`Errors:\n${response.data.errors.join('\n')}`); showNotification('Import Warnings', response.data.errors[0], 'warning');
} }
loadResources(); loadResources();
} catch (error) { } catch (error) {
alert('Error importing resources'); showNotification('Error', 'Failed to import resources', 'error');
} }
// Reset file input // Reset file input
@@ -318,9 +334,13 @@ function Dashboard() {
return statusScore + incidentScore + uptimePenalty + responsePenalty + maintenanceOffset; 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 const filteredResources = resources
.filter((r) => { .filter((r) => {
if (searchText) {
const q = searchText.toLowerCase();
if (!r.name.toLowerCase().includes(q) && !r.url.toLowerCase().includes(q)) return false;
}
if (tagFilter) { if (tagFilter) {
const tags = (r.tags || '').toLowerCase(); const tags = (r.tags || '').toLowerCase();
if (!tags.includes(tagFilter.toLowerCase())) return false; if (!tags.includes(tagFilter.toLowerCase())) return false;
@@ -369,7 +389,7 @@ function Dashboard() {
useEffect(() => { useEffect(() => {
setRenderLimit(120); setRenderLimit(120);
}, [tagFilter, groupFilter, quickFilter, sortKey]); }, [searchText, tagFilter, groupFilter, quickFilter, sortKey]);
const visibleResources = sortedResources.slice(0, renderLimit); const visibleResources = sortedResources.slice(0, renderLimit);
@@ -419,8 +439,17 @@ function Dashboard() {
<span className="resource-group-pill">{groupName}</span> <span className="resource-group-pill">{groupName}</span>
{resource.hasActiveIncident && <span className="incident-inline">active incident</span>} {resource.hasActiveIncident && <span className="incident-inline">active incident</span>}
{resource.maintenance_mode && <span className="maintenance-inline">🛠 maintenance</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> </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> <p className="resource-type row-type">{resource.type}</p>
</div> </div>
@@ -495,6 +524,13 @@ function Dashboard() {
</div> </div>
<div className="dashboard-actions"> <div className="dashboard-actions">
<NotificationCenter /> <NotificationCenter />
<input
type="text"
placeholder="Search monitors..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="tag-filter-input"
/>
<input <input
type="text" type="text"
placeholder="Filter by tag" placeholder="Filter by tag"
@@ -746,16 +782,18 @@ function Dashboard() {
/> />
</div> </div>
{formData.type !== 'heartbeat' && (
<div className="form-group"> <div className="form-group">
<label>URL *</label> <label>URL *</label>
<input <input
type="url" type="url"
value={formData.url} value={formData.url}
onChange={(e) => setFormData({ ...formData, url: e.target.value })} onChange={(e) => setFormData({ ...formData, url: e.target.value })}
required required={formData.type !== 'heartbeat'}
placeholder="https://your-zima.example.com" placeholder="https://your-zima.example.com"
/> />
</div> </div>
)}
<div className="form-group"> <div className="form-group">
<label>Group</label> <label>Group</label>
@@ -783,9 +821,24 @@ function Dashboard() {
<option value="dns">DNS Lookup</option> <option value="dns">DNS Lookup</option>
<option value="websocket">WebSocket</option> <option value="websocket">WebSocket</option>
<option value="icmp">ICMP Ping</option> <option value="icmp">ICMP Ping</option>
<option value="heartbeat">Heartbeat (cron job)</option>
</select> </select>
</div> </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"> <div className="form-group">
<label>Check Interval (ms)</label> <label>Check Interval (ms)</label>
<input <input
@@ -796,6 +849,7 @@ function Dashboard() {
/> />
</div> </div>
{formData.type !== 'heartbeat' && (
<div className="form-group"> <div className="form-group">
<label>Timeout (ms)</label> <label>Timeout (ms)</label>
<input <input
@@ -805,6 +859,7 @@ function Dashboard() {
min="1000" min="1000"
/> />
</div> </div>
)}
{(formData.type === 'http' || formData.type === 'https' || formData.type === 'health') && ( {(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> <small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
</div> </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"> <div className="form-actions">
<button type="button" className="btn" onClick={() => setShowModal(false)}> <button type="button" className="btn" onClick={() => setShowModal(false)}>
Cancel Cancel
@@ -991,9 +1056,38 @@ function Dashboard() {
<option value="ping">Ping</option> <option value="ping">Ping</option>
<option value="health">Health API</option> <option value="health">Health API</option>
<option value="tls">TLS Certificate</option> <option value="tls">TLS Certificate</option>
<option value="heartbeat">Heartbeat (cron job)</option>
</select> </select>
</div> </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"> <div className="form-group">
<label>Check Interval (ms)</label> <label>Check Interval (ms)</label>
<input <input
@@ -1163,6 +1257,16 @@ function Dashboard() {
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small> <small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
</div> </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"> <div className="form-actions">
<button type="button" className="btn" onClick={() => setShowEditModal(false)}> <button type="button" className="btn" onClick={() => setShowEditModal(false)}>
Cancel Cancel
@@ -1207,6 +1311,13 @@ function ResourceDetail() {
const [slaLoading, setSlaLoading] = useState(false); const [slaLoading, setSlaLoading] = useState(false);
const slaWindow = 24; 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 [maintenanceWindows, setMaintenanceWindows] = useState([]);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const [maintenanceForm, setMaintenanceForm] = useState({ const [maintenanceForm, setMaintenanceForm] = useState({
@@ -1301,9 +1412,9 @@ function ResourceDetail() {
setShowIncidentModal(false); setShowIncidentModal(false);
setEditingIncident(null); setEditingIncident(null);
setIncidentDescription(''); setIncidentDescription('');
loadIncidents(); // Reload to show updated description loadIncidents();
} catch (error) { } 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 { } finally {
setUpdatingIncident(false); setUpdatingIncident(false);
} }
@@ -1337,9 +1448,9 @@ function ResourceDetail() {
setShowMaintenanceModal(false); setShowMaintenanceModal(false);
setMaintenanceForm({ start_time: '', end_time: '', reason: '' }); setMaintenanceForm({ start_time: '', end_time: '', reason: '' });
loadMaintenanceWindows(); loadMaintenanceWindows();
alert('Maintenance window created'); showNotification('Success', 'Maintenance window created', 'success');
} catch (error) { } 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}`); await axios.delete(`/api/maintenance-windows/${windowId}`);
loadMaintenanceWindows(); loadMaintenanceWindows();
} catch (error) { } 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}`); await axios.delete(`/api/resources/${id}`);
navigate('/'); navigate('/');
} catch (error) { } 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 (loading) return <div className="container">Loading...</div>;
if (!resource) return <div className="container">Resource not found</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) => ({ const chartData = resource.stats.checks.map((check) => ({
time: formatChartTime(check.checked_at), time: formatChartTime(check.checked_at),
responseTime: check.response_time, responseTime: check.response_time,
@@ -1400,6 +1522,7 @@ function ResourceDetail() {
return ( return (
<div className="container"> <div className="container">
{toastStack}
<Link to="/" className="back-button">← Back to Dashboard</Link> <Link to="/" className="back-button">← Back to Dashboard</Link>
<div className="detail-section"> <div className="detail-section">
@@ -1617,8 +1740,8 @@ function ResourceDetail() {
const isTruncated = description.length > 60; const isTruncated = description.length > 60;
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description); const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
return ( return (
<> <React.Fragment key={incident.id}>
<tr key={incident.id}> <tr>
<td>{start ? formatLocalTime(start) : '-'}</td> <td>{start ? formatLocalTime(start) : '-'}</td>
<td>{end ? formatLocalTime(end) : 'Open'}</td> <td>{end ? formatLocalTime(end) : 'Open'}</td>
<td>{durationText}</td> <td>{durationText}</td>
@@ -1646,7 +1769,7 @@ function ResourceDetail() {
</td> </td>
</tr> </tr>
)} )}
</> </React.Fragment>
); );
})} })}
</tbody> </tbody>
@@ -1821,6 +1944,7 @@ function Navbar() {
<Link to="/sla" className="nav-link">SLA</Link> <Link to="/sla" className="nav-link">SLA</Link>
<Link to="/observability" className="nav-link">Observability</Link> <Link to="/observability" className="nav-link">Observability</Link>
<Link to="/notifications" className="nav-link">Notifications</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"> <Link to="/settings" className="nav-link settings-link">
Settings Settings
{!notificationsConfigured && ( {!notificationsConfigured && (
+16 -2
View File
@@ -13,6 +13,7 @@ function SettingsWizard() {
email_to: '', email_to: '',
webhook_enabled: false, webhook_enabled: false,
webhook_url: '', webhook_url: '',
webhook_template: '',
check_interval: 60000, check_interval: 60000,
timeout: 5000, timeout: 5000,
timezone: 'UTC', timezone: 'UTC',
@@ -290,10 +291,22 @@ function SettingsWizard() {
<small>Receives POST requests with incident data when resources go offline/online</small> <small>Receives POST requests with incident data when resources go offline/online</small>
</div> </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"> <div className="info-box">
<strong>💡 Webhook Payload Example:</strong> <strong>Default payload (when no template is set):</strong>
<pre>{JSON.stringify({ <pre>{JSON.stringify({
resource: "ZimaOS Server", resource: "My Server",
url: "https://example.com", url: "https://example.com",
status: "down", status: "down",
message: "Resource is DOWN", message: "Resource is DOWN",
@@ -314,6 +327,7 @@ function SettingsWizard() {
onClick={() => handleSaveSection('Webhook', { onClick={() => handleSaveSection('Webhook', {
webhook_enabled: settings.webhook_enabled, webhook_enabled: settings.webhook_enabled,
webhook_url: settings.webhook_url, webhook_url: settings.webhook_url,
webhook_template: settings.webhook_template,
})} })}
disabled={savingSection === 'Webhook'} disabled={savingSection === 'Webhook'}
> >
+4 -8
View File
@@ -18,14 +18,10 @@ function Status() {
const loadStatusData = async () => { const loadStatusData = async () => {
try { try {
const [resourcesRes, incidentsRes, maintenanceRes] = await Promise.all([ const statusRes = await axios.get('/api/status-page');
axios.get('/api/resources'), setResources(statusRes.data.resources || []);
axios.get('/api/incidents'), setIncidents(statusRes.data.incidents || []);
axios.get('/api/maintenance-windows'), setMaintenanceWindows(statusRes.data.maintenanceWindows || []);
]);
setResources(resourcesRes.data.resources || []);
setIncidents(incidentsRes.data.incidents || []);
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
setLastUpdated(new Date()); setLastUpdated(new Date());
} catch (error) { } catch (error) {
// Status load error handled // Status load error handled
+9
View File
@@ -311,10 +311,19 @@ try {
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run(); db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run();
} catch (err) {} } 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 // Helpful indexes for time-range queries
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC); 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_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; module.exports = db;
+238 -220
View File
@@ -3,6 +3,7 @@ const cors = require('cors');
const path = require('path'); const path = require('path');
const WebSocket = require('ws'); const WebSocket = require('ws');
const http = require('http'); const http = require('http');
const { randomUUID } = require('crypto');
require('dotenv').config(); require('dotenv').config();
const db = require('./database'); const db = require('./database');
@@ -105,6 +106,123 @@ function getTimezoneOffset() {
return result; 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(cors());
app.use(express.json()); app.use(express.json());
@@ -135,17 +253,6 @@ app.use((req, res, next) => {
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 // API Routes
// Get all groups // Get all groups
@@ -204,42 +311,24 @@ app.get('/api/resources/:id', (req, res) => {
// Create resource // Create resource
app.post('/api/resources', (req, res) => { 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) { const validationError = validateResourceBody(req.body);
return res.status(400).json({ error: 'Name and URL are required' }); if (validationError) return res.status(400).json({ error: validationError });
}
// 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' });
}
try { try {
const isHeartbeat = (type || 'http') === 'heartbeat';
const token = isHeartbeat ? randomUUID() : null;
const effectiveUrl = isHeartbeat ? `heartbeat://${name}` : url;
const stmt = db.prepare(` 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const result = stmt.run( const result = stmt.run(
name, name,
url, effectiveUrl,
type || 'http', type || 'http',
check_interval || 60000, check_interval || 60000,
timeout || 5000, timeout || 5000,
@@ -252,7 +341,12 @@ app.post('/api/resources', (req, res) => {
sla_target || 99.9, sla_target || 99.9,
email_to || null, email_to || null,
maintenance_mode ? 1 : 0, 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 // Invalidate related cache entries
@@ -262,9 +356,8 @@ app.post('/api/resources', (req, res) => {
// Audit log // Audit log
auditLog.logResourceChange('create', result.lastInsertRowid, { name, url, type, enabled: true }, 'system', req.ip); 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) { } catch (err) {
// Helpful hint if migration missing
const hint = err.message.includes('no column named maintenance_mode') const hint = err.message.includes('no column named maintenance_mode')
? 'Database schema missing maintenance_mode. Restart server to run migrations.' ? 'Database schema missing maintenance_mode. Restart server to run migrations.'
: undefined; : undefined;
@@ -274,21 +367,28 @@ app.post('/api/resources', (req, res) => {
// Update resource // Update resource
app.put('/api/resources/:id', (req, res) => { 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(` const stmt = db.prepare(`
UPDATE resources 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 = ? WHERE id = ?
`); `);
stmt.run( stmt.run(
name, name,
url, effectiveUrl,
type, type,
check_interval, check_interval,
timeout, timeout,
enabled ? 1 : 0, enabled ? 1 : 0,
group_id || null, group_id || null,
http_keyword || null, http_keyword || null,
http_headers || null, http_headers || null,
@@ -299,10 +399,13 @@ app.put('/api/resources/:id', (req, res) => {
email_to || null, email_to || null,
maintenance_mode ? 1 : 0, maintenance_mode ? 1 : 0,
retention_days || null, retention_days || null,
is_public !== false ? 1 : 0,
heartbeat_timeout || 300000,
consecutive_failures_threshold || 1,
response_time_threshold || null,
req.params.id req.params.id
); );
// Invalidate related cache entries
cache.invalidatePattern('history:'); cache.invalidatePattern('history:');
cache.invalidatePattern('sla:'); cache.invalidatePattern('sla:');
@@ -382,68 +485,20 @@ app.patch('/api/resources/:id/maintenance-mode', (req, res) => {
// Delete resource // Delete resource
app.delete('/api/resources/:id', (req, res) => { app.delete('/api/resources/:id', (req, res) => {
const resource = db.prepare('SELECT name, url FROM resources WHERE id = ?').get(req.params.id); 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); db.prepare('DELETE FROM resources WHERE id = ?').run(req.params.id);
// Invalidate related cache entries
cache.invalidatePattern('history:'); cache.invalidatePattern('history:');
cache.invalidatePattern('sla:'); cache.invalidatePattern('sla:');
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
// Audit log
if (resource) {
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
}
res.json({ message: 'Resource deleted' }); res.json({ message: 'Resource deleted' });
}); });
// Get dashboard overview (grouped) // Get dashboard overview (grouped)
app.get('/api/dashboard', (req, res) => { app.get('/api/dashboard', (req, res) => {
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all(); res.json(getDashboardPayload());
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 });
}); });
// Get incidents // Get incidents
@@ -550,90 +605,20 @@ app.post('/api/notifications/clear', (req, res) => {
// Get settings // Get settings
app.get('/api/settings', (req, res) => { 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('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache'); res.set('Pragma', 'no-cache');
res.set('Expires', '0'); res.set('Expires', '0');
// Try to get settings from database first, fallback to process.env
try { try {
const dbSettings = {}; const dbSettings = {};
const rows = db.prepare('SELECT key, value FROM settings').all(); const rows = db.prepare('SELECT key, value FROM settings').all();
for (const row of rows) { for (const row of rows) {
dbSettings[row.key] = row.value; dbSettings[row.key] = row.value;
} }
res.json(buildSettingsFromDb(dbSettings));
// 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);
} catch (error) { } catch (error) {
console.error('Error reading settings:', error); console.error('Error reading settings:', error);
// Fallback to env vars only if database read fails res.json(buildSettingsFromDb({}));
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);
} }
}); });
@@ -672,6 +657,7 @@ app.post('/api/settings', (req, res) => {
refresh_interval, refresh_interval,
theme, theme,
incident_failure_threshold, incident_failure_threshold,
webhook_template,
} = req.body; } = req.body;
// Validate email configuration if enabled // Validate email configuration if enabled
@@ -738,6 +724,7 @@ ITEMS_PER_PAGE=${items_per_page || 20}
REFRESH_INTERVAL=${refresh_interval || 5000} REFRESH_INTERVAL=${refresh_interval || 5000}
THEME=${theme || 'light'} THEME=${theme || 'light'}
INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10} INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
WEBHOOK_TEMPLATE=${webhook_template || ''}
`; `;
try { try {
@@ -772,6 +759,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
process.env.REFRESH_INTERVAL = String(refresh_interval || 5000); process.env.REFRESH_INTERVAL = String(refresh_interval || 5000);
process.env.THEME = theme || 'light'; process.env.THEME = theme || 'light';
process.env.INCIDENT_FAILURE_THRESHOLD = String(incident_failure_threshold || 10); process.env.INCIDENT_FAILURE_THRESHOLD = String(incident_failure_threshold || 10);
process.env.WEBHOOK_TEMPLATE = webhook_template || '';
// Also save to database for persistence // Also save to database for persistence
const settingsTable = [ const settingsTable = [
@@ -783,6 +771,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
{ key: 'email_to', value: email_to }, { key: 'email_to', value: email_to },
{ key: 'webhook_enabled', value: String(webhook_enabled) }, { key: 'webhook_enabled', value: String(webhook_enabled) },
{ key: 'webhook_url', value: webhook_url }, { key: 'webhook_url', value: webhook_url },
{ key: 'webhook_template', value: webhook_template || '' },
{ key: 'timezone', value: timezone || 'UTC' }, { key: 'timezone', value: timezone || 'UTC' },
{ key: 'retention_days', value: String(retention_days || 7) }, { key: 'retention_days', value: String(retention_days || 7) },
{ key: 'check_interval', value: String(check_interval || 60000) }, { key: 'check_interval', value: String(check_interval || 60000) },
@@ -806,7 +795,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
for (const setting of settingsTable) { for (const setting of settingsTable) {
db.prepare(` db.prepare(`
INSERT OR REPLACE INTO settings (key, value, updated_at) INSERT OR REPLACE INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, CURRENT_TIMESTAMP)
`).run(setting.key, setting.value); `).run(setting.key, setting.value);
} }
@@ -821,6 +810,7 @@ INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
email_to, email_to,
webhook_enabled: webhook_enabled === true || webhook_enabled === 'true', webhook_enabled: webhook_enabled === true || webhook_enabled === 'true',
webhook_url, webhook_url,
webhook_template: webhook_template || '',
}); });
res.json({ message: 'Settings saved successfully' }); 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 // Get transaction steps for a resource
app.get('/api/resources/:id/transaction-steps', (req, res) => { app.get('/api/resources/:id/transaction-steps', (req, res) => {
const { id } = req.params; 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) // Only serve index.html for non-file routes (no extension)
app.get('*', (req, res, next) => { app.get('*', (req, res, next) => {
// If the request has a file extension, let static middleware handle it or 404 // 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 // WebSocket handler function
function broadcastDashboardUpdate() { function broadcastDashboardUpdate() {
try { try {
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all(); const message = JSON.stringify({ type: 'dashboard', data: getDashboardPayload() });
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
const overview = resources.map(resource => {
const lastCheck = monitorService.getLastCheck(resource.id);
const stats = monitorService.getResourceStats(resource.id, 24);
const activeIncident = db.prepare(`
SELECT * FROM incidents
WHERE resource_id = ? AND resolved_at IS NULL
`).get(resource.id);
const recentChecks = db.prepare(`
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
FROM checks
WHERE resource_id = ?
ORDER BY checked_at DESC
LIMIT 15
`).all(resource.id).reverse();
return {
id: resource.id,
name: resource.name,
url: resource.url,
type: resource.type,
group_id: resource.group_id,
enabled: resource.enabled,
status: lastCheck?.status || 'unknown',
uptime: stats.uptime,
avgResponseTime: stats.avgResponseTime,
lastCheck: lastCheck?.checked_at,
hasActiveIncident: !!activeIncident,
recentChecks,
};
});
const message = JSON.stringify({
type: 'dashboard',
data: { resources: overview, groups }
});
wsClients.forEach(client => { wsClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(message); client.send(message);
+31 -4
View File
@@ -98,12 +98,36 @@ class MonitorService {
case 'icmp': case 'icmp':
case 'ping': case 'ping':
return this.checkIcmp(resource, startTime, base); return this.checkIcmp(resource, startTime, base);
case 'heartbeat':
return this.checkHeartbeat(resource, startTime, base);
default: default:
// Fallback to HTTP if unknown // Fallback to HTTP if unknown
return this.checkHttp(resource, startTime, base); 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) { async checkHttp(resource, startTime, base) {
let result = { ...base }; let result = { ...base };
const circuitBreaker = this.getResourceCircuitBreaker(resource.id); const circuitBreaker = this.getResourceCircuitBreaker(resource.id);
@@ -353,9 +377,12 @@ class MonitorService {
handleIncident(resourceId, isDown) { handleIncident(resourceId, isDown) {
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(resourceId); 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 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(` const activeIncident = db.prepare(`
SELECT * FROM incidents SELECT * FROM incidents
@@ -371,8 +398,8 @@ class MonitorService {
LIMIT ? LIMIT ?
`).all(resourceId, threshold); `).all(resourceId, threshold);
const consecutiveFailures = recentChecks.every(c => c.status === 'down') ? recentChecks.length : const firstUpIdx = recentChecks.findIndex(c => c.status === 'up');
recentChecks.findIndex(c => c.status === 'up') + 1; const consecutiveFailures = firstUpIdx === -1 ? recentChecks.length : firstUpIdx;
// Only trigger incident if we've hit the threshold // Only trigger incident if we've hit the threshold
if (consecutiveFailures >= threshold && !activeIncident) { if (consecutiveFailures >= threshold && !activeIncident) {
+53 -19
View File
@@ -20,13 +20,14 @@ class NotificationService {
email_to: process.env.EMAIL_TO, email_to: process.env.EMAIL_TO,
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true', webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
webhook_url: process.env.WEBHOOK_URL, webhook_url: process.env.WEBHOOK_URL,
webhook_template: process.env.WEBHOOK_TEMPLATE || '',
}); });
} }
setConfig(config) { setConfig(config) {
this.emailEnabled = !!config.email_enabled; this.emailEnabled = !!config.email_enabled;
this.webhookEnabled = !!config.webhook_enabled; this.webhookEnabled = !!config.webhook_enabled;
this.config = config; this.config = { ...this.config, ...config };
// Recreate transporter when email config changes; skip if incomplete // Recreate transporter when email config changes; skip if incomplete
if ( if (
@@ -69,10 +70,12 @@ class NotificationService {
} }
const isDown = incident.type === 'started'; const isDown = incident.type === 'started';
const statusEmoji = isDown ? '🔴' : '🟢'; const isSlow = incident.type === 'slow';
const statusText = isDown ? 'DOWN' : 'UP'; const statusEmoji = isDown ? '🔴' : isSlow ? '🟡' : '🟢';
const statusText = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}`; 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()}${slowDetail}`;
const promises = []; const promises = [];
@@ -103,16 +106,18 @@ class NotificationService {
await Promise.allSettled(promises); await Promise.allSettled(promises);
// Save notification to in-app notification center // 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); this.saveNotification(resource, incident, status, message);
} }
saveNotification(resource, incident, status, message) { saveNotification(resource, incident, status, message) {
try { try {
const db = require('./database'); const db = require('./database');
const title = status === 'down' ? const title = status === 'down'
`🔴 ${resource.name} is DOWN` : ? `🔴 ${resource.name} is DOWN`
`🟢 ${resource.name} is UP`; : status === 'slow'
? `🟡 ${resource.name} is SLOW`
: `🟢 ${resource.name} is UP`;
const result = db.prepare(` const result = db.prepare(`
INSERT INTO notifications (resource_id, incident_id, type, title, message, read) 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>` ? `<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 = ` let htmlContent = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
${logoBlock} ${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>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>URL:</strong> <a href="${resource.url}">${resource.url}</a></p>
<p><strong>Check Type:</strong> ${resource.type || 'http'}</p> <p><strong>Check Type:</strong> ${resource.type || 'http'}</p>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p> <p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
@@ -221,7 +232,7 @@ class NotificationService {
await this.transporter.sendMail({ await this.transporter.sendMail({
from: this.config.email_from, from: this.config.email_from,
to: emailTo, 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, text: message,
html: htmlContent, html: htmlContent,
attachments: hasLogo attachments: hasLogo
@@ -282,13 +293,36 @@ class NotificationService {
async sendWebhook(resource, message, type) { async sendWebhook(resource, message, type) {
try { try {
await axios.post(this.config.webhook_url || process.env.WEBHOOK_URL, { const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
resource: resource.name, const status = type === 'started' ? 'down' : type === 'slow' ? 'slow' : 'up';
url: resource.url, const template = this.config.webhook_template;
status: type === 'started' ? 'down' : 'up',
message, let payload;
timestamp: new Date().toISOString(), 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,
message,
timestamp: new Date().toISOString(),
};
}
await axios.post(webhookUrl, payload);
} catch (error) { } catch (error) {
// Webhook error handled silently // Webhook error handled silently
} }
+45 -40
View File
@@ -7,6 +7,8 @@ class Scheduler {
constructor() { constructor() {
this.jobs = new Map(); this.jobs = new Map();
this.isRunning = false; this.isRunning = false;
this.lastCheckedAt = new Map();
this.slowAlertCooldown = new Map();
} }
start() { start() {
@@ -47,40 +49,21 @@ class Scheduler {
SELECT id, retention_days FROM resources SELECT id, retention_days FROM resources
`).all(); `).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) INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?) SELECT resource_id, status, response_time, status_code, error_message, details, checked_at
`); FROM checks WHERE resource_id = ? AND checked_at < ?
const deleteOld = db.prepare(`
DELETE FROM checks WHERE id = ?
`); `);
const bulkDelete = db.prepare(`DELETE FROM checks WHERE resource_id = ? AND checked_at < ?`);
const transaction = db.transaction(() => { const transaction = db.transaction(() => {
resources.forEach(resource => { resources.forEach(resource => {
// Use per-resource retention if set, otherwise use global
const retentionDays = resource.retention_days || globalRetentionDays; const retentionDays = resource.retention_days || globalRetentionDays;
const cutoffDate = new Date(); const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays); cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0]; const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
bulkArchive.run(resource.id, cutoffString);
// Get old checks for this resource bulkDelete.run(resource.id, cutoffString);
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);
});
}); });
}); });
@@ -101,6 +84,13 @@ class Scheduler {
const baseSpacingMs = 250; // spread starts in 250ms increments const baseSpacingMs = 250; // spread starts in 250ms increments
const tasks = resources.map((resource, index) => (async () => { 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()); await sleep(index * baseSpacingMs + jitter());
try { try {
const result = await monitorService.checkResource(resource); 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 capSetting = db.prepare(`SELECT value FROM settings WHERE key = 'checks_cap_per_resource'`).get();
const cap = capSetting ? parseInt(capSetting.value) : 10000; const cap = capSetting ? parseInt(capSetting.value) : 10000;
if (Number.isFinite(cap) && cap > 0) { 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;
db.prepare(` const excess = rowCount - cap;
DELETE FROM checks if (excess > 0) {
WHERE id IN ( db.prepare(`
SELECT id FROM checks DELETE FROM checks WHERE id IN (
WHERE resource_id = ? SELECT id FROM checks WHERE resource_id = ? ORDER BY id ASC LIMIT ?
ORDER BY id ASC
LIMIT (
SELECT MAX(COUNT(*) - ?, 0)
FROM checks WHERE resource_id = ?
) )
) `).run(resource.id, excess);
`).run(resource.id, cap, resource.id); }
} }
} catch (e) { } catch (e) {
// Non-fatal // Non-fatal
@@ -137,9 +123,9 @@ class Scheduler {
if (incident.type !== 'none') { if (incident.type !== 'none') {
const stats = monitorService.getResourceStats(resource.id, 24); const stats = monitorService.getResourceStats(resource.id, 24);
const recentChecks = db.prepare(` const recentChecks = db.prepare(`
SELECT response_time, status FROM checks SELECT response_time, status FROM checks
WHERE resource_id = ? WHERE resource_id = ?
ORDER BY checked_at DESC ORDER BY checked_at DESC
LIMIT 12 LIMIT 12
`).all(resource.id); `).all(resource.id);
stats.recentChecks = recentChecks.reverse(); stats.recentChecks = recentChecks.reverse();
@@ -147,6 +133,25 @@ class Scheduler {
await notificationService.sendAlert(resource, incident, stats); 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) { } catch (error) {
// Check error handled silently // Check error handled silently
} }