Redesign UI into command center layout

This commit is contained in:
Nate Emmert committed 2026-03-10 11:06:17 -04:00
1 parent 8942d41354
commit b9da742b3d
17 files changed
+1431 -441

No files matched your search

+6 -6
View File
@@ -1,15 +1,15 @@
{
"files": {
"main.css": "/static/css/main.4f505a72.css",
"main.js": "/static/js/main.29f97b36.js",
"main.css": "/static/css/main.69110889.css",
"main.js": "/static/js/main.820bab7f.js",
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
"index.html": "/index.html",
"main.4f505a72.css.map": "/static/css/main.4f505a72.css.map",
"main.29f97b36.js.map": "/static/js/main.29f97b36.js.map",
"main.69110889.css.map": "/static/css/main.69110889.css.map",
"main.820bab7f.js.map": "/static/js/main.820bab7f.js.map",
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
},
"entrypoints": [
"static/css/main.4f505a72.css",
"static/js/main.29f97b36.js"
"static/css/main.69110889.css",
"static/js/main.820bab7f.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.820bab7f.js"></script><link href="/static/css/main.69110889.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:#f8fafc;color:#0f172a;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,#dff7f2 0,#0000 40%),radial-gradient(circle at 95% 10%,#e8eefc 0,#0000 38%),#f8fafc;min-height:100vh}.navbar{backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);background:#ffffffd9;border-bottom:1px solid #dbe2ea;box-shadow:0 4px 18px #0f172a0f;color:#0f172a;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:#475569;font-size:.92rem;font-weight:600;padding:.4rem .7rem;text-decoration:none;transition:background .2s,color .2s,box-shadow .2s}.nav-link:hover{background:#fff;box-shadow:inset 0 0 0 1px #dbe2ea;color:#0f172a}.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:#fff;border:1px solid #dbe2ea;border-radius:999px;color:#334155;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{font-size:1.85rem;letter-spacing:-.02em;margin:0}.dashboard-subtitle{color:#64748b;font-size:.9rem;margin:.2rem 0 0}.dashboard-actions{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.tag-filter-input{background:#ffffffe6;border:1px solid #cbd5e1;border-radius:10px;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:#ffffffd1;border:1px solid #cbd5e1;border-radius:12px;padding:.65rem}.resource-list-header{grid-gap:1rem;border-bottom:1px solid #e2e8f0;color:#64748b;display:grid;font-size:.72rem;gap:1rem;grid-template-columns:minmax(260px,2fr) minmax(220px,1.25fr) minmax(170px,.8fr);letter-spacing:.08em;margin-bottom:.45rem;padding:.35rem .6rem .6rem;text-transform:uppercase}.resource-list-count{color:#64748b;font-size:.75rem;margin:0 .6rem .55rem}.resource-row{grid-gap:1rem;align-items:cLine truncated
/*# sourceMappingURL=main.69110889.css.map*/
@@ -0,0 +1 @@
{"version":3,"file":"static/css/main.69110889.css","mappings":"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 truncated
@@ -1 +0,0 @@
{"version":3,"file":"static/js/main.29f97b36.js","mappings":"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 truncated
@@ -1,3 +1,3 @@
/*! For license information please see main.29f97b36.js.LICENSE.txt */
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
//# sourceMappingURL=main.29f97b36.js.map
/*! For license information please see main.820bab7f.js.LICENSE.txt */
(()=>{var e={356:(e,t,n)=>{e.exports=n(9645).last},418:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.toString=function e(t){if(null==t)return"";if("string"===typeof t)return t;if(Array.isArray(t))return t.map(e).join(",");const n=String(t);return"0"===n&&Object.is(Number(t),-0)?"-0":n}},579:(e,t,n)=>{"use strict";e.exports=n(2799)},870:(e,t)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t.isObject=function(e){return null!==e&&("object"===typeof e||"function"===typeof e)}},1203:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(8420);t.isArrayLike=function(e){return null!=e&&"function"!==typeof e&&r.isLength(e.length)}},1293:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(4830);t.debounce=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};"object"!==typeof n&&(n={});const{leading:a=!1,trailing:i=!0,maxWait:o}=n,l=Array(2);let s;a&&(l[0]="leading"),i&&(l[1]="trailing");let c=null;const u=r.debounce(function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];s=e.apply(this,n),c=null},t,{edges:l}),d=function(){for(var t=arguments.length,n=new Array(t),r=0;r<t;r++)n[r]=arguments[r];return null!=o&&(null===c&&(c=Date.now()),Date.now()-c>=o)?(s=e.apply(this,n),c=Date.now(),u.cancel(),u.schedule(),s):(u.apply(this,n),s)};return d.cancel=u.cancel,d.flush=()=>(u.flush(),s),d}},1444:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(418),a=n(3272);t.toPath=function(e){if(Array.isArray(e))return e.map(a.toKey);if("symbol"===typeof e)return[e];const t=[],n=(e=r.toString(e)).length;if(0===n)return t;let i=0,o="",l="",s=!1;for(46===e.charCodeAt(0)&&(t.push(""),i++);i<n;){const r=e[i];l?"\\"===r&&i+1<n?(i++,o+=e[i]):r===l?l="":o+=r:s?'"'===r||"'"===r?l=r:"]"===r?(s=!1,t.push(o),o=""):o+=r:"["===r?(s=!0,o&&(t.push(o),o="")):"."===r?o&&(t.push(o),o=""):o+=r,i++}return o&&t.push(o),t}},2086:(e,t,n)=>{"use strict";e.exports=n(5082)},2104:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(1203),a=n(9921);t.isArrayLikeObject=function(e){return a.isObjectLike(e)&&r.isArrayLike(e)}},2132:(e,t,n)=>{"use strict";var r=n(2897).default;Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const a=n(870),i=n(4803),o=n(9972);function l(e,t,n,u){if(t===e)return!0;switch(typeof t){case"object":return function(e,t,n,r){if(null==t)return!0;if(Array.isArray(t))return s(e,t,n,r);if(t instanceof Map)return function(e,t,n,r){if(0===t.size)return!0;if(!(e instanceof Map))return!1;for(const[a,i]of t.entries()){if(!1===n(e.get(a),i,a,e,t,r))return!1}return!0}(e,t,n,r);if(t instanceof Set)return c(e,t,n,r);const a=Object.keys(t);if(null==e)return 0===a.length;if(0===a.length)return!0;if(null!==r&&void 0!==r&&r.has(t))return r.get(t)===e;null===r||void 0===r||r.set(t,e);try{for(let o=0;o<a.length;o++){const l=a[o];if(!i.isPrimitive(e)&&!(l in e))return!1;if(void 0===t[l]&&void 0!==e[l])return!1;if(null===t[l]&&null!==e[l])return!1;if(!n(e[l],t[l],l,e,t,r))return!1}return!0}finally{null===r||void 0===r||r.delete(t)}}(e,t,n,u);case"function":return Object.keys(t).length>0?l(e,r({},t),n,u):o.eq(e,t);default:return a.isObject(e)?"string"!==typeof t||""===t:o.eq(e,t)}}function s(e,t,n,r){if(0===t.length)return!0;if(!Array.isArray(e))return!1;const a=new Set;for(let i=0;i<t.length;i++){const o=t[i];let l=!1;for(let s=0;s<e.length;s++){if(a.has(s))continue;let c=!1;if(n(e[s],o,i,e,t,r)&&(c=!0),c){a.add(s),l=!0;break}}if(!l)return!1}return!0}function c(e,t,n,r){return 0===t.size||e instanceof Set&&s([...e],[...t],n,r)}t.isMatchWith=function e(t,n,r){return"function"!==typeof r?e(t,n,()=>{}):l(t,n,function e(t,n,a,i,o,s){const c=r(t,n,a,i,o,s);return void 0!==c?Boolean(c):l(t,n,e,s)},new Map)},t.isSetMatch=c},2204:(e,t,n)=>{"use strict";Object.defineProperty(t,Symbol.toStringTag,{value:"Module"});const r=n(5316),a=n(7312),i=n(3799);t.sortBy=function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),o=1;o<t;o++)n[o-1]=arguments[o];const l=n.length;return l>1&&i.isIterateeCall(e,n[0],n[1])?n=[]:l>2&&i.isIterateeCall(n[0],n[1],n[2])&&(n=[n[0]]),r.orderBy(e,a.flatten(n),["asc"])}},2330:(e,t,n)=>{"use strict";var r=n(5043);var a="function"===typeof Object.is?Object.is:function(e,t){return e===t&&(0!==e||1/e===1/t)||e!==e&&t!==t},i=r.useState,o=r.useEffect,l=r.useLayoutEffect,s=r.useDebugValue;function c(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!a(e,n)}catch(r){return!0}}var u="undefined"===typeof window||"undefined"===typeof window.document||"undefined"===typeof window.document.createElement?function(e,t){return t()}:function(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),a=r[0].inst,u=r[1];return l(function(){a.value=n,a.getSnapshot=t,c(a)&&u({inst:a})},[e,n,t]),o(function(){return c(a)&&u({iLine truncated
//# sourceMappingURL=main.820bab7f.js.map
@@ -0,0 +1 @@
{"version":3,"file":"static/js/main.820bab7f.js","mappings":"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 truncated
+764 -47
View File
File diff suppressed because it is too large. Load diff
+264 -243
View File
@@ -29,8 +29,6 @@ function Dashboard() {
const [showGroupModal, setShowGroupModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [actionsOpen, setActionsOpen] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState({});
const [editingId, setEditingId] = useState(null);
const [editData, setEditData] = useState({});
const [notifications, setNotifications] = useState([]);
const [formData, setFormData] = useState({
@@ -53,6 +51,7 @@ function Dashboard() {
response_time_threshold: null,
});
const [tagFilter, setTagFilter] = useState('');
const [groupFilter, setGroupFilter] = useState('all');
const [groupData, setGroupData] = useState({ name: '', description: '' });
const wsRef = useRef(null);
const actionsRef = useRef(null);
@@ -279,121 +278,114 @@ function Dashboard() {
e.target.value = '';
};
const toggleGroup = (groupId) => {
setCollapsedGroups(prev => ({
...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);
const groupNameById = groups.reduce((acc, g) => {
acc[g.id] = g.name;
return acc;
}, {});
const getGroupStats = (groupResources) => {
if (!groupResources || groupResources.length === 0) return { uptime: 0, avgResponse: 0, total: 0 };
const uptime = (groupResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / groupResources.length).toFixed(2);
const avgResponse = (groupResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0), 0) / groupResources.length).toFixed(0);
return { uptime, avgResponse, total: groupResources.length };
};
// Filter resources by tag/group and sort for stable scanning
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;
return true;
})
.sort((a, b) => {
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 || ''));
});
const renderResourceCard = (resource) => {
const totalCount = filteredResources.length;
const upCount = filteredResources.filter((r) => r.status === 'up').length;
const downCount = filteredResources.filter((r) => r.status === 'down').length;
const maintenanceCount = filteredResources.filter((r) => r.maintenance_mode).length;
const avgUptime = totalCount
? (filteredResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / totalCount).toFixed(2)
: '0.00';
const avgResponse = totalCount
? Math.round(filteredResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0, 10), 0) / totalCount)
: 0;
const activeIncidents = filteredResources
.filter((r) => r.hasActiveIncident || r.status === 'down')
.slice(0, 8);
const groupCounts = groups
.map((g) => ({
id: g.id,
name: g.name,
count: filteredResources.filter((r) => String(r.group_id || '') === String(g.id)).length,
}))
.filter((g) => g.count > 0);
const ungroupedCount = filteredResources.filter((r) => !r.group_id).length;
const renderResourceRow = (resource) => {
const sparkData = (resource.recentChecks || []).map((c, idx) => ({
idx,
responseTime: c.response_time || 0,
statusValue: c.status === 'up' ? 1 : 0,
}));
const isEditing = editingId === resource.id;
const groupName = groupNameById[resource.group_id] || 'Ungrouped';
return (
<div
key={resource.id}
className="resource-card compact"
onClick={(e) => !isEditing && !e.target.closest('.card-actions') && navigate(`/resource/${resource.id}`)}
className="resource-row"
onClick={(e) => !e.target.closest('.row-actions') && navigate(`/resource/${resource.id}`)}
>
<div className="resource-header">
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{isEditing ? (
<input
type="text"
value={editData.name || resource.name}
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 className="row-main">
<div className="row-title-line">
<h3 className="resource-name row-name">{resource.name}</h3>
<span className="resource-group-pill">{groupName}</span>
{resource.hasActiveIncident && <span className="incident-inline">active incident</span>}
{resource.maintenance_mode && <span className="maintenance-inline">🛠 maintenance</span>}
</div>
<span className={`status-badge status-${resource.status}`}>
{resource.status}
</span>
<p className="resource-url row-url">{resource.url}</p>
<p className="resource-type row-type">{resource.type}</p>
</div>
{resource.hasActiveIncident && (
<div className="incident-badge">⚠️ Active Incident</div>
)}
<div className="resource-meta">
<div className="row-metrics">
<div>
<p
className="stat-value small"
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>
<p className="stat-value small">{resource.uptime}%</p>
<p className="stat-label">Uptime</p>
</div>
<div>
<p className="stat-value small" style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); if (!isEditing) setEditingId(resource.id); }}>
{resource.avgResponseTime}ms
</p>
<p className="stat-value small">{resource.avgResponseTime}ms</p>
<p className="stat-label">Avg Resp</p>
</div>
<div className="sparkline">
<div className="row-sparkline">
{sparkData.length > 1 ? (
<ResponsiveContainer width="100%" height={50}>
<LineChart data={sparkData} margin={{ top: 4, bottom: 0, left: 0, right: 0 }}>
<Line type="monotone" dataKey="responseTime" stroke="#667eea" strokeWidth={2} dot={false} />
<ResponsiveContainer width="100%" height={44}>
<LineChart data={sparkData} margin={{ top: 2, bottom: 0, left: 0, right: 0 }}>
<Line type="monotone" dataKey="responseTime" stroke="#14b8a6" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<span style={{ fontSize: '0.75rem', color: '#999' }}>No recent data</span>
<span className="last-check">No recent data</span>
)}
</div>
</div>
<div className="card-actions">
<button className="btn-icon" title="Edit" onClick={(e) => { e.stopPropagation(); openEditModal(resource); }}>✎</button>
<button className="btn-icon" title="Delete" onClick={(e) => { e.stopPropagation(); handleDeleteResource(resource.id); }}>🗑</button>
</div>
{!isEditing && resource.lastCheck && (
<div className="row-side">
<span className={`status-badge status-${resource.status}`}>
{resource.status}
</span>
<div className="row-actions">
<button className="btn-icon" title="Edit" onClick={(e) => { e.stopPropagation(); openEditModal(resource); }}>✎</button>
<button className="btn-icon" title="Delete" onClick={(e) => { e.stopPropagation(); handleDeleteResource(resource.id); }}>🗑</button>
</div>
<p className="last-check">
Last: {formatLocalTime(resource.lastCheck)}
Last: {resource.lastCheck ? formatLocalTime(resource.lastCheck) : 'Never'}
</p>
)}
</div>
</div>
);
};
@@ -402,20 +394,11 @@ function Dashboard() {
<div className="container">
{/* Real-time Notifications */}
{notifications.length > 0 && (
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 9999 }}>
<div className="toast-stack">
{notifications.map(notif => (
<div
key={notif.id}
style={{
marginBottom: '10px',
padding: '12px 16px',
borderRadius: '4px',
backgroundColor: notif.type === 'error' ? '#f8d7da' : notif.type === 'success' ? '#d4edda' : '#d1ecf1',
color: notif.type === 'error' ? '#721c24' : notif.type === 'success' ? '#155724' : '#0c5460',
border: `1px solid ${notif.type === 'error' ? '#f5c6cb' : notif.type === 'success' ? '#c3e6cb' : '#bee5eb'}`,
minWidth: '300px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
className={`toast-item toast-${notif.type}`}
>
<strong>{notif.title}</strong>
<div>{notif.message}</div>
@@ -423,18 +406,32 @@ function Dashboard() {
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2>SkyWatch Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div className="dashboard-toolbar">
<div>
<h2 className="dashboard-title">Command Center</h2>
<p className="dashboard-subtitle">Live operational view of all monitors</p>
</div>
<div className="dashboard-actions">
<NotificationCenter />
<input
type="text"
placeholder="Filter by tag"
value={tagFilter}
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>
<div ref={actionsRef} className="actions-menu-wrapper">
<button
className="btn btn-secondary"
onClick={() => setActionsOpen((o) => !o)}
@@ -446,23 +443,11 @@ function Dashboard() {
{actionsOpen && (
<div
role="menu"
style={{
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,
}}
className="actions-menu"
>
<button
className="btn btn-ghost"
onClick={() => { setActionsOpen(false); setShowGroupModal(true); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '0.6rem 0.8rem' }}
role="menuitem"
>
+ New Group
@@ -470,16 +455,14 @@ function Dashboard() {
<button
className="btn btn-ghost"
onClick={() => { setActionsOpen(false); setShowModal(true); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '0.6rem 0.8rem' }}
role="menuitem"
>
+ Add Resource
</button>
<hr style={{ margin: '0.25rem 0', border: 0, borderTop: '1px solid #eee' }} />
<hr className="actions-menu-divider" />
<button
className="btn btn-ghost"
onClick={() => { setActionsOpen(false); handleExportCSV(); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '0.6rem 0.8rem' }}
role="menuitem"
>
⬇ Export CSV
@@ -487,7 +470,6 @@ function Dashboard() {
<button
className="btn btn-ghost"
onClick={() => { setActionsOpen(false); document.getElementById('csv-import').click(); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '0.6rem 0.8rem' }}
role="menuitem"
>
⬆ Import CSV
@@ -499,12 +481,39 @@ function Dashboard() {
id="csv-import"
type="file"
accept=".csv"
style={{ display: 'none' }}
className="hidden-file-input"
onChange={handleImportCSV}
/>
</div>
</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 ? (
<div className="empty-state">
<h3>No resources yet</h3>
@@ -513,51 +522,66 @@ function Dashboard() {
) : filteredResources.length === 0 ? (
<div className="empty-state">
<h3>No resources match filter</h3>
<p>Try adjusting your tag filter</p>
<p>Try adjusting your tag/group filters</p>
</div>
) : (
<div>
{groups.map((group) => {
const groupStats = getGroupStats(groupResourcesMap[group.id]);
const isCollapsed = collapsedGroups[group.id];
return (
<div key={group.id} className="group-section">
<div className="group-header" onClick={() => toggleGroup(group.id)}>
<span className="group-toggle">{isCollapsed ? '▶' : '▼'}</span>
<h3 style={{ margin: 0, flex: 1 }}>{group.name}</h3>
<div className="group-stats">
<span className="group-stat">{groupStats.total} resources</span>
<span className="group-stat">{groupStats.uptime}% up</span>
<span className="group-stat">{groupStats.avgResponse}ms avg</span>
</div>
</div>
{!isCollapsed && (
<div className="dashboard-grid">
{groupResourcesMap[group.id]?.map(renderResourceCard)}
</div>
)}
</div>
);
})}
{groupResourcesMap['ungrouped']?.length > 0 && (
<div className="group-section">
<div className="group-header" onClick={() => toggleGroup('ungrouped')}>
<span className="group-toggle">{collapsedGroups['ungrouped'] ? '▶' : '▼'}</span>
<h3 style={{ margin: 0, flex: 1 }}>Ungrouped</h3>
<div className="group-stats">
<span className="group-stat">{groupResourcesMap['ungrouped'].length} resources</span>
<span className="group-stat">{getGroupStats(groupResourcesMap['ungrouped']).uptime}% up</span>
<span className="group-stat">{getGroupStats(groupResourcesMap['ungrouped']).avgResponse}ms avg</span>
</div>
</div>
{!collapsedGroups['ungrouped'] && (
<div className="dashboard-grid">
{groupResourcesMap['ungrouped'].map(renderResourceCard)}
<div className="cc-layout">
<section className="resource-list-shell cc-main">
<div className="resource-list-header">
<div>Resource</div>
<div>Metrics</div>
<div>Status</div>
</div>
<div className="resource-list-count">{filteredResources.length} monitors</div>
<div className="resource-list">
{filteredResources.map(renderResourceRow)}
</div>
</section>
<aside className="cc-rail">
<div className="cc-panel">
<h3>Active Incidents</h3>
{activeIncidents.length === 0 ? (
<p className="cc-empty">No active incidents</p>
) : (
<div className="cc-incident-list">
{activeIncidents.map((incident) => (
<button
key={incident.id}
className="cc-incident-item"
onClick={() => navigate(`/resource/${incident.id}`)}
>
<span className="cc-incident-name">{incident.name}</span>
<span className={`status-badge status-${incident.status}`}>{incident.status}</span>
</button>
))}
</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 && (
@@ -1272,13 +1296,13 @@ function ResourceDetail() {
<Link to="/" className="back-button">← Back to Dashboard</Link>
<div className="detail-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div className="detail-header-row">
<div>
<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>
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<div className="detail-actions">
<button className="btn" onClick={toggleEnabled}>
{resource.enabled ? 'Disable' : 'Enable'}
</button>
@@ -1295,12 +1319,12 @@ function ResourceDetail() {
<div className="incident-badge">⚠️ Active Incident - Resource is currently DOWN</div>
)}
{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
</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">
<p className="stat-value">{resource.stats.uptime}%</p>
<p className="stat-label">Uptime (24h)</p>
@@ -1329,7 +1353,7 @@ function ResourceDetail() {
) : !sla ? (
<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">
<p className="stat-value">{sla.uptimePct}%</p>
<p className="stat-label">Uptime</p>
@@ -1375,100 +1399,103 @@ function ResourceDetail() {
<div className="detail-section">
<h2>Recent Checks</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.9rem', color: '#555', marginRight: '0.4rem' }}>Status</label>
<div className="section-controls">
<div className="filter-control">
<label>Status</label>
<select value={checksStatus} onChange={(e) => { setChecksPage(0); setChecksStatus(e.target.value); }}>
<option value="">All</option>
<option value="up">Up</option>
<option value="down">Down</option>
</select>
</div>
<div>
<label style={{ fontSize: '0.9rem', color: '#555', marginRight: '0.4rem' }}>Sort</label>
<div className="filter-control">
<label>Sort</label>
<select value={checksSort} onChange={(e) => { setChecksPage(0); setChecksSort(e.target.value); }}>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</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={checksLoading || checks.length < checksLimit} onClick={() => setChecksPage(checksPage + 1)}>Next →</button>
</div>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Time</th>
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Status</th>
<th style={{ padding: '0.75rem', textAlign: 'right' }}>Response Time</th>
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Message</th>
<tr>
<th>Time</th>
<th>Status</th>
<th className="text-right">Response Time</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{checksLoading ? (
<tr><td colSpan="4" style={{ padding: '0.75rem' }}>Loading checks...</td></tr>
<tr><td colSpan="4">Loading checks...</td></tr>
) : 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) => (
<tr key={check.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '0.75rem' }}>
<tr key={check.id}>
<td>
{formatLocalTime(check.checked_at)}
</td>
<td style={{ padding: '0.75rem' }}>
<td>
<span className={`status-badge status-${check.status}`}>
{check.status}
</span>
</td>
<td style={{ padding: '0.75rem', textAlign: 'right' }}>
<td className="text-right">
{check.response_time ? `${check.response_time}ms` : '-'}
</td>
<td style={{ padding: '0.75rem', color: '#666' }}>
<td className="muted-cell">
{check.error_message || 'OK'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="detail-section">
<h2>Incidents Timeline</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.9rem', color: '#555', marginRight: '0.4rem' }}>Status</label>
<div className="section-controls">
<div className="filter-control">
<label>Status</label>
<select value={incidentsStatus} onChange={(e) => { setIncidentsPage(0); setIncidentsStatus(e.target.value); }}>
<option value="all">All</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
</div>
<div>
<label style={{ fontSize: '0.9rem', color: '#555', marginRight: '0.4rem' }}>Sort</label>
<div className="filter-control">
<label>Sort</label>
<select value={incidentsSort} onChange={(e) => { setIncidentsPage(0); setIncidentsSort(e.target.value); }}>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</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={incidentsLoading || incidents.length < incidentsLimit} onClick={() => setIncidentsPage(incidentsPage + 1)}>Next →</button>
</div>
</div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '0.75rem', textAlign: 'left', width: '25%' }}>Started</th>
<th style={{ padding: '0.75rem', textAlign: 'left', width: '25%' }}>Resolved</th>
<th style={{ padding: '0.75rem', textAlign: 'left', width: '15%' }}>Duration</th>
<th style={{ padding: '0.75rem', textAlign: 'left', width: '35%' }}>Reason</th>
<tr>
<th className="w-25">Started</th>
<th className="w-25">Resolved</th>
<th className="w-15">Duration</th>
<th className="w-35">Reason</th>
</tr>
</thead>
<tbody>
{incidentsLoading ? (
<tr><td colSpan="4" style={{ padding: '0.75rem' }}>Loading incidents...</td></tr>
<tr><td colSpan="4">Loading incidents...</td></tr>
) : 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) => {
const start = incident.started_at || incident.created_at;
const end = incident.resolved_at;
@@ -1480,22 +1507,21 @@ function ResourceDetail() {
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
return (
<>
<tr key={incident.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '0.75rem' }}>{start ? formatLocalTime(start) : '-'}</td>
<td style={{ padding: '0.75rem' }}>{end ? formatLocalTime(end) : 'Open'}</td>
<td style={{ padding: '0.75rem' }}>{durationText}</td>
<td style={{ padding: '0.75rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ cursor: 'pointer', color: '#0066cc', flex: 1 }} onClick={() => setExpandedIncidentId(isExpanded ? null : incident.id)}>
<tr key={incident.id}>
<td>{start ? formatLocalTime(start) : '-'}</td>
<td>{end ? formatLocalTime(end) : 'Open'}</td>
<td>{durationText}</td>
<td className="incident-reason-cell">
<span className="incident-reason-toggle" onClick={() => setExpandedIncidentId(isExpanded ? null : incident.id)}>
{displayText}
{isTruncated && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.85rem', color: '#666' }}>
<span className="expand-indicator">
{isExpanded ? '▼' : '▶'}
</span>
)}
</span>
<button
className="btn btn-secondary"
style={{ marginLeft: '0.5rem', padding: '0.3rem 0.6rem', fontSize: '0.85rem' }}
className="btn btn-secondary btn-compact"
onClick={() => handleEditIncident(incident)}
>
Edit
@@ -1503,8 +1529,8 @@ function ResourceDetail() {
</td>
</tr>
{isExpanded && (
<tr style={{ backgroundColor: '#f9f9f9', borderBottom: '1px solid #eee' }}>
<td colSpan="4" style={{ padding: '0.75rem', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontFamily: 'monospace', fontSize: '0.9rem', color: '#333' }}>
<tr className="expanded-row">
<td colSpan="4" className="expanded-cell">
{description}
</td>
</tr>
@@ -1514,37 +1540,38 @@ function ResourceDetail() {
})}
</tbody>
</table>
</div>
</div>
<div className="detail-section">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div className="section-title-row">
<h2>Maintenance Windows</h2>
<button className="btn btn-primary" onClick={() => setShowMaintenanceModal(true)}>
+ Schedule Maintenance
</button>
</div>
{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>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Start Time</th>
<th style={{ padding: '0.75rem', textAlign: 'left' }}>End Time</th>
<th style={{ padding: '0.75rem', textAlign: 'left' }}>Reason</th>
<th style={{ padding: '0.75rem', textAlign: 'right' }}>Actions</th>
<tr>
<th>Start Time</th>
<th>End Time</th>
<th>Reason</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{maintenanceWindows.map(window => (
<tr key={window.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '0.75rem' }}>{formatLocalTime(window.start_time)}</td>
<td style={{ padding: '0.75rem' }}>{formatLocalTime(window.end_time)}</td>
<td style={{ padding: '0.75rem', color: '#666' }}>{window.reason || '-'}</td>
<td style={{ padding: '0.75rem', textAlign: 'right' }}>
<tr key={window.id}>
<td>{formatLocalTime(window.start_time)}</td>
<td>{formatLocalTime(window.end_time)}</td>
<td className="muted-cell">{window.reason || '-'}</td>
<td className="text-right">
<button
className="btn btn-danger"
style={{ padding: '0.3rem 0.6rem', fontSize: '0.85rem' }}
className="btn btn-danger btn-compact"
onClick={() => handleDeleteMaintenanceWindow(window.id)}
>
Delete
@@ -1554,12 +1581,13 @@ function ResourceDetail() {
))}
</tbody>
</table>
</div>
)}
</div>
{showMaintenanceModal && (
<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>
<form onSubmit={handleCreateMaintenanceWindow}>
<div className="form-group">
@@ -1673,32 +1701,25 @@ function Navbar() {
return (
<nav className="navbar">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<div className="navbar-inner">
<div className="navbar-left">
<h1>🔍 SkyWatch</h1>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<Link to="/" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Dashboard</Link>
<Link to="/history" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>History</Link>
<Link to="/sla" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>SLA</Link>
<Link to="/observability" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Observability</Link>
<Link to="/notifications" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500 }}>Notifications</Link>
<Link to="/settings" style={{ color: 'white', textDecoration: 'none', opacity: 0.9, fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div className="navbar-links">
<Link to="/" className="nav-link">Dashboard</Link>
<Link to="/history" className="nav-link">History</Link>
<Link to="/sla" className="nav-link">SLA</Link>
<Link to="/observability" className="nav-link">Observability</Link>
<Link to="/notifications" className="nav-link">Notifications</Link>
<Link to="/settings" className="nav-link settings-link">
Settings
{!notificationsConfigured && (
<span style={{
background: '#ffc107',
color: '#000',
padding: '0.15rem 0.5rem',
borderRadius: '12px',
fontSize: '0.7rem',
fontWeight: 'bold'
}}>SETUP</span>
<span className="setup-badge">SETUP</span>
)}
</Link>
</div>
</div>
{notificationsConfigured && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', opacity: 0.9 }}>
<div className="notifications-pill">
<span>🔔</span>
<span>Notifications Active</span>
</div>
+211
View File
@@ -0,0 +1,211 @@
.observability-page {
padding-top: 2rem;
}
.obs-tabs {
margin-bottom: 2rem;
border-bottom: 1px solid #dbe2ea;
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: #64748b;
}
.obs-tab.is-active {
border-bottom-color: #0ea5a2;
color: #0f172a;
background: #ffffff;
border-color: #dbe2ea;
border-bottom-color: #0ea5a2;
}
.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 #dbe2ea;
border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.obs-card-label {
font-size: 0.85rem;
color: #64748b;
}
.obs-card-value {
font-size: 1.5rem;
font-weight: 700;
margin-top: 0.45rem;
color: #0f172a;
}
.obs-card-value.is-danger {
color: #b91c1c;
}
.obs-card-value.is-ok {
color: #0f766e;
}
.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: #f1f5f9;
border-bottom: 2px solid #dbe2ea;
color: #1e293b;
font-size: 0.84rem;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.obs-table td {
padding: 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
.obs-table tbody tr:hover {
background: #f0f9ff;
}
.obs-right {
text-align: right !important;
}
.obs-rate-pill {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 600;
}
.obs-rate-pill.is-good {
background: #dcfce7;
color: #166534;
}
.obs-rate-pill.is-warn {
background: #fef3c7;
color: #92400e;
}
.obs-empty-copy {
color: #94a3b8;
text-align: center;
padding: 2rem;
}
.obs-zebra {
background: #f8fafc;
}
.obs-error-code {
font-size: 0.85rem;
color: #b91c1c;
}
.obs-muted-sm {
font-size: 0.85rem;
color: #64748b;
}
.obs-small {
font-size: 0.85rem;
}
.obs-audit-summary {
margin-bottom: 2rem;
padding: 1rem;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #dbe2ea;
}
.obs-audit-summary-line {
margin: 0.25rem 0;
font-size: 0.9rem;
color: #64748b;
}
.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 #dbe2ea;
border-radius: 10px;
background: #fff;
}
.obs-filter-input:focus {
outline: none;
border-color: #0ea5a2;
box-shadow: 0 0 0 3px rgba(14, 165, 162, 0.18);
}
.obs-click-row {
cursor: pointer;
}
.obs-toggle-code {
cursor: pointer;
color: #0f766e;
}
.obs-expanded-row {
background: #f0f9ff;
}
.obs-expanded-cell {
padding: 1rem;
}
.obs-pre {
margin-top: 0.5rem;
background: #fff;
padding: 0.75rem;
border-radius: 8px;
overflow-x: auto;
border: 1px solid #dbe2ea;
}
@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 axios from 'axios';
import { formatLocalTime } from './utils/timeUtils';
import './Observability.css';
function Observability() {
const [activeTab, setActiveTab] = useState('metrics');
@@ -45,49 +46,25 @@ function Observability() {
};
return (
<div className="container" style={{ paddingTop: '2rem' }}>
<div className="container observability-page">
<h2>System Observability</h2>
<div style={{ marginBottom: '2rem', borderBottom: '1px solid #ddd', display: 'flex', gap: '1rem' }}>
<div className="obs-tabs">
<button
onClick={() => setActiveTab('metrics')}
style={{
padding: '0.75rem 1.5rem',
border: 'none',
borderBottom: activeTab === 'metrics' ? '3px solid #007bff' : 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
fontWeight: activeTab === 'metrics' ? 'bold' : 'normal',
color: activeTab === 'metrics' ? '#007bff' : '#666'
}}
className={`obs-tab ${activeTab === 'metrics' ? 'is-active' : ''}`}
>
API Metrics
</button>
<button
onClick={() => setActiveTab('errors')}
style={{
padding: '0.75rem 1.5rem',
border: 'none',
borderBottom: activeTab === 'errors' ? '3px solid #007bff' : 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
fontWeight: activeTab === 'errors' ? 'bold' : 'normal',
color: activeTab === 'errors' ? '#007bff' : '#666'
}}
className={`obs-tab ${activeTab === 'errors' ? 'is-active' : ''}`}
>
Errors
</button>
<button
onClick={() => setActiveTab('audit')}
style={{
padding: '0.75rem 1.5rem',
border: 'none',
borderBottom: activeTab === 'audit' ? '3px solid #007bff' : 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
fontWeight: activeTab === 'audit' ? 'bold' : 'normal',
color: activeTab === 'audit' ? '#007bff' : '#666'
}}
className={`obs-tab ${activeTab === 'audit' ? 'is-active' : ''}`}
>
Audit Logs
</button>
@@ -96,64 +73,59 @@ function Observability() {
{/* Metrics Tab */}
{activeTab === 'metrics' && metrics && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '1rem', marginBottom: '2rem' }}>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Server Uptime</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
<div className="obs-summary-grid">
<div className="obs-summary-card">
<div className="obs-card-label">Server Uptime</div>
<div className="obs-card-value">
{metrics.uptimeFormatted || `${Math.floor(metrics.uptime / 3600)}h`}
</div>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Total API Requests</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
<div className="obs-summary-card">
<div className="obs-card-label">Total API Requests</div>
<div className="obs-card-value">
{metrics.apiStats.reduce((sum, s) => sum + s.totalRequests, 0)}
</div>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Errors Logged</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem', color: metrics.errorCount > 0 ? '#dc3545' : '#28a745' }}>
<div className="obs-summary-card">
<div className="obs-card-label">Errors Logged</div>
<div className={`obs-card-value ${metrics.errorCount > 0 ? 'is-danger' : 'is-ok'}`}>
{metrics.errorCount}
</div>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '4px', backgroundColor: '#f9f9f9' }}>
<div style={{ fontSize: '0.9rem', color: '#666' }}>Unique Errors</div>
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', marginTop: '0.5rem' }}>
<div className="obs-summary-card">
<div className="obs-card-label">Unique Errors</div>
<div className="obs-card-value">
{metrics.uniqueErrors}
</div>
</div>
</div>
<h3>API Endpoint Performance</h3>
<div style={{ overflowX: 'auto', marginBottom: '2rem' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div className="obs-table-wrap obs-spacing-bottom">
<table className="obs-table">
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Endpoint</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Requests</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Success Rate</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Avg Response</th>
<th style={{ textAlign: 'right', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Min/Max</th>
<tr>
<th>Endpoint</th>
<th className="obs-right">Requests</th>
<th className="obs-right">Success Rate</th>
<th className="obs-right">Avg Response</th>
<th className="obs-right">Min/Max</th>
</tr>
</thead>
<tbody>
{metrics.apiStats.map((stat, i) => (
<tr key={i} style={{ borderBottom: '1px solid #ddd' }}>
<td style={{ padding: '0.75rem' }}>
<tr key={i}>
<td>
<code>{stat.method} {stat.endpoint}</code>
</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.totalRequests}</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
<span style={{
padding: '0.25rem 0.5rem',
borderRadius: '3px',
backgroundColor: stat.successRate >= 95 ? '#d4edda' : '#fff3cd',
color: stat.successRate >= 95 ? '#155724' : '#856404'
}}>
<td className="obs-right">{stat.totalRequests}</td>
<td className="obs-right">
<span className={`obs-rate-pill ${stat.successRate >= 95 ? 'is-good' : 'is-warn'}`}>
{stat.successRate}%
</span>
</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>{stat.avgResponseTime}ms</td>
<td style={{ textAlign: 'right', padding: '0.75rem' }}>
<td className="obs-right">{stat.avgResponseTime}ms</td>
<td className="obs-right">
{stat.minResponseTime}ms / {stat.maxResponseTime}ms
</td>
</tr>
@@ -168,27 +140,27 @@ function Observability() {
{activeTab === 'errors' && (
<div>
{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' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div className="obs-table-wrap">
<table className="obs-table">
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Error Message</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Context</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Occurred At</th>
<tr>
<th>Error Message</th>
<th>Context</th>
<th>Occurred At</th>
</tr>
</thead>
<tbody>
{errors.map((error, i) => (
<tr key={i} style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white' }}>
<td style={{ padding: '0.75rem' }}>
<code style={{ fontSize: '0.85rem', color: '#d9534f' }}>{error.error_message}</code>
<tr key={i} className={i % 2 ? 'obs-zebra' : ''}>
<td>
<code className="obs-error-code">{error.error_message}</code>
</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) : '-'}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
<td className="obs-small">
{formatLocalTime(error.occurred_at)}
</td>
</tr>
@@ -204,84 +176,84 @@ function Observability() {
{activeTab === 'audit' && (
<div>
{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>
{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}
</p>
))}
</div>
)}
<div style={{ marginBottom: '1rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.5rem' }}>
<div className="obs-filters-grid">
<input
type="text"
placeholder="Entity Type"
value={filters.entityType}
onChange={(e) => setFilters({ ...filters, entityType: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
className="obs-filter-input"
/>
<input
type="text"
placeholder="Entity ID"
value={filters.entityId}
onChange={(e) => setFilters({ ...filters, entityId: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
className="obs-filter-input"
/>
<input
type="text"
placeholder="User ID"
value={filters.userId}
onChange={(e) => setFilters({ ...filters, userId: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
className="obs-filter-input"
/>
<input
type="text"
placeholder="Action"
value={filters.action}
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }}
className="obs-filter-input"
/>
</div>
{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' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div className="obs-table-wrap">
<table className="obs-table">
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Action</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Entity</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>User</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Changes</th>
<th style={{ textAlign: 'left', padding: '0.75rem', backgroundColor: '#f5f5f5' }}>Time</th>
<tr>
<th>Action</th>
<th>Entity</th>
<th>User</th>
<th>Changes</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{auditLogs.map((log, i) => (
<React.Fragment key={i}>
<tr style={{ borderBottom: '1px solid #ddd', backgroundColor: i % 2 ? '#f9f9f9' : 'white', cursor: 'pointer' }} onClick={() => setExpandedAuditRow(expandedAuditRow === i ? null : i)}>
<td style={{ padding: '0.75rem' }}><strong>{log.action}</strong></td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem', color: '#666' }}>
<tr className={`obs-click-row ${i % 2 ? 'obs-zebra' : ''}`} onClick={() => setExpandedAuditRow(expandedAuditRow === i ? null : i)}>
<td><strong>{log.action}</strong></td>
<td className="obs-muted-sm">
{log.entity_type} #{log.entity_id}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>{log.user_id}</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
<code style={{ cursor: 'pointer', color: '#007bff' }}>
<td className="obs-small">{log.user_id}</td>
<td className="obs-small">
<code className="obs-toggle-code">
{expandedAuditRow === i ? '▼ Hide' : '▶ Show'} Changes
</code>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.85rem' }}>
<td className="obs-small">
{formatLocalTime(log.created_at)}
</td>
</tr>
{expandedAuditRow === i && (
<tr style={{ backgroundColor: '#f0f8ff', borderBottom: '1px solid #ddd' }}>
<td colSpan="5" style={{ padding: '1rem' }}>
<tr className="obs-expanded-row">
<td colSpan="5" className="obs-expanded-cell">
<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)}
</pre>
</td>
+29 -33
View File
@@ -2,10 +2,6 @@
max-width: 1200px;
margin: 0 auto;
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 {
@@ -13,19 +9,19 @@
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
border-bottom: 2px solid #eee;
border-bottom: 1px solid #dbe2ea;
padding-bottom: 1.5rem;
}
.status-header h1 {
margin: 0 0 0.5rem 0;
font-size: 2.5rem;
color: #333;
font-size: 2.2rem;
color: #0f172a;
}
.status-subtitle {
margin: 0;
color: #666;
color: #64748b;
font-size: 1rem;
}
@@ -48,7 +44,7 @@
.last-updated {
margin: 0;
font-size: 0.85rem;
color: #666;
color: #64748b;
}
.status-summary {
@@ -59,12 +55,12 @@
}
.summary-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #dbe2ea;
border-radius: 14px;
padding: 1.5rem;
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 {
@@ -75,7 +71,7 @@
.summary-label {
font-size: 0.9rem;
color: #666;
color: #64748b;
margin: 0;
}
@@ -85,9 +81,9 @@
.status-section h2 {
font-size: 1.5rem;
color: #333;
color: #1e293b;
margin: 0 0 1rem 0;
border-bottom: 2px solid #f0f0f0;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.5rem;
}
@@ -98,30 +94,30 @@
}
.status-card {
border: 2px solid #ddd;
border-radius: 8px;
border: 1px solid #dbe2ea;
border-radius: 14px;
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;
}
.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 {
border-color: #4caf50;
background-color: #f1f8f6;
border-color: #86efac;
background: #f0fdf4;
}
.status-card-down {
border-color: #f44336;
background-color: #fdeaea;
border-color: #fca5a5;
background: #fef2f2;
}
.status-card-maintenance {
border-color: #ff9800;
background-color: #fff3e0;
border-color: #fcd34d;
background: #fffbeb;
}
.status-card-header {
@@ -134,7 +130,7 @@
.status-card h3 {
margin: 0;
font-size: 1.2rem;
color: #333;
color: #0f172a;
flex: 1;
}
@@ -249,15 +245,15 @@
width: 100%;
border-collapse: collapse;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
border: 1px solid #dbe2ea;
border-radius: 12px;
overflow: hidden;
}
.maintenance-table thead,
.incidents-table thead {
background-color: #f5f5f5;
border-bottom: 2px solid #ddd;
background-color: #f1f5f9;
border-bottom: 2px solid #dbe2ea;
}
.maintenance-table th,
@@ -271,12 +267,12 @@
.maintenance-table td,
.incidents-table td {
padding: 1rem;
border-bottom: 1px solid #eee;
border-bottom: 1px solid #e5e7eb;
}
.maintenance-table tr:hover,
.incidents-table tr:hover {
background-color: #f9f9f9;
background-color: #f0f9ff;
}
.incident-desc-cell {
+3 -4
View File
@@ -1,11 +1,10 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: 'Manrope', 'IBM Plex Sans', 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
color: #0f172a;
background-color: #f8fafc;
}
* {
+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.