Merge redesign command-center updates

This commit is contained in:
Nate Emmert committed 2026-03-10 14:54:50 -04:00
commit 85368ba0dc
23 files changed
+2240 -641

No files matched your search

+6 -6
View File
@@ -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
View File
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.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
@@ -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
View File
File diff suppressed because it is too large. Load diff
+377 -245
View File
@@ -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>
+290
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+229
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+77 -39
View File
@@ -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
View File
@@ -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 {
+3 -4
View File
@@ -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;
} }
* { * {
+74
View File
@@ -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.
+34
View File
@@ -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);