Add major features: mobile responsive, alert acknowledgment, quiet hours, HTTP keyword matching, custom headers, certificate expiry warnings, and SLA dashboard

This commit is contained in:
nmemmert committed 2025-12-11 09:20:52 -05:00
1 parent c8a8cc581a
commit 1844bccbd4
14 files changed
+586 -124

No files matched your search

+6 -6
View File
@@ -1,15 +1,15 @@
{
"files": {
"main.css": "/static/css/main.8024c431.css",
"main.js": "/static/js/main.daa1c33d.js",
"main.css": "/static/css/main.44e40ac2.css",
"main.js": "/static/js/main.f1340838.js",
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
"index.html": "/index.html",
"main.8024c431.css.map": "/static/css/main.8024c431.css.map",
"main.daa1c33d.js.map": "/static/js/main.daa1c33d.js.map",
"main.44e40ac2.css.map": "/static/css/main.44e40ac2.css.map",
"main.f1340838.js.map": "/static/js/main.f1340838.js.map",
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
},
"entrypoints": [
"static/css/main.8024c431.css",
"static/js/main.daa1c33d.js"
"static/css/main.44e40ac2.css",
"static/js/main.f1340838.js"
]
}
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>React App</title><script defer="defer" src="/static/js/main.daa1c33d.js"></script><link href="/static/css/main.8024c431.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>React App</title><script defer="defer" src="/static/js/main.f1340838.js"></script><link href="/static/css/main.44e40ac2.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
@@ -1,2 +0,0 @@
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.App{min-height:100vh}.navbar{background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 2px 8px #0000001a;color:#fff;padding:1rem 2rem}.navbar h1{font-size:1.5rem;margin:0}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-bottom:1.5rem}.resource-card{background:#fff;border:1px solid #f1f1f3;border-radius:10px;box-shadow:0 2px 8px #00000014;cursor:pointer;padding:1rem 1.1rem;transition:transform .2s,box-shadow .2s}.resource-card.compact{display:flex;flex-direction:column;gap:.4rem}.resource-card:hover{box-shadow:0 4px 12px #00000026;transform:translateY(-2px)}.resource-header{align-items:flex-start;display:flex;justify-content:space-between;margin-bottom:.5rem}.resource-name{font-size:1.05rem;font-weight:700;margin:0 0 .2rem}.resource-url{color:#666;font-size:.8rem;word-break:break-all}.status-badge{border-radius:12px;font-size:.7rem;font-weight:600;padding:.25rem .75rem;text-transform:uppercase}.status-up{background:#d4edda;color:#155724}.status-down{background:#f8d7da;color:#721c24}.status-unknown{background:#e2e3e5;color:#383d41}.stats-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:1fr 1fr;margin-top:1rem}.stat{text-align:center}.stat-value{color:#333;font-size:1.2rem;font-weight:700;margin:0}.stat-value.small{font-size:1.05rem}.stat-label{color:#666;font-size:.75rem;margin:0;text-transform:uppercase}.resource-meta{grid-gap:.75rem;align-items:center;display:grid;gap:.75rem;grid-template-columns:repeat(3,1fr)}.sparkline{height:52px;width:100%}.last-check{color:#999;font-size:.75rem;margin:0}.group-section{background:#fafafa;border-radius:10px;margin-bottom:2rem;padding:1rem}.group-header{align-items:center;background:#fff;border:1px solid #e0e0e0;border-radius:8px;cursor:pointer;display:flex;gap:1rem;margin-bottom:1rem;padding:1rem;transition:background .2s}.group-header:hover{background:#f9f9f9}.group-header h3{color:#333;font-size:1.1rem;margin:0}.group-toggle{color:#667eea;font-size:.9rem;min-width:15px}.group-stats{display:flex;font-size:.85rem;gap:1.5rem;margin-left:auto}.group-stat{background:#f0f0f0;border-radius:6px;color:#666;font-weight:500;padding:.4rem .8rem}.edit-input{border:1px solid #667eea;border-radius:6px;font-size:1.05rem;margin-bottom:.5rem;padding:.5rem;width:100%}.card-edit-form{border-top:1px solid #eee;margin-top:.75rem;padding-top:.75rem}.edit-field{margin-bottom:.5rem}.edit-field label{color:#666;display:block;font-size:.75rem;font-weight:600;margin-bottom:.25rem}.edit-field input{border:1px solid #ddd;border-radius:4px;font-size:.9rem;padding:.4rem .6rem;width:100%}.card-actions{display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem}.btn-icon{background:none;border:none;cursor:pointer;font-size:1rem;opacity:.6;padding:.25rem .5rem;transition:opacity .2s}.btn-icon:hover{opacity:1}.btn-sm{font-size:.8rem;padding:.4rem .8rem}.btn{border:none;border-radius:6px;cursor:pointer;font-size:1rem;font-weight:600;padding:.75rem 1.5rem;transition:all .2s}.btn-primary{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}.btn-primary:hover{box-shadow:0 4px 8px #667eea4d;transform:translateY(-1px)}.btn-danger{background:#dc3545;color:#fff}.btn-danger:hover{background:#c82333}.modal-overlay{align-items:center;background:#00000080;bottom:0;display:flex;justify-content:center;left:0;position:fixed;right:0;top:0;z-index:1000}.modal{background:#fff;border-radius:12px;max-height:90vh;max-width:500px;overflow-y:auto;padding:2rem;width:90%}.modal h2{margin-top:0}.form-group{margin-bottom:1.5rem}.form-group label{color:#333;display:block;font-weight:600;margin-bottom:.5rem}.form-group input,.form-group select{border:1px solid #ddd;border-radius:6px;font-size:1rem;padding:.75rem;width:100%}.form-group input:focus,.form-group select:focus{border-color:#667eea;outline:none}.form-actions{display:flex;gap:1rem;justify-content:flex-end;margin-top:2rem}.detail-section{background:#fff;border-radius:8px;box-shadow:0 2px 8px #0000001a;margin-bottom:1.5rem;padding:1.5rem}.detail-section h2{color:#333;margin-top:0}.chart-container{height:300px;margin-top:1rem}.incident-badge{background:#fff3cd;color:#856404;font-weight:600}.back-button,.incident-badge{border-radius:6px;display:inline-block;margin-bottom:1rem;padding:.5rem 1rem}.back-button{background:#6c757d;color:#fff;text-decoration:none;transition:background .2s}.back-button:hover{background:#5a6268}.empty-state{color:#666;padding:3rem;text-align:center}.empty-state h3{color:#333;margin-bottom:.5rem}.settings-wizard{margin:0 auto;max-width:900px}.settings-descriptionLine truncated
/*# sourceMappingURL=main.8024c431.css.map*/
@@ -1 +0,0 @@
{"version":3,"file":"static/css/main.8024c431.css","mappings":"AAAA,KAKE,kCAAmC,CACnC,iCAAkC,CAClC,wBAAyB,CALzB,mIAEY,CAHZ,QAOF,CAEA,EACE,qBACF,CAEA,KACE,uEAEF,CCjBA,KACE,gBACF,CAEA,QACE,kDAA6D,CAG7D,8BAAqC,CADrC,UAAY,CADZ,iBAGF,CAEA,WAEE,gBAAiB,CADjB,QAEF,CAEA,WAEE,aAAc,CADd,gBAAiB,CAEjB,YACF,CAEA,gBAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,yDAA4D,CAE5D,oBACF,CAEA,eACE,eAAiB,CAMjB,wBAAyB,CALzB,kBAAmB,CAEnB,8BAAsC,CAEtC,cAAe,CAHf,mBAAoB,CAEpB,uCAGF,CAEA,uBACE,YAAa,CACb,qBAAsB,CACtB,SACF,CAEA,qBAEE,+BAAuC,CADvC,0BAEF,CAEA,iBAGE,sBAAuB,CAFvB,YAAa,CACb,6BAA8B,CAE9B,mBACF,CAEA,eACE,iBAAkB,CAClB,eAAgB,CAChB,gBACF,CAEA,cACE,UAAW,CACX,eAAiB,CACjB,oBACF,CAEA,cAEE,kBAAmB,CACnB,eAAiB,CACjB,eAAgB,CAHhB,qBAAwB,CAIxB,wBACF,CAEA,WACE,kBAAmB,CACnB,aACF,CAEA,aACE,kBAAmB,CACnB,aACF,CAEA,gBACE,kBAAmB,CACnB,aACF,CAEA,YAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,6BAA8B,CAE9B,eACF,CAEA,MACE,iBACF,CAEA,YAGE,UAAW,CAFX,gBAAiB,CACjB,eAAgB,CAEhB,QACF,CAEA,kBACE,iBACF,CAEA,YAEE,UAAW,CADX,gBAAkB,CAGlB,QAAS,CADT,wBAEF,CAEA,eAIE,eAAY,CADZ,kBAAmB,CAFnB,YAAa,CAGb,UAAY,CAFZ,mCAGF,CAEA,WAEE,WAAY,CADZ,UAEF,CAEA,YAGE,UAAW,CADX,gBAAkB,CADlB,QAGF,CAEA,eAEE,kBAAmB,CACnB,kBAAmB,CAFnB,kBAAmB,CAGnB,YACF,CAEA,cAEE,kBAAmB,CAGnB,eAAiB,CAIjB,wBAAyB,CAHzB,iBAAkB,CAClB,cAAe,CANf,YAAa,CAEb,QAAS,CAOT,kBAAmB,CANnB,YAAa,CAIb,yBAGF,CAEA,oBACE,kBACF,CAEA,iBAGE,UAAW,CAFX,gBAAiB,CACjB,QAEF,CAEA,cAEE,aAAc,CADd,eAAiB,CAEjB,cACF,CAEA,aACE,YAAa,CAGb,gBAAkB,CAFlB,UAAW,CACX,gBAEF,CAEA,YACE,kBAAmB,CAEnB,iBAAkB,CAClB,UAAW,CACX,eAAgB,CAHhB,mBAIF,CAEA,YAEE,wBAAyB,CACzB,iBAAkB,CAClB,iBAAkB,CAElB,mBAAqB,CALrB,aAAe,CAIf,UAEF,CAEA,gBAGE,yBAA0B,CAF1B,iBAAmB,CACnB,kBAEF,CAEA,YACE,mBACF,CAEA,kBAGE,UAAW,CAFX,aAAc,CACd,gBAAkB,CAGlB,eAAgB,CADhB,oBAEF,CAEA,kBAGE,qBAAsB,CACtB,iBAAkB,CAClB,eAAiB,CAHjB,mBAAsB,CADtB,UAKF,CAEA,cACE,YAAa,CACb,SAAW,CAEX,wBAAyB,CADzB,gBAEF,CAEA,UACE,eAAgB,CAChB,WAAY,CACZ,cAAe,CACf,cAAe,CAEf,UAAY,CADZ,oBAAuB,CAEvB,sBACF,CAEA,gBACE,SACF,CAEA,QAEE,eAAiB,CADjB,mBAEF,CAEA,KAEE,WAAY,CACZ,iBAAkB,CAGlB,cAAe,CAFf,cAAe,CACf,eAAgB,CAJhB,qBAAuB,CAMvB,kBACF,CAEA,aACE,kDAA6D,CAC7D,UACF,CAEA,mBAEE,8BAA8C,CAD9C,0BAEF,CAEA,YACE,kBAAmB,CACnB,UACF,CAEA,kBACE,kBACF,CAEA,eAQE,kBAAmB,CAFnB,oBAA8B,CAD9B,QAAS,CAET,YAAa,CAEb,sBAAuB,CANvB,MAAO,CAFP,cAAe,CAGf,OAAQ,CAFR,KAAM,CAQN,YACF,CAEA,OACE,eAAiB,CACjB,kBAAmB,CAInB,eAAgB,CAFhB,eAAgB,CAGhB,eAAgB,CAJhB,YAAa,CAEb,SAGF,CAEA,UACE,YACF,CAEA,YACE,oBACF,CAEA,kBAIE,UAAW,CAHX,aAAc,CAEd,eAAgB,CADhB,mBAGF,CAEA,qCAIE,qBAAsB,CACtB,iBAAkB,CAClB,cAAe,CAHf,cAAgB,CADhB,UAKF,CAEA,iDAGE,oBAAqB,CADrB,YAEF,CAEA,cACE,YAAa,CACb,QAAS,CACT,wBAAyB,CACzB,eACF,CAEA,gBACE,eAAiB,CACjB,iBAAkB,CAGlB,8BAAqC,CADrC,oBAAqB,CADrB,cAGF,CAEA,mBAEE,UAAW,CADX,YAEF,CAEA,iBAEE,YAAa,CADb,eAEF,CAEA,gBAGE,kBAAmB,CACnB,aAAc,CAEd,eAEF,CAEA,6BALE,iBAAkB,CAJlB,oBAAqB,CAMrB,kBAAmB,CALnB,kBAiBF,CATA,aAIE,kBAAmB,CACnB,UAAY,CACZ,oBAAqB,CAErB,yBACF,CAEA,mBACE,kBACF,CAEA,aAGE,UAAW,CADX,YAAa,CADb,iBAGF,CAEA,gBACE,UAAW,CACX,mBACF,CCxYA,iBAEE,aAAc,CADd,eAEF,CAEA,sBACE,UAAW,CACX,kBACF,CAEA,kBACE,eAAiB,CACjB,iBAAkB,CAGlB,8BAAqC,CADrC,oBAAqB,CADrB,YAGF,CAEA,iBAGE,kBAAmB,CAGnB,+BAAgC,CALhC,YAAa,CACb,6BAA8B,CAE9B,oBAAqB,CACrB,mBAEF,CAEA,oBAEE,UAAW,CADX,QAEF,CAEA,eACE,iBACF,CAEA,UAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,wDAA2D,CAE3D,kBACF,CAEA,kBAEE,UAAW,CADX,aAAc,CAEd,gBAAkB,CAClB,iBACF,CAEA,UACE,kBAAmB,CACnB,6BAA8B,CAG9B,iBAAkB,CADlB,eAAgB,CADhB,YAGF,CAEA,iBAGE,UAAW,CAFX,aAAc,CACd,mBAEF,CAEA,aACE,uBAAyB,CACzB,SACF,CAEA,aAEE,UAAW,CADX,eAEF,CAEA,YACE,aAAc,CACd,oBACF,CAEA,kBACE,yBACF,CAEA,cACE,eAAiB,CAEjB,iBAAkB,CAElB,gBAAkB,CAClB,gBAAkB,CAFlB,eAAgB,CAFhB,YAKF,CAEA,eAEE,oBAAqB,CAErB,WAAY,CAHZ,iBAAkB,CAElB,UAEF,CAEA,qBAGE,QAAS,CAFT,SAAU,CACV,OAEF,CAEA,eAOE,qBAAsB,CAEtB,kBAAmB,CAHnB,QAAS,CAJT,cAAe,CAEf,MAAO,CAHP,iBAAkB,CAIlB,OAAQ,CAFR,KAAM,CAKN,cAEF,CAEA,sBAOE,qBAAuB,CAEvB,iBAAkB,CAHlB,UAAW,CAJX,UAAW,CACX,WAAY,CAEZ,QAAS,CAJT,iBAAkB,CAOlB,cAAe,CAJf,UAMF,CAEA,4CACE,wBACF,CAEA,mDACE,0BACF,CAEA,eACE,kBAAmB,CACnB,UAAY,CACZ,eACF,CAEA,oCACE,kBACF,CAEA,wBAEE,kBAAmB,CADnB,UAEF,CAEA,aAEE,iBAAkB,CAClB,eAAgB,CAFhB,YAGF,CAEA,qBACE,kBAAmB,CAEnB,wBAAyB,CADzB,aAEF,CAEA,mBACE,kBAAmB,CAEnB,wBAAyB,CADzB,aAEF,CAEA,oBACE,aAAc,CACd,mBACF,CAEA,eACE,QACF,CAEA,kBACE,YAAa,CACb,sBAAuB,CACvB,eACF,CAEA,WAEE,gBAAiB,CADjB,iBAEF","sources":["index.css","App.css","SettingsWizard.css"],"sourcesContent":["body {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',\n 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',\n sans-serif;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n background-color: #f5f5f5;\n}\n\n* {\n box-sizing: border-box;\n}\n\ncode {\n font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',\n monospace;\n}\n",".App {\n min-height: 100vh;\n}\n\n.navbar {\n background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);\n padding: 1rem 2rem;\n color: white;\n box-shadow: 0 2px 8px rgba(0,0,0,0.1);\n}\n\n.navbar h1 {\n margin: 0;\n font-size: 1.5rem;\n}\n\n.container {\n max-width: 1400px;\n margin: 0 auto;\n paddiLine truncated
-3
View File
@@ -1,3 +0,0 @@
/*! For license information please see main.daa1c33d.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 u;a&&(l[0]="leading"),i&&(l[1]="trailing");let s=null;const c=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];u=e.apply(this,n),s=null},t,{edges:l}),f=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===s&&(s=Date.now()),Date.now()-s>=o)?(u=e.apply(this,n),s=Date.now(),c.cancel(),c.schedule(),u):(c.apply(this,n),u)};return f.cancel=c.cancel,f.flush=()=>(c.flush(),u),f}},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="",u=!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:u?'"'===r||"'"===r?l=r:"]"===r?(u=!1,t.push(o),o=""):o+=r:"["===r?(u=!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,c){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 u(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 s(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,c);case"function":return Object.keys(t).length>0?l(e,r({},t),n,c):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function u(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 u=0;u<e.length;u++){if(a.has(u))continue;let s=!1;if(n(e[u],o,i,e,t,r)&&(s=!0),s){a.add(u),l=!0;break}}if(!l)return!1}return!0}function s(e,t,n,r){return 0===t.size||e instanceof Set&&u([...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,u){const s=r(t,n,a,i,o,u);return void 0!==s?Boolean(s):l(t,n,e,u)},new Map)},t.isSetMatch=s},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,u=r.useDebugValue;function s(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var c="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,c=r[1];return l(function(){a.value=n,a.getSnapshot=t,s(a)&&c({inst:a})},[e,n,t]),o(function(){return s(a)&&c({iLine truncated
//# sourceMappingURL=main.daa1c33d.js.map
@@ -1,101 +0,0 @@
/*! decimal.js-light v2.5.1 https://github.com/MikeMcl/decimal.js-light/LICENCE */
/**
* @license React
* react-dom-client.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-dom.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* use-sync-external-store-shim.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* use-sync-external-store-shim/with-selector.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* use-sync-external-store-with-selector.production.js
*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* react-router v7.10.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/** @license React v17.0.2
* react-is.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
@@ -1 +0,0 @@
{"version":3,"file":"static/js/main.daa1c33d.js","mappings":"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 truncated
+133
View File
@@ -391,3 +391,136 @@
color: #333;
margin-bottom: 0.5rem;
}
/* Mobile Responsive Styles */
@media (max-width: 768px) {
.navbar {
padding: 0.75rem 1rem;
}
.navbar h1 {
font-size: 1.25rem;
}
.navbar-links {
flex-wrap: wrap;
gap: 0.5rem;
}
.navbar-links a {
padding: 0.4rem 0.8rem;
font-size: 0.9rem;
}
.container {
padding: 1rem;
}
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.dashboard-actions {
width: 100%;
flex-direction: column;
gap: 0.5rem;
}
.dashboard-actions button,
.dashboard-actions select {
width: 100%;
}
.dashboard-grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 0.75rem;
}
.resource-card {
padding: 0.875rem;
}
.resource-meta {
flex-direction: column;
gap: 0.5rem;
}
.resource-meta > div {
text-align: left !important;
}
.chart-container {
height: 300px !important;
}
.detail-section {
padding: 1rem !important;
}
.form-row {
flex-direction: column;
gap: 0rem;
}
.form-group {
width: 100%;
}
.modal-content {
width: 95%;
max-width: none;
margin: 1rem;
padding: 1.5rem;
}
.group-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.group-stats {
width: 100%;
justify-content: space-between;
}
.card-actions {
flex-direction: row;
justify-content: flex-end;
gap: 0.5rem;
}
}
@media (max-width: 480px) {
.navbar h1 {
font-size: 1.1rem;
}
.stat-value {
font-size: 1.5rem;
}
.stat-label {
font-size: 0.75rem;
}
.stats-grid {
grid-template-columns: 1fr !important;
}
.modal-content h2 {
font-size: 1.25rem;
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
}
}
+78
View File
@@ -5,6 +5,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import './App.css';
import SettingsWizard from './SettingsWizard';
import History from './History';
import SLA from './SLA';
function Dashboard() {
const [resources, setResources] = useState([]);
@@ -21,6 +22,12 @@ function Dashboard() {
check_interval: 60000,
timeout: 5000,
group_id: null,
http_keyword: '',
http_headers: '',
quiet_hours_start: '',
quiet_hours_end: '',
cert_expiry_days: 30,
sla_target: 99.9,
});
const [groupData, setGroupData] = useState({ name: '', description: '' });
const navigate = useNavigate();
@@ -392,6 +399,75 @@ function Dashboard() {
/>
</div>
{(formData.type === 'http' || formData.type === 'https' || formData.type === 'health') && (
<>
<div className="form-group">
<label>Keyword to Match (optional)</label>
<input
type="text"
value={formData.http_keyword}
onChange={(e) => setFormData({ ...formData, http_keyword: e.target.value })}
placeholder="Text that must appear in response"
/>
</div>
<div className="form-group">
<label>Custom Headers (JSON, optional)</label>
<textarea
value={formData.http_headers}
onChange={(e) => setFormData({ ...formData, http_headers: e.target.value })}
placeholder='{"Authorization": "Bearer token"}'
rows="2"
/>
</div>
</>
)}
{formData.type === 'tls' && (
<div className="form-group">
<label>Certificate Expiry Warning (days)</label>
<input
type="number"
value={formData.cert_expiry_days}
onChange={(e) => setFormData({ ...formData, cert_expiry_days: parseInt(e.target.value) })}
min="1"
max="90"
/>
</div>
)}
<div className="form-row">
<div className="form-group">
<label>Quiet Hours Start (optional)</label>
<input
type="time"
value={formData.quiet_hours_start}
onChange={(e) => setFormData({ ...formData, quiet_hours_start: e.target.value })}
/>
</div>
<div className="form-group">
<label>Quiet Hours End (optional)</label>
<input
type="time"
value={formData.quiet_hours_end}
onChange={(e) => setFormData({ ...formData, quiet_hours_end: e.target.value })}
/>
</div>
</div>
<div className="form-group">
<label>SLA Target (%)</label>
<input
type="number"
step="0.1"
value={formData.sla_target}
onChange={(e) => setFormData({ ...formData, sla_target: parseFloat(e.target.value) })}
min="0"
max="100"
/>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={() => setShowModal(false)}>
Cancel
@@ -588,6 +664,7 @@ function Navbar() {
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<Link to="/" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Dashboard</Link>
<Link to="/history" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>History</Link>
<Link to="/sla" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>SLA</Link>
<Link to="/settings" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
Settings
{!notificationsConfigured && (
@@ -623,6 +700,7 @@ function App() {
<Route path="/" element={<Dashboard />} />
<Route path="/resource/:id" element={<ResourceDetail />} />
<Route path="/history" element={<History />} />
<Route path="/sla" element={<SLA />} />
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
</Routes>
</div>
+163
View File
@@ -0,0 +1,163 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './App.css';
function SLA() {
const [slaData, setSlaData] = useState([]);
const [days, setDays] = useState(30);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
loadSLAData();
}, [days]);
const loadSLAData = async () => {
setLoading(true);
setError(null);
try {
const response = await axios.get('/api/sla', { params: { days } });
setSlaData(response.data || []);
setLoading(false);
} catch (error) {
console.error('Error loading SLA data:', error);
setError(error.message);
setLoading(false);
}
};
const acknowledgeIncident = async (incidentId) => {
try {
await axios.post(`/api/incidents/${incidentId}/acknowledge`, {
acknowledged_by: 'User'
});
alert('Incident acknowledged');
} catch (error) {
console.error('Error acknowledging incident:', error);
alert('Error acknowledging incident');
}
};
if (loading) return <div className="container">Loading SLA data...</div>;
if (error) return <div className="container">Error loading SLA data: {error}</div>;
const overallStats = slaData.length > 0 ? {
totalResources: slaData.length,
meetingTarget: slaData.filter(s => s.meets_target).length,
avgUptime: (slaData.reduce((sum, s) => sum + parseFloat(s.actual_uptime), 0) / slaData.length).toFixed(2),
totalIncidents: slaData.reduce((sum, s) => sum + s.incidents, 0),
totalDowntime: slaData.reduce((sum, s) => sum + s.downtime_minutes, 0),
} : null;
return (
<div className="container">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
<h2>SLA Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{[7, 14, 30, 90].map((d) => (
<button
key={d}
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setDays(d)}
>
{d} Days
</button>
))}
</div>
</div>
{overallStats && (
<div className="stats-grid" style={{ marginBottom: '2rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.totalResources}</p>
<p className="stat-label">Total Resources</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2.5rem' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
<p className="stat-label">Meeting SLA Target</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.avgUptime}%</p>
<p className="stat-label">Average Uptime</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#ff9800', fontSize: '2.5rem' }}>{overallStats.totalIncidents}</p>
<p className="stat-label">Total Incidents</p>
</div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
<p className="stat-value" style={{ color: '#f44336', fontSize: '2.5rem' }}>{Math.round(overallStats.totalDowntime)}m</p>
<p className="stat-label">Total Downtime</p>
</div>
</div>
)}
{slaData.length === 0 ? (
<div className="empty-state">
<h3>No SLA data available</h3>
<p>Add resources to start tracking SLA metrics</p>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<thead>
<tr style={{ background: '#667eea', color: 'white' }}>
<th style={{ padding: '1rem', textAlign: 'left' }}>Resource</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Target</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Actual</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Status</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Checks</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Incidents</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Downtime</th>
</tr>
</thead>
<tbody>
{slaData.map((item, idx) => (
<tr key={item.resource_id} style={{ borderBottom: '1px solid #eee', background: idx % 2 === 0 ? '#fafafa' : '#fff' }}>
<td style={{ padding: '1rem', fontWeight: 'bold' }}>{item.resource_name}</td>
<td style={{ padding: '1rem', textAlign: 'center' }}>{item.sla_target}%</td>
<td style={{ padding: '1rem', textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#4caf50' : '#f44336' }}>
{item.actual_uptime}%
</td>
<td style={{ padding: '1rem', textAlign: 'center' }}>
<span style={{
padding: '0.25rem 0.75rem',
borderRadius: '12px',
background: item.meets_target ? '#e8f5e9' : '#ffebee',
color: item.meets_target ? '#2e7d32' : '#c62828',
fontSize: '0.875rem',
fontWeight: 'bold'
}}>
{item.meets_target ? '✓ PASS' : '✗ FAIL'}
</span>
</td>
<td style={{ padding: '1rem', textAlign: 'center' }}>
{item.successful_checks}/{item.total_checks}
</td>
<td style={{ padding: '1rem', textAlign: 'center', color: item.incidents > 0 ? '#ff9800' : '#666' }}>
{item.incidents}
</td>
<td style={{ padding: '1rem', textAlign: 'center' }}>
{item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
<h3 style={{ marginTop: 0, color: '#667eea' }}>SLA Report Summary</h3>
<p style={{ color: '#666', lineHeight: '1.6' }}>
This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{days} days</strong>.
Resources meeting their target uptime are marked as <strong style={{ color: '#4caf50' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#f44336' }}>FAIL</strong>.
</p>
<p style={{ color: '#666', lineHeight: '1.6', marginBottom: 0 }}>
Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets.
</p>
</div>
</div>
);
}
export default SLA;
+50
View File
@@ -42,9 +42,19 @@ db.exec(`
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
resolved_at DATETIME,
notified INTEGER DEFAULT 0,
acknowledged INTEGER DEFAULT 0,
acknowledged_at DATETIME,
acknowledged_by TEXT,
FOREIGN KEY (resource_id) REFERENCES resources(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_checks_resource_id ON checks(resource_id);
CREATE INDEX IF NOT EXISTS idx_checks_checked_at ON checks(checked_at);
CREATE INDEX IF NOT EXISTS idx_incidents_resource_id ON incidents(resource_id);
@@ -64,4 +74,44 @@ try {
// Column already exists
}
try {
db.prepare("ALTER TABLE resources ADD COLUMN http_keyword TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN http_headers TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN quiet_hours_start TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN quiet_hours_end TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN cert_expiry_days INTEGER DEFAULT 30").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE resources ADD COLUMN sla_target REAL DEFAULT 99.9").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE incidents ADD COLUMN acknowledged INTEGER DEFAULT 0").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE incidents ADD COLUMN acknowledged_at DATETIME").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE incidents ADD COLUMN acknowledged_by TEXT").run();
} catch (err) {}
try {
db.prepare("ALTER TABLE checks ADD COLUMN cert_expiry_date TEXT").run();
} catch (err) {}
module.exports = db;
+90 -7
View File
@@ -72,15 +72,15 @@ app.get('/api/resources/:id', (req, res) => {
// Create resource
app.post('/api/resources', (req, res) => {
const { name, url, type, check_interval, timeout, group_id } = 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 } = req.body;
if (!name || !url) {
return res.status(400).json({ error: 'Name and URL are required' });
}
const stmt = db.prepare(`
INSERT INTO resources (name, url, type, check_interval, timeout, group_id)
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -89,7 +89,13 @@ app.post('/api/resources', (req, res) => {
type || 'http',
check_interval || 60000,
timeout || 5000,
group_id || null
group_id || null,
http_keyword || null,
http_headers || null,
quiet_hours_start || null,
quiet_hours_end || null,
cert_expiry_days || 30,
sla_target || 99.9
);
res.json({ id: result.lastInsertRowid, message: 'Resource created' });
@@ -97,15 +103,30 @@ 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 } = 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 } = req.body;
const stmt = db.prepare(`
UPDATE resources
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?
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 = ?
WHERE id = ?
`);
stmt.run(name, url, type, check_interval, timeout, enabled ? 1 : 0, group_id || null, req.params.id);
stmt.run(
name,
url,
type,
check_interval,
timeout,
enabled ? 1 : 0,
group_id || null,
http_keyword || null,
http_headers || null,
quiet_hours_start || null,
quiet_hours_end || null,
cert_expiry_days || 30,
sla_target || 99.9,
req.params.id
);
res.json({ message: 'Resource updated' });
});
@@ -322,6 +343,68 @@ app.get('/api/history/overview', (req, res) => {
res.json(overview);
});
// Acknowledge incident
app.post('/api/incidents/:id/acknowledge', (req, res) => {
const { id } = req.params;
const { acknowledged_by } = req.body;
const stmt = db.prepare(`
UPDATE incidents
SET acknowledged = 1, acknowledged_at = datetime('now'), acknowledged_by = ?
WHERE id = ? AND resolved_at IS NULL
`);
const result = stmt.run(acknowledged_by || 'User', id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Incident not found or already resolved' });
}
res.json({ message: 'Incident acknowledged' });
});
// Get SLA report
app.get('/api/sla', (req, res) => {
const { days = 30 } = req.query;
const resources = db.prepare('SELECT * FROM resources WHERE enabled = 1').all();
const slaData = resources.map(resource => {
const checks = db.prepare(`
SELECT status
FROM checks
WHERE resource_id = ? AND checked_at > datetime('now', ?)
`).all(resource.id, `-${days} days`);
const upCount = checks.filter(c => c.status === 'up').length;
const actualUptime = checks.length > 0 ? (upCount / checks.length * 100) : 0;
const target = resource.sla_target || 99.9;
const meetsTarget = actualUptime >= target;
const incidents = db.prepare(`
SELECT COUNT(*) as count,
SUM(julianday(COALESCE(resolved_at, datetime('now'))) - julianday(started_at)) * 24 * 60 as downtime_minutes
FROM incidents
WHERE resource_id = ? AND started_at > datetime('now', ?)
`).get(resource.id, `-${days} days`);
return {
resource_id: resource.id,
resource_name: resource.name,
sla_target: target,
actual_uptime: actualUptime.toFixed(2),
meets_target: meetsTarget,
total_checks: checks.length,
successful_checks: upCount,
incidents: incidents.count || 0,
downtime_minutes: Math.round(incidents.downtime_minutes || 0),
group_id: resource.group_id,
};
});
res.json(slaData);
});
// Test email
app.post('/api/test-email', async (req, res) => {
const nodemailer = require('nodemailer');
+31 -2
View File
@@ -65,19 +65,41 @@ class MonitorService {
async checkHttp(resource, startTime, base) {
let result = { ...base };
try {
const headers = {};
if (resource.http_headers) {
try {
const customHeaders = JSON.parse(resource.http_headers);
Object.assign(headers, customHeaders);
} catch (e) {
// Invalid JSON, skip custom headers
}
}
const response = await axios.get(resource.url, {
timeout: resource.timeout,
validateStatus: () => true,
maxRedirects: 5,
headers,
});
const responseTime = Date.now() - startTime;
const isUp = response.status >= 200 && response.status < 400;
// Check for keyword if specified
let keywordMatch = true;
if (resource.http_keyword && response.data) {
const dataStr = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
keywordMatch = dataStr.includes(resource.http_keyword);
if (!keywordMatch) {
result.error_message = `Keyword "${resource.http_keyword}" not found in response`;
}
}
result = {
...result,
status: isUp ? 'up' : 'down',
status: (isUp && keywordMatch) ? 'up' : 'down',
response_time: responseTime,
status_code: response.status,
error_message: isUp ? null : `HTTP ${response.status}`,
error_message: isUp && keywordMatch ? null : (result.error_message || `HTTP ${response.status}`),
};
} catch (error) {
result.error_message = error.message;
@@ -124,18 +146,25 @@ class MonitorService {
const daysRemaining = Math.round((expires - now) / (1000 * 60 * 60 * 24));
const validHost = cert.subjectaltname ? cert.subjectaltname.includes(host) : true;
// Check certificate expiry threshold
const expiryThreshold = resource.cert_expiry_days || 30;
const certExpiringWarning = daysRemaining <= expiryThreshold && daysRemaining > 0;
const status = daysRemaining > 0 && validHost ? 'up' : 'down';
resolve({
...base,
status,
response_time: Date.now() - startTime,
error_message: status === 'up' ? null : 'TLS invalid or expired',
cert_expiry_date: cert.valid_to,
details: JSON.stringify({
issuer: cert.issuer?.CN,
subject: cert.subject?.CN,
valid_to: cert.valid_to,
days_remaining: daysRemaining,
host_matches: validHost,
cert_expiring_soon: certExpiringWarning,
expiry_threshold: expiryThreshold,
}),
});
socket.end();
+34
View File
@@ -49,6 +49,18 @@ class NotificationService {
}
async sendAlert(resource, incident, stats = null) {
// Check if incident is acknowledged
if (incident.acknowledged) {
console.log(`Incident ${incident.id} already acknowledged, skipping alert`);
return;
}
// Check quiet hours
if (this.isQuietHours(resource)) {
console.log(`Resource ${resource.name} is in quiet hours, skipping alert`);
return;
}
const isDown = incident.type === 'started';
const statusEmoji = isDown ? '🔴' : '🟢';
const statusText = isDown ? 'DOWN' : 'UP';
@@ -68,6 +80,28 @@ class NotificationService {
await Promise.allSettled(promises);
}
isQuietHours(resource) {
if (!resource.quiet_hours_start || !resource.quiet_hours_end) {
return false;
}
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes();
const [startHour, startMin] = resource.quiet_hours_start.split(':').map(Number);
const [endHour, endMin] = resource.quiet_hours_end.split(':').map(Number);
const startTime = startHour * 60 + startMin;
const endTime = endHour * 60 + endMin;
// Handle overnight quiet hours
if (startTime > endTime) {
return currentTime >= startTime || currentTime < endTime;
}
return currentTime >= startTime && currentTime < endTime;
}
async sendEmail(resource, message, type, stats = null) {
if (!this.transporter) {
console.error('Email config incomplete; skipping email');