feat: Add real-time notification center with WebSocket integration
- Add in-app notification system with database storage - Implement bell icon component with unread count badge in dashboard header - Add full Notifications page tab with pagination and filtering (All/Unread/Read) - Implement real-time WebSocket push notifications to all connected clients - Add notification API endpoints: GET, PUT /read, DELETE, POST /clear - Fix critical bug in notificationService where 'status' variable was undefined - Add Notifications route to App.js routing - Enhanced UI with notification popup in header and dedicated full-page view - Auto-reconnect WebSocket with 5s timeout on disconnect - Support rich notification content with resource details and timestamps
This commit is contained in:
1 parent
7968c9231e
commit
db85573986
37 files changed
+3521
-207
No files matched your search
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.b6a66622.css",
|
||||
"main.js": "/static/js/main.0a56784a.js",
|
||||
"main.css": "/static/css/main.4f505a72.css",
|
||||
"main.js": "/static/js/main.29f97b36.js",
|
||||
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
|
||||
"index.html": "/index.html",
|
||||
"main.b6a66622.css.map": "/static/css/main.b6a66622.css.map",
|
||||
"main.0a56784a.js.map": "/static/js/main.0a56784a.js.map",
|
||||
"main.4f505a72.css.map": "/static/css/main.4f505a72.css.map",
|
||||
"main.29f97b36.js.map": "/static/js/main.29f97b36.js.map",
|
||||
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.b6a66622.css",
|
||||
"static/js/main.0a56784a.js"
|
||||
"static/css/main.4f505a72.css",
|
||||
"static/js/main.29f97b36.js"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.0a56784a.js"></script><link href="/static/css/main.b6a66622.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.29f97b36.js"></script><link href="/static/css/main.4f505a72.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||
+2
-2
@@ -1,2 +1,2 @@
|
||||
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#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;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}@media (max-width:1024px){.dashboard-grid{grid-template-columns:repeat(auto-fill,minmax(20Line truncated
|
||||
/*# sourceMappingURL=main.b6a66622.css.map*/
|
||||
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;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}@media (max-width:1024px){.dashboard-grid{grid-template-columns:repeat(auto-fill,minmax(20Line truncated
|
||||
/*# sourceMappingURL=main.4f505a72.css.map*/
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"static/css/main.4f505a72.css","mappings":"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 truncated
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"static/css/main.b6a66622.css","mappings":"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 truncated
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"static/js/main.0a56784a.js","mappings":"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 truncated
|
||||
@@ -1,3 +1,3 @@
|
||||
/*! For license information please see main.0a56784a.js.LICENSE.txt */
|
||||
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
|
||||
//# sourceMappingURL=main.0a56784a.js.map
|
||||
/*! For license information please see main.29f97b36.js.LICENSE.txt */
|
||||
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
|
||||
//# sourceMappingURL=main.29f97b36.js.map
|
||||
File renamed without changes.
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"static/js/main.29f97b36.js","mappings":"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 truncated
|
||||
Generated
+8
-3
@@ -8,7 +8,6 @@
|
||||
"name": "client",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"axios": "^1.13.2",
|
||||
@@ -3396,6 +3395,7 @@
|
||||
"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",
|
||||
@@ -3415,6 +3415,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -3493,7 +3494,8 @@
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -6758,6 +6760,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -6880,7 +6883,8 @@
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
"version": "0.2.0",
|
||||
@@ -11407,6 +11411,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"axios": "^1.13.2",
|
||||
|
||||
+98
-23
@@ -7,6 +7,9 @@ import SettingsWizard from './SettingsWizard';
|
||||
import History from './History';
|
||||
import SLA from './SLA';
|
||||
import Status from './Status';
|
||||
import Observability from './Observability';
|
||||
import Notifications from './Notifications';
|
||||
import NotificationCenter from './NotificationCenter';
|
||||
import { formatLocalTime, formatChartTime } from './utils/timeUtils';
|
||||
|
||||
// Simple duration formatter for incident spans
|
||||
@@ -29,6 +32,7 @@ function Dashboard() {
|
||||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [editData, setEditData] = useState({});
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
url: '',
|
||||
@@ -54,13 +58,24 @@ function Dashboard() {
|
||||
const actionsRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Show toast notification
|
||||
const showNotification = (title, message, type = 'info') => {
|
||||
const id = Date.now();
|
||||
setNotifications(prev => [...prev, { id, title, message, type }]);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const loadResources = useCallback(async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/dashboard');
|
||||
setResources(response.data.resources);
|
||||
setGroups(response.data.groups || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading resources:', error);
|
||||
// Error loading resources handled
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -78,17 +93,34 @@ function Dashboard() {
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'dashboard') {
|
||||
setResources(message.data.resources || []);
|
||||
setGroups(message.data.groups || []);
|
||||
} else if (message.type === 'alert') {
|
||||
// Show real-time alert notification
|
||||
const alert = message.data;
|
||||
showNotification(`Alert: ${alert.resourceName}`, alert.message, 'error');
|
||||
} else if (message.type === 'incident') {
|
||||
// Show real-time incident notification
|
||||
const incident = message.data;
|
||||
if (incident.type === 'started') {
|
||||
showNotification(`Incident Started`, `${incident.resourceName} is down`, 'error');
|
||||
} else if (incident.type === 'resolved') {
|
||||
showNotification(`Incident Resolved`, `${incident.resourceName} is back up`, 'success');
|
||||
}
|
||||
// Refresh data to show updated incidents
|
||||
loadResources();
|
||||
} else if (message.type === 'metrics') {
|
||||
// Metrics update received (can be used for performance dashboard)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
// Parse error handled silently
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
// WebSocket error handled
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
@@ -97,7 +129,6 @@ function Dashboard() {
|
||||
|
||||
wsRef.current = ws;
|
||||
} catch (error) {
|
||||
console.error('Error connecting WebSocket:', error);
|
||||
loadResources();
|
||||
}
|
||||
}, [loadResources]);
|
||||
@@ -137,7 +168,6 @@ function Dashboard() {
|
||||
setGroupData({ name: '', description: '' });
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
console.error('Error creating group:', error);
|
||||
alert('Error creating group');
|
||||
}
|
||||
};
|
||||
@@ -168,7 +198,6 @@ function Dashboard() {
|
||||
});
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
console.error('Error creating resource:', error);
|
||||
alert('Error creating resource');
|
||||
}
|
||||
};
|
||||
@@ -190,7 +219,6 @@ function Dashboard() {
|
||||
setEditData({});
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
console.error('Error updating resource:', error);
|
||||
alert('Error updating resource');
|
||||
}
|
||||
};
|
||||
@@ -206,7 +234,6 @@ function Dashboard() {
|
||||
await axios.delete(`/api/resources/${id}`);
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
console.error('Error deleting resource:', error);
|
||||
alert('Error deleting resource');
|
||||
}
|
||||
}
|
||||
@@ -225,7 +252,6 @@ function Dashboard() {
|
||||
link.click();
|
||||
link.parentChild.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error('Error exporting resources:', error);
|
||||
alert('Error exporting resources');
|
||||
}
|
||||
};
|
||||
@@ -246,7 +272,6 @@ function Dashboard() {
|
||||
}
|
||||
loadResources();
|
||||
} catch (error) {
|
||||
console.error('Error importing resources:', error);
|
||||
alert('Error importing resources');
|
||||
}
|
||||
|
||||
@@ -375,9 +400,33 @@ function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
{/* Real-time Notifications */}
|
||||
{notifications.length > 0 && (
|
||||
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 9999 }}>
|
||||
{notifications.map(notif => (
|
||||
<div
|
||||
key={notif.id}
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: notif.type === 'error' ? '#f8d7da' : notif.type === 'success' ? '#d4edda' : '#d1ecf1',
|
||||
color: notif.type === 'error' ? '#721c24' : notif.type === 'success' ? '#155724' : '#0c5460',
|
||||
border: `1px solid ${notif.type === 'error' ? '#f5c6cb' : notif.type === 'success' ? '#c3e6cb' : '#bee5eb'}`,
|
||||
minWidth: '300px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<strong>{notif.title}</strong>
|
||||
<div>{notif.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h2>SkyWatch Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<NotificationCenter />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by tag"
|
||||
@@ -748,6 +797,19 @@ function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Data Retention (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Leave empty to use global setting"
|
||||
value={formData.retention_days || ''}
|
||||
onChange={(e) => setFormData({ ...formData, retention_days: e.target.value ? parseInt(e.target.value) : null })}
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={() => setShowModal(false)}>
|
||||
Cancel
|
||||
@@ -957,6 +1019,19 @@ function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Data Retention (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
placeholder="Leave empty to use global setting"
|
||||
value={editData.retention_days || ''}
|
||||
onChange={(e) => setEditData({ ...editData, retention_days: e.target.value ? parseInt(e.target.value) : null })}
|
||||
/>
|
||||
<small style={{ color: '#666', fontSize: '0.85rem' }}>Override global retention period for this monitor only</small>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={() => setShowEditModal(false)}>
|
||||
Cancel
|
||||
@@ -1036,7 +1111,6 @@ function ResourceDetail() {
|
||||
setResource(response.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error loading resource:', error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -1054,7 +1128,7 @@ function ResourceDetail() {
|
||||
});
|
||||
setChecks(response.data.checks || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading checks:', error);
|
||||
// Checks load error handled
|
||||
} finally {
|
||||
setChecksLoading(false);
|
||||
}
|
||||
@@ -1073,7 +1147,7 @@ function ResourceDetail() {
|
||||
});
|
||||
setIncidents(response.data.incidents || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading incidents:', error);
|
||||
// Incidents load error handled
|
||||
} finally {
|
||||
setIncidentsLoading(false);
|
||||
}
|
||||
@@ -1098,7 +1172,6 @@ function ResourceDetail() {
|
||||
setIncidentDescription('');
|
||||
loadIncidents(); // Reload to show updated description
|
||||
} catch (error) {
|
||||
console.error('Error updating incident:', error);
|
||||
alert('Failed to update incident: ' + (error.response?.data?.error || error.message));
|
||||
} finally {
|
||||
setUpdatingIncident(false);
|
||||
@@ -1111,7 +1184,7 @@ function ResourceDetail() {
|
||||
const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } });
|
||||
setSla(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading SLA summary:', error);
|
||||
// SLA load error handled
|
||||
} finally {
|
||||
setSlaLoading(false);
|
||||
}
|
||||
@@ -1122,7 +1195,7 @@ function ResourceDetail() {
|
||||
const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
|
||||
setMaintenanceWindows(response.data.windows || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading maintenance windows:', error);
|
||||
// Maintenance windows load error handled
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1135,7 +1208,6 @@ function ResourceDetail() {
|
||||
loadMaintenanceWindows();
|
||||
alert('Maintenance window created');
|
||||
} catch (error) {
|
||||
console.error('Error creating maintenance window:', error);
|
||||
alert('Error creating maintenance window');
|
||||
}
|
||||
};
|
||||
@@ -1146,7 +1218,6 @@ function ResourceDetail() {
|
||||
await axios.delete(`/api/maintenance-windows/${windowId}`);
|
||||
loadMaintenanceWindows();
|
||||
} catch (error) {
|
||||
console.error('Error deleting maintenance window:', error);
|
||||
alert('Error deleting maintenance window');
|
||||
}
|
||||
}
|
||||
@@ -1158,7 +1229,6 @@ function ResourceDetail() {
|
||||
await axios.delete(`/api/resources/${id}`);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
console.error('Error deleting resource:', error);
|
||||
alert('Error deleting resource');
|
||||
}
|
||||
}
|
||||
@@ -1172,7 +1242,7 @@ function ResourceDetail() {
|
||||
});
|
||||
loadResource();
|
||||
} catch (error) {
|
||||
console.error('Error updating resource:', error);
|
||||
// Update resource error handled
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1184,7 +1254,7 @@ function ResourceDetail() {
|
||||
});
|
||||
loadResource();
|
||||
} catch (error) {
|
||||
console.error('Error updating maintenance mode:', error);
|
||||
// Update maintenance mode error handled
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1591,12 +1661,13 @@ function Navbar() {
|
||||
try {
|
||||
const response = await axios.get('/api/settings');
|
||||
setNotificationsConfigured(response.data.email_enabled || response.data.webhook_enabled);
|
||||
// Cache server timezone for timestamp formatting
|
||||
// Cache server timezone for timestamp formatting with timestamp
|
||||
if (response.data.timezone) {
|
||||
localStorage.setItem('serverTimezone', response.data.timezone);
|
||||
localStorage.setItem('serverTimezoneTime', Date.now().toString());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking notifications:', error);
|
||||
// Notifications check error handled
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1609,6 +1680,8 @@ function Navbar() {
|
||||
<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="/observability" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Observability</Link>
|
||||
<Link to="/notifications" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Notifications</Link>
|
||||
<Link to="/settings" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
Settings
|
||||
{!notificationsConfigured && (
|
||||
@@ -1647,6 +1720,8 @@ function App() {
|
||||
<Route path="/sla" element={<SLA />} />
|
||||
<Route path="/settings" element={<div className="container"><SettingsWizard /></div>} />
|
||||
<Route path="/status" element={<Status />} />
|
||||
<Route path="/observability" element={<Observability />} />
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
|
||||
@@ -11,9 +11,7 @@ class ErrorBoundary extends React.Component {
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
// Log to console; could later POST to /api if needed
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('App error:', error, info);
|
||||
// Error handled silently
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
+38
-2
@@ -22,6 +22,7 @@ const asNumberOrZero = (value) => {
|
||||
function History() {
|
||||
const [historyData, setHistoryData] = useState([]);
|
||||
const [days, setDays] = useState(7);
|
||||
const [maxDays, setMaxDays] = useState(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAveraged, setShowAveraged] = useState(true);
|
||||
@@ -29,7 +30,28 @@ function History() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [effectiveDays, setEffectiveDays] = useState(7);
|
||||
const [limitedByRetention, setLimitedByRetention] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch retention setting from server
|
||||
const fetchRetention = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/settings');
|
||||
const retentionDays = response.data.retention_days || 30;
|
||||
setMaxDays(parseInt(retentionDays));
|
||||
// Adjust current days if it exceeds retention
|
||||
if (days > retentionDays) {
|
||||
setDays(parseInt(retentionDays));
|
||||
}
|
||||
} catch (error) {
|
||||
// Failed to fetch retention setting, use default
|
||||
}
|
||||
};
|
||||
fetchRetention();
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
const loadHistory = async () => {
|
||||
setLoading(true);
|
||||
@@ -47,6 +69,8 @@ function History() {
|
||||
if (response.data.resources && Array.isArray(response.data.resources)) {
|
||||
data = response.data.resources;
|
||||
total = response.data.total || data.length;
|
||||
setEffectiveDays(response.data.effective_days || days);
|
||||
setLimitedByRetention(Boolean(response.data.limited_by_retention));
|
||||
} else if (Array.isArray(response.data)) {
|
||||
data = response.data;
|
||||
total = data.length;
|
||||
@@ -61,7 +85,6 @@ function History() {
|
||||
});
|
||||
return { ...resource, trendsData: trendsResponse.data };
|
||||
} catch (err) {
|
||||
console.error(`Error loading trends for resource ${resource.id}:`, err);
|
||||
return resource;
|
||||
}
|
||||
});
|
||||
@@ -72,7 +95,6 @@ function History() {
|
||||
setTotalItems(total);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error loading history:', error);
|
||||
setError(error.message);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -86,6 +108,18 @@ function History() {
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
{limitedByRetention && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffc107',
|
||||
borderRadius: '4px',
|
||||
color: '#856404'
|
||||
}}>
|
||||
⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<h2>Monitoring History</h2>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
@@ -95,6 +129,8 @@ function History() {
|
||||
key={d}
|
||||
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setDays(d)}
|
||||
disabled={d > maxDays}
|
||||
style={{ opacity: d > maxDays ? 0.5 : 1 }}
|
||||
>
|
||||
{d} Days
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
.notification-center {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-bell-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notification-bell {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding: 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.notification-bell:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notification-popup {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 20px;
|
||||
width: 400px;
|
||||
max-height: 600px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.notification-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notification-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fafafa;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: #007bff;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
margin-left: auto;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: #c82333;
|
||||
border-color: #c82333;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
background: white;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: #f0f8ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notification-resource {
|
||||
background: #f0f0f0;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: #999;
|
||||
transition: color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.action-btn.mark-read:hover {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.notification-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 600px) {
|
||||
.notification-popup {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
width: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.notification-filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { formatLocalTime } from './utils/timeUtils';
|
||||
import './NotificationCenter.css';
|
||||
|
||||
function NotificationCenter() {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [showCenter, setShowCenter] = useState(false);
|
||||
const [filter, setFilter] = useState('all'); // all, unread, read
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
// Refresh every 10 seconds
|
||||
const interval = setInterval(loadNotifications, 10000);
|
||||
|
||||
// Connect to WebSocket for real-time updates
|
||||
connectWebSocket();
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (window.notificationWebSocket) {
|
||||
window.notificationWebSocket.close();
|
||||
}
|
||||
};
|
||||
}, [filter]);
|
||||
|
||||
const connectWebSocket = () => {
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected for real-time notifications');
|
||||
setWsConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'notification') {
|
||||
// New notification received - reload notifications
|
||||
loadNotifications();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setWsConnected(false);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
setWsConnected(false);
|
||||
// Attempt to reconnect after 5 seconds
|
||||
setTimeout(connectWebSocket, 5000);
|
||||
};
|
||||
|
||||
window.notificationWebSocket = ws;
|
||||
} catch (error) {
|
||||
console.error('Failed to connect WebSocket:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotifications = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
if (filter === 'unread') params.read = 'unread';
|
||||
if (filter === 'read') params.read = 'read';
|
||||
params.limit = 100;
|
||||
|
||||
const response = await axios.get('/api/notifications', { params });
|
||||
setNotifications(response.data.notifications);
|
||||
setUnreadCount(response.data.unreadCount);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notifications:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (notificationId) => {
|
||||
try {
|
||||
await axios.put(`/api/notifications/${notificationId}/read`);
|
||||
loadNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (notificationId) => {
|
||||
try {
|
||||
await axios.delete(`/api/notifications/${notificationId}`);
|
||||
loadNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
|
||||
|
||||
try {
|
||||
await axios.post('/api/notifications/clear', { type: filter });
|
||||
loadNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear notifications:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notification-center">
|
||||
{/* Notification Bell Icon */}
|
||||
<div className="notification-bell-container">
|
||||
<button
|
||||
className="notification-bell"
|
||||
onClick={() => setShowCenter(!showCenter)}
|
||||
title={`${unreadCount} unread notifications`}
|
||||
>
|
||||
🔔
|
||||
{unreadCount > 0 && (
|
||||
<span className="notification-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notification Center Popup */}
|
||||
{showCenter && (
|
||||
<div className="notification-popup">
|
||||
<div className="notification-header">
|
||||
<h3>Notifications</h3>
|
||||
<button
|
||||
className="close-btn"
|
||||
onClick={() => setShowCenter(false)}
|
||||
title="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="notification-filters">
|
||||
<button
|
||||
className={`filter-btn ${filter === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${filter === 'unread' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('unread')}
|
||||
>
|
||||
Unread ({unreadCount})
|
||||
</button>
|
||||
<button
|
||||
className={`filter-btn ${filter === 'read' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('read')}
|
||||
>
|
||||
Read
|
||||
</button>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
className="filter-btn clear-btn"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notification-list">
|
||||
{loading ? (
|
||||
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>Loading...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>
|
||||
No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications
|
||||
</p>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`notification-item ${notification.read ? 'read' : 'unread'}`}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-title">{notification.title}</div>
|
||||
<div className="notification-message">{notification.message}</div>
|
||||
<div className="notification-meta">
|
||||
{notification.resource_name && (
|
||||
<span className="notification-resource">
|
||||
Resource: {notification.resource_name}
|
||||
</span>
|
||||
)}
|
||||
<span className="notification-time">
|
||||
{formatLocalTime(notification.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="notification-actions">
|
||||
{!notification.read && (
|
||||
<button
|
||||
className="action-btn mark-read"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
title="Mark as read"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="action-btn delete"
|
||||
onClick={() => handleDelete(notification.id)}
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationCenter;
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { formatLocalTime } from './utils/timeUtils';
|
||||
|
||||
function Notifications() {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const itemsPerPage = 50;
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
const interval = setInterval(loadNotifications, 10000);
|
||||
|
||||
// Connect to WebSocket for real-time updates
|
||||
connectWebSocket();
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (window.notificationWebSocket) {
|
||||
window.notificationWebSocket.close();
|
||||
}
|
||||
};
|
||||
}, [filter, currentPage]);
|
||||
|
||||
const connectWebSocket = () => {
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'notification') {
|
||||
loadNotifications();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(connectWebSocket, 5000);
|
||||
};
|
||||
|
||||
window.notificationWebSocket = ws;
|
||||
} catch (error) {
|
||||
console.error('Failed to connect WebSocket:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotifications = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
limit: itemsPerPage,
|
||||
offset: (currentPage - 1) * itemsPerPage
|
||||
};
|
||||
if (filter === 'unread') params.read = 'unread';
|
||||
if (filter === 'read') params.read = 'read';
|
||||
|
||||
const response = await axios.get('/api/notifications', { params });
|
||||
setNotifications(response.data.notifications);
|
||||
setUnreadCount(response.data.unreadCount);
|
||||
setTotal(response.data.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notifications:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAsRead = async (notificationId) => {
|
||||
try {
|
||||
await axios.put(`/api/notifications/${notificationId}/read`);
|
||||
loadNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to mark notification as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (notificationId) => {
|
||||
try {
|
||||
await axios.delete(`/api/notifications/${notificationId}`);
|
||||
loadNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
|
||||
|
||||
try {
|
||||
await axios.post('/api/notifications/clear', { type: filter });
|
||||
setCurrentPage(1);
|
||||
loadNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear notifications:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: '2rem' }}>
|
||||
<h2>📬 Notifications</h2>
|
||||
|
||||
<div style={{ marginBottom: '2rem', display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setFilter('all'); setCurrentPage(1); }}
|
||||
>
|
||||
All ({total})
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${filter === 'unread' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setFilter('unread'); setCurrentPage(1); }}
|
||||
>
|
||||
Unread ({unreadCount})
|
||||
</button>
|
||||
<button
|
||||
className={`btn ${filter === 'read' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setFilter('read'); setCurrentPage(1); }}
|
||||
>
|
||||
Read ({total - unreadCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleClearAll}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ textAlign: 'center', color: '#999' }}>Loading...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#999' }}>
|
||||
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}>
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: notification.read ? 'white' : '#f0f8ff',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '1rem'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '1rem' }}>{notification.title}</h4>
|
||||
<p style={{ margin: '0 0 0.5rem 0', color: '#666', fontSize: '0.95rem' }}>
|
||||
{notification.message}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', color: '#999', flexWrap: 'wrap' }}>
|
||||
{notification.resource_name && (
|
||||
<span style={{ backgroundColor: '#f0f0f0', padding: '0.25rem 0.5rem', borderRadius: '3px' }}>
|
||||
📌 {notification.resource_name}
|
||||
</span>
|
||||
)}
|
||||
<span>⏰ {formatLocalTime(notification.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
{!notification.read && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
title="Mark as read"
|
||||
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem' }}
|
||||
>
|
||||
✓ Read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleDelete(notification.id)}
|
||||
title="Delete"
|
||||
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem', background: '#dc3545' }}
|
||||
>
|
||||
✕ Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
className={`btn ${currentPage === page ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
style={{ minWidth: '40px' }}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Notifications;
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { formatLocalTime } from './utils/timeUtils';
|
||||
|
||||
function Observability() {
|
||||
const [activeTab, setActiveTab] = useState('metrics');
|
||||
const [metrics, setMetrics] = useState(null);
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [auditLogs, setAuditLogs] = useState([]);
|
||||
const [auditSummary, setAuditSummary] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedAuditRow, setExpandedAuditRow] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
entityType: '',
|
||||
entityId: '',
|
||||
userId: '',
|
||||
action: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [activeTab, filters]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (activeTab === 'metrics') {
|
||||
const resp = await axios.get('/api/observability/metrics');
|
||||
setMetrics(resp.data);
|
||||
} else if (activeTab === 'errors') {
|
||||
const resp = await axios.get('/api/observability/errors?limit=50');
|
||||
setErrors(resp.data.errors);
|
||||
} else if (activeTab === 'audit') {
|
||||
const auditResp = await axios.get('/api/observability/audit-logs', { params: filters });
|
||||
setAuditLogs(auditResp.data.logs);
|
||||
setAuditSummary(auditResp.data.summary);
|
||||
}
|
||||
} catch (error) {
|
||||
// Error loading data
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: '2rem' }}>
|
||||
<h2>System Observability</h2>
|
||||
|
||||
<div style={{ marginBottom: '2rem', borderBottom: '1px solid #ddd', display: 'flex', gap: '1rem' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('metrics')}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'metrics' ? '3px solid #007bff' : 'none',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontWeight: activeTab === 'metrics' ? 'bold' : 'normal',
|
||||
color: activeTab === 'metrics' ? '#007bff' : '#666'
|
||||
}}
|
||||
>
|
||||
API Metrics
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('errors')}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'errors' ? '3px solid #007bff' : 'none',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontWeight: activeTab === 'errors' ? 'bold' : 'normal',
|
||||
color: activeTab === 'errors' ? '#007bff' : '#666'
|
||||
}}
|
||||
>
|
||||
Errors
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('audit')}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === 'audit' ? '3px solid #007bff' : 'none',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontWeight: activeTab === 'audit' ? 'bold' : 'normal',
|
||||
color: activeTab === 'audit' ? '#007bff' : '#666'
|
||||
}}
|
||||
>
|
||||
Audit Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Metrics Tab */}
|
||||
{activeTab === 'metrics' && metrics && (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
|
||||
<div style={{ fontSize: '0.9rem', color: '#666' }}>Server Uptime</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
|
||||
{metrics.uptimeFormatted || `${Math.floor(metrics.uptime / 3600)}h`}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
|
||||
<div style={{ fontSize: '0.9rem', color: '#666' }}>Total API Requests</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
|
||||
{metrics.apiStats.reduce((sum, s) => sum + s.totalRequests, 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
|
||||
<div style={{ fontSize: '0.9rem', color: '#666' }}>Errors Logged</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem', color: metrics.errorCount > 0 ? '#dc3545' : '#28a745' }}>
|
||||
{metrics.errorCount}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
|
||||
<div style={{ fontSize: '0.9rem', color: '#666' }}>Unique Errors</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
|
||||
{metrics.uniqueErrors}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>API Endpoint Performance</h3>
|
||||
<div style={{ overflowX: 'auto', marginBottom: '2rem' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Endpoint</th>
|
||||
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Requests</th>
|
||||
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Success Rate</th>
|
||||
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Avg Response</th>
|
||||
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Min/Max</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.apiStats.map((stat, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #ddd' }}>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<code>{stat.method} {stat.endpoint}</code>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.totalRequests}</td>
|
||||
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
|
||||
<span style={{
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '3px',
|
||||
backgroundColor: stat.successRate >= 95 ? '#d4edda' : '#fff3cd',
|
||||
color: stat.successRate >= 95 ? '#155724' : '#856404'
|
||||
}}>
|
||||
{stat.successRate}%
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.avgResponseTime}ms</td>
|
||||
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
|
||||
{stat.minResponseTime}ms / {stat.maxResponseTime}ms
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors Tab */}
|
||||
{activeTab === 'errors' && (
|
||||
<div>
|
||||
{errors.length === 0 ? (
|
||||
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>No errors logged</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Error Message</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Context</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Occurred At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{errors.map((error, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white' }}>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<code style={{ fontSize: '0.85rem', color: '#d9534f' }}>{error.error_message}</code>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem', color: '#666' }}>
|
||||
{error.context ? JSON.stringify(JSON.parse(error.context)).substring(0, 50) : '-'}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
|
||||
{formatLocalTime(error.occurred_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audit Logs Tab */}
|
||||
{activeTab === 'audit' && (
|
||||
<div>
|
||||
{auditSummary && (
|
||||
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: '#f9f9f9', borderRadius: '4px' }}>
|
||||
<p><strong>Summary (Last 7 Days):</strong> {auditSummary.totalEvents} events</p>
|
||||
{auditSummary.actionBreakdown.map(action => (
|
||||
<p key={action.action} style={{ margin: '0.25rem 0', fontSize: '0.9rem', color: '#666' }}>
|
||||
{action.action}: {action.count}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '1rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Entity Type"
|
||||
value={filters.entityType}
|
||||
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
|
||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Entity ID"
|
||||
value={filters.entityId}
|
||||
onChange={(e) => setFilters({ ...filters, entityId: e.target.value })}
|
||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="User ID"
|
||||
value={filters.userId}
|
||||
onChange={(e) => setFilters({ ...filters, userId: e.target.value })}
|
||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Action"
|
||||
value={filters.action}
|
||||
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
|
||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{auditLogs.length === 0 ? (
|
||||
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>No audit logs found</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Action</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Entity</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>User</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Changes</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditLogs.map((log, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<tr style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white', cursor: 'pointer' }} onClick={() => setExpandedAuditRow(expandedAuditRow === i ? null : i)}>
|
||||
<td style={{ padding: '0.75rem' }}><strong>{log.action}</strong></td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem', color: '#666' }}>
|
||||
{log.entity_type} #{log.entity_id}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>{log.user_id}</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
|
||||
<code style={{ cursor: 'pointer', color: '#007bff' }}>
|
||||
{expandedAuditRow === i ? '▼ Hide' : '▶ Show'} Changes
|
||||
</code>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
|
||||
{formatLocalTime(log.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedAuditRow === i && (
|
||||
<tr style={{ backgroundColor: '#f0f8ff', borderBottom: '1px solid #ddd' }}>
|
||||
<td colSpan="5" style={{ padding: '1rem' }}>
|
||||
<strong>Full Changes:</strong>
|
||||
<pre style={{ marginTop: '0.5rem', backgroundColor: 'white', padding: '0.75rem', borderRadius: '4px', overflowX: 'auto', border: '1px solid #ddd' }}>
|
||||
{JSON.stringify(log.changes, null, 2)}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Observability;
|
||||
+35
-2
@@ -5,6 +5,7 @@ import './App.css';
|
||||
function SLA() {
|
||||
const [slaData, setSlaData] = useState([]);
|
||||
const [days, setDays] = useState(30);
|
||||
const [maxDays, setMaxDays] = useState(30);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -13,6 +14,25 @@ function SLA() {
|
||||
const [effectiveDays, setEffectiveDays] = useState(30);
|
||||
const [limitedByRetention, setLimitedByRetention] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch retention setting from server
|
||||
const fetchRetention = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/settings');
|
||||
const retentionDays = response.data.retention_days || 30;
|
||||
setMaxDays(parseInt(retentionDays));
|
||||
// Adjust current days if it exceeds retention
|
||||
if (days > retentionDays) {
|
||||
setDays(parseInt(retentionDays));
|
||||
}
|
||||
} catch (error) {
|
||||
// Failed to fetch retention setting, use default
|
||||
}
|
||||
};
|
||||
fetchRetention();
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const loadSLAData = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -25,7 +45,6 @@ function SLA() {
|
||||
setLimitedByRetention(Boolean(response.data.limited_by_retention));
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error loading SLA data:', error);
|
||||
setError(error.message);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -43,11 +62,23 @@ function SLA() {
|
||||
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),
|
||||
totalDowntime: (slaData.reduce((sum, s) => sum + s.downtime_minutes, 0) / 60).toFixed(2), // Convert to hours
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
{limitedByRetention && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffc107',
|
||||
borderRadius: '4px',
|
||||
color: '#856404'
|
||||
}}>
|
||||
⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
||||
</div>
|
||||
)}
|
||||
<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' }}>
|
||||
@@ -56,6 +87,8 @@ function SLA() {
|
||||
key={d}
|
||||
className={`btn ${days === d ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setDays(d)}
|
||||
disabled={d > maxDays}
|
||||
style={{ opacity: d > maxDays ? 0.5 : 1 }}
|
||||
>
|
||||
{d} Days
|
||||
</button>
|
||||
|
||||
@@ -30,7 +30,6 @@ function SettingsWizard() {
|
||||
default_sort: 'name',
|
||||
items_per_page: 20,
|
||||
refresh_interval: 5000,
|
||||
theme: 'light',
|
||||
incident_failure_threshold: 10,
|
||||
});
|
||||
|
||||
@@ -48,7 +47,7 @@ function SettingsWizard() {
|
||||
const response = await axios.get('/api/settings');
|
||||
setSettings(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
// Settings load error handled
|
||||
}
|
||||
};
|
||||
|
||||
@@ -62,7 +61,6 @@ function SettingsWizard() {
|
||||
// Clear message after 3 seconds
|
||||
setTimeout(() => setSavedMessage(null), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
setSavedMessage({ section: sectionName, success: false, error: error.response?.data?.error || 'Failed to save' });
|
||||
setTimeout(() => setSavedMessage(null), 5000);
|
||||
} finally {
|
||||
@@ -115,6 +113,7 @@ function SettingsWizard() {
|
||||
{ name: 'Gmail', host: 'smtp.gmail.com', port: 587 },
|
||||
{ name: 'Outlook', host: 'smtp-mail.outlook.com', port: 587 },
|
||||
{ name: 'Yahoo', host: 'smtp.mail.yahoo.com', port: 587 },
|
||||
{ name: 'iCloud', host: 'smtp.mail.me.com', port: 587 },
|
||||
{ name: 'Custom', host: '', port: 587 },
|
||||
];
|
||||
|
||||
@@ -363,6 +362,20 @@ function SettingsWizard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Global Data Retention (days)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.retention_days}
|
||||
onChange={(e) => setSettings({ ...settings, retention_days: parseInt(e.target.value) })}
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
<small>Default retention period for all monitors (1-365 days). Individual monitors can override this.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Timezone</label>
|
||||
@@ -391,6 +404,18 @@ function SettingsWizard() {
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Consecutive Failures Threshold</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.consecutive_failures || 3}
|
||||
onChange={(e) => setSettings({ ...settings, consecutive_failures: parseInt(e.target.value) })}
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
<small>Number of consecutive failures before triggering an alert (default: 3)</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Incident Failure Threshold</label>
|
||||
<input
|
||||
@@ -400,7 +425,33 @@ function SettingsWizard() {
|
||||
min="1"
|
||||
max="100"
|
||||
/>
|
||||
<small>Number of consecutive failed checks before automatically creating an incident (default: 10)</small>
|
||||
<small>Number of failures before creating an incident (default: 10)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Grace Period (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.grace_period || 300}
|
||||
onChange={(e) => setSettings({ ...settings, grace_period: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
step="60"
|
||||
/>
|
||||
<small>Wait time before alerting after first failure (default: 300s = 5 minutes)</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Downtime Threshold (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.downtime_threshold || 600}
|
||||
onChange={(e) => setSettings({ ...settings, downtime_threshold: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
step="60"
|
||||
/>
|
||||
<small>Minimum downtime before creating incident (default: 600s = 10 minutes)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -411,25 +462,183 @@ function SettingsWizard() {
|
||||
timeout: settings.timeout,
|
||||
timezone: settings.timezone,
|
||||
retention_days: settings.retention_days,
|
||||
auto_cleanup_enabled: settings.auto_cleanup_enabled,
|
||||
consecutive_failures: settings.consecutive_failures,
|
||||
grace_period: settings.grace_period,
|
||||
downtime_threshold: settings.downtime_threshold,
|
||||
incident_failure_threshold: settings.incident_failure_threshold,
|
||||
})}
|
||||
disabled={savingSection === 'Monitoring'}
|
||||
>
|
||||
{savingSection === 'Monitoring' ? 'Saving...' : '💾 Save Monitoring Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alert Settings */}
|
||||
<div className="settings-section">
|
||||
<h3>🔔 Alert Settings</h3>
|
||||
<div className="settings-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Alert Retry Count</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.alert_retry_count || 3}
|
||||
onChange={(e) => setSettings({ ...settings, alert_retry_count: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
max="10"
|
||||
/>
|
||||
<small>Number of times to retry sending alert if it fails (default: 3)</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Alert Retry Delay (seconds)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.alert_retry_delay || 60}
|
||||
onChange={(e) => setSettings({ ...settings, alert_retry_delay: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
step="30"
|
||||
/>
|
||||
<small>Wait time between alert retries (default: 60s)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Fallback Webhook URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.fallback_webhook || ''}
|
||||
onChange={(e) => setSettings({ ...settings, fallback_webhook: e.target.value })}
|
||||
placeholder="https://backup-webhook.example.com"
|
||||
/>
|
||||
<small>Backup webhook to use if primary alert methods fail</small>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Global Quiet Hours Start</label>
|
||||
<input
|
||||
type="time"
|
||||
value={settings.global_quiet_hours_start || ''}
|
||||
onChange={(e) => setSettings({ ...settings, global_quiet_hours_start: e.target.value })}
|
||||
/>
|
||||
<small>Suppress non-critical alerts starting at this time</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Global Quiet Hours End</label>
|
||||
<input
|
||||
type="time"
|
||||
value={settings.global_quiet_hours_end || ''}
|
||||
onChange={(e) => setSettings({ ...settings, global_quiet_hours_end: e.target.value })}
|
||||
/>
|
||||
<small>Resume normal alerting at this time</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Escalation Hours</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.escalation_hours || 4}
|
||||
onChange={(e) => setSettings({ ...settings, escalation_hours: parseInt(e.target.value) })}
|
||||
min="1"
|
||||
max="48"
|
||||
/>
|
||||
<small>Hours after which unresolved incidents are escalated (default: 4)</small>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleSaveSection('Alerts', {
|
||||
alert_retry_count: settings.alert_retry_count,
|
||||
alert_retry_delay: settings.alert_retry_delay,
|
||||
fallback_webhook: settings.fallback_webhook,
|
||||
global_quiet_hours_start: settings.global_quiet_hours_start,
|
||||
global_quiet_hours_end: settings.global_quiet_hours_end,
|
||||
escalation_hours: settings.escalation_hours,
|
||||
})}
|
||||
disabled={savingSection === 'Alerts'}
|
||||
>
|
||||
{savingSection === 'Alerts' ? 'Saving...' : '💾 Save Alert Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Settings */}
|
||||
<div className="settings-section">
|
||||
<h3>📊 Dashboard Settings</h3>
|
||||
<div className="settings-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Default Sort Order</label>
|
||||
<select
|
||||
value={settings.default_sort || 'name'}
|
||||
onChange={(e) => setSettings({ ...settings, default_sort: e.target.value })}
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="status">Status</option>
|
||||
<option value="created_at">Created Date</option>
|
||||
<option value="group">Group</option>
|
||||
</select>
|
||||
<small>Default sorting for resources list</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Items Per Page</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.items_per_page || 20}
|
||||
onChange={(e) => setSettings({ ...settings, items_per_page: parseInt(e.target.value) })}
|
||||
min="10"
|
||||
max="100"
|
||||
step="10"
|
||||
/>
|
||||
<small>Number of resources to display per page</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Dashboard Refresh Interval (ms)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.refresh_interval || 5000}
|
||||
onChange={(e) => setSettings({ ...settings, refresh_interval: parseInt(e.target.value) })}
|
||||
min="1000"
|
||||
step="1000"
|
||||
/>
|
||||
<small>How often to refresh dashboard data (default: 5000ms = 5 seconds)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="toggle-switch-row">
|
||||
<span>Auto Cleanup Enabled</span>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.auto_cleanup_enabled || false}
|
||||
onChange={(e) => setSettings({ ...settings, auto_cleanup_enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
<small>Automatically archive old check data based on retention period</small>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleSaveSection('Dashboard', {
|
||||
default_sort: settings.default_sort,
|
||||
items_per_page: settings.items_per_page,
|
||||
refresh_interval: settings.refresh_interval,
|
||||
theme: settings.theme,
|
||||
incident_failure_threshold: settings.incident_failure_threshold,
|
||||
auto_cleanup_enabled: settings.auto_cleanup_enabled,
|
||||
})}
|
||||
disabled={savingSection === 'Monitoring'}
|
||||
disabled={savingSection === 'Dashboard'}
|
||||
>
|
||||
{savingSection === 'Monitoring' ? 'Saving...' : '💾 Save Monitoring Settings'}
|
||||
{savingSection === 'Dashboard' ? 'Saving...' : '💾 Save Dashboard Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ function Status() {
|
||||
|
||||
useEffect(() => {
|
||||
loadStatusData();
|
||||
const interval = setInterval(loadStatusData, 30000); // Refresh every 30 seconds
|
||||
const interval = setInterval(loadStatusData, 15000); // Refresh every 15 seconds for critical status
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
@@ -28,7 +28,7 @@ function Status() {
|
||||
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Error loading status data:', error);
|
||||
// Status load error handled
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -28,12 +28,19 @@ export const formatLocalTime = (timestamp, options = {}) => {
|
||||
let timeZone;
|
||||
try {
|
||||
const cachedSettings = localStorage.getItem('serverTimezone');
|
||||
if (cachedSettings && cachedSettings !== 'null' && cachedSettings !== 'undefined' && cachedSettings.trim() !== '') {
|
||||
const cacheTime = localStorage.getItem('serverTimezoneTime');
|
||||
const now = Date.now();
|
||||
// Invalidate cache every 1 hour
|
||||
if (cachedSettings && cacheTime && (now - parseInt(cacheTime)) < 3600000 && cachedSettings !== 'null' && cachedSettings !== 'undefined' && cachedSettings.trim() !== '') {
|
||||
// Remove any quotes that might be in the string
|
||||
timeZone = cachedSettings.replace(/^["']|["']$/g, '').trim();
|
||||
} else if (cachedSettings) {
|
||||
// Clear stale cache
|
||||
localStorage.removeItem('serverTimezone');
|
||||
localStorage.removeItem('serverTimezoneTime');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error reading serverTimezone from localStorage:', e);
|
||||
// Timezone load error handled silently
|
||||
}
|
||||
|
||||
const defaultOptions = {
|
||||
@@ -72,12 +79,27 @@ export const formatLocalTimeOnly = (timestamp, options = {}) => {
|
||||
|
||||
/**
|
||||
* Format timestamp for chart display (shorter format)
|
||||
* Shows date and time for better context
|
||||
*/
|
||||
export const formatChartTime = (timestamp) => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// If within last 24 hours, show time only
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise show date and time
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user