Merge redesign command-center updates
This commit is contained in:
commit
85368ba0dc
23 files changed
+2240
-641
No files matched your search
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"files": {
|
"files": {
|
||||||
"main.css": "/static/css/main.4f505a72.css",
|
"main.css": "/static/css/main.6034edfe.css",
|
||||||
"main.js": "/static/js/main.29f97b36.js",
|
"main.js": "/static/js/main.243399c3.js",
|
||||||
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
|
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
|
||||||
"index.html": "/index.html",
|
"index.html": "/index.html",
|
||||||
"main.4f505a72.css.map": "/static/css/main.4f505a72.css.map",
|
"main.6034edfe.css.map": "/static/css/main.6034edfe.css.map",
|
||||||
"main.29f97b36.js.map": "/static/js/main.29f97b36.js.map",
|
"main.243399c3.js.map": "/static/js/main.243399c3.js.map",
|
||||||
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
|
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
|
||||||
},
|
},
|
||||||
"entrypoints": [
|
"entrypoints": [
|
||||||
"static/css/main.4f505a72.css",
|
"static/css/main.6034edfe.css",
|
||||||
"static/js/main.29f97b36.js"
|
"static/js/main.243399c3.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.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>
|
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.243399c3.js"></script><link href="/static/css/main.6034edfe.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.App{min-height:100vh}.navbar{background:linear-gradient(135deg,#667eea,#764ba2);box-shadow:0 2px 8px #0000001a;color:#fff;padding:1rem 2rem}.navbar h1{font-size:1.5rem;margin:0}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-bottom:1.5rem}.resource-card{background:#fff;border:1px solid #f1f1f3;border-radius:10px;box-shadow:0 2px 8px #00000014;cursor:pointer;padding:1rem 1.1rem;transition:transform .2s,box-shadow .2s}.resource-card.compact{display:flex;flex-direction:column;gap:.4rem}.resource-card:hover{box-shadow:0 4px 12px #00000026;transform:translateY(-2px)}.resource-header{align-items:flex-start;display:flex;justify-content:space-between;margin-bottom:.5rem}.resource-name{font-size:1.05rem;font-weight:700;margin:0 0 .2rem}.resource-url{color:#666;font-size:.8rem;word-break:break-all}.status-badge{border-radius:12px;font-size:.7rem;font-weight:600;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*/
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"static/css/main.4f505a72.css","mappings":"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 truncated
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#020617;color:#e2e8f0;font-family:Manrope,IBM Plex Sans,Segoe UI,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.App{background:radial-gradient(circle at 15% 0,#1e40af33 0,#0000 42%),radial-gradient(circle at 95% 10%,#0f766e24 0,#0000 36%),#020617;min-height:100vh}.navbar{backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);background:#0f172ad1;border-bottom:1px solid #334155;box-shadow:0 4px 18px #0f172a0f;color:#e2e8f0;padding:.85rem 2rem;position:-webkit-sticky;position:sticky;top:0;z-index:30}.navbar h1{font-size:1.5rem;letter-spacing:-.02em;margin:0}.navbar-inner{gap:1rem;justify-content:space-between;width:100%}.navbar-inner,.navbar-left{align-items:center;display:flex}.navbar-left{gap:1.5rem;min-width:0}.navbar-links{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.nav-link{border-radius:7px;color:#cbd5e1;font-size:.92rem;font-weight:600;padding:.4rem .7rem;text-decoration:none;transition:background .2s,color .2s,box-shadow .2s}.nav-link:hover{background:#111827;box-shadow:inset 0 0 0 1px #334155;color:#f8fafc}.settings-link{align-items:center;display:inline-flex;gap:.4rem}.setup-badge{background:#facc15;border-radius:999px;color:#111827;font-size:.68rem;font-weight:700;padding:.15rem .5rem}.notifications-pill{align-items:center;background:#111827;border:1px solid #334155;border-radius:999px;color:#e2e8f0;display:flex;font-size:.82rem;gap:.45rem;padding:.3rem .7rem}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-toolbar{align-items:center;display:flex;gap:1rem;justify-content:space-between;margin-bottom:2rem}.dashboard-title{color:#f8fafc;font-size:1.85rem;letter-spacing:-.02em;margin:0}.dashboard-subtitle{color:#94a3b8;font-size:.9rem;margin:.2rem 0 0}.dashboard-actions{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.quick-filter-row{display:flex;flex-wrap:wrap;gap:.45rem;margin:.2rem 0 .8rem}.quick-chip{background:#0f172a;border:1px solid #334155;border-radius:999px;color:#cbd5e1;cursor:pointer;font-size:.74rem;font-weight:700;letter-spacing:.02em;padding:.28rem .7rem}.quick-chip:hover{background:#111f34}.quick-chip.active{background:#14b8a6;border-color:#14b8a6;color:#052e2b}.tag-filter-input{background:#111827;border:1px solid #334155;border-radius:10px;color:#e2e8f0;min-width:180px;padding:.5rem .75rem}.actions-menu-wrapper{position:relative}.actions-menu{background:#fff;border:1px solid #dbe2ea;border-radius:8px;box-shadow:0 8px 20px #0f172a24;min-width:220px;padding:.35rem 0;position:absolute;right:0;top:110%;z-index:20}.actions-menu-divider{border:0;border-top:1px solid #eef2f6;margin:.25rem 0}.hidden-file-input{display:none}.toast-stack{position:fixed;right:20px;top:20px;z-index:9999}.toast-item{border:1px solid #0000;border-radius:8px;box-shadow:0 8px 20px #0f172a29;margin-bottom:10px;min-width:280px;padding:12px 16px}.toast-error{background:#fee2e2;border-color:#fecaca;color:#7f1d1d}.toast-success{background:#dcfce7;border-color:#bbf7d0;color:#14532d}.toast-info{background:#dbeafe;border-color:#bfdbfe;color:#1e3a8a}.cc-metrics-grid{grid-gap:.65rem;display:grid;gap:.65rem;grid-template-columns:repeat(6,minmax(0,1fr));margin-bottom:1rem}.cc-metric-card{background:linear-gradient(180deg,#0f172af5,#1e293bf5);border:1px solid #334155;border-radius:10px;color:#e2e8f0;padding:.75rem}.cc-metric-label{color:#94a3b8;font-size:.68rem;letter-spacing:.08em;margin:0;text-transform:uppercase}.cc-metric-value{font-size:1.2rem;font-weight:700;margin:.35rem 0 0}.metric-ok{border-color:#14532d}.metric-down{border-color:#7f1d1d}.metric-maint{border-color:#78350f}.cc-layout{grid-gap:.8rem;align-items:start;display:grid;gap:.8rem;grid-template-columns:minmax(0,1fr) 300px}.cc-main{min-width:0}.cc-rail{display:flex;flex-direction:column;gap:.75rem;position:-webkit-sticky;position:sticky;top:82px}.cc-panel{background:#0f172af0;border:1px solid #334155;border-radius:12px;color:#e2e8f0;padding:.75rem}.cc-panel h3{font-size:.9rem;letter-spacing:.03em;margin:0 0 .65rem}.cc-empty{color:#94a3b8;font-size:.85rem;margin:0}.cc-group-list,.cc-incident-list{display:flex;flex-direction:column;gap:.4rem}.cc-group-item,.cc-incident-item{align-items:center;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#e2e8f0;cursor:pointer;display:flex;justify-content:space-between;padding:.45rem .55rem;width:100%}.cc-group-item:hover,.cc-incident-item:hover{background:#111f34;border-color:#475569}.cc-incident-name{font-size:.85rem;font-weight:600;margin-right:.5rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dashboard-grid{grid-gap:1rem;display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-bottom:1.5rem}.resource-list{display:flex;flex-direction:column;gap:.45rem}.resource-list-shell{background:#0f172ae6;border:1px solid #334155;border-radius:12px;max-height:calc(100vhLine truncated
|
||||||
|
/*# sourceMappingURL=main.6034edfe.css.map*/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"static/css/main.6034edfe.css","mappings":"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 truncated
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
/*! For license information please see main.29f97b36.js.LICENSE.txt */
|
/*! For license information please see main.243399c3.js.LICENSE.txt */
|
||||||
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
|
(()=>{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
|
//# sourceMappingURL=main.243399c3.js.map
|
||||||
File renamed without changes.
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"static/js/main.243399c3.js","mappings":"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 truncated
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"static/js/main.29f97b36.js","mappings":"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 truncated
|
|
||||||
+911
-59
File diff suppressed because it is too large.
Load diff
+377
-245
@@ -29,8 +29,6 @@ function Dashboard() {
|
|||||||
const [showGroupModal, setShowGroupModal] = useState(false);
|
const [showGroupModal, setShowGroupModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [actionsOpen, setActionsOpen] = useState(false);
|
const [actionsOpen, setActionsOpen] = useState(false);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState({});
|
|
||||||
const [editingId, setEditingId] = useState(null);
|
|
||||||
const [editData, setEditData] = useState({});
|
const [editData, setEditData] = useState({});
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -53,6 +51,10 @@ function Dashboard() {
|
|||||||
response_time_threshold: null,
|
response_time_threshold: null,
|
||||||
});
|
});
|
||||||
const [tagFilter, setTagFilter] = useState('');
|
const [tagFilter, setTagFilter] = useState('');
|
||||||
|
const [groupFilter, setGroupFilter] = useState('all');
|
||||||
|
const [quickFilter, setQuickFilter] = useState('all');
|
||||||
|
const [sortKey, setSortKey] = useState('severity');
|
||||||
|
const [renderLimit, setRenderLimit] = useState(120);
|
||||||
const [groupData, setGroupData] = useState({ name: '', description: '' });
|
const [groupData, setGroupData] = useState({ name: '', description: '' });
|
||||||
const wsRef = useRef(null);
|
const wsRef = useRef(null);
|
||||||
const actionsRef = useRef(null);
|
const actionsRef = useRef(null);
|
||||||
@@ -239,6 +241,17 @@ function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleMaintenance = async (resource) => {
|
||||||
|
try {
|
||||||
|
await axios.patch(`/api/resources/${resource.id}/maintenance-mode`, {
|
||||||
|
maintenance_mode: !resource.maintenance_mode,
|
||||||
|
});
|
||||||
|
loadResources();
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error updating maintenance mode');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleExportCSV = async () => {
|
const handleExportCSV = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/resources/export', {
|
const response = await axios.get('/api/resources/export', {
|
||||||
@@ -279,121 +292,182 @@ function Dashboard() {
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleGroup = (groupId) => {
|
const groupNameById = groups.reduce((acc, g) => {
|
||||||
setCollapsedGroups(prev => ({
|
acc[g.id] = g.name;
|
||||||
...prev,
|
|
||||||
[groupId]: !prev[groupId]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter resources by tag
|
|
||||||
const filteredResources = tagFilter
|
|
||||||
? resources.filter(r => {
|
|
||||||
const tags = (r.tags || '').toLowerCase();
|
|
||||||
return tags.includes(tagFilter.toLowerCase());
|
|
||||||
})
|
|
||||||
: resources;
|
|
||||||
|
|
||||||
const groupResourcesMap = filteredResources.reduce((acc, r) => {
|
|
||||||
const gid = r.group_id || 'ungrouped';
|
|
||||||
if (!acc[gid]) acc[gid] = [];
|
|
||||||
acc[gid].push(r);
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const getGroupStats = (groupResources) => {
|
const getTrend = (recentChecks = []) => {
|
||||||
if (!groupResources || groupResources.length === 0) return { uptime: 0, avgResponse: 0, total: 0 };
|
const withTimes = recentChecks.filter((c) => typeof c.response_time === 'number' && c.response_time > 0);
|
||||||
const uptime = (groupResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / groupResources.length).toFixed(2);
|
if (withTimes.length < 2) return 'flat';
|
||||||
const avgResponse = (groupResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0), 0) / groupResources.length).toFixed(0);
|
const first = withTimes[0].response_time;
|
||||||
return { uptime, avgResponse, total: groupResources.length };
|
const last = withTimes[withTimes.length - 1].response_time;
|
||||||
|
if (first <= 0) return 'flat';
|
||||||
|
const deltaPct = ((last - first) / first) * 100;
|
||||||
|
if (deltaPct <= -12) return 'better';
|
||||||
|
if (deltaPct >= 12) return 'worse';
|
||||||
|
return 'flat';
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderResourceCard = (resource) => {
|
const getSeverityScore = (r) => {
|
||||||
|
const statusScore = r.status === 'down' ? 1000 : r.status === 'unknown' ? 400 : 0;
|
||||||
|
const incidentScore = r.hasActiveIncident ? 350 : 0;
|
||||||
|
const maintenanceOffset = r.maintenance_mode ? -300 : 0;
|
||||||
|
const uptimePenalty = Math.max(0, 100 - parseFloat(r.uptime || 0)) * 3;
|
||||||
|
const responsePenalty = Math.min(250, Math.round((parseInt(r.avgResponseTime || 0, 10) || 0) / 20));
|
||||||
|
return statusScore + incidentScore + uptimePenalty + responsePenalty + maintenanceOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter resources by tag/group/quick filters
|
||||||
|
const filteredResources = resources
|
||||||
|
.filter((r) => {
|
||||||
|
if (tagFilter) {
|
||||||
|
const tags = (r.tags || '').toLowerCase();
|
||||||
|
if (!tags.includes(tagFilter.toLowerCase())) return false;
|
||||||
|
}
|
||||||
|
if (groupFilter === 'ungrouped') return !r.group_id;
|
||||||
|
if (groupFilter !== 'all') return String(r.group_id || '') === groupFilter;
|
||||||
|
|
||||||
|
if (quickFilter === 'down' && r.status !== 'down') return false;
|
||||||
|
if (quickFilter === 'maintenance' && !r.maintenance_mode) return false;
|
||||||
|
if (quickFilter === 'nodata' && (!r.recentChecks || r.recentChecks.length === 0)) return false;
|
||||||
|
if (quickFilter === 'slow' && (parseInt(r.avgResponseTime || 0, 10) || 0) < 1500) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedResources = [...filteredResources].sort((a, b) => {
|
||||||
|
if (sortKey === 'name') {
|
||||||
|
return String(a.name || '').localeCompare(String(b.name || ''));
|
||||||
|
}
|
||||||
|
if (sortKey === 'status') {
|
||||||
|
const rank = { down: 0, unknown: 1, up: 2 };
|
||||||
|
return (rank[a.status] ?? 3) - (rank[b.status] ?? 3);
|
||||||
|
}
|
||||||
|
if (sortKey === 'uptime') {
|
||||||
|
return parseFloat(a.uptime || 0) - parseFloat(b.uptime || 0);
|
||||||
|
}
|
||||||
|
if (sortKey === 'response') {
|
||||||
|
return (parseInt(b.avgResponseTime || 0, 10) || 0) - (parseInt(a.avgResponseTime || 0, 10) || 0);
|
||||||
|
}
|
||||||
|
if (sortKey === 'lastcheck') {
|
||||||
|
const ta = a.lastCheck ? new Date(a.lastCheck).getTime() : 0;
|
||||||
|
const tb = b.lastCheck ? new Date(b.lastCheck).getTime() : 0;
|
||||||
|
return tb - ta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: severity first
|
||||||
|
const severityDiff = getSeverityScore(b) - getSeverityScore(a);
|
||||||
|
if (severityDiff !== 0) return severityDiff;
|
||||||
|
|
||||||
|
const groupA = (groupNameById[a.group_id] || 'Ungrouped').toLowerCase();
|
||||||
|
const groupB = (groupNameById[b.group_id] || 'Ungrouped').toLowerCase();
|
||||||
|
if (groupA < groupB) return -1;
|
||||||
|
if (groupA > groupB) return 1;
|
||||||
|
return String(a.name || '').localeCompare(String(b.name || ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRenderLimit(120);
|
||||||
|
}, [tagFilter, groupFilter, quickFilter, sortKey]);
|
||||||
|
|
||||||
|
const visibleResources = sortedResources.slice(0, renderLimit);
|
||||||
|
|
||||||
|
const totalCount = sortedResources.length;
|
||||||
|
const upCount = sortedResources.filter((r) => r.status === 'up').length;
|
||||||
|
const downCount = sortedResources.filter((r) => r.status === 'down').length;
|
||||||
|
const maintenanceCount = sortedResources.filter((r) => r.maintenance_mode).length;
|
||||||
|
const avgUptime = totalCount
|
||||||
|
? (sortedResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / totalCount).toFixed(2)
|
||||||
|
: '0.00';
|
||||||
|
const avgResponse = totalCount
|
||||||
|
? Math.round(sortedResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0, 10), 0) / totalCount)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const activeIncidents = sortedResources
|
||||||
|
.filter((r) => r.hasActiveIncident || r.status === 'down')
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const groupCounts = groups
|
||||||
|
.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
count: sortedResources.filter((r) => String(r.group_id || '') === String(g.id)).length,
|
||||||
|
}))
|
||||||
|
.filter((g) => g.count > 0);
|
||||||
|
|
||||||
|
const ungroupedCount = sortedResources.filter((r) => !r.group_id).length;
|
||||||
|
|
||||||
|
const renderResourceRow = (resource) => {
|
||||||
const sparkData = (resource.recentChecks || []).map((c, idx) => ({
|
const sparkData = (resource.recentChecks || []).map((c, idx) => ({
|
||||||
idx,
|
idx,
|
||||||
responseTime: c.response_time || 0,
|
responseTime: c.response_time || 0,
|
||||||
statusValue: c.status === 'up' ? 1 : 0,
|
statusValue: c.status === 'up' ? 1 : 0,
|
||||||
}));
|
}));
|
||||||
|
const groupName = groupNameById[resource.group_id] || 'Ungrouped';
|
||||||
const isEditing = editingId === resource.id;
|
const trend = getTrend(resource.recentChecks || []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={resource.id}
|
key={resource.id}
|
||||||
className="resource-card compact"
|
className="resource-row"
|
||||||
onClick={(e) => !isEditing && !e.target.closest('.card-actions') && navigate(`/resource/${resource.id}`)}
|
onClick={(e) => !e.target.closest('.row-actions') && navigate(`/resource/${resource.id}`)}
|
||||||
>
|
>
|
||||||
<div className="resource-header">
|
<div className="row-main">
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
<div className="row-title-line">
|
||||||
{isEditing ? (
|
<h3 className="resource-name row-name">{resource.name}</h3>
|
||||||
<input
|
<span className="resource-group-pill">{groupName}</span>
|
||||||
type="text"
|
{resource.hasActiveIncident && <span className="incident-inline">active incident</span>}
|
||||||
value={editData.name || resource.name}
|
{resource.maintenance_mode && <span className="maintenance-inline">🛠 maintenance</span>}
|
||||||
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="edit-input"
|
|
||||||
placeholder="Resource name"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
|
||||||
<h3 className="resource-name" style={{ margin: 0 }}>{resource.name}</h3>
|
|
||||||
{resource.maintenance_mode && (
|
|
||||||
<span title="Maintenance mode" style={{ fontSize: '0.95rem' }}>🛠️</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="resource-url">{resource.url}</p>
|
|
||||||
<p className="resource-type">Type: {resource.type}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className={`status-badge status-${resource.status}`}>
|
<p className="resource-url row-url">{resource.url}</p>
|
||||||
{resource.status}
|
<p className="resource-type row-type">{resource.type}</p>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{resource.hasActiveIncident && (
|
<div className="row-metrics">
|
||||||
<div className="incident-badge">⚠️ Active Incident</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="resource-meta">
|
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p className="stat-value small">{resource.uptime}%</p>
|
||||||
className="stat-value small"
|
<p className="stat-label">Uptime</p>
|
||||||
style={{ cursor: 'pointer', color: resource.maintenance_mode ? '#ffc107' : undefined }}
|
|
||||||
onClick={(e) => { e.stopPropagation(); if (!isEditing) setEditingId(resource.id); }}
|
|
||||||
>
|
|
||||||
{resource.uptime}%
|
|
||||||
</p>
|
|
||||||
<p className="stat-label">Uptime (24h)</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="stat-value small" style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); if (!isEditing) setEditingId(resource.id); }}>
|
<p className="stat-value small">
|
||||||
{resource.avgResponseTime}ms
|
{resource.avgResponseTime}ms
|
||||||
|
<span className={`trend-chip trend-${trend}`}>{trend === 'better' ? '↓' : trend === 'worse' ? '↑' : '→'}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="stat-label">Avg Resp</p>
|
<p className="stat-label">Avg Resp</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="sparkline">
|
<div className="row-sparkline">
|
||||||
{sparkData.length > 1 ? (
|
{sparkData.length > 1 ? (
|
||||||
<ResponsiveContainer width="100%" height={50}>
|
<ResponsiveContainer width="100%" height={44}>
|
||||||
<LineChart data={sparkData} margin={{ top: 4, bottom: 0, left: 0, right: 0 }}>
|
<LineChart data={sparkData} margin={{ top: 2, bottom: 0, left: 0, right: 0 }}>
|
||||||
<Line type="monotone" dataKey="responseTime" stroke="#667eea" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="responseTime" stroke="#14b8a6" strokeWidth={2} dot={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ fontSize: '0.75rem', color: '#999' }}>No recent data</span>
|
<span className="last-check">No recent data</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-actions">
|
<div className="row-side">
|
||||||
<button className="btn-icon" title="Edit" onClick={(e) => { e.stopPropagation(); openEditModal(resource); }}>✎</button>
|
<span className={`status-badge status-${resource.status}`}>
|
||||||
<button className="btn-icon" title="Delete" onClick={(e) => { e.stopPropagation(); handleDeleteResource(resource.id); }}>🗑</button>
|
{resource.status}
|
||||||
</div>
|
</span>
|
||||||
|
<div className="row-actions">
|
||||||
{!isEditing && resource.lastCheck && (
|
<button className="btn-icon action-edit" title="Edit" onClick={(e) => { e.stopPropagation(); openEditModal(resource); }}>✎</button>
|
||||||
|
<button
|
||||||
|
className="btn-icon action-maint"
|
||||||
|
title={resource.maintenance_mode ? 'End maintenance' : 'Start maintenance'}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleToggleMaintenance(resource); }}
|
||||||
|
>
|
||||||
|
{resource.maintenance_mode ? '✅' : '🛠'}
|
||||||
|
</button>
|
||||||
|
<button className="btn-icon action-delete" title="Delete" onClick={(e) => { e.stopPropagation(); handleDeleteResource(resource.id); }}>🗑</button>
|
||||||
|
</div>
|
||||||
<p className="last-check">
|
<p className="last-check">
|
||||||
Last: {formatLocalTime(resource.lastCheck)}
|
Last: {resource.lastCheck ? formatLocalTime(resource.lastCheck) : 'Never'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -402,20 +476,11 @@ function Dashboard() {
|
|||||||
<div className="container">
|
<div className="container">
|
||||||
{/* Real-time Notifications */}
|
{/* Real-time Notifications */}
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 9999 }}>
|
<div className="toast-stack">
|
||||||
{notifications.map(notif => (
|
{notifications.map(notif => (
|
||||||
<div
|
<div
|
||||||
key={notif.id}
|
key={notif.id}
|
||||||
style={{
|
className={`toast-item toast-${notif.type}`}
|
||||||
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>
|
<strong>{notif.title}</strong>
|
||||||
<div>{notif.message}</div>
|
<div>{notif.message}</div>
|
||||||
@@ -423,18 +488,44 @@ function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
<div className="dashboard-toolbar">
|
||||||
<h2>SkyWatch Dashboard</h2>
|
<div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
<h2 className="dashboard-title">Command Center</h2>
|
||||||
|
<p className="dashboard-subtitle">Live operational view of all monitors</p>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-actions">
|
||||||
<NotificationCenter />
|
<NotificationCenter />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Filter by tag"
|
placeholder="Filter by tag"
|
||||||
value={tagFilter}
|
value={tagFilter}
|
||||||
onChange={(e) => setTagFilter(e.target.value)}
|
onChange={(e) => setTagFilter(e.target.value)}
|
||||||
style={{ padding: '0.5rem 0.75rem', borderRadius: '4px', border: '1px solid #ddd', minWidth: '160px' }}
|
className="tag-filter-input"
|
||||||
/>
|
/>
|
||||||
<div ref={actionsRef} style={{ position: 'relative' }}>
|
<select
|
||||||
|
value={groupFilter}
|
||||||
|
onChange={(e) => setGroupFilter(e.target.value)}
|
||||||
|
className="tag-filter-input"
|
||||||
|
>
|
||||||
|
<option value="all">All groups</option>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<option key={g.id} value={String(g.id)}>{g.name}</option>
|
||||||
|
))}
|
||||||
|
<option value="ungrouped">Ungrouped</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={sortKey}
|
||||||
|
onChange={(e) => setSortKey(e.target.value)}
|
||||||
|
className="tag-filter-input"
|
||||||
|
>
|
||||||
|
<option value="severity">Sort: Severity</option>
|
||||||
|
<option value="status">Sort: Status</option>
|
||||||
|
<option value="uptime">Sort: Uptime</option>
|
||||||
|
<option value="response">Sort: Response</option>
|
||||||
|
<option value="lastcheck">Sort: Last Check</option>
|
||||||
|
<option value="name">Sort: Name</option>
|
||||||
|
</select>
|
||||||
|
<div ref={actionsRef} className="actions-menu-wrapper">
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => setActionsOpen((o) => !o)}
|
onClick={() => setActionsOpen((o) => !o)}
|
||||||
@@ -446,23 +537,11 @@ function Dashboard() {
|
|||||||
{actionsOpen && (
|
{actionsOpen && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
style={{
|
className="actions-menu"
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: '110%',
|
|
||||||
background: '#fff',
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '6px',
|
|
||||||
boxShadow: '0 8px 20px rgba(0,0,0,0.12)',
|
|
||||||
minWidth: '200px',
|
|
||||||
padding: '0.25rem 0',
|
|
||||||
zIndex: 20,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => { setActionsOpen(false); setShowGroupModal(true); }}
|
onClick={() => { setActionsOpen(false); setShowGroupModal(true); }}
|
||||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '0.6rem 0.8rem' }}
|
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
+ New Group
|
+ New Group
|
||||||
@@ -470,16 +549,14 @@ function Dashboard() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => { setActionsOpen(false); setShowModal(true); }}
|
onClick={() => { setActionsOpen(false); setShowModal(true); }}
|
||||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '0.6rem 0.8rem' }}
|
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
+ Add Resource
|
+ Add Resource
|
||||||
</button>
|
</button>
|
||||||
<hr style={{ margin: '0.25rem 0', border: 0, borderTop: '1px solid #eee' }} />
|
<hr className="actions-menu-divider" />
|
||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => { setActionsOpen(false); handleExportCSV(); }}
|
onClick={() => { setActionsOpen(false); handleExportCSV(); }}
|
||||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '0.6rem 0.8rem' }}
|
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
⬇ Export CSV
|
⬇ Export CSV
|
||||||
@@ -487,7 +564,6 @@ function Dashboard() {
|
|||||||
<button
|
<button
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
onClick={() => { setActionsOpen(false); document.getElementById('csv-import').click(); }}
|
onClick={() => { setActionsOpen(false); document.getElementById('csv-import').click(); }}
|
||||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '0.6rem 0.8rem' }}
|
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
⬆ Import CSV
|
⬆ Import CSV
|
||||||
@@ -499,65 +575,120 @@ function Dashboard() {
|
|||||||
id="csv-import"
|
id="csv-import"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv"
|
accept=".csv"
|
||||||
style={{ display: 'none' }}
|
className="hidden-file-input"
|
||||||
onChange={handleImportCSV}
|
onChange={handleImportCSV}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="quick-filter-row">
|
||||||
|
<button className={`quick-chip ${quickFilter === 'all' ? 'active' : ''}`} onClick={() => setQuickFilter('all')}>All</button>
|
||||||
|
<button className={`quick-chip ${quickFilter === 'down' ? 'active' : ''}`} onClick={() => setQuickFilter('down')}>Down</button>
|
||||||
|
<button className={`quick-chip ${quickFilter === 'maintenance' ? 'active' : ''}`} onClick={() => setQuickFilter('maintenance')}>Maintenance</button>
|
||||||
|
<button className={`quick-chip ${quickFilter === 'slow' ? 'active' : ''}`} onClick={() => setQuickFilter('slow')}>High Latency</button>
|
||||||
|
<button className={`quick-chip ${quickFilter === 'nodata' ? 'active' : ''}`} onClick={() => setQuickFilter('nodata')}>No Data</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cc-metrics-grid">
|
||||||
|
<div className="cc-metric-card">
|
||||||
|
<p className="cc-metric-label">Monitors</p>
|
||||||
|
<p className="cc-metric-value">{totalCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="cc-metric-card metric-ok">
|
||||||
|
<p className="cc-metric-label">Up</p>
|
||||||
|
<p className="cc-metric-value">{upCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="cc-metric-card metric-down">
|
||||||
|
<p className="cc-metric-label">Down</p>
|
||||||
|
<p className="cc-metric-value">{downCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="cc-metric-card metric-maint">
|
||||||
|
<p className="cc-metric-label">Maintenance</p>
|
||||||
|
<p className="cc-metric-value">{maintenanceCount}</p>
|
||||||
|
</div>
|
||||||
|
<div className="cc-metric-card">
|
||||||
|
<p className="cc-metric-label">Avg Uptime</p>
|
||||||
|
<p className="cc-metric-value">{avgUptime}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="cc-metric-card">
|
||||||
|
<p className="cc-metric-label">Avg Resp</p>
|
||||||
|
<p className="cc-metric-value">{avgResponse}ms</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{resources.length === 0 ? (
|
{resources.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>No resources yet</h3>
|
<h3>No resources yet</h3>
|
||||||
<p>Add your first resource to start monitoring</p>
|
<p>Add your first resource to start monitoring</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredResources.length === 0 ? (
|
) : sortedResources.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>No resources match filter</h3>
|
<h3>No resources match filter</h3>
|
||||||
<p>Try adjusting your tag filter</p>
|
<p>Try adjusting your tag/group filters</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="cc-layout">
|
||||||
{groups.map((group) => {
|
<section className="resource-list-shell cc-main">
|
||||||
const groupStats = getGroupStats(groupResourcesMap[group.id]);
|
<div className="resource-list-header">
|
||||||
const isCollapsed = collapsedGroups[group.id];
|
<div>Resource</div>
|
||||||
return (
|
<div>Metrics</div>
|
||||||
<div key={group.id} className="group-section">
|
<div>Status</div>
|
||||||
<div className="group-header" onClick={() => toggleGroup(group.id)}>
|
</div>
|
||||||
<span className="group-toggle">{isCollapsed ? '▶' : '▼'}</span>
|
<div className="resource-list-count">{visibleResources.length} of {sortedResources.length} monitors</div>
|
||||||
<h3 style={{ margin: 0, flex: 1 }}>{group.name}</h3>
|
<div className="resource-list">
|
||||||
<div className="group-stats">
|
{visibleResources.map(renderResourceRow)}
|
||||||
<span className="group-stat">{groupStats.total} resources</span>
|
{visibleResources.length < sortedResources.length && (
|
||||||
<span className="group-stat">{groupStats.uptime}% up</span>
|
<button className="load-more-btn" onClick={() => setRenderLimit((prev) => prev + 120)}>
|
||||||
<span className="group-stat">{groupStats.avgResponse}ms avg</span>
|
Load 120 more
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="dashboard-grid">
|
|
||||||
{groupResourcesMap[group.id]?.map(renderResourceCard)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
</section>
|
||||||
})}
|
|
||||||
{groupResourcesMap['ungrouped']?.length > 0 && (
|
<aside className="cc-rail">
|
||||||
<div className="group-section">
|
<div className="cc-panel">
|
||||||
<div className="group-header" onClick={() => toggleGroup('ungrouped')}>
|
<h3>Active Incidents</h3>
|
||||||
<span className="group-toggle">{collapsedGroups['ungrouped'] ? '▶' : '▼'}</span>
|
{activeIncidents.length === 0 ? (
|
||||||
<h3 style={{ margin: 0, flex: 1 }}>Ungrouped</h3>
|
<p className="cc-empty">No active incidents</p>
|
||||||
<div className="group-stats">
|
) : (
|
||||||
<span className="group-stat">{groupResourcesMap['ungrouped'].length} resources</span>
|
<div className="cc-incident-list">
|
||||||
<span className="group-stat">{getGroupStats(groupResourcesMap['ungrouped']).uptime}% up</span>
|
{activeIncidents.map((incident) => (
|
||||||
<span className="group-stat">{getGroupStats(groupResourcesMap['ungrouped']).avgResponse}ms avg</span>
|
<button
|
||||||
</div>
|
key={incident.id}
|
||||||
</div>
|
className="cc-incident-item"
|
||||||
{!collapsedGroups['ungrouped'] && (
|
onClick={() => navigate(`/resource/${incident.id}`)}
|
||||||
<div className="dashboard-grid">
|
>
|
||||||
{groupResourcesMap['ungrouped'].map(renderResourceCard)}
|
<span className="cc-incident-name">{incident.name}</span>
|
||||||
|
<span className={`status-badge status-${incident.status}`}>{incident.status}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div className="cc-panel">
|
||||||
|
<h3>Groups</h3>
|
||||||
|
<div className="cc-group-list">
|
||||||
|
{groupCounts.map((g) => (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
className="cc-group-item"
|
||||||
|
onClick={() => setGroupFilter(String(g.id))}
|
||||||
|
>
|
||||||
|
<span>{g.name}</span>
|
||||||
|
<span>{g.count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{ungroupedCount > 0 && (
|
||||||
|
<button className="cc-group-item" onClick={() => setGroupFilter('ungrouped')}>
|
||||||
|
<span>Ungrouped</span>
|
||||||
|
<span>{ungroupedCount}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showGroupModal && (
|
{showGroupModal && (
|
||||||
@@ -1272,13 +1403,13 @@ function ResourceDetail() {
|
|||||||
<Link to="/" className="back-button">← Back to Dashboard</Link>
|
<Link to="/" className="back-button">← Back to Dashboard</Link>
|
||||||
|
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
|
<div className="detail-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h2>{resource.name}</h2>
|
<h2>{resource.name}</h2>
|
||||||
<p style={{ color: '#666' }}>{resource.url}</p>
|
<p className="resource-url-detail">{resource.url}</p>
|
||||||
<p className="resource-type">Type: {resource.type}</p>
|
<p className="resource-type">Type: {resource.type}</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
<div className="detail-actions">
|
||||||
<button className="btn" onClick={toggleEnabled}>
|
<button className="btn" onClick={toggleEnabled}>
|
||||||
{resource.enabled ? 'Disable' : 'Enable'}
|
{resource.enabled ? 'Disable' : 'Enable'}
|
||||||
</button>
|
</button>
|
||||||
@@ -1295,12 +1426,12 @@ function ResourceDetail() {
|
|||||||
<div className="incident-badge">⚠️ Active Incident - Resource is currently DOWN</div>
|
<div className="incident-badge">⚠️ Active Incident - Resource is currently DOWN</div>
|
||||||
)}
|
)}
|
||||||
{resource.maintenance_mode && (
|
{resource.maintenance_mode && (
|
||||||
<div className="incident-badge" style={{ background: '#fff3cd', color: '#856404', borderColor: '#ffeeba' }}>
|
<div className="incident-badge maintenance-badge">
|
||||||
🛠️ Maintenance mode active — alerts are suppressed
|
🛠️ Maintenance mode active — alerts are suppressed
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)', marginTop: '2rem' }}>
|
<div className="stats-grid stats-grid-4 detail-stats-grid">
|
||||||
<div className="stat">
|
<div className="stat">
|
||||||
<p className="stat-value">{resource.stats.uptime}%</p>
|
<p className="stat-value">{resource.stats.uptime}%</p>
|
||||||
<p className="stat-label">Uptime (24h)</p>
|
<p className="stat-label">Uptime (24h)</p>
|
||||||
@@ -1329,7 +1460,7 @@ function ResourceDetail() {
|
|||||||
) : !sla ? (
|
) : !sla ? (
|
||||||
<p>No SLA data yet</p>
|
<p>No SLA data yet</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
<div className="stats-grid stats-grid-4">
|
||||||
<div className="stat">
|
<div className="stat">
|
||||||
<p className="stat-value">{sla.uptimePct}%</p>
|
<p className="stat-value">{sla.uptimePct}%</p>
|
||||||
<p className="stat-label">Uptime</p>
|
<p className="stat-label">Uptime</p>
|
||||||
@@ -1363,11 +1494,15 @@ function ResourceDetail() {
|
|||||||
<div className="chart-container">
|
<div className="chart-container">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
<XAxis dataKey="time" />
|
<XAxis dataKey="time" tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||||
<YAxis />
|
<YAxis tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||||
<Tooltip />
|
<Tooltip
|
||||||
<Line type="monotone" dataKey="responseTime" stroke="#667eea" strokeWidth={2} />
|
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px' }}
|
||||||
|
labelStyle={{ color: '#cbd5e1' }}
|
||||||
|
itemStyle={{ color: '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="responseTime" stroke="#60a5fa" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -1375,100 +1510,103 @@ function ResourceDetail() {
|
|||||||
|
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h2>Recent Checks</h2>
|
<h2>Recent Checks</h2>
|
||||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
<div className="section-controls">
|
||||||
<div>
|
<div className="filter-control">
|
||||||
<label style={{ fontSize: '0.9rem', color: '#555', marginRight: '0.4rem' }}>Status</label>
|
<label>Status</label>
|
||||||
<select value={checksStatus} onChange={(e) => { setChecksPage(0); setChecksStatus(e.target.value); }}>
|
<select value={checksStatus} onChange={(e) => { setChecksPage(0); setChecksStatus(e.target.value); }}>
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
<option value="up">Up</option>
|
<option value="up">Up</option>
|
||||||
<option value="down">Down</option>
|
<option value="down">Down</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="filter-control">
|
||||||
<label style={{ fontSize: '0.9rem', color: '#555', marginRight: '0.4rem' }}>Sort</label>
|
<label>Sort</label>
|
||||||
<select value={checksSort} onChange={(e) => { setChecksPage(0); setChecksSort(e.target.value); }}>
|
<select value={checksSort} onChange={(e) => { setChecksPage(0); setChecksSort(e.target.value); }}>
|
||||||
<option value="desc">Newest first</option>
|
<option value="desc">Newest first</option>
|
||||||
<option value="asc">Oldest first</option>
|
<option value="asc">Oldest first</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
<div className="controls-pagination">
|
||||||
<button className="btn btn-secondary" disabled={checksPage === 0 || checksLoading} onClick={() => setChecksPage(Math.max(0, checksPage - 1))}>← Prev</button>
|
<button className="btn btn-secondary" disabled={checksPage === 0 || checksLoading} onClick={() => setChecksPage(Math.max(0, checksPage - 1))}>← Prev</button>
|
||||||
<button className="btn btn-secondary" disabled={checksLoading || checks.length < checksLimit} onClick={() => setChecksPage(checksPage + 1)}>Next →</button>
|
<button className="btn btn-secondary" disabled={checksLoading || checks.length < checksLimit} onClick={() => setChecksPage(checksPage + 1)}>Next →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Time</th>
|
<th>Time</th>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Status</th>
|
<th>Status</th>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'right' }}>Response Time</th>
|
<th className="text-right">Response Time</th>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Message</th>
|
<th>Message</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{checksLoading ? (
|
{checksLoading ? (
|
||||||
<tr><td colSpan="4" style={{ padding: '0.75rem' }}>Loading checks...</td></tr>
|
<tr><td colSpan="4">Loading checks...</td></tr>
|
||||||
) : checks.length === 0 ? (
|
) : checks.length === 0 ? (
|
||||||
<tr><td colSpan="4" style={{ padding: '0.75rem' }}>No checks found</td></tr>
|
<tr><td colSpan="4">No checks found</td></tr>
|
||||||
) : checks.map((check) => (
|
) : checks.map((check) => (
|
||||||
<tr key={check.id} style={{ borderBottom: '1px solid #eee' }}>
|
<tr key={check.id}>
|
||||||
<td style={{ padding: '0.75rem' }}>
|
<td>
|
||||||
{formatLocalTime(check.checked_at)}
|
{formatLocalTime(check.checked_at)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '0.75rem' }}>
|
<td>
|
||||||
<span className={`status-badge status-${check.status}`}>
|
<span className={`status-badge status-${check.status}`}>
|
||||||
{check.status}
|
{check.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '0.75rem', textAlign: 'right' }}>
|
<td className="text-right">
|
||||||
{check.response_time ? `${check.response_time}ms` : '-'}
|
{check.response_time ? `${check.response_time}ms` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
<td className="muted-cell">
|
||||||
{check.error_message || 'OK'}
|
{check.error_message || 'OK'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<h2>Incidents Timeline</h2>
|
<h2>Incidents Timeline</h2>
|
||||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
<div className="section-controls">
|
||||||
<div>
|
<div className="filter-control">
|
||||||
<label style={{ fontSize: '0.9rem', color: '#555', marginRight: '0.4rem' }}>Status</label>
|
<label>Status</label>
|
||||||
<select value={incidentsStatus} onChange={(e) => { setIncidentsPage(0); setIncidentsStatus(e.target.value); }}>
|
<select value={incidentsStatus} onChange={(e) => { setIncidentsPage(0); setIncidentsStatus(e.target.value); }}>
|
||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
<option value="open">Open</option>
|
<option value="open">Open</option>
|
||||||
<option value="closed">Closed</option>
|
<option value="closed">Closed</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="filter-control">
|
||||||
<label style={{ fontSize: '0.9rem', color: '#555', marginRight: '0.4rem' }}>Sort</label>
|
<label>Sort</label>
|
||||||
<select value={incidentsSort} onChange={(e) => { setIncidentsPage(0); setIncidentsSort(e.target.value); }}>
|
<select value={incidentsSort} onChange={(e) => { setIncidentsPage(0); setIncidentsSort(e.target.value); }}>
|
||||||
<option value="desc">Newest first</option>
|
<option value="desc">Newest first</option>
|
||||||
<option value="asc">Oldest first</option>
|
<option value="asc">Oldest first</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
<div className="controls-pagination">
|
||||||
<button className="btn btn-secondary" disabled={incidentsPage === 0 || incidentsLoading} onClick={() => setIncidentsPage(Math.max(0, incidentsPage - 1))}>← Prev</button>
|
<button className="btn btn-secondary" disabled={incidentsPage === 0 || incidentsLoading} onClick={() => setIncidentsPage(Math.max(0, incidentsPage - 1))}>← Prev</button>
|
||||||
<button className="btn btn-secondary" disabled={incidentsLoading || incidents.length < incidentsLimit} onClick={() => setIncidentsPage(incidentsPage + 1)}>Next →</button>
|
<button className="btn btn-secondary" disabled={incidentsLoading || incidents.length < incidentsLimit} onClick={() => setIncidentsPage(incidentsPage + 1)}>Next →</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left', width: '25%' }}>Started</th>
|
<th className="w-25">Started</th>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left', width: '25%' }}>Resolved</th>
|
<th className="w-25">Resolved</th>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left', width: '15%' }}>Duration</th>
|
<th className="w-15">Duration</th>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left', width: '35%' }}>Reason</th>
|
<th className="w-35">Reason</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{incidentsLoading ? (
|
{incidentsLoading ? (
|
||||||
<tr><td colSpan="4" style={{ padding: '0.75rem' }}>Loading incidents...</td></tr>
|
<tr><td colSpan="4">Loading incidents...</td></tr>
|
||||||
) : incidents.length === 0 ? (
|
) : incidents.length === 0 ? (
|
||||||
<tr><td colSpan="4" style={{ padding: '0.75rem' }}>No incidents found</td></tr>
|
<tr><td colSpan="4">No incidents found</td></tr>
|
||||||
) : incidents.map((incident) => {
|
) : incidents.map((incident) => {
|
||||||
const start = incident.started_at || incident.created_at;
|
const start = incident.started_at || incident.created_at;
|
||||||
const end = incident.resolved_at;
|
const end = incident.resolved_at;
|
||||||
@@ -1480,22 +1618,21 @@ function ResourceDetail() {
|
|||||||
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
|
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr key={incident.id} style={{ borderBottom: '1px solid #eee' }}>
|
<tr key={incident.id}>
|
||||||
<td style={{ padding: '0.75rem' }}>{start ? formatLocalTime(start) : '-'}</td>
|
<td>{start ? formatLocalTime(start) : '-'}</td>
|
||||||
<td style={{ padding: '0.75rem' }}>{end ? formatLocalTime(end) : 'Open'}</td>
|
<td>{end ? formatLocalTime(end) : 'Open'}</td>
|
||||||
<td style={{ padding: '0.75rem' }}>{durationText}</td>
|
<td>{durationText}</td>
|
||||||
<td style={{ padding: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<td className="incident-reason-cell">
|
||||||
<span style={{ cursor: 'pointer', color: '#0066cc', flex: 1 }} onClick={() => setExpandedIncidentId(isExpanded ? null : incident.id)}>
|
<span className="incident-reason-toggle" onClick={() => setExpandedIncidentId(isExpanded ? null : incident.id)}>
|
||||||
{displayText}
|
{displayText}
|
||||||
{isTruncated && (
|
{isTruncated && (
|
||||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.85rem', color: '#666' }}>
|
<span className="expand-indicator">
|
||||||
{isExpanded ? '▼' : '▶'}
|
{isExpanded ? '▼' : '▶'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary btn-compact"
|
||||||
style={{ marginLeft: '0.5rem', padding: '0.3rem 0.6rem', fontSize: '0.85rem' }}
|
|
||||||
onClick={() => handleEditIncident(incident)}
|
onClick={() => handleEditIncident(incident)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
@@ -1503,8 +1640,8 @@ function ResourceDetail() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr style={{ backgroundColor: '#f9f9f9', borderBottom: '1px solid #eee' }}>
|
<tr className="expanded-row">
|
||||||
<td colSpan="4" style={{ padding: '0.75rem', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.9rem', color: '#333' }}>
|
<td colSpan="4" className="expanded-cell">
|
||||||
{description}
|
{description}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1514,37 +1651,38 @@ function ResourceDetail() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div className="section-title-row">
|
||||||
<h2>Maintenance Windows</h2>
|
<h2>Maintenance Windows</h2>
|
||||||
<button className="btn btn-primary" onClick={() => setShowMaintenanceModal(true)}>
|
<button className="btn btn-primary" onClick={() => setShowMaintenanceModal(true)}>
|
||||||
+ Schedule Maintenance
|
+ Schedule Maintenance
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{maintenanceWindows.length === 0 ? (
|
{maintenanceWindows.length === 0 ? (
|
||||||
<p style={{ color: '#666', textAlign: 'center', padding: '2rem' }}>No maintenance windows scheduled</p>
|
<p className="empty-copy">No maintenance windows scheduled</p>
|
||||||
) : (
|
) : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Start Time</th>
|
<th>Start Time</th>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>End Time</th>
|
<th>End Time</th>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Reason</th>
|
<th>Reason</th>
|
||||||
<th style={{ padding: '0.75rem', textAlign: 'right' }}>Actions</th>
|
<th className="text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{maintenanceWindows.map(window => (
|
{maintenanceWindows.map(window => (
|
||||||
<tr key={window.id} style={{ borderBottom: '1px solid #eee' }}>
|
<tr key={window.id}>
|
||||||
<td style={{ padding: '0.75rem' }}>{formatLocalTime(window.start_time)}</td>
|
<td>{formatLocalTime(window.start_time)}</td>
|
||||||
<td style={{ padding: '0.75rem' }}>{formatLocalTime(window.end_time)}</td>
|
<td>{formatLocalTime(window.end_time)}</td>
|
||||||
<td style={{ padding: '0.75rem', color: '#666' }}>{window.reason || '-'}</td>
|
<td className="muted-cell">{window.reason || '-'}</td>
|
||||||
<td style={{ padding: '0.75rem', textAlign: 'right' }}>
|
<td className="text-right">
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger"
|
className="btn btn-danger btn-compact"
|
||||||
style={{ padding: '0.3rem 0.6rem', fontSize: '0.85rem' }}
|
|
||||||
onClick={() => handleDeleteMaintenanceWindow(window.id)}
|
onClick={() => handleDeleteMaintenanceWindow(window.id)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -1554,12 +1692,13 @@ function ResourceDetail() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showMaintenanceModal && (
|
{showMaintenanceModal && (
|
||||||
<div className="modal-overlay" onClick={() => setShowMaintenanceModal(false)}>
|
<div className="modal-overlay" onClick={() => setShowMaintenanceModal(false)}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h2>Schedule Maintenance Window</h2>
|
<h2>Schedule Maintenance Window</h2>
|
||||||
<form onSubmit={handleCreateMaintenanceWindow}>
|
<form onSubmit={handleCreateMaintenanceWindow}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -1673,32 +1812,25 @@ function Navbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
<div className="navbar-inner">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
<div className="navbar-left">
|
||||||
<h1>🔍 SkyWatch</h1>
|
<h1>🔍 SkyWatch</h1>
|
||||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
<div className="navbar-links">
|
||||||
<Link to="/" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Dashboard</Link>
|
<Link to="/" className="nav-link">Dashboard</Link>
|
||||||
<Link to="/history" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>History</Link>
|
<Link to="/history" className="nav-link">History</Link>
|
||||||
<Link to="/sla" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>SLA</Link>
|
<Link to="/sla" className="nav-link">SLA</Link>
|
||||||
<Link to="/observability" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Observability</Link>
|
<Link to="/observability" className="nav-link">Observability</Link>
|
||||||
<Link to="/notifications" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Notifications</Link>
|
<Link to="/notifications" className="nav-link">Notifications</Link>
|
||||||
<Link to="/settings" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<Link to="/settings" className="nav-link settings-link">
|
||||||
Settings
|
Settings
|
||||||
{!notificationsConfigured && (
|
{!notificationsConfigured && (
|
||||||
<span style={{
|
<span className="setup-badge">SETUP</span>
|
||||||
background: '#ffc107',
|
|
||||||
color: '#000',
|
|
||||||
padding: '0.15rem 0.5rem',
|
|
||||||
borderRadius: '12px',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>SETUP</span>
|
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{notificationsConfigured && (
|
{notificationsConfigured && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', opacity: 0.9 }}>
|
<div className="notifications-pill">
|
||||||
<span>🔔</span>
|
<span>🔔</span>
|
||||||
<span>Notifications Active</span>
|
<span>Notifications Active</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
.cc-page {
|
||||||
|
padding-top: 1.4rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-alert {
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #854d0e;
|
||||||
|
background: #2b1907;
|
||||||
|
color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.65rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
color: #f8fafc !important;
|
||||||
|
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-page h2 {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pill-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #111827;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pill-toggle input {
|
||||||
|
accent-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pill-toggle:focus-within,
|
||||||
|
.cc-input-sm:focus,
|
||||||
|
.cc-select-sm:focus {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-surface {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kpi {
|
||||||
|
background: #0b1325;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kpi-label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-kpi-value {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #0b1325;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table thead tr {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-table tbody tr:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-input-sm {
|
||||||
|
width: 56px;
|
||||||
|
padding: 0.45rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-select-sm {
|
||||||
|
padding: 0.45rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 2.2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-resource-card {
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-resource-title {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
color: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-resource-meta {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-resource-meta strong {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-chart-box {
|
||||||
|
height: 400px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: #111827;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-trend-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
background: #0b1325;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-row.is-unread {
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-chip {
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-actions .btn:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cc-controls {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-controls .btn,
|
||||||
|
.cc-controls .tag-filter-input,
|
||||||
|
.cc-controls .cc-pill-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cc-pagination {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-59
@@ -12,6 +12,7 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import './CommandCenterPages.css';
|
||||||
import { formatChartTime } from './utils/timeUtils';
|
import { formatChartTime } from './utils/timeUtils';
|
||||||
|
|
||||||
const asNumberOrZero = (value) => {
|
const asNumberOrZero = (value) => {
|
||||||
@@ -107,23 +108,16 @@ function History() {
|
|||||||
if (error) return <div className="container">Error loading history: {error}</div>;
|
if (error) return <div className="container">Error loading history: {error}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container cc-page">
|
||||||
{limitedByRetention && (
|
{limitedByRetention && (
|
||||||
<div style={{
|
<div className="cc-alert">
|
||||||
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.
|
⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
<div className="cc-page-header">
|
||||||
<h2>Monitoring History</h2>
|
<h2 className="cc-page-title" style={{ color: '#f8fafc', fontWeight: 800 }}>Monitoring History</h2>
|
||||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div className="cc-controls">
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div className="cc-controls">
|
||||||
{[7, 14, 30].map((d) => (
|
{[7, 14, 30].map((d) => (
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
@@ -136,21 +130,19 @@ function History() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
|
<label className="cc-pill-toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showAveraged}
|
checked={showAveraged}
|
||||||
onChange={(e) => setShowAveraged(e.target.checked)}
|
onChange={(e) => setShowAveraged(e.target.checked)}
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
/>
|
/>
|
||||||
<span>Show Averages</span>
|
<span>Show Averages</span>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
|
<label className="cc-pill-toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showTrends}
|
checked={showTrends}
|
||||||
onChange={(e) => setShowTrends(e.target.checked)}
|
onChange={(e) => setShowTrends(e.target.checked)}
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
/>
|
/>
|
||||||
<span>Show Week-over-Week Trends</span>
|
<span>Show Week-over-Week Trends</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -169,41 +161,43 @@ function History() {
|
|||||||
const chartData = prepareChartData(resource.checks || []);
|
const chartData = prepareChartData(resource.checks || []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={resource.id} className="detail-section" style={{ marginBottom: '3rem', background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
<div key={resource.id} className="detail-section cc-surface history-resource-card">
|
||||||
<h3 style={{ marginBottom: '0.5rem', color: '#667eea', fontSize: '1.5rem' }}>{resource.name}</h3>
|
<h3 className="history-resource-title">{resource.name}</h3>
|
||||||
<p style={{ color: '#666', marginBottom: '1.5rem', fontSize: '0.95rem' }}>
|
<p className="history-resource-meta">
|
||||||
<span style={{ marginRight: '1.5rem' }}>Type: <strong style={{ color: '#333' }}>{resource.type.toUpperCase()}</strong></span>
|
<span>Type: <strong>{resource.type.toUpperCase()}</strong></span>
|
||||||
<span style={{ marginRight: '1.5rem' }}>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336' }}>{resource.uptime}%</strong></span>
|
<span>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</strong></span>
|
||||||
<span>Avg Response: <strong style={{ color: '#333' }}>{resource.avgResponseTime}ms</strong></span>
|
<span>Avg Response: <strong>{resource.avgResponseTime}ms</strong></span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{chartData.length > 0 ? (
|
{chartData.length > 0 ? (
|
||||||
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
|
<div className="chart-container history-chart-box">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={80}
|
height={80}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
|
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="right"
|
yAxisId="right"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
domain={[0, 1]}
|
domain={[0, 1]}
|
||||||
ticks={[0, 1]}
|
ticks={[0, 1]}
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#666' } }}
|
label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#94a3b8' } }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
|
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
|
||||||
|
labelStyle={{ color: '#cbd5e1' }}
|
||||||
|
itemStyle={{ color: '#e2e8f0' }}
|
||||||
formatter={(value, name) => {
|
formatter={(value, name) => {
|
||||||
if (name === 'Response Time') return `${value}ms`;
|
if (name === 'Response Time') return `${value}ms`;
|
||||||
if (name === 'Status') return value === 1 ? '✓ UP' : '✗ DOWN';
|
if (name === 'Status') return value === 1 ? '✓ UP' : '✗ DOWN';
|
||||||
@@ -249,18 +243,18 @@ function History() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: '#999', padding: '2rem', textAlign: 'center', background: '#fafafa', borderRadius: '6px' }}>No check data available</div>
|
<div className="cc-empty">No check data available</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTrends && resource.trendsData && (
|
{showTrends && resource.trendsData && (
|
||||||
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
|
<div className="chart-container history-chart-box">
|
||||||
<h4 style={{ marginBottom: '1rem', color: '#667eea' }}>Week-over-Week Comparison</h4>
|
<h4 className="history-resource-title">Week-over-Week Comparison</h4>
|
||||||
{(() => {
|
{(() => {
|
||||||
const comparison = resource.trendsData?.comparison || {};
|
const comparison = resource.trendsData?.comparison || {};
|
||||||
const responseTimeChange = asNumberOrZero(comparison.response_time_change);
|
const responseTimeChange = asNumberOrZero(comparison.response_time_change);
|
||||||
const uptimeChange = asNumberOrZero(comparison.uptime_change);
|
const uptimeChange = asNumberOrZero(comparison.uptime_change);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.9rem' }}>
|
<div className="history-trend-row">
|
||||||
<div>
|
<div>
|
||||||
<strong>Response Time Change:</strong>{' '}
|
<strong>Response Time Change:</strong>{' '}
|
||||||
<span style={{ color: responseTimeChange >= 0 ? '#f44336' : '#4caf50' }}>
|
<span style={{ color: responseTimeChange >= 0 ? '#f44336' : '#4caf50' }}>
|
||||||
@@ -280,20 +274,22 @@ function History() {
|
|||||||
})()}
|
})()}
|
||||||
<ResponsiveContainer width="100%" height="90%">
|
<ResponsiveContainer width="100%" height="90%">
|
||||||
<ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
<ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="day"
|
dataKey="day"
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
angle={-45}
|
angle={-45}
|
||||||
textAnchor="end"
|
textAnchor="end"
|
||||||
height={80}
|
height={80}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
|
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
|
||||||
tick={{ fontSize: 12, fill: '#666' }}
|
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
|
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
|
||||||
|
labelStyle={{ color: '#cbd5e1' }}
|
||||||
|
itemStyle={{ color: '#e2e8f0' }}
|
||||||
formatter={(value, name) => {
|
formatter={(value, name) => {
|
||||||
if (name === 'Current Period') return `${value}ms`;
|
if (name === 'Current Period') return `${value}ms`;
|
||||||
if (name === 'Previous Period') return `${value}ms`;
|
if (name === 'Previous Period') return `${value}ms`;
|
||||||
@@ -327,22 +323,22 @@ function History() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
|
<div className="cc-kpi-grid">
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.checks?.length || 0}</p>
|
<p className="cc-kpi-label">Total Checks</p>
|
||||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Total Checks</p>
|
<p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{resource.checks?.length || 0}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336', fontSize: '2rem', fontWeight: 'bold' }}>{resource.uptime}%</p>
|
<p className="cc-kpi-label">Uptime</p>
|
||||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Uptime</p>
|
<p className="cc-kpi-value" style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.avgResponseTime}ms</p>
|
<p className="cc-kpi-label">Avg Response</p>
|
||||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Avg Response</p>
|
<p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{resource.avgResponseTime}ms</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2rem', fontWeight: 'bold' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p>
|
<p className="cc-kpi-label">Successful Checks</p>
|
||||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Successful Checks</p>
|
<p className="cc-kpi-value" style={{ color: '#34d399' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +346,7 @@ function History() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{totalItems > pageSize && (
|
{totalItems > pageSize && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
<div className="cc-pagination">
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
@@ -358,8 +354,8 @@ function History() {
|
|||||||
>
|
>
|
||||||
← Previous
|
← Previous
|
||||||
</button>
|
</button>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div className="cc-controls">
|
||||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span>
|
<span>Page</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={currentPage}
|
value={currentPage}
|
||||||
@@ -367,14 +363,14 @@ function History() {
|
|||||||
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}}
|
}}
|
||||||
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }}
|
className="cc-input-sm"
|
||||||
/>
|
/>
|
||||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span>
|
<span>of {Math.ceil(totalItems / pageSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }}
|
className="cc-select-sm"
|
||||||
>
|
>
|
||||||
<option value={5}>5 per page</option>
|
<option value={5}>5 per page</option>
|
||||||
<option value={10}>10 per page</option>
|
<option value={10}>10 per page</option>
|
||||||
|
|||||||
+21
-28
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { formatLocalTime } from './utils/timeUtils';
|
import { formatLocalTime } from './utils/timeUtils';
|
||||||
|
import './CommandCenterPages.css';
|
||||||
|
|
||||||
function Notifications() {
|
function Notifications() {
|
||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
@@ -106,11 +107,13 @@ function Notifications() {
|
|||||||
const totalPages = Math.ceil(total / itemsPerPage);
|
const totalPages = Math.ceil(total / itemsPerPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem' }}>
|
<div className="container cc-page">
|
||||||
<h2>📬 Notifications</h2>
|
<div className="cc-page-header">
|
||||||
|
<h2 className="cc-page-title">Notifications</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '2rem', display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
<div className="cc-controls" style={{ marginBottom: '1rem' }}>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div className="cc-controls">
|
||||||
<button
|
<button
|
||||||
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
|
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
onClick={() => { setFilter('all'); setCurrentPage(1); }}
|
onClick={() => { setFilter('all'); setCurrentPage(1); }}
|
||||||
@@ -143,50 +146,40 @@ function Notifications() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p style={{ textAlign: 'center', color: '#999' }}>Loading...</p>
|
<p className="cc-empty">Loading...</p>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#999' }}>
|
<div className="cc-empty">
|
||||||
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
|
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}>
|
<div className="notifications-list">
|
||||||
{notifications.map((notification) => (
|
{notifications.map((notification) => (
|
||||||
<div
|
<div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
style={{
|
className={`notification-row ${notification.read ? 'is-read' : 'is-unread'}`}
|
||||||
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 }}>
|
<div className="notification-content">
|
||||||
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '1rem' }}>{notification.title}</h4>
|
<h4 className="notification-title">{notification.title}</h4>
|
||||||
<p style={{ margin: '0 0 0.5rem 0', color: '#666', fontSize: '0.95rem' }}>
|
<p className="notification-message">
|
||||||
{notification.message}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', color: '#999', flexWrap: 'wrap' }}>
|
<div className="notification-meta">
|
||||||
{notification.resource_name && (
|
{notification.resource_name && (
|
||||||
<span style={{ backgroundColor: '#f0f0f0', padding: '0.25rem 0.5rem', borderRadius: '3px' }}>
|
<span className="notification-chip">
|
||||||
📌 {notification.resource_name}
|
{notification.resource_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>⏰ {formatLocalTime(notification.created_at)}</span>
|
<span>{formatLocalTime(notification.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
<div className="notification-actions">
|
||||||
{!notification.read && (
|
{!notification.read && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
onClick={() => handleMarkAsRead(notification.id)}
|
onClick={() => handleMarkAsRead(notification.id)}
|
||||||
title="Mark as read"
|
title="Mark as read"
|
||||||
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem' }}
|
|
||||||
>
|
>
|
||||||
✓ Read
|
✓ Read
|
||||||
</button>
|
</button>
|
||||||
@@ -195,7 +188,7 @@ function Notifications() {
|
|||||||
className="btn btn-sm"
|
className="btn btn-sm"
|
||||||
onClick={() => handleDelete(notification.id)}
|
onClick={() => handleDelete(notification.id)}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem', background: '#dc3545' }}
|
style={{ background: '#b91c1c' }}
|
||||||
>
|
>
|
||||||
✕ Delete
|
✕ Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -206,7 +199,7 @@ function Notifications() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
<div className="cc-pagination">
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||||
<button
|
<button
|
||||||
key={page}
|
key={page}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
.observability-page {
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observability-page h2 {
|
||||||
|
color: #f8fafc;
|
||||||
|
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-tabs {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-tab {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
border-top-right-radius: 10px;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-tab:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-tab.is-active {
|
||||||
|
border-bottom-color: #60a5fa;
|
||||||
|
color: #f8fafc;
|
||||||
|
background: #0f172a;
|
||||||
|
border-color: #334155;
|
||||||
|
border-bottom-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-summary-card {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, #0f172a 0%, #0b1325 100%);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-card-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-card-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-card-value.is-danger {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-card-value.is-ok {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-spacing-bottom {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border-bottom: 2px solid #334155;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid #1f2937;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-table tbody tr:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-right {
|
||||||
|
text-align: right !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-rate-pill {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-rate-pill.is-good {
|
||||||
|
background: #052e16;
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-rate-pill.is-warn {
|
||||||
|
background: #3f2a0f;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-empty-copy {
|
||||||
|
color: #cbd5e1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-zebra {
|
||||||
|
background: #0b1325;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-error-code {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-muted-sm {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-small {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-audit-summary {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-audit-summary-line {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-filters-grid {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-filter-input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #111827;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-filter-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-toggle-code:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-click-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-toggle-code {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-expanded-row {
|
||||||
|
background: #111b2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-expanded-cell {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.obs-pre {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: #0b1325;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.obs-tab {
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-100
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { formatLocalTime } from './utils/timeUtils';
|
import { formatLocalTime } from './utils/timeUtils';
|
||||||
|
import './Observability.css';
|
||||||
|
|
||||||
function Observability() {
|
function Observability() {
|
||||||
const [activeTab, setActiveTab] = useState('metrics');
|
const [activeTab, setActiveTab] = useState('metrics');
|
||||||
@@ -45,49 +46,25 @@ function Observability() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container" style={{ paddingTop: '2rem' }}>
|
<div className="container observability-page">
|
||||||
<h2>System Observability</h2>
|
<h2>System Observability</h2>
|
||||||
|
|
||||||
<div style={{ marginBottom: '2rem', borderBottom: '1px solid #ddd', display: 'flex', gap: '1rem' }}>
|
<div className="obs-tabs">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('metrics')}
|
onClick={() => setActiveTab('metrics')}
|
||||||
style={{
|
className={`obs-tab ${activeTab === 'metrics' ? 'is-active' : ''}`}
|
||||||
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
|
API Metrics
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('errors')}
|
onClick={() => setActiveTab('errors')}
|
||||||
style={{
|
className={`obs-tab ${activeTab === 'errors' ? 'is-active' : ''}`}
|
||||||
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
|
Errors
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('audit')}
|
onClick={() => setActiveTab('audit')}
|
||||||
style={{
|
className={`obs-tab ${activeTab === 'audit' ? 'is-active' : ''}`}
|
||||||
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
|
Audit Logs
|
||||||
</button>
|
</button>
|
||||||
@@ -96,64 +73,59 @@ function Observability() {
|
|||||||
{/* Metrics Tab */}
|
{/* Metrics Tab */}
|
||||||
{activeTab === 'metrics' && metrics && (
|
{activeTab === 'metrics' && metrics && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
|
<div className="obs-summary-grid">
|
||||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
|
<div className="obs-summary-card">
|
||||||
<div style={{ fontSize: '0.9rem', color: '#666' }}>Server Uptime</div>
|
<div className="obs-card-label">Server Uptime</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
|
<div className="obs-card-value">
|
||||||
{metrics.uptimeFormatted || `${Math.floor(metrics.uptime / 3600)}h`}
|
{metrics.uptimeFormatted || `${Math.floor(metrics.uptime / 3600)}h`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
|
<div className="obs-summary-card">
|
||||||
<div style={{ fontSize: '0.9rem', color: '#666' }}>Total API Requests</div>
|
<div className="obs-card-label">Total API Requests</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
|
<div className="obs-card-value">
|
||||||
{metrics.apiStats.reduce((sum, s) => sum + s.totalRequests, 0)}
|
{metrics.apiStats.reduce((sum, s) => sum + s.totalRequests, 0)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
|
<div className="obs-summary-card">
|
||||||
<div style={{ fontSize: '0.9rem', color: '#666' }}>Errors Logged</div>
|
<div className="obs-card-label">Errors Logged</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem', color: metrics.errorCount > 0 ? '#dc3545' : '#28a745' }}>
|
<div className={`obs-card-value ${metrics.errorCount > 0 ? 'is-danger' : 'is-ok'}`}>
|
||||||
{metrics.errorCount}
|
{metrics.errorCount}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
|
<div className="obs-summary-card">
|
||||||
<div style={{ fontSize: '0.9rem', color: '#666' }}>Unique Errors</div>
|
<div className="obs-card-label">Unique Errors</div>
|
||||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
|
<div className="obs-card-value">
|
||||||
{metrics.uniqueErrors}
|
{metrics.uniqueErrors}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3>API Endpoint Performance</h3>
|
<h3>API Endpoint Performance</h3>
|
||||||
<div style={{ overflowX: 'auto', marginBottom: '2rem' }}>
|
<div className="obs-table-wrap obs-spacing-bottom">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table className="obs-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr>
|
||||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Endpoint</th>
|
<th>Endpoint</th>
|
||||||
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Requests</th>
|
<th className="obs-right">Requests</th>
|
||||||
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Success Rate</th>
|
<th className="obs-right">Success Rate</th>
|
||||||
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Avg Response</th>
|
<th className="obs-right">Avg Response</th>
|
||||||
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Min/Max</th>
|
<th className="obs-right">Min/Max</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{metrics.apiStats.map((stat, i) => (
|
{metrics.apiStats.map((stat, i) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #ddd' }}>
|
<tr key={i}>
|
||||||
<td style={{ padding: '0.75rem' }}>
|
<td>
|
||||||
<code>{stat.method} {stat.endpoint}</code>
|
<code>{stat.method} {stat.endpoint}</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.totalRequests}</td>
|
<td className="obs-right">{stat.totalRequests}</td>
|
||||||
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
|
<td className="obs-right">
|
||||||
<span style={{
|
<span className={`obs-rate-pill ${stat.successRate >= 95 ? 'is-good' : 'is-warn'}`}>
|
||||||
padding: '0.25rem 0.5rem',
|
|
||||||
borderRadius: '3px',
|
|
||||||
backgroundColor: stat.successRate >= 95 ? '#d4edda' : '#fff3cd',
|
|
||||||
color: stat.successRate >= 95 ? '#155724' : '#856404'
|
|
||||||
}}>
|
|
||||||
{stat.successRate}%
|
{stat.successRate}%
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.avgResponseTime}ms</td>
|
<td className="obs-right">{stat.avgResponseTime}ms</td>
|
||||||
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
|
<td className="obs-right">
|
||||||
{stat.minResponseTime}ms / {stat.maxResponseTime}ms
|
{stat.minResponseTime}ms / {stat.maxResponseTime}ms
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -168,27 +140,27 @@ function Observability() {
|
|||||||
{activeTab === 'errors' && (
|
{activeTab === 'errors' && (
|
||||||
<div>
|
<div>
|
||||||
{errors.length === 0 ? (
|
{errors.length === 0 ? (
|
||||||
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>No errors logged</p>
|
<p className="obs-empty-copy">No errors logged</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="obs-table-wrap">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table className="obs-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr>
|
||||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Error Message</th>
|
<th>Error Message</th>
|
||||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Context</th>
|
<th>Context</th>
|
||||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Occurred At</th>
|
<th>Occurred At</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{errors.map((error, i) => (
|
{errors.map((error, i) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white' }}>
|
<tr key={i} className={i % 2 ? 'obs-zebra' : ''}>
|
||||||
<td style={{ padding: '0.75rem' }}>
|
<td>
|
||||||
<code style={{ fontSize: '0.85rem', color: '#d9534f' }}>{error.error_message}</code>
|
<code className="obs-error-code">{error.error_message}</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem', color: '#666' }}>
|
<td className="obs-muted-sm">
|
||||||
{error.context ? JSON.stringify(JSON.parse(error.context)).substring(0, 50) : '-'}
|
{error.context ? JSON.stringify(JSON.parse(error.context)).substring(0, 50) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
|
<td className="obs-small">
|
||||||
{formatLocalTime(error.occurred_at)}
|
{formatLocalTime(error.occurred_at)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -204,84 +176,84 @@ function Observability() {
|
|||||||
{activeTab === 'audit' && (
|
{activeTab === 'audit' && (
|
||||||
<div>
|
<div>
|
||||||
{auditSummary && (
|
{auditSummary && (
|
||||||
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: '#f9f9f9', borderRadius: '4px' }}>
|
<div className="obs-audit-summary">
|
||||||
<p><strong>Summary (Last 7 Days):</strong> {auditSummary.totalEvents} events</p>
|
<p><strong>Summary (Last 7 Days):</strong> {auditSummary.totalEvents} events</p>
|
||||||
{auditSummary.actionBreakdown.map(action => (
|
{auditSummary.actionBreakdown.map(action => (
|
||||||
<p key={action.action} style={{ margin: '0.25rem 0', fontSize: '0.9rem', color: '#666' }}>
|
<p key={action.action} className="obs-audit-summary-line">
|
||||||
{action.action}: {action.count}
|
{action.action}: {action.count}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginBottom: '1rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.5rem' }}>
|
<div className="obs-filters-grid">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Entity Type"
|
placeholder="Entity Type"
|
||||||
value={filters.entityType}
|
value={filters.entityType}
|
||||||
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
|
className="obs-filter-input"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Entity ID"
|
placeholder="Entity ID"
|
||||||
value={filters.entityId}
|
value={filters.entityId}
|
||||||
onChange={(e) => setFilters({ ...filters, entityId: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, entityId: e.target.value })}
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
|
className="obs-filter-input"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="User ID"
|
placeholder="User ID"
|
||||||
value={filters.userId}
|
value={filters.userId}
|
||||||
onChange={(e) => setFilters({ ...filters, userId: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, userId: e.target.value })}
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
|
className="obs-filter-input"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Action"
|
placeholder="Action"
|
||||||
value={filters.action}
|
value={filters.action}
|
||||||
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
|
className="obs-filter-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{auditLogs.length === 0 ? (
|
{auditLogs.length === 0 ? (
|
||||||
<p style={{ color: '#999', textAlign: 'center', padding: '2rem' }}>No audit logs found</p>
|
<p className="obs-empty-copy">No audit logs found</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="obs-table-wrap">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table className="obs-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
<tr>
|
||||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Action</th>
|
<th>Action</th>
|
||||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Entity</th>
|
<th>Entity</th>
|
||||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>User</th>
|
<th>User</th>
|
||||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Changes</th>
|
<th>Changes</th>
|
||||||
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Time</th>
|
<th>Time</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{auditLogs.map((log, i) => (
|
{auditLogs.map((log, i) => (
|
||||||
<React.Fragment key={i}>
|
<React.Fragment key={i}>
|
||||||
<tr style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white', cursor: 'pointer' }} onClick={() => setExpandedAuditRow(expandedAuditRow === i ? null : i)}>
|
<tr className={`obs-click-row ${i % 2 ? 'obs-zebra' : ''}`} onClick={() => setExpandedAuditRow(expandedAuditRow === i ? null : i)}>
|
||||||
<td style={{ padding: '0.75rem' }}><strong>{log.action}</strong></td>
|
<td><strong>{log.action}</strong></td>
|
||||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem', color: '#666' }}>
|
<td className="obs-muted-sm">
|
||||||
{log.entity_type} #{log.entity_id}
|
{log.entity_type} #{log.entity_id}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>{log.user_id}</td>
|
<td className="obs-small">{log.user_id}</td>
|
||||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
|
<td className="obs-small">
|
||||||
<code style={{ cursor: 'pointer', color: '#007bff' }}>
|
<code className="obs-toggle-code">
|
||||||
{expandedAuditRow === i ? '▼ Hide' : '▶ Show'} Changes
|
{expandedAuditRow === i ? '▼ Hide' : '▶ Show'} Changes
|
||||||
</code>
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
|
<td className="obs-small">
|
||||||
{formatLocalTime(log.created_at)}
|
{formatLocalTime(log.created_at)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{expandedAuditRow === i && (
|
{expandedAuditRow === i && (
|
||||||
<tr style={{ backgroundColor: '#f0f8ff', borderBottom: '1px solid #ddd' }}>
|
<tr className="obs-expanded-row">
|
||||||
<td colSpan="5" style={{ padding: '1rem' }}>
|
<td colSpan="5" className="obs-expanded-cell">
|
||||||
<strong>Full Changes:</strong>
|
<strong>Full Changes:</strong>
|
||||||
<pre style={{ marginTop: '0.5rem', backgroundColor: 'white', padding: '0.75rem', borderRadius: '4px', overflowX: 'auto', border: '1px solid #ddd' }}>
|
<pre className="obs-pre">
|
||||||
{JSON.stringify(log.changes, null, 2)}
|
{JSON.stringify(log.changes, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
+54
-60
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import './CommandCenterPages.css';
|
||||||
|
|
||||||
function SLA() {
|
function SLA() {
|
||||||
const [slaData, setSlaData] = useState([]);
|
const [slaData, setSlaData] = useState([]);
|
||||||
@@ -66,22 +67,15 @@ function SLA() {
|
|||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container cc-page">
|
||||||
{limitedByRetention && (
|
{limitedByRetention && (
|
||||||
<div style={{
|
<div className="cc-alert">
|
||||||
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.
|
⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
<div className="cc-page-header">
|
||||||
<h2>SLA Dashboard</h2>
|
<h2 className="cc-page-title">SLA Dashboard</h2>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
<div className="cc-controls">
|
||||||
{[7, 14, 30, 90].map((d) => (
|
{[7, 14, 30, 90].map((d) => (
|
||||||
<button
|
<button
|
||||||
key={d}
|
key={d}
|
||||||
@@ -95,33 +89,33 @@ function SLA() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{limitedByRetention && (
|
{limitedByRetention && (
|
||||||
<div style={{ background: '#FFF8E1', color: '#8D6E63', padding: '0.5rem 0.75rem', borderRadius: '6px', border: '1px solid #FFECB3' }}>
|
<div className="cc-alert" style={{ marginBottom: 0 }}>
|
||||||
Limited by data retention ({effectiveDays} days). Increase Retention in Settings to view longer periods.
|
Limited by data retention ({effectiveDays} days). Increase Retention in Settings to view longer periods.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{overallStats && (
|
{overallStats && (
|
||||||
<div className="stats-grid" style={{ marginBottom: '2rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
|
<div className="cc-kpi-grid" style={{ marginBottom: '1rem' }}>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.totalResources}</p>
|
<p className="cc-kpi-label">Total Resources</p>
|
||||||
<p className="stat-label">Total Resources</p>
|
<p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{overallStats.totalResources}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2.5rem' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
|
<p className="cc-kpi-label">Meeting SLA Target</p>
|
||||||
<p className="stat-label">Meeting SLA Target</p>
|
<p className="cc-kpi-value" style={{ color: '#22c55e' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.avgUptime}%</p>
|
<p className="cc-kpi-label">Average Uptime</p>
|
||||||
<p className="stat-label">Average Uptime</p>
|
<p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{overallStats.avgUptime}%</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#ff9800', fontSize: '2.5rem' }}>{overallStats.totalIncidents}</p>
|
<p className="cc-kpi-label">Total Incidents</p>
|
||||||
<p className="stat-label">Total Incidents</p>
|
<p className="cc-kpi-value" style={{ color: '#f59e0b' }}>{overallStats.totalIncidents}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
<div className="cc-kpi">
|
||||||
<p className="stat-value" style={{ color: '#f44336', fontSize: '2.5rem' }}>{Math.round(overallStats.totalDowntime)}m</p>
|
<p className="cc-kpi-label">Total Downtime</p>
|
||||||
<p className="stat-label">Total Downtime</p>
|
<p className="cc-kpi-value" style={{ color: '#ef4444' }}>{Math.round(overallStats.totalDowntime)}m</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -132,46 +126,46 @@ function SLA() {
|
|||||||
<p>Add resources to start tracking SLA metrics</p>
|
<p>Add resources to start tracking SLA metrics</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div className="cc-table-wrap">
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
<table className="cc-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ background: '#667eea', color: 'white' }}>
|
<tr>
|
||||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Resource</th>
|
<th>Resource</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Target</th>
|
<th style={{ textAlign: 'center' }}>Target</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Actual</th>
|
<th style={{ textAlign: 'center' }}>Actual</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Status</th>
|
<th style={{ textAlign: 'center' }}>Status</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Checks</th>
|
<th style={{ textAlign: 'center' }}>Checks</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Incidents</th>
|
<th style={{ textAlign: 'center' }}>Incidents</th>
|
||||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Downtime</th>
|
<th style={{ textAlign: 'center' }}>Downtime</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{slaData.map((item, idx) => (
|
{slaData.map((item, idx) => (
|
||||||
<tr key={item.resource_id} style={{ borderBottom: '1px solid #eee', background: idx % 2 === 0 ? '#fafafa' : '#fff' }}>
|
<tr key={item.resource_id} style={{ background: idx % 2 === 0 ? '#0b1325' : '#0f172a' }}>
|
||||||
<td style={{ padding: '1rem', fontWeight: 'bold' }}>{item.resource_name}</td>
|
<td style={{ fontWeight: 'bold' }}>{item.resource_name}</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center' }}>{item.sla_target}%</td>
|
<td style={{ textAlign: 'center' }}>{item.sla_target}%</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#4caf50' : '#f44336' }}>
|
<td style={{ textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#22c55e' : '#ef4444' }}>
|
||||||
{item.actual_uptime}%
|
{item.actual_uptime}%
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '0.25rem 0.75rem',
|
padding: '0.25rem 0.75rem',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
background: item.meets_target ? '#e8f5e9' : '#ffebee',
|
background: item.meets_target ? '#052e16' : '#3f1d1d',
|
||||||
color: item.meets_target ? '#2e7d32' : '#c62828',
|
color: item.meets_target ? '#4ade80' : '#fda4af',
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}}>
|
}}>
|
||||||
{item.meets_target ? '✓ PASS' : '✗ FAIL'}
|
{item.meets_target ? '✓ PASS' : '✗ FAIL'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
{item.successful_checks}/{item.total_checks}
|
{item.successful_checks}/{item.total_checks}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center', color: item.incidents > 0 ? '#ff9800' : '#666' }}>
|
<td style={{ textAlign: 'center', color: item.incidents > 0 ? '#f59e0b' : '#94a3b8' }}>
|
||||||
{item.incidents}
|
{item.incidents}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
{item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'}
|
{item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -180,7 +174,7 @@ function SLA() {
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
{totalItems > pageSize && (
|
{totalItems > pageSize && (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
<div className="cc-pagination" style={{ marginTop: '1rem' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
@@ -188,8 +182,8 @@ function SLA() {
|
|||||||
>
|
>
|
||||||
← Previous
|
← Previous
|
||||||
</button>
|
</button>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<div className="cc-controls">
|
||||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span>
|
<span>Page</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={currentPage}
|
value={currentPage}
|
||||||
@@ -197,14 +191,14 @@ function SLA() {
|
|||||||
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
}}
|
}}
|
||||||
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }}
|
className="cc-input-sm"
|
||||||
/>
|
/>
|
||||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span>
|
<span>of {Math.ceil(totalItems / pageSize)}</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
||||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }}
|
className="cc-select-sm"
|
||||||
>
|
>
|
||||||
<option value={5}>5 per page</option>
|
<option value={5}>5 per page</option>
|
||||||
<option value={10}>10 per page</option>
|
<option value={10}>10 per page</option>
|
||||||
@@ -223,13 +217,13 @@ function SLA() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
<div className="cc-surface" style={{ marginTop: '1rem' }}>
|
||||||
<h3 style={{ marginTop: 0, color: '#667eea' }}>SLA Report Summary</h3>
|
<h3 className="history-resource-title">SLA Report Summary</h3>
|
||||||
<p style={{ color: '#666', lineHeight: '1.6' }}>
|
<p style={{ color: '#cbd5e1', lineHeight: '1.6' }}>
|
||||||
This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{effectiveDays} days</strong>.
|
This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{effectiveDays} days</strong>.
|
||||||
Resources meeting their target uptime are marked as <strong style={{ color: '#4caf50' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#f44336' }}>FAIL</strong>.
|
Resources meeting their target uptime are marked as <strong style={{ color: '#22c55e' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#ef4444' }}>FAIL</strong>.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ color: '#666', lineHeight: '1.6', marginBottom: 0 }}>
|
<p style={{ color: '#cbd5e1', lineHeight: '1.6', marginBottom: 0 }}>
|
||||||
Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets.
|
Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,39 @@
|
|||||||
.settings-wizard {
|
.settings-wizard {
|
||||||
max-width: 900px;
|
max-width: 980px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-wizard > h2 {
|
||||||
|
color: #f8fafc;
|
||||||
|
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-description {
|
.settings-description {
|
||||||
color: #666;
|
color: #cbd5e1;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
background: white;
|
background: rgba(15, 23, 42, 0.85);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
padding: 2rem;
|
padding: 1.35rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.settings-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 0.8rem;
|
||||||
border-bottom: 2px solid #f0f0f0;
|
border-bottom: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header h3 {
|
.settings-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #333;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-form {
|
.settings-form {
|
||||||
@@ -43,23 +49,43 @@
|
|||||||
|
|
||||||
.form-group small {
|
.form-group small {
|
||||||
display: block;
|
display: block;
|
||||||
color: #999;
|
color: #cbd5e1;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-wizard .form-group label {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-wizard .form-group input,
|
||||||
|
.settings-wizard .form-group select,
|
||||||
|
.settings-wizard .form-group textarea {
|
||||||
|
background: #0b1325;
|
||||||
|
color: #f8fafc;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-wizard .form-group input:focus,
|
||||||
|
.settings-wizard .form-group select:focus,
|
||||||
|
.settings-wizard .form-group textarea:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
.info-box {
|
.info-box {
|
||||||
background: #f8f9fa;
|
background: #0b1325;
|
||||||
border-left: 4px solid #667eea;
|
border-left: 4px solid #60a5fa;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 1.5rem 0;
|
margin: 1.5rem 0;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box strong {
|
.info-box strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: #333;
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box ol {
|
.info-box ol {
|
||||||
@@ -69,11 +95,11 @@
|
|||||||
|
|
||||||
.info-box li {
|
.info-box li {
|
||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
color: #666;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-box a {
|
.info-box a {
|
||||||
color: #667eea;
|
color: #bfdbfe;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +108,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-box pre {
|
.info-box pre {
|
||||||
background: white;
|
background: #111827;
|
||||||
|
color: #f8fafc;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch {
|
.toggle-switch {
|
||||||
@@ -110,7 +138,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background-color: #ccc;
|
background-color: #334155;
|
||||||
transition: .4s;
|
transition: .4s;
|
||||||
border-radius: 34px;
|
border-radius: 34px;
|
||||||
}
|
}
|
||||||
@@ -122,13 +150,17 @@
|
|||||||
width: 26px;
|
width: 26px;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
background-color: white;
|
background-color: #e2e8f0;
|
||||||
transition: .4s;
|
transition: .4s;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider {
|
.toggle-switch input:checked + .toggle-slider {
|
||||||
background-color: #667eea;
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:focus + .toggle-slider {
|
||||||
|
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-slider:before {
|
.toggle-switch input:checked + .toggle-slider:before {
|
||||||
@@ -136,13 +168,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: #6c757d;
|
background: #1e293b;
|
||||||
color: white;
|
color: #f8fafc;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
border: 1px solid #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: #5a6268;
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:focus-visible {
|
||||||
|
outline: 2px solid #60a5fa;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:disabled {
|
.btn-secondary:disabled {
|
||||||
@@ -157,15 +195,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.test-result.success {
|
.test-result.success {
|
||||||
background: #d4edda;
|
background: #052e16;
|
||||||
color: #155724;
|
color: #86efac;
|
||||||
border: 1px solid #c3e6cb;
|
border: 1px solid #166534;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-result.error {
|
.test-result.error {
|
||||||
background: #f8d7da;
|
background: #3f1d1d;
|
||||||
color: #721c24;
|
color: #fda4af;
|
||||||
border: 1px solid #f5c6cb;
|
border: 1px solid #7f1d1d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-result strong {
|
.test-result strong {
|
||||||
@@ -201,15 +239,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.save-status.success {
|
.save-status.success {
|
||||||
background: #4caf50;
|
background: #166534;
|
||||||
color: white;
|
color: #dcfce7;
|
||||||
border-left: 4px solid #388e3c;
|
border-left: 4px solid #22c55e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-status.error {
|
.save-status.error {
|
||||||
background: #f44336;
|
background: #7f1d1d;
|
||||||
color: white;
|
color: #fee2e2;
|
||||||
border-left: 4px solid #d32f2f;
|
border-left: 4px solid #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-status strong {
|
.save-status strong {
|
||||||
|
|||||||
+29
-33
@@ -2,10 +2,6 @@
|
|||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu',
|
|
||||||
'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-header {
|
.status-header {
|
||||||
@@ -13,19 +9,19 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
border-bottom: 2px solid #eee;
|
border-bottom: 1px solid #dbe2ea;
|
||||||
padding-bottom: 1.5rem;
|
padding-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-header h1 {
|
.status-header h1 {
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
font-size: 2.5rem;
|
font-size: 2.2rem;
|
||||||
color: #333;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-subtitle {
|
.status-subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #666;
|
color: #64748b;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +44,7 @@
|
|||||||
.last-updated {
|
.last-updated {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: #666;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-summary {
|
.status-summary {
|
||||||
@@ -59,12 +55,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-card {
|
.summary-card {
|
||||||
background: white;
|
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #dbe2ea;
|
||||||
border-radius: 8px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-value {
|
.summary-value {
|
||||||
@@ -75,7 +71,7 @@
|
|||||||
|
|
||||||
.summary-label {
|
.summary-label {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #666;
|
color: #64748b;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +81,9 @@
|
|||||||
|
|
||||||
.status-section h2 {
|
.status-section h2 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #333;
|
color: #1e293b;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
border-bottom: 2px solid #f0f0f0;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,30 +94,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
border: 2px solid #ddd;
|
border: 1px solid #dbe2ea;
|
||||||
border-radius: 8px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card:hover {
|
.status-card:hover {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card-up {
|
.status-card-up {
|
||||||
border-color: #4caf50;
|
border-color: #86efac;
|
||||||
background-color: #f1f8f6;
|
background: #f0fdf4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card-down {
|
.status-card-down {
|
||||||
border-color: #f44336;
|
border-color: #fca5a5;
|
||||||
background-color: #fdeaea;
|
background: #fef2f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card-maintenance {
|
.status-card-maintenance {
|
||||||
border-color: #ff9800;
|
border-color: #fcd34d;
|
||||||
background-color: #fff3e0;
|
background: #fffbeb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card-header {
|
.status-card-header {
|
||||||
@@ -134,7 +130,7 @@
|
|||||||
.status-card h3 {
|
.status-card h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
color: #333;
|
color: #0f172a;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,15 +245,15 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #dbe2ea;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maintenance-table thead,
|
.maintenance-table thead,
|
||||||
.incidents-table thead {
|
.incidents-table thead {
|
||||||
background-color: #f5f5f5;
|
background-color: #f1f5f9;
|
||||||
border-bottom: 2px solid #ddd;
|
border-bottom: 2px solid #dbe2ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maintenance-table th,
|
.maintenance-table th,
|
||||||
@@ -271,12 +267,12 @@
|
|||||||
.maintenance-table td,
|
.maintenance-table td,
|
||||||
.incidents-table td {
|
.incidents-table td {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maintenance-table tr:hover,
|
.maintenance-table tr:hover,
|
||||||
.incidents-table tr:hover {
|
.incidents-table tr:hover {
|
||||||
background-color: #f9f9f9;
|
background-color: #f0f9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.incident-desc-cell {
|
.incident-desc-cell {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: 'Manrope', 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: #f5f5f5;
|
color: #e2e8f0;
|
||||||
|
background-color: #020617;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# UI Style Guide
|
||||||
|
|
||||||
|
This project uses a simple, data-first interface style.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. Keep information dense but readable.
|
||||||
|
2. Use neutral surfaces and one primary accent.
|
||||||
|
3. Avoid decorative complexity in monitoring views.
|
||||||
|
4. Prioritize fast scanning of status, trend, and incident state.
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
1. Text:
|
||||||
|
- Primary: `#0f172a`
|
||||||
|
- Secondary: `#64748b`
|
||||||
|
|
||||||
|
2. Surface:
|
||||||
|
- Page background: `#f8fafc`
|
||||||
|
- Card background: `#ffffff`
|
||||||
|
- Border: `#dbe2ea` or `#e5e7eb`
|
||||||
|
|
||||||
|
3. Accent:
|
||||||
|
- Primary action: `#0ea5a2`
|
||||||
|
- Primary hover: `#0b8a87`
|
||||||
|
|
||||||
|
4. State colors:
|
||||||
|
- Success: `#166534` on `#dcfce7`
|
||||||
|
- Warning: `#92400e` on `#fef3c7`
|
||||||
|
- Danger: `#b91c1c`
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
1. Base family: Manrope, IBM Plex Sans, Segoe UI, sans-serif.
|
||||||
|
2. Use clear hierarchy:
|
||||||
|
- Page title: 2.2rem
|
||||||
|
- Section title: 1.5rem
|
||||||
|
- Body: 1rem
|
||||||
|
- Meta labels: 0.84rem to 0.9rem
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
1. Buttons
|
||||||
|
- `.btn-primary` for create/confirm actions.
|
||||||
|
- `.btn-secondary` for neutral actions (paging, toggles).
|
||||||
|
- `.btn-danger` for destructive actions.
|
||||||
|
- `.btn-compact` for table-row actions.
|
||||||
|
|
||||||
|
2. Tables
|
||||||
|
- Use `.data-table` and `.obs-table` patterns.
|
||||||
|
- Header row uses uppercase small labels.
|
||||||
|
- Right-align numeric columns with utility class (`.text-right` or `.obs-right`).
|
||||||
|
|
||||||
|
3. Filters and controls
|
||||||
|
- Use `.section-controls` + `.filter-control` in detail pages.
|
||||||
|
- Keep pagination controls grouped in `.controls-pagination`.
|
||||||
|
|
||||||
|
## Spacing and layout
|
||||||
|
|
||||||
|
1. Standard spacing increments: 0.5rem, 0.75rem, 1rem, 1.5rem, 2rem.
|
||||||
|
2. Keep card radius between 8px and 12px.
|
||||||
|
3. Use 1px borders and light shadows for separation.
|
||||||
|
|
||||||
|
## Responsiveness
|
||||||
|
|
||||||
|
1. Collapse toolbar and filter rows under 768px.
|
||||||
|
2. Convert 4-column stat grids to 2 columns at tablet and 1 column on small phones.
|
||||||
|
3. Ensure action menus and inputs expand to full width on mobile.
|
||||||
|
|
||||||
|
## Usage notes
|
||||||
|
|
||||||
|
1. Prefer CSS classes over inline styles.
|
||||||
|
2. Add new styles to page stylesheet first, then extract to shared patterns when repeated.
|
||||||
|
3. Keep UI changes visual only unless explicitly requested to change behavior.
|
||||||
@@ -345,6 +345,40 @@ app.patch('/api/resources/:id/group', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Toggle maintenance mode without overwriting other resource fields
|
||||||
|
app.patch('/api/resources/:id/maintenance-mode', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const { maintenance_mode } = req.body;
|
||||||
|
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid resource id' });
|
||||||
|
}
|
||||||
|
if (typeof maintenance_mode !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'maintenance_mode must be a boolean' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare('UPDATE resources SET maintenance_mode = ? WHERE id = ?');
|
||||||
|
const info = stmt.run(maintenance_mode ? 1 : 0, id);
|
||||||
|
|
||||||
|
if (info.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Resource not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate related cache entries and broadcast live update
|
||||||
|
cache.invalidatePattern('history:');
|
||||||
|
cache.invalidatePattern('sla:');
|
||||||
|
if (global.broadcastDashboardUpdate) {
|
||||||
|
try { global.broadcastDashboardUpdate(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = db.prepare('SELECT * FROM resources WHERE id = ?').get(id);
|
||||||
|
res.json(updated);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to update maintenance mode', details: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Delete resource
|
// Delete resource
|
||||||
app.delete('/api/resources/:id', (req, res) => {
|
app.delete('/api/resources/:id', (req, res) => {
|
||||||
const resource = db.prepare('SELECT name, url FROM resources WHERE id = ?').get(req.params.id);
|
const resource = db.prepare('SELECT name, url FROM resources WHERE id = ?').get(req.params.id);
|
||||||
|
|||||||
Reference in new issue
Block a user