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": { "files": {
"main.css": "/static/css/main.4f505a72.css", "main.css": "/static/css/main.69110889.css",
"main.js": "/static/js/main.29f97b36.js", "main.js": "/static/js/main.820bab7f.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.69110889.css.map": "/static/css/main.69110889.css.map",
"main.29f97b36.js.map": "/static/js/main.29f97b36.js.map", "main.820bab7f.js.map": "/static/js/main.820bab7f.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.69110889.css",
"static/js/main.29f97b36.js" "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 */ /*! 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 (()=>{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.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 [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,7 @@ function Dashboard() {
response_time_threshold: null, response_time_threshold: null,
}); });
const [tagFilter, setTagFilter] = useState(''); const [tagFilter, setTagFilter] = useState('');
const [groupFilter, setGroupFilter] = useState('all');
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);
@@ -279,121 +278,114 @@ 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) => { // Filter resources by tag/group and sort for stable scanning
if (!groupResources || groupResources.length === 0) return { uptime: 0, avgResponse: 0, total: 0 }; const filteredResources = resources
const uptime = (groupResources.reduce((sum, r) => sum + parseFloat(r.uptime || 0), 0) / groupResources.length).toFixed(2); .filter((r) => {
const avgResponse = (groupResources.reduce((sum, r) => sum + parseInt(r.avgResponseTime || 0), 0) / groupResources.length).toFixed(0); if (tagFilter) {
return { uptime, avgResponse, total: groupResources.length }; 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) => ({ 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;
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</p>
{resource.avgResponseTime}ms
</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" 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"> <p className="last-check">
Last: {formatLocalTime(resource.lastCheck)} Last: {resource.lastCheck ? formatLocalTime(resource.lastCheck) : 'Never'}
</p> </p>
)} </div>
</div> </div>
); );
}; };
@@ -402,20 +394,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 +406,32 @@ 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>
<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 +443,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 +455,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 +470,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,12 +481,39 @@ 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="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>
@@ -513,51 +522,66 @@ function Dashboard() {
) : filteredResources.length === 0 ? ( ) : filteredResources.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">{filteredResources.length} monitors</div>
<h3 style={{ margin: 0, flex: 1 }}>{group.name}</h3> <div className="resource-list">
<div className="group-stats"> {filteredResources.map(renderResourceRow)}
<span className="group-stat">{groupStats.total} resources</span> </div>
<span className="group-stat">{groupStats.uptime}% up</span> </section>
<span className="group-stat">{groupStats.avgResponse}ms avg</span>
</div> <aside className="cc-rail">
</div> <div className="cc-panel">
{!isCollapsed && ( <h3>Active Incidents</h3>
<div className="dashboard-grid"> {activeIncidents.length === 0 ? (
{groupResourcesMap[group.id]?.map(renderResourceCard)} <p className="cc-empty">No active incidents</p>
</div> ) : (
)} <div className="cc-incident-list">
</div> {activeIncidents.map((incident) => (
); <button
})} key={incident.id}
{groupResourcesMap['ungrouped']?.length > 0 && ( className="cc-incident-item"
<div className="group-section"> onClick={() => navigate(`/resource/${incident.id}`)}
<div className="group-header" onClick={() => toggleGroup('ungrouped')}> >
<span className="group-toggle">{collapsedGroups['ungrouped'] ? '▶' : '▼'}</span> <span className="cc-incident-name">{incident.name}</span>
<h3 style={{ margin: 0, flex: 1 }}>Ungrouped</h3> <span className={`status-badge status-${incident.status}`}>{incident.status}</span>
<div className="group-stats"> </button>
<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> </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 +1296,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 +1319,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 +1353,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>
@@ -1375,100 +1399,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 +1507,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 +1529,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 +1540,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 +1581,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 +1701,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>
+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 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>
+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: #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.