Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcfd9e9b65 | ||
|
|
b13a8c343f | ||
|
|
42f224ab93 | ||
|
|
0b4797f38c | ||
|
|
b710f68d21 | ||
|
|
0f93d16874 | ||
|
|
f119168399 | ||
|
|
59078362da | ||
|
|
3a3d32ecac | ||
|
|
dd85a79d55 | ||
|
|
98e0949d08 | ||
|
|
5e98e9aa05 | ||
|
|
279cba5115 | ||
|
|
d46a37a219 | ||
|
|
8d50d3cb61 | ||
|
|
de4619992e | ||
|
|
50f4f73af1 | ||
|
|
a8fca2d1a1 | ||
|
|
b83a19e9cf | ||
|
|
5cfea703fb | ||
|
|
4fea0a95f5 | ||
|
|
a6cf6a9762 | ||
|
|
34986b5760 | ||
|
|
85368ba0dc | ||
|
|
0475bf5aa5 |
No files matched your search
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "SkyWatch",
|
||||
"runtimeExecutable": "node",
|
||||
"runtimeArgs": ["server/index.js"],
|
||||
"port": 3001,
|
||||
"url": "http://localhost:3001"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"files": {
|
||||
"main.css": "/static/css/main.69110889.css",
|
||||
"main.js": "/static/js/main.820bab7f.js",
|
||||
"main.css": "/static/css/main.d4111563.css",
|
||||
"main.js": "/static/js/main.ee340b3d.js",
|
||||
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
|
||||
"index.html": "/index.html",
|
||||
"main.69110889.css.map": "/static/css/main.69110889.css.map",
|
||||
"main.820bab7f.js.map": "/static/js/main.820bab7f.js.map",
|
||||
"main.d4111563.css.map": "/static/css/main.d4111563.css.map",
|
||||
"main.ee340b3d.js.map": "/static/js/main.ee340b3d.js.map",
|
||||
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/css/main.69110889.css",
|
||||
"static/js/main.820bab7f.js"
|
||||
"static/css/main.d4111563.css",
|
||||
"static/js/main.ee340b3d.js"
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.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>
|
||||
<!doctype html><html lang="en" data-theme="dark"><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.ee340b3d.js"></script><link href="/static/css/main.d4111563.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:#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*/
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"static/css/main.69110889.css","mappings":"AAAA,KAGE,kCAAmC,CACnC,iCAAkC,CAElC,wBAAyB,CADzB,aAAc,CAHd,qDAA+D,CAD/D,QAMF,CAEA,EACE,qBACF,CAEA,KACE,uEAEF,CChBA,KAEE,8HAGS,CAJT,gBAKF,CAEA,QAEE,yBAA0B,CAC1B,iCAAkC,CAFlC,oBAAqC,CAKrC,+BAAgC,CAChC,+BAA6C,CAF7C,aAAc,CADd,mBAAqB,CAIrB,uBAAgB,CAAhB,eAAgB,CAChB,KAAM,CACN,UACF,CAEA,WAEE,gBAAiB,CACjB,qBAAuB,CAFvB,QAGF,CAEA,cAIE,QAAS,CADT,6BAA8B,CAE9B,UACF,CAEA,2BANE,kBAAmB,CADnB,YAYF,CALA,aAGE,UAAW,CACX,WACF,CAEA,cAGE,kBAAmB,CAFnB,YAAa,CAGb,cAAe,CAFf,SAGF,CAEA,UAME,iBAAkB,CALlB,aAAc,CAGd,gBAAkB,CADlB,eAAgB,CAEhB,mBAAsB,CAHtB,oBAAqB,CAKrB,kDACF,CAEA,gBACE,eAAmB,CAEnB,kCAAmC,CADnC,aAEF,CAEA,eAEE,kBAAmB,CADnB,mBAAoB,CAEpB,SACF,CAEA,aACE,kBAAmB,CAGnB,mBAAoB,CAFpB,aAAc,CAGd,gBAAkB,CAClB,eAAgB,CAHhB,oBAIF,CAEA,oBAEE,kBAAmB,CAInB,eAAmB,CACnB,wBAAyB,CACzB,mBAAoB,CAHpB,aAAc,CAJd,YAAa,CAGb,gBAAkB,CADlB,UAAY,CAMZ,mBACF,CAEA,WAEE,aAAc,CADd,gBAAiB,CAEjB,YACF,CAEA,mBAGE,kBAAmB,CAFnB,YAAa,CAGb,QAAS,CAFT,6BAA8B,CAG9B,kBACF,CAEA,iBAEE,iBAAkB,CAClB,qBAAuB,CAFvB,QAGF,CAEA,oBAEE,aAAc,CACd,eAAiB,CAFjB,gBAGF,CAEA,mBAIE,kBAAmB,CAHnB,YAAa,CAEb,cAAe,CADf,SAGF,CAEA,kBAKE,oBAAoC,CAFpC,wBAAyB,CADzB,kBAAmB,CAEnB,eAAgB,CAHhB,oBAKF,CAEA,sBACE,iBACF,CAEA,cAIE,eAAmB,CACnB,wBAAyB,CACzB,iBAAkB,CAClB,+BAA6C,CAC7C,eAAgB,CAChB,gBAAkB,CARlB,iBAAkB,CAClB,OAAQ,CACR,QAAS,CAOT,UACF,CAEA,sBAEE,QAAS,CACT,4BAA6B,CAF7B,eAGF,CAEA,mBACE,YACF,CAEA,aACE,cAAe,CAEf,UAAW,CADX,QAAS,CAET,YACF,CAEA,YAME,sBAA6B,CAH7B,iBAAkB,CAElB,+BAA6C,CAJ7C,kBAAmB,CAGnB,eAAgB,CAFhB,iBAKF,CAEA,aACE,kBAAmB,CAEnB,oBAAqB,CADrB,aAEF,CAEA,eACE,kBAAmB,CAEnB,oBAAqB,CADrB,aAEF,CAEA,YACE,kBAAmB,CAEnB,oBAAqB,CADrB,aAEF,CAEA,iBAGE,eAAY,CAFZ,YAAa,CAEb,UAAY,CADZ,6CAAgD,CAEhD,kBACF,CAEA,gBACE,sDAA2F,CAC3F,wBAAyB,CACzB,kBAAmB,CAEnB,aAAc,CADd,cAEF,CAEA,iBAKE,aAAc,CAHd,gBAAkB,CAElB,oBAAsB,CAHtB,QAAS,CAET,wBAGF,CAEA,iBAEE,gBAAiB,CACjB,eAAgB,CAFhB,iBAGF,CAEA,WACE,oBACF,CAEA,aACE,oBACF,CAEA,cACE,oBACF,CAEA,WAGE,cAAW,CACX,iBAAkB,CAHlB,YAAa,CAEb,SAAW,CADX,yCAGF,CAEA,SACE,WACF,CAEA,SACE,YAAa,CACb,qBAAsB,CACtB,UAAY,CACZ,uBAAgB,CAAhB,eAAgB,CAChB,QACF,CAEA,UACE,oBAAkC,CAElC,wBAAyB,CACzB,kBAAmB,CAFnB,aAAc,CAGd,cACF,CAEA,aAEE,eAAiB,CACjB,oBAAsB,CAFtB,iBAGF,CAEA,UAEE,aAAc,CACd,gBAAkB,CAFlB,QAGF,CAEA,iCAEE,YAAa,CACb,qBAAsB,CACtB,SACF,CAEA,iCAUE,kBAAmB,CAPnB,kBAAmB,CACnB,wBAAyB,CAEzB,iBAAkB,CADlB,aAAc,CAMd,cAAe,CAHf,YAAa,CACb,6BAA8B,CAF9B,qBAAwB,CALxB,UAUF,CAEA,6CAEE,kBAAmB,CACnB,oBACF,CAEA,kBACE,gBAAkB,CAClB,eAAgB,CAIhB,kBAAoB,CAFpB,eAAgB,CAChB,sBAAuB,CAFvB,kBAIF,CAEA,gBAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,yDAA4D,CAE5D,oBACF,CAEA,eACE,YAAa,CACb,qBAAsB,CACtB,UACF,CAEA,qBACE,oBAAqC,CACrC,wBAAyB,CACzB,kBAAmB,CACnB,cACF,CAEA,sBAGE,aAAS,CAMT,+BAAgC,CADhC,aAAc,CAPd,YAAa,CAIb,gBAAkB,CAFlB,QAAS,CADT,+EAAoF,CAKpF,oBAAsB,CAGtB,oBAAsB,CANtB,0BAA8B,CAE9B,wBAKF,CAEA,qBAEE,aAAc,CADd,gBAAkB,CAElB,qBACF,CAEA,cAIE,aAAS,CADT,kBAAmB,CAGnB,oBAAqC,CACrC,wBAAyB,CACzB,kBAAmB,CACnB,+BAA6C,CAC7C,cAAe,CATf,YAAa,CAGb,QAAS,CAFT,+EAAoF,CAGpF,oBAAuB,CAMvB,wDACF,CAEA,oBAEE,oBAAqB,CACrB,+BAA4C,CAF5C,0BAGF,CAEA,UACE,WACF,CAEA,gBAEE,kBAAmB,CADnB,YAAa,CAGb,cAAe,CADf,SAAW,CAEX,oBACF,CAEA,UAEE,gBAAkB,CADlB,QAEF,CAEA,qBAGE,kBAAmB,CADnB,mBAAoB,CAEpB,aAAc,CACd,gBAAkB,CAClB,eAAgB,CALhB,oBAMF,CAEA,iBAGE,kBAAmB,CACnB,wBAAyB,CAEzB,mBAAoB,CAJpB,aAKF,CAEA,qCARE,gBAAkB,CAIlB,qBAWF,CAPA,oBAGE,kBAAmB,CACnB,wBAAyB,CAEzB,mBAAoB,CAJpB,aAKF,CAEA,mBAEE,QACF,CAEA,SAIE,gBAAkB,CAFlB,eAAgB,CAChB,sBAAuB,CAFvB,kBAIF,CAEA,UACE,gBACF,CAEA,aAGE,cAAW,CACX,kBAAmB,CAHnB,YAAa,CAEb,SAAW,CADX,6CAGF,CAEA,yBACE,gBACF,CAEA,eACE,cACF,CAEA,UAGE,oBAAqB,CAFrB,YAAa,CACb,qBAAsB,CAEtB,SACF,CAEA,aACE,YAAa,CACb,UACF,CAEA,eACE,+CAA6D,CAM7D,wBAAyB,CALzB,kBAAmB,CAEnB,+BAA6C,CAE7C,cAAe,CAHf,mBAAoB,CAEpB,uCAGF,CAEA,uBACE,YAAa,CACb,qBAAsB,CACtB,SACF,CAEA,qBAEE,gCAA8C,CAD9C,0BAEF,CAEA,iBAGE,sBAAuB,CAFvB,YAAa,CACb,6BAA8B,CAE9B,mBACF,CAEA,eACE,iBAAkB,CAClB,eAAgB,CAChB,gBACF,CAEA,cACE,aAAc,CACd,eAAiB,CACjB,oBACF,CAEA,eAEE,aAAc,CACd,gBAAkB,CAFlB,iBAGF,CAEA,cAEE,kBAAmB,CACnB,eAAiB,CACjB,eAAgB,CAChB,wBACF,CAEA,WACE,kBAAmB,CACnB,aACF,CAEA,aACE,kBAAmB,CACnB,aACF,CAEA,gBACE,kBAAmB,CACnB,aACF,CAEA,YAGE,aAAS,CAFT,YAAa,CAEb,QAAS,CADT,6BAA8B,CAE9B,eACF,CAEA,MACE,iBACF,CAEA,YAGE,UAAW,CAFX,gBAAiB,CACjB,eAAgB,CAEhB,QACF,CAEA,kBACE,iBACF,CAEA,YAEE,UAAW,CADX,gBAAkB,CAGlB,QAAS,CADT,wBAEF,CAEA,eAIE,eAAY,CADZ,kBAAmB,CAFnB,YAAa,CAGb,UAAY,CAFZ,mCAGF,CAEA,WAEE,WAAY,CADZ,UAEF,CAEA,YAGE,UAAW,CADX,gBAAkB,CADlB,QAGF,CAEA,eAEE,oBAAoC,CACpC,wBAAyB,CACzB,kBAAmB,CAHnB,kBAAmB,CAInB,YACF,CAEA,cAEE,kBAAmB,CAGnB,eAAiB,CAIjB,wBAAyB,CAHzB,kBAAmB,CACnB,cAAe,CANf,YAAa,CAEb,QAAS,CAOT,kBAAmB,CANnB,YAAa,CAIb,yBAGF,CAEA,oBACE,kBACF,CAEA,iBAGE,UAAW,CAFX,gBAAiB,CACjB,QAEF,CAEA,YAEE,QAAO,CADP,QAEF,CAEA,cAEE,aAAc,CADd,eAAiB,CAEjB,cACF,CAEA,aACE,YAAa,CAGb,gBAAkB,CAFlB,UAAW,CACX,gBAEF,CAEA,YACE,kBAAmB,CACnB,wBAAyB,CAEzB,mBAAoB,CACpB,aAAc,CACd,eAAgB,CAHhB,mBAIF,CAEA,YAEE,wBAAyB,CACzB,iBAAkB,CAClB,iBAAkB,CAElB,mBAAqB,CALrB,aAAe,CAIf,UAEF,CAEA,gBAGE,yBAA0B,CAF1B,iBAAmB,CACnB,kBAEF,CAEA,YACE,mBACF,CAEA,kBAGE,UAAW,CAFX,aAAc,CACd,gBAAkB,CAGlB,eAAgB,CADhB,oBAEF,CAEA,kBAGE,qBAAsB,CACtB,iBAAkB,CACLine truncated
|
||||
@@ -0,0 +1,2 @@
|
||||
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#020617;color:#e2e8f0;font-family:Manrope,IBM Plex Sans,Segoe UI,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}@keyframes skeleton-pulse{0%,to{opacity:.4}50%{opacity:.8}}.skeleton{animation:skeleton-pulse 1.4s ease-in-out infinite;background:#1e293b;background:var(--surface-2);border-radius:4px}.skeleton-row{min-height:72px;pointer-events:none}.skeleton-name{height:14px;margin-bottom:8px;width:40%}.skeleton-url{height:11px;width:60%}.skeleton-badge{border-radius:12px;height:22px;width:56px}.action-delete-confirm{color:#ef4444!important}.discovery-scanning{align-items:center;color:#94a3b8;color:var(--text-muted);display:flex;gap:.75rem;padding:1rem 0}@keyframes spin{to{transform:rotate(1turn)}}.discovery-spinner{animation:spin .8s linear infinite;border:2px solid #334155;border-top-color:#14b8a6;border:2px solid var(--border);border-radius:50%;border-top-color:var(--accent);flex-shrink:0;height:18px;width:18px}.discovery-list{display:flex;flex-direction:column;gap:.5rem;max-height:360px;overflow-y:auto}.discovery-row{align-items:center;background:#1e293b;background:var(--surface-2);border:1px solid #334155;border:1px solid var(--border);border-radius:6px;display:flex;gap:1rem;justify-content:space-between;padding:.6rem .75rem}.discovery-info{display:flex;flex-direction:column;gap:2px;min-width:0}.discovery-ip{color:#e2e8f0;color:var(--text);font-size:.9rem;font-weight:600}.discovery-hostname{color:#94a3b8;color:var(--text-muted);font-size:.8rem}.discovery-url{color:#14b8a6;color:var(--accent);font-family:monospace;font-size:.8rem;word-break:break-all}.discovery-ports{color:#64748b;color:var(--text-dim);font-size:.75rem}.btn-sm{font-size:.82rem;padding:.3rem .7rem;white-space:nowrap}:root{--app-bg:#020617;--surface:#0f172a;--surface-2:#1e293b;--border:#334155;--text:#e2e8f0;--text-muted:#94a3b8;--text-dim:#64748b;--accent:#14b8a6}[data-theme=light]{--app-bg:#f8fafc;--surface:#fff;--surface-2:#f1f5f9;--border:#e2e8f0;--text:#1e293b;--text-muted:#475569;--text-dim:#94a3b8;--accent:#0d9488}[data-theme=light] .App{background:#020617;background:var(--app-bg)}[data-theme=light] .navbar{background:#ffffffeb;border-bottom:1px solid #334155;border-bottom:1px solid var(--border);color:#e2e8f0;color:var(--text)}[data-theme=light] .nav-link{color:#475569}[data-theme=light] .nav-link:hover{background:#f1f5f9;box-shadow:inset 0 0 0 1px #e2e8f0;color:#1e293b}[data-theme=light] .container,[data-theme=light] .detail-section,[data-theme=light] .resource-row{background:#0f172a;background:var(--surface);color:#e2e8f0;color:var(--text)}.App{background:radial-gradient(circle at 15% 0,#1e40af33 0,#0000 42%),radial-gradient(circle at 95% 10%,#0f766e24 0,#0000 36%),#020617;min-height:100vh}.navbar{backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);background:#0f172ad1;border-bottom:1px solid #334155;box-shadow:0 4px 18px #0f172a0f;color:#e2e8f0;padding:.85rem 2rem;position:-webkit-sticky;position:sticky;top:0;z-index:30}.navbar h1{font-size:1.5rem;letter-spacing:-.02em;margin:0}.navbar-inner{gap:1rem;justify-content:space-between;width:100%}.navbar-inner,.navbar-left{align-items:center;display:flex}.navbar-left{gap:1.5rem;min-width:0}.navbar-links{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.nav-link{border-radius:7px;color:#cbd5e1;font-size:.92rem;font-weight:600;padding:.4rem .7rem;text-decoration:none;transition:background .2s,color .2s,box-shadow .2s}.nav-link:hover{background:#111827;box-shadow:inset 0 0 0 1px #334155;color:#f8fafc}.settings-link{align-items:center;display:inline-flex;gap:.4rem}.setup-badge{background:#facc15;border-radius:999px;color:#111827;font-size:.68rem;font-weight:700;padding:.15rem .5rem}.notifications-pill{align-items:center;background:#111827;border:1px solid #334155;border-radius:999px;color:#e2e8f0;display:flex;font-size:.82rem;gap:.45rem;padding:.3rem .7rem}.container{margin:0 auto;max-width:1400px;padding:2rem}.dashboard-toolbar{align-items:center;display:flex;gap:1rem;justify-content:space-between;margin-bottom:2rem}.dashboard-title{color:#f8fafc;font-size:1.85rem;letter-spacing:-.02em;margin:0}.dashboard-subtitle{color:#94a3b8;font-size:.9rem;margin:.2rem 0 0}.dashboard-actions{align-items:center;display:flex;flex-wrap:wrap;gap:.5rem}.quick-filter-row{display:flex;flex-wrap:wrap;gap:.45rem;margin:.2rem 0 .8rem}.quick-chip{background:#0f172a;border:1px solid #334155;border-radius:999px;color:#cbd5e1;cursor:pointer;font-size:.74rem;font-weight:700;letter-spacing:.02em;padding:.28rem .7rem}.quick-chip:hover{background:#111f34}.quick-chip.active{background:#14b8a6;border-color:#14b8a6;color:#052e2b}.tag-filter-input{background:#111827;border:1px solid #334155;border-radius:10px;color:#e2e8f0;min-width:180px;padding:.5rem .75rem}.actions-menu-wrapper{position:relative}.actions-menu{background:#fff;border:1px solid #Line truncated
|
||||
/*# sourceMappingURL=main.d4111563.css.map*/
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"static/css/main.d4111563.css","mappings":"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 truncated
|
||||
@@ -1,3 +0,0 @@
|
||||
/*! 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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"static/js/main.820bab7f.js","mappings":"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 truncated
|
||||
@@ -0,0 +1,3 @@
|
||||
/*! For license information please see main.ee340b3d.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.ee340b3d.js.map
|
||||
File renamed without changes.
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"static/js/main.ee340b3d.js","mappings":"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 truncated
|
||||
Generated
-95
@@ -3390,36 +3390,6 @@
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom/node_modules/aria-query": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
||||
@@ -3490,13 +3460,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -6755,16 +6718,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
@@ -6879,13 +6832,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
||||
@@ -11406,16 +11352,6 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.25.9",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
|
||||
@@ -16198,23 +16134,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
@@ -16709,20 +16628,6 @@
|
||||
"is-typedarray": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "4.9.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
|
||||
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/app-icon.png" />
|
||||
|
||||
+470
-60
@@ -1,18 +1,121 @@
|
||||
/* ── Skeleton loading ── */
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
.skeleton {
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
animation: skeleton-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.skeleton-row { pointer-events: none; min-height: 72px; }
|
||||
.skeleton-name { width: 40%; height: 14px; margin-bottom: 8px; }
|
||||
.skeleton-url { width: 60%; height: 11px; }
|
||||
.skeleton-badge { width: 56px; height: 22px; border-radius: 12px; }
|
||||
|
||||
/* ── Inline delete confirm ── */
|
||||
.action-delete-confirm { color: #ef4444 !important; }
|
||||
|
||||
/* ── Network discovery modal ── */
|
||||
.discovery-scanning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.discovery-spinner {
|
||||
width: 18px; height: 18px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.discovery-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 360px; overflow-y: auto; }
|
||||
.discovery-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.discovery-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.discovery-ip { font-weight: 600; font-size: 0.9rem; color: var(--text); }
|
||||
.discovery-hostname { font-size: 0.8rem; color: var(--text-muted); }
|
||||
.discovery-url { font-size: 0.8rem; color: var(--accent); font-family: monospace; word-break: break-all; }
|
||||
.discovery-ports { font-size: 0.75rem; color: var(--text-dim); }
|
||||
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.82rem; white-space: nowrap; }
|
||||
|
||||
/* ── Theme variables ── */
|
||||
:root {
|
||||
--app-bg: #020617;
|
||||
--surface: #0f172a;
|
||||
--surface-2: #1e293b;
|
||||
--border: #334155;
|
||||
--text: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dim: #64748b;
|
||||
--accent: #14b8a6;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--app-bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f1f5f9;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
--text-muted: #475569;
|
||||
--text-dim: #94a3b8;
|
||||
--accent: #0d9488;
|
||||
}
|
||||
|
||||
[data-theme="light"] .App {
|
||||
background: var(--app-bg);
|
||||
}
|
||||
|
||||
[data-theme="light"] .navbar {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-link {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
[data-theme="light"] .nav-link:hover {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
box-shadow: inset 0 0 0 1px #e2e8f0;
|
||||
}
|
||||
|
||||
[data-theme="light"] .container,
|
||||
[data-theme="light"] .detail-section,
|
||||
[data-theme="light"] .resource-row {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.App {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 15% 0%, #dff7f2 0%, transparent 40%),
|
||||
radial-gradient(circle at 95% 10%, #e8eefc 0%, transparent 38%),
|
||||
#f8fafc;
|
||||
radial-gradient(circle at 15% 0%, rgba(30, 64, 175, 0.2) 0%, transparent 42%),
|
||||
radial-gradient(circle at 95% 10%, rgba(15, 118, 110, 0.14) 0%, transparent 36%),
|
||||
#020617;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
padding: 0.85rem 2rem;
|
||||
color: #0f172a;
|
||||
border-bottom: 1px solid #dbe2ea;
|
||||
color: #e2e8f0;
|
||||
border-bottom: 1px solid #334155;
|
||||
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.06);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -48,7 +151,7 @@
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #475569;
|
||||
color: #cbd5e1;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
@@ -58,9 +161,9 @@
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
box-shadow: inset 0 0 0 1px #dbe2ea;
|
||||
background: #111827;
|
||||
color: #f8fafc;
|
||||
box-shadow: inset 0 0 0 1px #334155;
|
||||
}
|
||||
|
||||
.settings-link {
|
||||
@@ -83,9 +186,9 @@
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.82rem;
|
||||
color: #334155;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dbe2ea;
|
||||
color: #e2e8f0;
|
||||
background: #111827;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
}
|
||||
@@ -108,11 +211,12 @@
|
||||
margin: 0;
|
||||
font-size: 1.85rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -123,12 +227,42 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quick-filter-row {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0.2rem 0 0.8rem;
|
||||
}
|
||||
|
||||
.quick-chip {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #cbd5e1;
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.7rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-chip:hover {
|
||||
background: #111f34;
|
||||
}
|
||||
|
||||
.quick-chip.active {
|
||||
background: #14b8a6;
|
||||
border-color: #14b8a6;
|
||||
color: #052e2b;
|
||||
}
|
||||
|
||||
.tag-filter-input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border: 1px solid #334155;
|
||||
min-width: 180px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: #111827;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.actions-menu-wrapper {
|
||||
@@ -279,7 +413,44 @@
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.cc-incident-item,
|
||||
.cc-incident-item {
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 0.2rem 0.35rem 0.2rem 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cc-incident-item:hover {
|
||||
background: #111f34;
|
||||
border-color: #475569;
|
||||
}
|
||||
|
||||
.cc-incident-nav {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
padding: 0.25rem 0.55rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cc-ack-btn {
|
||||
flex-shrink: 0;
|
||||
color: #4ade80;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.cc-group-item {
|
||||
width: 100%;
|
||||
background: #0f172a;
|
||||
@@ -293,7 +464,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cc-incident-item:hover,
|
||||
.cc-group-item:hover {
|
||||
background: #111f34;
|
||||
border-color: #475569;
|
||||
@@ -322,49 +492,60 @@
|
||||
}
|
||||
|
||||
.resource-list-shell {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid #cbd5e1;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 0.65rem;
|
||||
max-height: calc(100vh - 250px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.resource-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||
grid-template-columns: 28px minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||
gap: 1rem;
|
||||
padding: 0.35rem 0.6rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #64748b;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #94a3b8;
|
||||
border-bottom: 1px solid #334155;
|
||||
margin-bottom: 0.45rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background: rgba(15, 23, 42, 0.98);
|
||||
}
|
||||
|
||||
.resource-list-count {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
margin: 0 0.6rem 0.55rem;
|
||||
position: sticky;
|
||||
top: 31px;
|
||||
z-index: 4;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.resource-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||
grid-template-columns: 28px minmax(260px, 2fr) minmax(220px, 1.25fr) minmax(170px, 0.8fr);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.65rem 0.8rem;
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
border: 1px solid #dbe2ea;
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(2, 6, 23, 0.55);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.resource-row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #94a3b8;
|
||||
box-shadow: 0 9px 18px rgba(15, 23, 42, 0.1);
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 12px 22px rgba(2, 6, 23, 0.65);
|
||||
}
|
||||
|
||||
.row-main {
|
||||
@@ -382,13 +563,14 @@
|
||||
.row-name {
|
||||
margin: 0;
|
||||
font-size: 0.98rem;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.resource-group-pill {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -414,6 +596,7 @@
|
||||
.row-url,
|
||||
.row-type {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.row-url {
|
||||
@@ -436,6 +619,12 @@
|
||||
|
||||
.row-metrics .stat-label {
|
||||
font-size: 0.64rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.row-metrics .stat-value {
|
||||
color: #e2e8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row-sparkline {
|
||||
@@ -488,11 +677,40 @@
|
||||
margin: 0 0 0.2rem 0;
|
||||
}
|
||||
|
||||
.trend-chip {
|
||||
display: inline-block;
|
||||
margin-left: 0.3rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
.trend-better {
|
||||
color: #34d399;
|
||||
}
|
||||
.trend-worse {
|
||||
color: #f87171;
|
||||
}
|
||||
.trend-flat {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.resource-url {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.load-more-btn {
|
||||
width: 100%;
|
||||
border: 1px dashed #94a3b8;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.load-more-btn:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.resource-type {
|
||||
margin: 0.15rem 0 0;
|
||||
@@ -537,7 +755,7 @@
|
||||
.stat-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
color: #e2e8f0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -547,7 +765,7 @@
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -567,7 +785,7 @@
|
||||
.last-check {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.group-section {
|
||||
@@ -671,17 +889,48 @@
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
background: #0b1325;
|
||||
border: 1px solid #334155;
|
||||
color: #e2e8f0;
|
||||
min-width: 34px;
|
||||
min-height: 30px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
opacity: 1;
|
||||
transition: transform 0.15s, background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
background: #111f34;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.btn-icon:focus-visible {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-icon.action-edit {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.btn-icon.action-maint {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.btn-icon.action-delete {
|
||||
color: #fca5a5;
|
||||
border-color: #7f1d1d;
|
||||
background: #3f1d1d;
|
||||
}
|
||||
|
||||
.btn-icon.action-delete:hover {
|
||||
background: #7f1d1d;
|
||||
border-color: #ef4444;
|
||||
color: #fee2e2;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@@ -813,17 +1062,18 @@
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid #e2e8f0;
|
||||
background: rgba(15, 23, 42, 0.94);
|
||||
border: 1px solid #334155;
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
box-shadow: 0 10px 24px rgba(2, 6, 23, 0.55);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-section h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.detail-header-row {
|
||||
@@ -841,7 +1091,7 @@
|
||||
}
|
||||
|
||||
.resource-url-detail {
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -875,7 +1125,15 @@
|
||||
|
||||
.filter-control label {
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.filter-control select {
|
||||
background: #111827;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
.controls-pagination {
|
||||
@@ -896,9 +1154,9 @@
|
||||
.data-table th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #dbe2ea;
|
||||
color: #1e293b;
|
||||
background: #f1f5f9;
|
||||
border-bottom: 2px solid #334155;
|
||||
color: #e2e8f0;
|
||||
background: #111827;
|
||||
font-size: 0.86rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -906,11 +1164,12 @@
|
||||
|
||||
.data-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #f0f9ff;
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
@@ -918,7 +1177,7 @@
|
||||
}
|
||||
|
||||
.muted-cell {
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.w-25 {
|
||||
@@ -942,14 +1201,14 @@
|
||||
|
||||
.incident-reason-toggle {
|
||||
cursor: pointer;
|
||||
color: #0f766e;
|
||||
color: #93c5fd;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.expand-indicator {
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.btn-compact {
|
||||
@@ -958,7 +1217,7 @@
|
||||
}
|
||||
|
||||
.expanded-row {
|
||||
background: #f8fafc;
|
||||
background: #0b1325;
|
||||
}
|
||||
|
||||
.expanded-cell {
|
||||
@@ -966,7 +1225,7 @@
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
color: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.section-title-row {
|
||||
@@ -978,7 +1237,7 @@
|
||||
}
|
||||
|
||||
.empty-copy {
|
||||
color: #64748b;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
@@ -986,6 +1245,10 @@
|
||||
.chart-container {
|
||||
margin-top: 1rem;
|
||||
height: 300px;
|
||||
background: #0b1325;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.incident-badge {
|
||||
@@ -1002,15 +1265,16 @@
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
background: #1e293b;
|
||||
color: #f8fafc;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #334155;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: #5a6268;
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -1111,6 +1375,16 @@
|
||||
position: static;
|
||||
}
|
||||
|
||||
.resource-list-shell {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.resource-list-count,
|
||||
.resource-list-header {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.resource-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.7rem;
|
||||
@@ -1120,6 +1394,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-metrics {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
@@ -1259,3 +1537,135 @@
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bulk action bar ── */
|
||||
.bulk-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Row checkbox ── */
|
||||
.row-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-checkbox input[type="checkbox"],
|
||||
.header-select-all input[type="checkbox"] {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
accent-color: #14b8a6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-select-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.resource-row-selected {
|
||||
border-color: #14b8a6 !important;
|
||||
background: rgba(20, 184, 166, 0.06) !important;
|
||||
}
|
||||
|
||||
/* ── Status sparkline bars ── */
|
||||
.status-spark-bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: stretch;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spark-bar {
|
||||
flex: 1;
|
||||
min-width: 3px;
|
||||
border-radius: 1px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.spark-bar:hover { opacity: 0.65; }
|
||||
.spark-bar-up { background: #14b8a6; }
|
||||
.spark-bar-down { background: #ef4444; }
|
||||
.spark-bar-unknown { background: #475569; }
|
||||
|
||||
/* ── Chart time-range buttons ── */
|
||||
.chart-range-btns {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.chart-range-btn {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
color: #94a3b8;
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.chart-range-btn:hover {
|
||||
border-color: #14b8a6;
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.chart-range-btn.active {
|
||||
background: #14b8a6;
|
||||
border-color: #14b8a6;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Check row expansion ── */
|
||||
.check-row-expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-row-expandable:hover {
|
||||
background: rgba(20, 184, 166, 0.05);
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: #94a3b8;
|
||||
background: #0a0f1a;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Section title row ── */
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-title-row h2 {
|
||||
margin: 0;
|
||||
}
|
||||
+8
-1675
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,290 @@
|
||||
.cc-page {
|
||||
padding-top: 1.4rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.cc-alert {
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #854d0e;
|
||||
background: #2b1907;
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.cc-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cc-page-title {
|
||||
margin: 0;
|
||||
font-size: 1.65rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fafc !important;
|
||||
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.cc-page h2 {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.cc-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cc-pill-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 999px;
|
||||
background: #111827;
|
||||
color: #f1f5f9;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.cc-pill-toggle input {
|
||||
accent-color: #3b82f6;
|
||||
}
|
||||
|
||||
.cc-pill-toggle:focus-within,
|
||||
.cc-input-sm:focus,
|
||||
.cc-select-sm:focus {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cc-surface {
|
||||
background: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.cc-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.cc-kpi {
|
||||
background: #0b1325;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.cc-kpi-label {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cc-kpi-value {
|
||||
margin: 0.35rem 0 0;
|
||||
color: #f8fafc;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cc-table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
background: #0b1325;
|
||||
}
|
||||
|
||||
.cc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.cc-table thead tr {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.cc-table th {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.cc-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.cc-table tbody tr:hover {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.cc-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #111827;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.cc-input-sm {
|
||||
width: 56px;
|
||||
padding: 0.45rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
background: #111827;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.cc-select-sm {
|
||||
padding: 0.45rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
background: #111827;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.cc-empty {
|
||||
text-align: center;
|
||||
color: #cbd5e1;
|
||||
padding: 2.2rem 1rem;
|
||||
}
|
||||
|
||||
.history-resource-card {
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
.history-resource-title {
|
||||
margin: 0 0 0.45rem;
|
||||
color: #dbeafe;
|
||||
}
|
||||
|
||||
.history-resource-meta {
|
||||
margin: 0 0 1rem;
|
||||
color: #e2e8f0;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.history-resource-meta strong {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.history-chart-box {
|
||||
height: 400px;
|
||||
margin-bottom: 1rem;
|
||||
background: #111827;
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.history-trend-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.8rem;
|
||||
color: #e2e8f0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notifications-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #334155;
|
||||
background: #0b1325;
|
||||
}
|
||||
|
||||
.notification-row.is-unread {
|
||||
border-color: #1d4ed8;
|
||||
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
margin: 0 0 0.4rem;
|
||||
color: #f8fafc;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.notification-chip {
|
||||
background: #111827;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-actions .btn:focus-visible {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cc-controls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cc-controls .btn,
|
||||
.cc-controls .tag-filter-input,
|
||||
.cc-controls .cc-pill-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cc-pagination {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+55
-59
@@ -12,6 +12,7 @@ import {
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import './App.css';
|
||||
import './CommandCenterPages.css';
|
||||
import { formatChartTime } from './utils/timeUtils';
|
||||
|
||||
const asNumberOrZero = (value) => {
|
||||
@@ -107,23 +108,16 @@ function History() {
|
||||
if (error) return <div className="container">Error loading history: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="container cc-page">
|
||||
{limitedByRetention && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffc107',
|
||||
borderRadius: '4px',
|
||||
color: '#856404'
|
||||
}}>
|
||||
<div className="cc-alert">
|
||||
⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<h2>Monitoring History</h2>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<div className="cc-page-header">
|
||||
<h2 className="cc-page-title" style={{ color: '#f8fafc', fontWeight: 800 }}>Monitoring History</h2>
|
||||
<div className="cc-controls">
|
||||
<div className="cc-controls">
|
||||
{[7, 14, 30].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
@@ -136,21 +130,19 @@ function History() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
|
||||
<label className="cc-pill-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAveraged}
|
||||
onChange={(e) => setShowAveraged(e.target.checked)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span>Show Averages</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}>
|
||||
<label className="cc-pill-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showTrends}
|
||||
onChange={(e) => setShowTrends(e.target.checked)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
<span>Show Week-over-Week Trends</span>
|
||||
</label>
|
||||
@@ -169,41 +161,43 @@ function History() {
|
||||
const chartData = prepareChartData(resource.checks || []);
|
||||
|
||||
return (
|
||||
<div key={resource.id} className="detail-section" style={{ marginBottom: '3rem', background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem', color: '#667eea', fontSize: '1.5rem' }}>{resource.name}</h3>
|
||||
<p style={{ color: '#666', marginBottom: '1.5rem', fontSize: '0.95rem' }}>
|
||||
<span style={{ marginRight: '1.5rem' }}>Type: <strong style={{ color: '#333' }}>{resource.type.toUpperCase()}</strong></span>
|
||||
<span style={{ marginRight: '1.5rem' }}>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336' }}>{resource.uptime}%</strong></span>
|
||||
<span>Avg Response: <strong style={{ color: '#333' }}>{resource.avgResponseTime}ms</strong></span>
|
||||
<div key={resource.id} className="detail-section cc-surface history-resource-card">
|
||||
<h3 className="history-resource-title">{resource.name}</h3>
|
||||
<p className="history-resource-meta">
|
||||
<span>Type: <strong>{resource.type.toUpperCase()}</strong></span>
|
||||
<span>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</strong></span>
|
||||
<span>Avg Response: <strong>{resource.avgResponseTime}ms</strong></span>
|
||||
</p>
|
||||
|
||||
{chartData.length > 0 ? (
|
||||
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
|
||||
<div className="chart-container history-chart-box">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 12, fill: '#666' }}
|
||||
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
|
||||
tick={{ fontSize: 12, fill: '#666' }}
|
||||
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
|
||||
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
domain={[0, 1]}
|
||||
ticks={[0, 1]}
|
||||
tick={{ fontSize: 12, fill: '#666' }}
|
||||
label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#666' } }}
|
||||
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||
label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#94a3b8' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
|
||||
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
|
||||
labelStyle={{ color: '#cbd5e1' }}
|
||||
itemStyle={{ color: '#e2e8f0' }}
|
||||
formatter={(value, name) => {
|
||||
if (name === 'Response Time') return `${value}ms`;
|
||||
if (name === 'Status') return value === 1 ? '✓ UP' : '✗ DOWN';
|
||||
@@ -249,18 +243,18 @@ function History() {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: '#999', padding: '2rem', textAlign: 'center', background: '#fafafa', borderRadius: '6px' }}>No check data available</div>
|
||||
<div className="cc-empty">No check data available</div>
|
||||
)}
|
||||
|
||||
{showTrends && resource.trendsData && (
|
||||
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}>
|
||||
<h4 style={{ marginBottom: '1rem', color: '#667eea' }}>Week-over-Week Comparison</h4>
|
||||
<div className="chart-container history-chart-box">
|
||||
<h4 className="history-resource-title">Week-over-Week Comparison</h4>
|
||||
{(() => {
|
||||
const comparison = resource.trendsData?.comparison || {};
|
||||
const responseTimeChange = asNumberOrZero(comparison.response_time_change);
|
||||
const uptimeChange = asNumberOrZero(comparison.uptime_change);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.9rem' }}>
|
||||
<div className="history-trend-row">
|
||||
<div>
|
||||
<strong>Response Time Change:</strong>{' '}
|
||||
<span style={{ color: responseTimeChange >= 0 ? '#f44336' : '#4caf50' }}>
|
||||
@@ -280,20 +274,22 @@ function History() {
|
||||
})()}
|
||||
<ResponsiveContainer width="100%" height="90%">
|
||||
<ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#666' }}
|
||||
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }}
|
||||
tick={{ fontSize: 12, fill: '#666' }}
|
||||
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
|
||||
tick={{ fontSize: 12, fill: '#94a3b8' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }}
|
||||
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
|
||||
labelStyle={{ color: '#cbd5e1' }}
|
||||
itemStyle={{ color: '#e2e8f0' }}
|
||||
formatter={(value, name) => {
|
||||
if (name === 'Current Period') return `${value}ms`;
|
||||
if (name === 'Previous Period') return `${value}ms`;
|
||||
@@ -327,22 +323,22 @@ function History() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.checks?.length || 0}</p>
|
||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Total Checks</p>
|
||||
<div className="cc-kpi-grid">
|
||||
<div className="cc-kpi">
|
||||
<p className="cc-kpi-label">Total Checks</p>
|
||||
<p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{resource.checks?.length || 0}</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
||||
<p className="stat-value" style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336', fontSize: '2rem', fontWeight: 'bold' }}>{resource.uptime}%</p>
|
||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Uptime</p>
|
||||
<div className="cc-kpi">
|
||||
<p className="cc-kpi-label">Uptime</p>
|
||||
<p className="cc-kpi-value" style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.avgResponseTime}ms</p>
|
||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Avg Response</p>
|
||||
<div className="cc-kpi">
|
||||
<p className="cc-kpi-label">Avg Response</p>
|
||||
<p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{resource.avgResponseTime}ms</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}>
|
||||
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2rem', fontWeight: 'bold' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p>
|
||||
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Successful Checks</p>
|
||||
<div className="cc-kpi">
|
||||
<p className="cc-kpi-label">Successful Checks</p>
|
||||
<p className="cc-kpi-value" style={{ color: '#34d399' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,7 +346,7 @@ function History() {
|
||||
})}
|
||||
|
||||
{totalItems > pageSize && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<div className="cc-pagination">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
@@ -358,8 +354,8 @@ function History() {
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span>
|
||||
<div className="cc-controls">
|
||||
<span>Page</span>
|
||||
<input
|
||||
type="number"
|
||||
value={currentPage}
|
||||
@@ -367,14 +363,14 @@ function History() {
|
||||
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }}
|
||||
className="cc-input-sm"
|
||||
/>
|
||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span>
|
||||
<span>of {Math.ceil(totalItems / pageSize)}</span>
|
||||
</div>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }}
|
||||
className="cc-select-sm"
|
||||
>
|
||||
<option value={5}>5 per page</option>
|
||||
<option value={10}>10 per page</option>
|
||||
|
||||
@@ -25,134 +25,157 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Panel ── */
|
||||
.notification-popup {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
right: 20px;
|
||||
width: 400px;
|
||||
width: 420px;
|
||||
max-height: 600px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #f9f9f9;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.notification-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
color: #64748b;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
color: #e2e8f0;
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
/* ── Filters ── */
|
||||
.notification-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fafafa;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 0.875rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
background: #1e293b;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
padding: 0.3rem 0.65rem;
|
||||
border: 1px solid #334155;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: #007bff;
|
||||
color: #007bff;
|
||||
border-color: #14b8a6;
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
background: #14b8a6;
|
||||
color: #0f172a;
|
||||
border-color: #14b8a6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
margin-left: auto;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-color: #dc3545;
|
||||
background: transparent;
|
||||
color: #f87171;
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: #c82333;
|
||||
border-color: #c82333;
|
||||
background: #f87171;
|
||||
color: #0f172a;
|
||||
border-color: #f87171;
|
||||
}
|
||||
|
||||
/* ── List ── */
|
||||
.notification-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 450px;
|
||||
max-height: 460px;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
background: white;
|
||||
transition: background 0.2s;
|
||||
background: #1e293b;
|
||||
transition: background 0.15s;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.notification-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.notification-item:hover {
|
||||
background: #f9f9f9;
|
||||
background: #263347;
|
||||
}
|
||||
|
||||
.notification-item.unread {
|
||||
background: #f0f8ff;
|
||||
font-weight: 500;
|
||||
background: #162032;
|
||||
border-left: 3px solid #14b8a6;
|
||||
}
|
||||
|
||||
.notification-item.unread:hover {
|
||||
background: #1c2a40;
|
||||
}
|
||||
|
||||
/* ── Content ── */
|
||||
.notification-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -160,84 +183,106 @@
|
||||
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
color: #333;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.875rem;
|
||||
color: #f1f5f9;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notification-resource {
|
||||
background: #f0f0f0;
|
||||
padding: 0.2rem 0.5rem;
|
||||
background: #0f172a;
|
||||
color: #94a3b8;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #334155;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Actions ── */
|
||||
.notification-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.25rem;
|
||||
align-items: flex-start;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: #999;
|
||||
transition: color 0.2s;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
color: #475569;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: #333;
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.action-btn.mark-read:hover {
|
||||
color: #28a745;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
color: #dc3545;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
/* ── Empty state ── */
|
||||
.notification-empty {
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
color: #475569;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
.notification-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.notification-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
/* ── Push prompt ── */
|
||||
.push-prompt {
|
||||
padding: 0.4rem 0.875rem 0;
|
||||
}
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 600px) {
|
||||
.notification-popup {
|
||||
position: fixed;
|
||||
|
||||
@@ -10,6 +10,9 @@ function NotificationCenter() {
|
||||
const [filter, setFilter] = useState('all'); // all, unread, read
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [pushPermission, setPushPermission] = useState(
|
||||
'Notification' in window ? Notification.permission : 'unsupported'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
@@ -41,8 +44,11 @@ function NotificationCenter() {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'notification') {
|
||||
// New notification received - reload notifications
|
||||
loadNotifications();
|
||||
if ('Notification' in window && Notification.permission === 'granted' && !document.hasFocus()) {
|
||||
const n = message.data || {};
|
||||
new Notification(n.title || 'SkyWatch Alert', { body: n.message || '', icon: '/favicon.ico' });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
@@ -103,6 +109,12 @@ function NotificationCenter() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestPush = async () => {
|
||||
if (!('Notification' in window)) return;
|
||||
const perm = await Notification.requestPermission();
|
||||
setPushPermission(perm);
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
|
||||
|
||||
@@ -173,6 +185,14 @@ function NotificationCenter() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pushPermission !== 'granted' && pushPermission !== 'unsupported' && (
|
||||
<div className="push-prompt">
|
||||
<button className="filter-btn" onClick={handleRequestPush}>
|
||||
{pushPermission === 'denied' ? '🔕 Push blocked' : '🔔 Enable push alerts'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="notification-list">
|
||||
{loading ? (
|
||||
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>Loading...</p>
|
||||
|
||||
+81
-83
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { formatLocalTime } from './utils/timeUtils';
|
||||
import './CommandCenterPages.css';
|
||||
|
||||
function Notifications() {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
@@ -9,50 +10,13 @@ function Notifications() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pendingClear, setPendingClear] = useState(false);
|
||||
const itemsPerPage = 50;
|
||||
const wsRef = useRef(null);
|
||||
const wsReconnectTimer = useRef(null);
|
||||
const wsReconnectDelay = useRef(1000);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
const interval = setInterval(loadNotifications, 10000);
|
||||
|
||||
// Connect to WebSocket for real-time updates
|
||||
connectWebSocket();
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
if (window.notificationWebSocket) {
|
||||
window.notificationWebSocket.close();
|
||||
}
|
||||
};
|
||||
}, [filter, currentPage]);
|
||||
|
||||
const connectWebSocket = () => {
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'notification') {
|
||||
loadNotifications();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setTimeout(connectWebSocket, 5000);
|
||||
};
|
||||
|
||||
window.notificationWebSocket = ws;
|
||||
} catch (error) {
|
||||
console.error('Failed to connect WebSocket:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadNotifications = async () => {
|
||||
const loadNotifications = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {
|
||||
@@ -67,11 +31,51 @@ function Notifications() {
|
||||
setUnreadCount(response.data.unreadCount);
|
||||
setTotal(response.data.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notifications:', error);
|
||||
// Notifications load error handled
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [filter, currentPage]);
|
||||
|
||||
const connectWebSocket = useCallback(() => {
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'notification') {
|
||||
loadNotifications();
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
wsReconnectTimer.current = setTimeout(() => {
|
||||
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
|
||||
connectWebSocket();
|
||||
}, wsReconnectDelay.current);
|
||||
};
|
||||
|
||||
ws.onopen = () => { wsReconnectDelay.current = 1000; };
|
||||
ws.onerror = () => {};
|
||||
|
||||
wsRef.current = ws;
|
||||
} catch (_) {}
|
||||
}, [loadNotifications]);
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications();
|
||||
const interval = setInterval(loadNotifications, 10000);
|
||||
connectWebSocket();
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearTimeout(wsReconnectTimer.current);
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
};
|
||||
}, [filter, currentPage, loadNotifications, connectWebSocket]);
|
||||
|
||||
const handleMarkAsRead = async (notificationId) => {
|
||||
try {
|
||||
@@ -92,25 +96,26 @@ function Notifications() {
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
|
||||
|
||||
try {
|
||||
await axios.post('/api/notifications/clear', { type: filter });
|
||||
setCurrentPage(1);
|
||||
setPendingClear(false);
|
||||
loadNotifications();
|
||||
} catch (error) {
|
||||
console.error('Failed to clear notifications:', error);
|
||||
setPendingClear(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: '2rem' }}>
|
||||
<h2>📬 Notifications</h2>
|
||||
<div className="container cc-page">
|
||||
<div className="cc-page-header">
|
||||
<h2 className="cc-page-title">Notifications</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '2rem', display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div className="cc-controls" style={{ marginBottom: '1rem' }}>
|
||||
<div className="cc-controls">
|
||||
<button
|
||||
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => { setFilter('all'); setCurrentPage(1); }}
|
||||
@@ -132,61 +137,54 @@ function Notifications() {
|
||||
</div>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleClearAll}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
pendingClear ? (
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
|
||||
<button className="btn btn-danger" onClick={handleClearAll}>Confirm Clear</button>
|
||||
<button className="btn btn-secondary" onClick={() => setPendingClear(false)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-danger" onClick={() => setPendingClear(true)} style={{ marginLeft: 'auto' }}>
|
||||
Clear All
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ textAlign: 'center', color: '#999' }}>Loading...</p>
|
||||
<p className="cc-empty">Loading...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#999' }}>
|
||||
<div className="cc-empty">
|
||||
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}>
|
||||
<div className="notifications-list">
|
||||
{notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
backgroundColor: notification.read ? 'white' : '#f0f8ff',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '1rem'
|
||||
}}
|
||||
className={`notification-row ${notification.read ? 'is-read' : 'is-unread'}`}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '1rem' }}>{notification.title}</h4>
|
||||
<p style={{ margin: '0 0 0.5rem 0', color: '#666', fontSize: '0.95rem' }}>
|
||||
<div className="notification-content">
|
||||
<h4 className="notification-title">{notification.title}</h4>
|
||||
<p className="notification-message">
|
||||
{notification.message}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', color: '#999', flexWrap: 'wrap' }}>
|
||||
<div className="notification-meta">
|
||||
{notification.resource_name && (
|
||||
<span style={{ backgroundColor: '#f0f0f0', padding: '0.25rem 0.5rem', borderRadius: '3px' }}>
|
||||
📌 {notification.resource_name}
|
||||
<span className="notification-chip">
|
||||
{notification.resource_name}
|
||||
</span>
|
||||
)}
|
||||
<span>⏰ {formatLocalTime(notification.created_at)}</span>
|
||||
<span>{formatLocalTime(notification.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
|
||||
<div className="notification-actions">
|
||||
{!notification.read && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleMarkAsRead(notification.id)}
|
||||
title="Mark as read"
|
||||
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem' }}
|
||||
>
|
||||
✓ Read
|
||||
</button>
|
||||
@@ -195,7 +193,7 @@ function Notifications() {
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleDelete(notification.id)}
|
||||
title="Delete"
|
||||
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem', background: '#dc3545' }}
|
||||
style={{ background: '#b91c1c' }}
|
||||
>
|
||||
✕ Delete
|
||||
</button>
|
||||
@@ -206,7 +204,7 @@ function Notifications() {
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div className="cc-pagination">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.observability-page h2 {
|
||||
color: #f8fafc;
|
||||
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.obs-tabs {
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid #dbe2ea;
|
||||
border-bottom: 1px solid #334155;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
@@ -19,15 +24,20 @@
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.obs-tab:focus-visible {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.obs-tab.is-active {
|
||||
border-bottom-color: #0ea5a2;
|
||||
color: #0f172a;
|
||||
background: #ffffff;
|
||||
border-color: #dbe2ea;
|
||||
border-bottom-color: #0ea5a2;
|
||||
border-bottom-color: #60a5fa;
|
||||
color: #f8fafc;
|
||||
background: #0f172a;
|
||||
border-color: #334155;
|
||||
border-bottom-color: #60a5fa;
|
||||
}
|
||||
|
||||
.obs-summary-grid {
|
||||
@@ -39,30 +49,30 @@
|
||||
|
||||
.obs-summary-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid #dbe2ea;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
background: linear-gradient(180deg, #0f172a 0%, #0b1325 100%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.obs-card-label {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.obs-card-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.45rem;
|
||||
color: #0f172a;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.obs-card-value.is-danger {
|
||||
color: #b91c1c;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.obs-card-value.is-ok {
|
||||
color: #0f766e;
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.obs-table-wrap {
|
||||
@@ -81,9 +91,9 @@
|
||||
.obs-table th {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
background: #f1f5f9;
|
||||
border-bottom: 2px solid #dbe2ea;
|
||||
color: #1e293b;
|
||||
background: #0f172a;
|
||||
border-bottom: 2px solid #334155;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.84rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -91,11 +101,12 @@
|
||||
|
||||
.obs-table td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
.obs-table tbody tr:hover {
|
||||
background: #f0f9ff;
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
.obs-right {
|
||||
@@ -105,38 +116,38 @@
|
||||
.obs-rate-pill {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.obs-rate-pill.is-good {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
background: #052e16;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.obs-rate-pill.is-warn {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
background: #3f2a0f;
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.obs-empty-copy {
|
||||
color: #94a3b8;
|
||||
color: #cbd5e1;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.obs-zebra {
|
||||
background: #f8fafc;
|
||||
background: #0b1325;
|
||||
}
|
||||
|
||||
.obs-error-code {
|
||||
font-size: 0.85rem;
|
||||
color: #b91c1c;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.obs-muted-sm {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.obs-small {
|
||||
@@ -146,15 +157,15 @@
|
||||
.obs-audit-summary {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f8fafc;
|
||||
background: #0f172a;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #dbe2ea;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.obs-audit-summary-line {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #64748b;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.obs-filters-grid {
|
||||
@@ -166,15 +177,21 @@
|
||||
|
||||
.obs-filter-input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #dbe2ea;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
background: #111827;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.obs-filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #0ea5a2;
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 162, 0.18);
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.obs-toggle-code:focus-visible {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.obs-click-row {
|
||||
@@ -183,11 +200,11 @@
|
||||
|
||||
.obs-toggle-code {
|
||||
cursor: pointer;
|
||||
color: #0f766e;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.obs-expanded-row {
|
||||
background: #f0f9ff;
|
||||
background: #111b2e;
|
||||
}
|
||||
|
||||
.obs-expanded-cell {
|
||||
@@ -196,11 +213,12 @@
|
||||
|
||||
.obs-pre {
|
||||
margin-top: 0.5rem;
|
||||
background: #fff;
|
||||
background: #0b1325;
|
||||
color: #e2e8f0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe2ea;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import { formatLocalTime } from './utils/timeUtils';
|
||||
import './Observability.css';
|
||||
@@ -9,7 +9,6 @@ function Observability() {
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [auditLogs, setAuditLogs] = useState([]);
|
||||
const [auditSummary, setAuditSummary] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedAuditRow, setExpandedAuditRow] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
entityType: '',
|
||||
@@ -17,16 +16,50 @@ function Observability() {
|
||||
userId: '',
|
||||
action: ''
|
||||
});
|
||||
const wsRef = useRef(null);
|
||||
const wsReconnectTimer = useRef(null);
|
||||
const wsReconnectDelay = useRef(1000);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, 30000); // Refresh every 30 seconds
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, filters]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
const connect = () => {
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'metrics' && activeTab === 'metrics') {
|
||||
setMetrics(msg.data);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
ws.onopen = () => { wsReconnectDelay.current = 1000; };
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
wsReconnectTimer.current = setTimeout(() => {
|
||||
wsReconnectDelay.current = Math.min(wsReconnectDelay.current * 2, 30000);
|
||||
connect();
|
||||
}, wsReconnectDelay.current);
|
||||
};
|
||||
ws.onerror = () => {};
|
||||
wsRef.current = ws;
|
||||
} catch (_) {}
|
||||
};
|
||||
connect();
|
||||
return () => {
|
||||
clearTimeout(wsReconnectTimer.current);
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (activeTab === 'metrics') {
|
||||
const resp = await axios.get('/api/observability/metrics');
|
||||
@@ -42,7 +75,6 @@ function Observability() {
|
||||
} catch (error) {
|
||||
// Error loading data
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { formatLocalTime, formatChartTime, formatDuration } from './utils/timeUtils';
|
||||
|
||||
function ResourceDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [resource, setResource] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checks, setChecks] = useState([]);
|
||||
const [checksLoading, setChecksLoading] = useState(false);
|
||||
const [checksStatus, setChecksStatus] = useState('');
|
||||
const [checksPage, setChecksPage] = useState(0);
|
||||
const checksLimit = 10;
|
||||
const [checksSort, setChecksSort] = useState('desc');
|
||||
|
||||
const [incidents, setIncidents] = useState([]);
|
||||
const [incidentsLoading, setIncidentsLoading] = useState(false);
|
||||
const [incidentsStatus, setIncidentsStatus] = useState('all');
|
||||
const [incidentsPage, setIncidentsPage] = useState(0);
|
||||
const incidentsLimit = 10;
|
||||
const [incidentsSort, setIncidentsSort] = useState('desc');
|
||||
const [expandedIncidentId, setExpandedIncidentId] = useState(null);
|
||||
const [showIncidentModal, setShowIncidentModal] = useState(false);
|
||||
const [editingIncident, setEditingIncident] = useState(null);
|
||||
const [incidentDescription, setIncidentDescription] = useState('');
|
||||
const [updatingIncident, setUpdatingIncident] = useState(false);
|
||||
const [expandedCheckId, setExpandedCheckId] = useState(null);
|
||||
const [chartRange, setChartRange] = useState('24h');
|
||||
const [chartChecks, setChartChecks] = useState([]);
|
||||
|
||||
const [sla, setSla] = useState(null);
|
||||
const [slaLoading, setSlaLoading] = useState(false);
|
||||
const slaWindow = 24;
|
||||
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const showNotification = useCallback((title, message, type = 'info') => {
|
||||
const id = Date.now();
|
||||
setNotifications(prev => [...prev, { id, title, message, type }]);
|
||||
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), 5000);
|
||||
}, []);
|
||||
|
||||
const [maintenanceWindows, setMaintenanceWindows] = useState([]);
|
||||
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
|
||||
const [maintenanceForm, setMaintenanceForm] = useState({ start_time: '', end_time: '', reason: '' });
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
loadResource(),
|
||||
loadChecks(),
|
||||
loadIncidents(),
|
||||
loadSla(),
|
||||
loadMaintenanceWindows(),
|
||||
loadChartChecks(),
|
||||
]).catch(() => {});
|
||||
const interval = setInterval(loadResource, 5000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadChartChecks();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartRange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadChecks();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [checksStatus, checksPage, checksSort]);
|
||||
|
||||
useEffect(() => {
|
||||
loadIncidents();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [incidentsStatus, incidentsPage, incidentsSort]);
|
||||
|
||||
const loadResource = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/resources/${id}`);
|
||||
setResource(response.data);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadChecks = async () => {
|
||||
try {
|
||||
setChecksLoading(true);
|
||||
const response = await axios.get(`/api/resources/${id}/checks`, {
|
||||
params: { limit: checksLimit, offset: checksPage * checksLimit, status: checksStatus || undefined, sort: checksSort },
|
||||
});
|
||||
setChecks(response.data.checks || []);
|
||||
} catch (error) {
|
||||
// Checks load error handled
|
||||
} finally {
|
||||
setChecksLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadIncidents = async () => {
|
||||
try {
|
||||
setIncidentsLoading(true);
|
||||
const response = await axios.get(`/api/resources/${id}/incidents`, {
|
||||
params: { limit: incidentsLimit, offset: incidentsPage * incidentsLimit, status: incidentsStatus, sort: incidentsSort },
|
||||
});
|
||||
setIncidents(response.data.incidents || []);
|
||||
} catch (error) {
|
||||
// Incidents load error handled
|
||||
} finally {
|
||||
setIncidentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const rangeMs = { '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000 };
|
||||
|
||||
const loadChartChecks = async () => {
|
||||
try {
|
||||
const from = new Date(Date.now() - rangeMs[chartRange]).toISOString();
|
||||
const resp = await axios.get(`/api/resources/${id}/checks`, {
|
||||
params: { from, limit: 200, sort: 'asc' },
|
||||
});
|
||||
setChartChecks(resp.data.checks || []);
|
||||
} catch (error) {
|
||||
// chart load error handled
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcknowledge = async (incidentId) => {
|
||||
try {
|
||||
await axios.post(`/api/incidents/${incidentId}/acknowledge`);
|
||||
loadIncidents();
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Failed to acknowledge incident', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditIncident = (incident) => {
|
||||
setEditingIncident(incident);
|
||||
setIncidentDescription(incident.description || '');
|
||||
setShowIncidentModal(true);
|
||||
};
|
||||
|
||||
const handleUpdateIncident = async () => {
|
||||
if (!editingIncident) return;
|
||||
try {
|
||||
setUpdatingIncident(true);
|
||||
await axios.patch(`/api/incidents/${editingIncident.id}`, { description: incidentDescription });
|
||||
setShowIncidentModal(false);
|
||||
setEditingIncident(null);
|
||||
setIncidentDescription('');
|
||||
loadIncidents();
|
||||
} catch (error) {
|
||||
showNotification('Error', error.response?.data?.error || 'Failed to update incident', 'error');
|
||||
} finally {
|
||||
setUpdatingIncident(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSla = async () => {
|
||||
try {
|
||||
setSlaLoading(true);
|
||||
const response = await axios.get(`/api/resources/${id}/sla`, { params: { hours: slaWindow } });
|
||||
setSla(response.data);
|
||||
} catch (error) {
|
||||
// SLA load error handled
|
||||
} finally {
|
||||
setSlaLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMaintenanceWindows = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/resources/${id}/maintenance-windows`);
|
||||
setMaintenanceWindows(response.data.windows || []);
|
||||
} catch (error) {
|
||||
// Maintenance windows load error handled
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateMaintenanceWindow = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await axios.post(`/api/resources/${id}/maintenance-windows`, maintenanceForm);
|
||||
setShowMaintenanceModal(false);
|
||||
setMaintenanceForm({ start_time: '', end_time: '', reason: '' });
|
||||
loadMaintenanceWindows();
|
||||
showNotification('Success', 'Maintenance window created', 'success');
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Failed to create maintenance window', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMaintenanceWindow = async (windowId) => {
|
||||
if (window.confirm('Delete this maintenance window?')) {
|
||||
try {
|
||||
await axios.delete(`/api/maintenance-windows/${windowId}`);
|
||||
loadMaintenanceWindows();
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Failed to delete maintenance window', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (window.confirm('Are you sure you want to delete this resource?')) {
|
||||
try {
|
||||
await axios.delete(`/api/resources/${id}`);
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Failed to delete resource', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnabled = async () => {
|
||||
try {
|
||||
await axios.put(`/api/resources/${id}`, { ...resource, enabled: !resource.enabled });
|
||||
loadResource();
|
||||
} catch (error) {
|
||||
// Update resource error handled
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMaintenance = async () => {
|
||||
try {
|
||||
await axios.put(`/api/resources/${id}`, { ...resource, maintenance_mode: !resource.maintenance_mode });
|
||||
loadResource();
|
||||
} catch (error) {
|
||||
// Update maintenance mode error handled
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="container">Loading...</div>;
|
||||
if (!resource) return <div className="container">Resource not found</div>;
|
||||
|
||||
const toastStack = notifications.length > 0 && (
|
||||
<div className="toast-stack">
|
||||
{notifications.map(n => (
|
||||
<div key={n.id} className={`toast-item toast-${n.type}`}>
|
||||
<strong>{n.title}</strong>
|
||||
<div>{n.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const chartData = chartChecks.map((check) => ({
|
||||
time: formatChartTime(check.checked_at),
|
||||
responseTime: check.response_time,
|
||||
status: check.status === 'up' ? 1 : 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
{toastStack}
|
||||
<Link to="/" className="back-button">← Back to Dashboard</Link>
|
||||
|
||||
<div className="detail-section">
|
||||
<div className="detail-header-row">
|
||||
<div>
|
||||
<h2>{resource.name}</h2>
|
||||
<p className="resource-url-detail">{resource.url}</p>
|
||||
<p className="resource-type">Type: {resource.type}</p>
|
||||
</div>
|
||||
<div className="detail-actions">
|
||||
<button className="btn" onClick={toggleEnabled}>{resource.enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button className="btn btn-secondary" onClick={toggleMaintenance}>
|
||||
{resource.maintenance_mode ? 'End Maintenance' : 'Start Maintenance'}
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={handleDelete}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resource.hasActiveIncident && (
|
||||
<div className="incident-badge">⚠️ Active Incident - Resource is currently DOWN</div>
|
||||
)}
|
||||
{resource.maintenance_mode && (
|
||||
<div className="incident-badge maintenance-badge">🛠️ Maintenance mode active — alerts are suppressed</div>
|
||||
)}
|
||||
|
||||
<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></div>
|
||||
<div className="stat"><p className="stat-value">{resource.stats.avgResponseTime}ms</p><p className="stat-label">Avg Response</p></div>
|
||||
<div className="stat"><p className="stat-value">{resource.stats.totalChecks}</p><p className="stat-label">Total Checks</p></div>
|
||||
<div className="stat">
|
||||
<p className={`stat-value status-badge status-${resource.lastCheck?.status || 'unknown'}`}>{resource.lastCheck?.status || 'unknown'}</p>
|
||||
<p className="stat-label">Current Status</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h2>SLA / SLO (last {slaWindow}h)</h2>
|
||||
{slaLoading ? <p>Loading SLA...</p> : !sla ? <p>No SLA data yet</p> : (
|
||||
<div className="stats-grid stats-grid-4">
|
||||
<div className="stat"><p className="stat-value">{sla.uptimePct}%</p><p className="stat-label">Uptime</p></div>
|
||||
<div className="stat"><p className="stat-value">{formatDuration(sla.downtimeMinutes * 60000 || 0)}</p><p className="stat-label">Downtime</p></div>
|
||||
<div className="stat"><p className="stat-value">{sla.p95LatencyMs != null ? `${sla.p95LatencyMs}ms` : '—'}</p><p className="stat-label">p95 Latency</p></div>
|
||||
<div className="stat"><p className="stat-value">{sla.totalChecks}</p><p className="stat-label">Checks in window</p></div>
|
||||
<div className="stat"><p className="stat-value">{sla.mttrMinutes != null ? `${sla.mttrMinutes}m` : '—'}</p><p className="stat-label">MTTR</p></div>
|
||||
<div className="stat"><p className="stat-value">{sla.mtbfMinutes != null ? `${sla.mtbfMinutes}m` : '—'}</p><p className="stat-label">MTBF</p></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<div className="section-title-row">
|
||||
<h2>Response Time</h2>
|
||||
<div className="chart-range-btns">
|
||||
{['1h', '6h', '24h', '7d'].map(r => (
|
||||
<button key={r} className={`chart-range-btn${chartRange === r ? ' active' : ''}`} onClick={() => setChartRange(r)}>{r}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="chart-container">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="time" tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px' }}
|
||||
labelStyle={{ color: '#cbd5e1' }}
|
||||
itemStyle={{ color: '#e2e8f0' }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="responseTime" stroke="#60a5fa" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h2>Recent Checks</h2>
|
||||
<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 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 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>
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<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">Loading checks...</td></tr>
|
||||
) : checks.length === 0 ? (
|
||||
<tr><td colSpan="4">No checks found</td></tr>
|
||||
) : checks.map((check) => {
|
||||
const isExpanded = expandedCheckId === check.id;
|
||||
let parsedDetails = null;
|
||||
if (check.details) {
|
||||
try { parsedDetails = JSON.parse(check.details); } catch {}
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={check.id}>
|
||||
<tr
|
||||
className={`check-row${parsedDetails ? ' check-row-expandable' : ''}`}
|
||||
onClick={() => parsedDetails && setExpandedCheckId(isExpanded ? null : check.id)}
|
||||
title={parsedDetails ? 'Click to expand details' : undefined}
|
||||
>
|
||||
<td>{formatLocalTime(check.checked_at)}</td>
|
||||
<td><span className={`status-badge status-${check.status}`}>{check.status}</span></td>
|
||||
<td className="text-right">{check.response_time ? `${check.response_time}ms` : '-'}</td>
|
||||
<td className="muted-cell">
|
||||
{check.error_message || 'OK'}
|
||||
{parsedDetails && <span className="expand-indicator">{isExpanded ? ' ▼' : ' ▶'}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && parsedDetails && (
|
||||
<tr className="expanded-row">
|
||||
<td colSpan="4" className="expanded-cell">
|
||||
<pre className="detail-json">{JSON.stringify(parsedDetails, null, 2)}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h2>Incidents Timeline</h2>
|
||||
<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 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 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>
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr><th className="w-20">Started</th><th className="w-20">Resolved</th><th className="w-15">Duration</th><th className="w-35">Reason</th><th className="w-10"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incidentsLoading ? (
|
||||
<tr><td colSpan="4">Loading incidents...</td></tr>
|
||||
) : incidents.length === 0 ? (
|
||||
<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;
|
||||
const durationMs = start && end ? (new Date(end).getTime() - new Date(start).getTime()) : null;
|
||||
const durationText = durationMs != null ? formatDuration(durationMs) : 'Ongoing';
|
||||
const isExpanded = expandedIncidentId === incident.id;
|
||||
const description = incident.description || 'No description';
|
||||
const isTruncated = description.length > 60;
|
||||
const displayText = isExpanded ? description : (isTruncated ? description.substring(0, 60) + '...' : description);
|
||||
return (
|
||||
<React.Fragment key={incident.id}>
|
||||
<tr>
|
||||
<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 className="expand-indicator">{isExpanded ? '▼' : '▶'}</span>}
|
||||
</span>
|
||||
<button className="btn btn-secondary btn-compact" onClick={() => handleEditIncident(incident)}>Edit</button>
|
||||
</td>
|
||||
<td>
|
||||
{!incident.resolved_at && (
|
||||
<button
|
||||
className="btn btn-secondary btn-compact"
|
||||
title="Acknowledge — mark as seen"
|
||||
onClick={() => handleAcknowledge(incident.id)}
|
||||
>Ack</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="expanded-row">
|
||||
<td colSpan="5" className="expanded-cell">{description}</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<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 className="empty-copy">No maintenance windows scheduled</p>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<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}>
|
||||
<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 btn-compact" onClick={() => handleDeleteMaintenanceWindow(window.id)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMaintenanceModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowMaintenanceModal(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Schedule Maintenance Window</h2>
|
||||
<form onSubmit={handleCreateMaintenanceWindow}>
|
||||
<div className="form-group">
|
||||
<label>Start Time *</label>
|
||||
<input type="datetime-local" required value={maintenanceForm.start_time} onChange={(e) => setMaintenanceForm({ ...maintenanceForm, start_time: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>End Time *</label>
|
||||
<input type="datetime-local" required value={maintenanceForm.end_time} onChange={(e) => setMaintenanceForm({ ...maintenanceForm, end_time: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Reason (Optional)</label>
|
||||
<textarea rows="3" placeholder="Planned maintenance, server upgrade, etc." value={maintenanceForm.reason} onChange={(e) => setMaintenanceForm({ ...maintenanceForm, reason: e.target.value })} />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={() => setShowMaintenanceModal(false)}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary">Schedule</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showIncidentModal && editingIncident && (
|
||||
<div className="modal-overlay" onClick={() => setShowIncidentModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Edit Incident</h2>
|
||||
<div className="form-group">
|
||||
<label style={{ fontSize: '0.9rem', color: '#666' }}>Incident Started</label>
|
||||
<div style={{ padding: '0.5rem', backgroundColor: '#f5f5f5', borderRadius: '4px', marginBottom: '1rem' }}>
|
||||
{formatLocalTime(editingIncident.started_at || editingIncident.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description *</label>
|
||||
<textarea rows="6" placeholder="Describe what happened and why..." value={incidentDescription} onChange={(e) => setIncidentDescription(e.target.value)} style={{ fontFamily: 'monospace', fontSize: '0.9rem' }} />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn" onClick={() => { setShowIncidentModal(false); setEditingIncident(null); setIncidentDescription(''); }}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" onClick={handleUpdateIncident} disabled={updatingIncident || !incidentDescription.trim()}>
|
||||
{updatingIncident ? 'Updating...' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResourceDetail;
|
||||
+54
-60
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './App.css';
|
||||
import './CommandCenterPages.css';
|
||||
|
||||
function SLA() {
|
||||
const [slaData, setSlaData] = useState([]);
|
||||
@@ -66,22 +67,15 @@ function SLA() {
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="container cc-page">
|
||||
{limitedByRetention && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffc107',
|
||||
borderRadius: '4px',
|
||||
color: '#856404'
|
||||
}}>
|
||||
<div className="cc-alert">
|
||||
⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<h2>SLA Dashboard</h2>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<div className="cc-page-header">
|
||||
<h2 className="cc-page-title">SLA Dashboard</h2>
|
||||
<div className="cc-controls">
|
||||
{[7, 14, 30, 90].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
@@ -95,33 +89,33 @@ function SLA() {
|
||||
))}
|
||||
</div>
|
||||
{limitedByRetention && (
|
||||
<div style={{ background: '#FFF8E1', color: '#8D6E63', padding: '0.5rem 0.75rem', borderRadius: '6px', border: '1px solid #FFECB3' }}>
|
||||
<div className="cc-alert" style={{ marginBottom: 0 }}>
|
||||
Limited by data retention ({effectiveDays} days). Increase Retention in Settings to view longer periods.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{overallStats && (
|
||||
<div className="stats-grid" style={{ marginBottom: '2rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.totalResources}</p>
|
||||
<p className="stat-label">Total Resources</p>
|
||||
<div className="cc-kpi-grid" style={{ marginBottom: '1rem' }}>
|
||||
<div className="cc-kpi">
|
||||
<p className="cc-kpi-label">Total Resources</p>
|
||||
<p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{overallStats.totalResources}</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2.5rem' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
|
||||
<p className="stat-label">Meeting SLA Target</p>
|
||||
<div className="cc-kpi">
|
||||
<p className="cc-kpi-label">Meeting SLA Target</p>
|
||||
<p className="cc-kpi-value" style={{ color: '#22c55e' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.avgUptime}%</p>
|
||||
<p className="stat-label">Average Uptime</p>
|
||||
<div className="cc-kpi">
|
||||
<p className="cc-kpi-label">Average Uptime</p>
|
||||
<p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{overallStats.avgUptime}%</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#ff9800', fontSize: '2.5rem' }}>{overallStats.totalIncidents}</p>
|
||||
<p className="stat-label">Total Incidents</p>
|
||||
<div className="cc-kpi">
|
||||
<p className="cc-kpi-label">Total Incidents</p>
|
||||
<p className="cc-kpi-value" style={{ color: '#f59e0b' }}>{overallStats.totalIncidents}</p>
|
||||
</div>
|
||||
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}>
|
||||
<p className="stat-value" style={{ color: '#f44336', fontSize: '2.5rem' }}>{Math.round(overallStats.totalDowntime)}m</p>
|
||||
<p className="stat-label">Total Downtime</p>
|
||||
<div className="cc-kpi">
|
||||
<p className="cc-kpi-label">Total Downtime</p>
|
||||
<p className="cc-kpi-value" style={{ color: '#ef4444' }}>{Math.round(overallStats.totalDowntime)}m</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -132,46 +126,46 @@ function SLA() {
|
||||
<p>Add resources to start tracking SLA metrics</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
|
||||
<div className="cc-table-wrap">
|
||||
<table className="cc-table">
|
||||
<thead>
|
||||
<tr style={{ background: '#667eea', color: 'white' }}>
|
||||
<th style={{ padding: '1rem', textAlign: 'left' }}>Resource</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Target</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Actual</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Status</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Checks</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Incidents</th>
|
||||
<th style={{ padding: '1rem', textAlign: 'center' }}>Downtime</th>
|
||||
<tr>
|
||||
<th>Resource</th>
|
||||
<th style={{ textAlign: 'center' }}>Target</th>
|
||||
<th style={{ textAlign: 'center' }}>Actual</th>
|
||||
<th style={{ textAlign: 'center' }}>Status</th>
|
||||
<th style={{ textAlign: 'center' }}>Checks</th>
|
||||
<th style={{ textAlign: 'center' }}>Incidents</th>
|
||||
<th style={{ textAlign: 'center' }}>Downtime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{slaData.map((item, idx) => (
|
||||
<tr key={item.resource_id} style={{ borderBottom: '1px solid #eee', background: idx % 2 === 0 ? '#fafafa' : '#fff' }}>
|
||||
<td style={{ padding: '1rem', fontWeight: 'bold' }}>{item.resource_name}</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center' }}>{item.sla_target}%</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#4caf50' : '#f44336' }}>
|
||||
<tr key={item.resource_id} style={{ background: idx % 2 === 0 ? '#0b1325' : '#0f172a' }}>
|
||||
<td style={{ fontWeight: 'bold' }}>{item.resource_name}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.sla_target}%</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#22c55e' : '#ef4444' }}>
|
||||
{item.actual_uptime}%
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '12px',
|
||||
background: item.meets_target ? '#e8f5e9' : '#ffebee',
|
||||
color: item.meets_target ? '#2e7d32' : '#c62828',
|
||||
background: item.meets_target ? '#052e16' : '#3f1d1d',
|
||||
color: item.meets_target ? '#4ade80' : '#fda4af',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{item.meets_target ? '✓ PASS' : '✗ FAIL'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{item.successful_checks}/{item.total_checks}
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center', color: item.incidents > 0 ? '#ff9800' : '#666' }}>
|
||||
<td style={{ textAlign: 'center', color: item.incidents > 0 ? '#f59e0b' : '#94a3b8' }}>
|
||||
{item.incidents}
|
||||
</td>
|
||||
<td style={{ padding: '1rem', textAlign: 'center' }}>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -180,7 +174,7 @@ function SLA() {
|
||||
</table>
|
||||
|
||||
{totalItems > pageSize && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<div className="cc-pagination" style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
@@ -188,8 +182,8 @@ function SLA() {
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span>
|
||||
<div className="cc-controls">
|
||||
<span>Page</span>
|
||||
<input
|
||||
type="number"
|
||||
value={currentPage}
|
||||
@@ -197,14 +191,14 @@ function SLA() {
|
||||
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
|
||||
setCurrentPage(page);
|
||||
}}
|
||||
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }}
|
||||
className="cc-input-sm"
|
||||
/>
|
||||
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span>
|
||||
<span>of {Math.ceil(totalItems / pageSize)}</span>
|
||||
</div>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
|
||||
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }}
|
||||
className="cc-select-sm"
|
||||
>
|
||||
<option value={5}>5 per page</option>
|
||||
<option value={10}>10 per page</option>
|
||||
@@ -223,13 +217,13 @@ function SLA() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<h3 style={{ marginTop: 0, color: '#667eea' }}>SLA Report Summary</h3>
|
||||
<p style={{ color: '#666', lineHeight: '1.6' }}>
|
||||
<div className="cc-surface" style={{ marginTop: '1rem' }}>
|
||||
<h3 className="history-resource-title">SLA Report Summary</h3>
|
||||
<p style={{ color: '#cbd5e1', lineHeight: '1.6' }}>
|
||||
This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{effectiveDays} days</strong>.
|
||||
Resources meeting their target uptime are marked as <strong style={{ color: '#4caf50' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#f44336' }}>FAIL</strong>.
|
||||
Resources meeting their target uptime are marked as <strong style={{ color: '#22c55e' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#ef4444' }}>FAIL</strong>.
|
||||
</p>
|
||||
<p style={{ color: '#666', lineHeight: '1.6', marginBottom: 0 }}>
|
||||
<p style={{ color: '#cbd5e1', lineHeight: '1.6', marginBottom: 0 }}>
|
||||
Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
.settings-wizard {
|
||||
max-width: 900px;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.settings-wizard > h2 {
|
||||
color: #f8fafc;
|
||||
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
color: #666;
|
||||
margin-bottom: 2rem;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border-radius: 12px;
|
||||
padding: 1.35rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.8rem;
|
||||
border-bottom: 1px solid #334155;
|
||||
}
|
||||
|
||||
.settings-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
@@ -43,23 +49,43 @@
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
color: #999;
|
||||
color: #cbd5e1;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.settings-wizard .form-group label {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.settings-wizard .form-group input,
|
||||
.settings-wizard .form-group select,
|
||||
.settings-wizard .form-group textarea {
|
||||
background: #0b1325;
|
||||
color: #f8fafc;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.settings-wizard .form-group input:focus,
|
||||
.settings-wizard .form-group select:focus,
|
||||
.settings-wizard .form-group textarea:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
background: #0b1325;
|
||||
border-left: 4px solid #60a5fa;
|
||||
padding: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.info-box ol {
|
||||
@@ -69,11 +95,11 @@
|
||||
|
||||
.info-box li {
|
||||
margin: 0.25rem 0;
|
||||
color: #666;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.info-box a {
|
||||
color: #667eea;
|
||||
color: #bfdbfe;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -82,12 +108,14 @@
|
||||
}
|
||||
|
||||
.info-box pre {
|
||||
background: white;
|
||||
background: #111827;
|
||||
color: #f8fafc;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
@@ -110,7 +138,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
background-color: #334155;
|
||||
transition: .4s;
|
||||
border-radius: 34px;
|
||||
}
|
||||
@@ -122,13 +150,17 @@
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
background-color: #e2e8f0;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background-color: #667eea;
|
||||
background-color: #2563eb;
|
||||
}
|
||||
|
||||
.toggle-switch input:focus + .toggle-slider {
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + .toggle-slider:before {
|
||||
@@ -136,13 +168,19 @@
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
background: #1e293b;
|
||||
color: #f8fafc;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #5a6268;
|
||||
background: #334155;
|
||||
}
|
||||
|
||||
.btn-secondary:focus-visible {
|
||||
outline: 2px solid #60a5fa;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
@@ -157,15 +195,15 @@
|
||||
}
|
||||
|
||||
.test-result.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
background: #052e16;
|
||||
color: #86efac;
|
||||
border: 1px solid #166534;
|
||||
}
|
||||
|
||||
.test-result.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
background: #3f1d1d;
|
||||
color: #fda4af;
|
||||
border: 1px solid #7f1d1d;
|
||||
}
|
||||
|
||||
.test-result strong {
|
||||
@@ -201,15 +239,15 @@
|
||||
}
|
||||
|
||||
.save-status.success {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
border-left: 4px solid #388e3c;
|
||||
background: #166534;
|
||||
color: #dcfce7;
|
||||
border-left: 4px solid #22c55e;
|
||||
}
|
||||
|
||||
.save-status.error {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
border-left: 4px solid #d32f2f;
|
||||
background: #7f1d1d;
|
||||
color: #fee2e2;
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.save-status strong {
|
||||
|
||||
@@ -13,6 +13,10 @@ function SettingsWizard() {
|
||||
email_to: '',
|
||||
webhook_enabled: false,
|
||||
webhook_url: '',
|
||||
webhook_template: '',
|
||||
ntfy_enabled: false,
|
||||
ntfy_url: 'https://ntfy.sh',
|
||||
ntfy_topic: '',
|
||||
check_interval: 60000,
|
||||
timeout: 5000,
|
||||
timezone: 'UTC',
|
||||
@@ -31,6 +35,7 @@ function SettingsWizard() {
|
||||
items_per_page: 20,
|
||||
refresh_interval: 5000,
|
||||
incident_failure_threshold: 10,
|
||||
slow_alert_consecutive: 3,
|
||||
});
|
||||
|
||||
const [testResult, setTestResult] = useState(null);
|
||||
@@ -57,6 +62,10 @@ function SettingsWizard() {
|
||||
setSavedMessage(null);
|
||||
try {
|
||||
await axios.post('/api/settings', sectionData);
|
||||
if (sectionData.timezone) {
|
||||
localStorage.setItem('serverTimezone', sectionData.timezone);
|
||||
localStorage.setItem('serverTimezoneTime', Date.now().toString());
|
||||
}
|
||||
setSavedMessage({ section: sectionName, success: true });
|
||||
// Clear message after 3 seconds
|
||||
setTimeout(() => setSavedMessage(null), 3000);
|
||||
@@ -100,9 +109,28 @@ function SettingsWizard() {
|
||||
});
|
||||
setTestResult({ success: true, message: response.data.message });
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error.response?.data?.error || 'Failed to send test webhook'
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error.response?.data?.error || 'Failed to send test webhook'
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestNtfy = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const response = await axios.post('/api/test-ntfy', {
|
||||
ntfy_url: settings.ntfy_url,
|
||||
ntfy_topic: settings.ntfy_topic,
|
||||
});
|
||||
setTestResult({ success: true, message: response.data.message });
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error.response?.data?.error || 'Failed to send ntfy notification',
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
@@ -290,10 +318,22 @@ function SettingsWizard() {
|
||||
<small>Receives POST requests with incident data when resources go offline/online</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Payload Template (optional)</label>
|
||||
<textarea
|
||||
value={settings.webhook_template}
|
||||
onChange={(e) => setSettings({ ...settings, webhook_template: e.target.value })}
|
||||
placeholder={'{"text": "{{name}} is {{status}} — {{url}}"}'}
|
||||
rows="4"
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<small>Variables: <code>{'{{name}}'}</code> <code>{'{{status}}'}</code> <code>{'{{url}}'}</code> <code>{'{{type}}'}</code> <code>{'{{message}}'}</code> <code>{'{{timestamp}}'}</code>. Leave blank for the default JSON payload.</small>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<strong>💡 Webhook Payload Example:</strong>
|
||||
<strong>Default payload (when no template is set):</strong>
|
||||
<pre>{JSON.stringify({
|
||||
resource: "ZimaOS Server",
|
||||
resource: "My Server",
|
||||
url: "https://example.com",
|
||||
status: "down",
|
||||
message: "Resource is DOWN",
|
||||
@@ -314,6 +354,7 @@ function SettingsWizard() {
|
||||
onClick={() => handleSaveSection('Webhook', {
|
||||
webhook_enabled: settings.webhook_enabled,
|
||||
webhook_url: settings.webhook_url,
|
||||
webhook_template: settings.webhook_template,
|
||||
})}
|
||||
disabled={savingSection === 'Webhook'}
|
||||
>
|
||||
@@ -324,6 +365,79 @@ function SettingsWizard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ntfy.sh Settings */}
|
||||
<div className="settings-section">
|
||||
<div className="settings-header">
|
||||
<h3>📲 ntfy.sh Push Notifications</h3>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.ntfy_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, ntfy_enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{settings.ntfy_enabled && (
|
||||
<div className="settings-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>ntfy Server URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={settings.ntfy_url}
|
||||
onChange={(e) => setSettings({ ...settings, ntfy_url: e.target.value })}
|
||||
placeholder="https://ntfy.sh"
|
||||
/>
|
||||
<small>Use <code>https://ntfy.sh</code> for the public server, or your self-hosted URL</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Topic</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.ntfy_topic}
|
||||
onChange={(e) => setSettings({ ...settings, ntfy_topic: e.target.value })}
|
||||
placeholder="my-skywatch-alerts"
|
||||
/>
|
||||
<small>Subscribe to this topic in the ntfy app to receive push notifications</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="info-box">
|
||||
<strong>💡 Setup:</strong>
|
||||
<ol>
|
||||
<li>Install the <strong>ntfy</strong> app on your phone (iOS or Android)</li>
|
||||
<li>Subscribe to your topic name above</li>
|
||||
<li>Send a test notification to confirm it works</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestNtfy}
|
||||
disabled={testing || !settings.ntfy_topic}
|
||||
>
|
||||
{testing ? 'Sending...' : '📲 Send Test Notification'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handleSaveSection('ntfy', {
|
||||
ntfy_enabled: settings.ntfy_enabled,
|
||||
ntfy_url: settings.ntfy_url,
|
||||
ntfy_topic: settings.ntfy_topic,
|
||||
})}
|
||||
disabled={savingSection === 'ntfy'}
|
||||
>
|
||||
{savingSection === 'ntfy' ? 'Saving...' : '💾 Save ntfy Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div className={`test-result ${testResult.success ? 'success' : 'error'}`}>
|
||||
@@ -416,6 +530,18 @@ function SettingsWizard() {
|
||||
<small>Number of consecutive failures before triggering an alert (default: 3)</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Slow Alert Sensitivity</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.slow_alert_consecutive || 3}
|
||||
onChange={(e) => setSettings({ ...settings, slow_alert_consecutive: parseInt(e.target.value) })}
|
||||
min="1"
|
||||
max="20"
|
||||
/>
|
||||
<small>Consecutive slow checks required before alerting — 1 = any spike, 3 = default, 5+ = only sustained slowness</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Incident Failure Threshold</label>
|
||||
<input
|
||||
@@ -466,6 +592,7 @@ function SettingsWizard() {
|
||||
grace_period: settings.grace_period,
|
||||
downtime_threshold: settings.downtime_threshold,
|
||||
incident_failure_threshold: settings.incident_failure_threshold,
|
||||
slow_alert_consecutive: settings.slow_alert_consecutive,
|
||||
})}
|
||||
disabled={savingSection === 'Monitoring'}
|
||||
>
|
||||
|
||||
@@ -319,3 +319,54 @@
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 90-day uptime bars ── */
|
||||
.uptime-history {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 28px;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.uptime-day-bar {
|
||||
flex: 1;
|
||||
min-width: 1px;
|
||||
border-radius: 1px;
|
||||
cursor: default;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.uptime-day-bar:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.uptime-history-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Spark bars in dashboard ── */
|
||||
.status-spark-bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: stretch;
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spark-bar {
|
||||
flex: 1;
|
||||
min-width: 3px;
|
||||
border-radius: 1px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.spark-bar:hover { opacity: 0.65; }
|
||||
.spark-bar-up { background: #14b8a6; }
|
||||
.spark-bar-down { background: #ef4444; }
|
||||
.spark-bar-unknown { background: #475569; }
|
||||
+43
-18
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { formatLocalTime } from './utils/timeUtils';
|
||||
import './Status.css';
|
||||
|
||||
function Status() {
|
||||
@@ -12,20 +13,17 @@ function Status() {
|
||||
|
||||
useEffect(() => {
|
||||
loadStatusData();
|
||||
const interval = setInterval(loadStatusData, 15000); // Refresh every 15 seconds for critical status
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(loadStatusData, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [autoRefresh]);
|
||||
|
||||
const loadStatusData = async () => {
|
||||
try {
|
||||
const [resourcesRes, incidentsRes, maintenanceRes] = await Promise.all([
|
||||
axios.get('/api/resources'),
|
||||
axios.get('/api/incidents'),
|
||||
axios.get('/api/maintenance-windows'),
|
||||
]);
|
||||
setResources(resourcesRes.data.resources || []);
|
||||
setIncidents(incidentsRes.data.incidents || []);
|
||||
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
|
||||
const statusRes = await axios.get('/api/status-page');
|
||||
setResources(statusRes.data.resources || []);
|
||||
setIncidents(statusRes.data.incidents || []);
|
||||
setMaintenanceWindows(statusRes.data.maintenanceWindows || []);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
// Status load error handled
|
||||
@@ -34,14 +32,6 @@ function Status() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatLocalTime = (timestamp) => {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms) => {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
@@ -129,6 +119,19 @@ function Status() {
|
||||
return m.resource_id === resource.id && now >= start && now <= end;
|
||||
});
|
||||
|
||||
const daily = resource.dailyUptime || [];
|
||||
const last90Days = (() => {
|
||||
const days = [];
|
||||
for (let i = 89; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
const entry = daily.find(e => e.day === key);
|
||||
days.push({ day: key, uptime: entry ? entry.uptime : null });
|
||||
}
|
||||
return days;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={resource.id}
|
||||
@@ -149,6 +152,28 @@ function Status() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="uptime-history" title="90-day uptime — hover for details">
|
||||
{last90Days.map((d) => {
|
||||
const color = d.uptime === null ? '#334155'
|
||||
: d.uptime >= 99 ? '#14b8a6'
|
||||
: d.uptime >= 95 ? '#f59e0b'
|
||||
: '#ef4444';
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
className="uptime-day-bar"
|
||||
style={{ background: color }}
|
||||
title={d.uptime !== null ? `${d.day}: ${d.uptime}%` : `${d.day}: no data`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="uptime-history-labels">
|
||||
<span>90 days ago</span>
|
||||
<span>{daily.length > 0 ? `${daily[daily.length - 1]?.uptime ?? '—'}% today` : ''}</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
|
||||
{activeIncident && (
|
||||
<div className="incident-info">
|
||||
<p className="incident-title">Active Incident</p>
|
||||
|
||||
@@ -3,8 +3,8 @@ body {
|
||||
font-family: 'Manrope', 'IBM Plex Sans', 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #0f172a;
|
||||
background-color: #f8fafc;
|
||||
color: #e2e8f0;
|
||||
background-color: #020617;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -9,6 +9,21 @@ export const getCurrentTimezone = () => {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
};
|
||||
|
||||
const getServerTimezone = () => {
|
||||
try {
|
||||
const tz = localStorage.getItem('serverTimezone');
|
||||
const ts = localStorage.getItem('serverTimezoneTime');
|
||||
if (tz && ts && (Date.now() - parseInt(ts)) < 3600000 && tz !== 'null' && tz !== 'undefined' && tz.trim() !== '') {
|
||||
return tz.replace(/^["']|["']$/g, '').trim();
|
||||
}
|
||||
if (tz) {
|
||||
localStorage.removeItem('serverTimezone');
|
||||
localStorage.removeItem('serverTimezoneTime');
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a timestamp to local time string with timezone info
|
||||
* Uses server timezone from localStorage if available, otherwise browser timezone
|
||||
@@ -23,40 +38,19 @@ export const formatLocalTime = (timestamp, options = {}) => {
|
||||
}
|
||||
|
||||
const date = new Date(ts);
|
||||
|
||||
// Try to get server timezone from settings API response (cached in localStorage)
|
||||
let timeZone;
|
||||
try {
|
||||
const cachedSettings = localStorage.getItem('serverTimezone');
|
||||
const cacheTime = localStorage.getItem('serverTimezoneTime');
|
||||
const now = Date.now();
|
||||
// Invalidate cache every 1 hour
|
||||
if (cachedSettings && cacheTime && (now - parseInt(cacheTime)) < 3600000 && cachedSettings !== 'null' && cachedSettings !== 'undefined' && cachedSettings.trim() !== '') {
|
||||
// Remove any quotes that might be in the string
|
||||
timeZone = cachedSettings.replace(/^["']|["']$/g, '').trim();
|
||||
} else if (cachedSettings) {
|
||||
// Clear stale cache
|
||||
localStorage.removeItem('serverTimezone');
|
||||
localStorage.removeItem('serverTimezoneTime');
|
||||
}
|
||||
} catch (e) {
|
||||
// Timezone load error handled silently
|
||||
}
|
||||
|
||||
const tz = getServerTimezone();
|
||||
|
||||
const defaultOptions = {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
timeZoneName: 'short',
|
||||
...(tz ? { timeZone: tz } : {})
|
||||
};
|
||||
|
||||
if (timeZone) {
|
||||
defaultOptions.timeZone = timeZone;
|
||||
}
|
||||
|
||||
|
||||
return date.toLocaleString('en-US', { ...defaultOptions, ...options });
|
||||
};
|
||||
|
||||
@@ -65,15 +59,17 @@ export const formatLocalTime = (timestamp, options = {}) => {
|
||||
*/
|
||||
export const formatLocalTimeOnly = (timestamp, options = {}) => {
|
||||
if (!timestamp) return 'Never';
|
||||
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const tz = getServerTimezone();
|
||||
const defaultOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
timeZoneName: 'short',
|
||||
...(tz ? { timeZone: tz } : {})
|
||||
};
|
||||
|
||||
|
||||
return date.toLocaleTimeString('en-US', { ...defaultOptions, ...options });
|
||||
};
|
||||
|
||||
@@ -83,26 +79,18 @@ export const formatLocalTimeOnly = (timestamp, options = {}) => {
|
||||
*/
|
||||
export const formatChartTime = (timestamp) => {
|
||||
if (!timestamp) return '';
|
||||
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// If within last 24 hours, show time only
|
||||
const tz = getServerTimezone();
|
||||
const tzOpt = tz ? { timeZone: tz } : {};
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', ...tzOpt });
|
||||
}
|
||||
|
||||
// Otherwise show date and time
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', ...tzOpt });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -112,10 +100,19 @@ export const getTimezoneInfo = () => {
|
||||
const tz = getCurrentTimezone();
|
||||
const now = new Date();
|
||||
const tzDisplay = now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
|
||||
|
||||
|
||||
return {
|
||||
timezone: tz,
|
||||
abbreviation: tzDisplay,
|
||||
offset: now.getTimezoneOffset()
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function formatDuration(ms) {
|
||||
if (!ms || ms < 0) return '0m';
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
Generated
+13
-401
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
@@ -118,35 +118,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
|
||||
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
"node-addon-api": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
@@ -162,26 +144,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -230,30 +192,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -353,12 +291,6 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
@@ -516,30 +448,6 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -568,15 +476,6 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
@@ -634,15 +533,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -713,15 +603,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
@@ -768,12 +649,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -859,12 +734,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -936,12 +805,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
@@ -1048,26 +911,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore-by-default": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
|
||||
@@ -1081,12 +924,6 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -1319,18 +1156,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
@@ -1344,33 +1169,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -1380,16 +1184,13 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.85.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
|
||||
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.9.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz",
|
||||
"integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
@@ -1533,15 +1334,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1579,32 +1371,6 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -1631,16 +1397,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
@@ -1680,35 +1436,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -2005,51 +1732,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
@@ -2078,15 +1760,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -2115,15 +1788,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
@@ -2140,34 +1804,6 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -2217,18 +1853,6 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -2258,12 +1882,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -2309,12 +1927,6 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
"start": "node server/index.js",
|
||||
"dev": "concurrently \"npm run server\" \"npm run client\"",
|
||||
"server": "nodemon server/index.js",
|
||||
"client": "cd client && npm start",
|
||||
"client": "cd client && PORT=3456 npm start",
|
||||
"build": "cd client && npm run build",
|
||||
"build:commit": "npm run build && git add -f client/build/ && git commit -m \"Auto-commit built React app\" && git push",
|
||||
"install-all": "npm install && cd client && npm install"
|
||||
@@ -22,7 +22,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
|
||||
+31
-1
@@ -1,7 +1,11 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const db = new Database(path.join(__dirname, '../data/monitor.db'));
|
||||
const dataDir = path.join(__dirname, '../data');
|
||||
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
const db = new Database(path.join(dataDir, 'monitor.db'));
|
||||
|
||||
// Create tables
|
||||
db.exec(`
|
||||
@@ -311,10 +315,36 @@ try {
|
||||
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run();
|
||||
} catch (err) {}
|
||||
|
||||
// Feature: HTTP method + request body support
|
||||
try { db.prepare("ALTER TABLE resources ADD COLUMN http_method TEXT DEFAULT 'GET'").run(); } catch(e) {}
|
||||
try { db.prepare("ALTER TABLE resources ADD COLUMN http_body TEXT").run(); } catch(e) {}
|
||||
|
||||
// Feature: alert escalation tracking
|
||||
try { db.prepare("ALTER TABLE incidents ADD COLUMN escalated INTEGER DEFAULT 0").run(); } catch(e) {}
|
||||
|
||||
// Feature: heartbeat / cron-job monitoring
|
||||
try { db.prepare("ALTER TABLE resources ADD COLUMN heartbeat_token TEXT").run(); } catch(e) {}
|
||||
try { db.prepare("ALTER TABLE resources ADD COLUMN heartbeat_timeout INTEGER DEFAULT 300000").run(); } catch(e) {}
|
||||
try { db.prepare("ALTER TABLE resources ADD COLUMN last_heartbeat_at DATETIME").run(); } catch(e) {}
|
||||
|
||||
// Feature: status page per-resource visibility
|
||||
try { db.prepare("ALTER TABLE resources ADD COLUMN is_public INTEGER DEFAULT 1").run(); } catch(e) {}
|
||||
|
||||
// Helpful indexes for time-range queries
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_resource_time ON incidents(resource_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_resources_heartbeat_token ON resources(heartbeat_token);
|
||||
`);
|
||||
|
||||
// One-time migration: null out per-resource check_interval and timeout so all resources
|
||||
// inherit from the global monitoring settings. Runs once, tracked by a settings flag.
|
||||
try {
|
||||
const alreadyMigrated = db.prepare("SELECT value FROM settings WHERE key = 'migrated_global_defaults_v1'").get();
|
||||
if (!alreadyMigrated) {
|
||||
db.prepare("UPDATE resources SET check_interval = NULL, timeout = NULL").run();
|
||||
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('migrated_global_defaults_v1', 'true')").run();
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
module.exports = db;
|
||||
+479
-329
@@ -3,8 +3,29 @@ const cors = require('cors');
|
||||
const path = require('path');
|
||||
const WebSocket = require('ws');
|
||||
const http = require('http');
|
||||
const { randomUUID } = require('crypto');
|
||||
require('dotenv').config();
|
||||
|
||||
// Prevent HTTP parser errors (e.g. "Data after Connection: close") and other
|
||||
// transient async errors from crashing the process.
|
||||
process.on('uncaughtException', (err) => {
|
||||
if (err.code === 'HPE_INVALID_CONSTANT' || (err.message && err.message.includes('Parse Error'))) {
|
||||
console.error('HTTP parse error (swallowed):', err.message);
|
||||
return;
|
||||
}
|
||||
console.error('Uncaught exception:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const msg = reason instanceof Error ? reason.message : String(reason);
|
||||
if (msg.includes('Parse Error')) {
|
||||
console.error('HTTP parse error (swallowed):', msg);
|
||||
return;
|
||||
}
|
||||
console.error('Unhandled rejection:', reason);
|
||||
});
|
||||
|
||||
const db = require('./database');
|
||||
const scheduler = require('./scheduler');
|
||||
const monitorService = require('./monitorService');
|
||||
@@ -12,6 +33,7 @@ const notificationService = require('./notificationService');
|
||||
const cache = require('./cache');
|
||||
const security = require('./securityMiddleware');
|
||||
const metrics = require('./metrics');
|
||||
const { discoverNetwork, getLocalSubnets } = require('./networkDiscovery');
|
||||
const auditLog = require('./auditLog');
|
||||
|
||||
// Initialize notifications table if it doesn't exist
|
||||
@@ -38,6 +60,12 @@ try {
|
||||
console.error('Error initializing notifications table:', error);
|
||||
}
|
||||
|
||||
// One-time: remove stale theme setting from DB (app is always dark mode)
|
||||
try {
|
||||
db.prepare("DELETE FROM settings WHERE key = 'theme'").run();
|
||||
} catch (_) {}
|
||||
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
@@ -105,6 +133,148 @@ function getTimezoneOffset() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build settings object merging DB overrides on top of env vars
|
||||
function buildSettingsFromDb(dbSettings = {}) {
|
||||
return {
|
||||
email_enabled: (dbSettings.email_enabled || process.env.EMAIL_ENABLED) === 'true',
|
||||
email_host: dbSettings.email_host || process.env.EMAIL_HOST || 'smtp.gmail.com',
|
||||
email_port: parseInt(dbSettings.email_port || process.env.EMAIL_PORT) || 587,
|
||||
email_user: dbSettings.email_user || process.env.EMAIL_USER || '',
|
||||
email_pass: '',
|
||||
email_from: dbSettings.email_from || process.env.EMAIL_FROM || '',
|
||||
email_to: dbSettings.email_to || process.env.EMAIL_TO || '',
|
||||
webhook_enabled: (dbSettings.webhook_enabled || process.env.WEBHOOK_ENABLED) === 'true',
|
||||
webhook_url: dbSettings.webhook_url || process.env.WEBHOOK_URL || '',
|
||||
check_interval: parseInt(dbSettings.check_interval || process.env.CHECK_INTERVAL) || 60000,
|
||||
timeout: parseInt(dbSettings.timeout || process.env.TIMEOUT) || 5000,
|
||||
timezone: dbSettings.timezone || process.env.TIMEZONE || 'UTC',
|
||||
retention_days: parseInt(dbSettings.retention_days || process.env.RETENTION_DAYS) || 7,
|
||||
auto_cleanup_enabled: (dbSettings.auto_cleanup_enabled || process.env.AUTO_CLEANUP_ENABLED) === 'true',
|
||||
consecutive_failures: parseInt(dbSettings.consecutive_failures || process.env.CONSECUTIVE_FAILURES) || 3,
|
||||
grace_period: parseInt(dbSettings.grace_period || process.env.GRACE_PERIOD) || 300,
|
||||
downtime_threshold: parseInt(dbSettings.downtime_threshold || process.env.DOWNTIME_THRESHOLD) || 600,
|
||||
alert_retry_count: parseInt(dbSettings.alert_retry_count || process.env.ALERT_RETRY_COUNT) || 3,
|
||||
alert_retry_delay: parseInt(dbSettings.alert_retry_delay || process.env.ALERT_RETRY_DELAY) || 60,
|
||||
fallback_webhook: dbSettings.fallback_webhook || process.env.FALLBACK_WEBHOOK || '',
|
||||
global_quiet_hours_start: dbSettings.global_quiet_hours_start || process.env.GLOBAL_QUIET_HOURS_START || '',
|
||||
global_quiet_hours_end: dbSettings.global_quiet_hours_end || process.env.GLOBAL_QUIET_HOURS_END || '',
|
||||
escalation_hours: parseInt(dbSettings.escalation_hours || process.env.ESCALATION_HOURS) || 4,
|
||||
default_sort: dbSettings.default_sort || process.env.DEFAULT_SORT || 'name',
|
||||
items_per_page: parseInt(dbSettings.items_per_page || process.env.ITEMS_PER_PAGE) || 20,
|
||||
refresh_interval: parseInt(dbSettings.refresh_interval || process.env.REFRESH_INTERVAL) || 5000,
|
||||
theme: 'dark',
|
||||
ntfy_enabled: (dbSettings.ntfy_enabled || process.env.NTFY_ENABLED) === 'true',
|
||||
ntfy_url: dbSettings.ntfy_url || process.env.NTFY_URL || 'https://ntfy.sh',
|
||||
ntfy_topic: dbSettings.ntfy_topic || process.env.NTFY_TOPIC || '',
|
||||
incident_failure_threshold: parseInt(dbSettings.incident_failure_threshold || process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
|
||||
slow_alert_consecutive: parseInt(dbSettings.slow_alert_consecutive || process.env.SLOW_ALERT_CONSECUTIVE) || 3,
|
||||
webhook_template: dbSettings.webhook_template || process.env.WEBHOOK_TEMPLATE || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Simple in-memory per-IP rate limiter (no external package needed)
|
||||
const _rlMap = new Map();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of _rlMap) { if (now - v.start > 60000) _rlMap.delete(k); }
|
||||
}, 5 * 60 * 1000);
|
||||
function rateLimit(maxReqs, windowMs = 60000) {
|
||||
return (req, res, next) => {
|
||||
const key = `${req.path}:${req.ip}`;
|
||||
const now = Date.now();
|
||||
const e = _rlMap.get(key);
|
||||
if (!e || now - e.start > windowMs) { _rlMap.set(key, { start: now, count: 1 }); return next(); }
|
||||
e.count++;
|
||||
if (e.count > maxReqs) return res.status(429).json({ error: 'Too many requests' });
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Build dashboard payload (shared between HTTP route and WebSocket broadcast)
|
||||
function getDashboardPayload() {
|
||||
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
|
||||
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
|
||||
|
||||
const overview = resources.map(resource => {
|
||||
const lastCheck = monitorService.getLastCheck(resource.id);
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents WHERE resource_id = ? AND resolved_at IS NULL
|
||||
`).get(resource.id);
|
||||
const recentChecks = db.prepare(`
|
||||
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
|
||||
FROM checks WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC LIMIT 15
|
||||
`).all(resource.id).reverse();
|
||||
|
||||
// Pull cert expiry from latest TLS check details
|
||||
let certDaysRemaining = null;
|
||||
if (resource.type === 'tls') {
|
||||
const lastTlsCheck = db.prepare(`
|
||||
SELECT details FROM checks WHERE resource_id = ? AND details IS NOT NULL ORDER BY checked_at DESC LIMIT 1
|
||||
`).get(resource.id);
|
||||
if (lastTlsCheck?.details) {
|
||||
try {
|
||||
const d = JSON.parse(lastTlsCheck.details);
|
||||
if (typeof d.days_remaining === 'number') certDaysRemaining = d.days_remaining;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
url: resource.url,
|
||||
type: resource.type,
|
||||
group_id: resource.group_id,
|
||||
enabled: resource.enabled,
|
||||
check_interval: resource.check_interval,
|
||||
timeout: resource.timeout,
|
||||
http_keyword: resource.http_keyword,
|
||||
http_headers: resource.http_headers,
|
||||
quiet_hours_start: resource.quiet_hours_start,
|
||||
quiet_hours_end: resource.quiet_hours_end,
|
||||
cert_expiry_days: resource.cert_expiry_days,
|
||||
sla_target: resource.sla_target,
|
||||
email_to: resource.email_to,
|
||||
maintenance_mode: resource.maintenance_mode,
|
||||
is_public: resource.is_public !== 0,
|
||||
heartbeat_token: resource.heartbeat_token,
|
||||
heartbeat_timeout: resource.heartbeat_timeout,
|
||||
consecutive_failures_threshold: resource.consecutive_failures_threshold,
|
||||
response_time_threshold: resource.response_time_threshold,
|
||||
http_method: resource.http_method || 'GET',
|
||||
http_body: resource.http_body || null,
|
||||
certDaysRemaining,
|
||||
status: lastCheck?.status || 'unknown',
|
||||
uptime: stats.uptime,
|
||||
avgResponseTime: stats.avgResponseTime,
|
||||
lastCheck: lastCheck?.checked_at,
|
||||
hasActiveIncident: !!activeIncident,
|
||||
activeIncidentId: activeIncident?.id || null,
|
||||
recentChecks,
|
||||
};
|
||||
});
|
||||
|
||||
return { resources: overview, groups };
|
||||
}
|
||||
|
||||
// Validate resource body fields (shared between POST and PUT)
|
||||
function validateResourceBody(body, requireUrl = true) {
|
||||
const { name, url, type, check_interval, timeout, sla_target, retention_days } = body;
|
||||
const isHeartbeat = (type || 'http') === 'heartbeat';
|
||||
if (!name) return 'Name is required';
|
||||
if (!isHeartbeat && requireUrl && !url) return 'URL is required';
|
||||
if (!isHeartbeat && url) {
|
||||
try { new URL(url); } catch (e) { return 'Invalid URL format'; }
|
||||
}
|
||||
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) return 'Check interval must be at least 10000ms';
|
||||
if (timeout && (isNaN(timeout) || timeout < 1000)) return 'Timeout must be at least 1000ms';
|
||||
if (sla_target !== undefined && sla_target !== null && (isNaN(sla_target) || sla_target < 0 || sla_target > 100)) return 'SLA target must be between 0 and 100';
|
||||
if (retention_days && (isNaN(retention_days) || retention_days < 1 || retention_days > 365)) return 'Retention days must be between 1 and 365';
|
||||
return null;
|
||||
}
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
@@ -135,17 +305,6 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Error tracking middleware
|
||||
app.use((err, req, res, next) => {
|
||||
metrics.trackError(err, {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
ip: req.ip
|
||||
});
|
||||
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
|
||||
// Get all groups
|
||||
@@ -204,42 +363,24 @@ app.get('/api/resources/:id', (req, res) => {
|
||||
|
||||
// Create resource
|
||||
app.post('/api/resources', (req, res) => {
|
||||
const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days } = req.body;
|
||||
const { name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body, tags } = req.body;
|
||||
|
||||
if (!name || !url) {
|
||||
return res.status(400).json({ error: 'Name and URL are required' });
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (e) {
|
||||
return res.status(400).json({ error: 'Invalid URL format' });
|
||||
}
|
||||
|
||||
// Validate numeric fields
|
||||
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) {
|
||||
return res.status(400).json({ error: 'Check interval must be at least 10000ms' });
|
||||
}
|
||||
if (timeout && (isNaN(timeout) || timeout < 1000)) {
|
||||
return res.status(400).json({ error: 'Timeout must be at least 1000ms' });
|
||||
}
|
||||
if (sla_target && (isNaN(sla_target) || sla_target < 0 || sla_target > 100)) {
|
||||
return res.status(400).json({ error: 'SLA target must be between 0 and 100' });
|
||||
}
|
||||
if (retention_days && (isNaN(retention_days) || retention_days < 1 || retention_days > 365)) {
|
||||
return res.status(400).json({ error: 'Retention days must be between 1 and 365' });
|
||||
}
|
||||
const validationError = validateResourceBody(req.body);
|
||||
if (validationError) return res.status(400).json({ error: validationError });
|
||||
|
||||
try {
|
||||
const isHeartbeat = (type || 'http') === 'heartbeat';
|
||||
const token = isHeartbeat ? randomUUID() : null;
|
||||
const effectiveUrl = isHeartbeat ? `heartbeat://${name}` : url;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO resources (name, url, type, check_interval, timeout, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_token, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
name,
|
||||
url,
|
||||
effectiveUrl,
|
||||
type || 'http',
|
||||
check_interval || 60000,
|
||||
timeout || 5000,
|
||||
@@ -252,7 +393,15 @@ app.post('/api/resources', (req, res) => {
|
||||
sla_target || 99.9,
|
||||
email_to || null,
|
||||
maintenance_mode ? 1 : 0,
|
||||
retention_days || null
|
||||
retention_days || null,
|
||||
(is_public === false || is_public === 0) ? 0 : 1,
|
||||
token,
|
||||
heartbeat_timeout || 300000,
|
||||
consecutive_failures_threshold || 1,
|
||||
response_time_threshold || null,
|
||||
http_method || 'GET',
|
||||
http_body || null,
|
||||
tags || null
|
||||
);
|
||||
|
||||
// Invalidate related cache entries
|
||||
@@ -262,9 +411,8 @@ app.post('/api/resources', (req, res) => {
|
||||
// Audit log
|
||||
auditLog.logResourceChange('create', result.lastInsertRowid, { name, url, type, enabled: true }, 'system', req.ip);
|
||||
|
||||
res.json({ id: result.lastInsertRowid, message: 'Resource created' });
|
||||
res.json({ id: result.lastInsertRowid, message: 'Resource created', heartbeat_token: token });
|
||||
} catch (err) {
|
||||
// Helpful hint if migration missing
|
||||
const hint = err.message.includes('no column named maintenance_mode')
|
||||
? 'Database schema missing maintenance_mode. Restart server to run migrations.'
|
||||
: undefined;
|
||||
@@ -274,21 +422,28 @@ app.post('/api/resources', (req, res) => {
|
||||
|
||||
// Update resource
|
||||
app.put('/api/resources/:id', (req, res) => {
|
||||
const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days } = req.body;
|
||||
const { name, url, type, check_interval, timeout, enabled, group_id, http_keyword, http_headers, quiet_hours_start, quiet_hours_end, cert_expiry_days, sla_target, email_to, maintenance_mode, retention_days, is_public, heartbeat_timeout, consecutive_failures_threshold, response_time_threshold, http_method, http_body, tags } = req.body;
|
||||
|
||||
const validationError = validateResourceBody(req.body);
|
||||
if (validationError) return res.status(400).json({ error: validationError });
|
||||
|
||||
const existing = db.prepare('SELECT url, type, heartbeat_token FROM resources WHERE id = ?').get(req.params.id);
|
||||
const isHeartbeat = (type || existing?.type || 'http') === 'heartbeat';
|
||||
const effectiveUrl = isHeartbeat ? (existing?.url || `heartbeat://${name}`) : url;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE resources
|
||||
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?, retention_days = ?
|
||||
UPDATE resources
|
||||
SET name = ?, url = ?, type = ?, check_interval = ?, timeout = ?, enabled = ?, group_id = ?, http_keyword = ?, http_headers = ?, quiet_hours_start = ?, quiet_hours_end = ?, cert_expiry_days = ?, sla_target = ?, email_to = ?, maintenance_mode = ?, retention_days = ?, is_public = ?, heartbeat_timeout = ?, consecutive_failures_threshold = ?, response_time_threshold = ?, http_method = ?, http_body = ?, tags = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
name,
|
||||
url,
|
||||
type,
|
||||
check_interval,
|
||||
timeout,
|
||||
enabled ? 1 : 0,
|
||||
name,
|
||||
effectiveUrl,
|
||||
type,
|
||||
check_interval,
|
||||
timeout,
|
||||
enabled ? 1 : 0,
|
||||
group_id || null,
|
||||
http_keyword || null,
|
||||
http_headers || null,
|
||||
@@ -299,10 +454,16 @@ app.put('/api/resources/:id', (req, res) => {
|
||||
email_to || null,
|
||||
maintenance_mode ? 1 : 0,
|
||||
retention_days || null,
|
||||
(is_public === false || is_public === 0) ? 0 : 1,
|
||||
heartbeat_timeout || 300000,
|
||||
consecutive_failures_threshold || 1,
|
||||
response_time_threshold || null,
|
||||
http_method || 'GET',
|
||||
http_body || null,
|
||||
tags || null,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
// Invalidate related cache entries
|
||||
cache.invalidatePattern('history:');
|
||||
cache.invalidatePattern('sla:');
|
||||
|
||||
@@ -345,71 +506,57 @@ app.patch('/api/resources/:id/group', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle maintenance mode without overwriting other resource fields
|
||||
app.patch('/api/resources/:id/maintenance-mode', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const { maintenance_mode } = req.body;
|
||||
|
||||
if (!Number.isInteger(id)) {
|
||||
return res.status(400).json({ error: 'Invalid resource id' });
|
||||
}
|
||||
if (typeof maintenance_mode !== 'boolean') {
|
||||
return res.status(400).json({ error: 'maintenance_mode must be a boolean' });
|
||||
}
|
||||
|
||||
const stmt = db.prepare('UPDATE resources SET maintenance_mode = ? WHERE id = ?');
|
||||
const info = stmt.run(maintenance_mode ? 1 : 0, id);
|
||||
|
||||
if (info.changes === 0) {
|
||||
return res.status(404).json({ error: 'Resource not found' });
|
||||
}
|
||||
|
||||
// Invalidate related cache entries and broadcast live update
|
||||
cache.invalidatePattern('history:');
|
||||
cache.invalidatePattern('sla:');
|
||||
if (global.broadcastDashboardUpdate) {
|
||||
try { global.broadcastDashboardUpdate(); } catch {}
|
||||
}
|
||||
|
||||
const updated = db.prepare('SELECT * FROM resources WHERE id = ?').get(id);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to update maintenance mode', details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete resource
|
||||
app.delete('/api/resources/:id', (req, res) => {
|
||||
const resource = db.prepare('SELECT name, url FROM resources WHERE id = ?').get(req.params.id);
|
||||
|
||||
if (!resource) return res.status(404).json({ error: 'Resource not found' });
|
||||
|
||||
db.prepare('DELETE FROM resources WHERE id = ?').run(req.params.id);
|
||||
|
||||
// Invalidate related cache entries
|
||||
|
||||
cache.invalidatePattern('history:');
|
||||
cache.invalidatePattern('sla:');
|
||||
|
||||
// Audit log
|
||||
if (resource) {
|
||||
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
|
||||
}
|
||||
|
||||
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
|
||||
|
||||
res.json({ message: 'Resource deleted' });
|
||||
});
|
||||
|
||||
// Get dashboard overview (grouped)
|
||||
app.get('/api/dashboard', (req, res) => {
|
||||
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
|
||||
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
|
||||
|
||||
const overview = resources.map(resource => {
|
||||
const lastCheck = monitorService.getLastCheck(resource.id);
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents
|
||||
WHERE resource_id = ? AND resolved_at IS NULL
|
||||
`).get(resource.id);
|
||||
|
||||
const recentChecks = db.prepare(`
|
||||
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
|
||||
FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT 15
|
||||
`).all(resource.id).reverse();
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
url: resource.url,
|
||||
type: resource.type,
|
||||
group_id: resource.group_id,
|
||||
enabled: resource.enabled,
|
||||
check_interval: resource.check_interval,
|
||||
timeout: resource.timeout,
|
||||
http_keyword: resource.http_keyword,
|
||||
http_headers: resource.http_headers,
|
||||
quiet_hours_start: resource.quiet_hours_start,
|
||||
quiet_hours_end: resource.quiet_hours_end,
|
||||
cert_expiry_days: resource.cert_expiry_days,
|
||||
sla_target: resource.sla_target,
|
||||
email_to: resource.email_to,
|
||||
status: lastCheck?.status || 'unknown',
|
||||
uptime: stats.uptime,
|
||||
avgResponseTime: stats.avgResponseTime,
|
||||
lastCheck: lastCheck?.checked_at,
|
||||
hasActiveIncident: !!activeIncident,
|
||||
recentChecks,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ resources: overview, groups });
|
||||
res.json(getDashboardPayload());
|
||||
});
|
||||
|
||||
// Get incidents
|
||||
@@ -516,90 +663,20 @@ app.post('/api/notifications/clear', (req, res) => {
|
||||
|
||||
// Get settings
|
||||
app.get('/api/settings', (req, res) => {
|
||||
// Prevent browser caching - always fetch fresh data
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
res.set('Pragma', 'no-cache');
|
||||
res.set('Expires', '0');
|
||||
|
||||
// Try to get settings from database first, fallback to process.env
|
||||
|
||||
try {
|
||||
const dbSettings = {};
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all();
|
||||
for (const row of rows) {
|
||||
dbSettings[row.key] = row.value;
|
||||
}
|
||||
|
||||
// Build response with database values taking precedence over env vars
|
||||
const settings = {
|
||||
email_enabled: (dbSettings.email_enabled || process.env.EMAIL_ENABLED) === 'true',
|
||||
email_host: dbSettings.email_host || process.env.EMAIL_HOST || 'smtp.gmail.com',
|
||||
email_port: parseInt(dbSettings.email_port || process.env.EMAIL_PORT) || 587,
|
||||
email_user: dbSettings.email_user || process.env.EMAIL_USER || '',
|
||||
email_pass: '', // Don't send password to client
|
||||
email_from: dbSettings.email_from || process.env.EMAIL_FROM || '',
|
||||
email_to: dbSettings.email_to || process.env.EMAIL_TO || '',
|
||||
webhook_enabled: (dbSettings.webhook_enabled || process.env.WEBHOOK_ENABLED) === 'true',
|
||||
webhook_url: dbSettings.webhook_url || process.env.WEBHOOK_URL || '',
|
||||
check_interval: parseInt(dbSettings.check_interval || process.env.CHECK_INTERVAL) || 60000,
|
||||
timeout: parseInt(dbSettings.timeout || process.env.TIMEOUT) || 5000,
|
||||
timezone: dbSettings.timezone || process.env.TIMEZONE || 'UTC',
|
||||
// Data retention settings
|
||||
retention_days: parseInt(dbSettings.retention_days || process.env.RETENTION_DAYS) || 7,
|
||||
auto_cleanup_enabled: (dbSettings.auto_cleanup_enabled || process.env.AUTO_CLEANUP_ENABLED) === 'true',
|
||||
// Incident thresholds
|
||||
consecutive_failures: parseInt(dbSettings.consecutive_failures || process.env.CONSECUTIVE_FAILURES) || 3,
|
||||
grace_period: parseInt(dbSettings.grace_period || process.env.GRACE_PERIOD) || 300,
|
||||
downtime_threshold: parseInt(dbSettings.downtime_threshold || process.env.DOWNTIME_THRESHOLD) || 600,
|
||||
// Alert retry logic
|
||||
alert_retry_count: parseInt(dbSettings.alert_retry_count || process.env.ALERT_RETRY_COUNT) || 3,
|
||||
alert_retry_delay: parseInt(dbSettings.alert_retry_delay || process.env.ALERT_RETRY_DELAY) || 60,
|
||||
fallback_webhook: dbSettings.fallback_webhook || process.env.FALLBACK_WEBHOOK || '',
|
||||
// Alert scheduling
|
||||
global_quiet_hours_start: dbSettings.global_quiet_hours_start || process.env.GLOBAL_QUIET_HOURS_START || '',
|
||||
global_quiet_hours_end: dbSettings.global_quiet_hours_end || process.env.GLOBAL_QUIET_HOURS_END || '',
|
||||
escalation_hours: parseInt(dbSettings.escalation_hours || process.env.ESCALATION_HOURS) || 4,
|
||||
// Dashboard customization
|
||||
default_sort: dbSettings.default_sort || process.env.DEFAULT_SORT || 'name',
|
||||
items_per_page: parseInt(dbSettings.items_per_page || process.env.ITEMS_PER_PAGE) || 20,
|
||||
refresh_interval: parseInt(dbSettings.refresh_interval || process.env.REFRESH_INTERVAL) || 5000,
|
||||
theme: dbSettings.theme || process.env.THEME || 'light',
|
||||
incident_failure_threshold: parseInt(dbSettings.incident_failure_threshold || process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
|
||||
};
|
||||
res.json(settings);
|
||||
res.json(buildSettingsFromDb(dbSettings));
|
||||
} catch (error) {
|
||||
console.error('Error reading settings:', error);
|
||||
// Fallback to env vars only if database read fails
|
||||
const settings = {
|
||||
email_enabled: process.env.EMAIL_ENABLED === 'true',
|
||||
email_host: process.env.EMAIL_HOST || 'smtp.gmail.com',
|
||||
email_port: parseInt(process.env.EMAIL_PORT) || 587,
|
||||
email_user: process.env.EMAIL_USER || '',
|
||||
email_pass: '', // Don't send password to client
|
||||
email_from: process.env.EMAIL_FROM || '',
|
||||
email_to: process.env.EMAIL_TO || '',
|
||||
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
|
||||
webhook_url: process.env.WEBHOOK_URL || '',
|
||||
check_interval: parseInt(process.env.CHECK_INTERVAL) || 60000,
|
||||
timeout: parseInt(process.env.TIMEOUT) || 5000,
|
||||
timezone: process.env.TIMEZONE || 'UTC',
|
||||
retention_days: parseInt(process.env.RETENTION_DAYS) || 7,
|
||||
auto_cleanup_enabled: process.env.AUTO_CLEANUP_ENABLED === 'true',
|
||||
consecutive_failures: parseInt(process.env.CONSECUTIVE_FAILURES) || 3,
|
||||
grace_period: parseInt(process.env.GRACE_PERIOD) || 300,
|
||||
downtime_threshold: parseInt(process.env.DOWNTIME_THRESHOLD) || 600,
|
||||
alert_retry_count: parseInt(process.env.ALERT_RETRY_COUNT) || 3,
|
||||
alert_retry_delay: parseInt(process.env.ALERT_RETRY_DELAY) || 60,
|
||||
fallback_webhook: process.env.FALLBACK_WEBHOOK || '',
|
||||
global_quiet_hours_start: process.env.GLOBAL_QUIET_HOURS_START || '',
|
||||
global_quiet_hours_end: process.env.GLOBAL_QUIET_HOURS_END || '',
|
||||
escalation_hours: parseInt(process.env.ESCALATION_HOURS) || 4,
|
||||
default_sort: process.env.DEFAULT_SORT || 'name',
|
||||
items_per_page: parseInt(process.env.ITEMS_PER_PAGE) || 20,
|
||||
refresh_interval: parseInt(process.env.REFRESH_INTERVAL) || 5000,
|
||||
theme: process.env.THEME || 'light',
|
||||
incident_failure_threshold: parseInt(process.env.INCIDENT_FAILURE_THRESHOLD) || 10,
|
||||
};
|
||||
res.json(settings);
|
||||
res.json(buildSettingsFromDb({}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -638,6 +715,10 @@ app.post('/api/settings', (req, res) => {
|
||||
refresh_interval,
|
||||
theme,
|
||||
incident_failure_threshold,
|
||||
webhook_template,
|
||||
ntfy_enabled,
|
||||
ntfy_url,
|
||||
ntfy_topic,
|
||||
} = req.body;
|
||||
|
||||
// Validate email configuration if enabled
|
||||
@@ -676,118 +757,90 @@ app.post('/api/settings', (req, res) => {
|
||||
return res.status(400).json({ error: 'Timeout must be at least 1000ms' });
|
||||
}
|
||||
|
||||
const envContent = `EMAIL_ENABLED=${email_enabled || false}
|
||||
EMAIL_HOST=${email_host || 'smtp.gmail.com'}
|
||||
EMAIL_PORT=${email_port || 587}
|
||||
EMAIL_USER=${email_user || ''}
|
||||
EMAIL_PASS=${email_pass || process.env.EMAIL_PASS || ''}
|
||||
EMAIL_FROM=${email_from || ''}
|
||||
EMAIL_TO=${email_to || ''}
|
||||
WEBHOOK_ENABLED=${webhook_enabled || false}
|
||||
WEBHOOK_URL=${webhook_url || ''}
|
||||
CHECK_INTERVAL=${check_interval || 60000}
|
||||
TIMEOUT=${timeout || 5000}
|
||||
TIMEZONE=${timezone || 'UTC'}
|
||||
RETENTION_DAYS=${retention_days || 7}
|
||||
AUTO_CLEANUP_ENABLED=${auto_cleanup_enabled || false}
|
||||
CONSECUTIVE_FAILURES=${consecutive_failures || 3}
|
||||
GRACE_PERIOD=${grace_period || 300}
|
||||
DOWNTIME_THRESHOLD=${downtime_threshold || 600}
|
||||
ALERT_RETRY_COUNT=${alert_retry_count || 3}
|
||||
ALERT_RETRY_DELAY=${alert_retry_delay || 60}
|
||||
FALLBACK_WEBHOOK=${fallback_webhook || ''}
|
||||
GLOBAL_QUIET_HOURS_START=${global_quiet_hours_start || ''}
|
||||
GLOBAL_QUIET_HOURS_END=${global_quiet_hours_end || ''}
|
||||
ESCALATION_HOURS=${escalation_hours || 4}
|
||||
DEFAULT_SORT=${default_sort || 'name'}
|
||||
ITEMS_PER_PAGE=${items_per_page || 20}
|
||||
REFRESH_INTERVAL=${refresh_interval || 5000}
|
||||
THEME=${theme || 'light'}
|
||||
INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10}
|
||||
`;
|
||||
// Only persist keys that are explicitly present in this request.
|
||||
// The settings UI saves one section at a time, so absent keys must be
|
||||
// left untouched — otherwise their DB/env values get overwritten with defaults.
|
||||
const body = req.body;
|
||||
const present = (key) => body[key] !== undefined;
|
||||
|
||||
// Field → { envKey, coerce }
|
||||
const FIELDS = {
|
||||
email_enabled: { env: 'EMAIL_ENABLED', coerce: v => String(v) },
|
||||
email_host: { env: 'EMAIL_HOST', coerce: v => v || '' },
|
||||
email_port: { env: 'EMAIL_PORT', coerce: v => String(v || 587) },
|
||||
email_user: { env: 'EMAIL_USER', coerce: v => v || '' },
|
||||
email_pass: { env: 'EMAIL_PASS', coerce: v => v || process.env.EMAIL_PASS || '' },
|
||||
email_from: { env: 'EMAIL_FROM', coerce: v => v || '' },
|
||||
email_to: { env: 'EMAIL_TO', coerce: v => v || '' },
|
||||
webhook_enabled: { env: 'WEBHOOK_ENABLED', coerce: v => String(v) },
|
||||
webhook_url: { env: 'WEBHOOK_URL', coerce: v => v || '' },
|
||||
webhook_template: { env: 'WEBHOOK_TEMPLATE', coerce: v => v || '' },
|
||||
check_interval: { env: 'CHECK_INTERVAL', coerce: v => String(v || 60000) },
|
||||
timeout: { env: 'TIMEOUT', coerce: v => String(v || 5000) },
|
||||
timezone: { env: 'TIMEZONE', coerce: v => v || 'UTC' },
|
||||
retention_days: { env: 'RETENTION_DAYS', coerce: v => String(v || 7) },
|
||||
auto_cleanup_enabled: { env: 'AUTO_CLEANUP_ENABLED', coerce: v => String(v) },
|
||||
consecutive_failures: { env: 'CONSECUTIVE_FAILURES', coerce: v => String(v || 3) },
|
||||
grace_period: { env: 'GRACE_PERIOD', coerce: v => String(v || 300) },
|
||||
downtime_threshold: { env: 'DOWNTIME_THRESHOLD', coerce: v => String(v || 600) },
|
||||
alert_retry_count: { env: 'ALERT_RETRY_COUNT', coerce: v => String(v || 3) },
|
||||
alert_retry_delay: { env: 'ALERT_RETRY_DELAY', coerce: v => String(v || 60) },
|
||||
fallback_webhook: { env: 'FALLBACK_WEBHOOK', coerce: v => v || '' },
|
||||
global_quiet_hours_start:{ env: 'GLOBAL_QUIET_HOURS_START',coerce: v => v || '' },
|
||||
global_quiet_hours_end: { env: 'GLOBAL_QUIET_HOURS_END', coerce: v => v || '' },
|
||||
escalation_hours: { env: 'ESCALATION_HOURS', coerce: v => String(v || 4) },
|
||||
default_sort: { env: 'DEFAULT_SORT', coerce: v => v || 'name' },
|
||||
items_per_page: { env: 'ITEMS_PER_PAGE', coerce: v => String(v || 20) },
|
||||
refresh_interval: { env: 'REFRESH_INTERVAL', coerce: v => String(v || 5000) },
|
||||
ntfy_enabled: { env: 'NTFY_ENABLED', coerce: v => String(v) },
|
||||
ntfy_url: { env: 'NTFY_URL', coerce: v => v || 'https://ntfy.sh' },
|
||||
ntfy_topic: { env: 'NTFY_TOPIC', coerce: v => v || '' },
|
||||
incident_failure_threshold: { env: 'INCIDENT_FAILURE_THRESHOLD', coerce: v => String(v || 10) },
|
||||
slow_alert_consecutive: { env: 'SLOW_ALERT_CONSECUTIVE', coerce: v => String(v || 3) },
|
||||
};
|
||||
|
||||
// Collect only the fields present in this request
|
||||
const updates = [];
|
||||
for (const [key, cfg] of Object.entries(FIELDS)) {
|
||||
if (present(key)) {
|
||||
updates.push({ key, envKey: cfg.env, value: cfg.coerce(body[key]) });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
// Update DB — only touched keys
|
||||
const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)');
|
||||
for (const u of updates) upsert.run(u.key, u.value);
|
||||
|
||||
// Update runtime config in process.env - these changes persist until server restart
|
||||
process.env.TIMEZONE = timezone || 'UTC';
|
||||
process.env.EMAIL_ENABLED = String(email_enabled);
|
||||
process.env.EMAIL_HOST = email_host;
|
||||
process.env.EMAIL_PORT = String(email_port);
|
||||
process.env.EMAIL_USER = email_user;
|
||||
process.env.EMAIL_PASS = email_pass || process.env.EMAIL_PASS || '';
|
||||
process.env.EMAIL_FROM = email_from;
|
||||
process.env.EMAIL_TO = email_to;
|
||||
process.env.WEBHOOK_ENABLED = String(webhook_enabled);
|
||||
process.env.WEBHOOK_URL = webhook_url;
|
||||
process.env.CHECK_INTERVAL = String(check_interval);
|
||||
process.env.TIMEOUT = String(timeout);
|
||||
process.env.RETENTION_DAYS = String(retention_days || 7);
|
||||
process.env.AUTO_CLEANUP_ENABLED = String(auto_cleanup_enabled);
|
||||
process.env.CONSECUTIVE_FAILURES = String(consecutive_failures || 3);
|
||||
process.env.GRACE_PERIOD = String(grace_period || 300);
|
||||
process.env.DOWNTIME_THRESHOLD = String(downtime_threshold || 600);
|
||||
process.env.ALERT_RETRY_COUNT = String(alert_retry_count || 3);
|
||||
process.env.ALERT_RETRY_DELAY = String(alert_retry_delay || 60);
|
||||
process.env.FALLBACK_WEBHOOK = fallback_webhook || '';
|
||||
process.env.GLOBAL_QUIET_HOURS_START = global_quiet_hours_start || '';
|
||||
process.env.GLOBAL_QUIET_HOURS_END = global_quiet_hours_end || '';
|
||||
process.env.ESCALATION_HOURS = String(escalation_hours || 4);
|
||||
process.env.DEFAULT_SORT = default_sort || 'name';
|
||||
process.env.ITEMS_PER_PAGE = String(items_per_page || 20);
|
||||
process.env.REFRESH_INTERVAL = String(refresh_interval || 5000);
|
||||
process.env.THEME = theme || 'light';
|
||||
process.env.INCIDENT_FAILURE_THRESHOLD = String(incident_failure_threshold || 10);
|
||||
// Update process.env — only touched keys
|
||||
for (const u of updates) process.env[u.envKey] = u.value;
|
||||
|
||||
// Also save to database for persistence
|
||||
const settingsTable = [
|
||||
{ key: 'email_enabled', value: String(email_enabled) },
|
||||
{ key: 'email_host', value: email_host },
|
||||
{ key: 'email_port', value: String(email_port) },
|
||||
{ key: 'email_user', value: email_user },
|
||||
{ key: 'email_from', value: email_from },
|
||||
{ key: 'email_to', value: email_to },
|
||||
{ key: 'webhook_enabled', value: String(webhook_enabled) },
|
||||
{ key: 'webhook_url', value: webhook_url },
|
||||
{ key: 'timezone', value: timezone || 'UTC' },
|
||||
{ key: 'retention_days', value: String(retention_days || 7) },
|
||||
{ key: 'check_interval', value: String(check_interval || 60000) },
|
||||
{ key: 'timeout', value: String(timeout || 5000) },
|
||||
{ key: 'consecutive_failures', value: String(consecutive_failures || 3) },
|
||||
{ key: 'grace_period', value: String(grace_period || 300) },
|
||||
{ key: 'downtime_threshold', value: String(downtime_threshold || 600) },
|
||||
{ key: 'alert_retry_count', value: String(alert_retry_count || 3) },
|
||||
{ key: 'alert_retry_delay', value: String(alert_retry_delay || 60) },
|
||||
{ key: 'fallback_webhook', value: fallback_webhook || '' },
|
||||
{ key: 'global_quiet_hours_start', value: global_quiet_hours_start || '' },
|
||||
{ key: 'global_quiet_hours_end', value: global_quiet_hours_end || '' },
|
||||
{ key: 'escalation_hours', value: String(escalation_hours || 4) },
|
||||
{ key: 'default_sort', value: default_sort || 'name' },
|
||||
{ key: 'items_per_page', value: String(items_per_page || 20) },
|
||||
{ key: 'refresh_interval', value: String(refresh_interval || 5000) },
|
||||
{ key: 'auto_cleanup_enabled', value: String(auto_cleanup_enabled || false) },
|
||||
{ key: 'theme', value: theme || 'light' },
|
||||
{ key: 'incident_failure_threshold', value: String(incident_failure_threshold || 10) },
|
||||
];
|
||||
|
||||
for (const setting of settingsTable) {
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO settings (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
`).run(setting.key, setting.value);
|
||||
// Merge into .env file (read → patch → write) so restarts stay consistent
|
||||
let envLines = [];
|
||||
try { envLines = fs.readFileSync(envPath, 'utf8').split('\n'); } catch (_) {}
|
||||
const envMap = {};
|
||||
for (const line of envLines) {
|
||||
const eq = line.indexOf('=');
|
||||
if (eq > 0) envMap[line.slice(0, eq)] = line.slice(eq + 1);
|
||||
}
|
||||
envMap['THEME'] = 'dark';
|
||||
for (const u of updates) envMap[u.envKey] = u.value;
|
||||
fs.writeFileSync(envPath, Object.entries(envMap).map(([k, v]) => `${k}=${v}`).join('\n') + '\n');
|
||||
|
||||
notificationService.setConfig({
|
||||
email_enabled: email_enabled === true || email_enabled === 'true',
|
||||
email_host,
|
||||
email_port,
|
||||
email_user,
|
||||
email_pass: email_pass || process.env.EMAIL_PASS || '',
|
||||
email_from,
|
||||
email_to,
|
||||
webhook_enabled: webhook_enabled === true || webhook_enabled === 'true',
|
||||
webhook_url,
|
||||
});
|
||||
// Update in-memory notification config for the fields that changed
|
||||
const notifKeys = ['email_enabled','email_host','email_port','email_user','email_pass',
|
||||
'email_from','email_to','webhook_enabled','webhook_url','webhook_template',
|
||||
'ntfy_enabled','ntfy_url','ntfy_topic'];
|
||||
const changedNotif = updates.filter(u => notifKeys.includes(u.key));
|
||||
if (changedNotif.length > 0) {
|
||||
const patch = {};
|
||||
for (const u of changedNotif) patch[u.key] = u.value;
|
||||
// Booleans need coercion
|
||||
if (patch.email_enabled !== undefined) patch.email_enabled = patch.email_enabled === 'true';
|
||||
if (patch.webhook_enabled !== undefined) patch.webhook_enabled = patch.webhook_enabled === 'true';
|
||||
if (patch.ntfy_enabled !== undefined) patch.ntfy_enabled = patch.ntfy_enabled === 'true';
|
||||
notificationService.setConfig(patch);
|
||||
}
|
||||
|
||||
res.json({ message: 'Settings saved successfully' });
|
||||
} catch (error) {
|
||||
@@ -1486,6 +1539,46 @@ app.post('/api/test-webhook', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/test-ntfy', async (req, res) => {
|
||||
const axios = require('axios');
|
||||
const { ntfy_url, ntfy_topic } = req.body;
|
||||
if (!ntfy_topic) return res.status(400).json({ error: 'ntfy topic is required' });
|
||||
const base = (ntfy_url || 'https://ntfy.sh').replace(/\/$/, '');
|
||||
try {
|
||||
await axios.post(`${base}/`, {
|
||||
topic: ntfy_topic,
|
||||
title: '🔔 SkyWatch Test',
|
||||
message: 'This is a test notification from SkyWatch',
|
||||
priority: 3,
|
||||
tags: ['white_check_mark'],
|
||||
});
|
||||
res.json({ message: 'Test notification sent to ntfy!' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Network discovery — scan local network for monitorable hosts
|
||||
app.post('/api/network-discovery', async (req, res) => {
|
||||
try {
|
||||
const { subnet } = req.body || {};
|
||||
const hosts = await discoverNetwork(subnet || null);
|
||||
res.json({ hosts });
|
||||
} catch (error) {
|
||||
console.error('Network discovery error:', error);
|
||||
res.status(500).json({ error: 'Network discovery failed', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Return detected local subnets so the UI can offer them as quick-select options
|
||||
app.get('/api/network-subnets', (req, res) => {
|
||||
try {
|
||||
res.json({ subnets: getLocalSubnets() });
|
||||
} catch (e) {
|
||||
res.json({ subnets: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear cache endpoint for debugging
|
||||
app.post('/api/cache/clear', (req, res) => {
|
||||
cache.clear();
|
||||
@@ -1683,6 +1776,82 @@ app.post('/api/resources/:id/alert-rules', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get alert rules for a resource
|
||||
app.get('/api/resources/:id/alert-rules', (req, res) => {
|
||||
const resource = db.prepare('SELECT consecutive_failures_threshold, response_time_threshold FROM resources WHERE id = ?').get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ error: 'Resource not found' });
|
||||
res.json(resource);
|
||||
});
|
||||
|
||||
// Heartbeat ping endpoint — cron jobs POST here to signal they're alive
|
||||
app.post('/api/heartbeat/:token', rateLimit(20), (req, res) => {
|
||||
const resource = db.prepare('SELECT id, name FROM resources WHERE heartbeat_token = ? AND enabled = 1').get(req.params.token);
|
||||
if (!resource) return res.status(404).json({ error: 'Unknown heartbeat token' });
|
||||
|
||||
db.prepare('UPDATE resources SET last_heartbeat_at = CURRENT_TIMESTAMP WHERE id = ?').run(resource.id);
|
||||
|
||||
// Write an 'up' check so we have a record of the ping
|
||||
db.prepare(`
|
||||
INSERT INTO checks (resource_id, status, response_time, checked_at)
|
||||
VALUES (?, 'up', 0, CURRENT_TIMESTAMP)
|
||||
`).run(resource.id);
|
||||
|
||||
res.json({ ok: true, resource: resource.name });
|
||||
});
|
||||
|
||||
// Public status page — only returns resources with is_public = 1
|
||||
app.get('/api/status-page', rateLimit(60), (req, res) => {
|
||||
try {
|
||||
const resources = db.prepare('SELECT * FROM resources WHERE is_public = 1 AND enabled = 1 ORDER BY name').all();
|
||||
const incidents = db.prepare(`
|
||||
SELECT i.*, r.name AS resource_name FROM incidents i
|
||||
JOIN resources r ON r.id = i.resource_id
|
||||
WHERE r.is_public = 1 AND i.resolved_at IS NULL
|
||||
ORDER BY i.started_at DESC
|
||||
`).all();
|
||||
const maintenanceWindows = db.prepare(`
|
||||
SELECT mw.*, r.name AS resource_name FROM maintenance_windows mw
|
||||
JOIN resources r ON r.id = mw.resource_id
|
||||
WHERE r.is_public = 1
|
||||
ORDER BY mw.start_time DESC
|
||||
`).all();
|
||||
|
||||
const dailyRows = db.prepare(`
|
||||
SELECT resource_id, date(checked_at) as day,
|
||||
ROUND(SUM(CASE WHEN status='up' THEN 100.0 ELSE 0 END) / COUNT(*), 1) as uptime_pct
|
||||
FROM checks
|
||||
WHERE checked_at > datetime('now', '-90 days')
|
||||
GROUP BY resource_id, date(checked_at)
|
||||
ORDER BY resource_id, day ASC
|
||||
`).all();
|
||||
const dailyByResource = {};
|
||||
dailyRows.forEach(row => {
|
||||
if (!dailyByResource[row.resource_id]) dailyByResource[row.resource_id] = [];
|
||||
dailyByResource[row.resource_id].push({ day: row.day, uptime: row.uptime_pct });
|
||||
});
|
||||
|
||||
const overview = resources.map(resource => {
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const lastCheck = monitorService.getLastCheck(resource.id);
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
url: resource.type === 'heartbeat' ? null : resource.url,
|
||||
type: resource.type,
|
||||
status: lastCheck?.status || 'unknown',
|
||||
uptime: stats.uptime,
|
||||
avgResponseTime: stats.avgResponseTime,
|
||||
lastCheck: lastCheck?.checked_at,
|
||||
dailyUptime: dailyByResource[resource.id] || [],
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ resources: overview, incidents, maintenanceWindows });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to load status page data' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get transaction steps for a resource
|
||||
app.get('/api/resources/:id/transaction-steps', (req, res) => {
|
||||
const { id } = req.params;
|
||||
@@ -1770,7 +1939,8 @@ app.get('/api/resources/export', (req, res) => {
|
||||
}
|
||||
|
||||
// CSV header
|
||||
const headers = ['name', 'url', 'type', 'check_interval', 'timeout', 'sla_target', 'tags', 'group_id', 'email_to'];
|
||||
const headers = ['name', 'url', 'type', 'check_interval', 'timeout', 'sla_target', 'tags', 'group_id', 'email_to',
|
||||
'http_method', 'http_body', 'heartbeat_timeout', 'is_public', 'consecutive_failures_threshold', 'response_time_threshold'];
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
// CSV rows
|
||||
@@ -1785,6 +1955,12 @@ app.get('/api/resources/export', (req, res) => {
|
||||
`"${(resource.tags || '').replace(/"/g, '""')}"`,
|
||||
resource.group_id || '',
|
||||
`"${(resource.email_to || '').replace(/"/g, '""')}"`,
|
||||
resource.http_method || 'GET',
|
||||
`"${(resource.http_body || '').replace(/"/g, '""')}"`,
|
||||
resource.heartbeat_timeout || 300000,
|
||||
resource.is_public !== 0 ? 1 : 0,
|
||||
resource.consecutive_failures_threshold || 1,
|
||||
resource.response_time_threshold || '',
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
});
|
||||
@@ -1974,8 +2150,9 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
|
||||
}
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO resources (name, url, type, check_interval, timeout, sla_target, tags, group_id, email_to)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO resources (name, url, type, check_interval, timeout, sla_target, tags, group_id, email_to,
|
||||
http_method, http_body, heartbeat_timeout, is_public, consecutive_failures_threshold, response_time_threshold)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
@@ -1987,7 +2164,13 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
|
||||
slaTarget,
|
||||
row.tags || '',
|
||||
row.group_id ? parseInt(row.group_id) : null,
|
||||
row.email_to || ''
|
||||
row.email_to || '',
|
||||
row.http_method || 'GET',
|
||||
row.http_body || null,
|
||||
parseInt(row.heartbeat_timeout) || 300000,
|
||||
row.is_public === '0' || row.is_public === 'false' ? 0 : 1,
|
||||
parseInt(row.consecutive_failures_threshold) || 1,
|
||||
row.response_time_threshold ? parseInt(row.response_time_threshold) : null
|
||||
);
|
||||
|
||||
imported.push(row.name);
|
||||
@@ -2027,6 +2210,12 @@ app.use(express.static(path.join(__dirname, '../client/build'), {
|
||||
}
|
||||
}));
|
||||
|
||||
// Error tracking middleware — must be after all routes
|
||||
app.use((err, req, res, next) => {
|
||||
metrics.trackError(err, { method: req.method, url: req.url, ip: req.ip });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Only serve index.html for non-file routes (no extension)
|
||||
app.get('*', (req, res, next) => {
|
||||
// If the request has a file extension, let static middleware handle it or 404
|
||||
@@ -2039,46 +2228,7 @@ app.get('*', (req, res, next) => {
|
||||
// WebSocket handler function
|
||||
function broadcastDashboardUpdate() {
|
||||
try {
|
||||
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all();
|
||||
const groups = db.prepare('SELECT * FROM groups ORDER BY name').all();
|
||||
|
||||
const overview = resources.map(resource => {
|
||||
const lastCheck = monitorService.getLastCheck(resource.id);
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents
|
||||
WHERE resource_id = ? AND resolved_at IS NULL
|
||||
`).get(resource.id);
|
||||
|
||||
const recentChecks = db.prepare(`
|
||||
SELECT response_time, status, REPLACE(checked_at, ' ', 'T') || 'Z' as checked_at
|
||||
FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT 15
|
||||
`).all(resource.id).reverse();
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
url: resource.url,
|
||||
type: resource.type,
|
||||
group_id: resource.group_id,
|
||||
enabled: resource.enabled,
|
||||
status: lastCheck?.status || 'unknown',
|
||||
uptime: stats.uptime,
|
||||
avgResponseTime: stats.avgResponseTime,
|
||||
lastCheck: lastCheck?.checked_at,
|
||||
hasActiveIncident: !!activeIncident,
|
||||
recentChecks,
|
||||
};
|
||||
});
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: 'dashboard',
|
||||
data: { resources: overview, groups }
|
||||
});
|
||||
|
||||
const message = JSON.stringify({ type: 'dashboard', data: getDashboardPayload() });
|
||||
wsClients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
|
||||
+57
-13
@@ -31,9 +31,14 @@ function parseHostPort(urlString, fallbackPort) {
|
||||
|
||||
class MonitorService {
|
||||
constructor() {
|
||||
// Keep-alive agents for pooled HTTP/S connections
|
||||
this.httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 });
|
||||
this.httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 50 });
|
||||
// Disable keep-alive to avoid "Parse Error: Data after Connection: close"
|
||||
// crashes. Keep-alive connection reuse causes the HTTP parser to throw
|
||||
// when a monitored server sends extra data after Connection: close — the
|
||||
// socket-level error becomes an uncaught exception that kills the process.
|
||||
// Monitoring checks are infrequent enough that connection pooling provides
|
||||
// no meaningful benefit.
|
||||
this.httpAgent = new http.Agent({ keepAlive: false });
|
||||
this.httpsAgent = new https.Agent({ keepAlive: false });
|
||||
|
||||
// Circuit breaker for HTTP checks (5 failures before opening, 60s reset)
|
||||
this.httpCircuitBreaker = new CircuitBreaker(5, 60000, this.isTransientError);
|
||||
@@ -98,12 +103,36 @@ class MonitorService {
|
||||
case 'icmp':
|
||||
case 'ping':
|
||||
return this.checkIcmp(resource, startTime, base);
|
||||
case 'heartbeat':
|
||||
return this.checkHeartbeat(resource, startTime, base);
|
||||
default:
|
||||
// Fallback to HTTP if unknown
|
||||
return this.checkHttp(resource, startTime, base);
|
||||
}
|
||||
}
|
||||
|
||||
checkHeartbeat(resource, startTime, base) {
|
||||
const now = Date.now();
|
||||
const lastPing = resource.last_heartbeat_at
|
||||
? new Date(resource.last_heartbeat_at).getTime()
|
||||
: null;
|
||||
const timeout = resource.heartbeat_timeout || 300000;
|
||||
|
||||
if (!lastPing) {
|
||||
return { ...base, status: 'down', response_time: null, error_message: 'No heartbeat received yet' };
|
||||
}
|
||||
const elapsed = now - lastPing;
|
||||
if (elapsed > timeout) {
|
||||
return {
|
||||
...base,
|
||||
status: 'down',
|
||||
response_time: elapsed,
|
||||
error_message: `Missed heartbeat — ${Math.round(elapsed / 1000)}s since last ping (timeout: ${timeout / 1000}s)`,
|
||||
};
|
||||
}
|
||||
return { ...base, status: 'up', response_time: elapsed };
|
||||
}
|
||||
|
||||
async checkHttp(resource, startTime, base) {
|
||||
let result = { ...base };
|
||||
const circuitBreaker = this.getResourceCircuitBreaker(resource.id);
|
||||
@@ -121,15 +150,26 @@ class MonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
const isHttps = resource.url.startsWith('https');
|
||||
const response = await axios.get(resource.url, {
|
||||
const method = (resource.http_method || 'GET').toUpperCase();
|
||||
const requestConfig = {
|
||||
method,
|
||||
url: resource.url,
|
||||
timeout: resource.timeout,
|
||||
validateStatus: () => true,
|
||||
maxRedirects: 5,
|
||||
headers,
|
||||
httpAgent: this.httpAgent,
|
||||
httpsAgent: this.httpsAgent,
|
||||
});
|
||||
};
|
||||
if (resource.http_body && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
try {
|
||||
requestConfig.data = JSON.parse(resource.http_body);
|
||||
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
||||
} catch {
|
||||
requestConfig.data = resource.http_body;
|
||||
}
|
||||
}
|
||||
const response = await axios(requestConfig);
|
||||
const responseTime = Date.now() - startTime;
|
||||
const isUp = response.status >= 200 && response.status < 400;
|
||||
|
||||
@@ -353,9 +393,12 @@ class MonitorService {
|
||||
handleIncident(resourceId, isDown) {
|
||||
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(resourceId);
|
||||
|
||||
// Get incident failure threshold from settings (default 10)
|
||||
// Per-resource threshold overrides global setting
|
||||
const setting = db.prepare("SELECT value FROM settings WHERE key = 'incident_failure_threshold'").get();
|
||||
const threshold = parseInt(setting?.value || '10');
|
||||
const globalThreshold = parseInt(setting?.value || '10');
|
||||
const threshold = (resource?.consecutive_failures_threshold > 0)
|
||||
? resource.consecutive_failures_threshold
|
||||
: globalThreshold;
|
||||
|
||||
const activeIncident = db.prepare(`
|
||||
SELECT * FROM incidents
|
||||
@@ -371,8 +414,8 @@ class MonitorService {
|
||||
LIMIT ?
|
||||
`).all(resourceId, threshold);
|
||||
|
||||
const consecutiveFailures = recentChecks.every(c => c.status === 'down') ? recentChecks.length :
|
||||
recentChecks.findIndex(c => c.status === 'up') + 1;
|
||||
const firstUpIdx = recentChecks.findIndex(c => c.status === 'up');
|
||||
const consecutiveFailures = firstUpIdx === -1 ? recentChecks.length : firstUpIdx;
|
||||
|
||||
// Only trigger incident if we've hit the threshold
|
||||
if (consecutiveFailures >= threshold && !activeIncident) {
|
||||
@@ -727,9 +770,10 @@ class MonitorService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return average of response times
|
||||
const sum = checks.reduce((acc, c) => acc + (c.response_time || 0), 0);
|
||||
return Math.round(sum / checks.length);
|
||||
// Use P95 so the baseline reflects realistic worst-case, not the average.
|
||||
// Threshold is 2× P95, making it very unlikely to trigger on normal variance.
|
||||
const p95Index = Math.min(Math.floor(checks.length * 0.95), checks.length - 1);
|
||||
return Math.round(checks[p95Index].response_time);
|
||||
}
|
||||
|
||||
// Check if response time is anomalous
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
const { exec } = require('child_process');
|
||||
const net = require('net');
|
||||
const os = require('os');
|
||||
|
||||
const COMMON_HTTP_PORTS = [80, 443, 8080, 8443, 3000, 3001, 5000, 8000, 8888, 9000, 9443];
|
||||
const PROBE_TIMEOUT_MS = 800;
|
||||
const PING_TIMEOUT_MS = 500;
|
||||
const MAX_HOSTS_TO_SCAN = 50;
|
||||
|
||||
function getLocalSubnets() {
|
||||
const ifaces = os.networkInterfaces();
|
||||
const subnets = [];
|
||||
for (const iface of Object.values(ifaces)) {
|
||||
for (const addr of iface) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) {
|
||||
subnets.push({ ip: addr.address, prefix: addr.cidr });
|
||||
}
|
||||
}
|
||||
}
|
||||
return subnets;
|
||||
}
|
||||
|
||||
function subnetToRange(cidr) {
|
||||
try {
|
||||
const [base, bits] = cidr.split('/');
|
||||
const prefixLen = parseInt(bits);
|
||||
if (prefixLen < 16 || prefixLen > 30) return [];
|
||||
const parts = base.split('.').map(Number);
|
||||
const hostBits = 32 - prefixLen;
|
||||
const totalHosts = Math.min(Math.pow(2, hostBits) - 2, MAX_HOSTS_TO_SCAN);
|
||||
const baseNum = (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3];
|
||||
const networkNum = baseNum & (~0 << hostBits);
|
||||
const ips = [];
|
||||
for (let i = 1; i <= totalHosts; i++) {
|
||||
const n = networkNum + i;
|
||||
ips.push(`${(n >> 24) & 255}.${(n >> 16) & 255}.${(n >> 8) & 255}.${n & 255}`);
|
||||
}
|
||||
return ips;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function pingHost(ip) {
|
||||
return new Promise((resolve) => {
|
||||
const cmd = process.platform === 'darwin'
|
||||
? `ping -c 1 -W ${PING_TIMEOUT_MS} ${ip}`
|
||||
: `ping -c 1 -W 1 ${ip}`;
|
||||
exec(cmd, { timeout: PING_TIMEOUT_MS + 500 }, (err) => resolve(!err));
|
||||
});
|
||||
}
|
||||
|
||||
function probePort(ip, port) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
let done = false;
|
||||
sock.setTimeout(PROBE_TIMEOUT_MS);
|
||||
sock.on('connect', () => { done = true; sock.destroy(); resolve(true); });
|
||||
sock.on('timeout', () => { if (!done) { done = true; sock.destroy(); resolve(false); } });
|
||||
sock.on('error', () => { if (!done) { done = true; resolve(false); } });
|
||||
try { sock.connect(port, ip); } catch { resolve(false); }
|
||||
});
|
||||
}
|
||||
|
||||
async function probeHost(ip) {
|
||||
const openPorts = [];
|
||||
await Promise.all(
|
||||
COMMON_HTTP_PORTS.map(async (port) => {
|
||||
if (await probePort(ip, port)) openPorts.push(port);
|
||||
})
|
||||
);
|
||||
return openPorts;
|
||||
}
|
||||
|
||||
function inferServiceType(ip, openPorts) {
|
||||
if (openPorts.includes(443) || openPorts.includes(8443) || openPorts.includes(9443)) {
|
||||
return { type: 'https', port: openPorts.find(p => [443, 8443, 9443].includes(p)), scheme: 'https' };
|
||||
}
|
||||
if (openPorts.length > 0) {
|
||||
const p = openPorts[0];
|
||||
return { type: 'http', port: p, scheme: 'http' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getArpHosts() {
|
||||
return new Promise((resolve) => {
|
||||
exec('arp -a', { timeout: 3000 }, (err, stdout) => {
|
||||
if (err) return resolve([]);
|
||||
const hosts = [];
|
||||
const lines = stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
const match = line.match(/\((\d+\.\d+\.\d+\.\d+)\)/);
|
||||
if (match && !line.includes('incomplete')) {
|
||||
const name = line.match(/^(\S+)/)?.[1];
|
||||
hosts.push({ ip: match[1], hostname: name && name !== '?' ? name : null });
|
||||
}
|
||||
}
|
||||
resolve(hosts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function discoverNetwork(customSubnet = null) {
|
||||
const arpHosts = await getArpHosts();
|
||||
const arpIps = new Set(arpHosts.map(h => h.ip));
|
||||
|
||||
const allIps = new Map();
|
||||
|
||||
if (customSubnet) {
|
||||
// Scan the user-specified subnet only (no ARP seeding, still probe all IPs in range)
|
||||
const range = subnetToRange(customSubnet);
|
||||
for (const ip of range) allIps.set(ip, { ip, hostname: null });
|
||||
// Supplement with ARP hostnames for any IPs that happen to be in the ARP table
|
||||
for (const h of arpHosts) {
|
||||
if (allIps.has(h.ip) && h.hostname) allIps.get(h.ip).hostname = h.hostname;
|
||||
}
|
||||
} else {
|
||||
// Auto-detect: ARP table + all local subnets
|
||||
for (const h of arpHosts) allIps.set(h.ip, { ip: h.ip, hostname: h.hostname });
|
||||
for (const subnet of getLocalSubnets()) {
|
||||
if (!subnet.prefix) continue;
|
||||
const range = subnetToRange(subnet.prefix);
|
||||
for (const ip of range) {
|
||||
if (!allIps.has(ip)) allIps.set(ip, { ip, hostname: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ping all IPs in parallel (skip ones already in ARP — they're reachable by definition)
|
||||
const candidates = [...allIps.values()];
|
||||
const pingResults = await Promise.all(
|
||||
candidates.map(async (host) => {
|
||||
const alive = arpIps.has(host.ip) || await pingHost(host.ip);
|
||||
return { ...host, alive };
|
||||
})
|
||||
);
|
||||
|
||||
const alive = pingResults.filter(h => h.alive);
|
||||
|
||||
// Probe open HTTP ports on alive hosts
|
||||
const results = await Promise.all(
|
||||
alive.map(async (host) => {
|
||||
const openPorts = await probeHost(host.ip);
|
||||
const service = inferServiceType(host.ip, openPorts);
|
||||
return {
|
||||
ip: host.ip,
|
||||
hostname: host.hostname,
|
||||
openPorts,
|
||||
service,
|
||||
suggestedUrl: service ? `${service.scheme}://${host.ip}${service.port !== 80 && service.port !== 443 ? ':' + service.port : ''}` : null,
|
||||
suggestedType: service ? service.type : 'icmp',
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Sort: HTTP-accessible hosts first, then by IP
|
||||
return results.sort((a, b) => {
|
||||
const aHttp = a.openPorts.length > 0 ? 0 : 1;
|
||||
const bHttp = b.openPorts.length > 0 ? 0 : 1;
|
||||
if (aHttp !== bHttp) return aHttp - bHttp;
|
||||
return a.ip.localeCompare(b.ip, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { discoverNetwork, getLocalSubnets };
|
||||
+195
-19
@@ -20,13 +20,18 @@ class NotificationService {
|
||||
email_to: process.env.EMAIL_TO,
|
||||
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
|
||||
webhook_url: process.env.WEBHOOK_URL,
|
||||
webhook_template: process.env.WEBHOOK_TEMPLATE || '',
|
||||
ntfy_enabled: process.env.NTFY_ENABLED === 'true',
|
||||
ntfy_url: process.env.NTFY_URL || 'https://ntfy.sh',
|
||||
ntfy_topic: process.env.NTFY_TOPIC || '',
|
||||
});
|
||||
}
|
||||
|
||||
setConfig(config) {
|
||||
this.emailEnabled = !!config.email_enabled;
|
||||
this.webhookEnabled = !!config.webhook_enabled;
|
||||
this.config = config;
|
||||
this.ntfyEnabled = !!config.ntfy_enabled;
|
||||
this.config = { ...this.config, ...config };
|
||||
|
||||
// Recreate transporter when email config changes; skip if incomplete
|
||||
if (
|
||||
@@ -69,10 +74,12 @@ class NotificationService {
|
||||
}
|
||||
|
||||
const isDown = incident.type === 'started';
|
||||
const statusEmoji = isDown ? '🔴' : '🟢';
|
||||
const statusText = isDown ? 'DOWN' : 'UP';
|
||||
|
||||
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}`;
|
||||
const isSlow = incident.type === 'slow';
|
||||
const statusEmoji = isDown ? '🔴' : isSlow ? '🟡' : '🟢';
|
||||
const statusText = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
|
||||
const slowDetail = isSlow ? `\nResponse Time: ${incident.responseTime}ms (threshold: ${incident.threshold}ms)` : '';
|
||||
|
||||
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}${slowDetail}`;
|
||||
|
||||
const promises = [];
|
||||
|
||||
@@ -100,19 +107,25 @@ class NotificationService {
|
||||
promises.push(this.sendWebhook(resource, message, incident.type));
|
||||
}
|
||||
|
||||
if (this.ntfyEnabled) {
|
||||
promises.push(this.sendNtfy(resource, incident.type));
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
// Save notification to in-app notification center
|
||||
const status = isDown ? 'down' : 'up';
|
||||
const status = isDown ? 'down' : isSlow ? 'slow' : 'up';
|
||||
this.saveNotification(resource, incident, status, message);
|
||||
}
|
||||
|
||||
saveNotification(resource, incident, status, message) {
|
||||
try {
|
||||
const db = require('./database');
|
||||
const title = status === 'down' ?
|
||||
`🔴 ${resource.name} is DOWN` :
|
||||
`🟢 ${resource.name} is UP`;
|
||||
const title = status === 'down'
|
||||
? `🔴 ${resource.name} is DOWN`
|
||||
: status === 'slow'
|
||||
? `🟡 ${resource.name} is SLOW`
|
||||
: `🟢 ${resource.name} is UP`;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO notifications (resource_id, incident_id, type, title, message, read)
|
||||
@@ -144,6 +157,116 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
async flushAlerts(pendingAlerts) {
|
||||
if (pendingAlerts.length === 0) return;
|
||||
if (pendingAlerts.length === 1) {
|
||||
const { resource, incident, stats } = pendingAlerts[0];
|
||||
await this.sendAlert(resource, incident, stats);
|
||||
return;
|
||||
}
|
||||
// Multiple alerts in one check cycle — save individual in-app entries but send one grouped email/webhook
|
||||
for (const { resource, incident } of pendingAlerts) {
|
||||
const status = incident.type === 'started' ? 'down' : 'up';
|
||||
const message = `${resource.name} is ${status.toUpperCase()}. URL: ${resource.url}`;
|
||||
this.saveNotification(resource, incident, status, message);
|
||||
}
|
||||
await this.sendGroupedEmail(pendingAlerts);
|
||||
if (this.webhookEnabled) {
|
||||
await this.sendGroupedWebhook(pendingAlerts);
|
||||
}
|
||||
if (this.ntfyEnabled) {
|
||||
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
|
||||
const ntfyTopic = this.config.ntfy_topic;
|
||||
if (ntfyTopic) {
|
||||
try {
|
||||
await axios.post(`${ntfyUrl.replace(/\/$/, '')}/`, {
|
||||
topic: ntfyTopic,
|
||||
title: `⚠️ ${pendingAlerts.length} monitors changed status`,
|
||||
message: pendingAlerts.map(({ resource, incident }) =>
|
||||
`${incident.type === 'started' ? '🔴' : '🟢'} ${resource.name}`
|
||||
).join('\n'),
|
||||
priority: 4,
|
||||
tags: ['warning'],
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendGroupedEmail(alerts) {
|
||||
if (!this.transporter) return;
|
||||
const targetEmail = this.config.email_to;
|
||||
if (!targetEmail || !this.emailEnabled) return;
|
||||
try {
|
||||
const rows = alerts.map(({ resource, incident }) => {
|
||||
const status = incident.type === 'started' ? 'DOWN' : 'UP';
|
||||
const emoji = incident.type === 'started' ? '🔴' : '🟢';
|
||||
return `<li>${emoji} <strong>${resource.name}</strong> is ${status} — ${resource.url}</li>`;
|
||||
}).join('');
|
||||
await this.transporter.sendMail({
|
||||
from: this.config.email_from,
|
||||
to: targetEmail,
|
||||
subject: `⚠️ ${alerts.length} monitors changed status`,
|
||||
text: alerts.map(({ resource, incident }) =>
|
||||
`${resource.name} is ${incident.type === 'started' ? 'DOWN' : 'UP'} — ${resource.url}`
|
||||
).join('\n'),
|
||||
html: `<div style="font-family:Arial,sans-serif;max-width:600px">
|
||||
<h2 style="color:#d32f2f">⚠️ ${alerts.length} Monitors Changed Status</h2>
|
||||
<ul>${rows}</ul>
|
||||
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||
</div>`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Grouped email error handled
|
||||
}
|
||||
}
|
||||
|
||||
async sendGroupedWebhook(alerts) {
|
||||
const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
|
||||
if (!webhookUrl) return;
|
||||
try {
|
||||
await axios.post(webhookUrl, {
|
||||
type: 'grouped_alert',
|
||||
count: alerts.length,
|
||||
resources: alerts.map(({ resource, incident }) => ({
|
||||
name: resource.name,
|
||||
url: resource.url,
|
||||
status: incident.type === 'started' ? 'down' : 'up',
|
||||
})),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Grouped webhook error handled
|
||||
}
|
||||
}
|
||||
|
||||
async sendEscalationAlert(incident, fallbackWebhookUrl) {
|
||||
try {
|
||||
const durationMin = Math.round((Date.now() - new Date(incident.started_at).getTime()) / 60000);
|
||||
const message = `⚠️ ESCALATION: ${incident.resource_name} has been DOWN for ${durationMin} minutes without resolution.`;
|
||||
if (fallbackWebhookUrl) {
|
||||
await axios.post(fallbackWebhookUrl, {
|
||||
resource: incident.resource_name,
|
||||
url: incident.resource_url,
|
||||
status: 'escalated',
|
||||
duration_minutes: durationMin,
|
||||
incident_id: incident.id,
|
||||
started_at: incident.started_at,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
this.saveNotification(
|
||||
{ id: incident.resource_id, name: incident.resource_name, url: incident.resource_url },
|
||||
{ id: incident.id },
|
||||
'escalated',
|
||||
message
|
||||
);
|
||||
} catch (error) {
|
||||
// Escalation alert error handled
|
||||
}
|
||||
}
|
||||
|
||||
isQuietHours(resource) {
|
||||
if (!resource.quiet_hours_start || !resource.quiet_hours_end) {
|
||||
return false;
|
||||
@@ -179,12 +302,18 @@ class NotificationService {
|
||||
? `<div style="margin-bottom: 12px;"><img src="cid:${logoCid}" alt="SkyWatch" style="height: 40px; width: 40px; border-radius: 8px;" /></div>`
|
||||
: '';
|
||||
|
||||
const isSlow = type === 'slow';
|
||||
const isDown = type === 'started';
|
||||
const headingColor = isDown ? '#d32f2f' : isSlow ? '#f57c00' : '#388e3c';
|
||||
const headingText = isDown ? '🔴 Alert' : isSlow ? '🟡 Slow Response' : '🟢 Recovered';
|
||||
const statusLabel = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
|
||||
|
||||
let htmlContent = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
${logoBlock}
|
||||
<h2 style="color: ${type === 'started' ? '#d32f2f' : '#388e3c'};">${type === 'started' ? '🔴 Alert' : '🟢 Recovered'}</h2>
|
||||
<h2 style="color: ${headingColor};">${headingText}</h2>
|
||||
<p><strong>Resource:</strong> ${resource.name}</p>
|
||||
<p><strong>Status:</strong> ${type === 'started' ? 'DOWN' : 'UP'}</p>
|
||||
<p><strong>Status:</strong> ${statusLabel}</p>
|
||||
<p><strong>URL:</strong> <a href="${resource.url}">${resource.url}</a></p>
|
||||
<p><strong>Check Type:</strong> ${resource.type || 'http'}</p>
|
||||
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
|
||||
@@ -221,7 +350,7 @@ class NotificationService {
|
||||
await this.transporter.sendMail({
|
||||
from: this.config.email_from,
|
||||
to: emailTo,
|
||||
subject: `Alert: ${resource.name} is ${type === 'started' ? 'DOWN' : 'UP'}`,
|
||||
subject: `Alert: ${resource.name} is ${type === 'started' ? 'DOWN' : type === 'slow' ? 'SLOW' : 'UP'}`,
|
||||
text: message,
|
||||
html: htmlContent,
|
||||
attachments: hasLogo
|
||||
@@ -282,17 +411,64 @@ class NotificationService {
|
||||
|
||||
async sendWebhook(resource, message, type) {
|
||||
try {
|
||||
await axios.post(this.config.webhook_url || process.env.WEBHOOK_URL, {
|
||||
resource: resource.name,
|
||||
url: resource.url,
|
||||
status: type === 'started' ? 'down' : 'up',
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
|
||||
const status = type === 'started' ? 'down' : type === 'slow' ? 'slow' : 'up';
|
||||
const template = this.config.webhook_template;
|
||||
|
||||
let payload;
|
||||
if (template) {
|
||||
const interpolated = template
|
||||
.replace(/\{\{name\}\}/g, resource.name)
|
||||
.replace(/\{\{status\}\}/g, status)
|
||||
.replace(/\{\{url\}\}/g, resource.url)
|
||||
.replace(/\{\{type\}\}/g, resource.type || 'http')
|
||||
.replace(/\{\{message\}\}/g, message)
|
||||
.replace(/\{\{timestamp\}\}/g, new Date().toISOString());
|
||||
try {
|
||||
payload = JSON.parse(interpolated);
|
||||
} catch {
|
||||
// Template is plain text (e.g. Slack text field), wrap it
|
||||
payload = { text: interpolated };
|
||||
}
|
||||
} else {
|
||||
payload = {
|
||||
resource: resource.name,
|
||||
url: resource.url,
|
||||
status,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, payload);
|
||||
} catch (error) {
|
||||
// Webhook error handled silently
|
||||
}
|
||||
}
|
||||
|
||||
async sendNtfy(resource, type) {
|
||||
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
|
||||
const ntfyTopic = this.config.ntfy_topic;
|
||||
if (!ntfyTopic) return;
|
||||
|
||||
const isDown = type === 'started';
|
||||
const isSlow = type === 'slow';
|
||||
const title = isDown ? `🔴 ${resource.name} is DOWN` : isSlow ? `🟡 ${resource.name} is SLOW` : `🟢 ${resource.name} is UP`;
|
||||
const priority = isDown ? 4 : isSlow ? 3 : 2;
|
||||
const tags = isDown ? ['red_circle', 'warning'] : isSlow ? ['yellow_circle'] : ['green_circle', 'white_check_mark'];
|
||||
|
||||
try {
|
||||
await axios.post(`${ntfyUrl.replace(/\/$/, '')}/`, {
|
||||
topic: ntfyTopic,
|
||||
title,
|
||||
message: resource.url || resource.name,
|
||||
priority,
|
||||
tags,
|
||||
});
|
||||
} catch (error) {
|
||||
// ntfy error handled silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new NotificationService();
|
||||
+133
-65
@@ -7,6 +7,9 @@ class Scheduler {
|
||||
constructor() {
|
||||
this.jobs = new Map();
|
||||
this.isRunning = false;
|
||||
this.lastCheckedAt = new Map();
|
||||
this.slowAlertCooldown = new Map();
|
||||
this.slowConsecutiveCount = new Map(); // tracks consecutive slow checks per resource
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -22,65 +25,46 @@ class Scheduler {
|
||||
}
|
||||
});
|
||||
|
||||
// Archive old checks daily at 2 AM
|
||||
// Archive old checks + update baselines + check escalations daily at 2 AM
|
||||
cron.schedule('0 2 * * *', () => {
|
||||
this.archiveOldChecks();
|
||||
this.updateBaselines();
|
||||
});
|
||||
|
||||
// Escalation check runs every hour
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
await this.checkEscalations();
|
||||
});
|
||||
|
||||
// Also run archive on startup (with delay to let DB init)
|
||||
setTimeout(() => {
|
||||
this.archiveOldChecks();
|
||||
this.updateBaselines();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
archiveOldChecks() {
|
||||
try {
|
||||
// Get global retention setting
|
||||
const retentionSetting = db.prepare(`
|
||||
SELECT value FROM settings WHERE key = 'retention_days'
|
||||
`).get();
|
||||
|
||||
const retentionSetting = db.prepare(`SELECT value FROM settings WHERE key = 'retention_days'`).get();
|
||||
const globalRetentionDays = retentionSetting ? parseInt(retentionSetting.value) : 30;
|
||||
|
||||
// Get all resources with their retention settings
|
||||
const resources = db.prepare(`
|
||||
SELECT id, retention_days FROM resources
|
||||
`).all();
|
||||
const resources = db.prepare(`SELECT id, retention_days FROM resources`).all();
|
||||
|
||||
const insertArchived = db.prepare(`
|
||||
const bulkArchive = db.prepare(`
|
||||
INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const deleteOld = db.prepare(`
|
||||
DELETE FROM checks WHERE id = ?
|
||||
SELECT resource_id, status, response_time, status_code, error_message, details, checked_at
|
||||
FROM checks WHERE resource_id = ? AND checked_at < ?
|
||||
`);
|
||||
const bulkDelete = db.prepare(`DELETE FROM checks WHERE resource_id = ? AND checked_at < ?`);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
resources.forEach(resource => {
|
||||
// Use per-resource retention if set, otherwise use global
|
||||
const retentionDays = resource.retention_days || globalRetentionDays;
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
|
||||
|
||||
// Get old checks for this resource
|
||||
const oldChecks = db.prepare(`
|
||||
SELECT * FROM checks WHERE resource_id = ? AND checked_at < ?
|
||||
`).all(resource.id, cutoffString);
|
||||
|
||||
oldChecks.forEach(check => {
|
||||
insertArchived.run(
|
||||
check.resource_id,
|
||||
check.status,
|
||||
check.response_time,
|
||||
check.status_code,
|
||||
check.error_message,
|
||||
check.details,
|
||||
check.checked_at
|
||||
);
|
||||
deleteOld.run(check.id);
|
||||
});
|
||||
bulkArchive.run(resource.id, cutoffString);
|
||||
bulkDelete.run(resource.id, cutoffString);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,20 +74,82 @@ class Scheduler {
|
||||
}
|
||||
}
|
||||
|
||||
updateBaselines() {
|
||||
try {
|
||||
const resources = db.prepare(`SELECT id, type FROM resources WHERE type != 'heartbeat'`).all();
|
||||
for (const resource of resources) {
|
||||
const baseline = monitorService.calculateResponseTimeBaseline(resource.id, 7);
|
||||
if (baseline !== null) {
|
||||
db.prepare(`UPDATE resources SET response_time_baseline = ? WHERE id = ?`).run(baseline, resource.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Baseline update error handled silently
|
||||
}
|
||||
}
|
||||
|
||||
async checkEscalations() {
|
||||
try {
|
||||
const escalationSetting = db.prepare(`SELECT value FROM settings WHERE key = 'escalation_hours'`).get();
|
||||
const escalationHours = parseInt(escalationSetting?.value || '4');
|
||||
const fallbackSetting = db.prepare(`SELECT value FROM settings WHERE key = 'fallback_webhook'`).get();
|
||||
const fallbackWebhook = fallbackSetting?.value || process.env.FALLBACK_WEBHOOK || '';
|
||||
|
||||
if (!fallbackWebhook) return;
|
||||
|
||||
const cutoff = new Date(Date.now() - escalationHours * 60 * 60 * 1000)
|
||||
.toISOString().replace('T', ' ').split('.')[0];
|
||||
|
||||
const stalled = db.prepare(`
|
||||
SELECT i.*, r.name AS resource_name, r.url AS resource_url, r.type AS resource_type,
|
||||
r.maintenance_mode
|
||||
FROM incidents i
|
||||
JOIN resources r ON r.id = i.resource_id
|
||||
WHERE i.resolved_at IS NULL AND i.escalated = 0 AND i.started_at < ?
|
||||
AND r.maintenance_mode = 0
|
||||
`).all(cutoff);
|
||||
|
||||
for (const incident of stalled) {
|
||||
await notificationService.sendEscalationAlert(incident, fallbackWebhook);
|
||||
db.prepare(`UPDATE incidents SET escalated = 1 WHERE id = ?`).run(incident.id);
|
||||
}
|
||||
} catch (error) {
|
||||
// Escalation check error handled silently
|
||||
}
|
||||
}
|
||||
|
||||
async runChecks() {
|
||||
const resources = db.prepare(`
|
||||
SELECT * FROM resources WHERE enabled = 1
|
||||
`).all();
|
||||
const resources = db.prepare(`SELECT * FROM resources WHERE enabled = 1`).all();
|
||||
|
||||
// Read global defaults once per cycle so per-resource nulls inherit correctly
|
||||
const globalIntervalRow = db.prepare(`SELECT value FROM settings WHERE key = 'check_interval'`).get();
|
||||
const globalTimeoutRow = db.prepare(`SELECT value FROM settings WHERE key = 'timeout'`).get();
|
||||
const globalCheckInterval = parseInt(globalIntervalRow?.value) || 60000;
|
||||
const globalTimeout = parseInt(globalTimeoutRow?.value) || 5000;
|
||||
|
||||
// Stagger checks to avoid thundering herd
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const jitter = (maxMs = 1500) => Math.floor(Math.random() * maxMs);
|
||||
const baseSpacingMs = 250;
|
||||
|
||||
// Collect incident alerts to flush as a group after all checks complete
|
||||
const pendingAlerts = [];
|
||||
|
||||
const baseSpacingMs = 250; // spread starts in 250ms increments
|
||||
const tasks = resources.map((resource, index) => (async () => {
|
||||
// Honour per-resource check_interval, falling back to global setting
|
||||
const now = Date.now();
|
||||
const lastChecked = this.lastCheckedAt.get(resource.id) || 0;
|
||||
const interval = resource.check_interval || globalCheckInterval;
|
||||
if (now - lastChecked < interval) return;
|
||||
this.lastCheckedAt.set(resource.id, now);
|
||||
|
||||
// Resolve effective timeout: per-resource override or global default
|
||||
const effectiveResource = resource.timeout
|
||||
? resource
|
||||
: { ...resource, timeout: globalTimeout };
|
||||
|
||||
await sleep(index * baseSpacingMs + jitter());
|
||||
try {
|
||||
const result = await monitorService.checkResource(resource);
|
||||
const result = await monitorService.checkResource(effectiveResource);
|
||||
monitorService.saveCheck(result);
|
||||
|
||||
// Rolling cap per resource (configurable via settings; default 10000)
|
||||
@@ -111,41 +157,61 @@ class Scheduler {
|
||||
const capSetting = db.prepare(`SELECT value FROM settings WHERE key = 'checks_cap_per_resource'`).get();
|
||||
const cap = capSetting ? parseInt(capSetting.value) : 10000;
|
||||
if (Number.isFinite(cap) && cap > 0) {
|
||||
// Delete oldest rows beyond cap, using id ordering per resource
|
||||
db.prepare(`
|
||||
DELETE FROM checks
|
||||
WHERE id IN (
|
||||
SELECT id FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY id ASC
|
||||
LIMIT (
|
||||
SELECT MAX(COUNT(*) - ?, 0)
|
||||
FROM checks WHERE resource_id = ?
|
||||
const rowCount = db.prepare('SELECT COUNT(*) as cnt FROM checks WHERE resource_id = ?').get(resource.id).cnt;
|
||||
const excess = rowCount - cap;
|
||||
if (excess > 0) {
|
||||
db.prepare(`
|
||||
DELETE FROM checks WHERE id IN (
|
||||
SELECT id FROM checks WHERE resource_id = ? ORDER BY id ASC LIMIT ?
|
||||
)
|
||||
)
|
||||
`).run(resource.id, cap, resource.id);
|
||||
`).run(resource.id, excess);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
const incident = monitorService.handleIncident(
|
||||
resource.id,
|
||||
result.status === 'down'
|
||||
);
|
||||
const incident = monitorService.handleIncident(resource.id, result.status === 'down');
|
||||
|
||||
if (incident.type !== 'none') {
|
||||
if (incident.type !== 'none' && !resource.maintenance_mode) {
|
||||
const stats = monitorService.getResourceStats(resource.id, 24);
|
||||
const recentChecks = db.prepare(`
|
||||
SELECT response_time, status FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC
|
||||
LIMIT 12
|
||||
SELECT response_time, status FROM checks
|
||||
WHERE resource_id = ?
|
||||
ORDER BY checked_at DESC LIMIT 12
|
||||
`).all(resource.id);
|
||||
stats.recentChecks = recentChecks.reverse();
|
||||
if (!resource.maintenance_mode) {
|
||||
await notificationService.sendAlert(resource, incident, stats);
|
||||
pendingAlerts.push({ resource, incident, stats });
|
||||
}
|
||||
|
||||
// Slow alert: prefer explicit threshold, fall back to 2× P95 baseline
|
||||
const effectiveThreshold = resource.response_time_threshold ||
|
||||
(resource.response_time_baseline ? resource.response_time_baseline * 2 : null);
|
||||
|
||||
if (result.status === 'up' && effectiveThreshold && !resource.maintenance_mode) {
|
||||
if (result.response_time > effectiveThreshold) {
|
||||
const consecutive = (this.slowConsecutiveCount.get(resource.id) || 0) + 1;
|
||||
this.slowConsecutiveCount.set(resource.id, consecutive);
|
||||
const slowAlertConsecutiveSetting = db.prepare(`SELECT value FROM settings WHERE key = 'slow_alert_consecutive'`).get();
|
||||
const slowAlertConsecutive = parseInt(slowAlertConsecutiveSetting?.value) || 3;
|
||||
if (consecutive >= slowAlertConsecutive) {
|
||||
const lastSlowAlert = this.slowAlertCooldown.get(resource.id) || 0;
|
||||
if (now - lastSlowAlert > 60 * 60 * 1000) {
|
||||
this.slowAlertCooldown.set(resource.id, now);
|
||||
await notificationService.sendAlert(resource, {
|
||||
type: 'slow',
|
||||
responseTime: result.response_time,
|
||||
threshold: effectiveThreshold,
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset consecutive counter when response time is back to normal
|
||||
this.slowConsecutiveCount.set(resource.id, 0);
|
||||
}
|
||||
} else if (!effectiveThreshold) {
|
||||
this.slowConsecutiveCount.set(resource.id, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
// Check error handled silently
|
||||
@@ -154,6 +220,9 @@ class Scheduler {
|
||||
|
||||
await Promise.allSettled(tasks);
|
||||
|
||||
// Flush collected alerts — grouped if multiple fired in the same cycle
|
||||
await notificationService.flushAlerts(pendingAlerts);
|
||||
|
||||
// Broadcast updated dashboard to all connected WebSocket clients
|
||||
if (global.broadcastDashboardUpdate) {
|
||||
global.broadcastDashboardUpdate();
|
||||
@@ -161,7 +230,6 @@ class Scheduler {
|
||||
}
|
||||
|
||||
stop() {
|
||||
// Stop the scheduler (cron tasks will continue running but we flag isRunning)
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateResourceBody (inline copy — avoids spinning up Express)
|
||||
// ---------------------------------------------------------------------------
|
||||
function validateResourceBody(body, requireUrl = true) {
|
||||
const { name, url, type, check_interval, timeout, sla_target, retention_days } = body;
|
||||
const isHeartbeat = (type || 'http') === 'heartbeat';
|
||||
if (!name) return 'Name is required';
|
||||
if (!isHeartbeat && requireUrl && !url) return 'URL is required';
|
||||
if (!isHeartbeat && url) {
|
||||
try { new URL(url); } catch (e) { return 'Invalid URL format'; }
|
||||
}
|
||||
if (check_interval && (isNaN(check_interval) || check_interval < 10000)) return 'Check interval must be at least 10000ms';
|
||||
if (timeout && (isNaN(timeout) || timeout < 1000)) return 'Timeout must be at least 1000ms';
|
||||
if (sla_target !== undefined && sla_target !== null && (isNaN(sla_target) || sla_target < 0 || sla_target > 100)) return 'SLA target must be between 0 and 100';
|
||||
if (retention_days && (isNaN(retention_days) || retention_days < 1 || retention_days > 365)) return 'Retention days must be between 1 and 365';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDuration (inline copy)
|
||||
// ---------------------------------------------------------------------------
|
||||
function formatDuration(ms) {
|
||||
if (!ms || ms < 0) return '0m';
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('validateResourceBody', () => {
|
||||
test('rejects missing name', () => {
|
||||
const result = validateResourceBody({ url: 'https://example.com' });
|
||||
assert.equal(result, 'Name is required');
|
||||
});
|
||||
|
||||
test('rejects missing URL for non-heartbeat', () => {
|
||||
const result = validateResourceBody({ name: 'Test' });
|
||||
assert.equal(result, 'URL is required');
|
||||
});
|
||||
|
||||
test('rejects invalid URL', () => {
|
||||
const result = validateResourceBody({ name: 'Test', url: 'not-a-url' });
|
||||
assert.equal(result, 'Invalid URL format');
|
||||
});
|
||||
|
||||
test('accepts valid http resource', () => {
|
||||
const result = validateResourceBody({ name: 'Test', url: 'https://example.com' });
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('accepts heartbeat without URL', () => {
|
||||
const result = validateResourceBody({ name: 'My Job', type: 'heartbeat' });
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('rejects check_interval below 10000', () => {
|
||||
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', check_interval: 5000 });
|
||||
assert.equal(result, 'Check interval must be at least 10000ms');
|
||||
});
|
||||
|
||||
test('rejects timeout below 1000', () => {
|
||||
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', timeout: 500 });
|
||||
assert.equal(result, 'Timeout must be at least 1000ms');
|
||||
});
|
||||
|
||||
test('rejects sla_target above 100', () => {
|
||||
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', sla_target: 101 });
|
||||
assert.equal(result, 'SLA target must be between 0 and 100');
|
||||
});
|
||||
|
||||
test('rejects retention_days above 365', () => {
|
||||
const result = validateResourceBody({ name: 'Test', url: 'https://example.com', retention_days: 400 });
|
||||
assert.equal(result, 'Retention days must be between 1 and 365');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
test('returns 0m for null input', () => {
|
||||
assert.equal(formatDuration(null), '0m');
|
||||
});
|
||||
|
||||
test('returns 0m for negative input', () => {
|
||||
assert.equal(formatDuration(-100), '0m');
|
||||
});
|
||||
|
||||
test('formats minutes only', () => {
|
||||
assert.equal(formatDuration(5 * 60000), '5m');
|
||||
});
|
||||
|
||||
test('formats hours and minutes', () => {
|
||||
assert.equal(formatDuration(90 * 60000), '1h 30m');
|
||||
});
|
||||
|
||||
test('formats exactly 1 hour', () => {
|
||||
assert.equal(formatDuration(60 * 60000), '1h 0m');
|
||||
});
|
||||
|
||||
test('formats 0 minutes as 0m', () => {
|
||||
assert.equal(formatDuration(0), '0m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate limiter logic', () => {
|
||||
// Inline the rate-limit counter logic
|
||||
function makeRateLimiter(maxReqs, windowMs = 60000) {
|
||||
const map = new Map();
|
||||
return (ip, path) => {
|
||||
const key = `${path}:${ip}`;
|
||||
const now = Date.now();
|
||||
const e = map.get(key);
|
||||
if (!e || now - e.start > windowMs) { map.set(key, { start: now, count: 1 }); return true; }
|
||||
e.count++;
|
||||
return e.count <= maxReqs;
|
||||
};
|
||||
}
|
||||
|
||||
test('allows requests within limit', () => {
|
||||
const check = makeRateLimiter(3);
|
||||
assert.ok(check('127.0.0.1', '/api/heartbeat/tok'));
|
||||
assert.ok(check('127.0.0.1', '/api/heartbeat/tok'));
|
||||
assert.ok(check('127.0.0.1', '/api/heartbeat/tok'));
|
||||
});
|
||||
|
||||
test('blocks requests exceeding limit', () => {
|
||||
const check = makeRateLimiter(2);
|
||||
check('127.0.0.1', '/test');
|
||||
check('127.0.0.1', '/test');
|
||||
const allowed = check('127.0.0.1', '/test');
|
||||
assert.equal(allowed, false);
|
||||
});
|
||||
|
||||
test('different IPs are tracked separately', () => {
|
||||
const check = makeRateLimiter(1);
|
||||
assert.ok(check('1.1.1.1', '/test'));
|
||||
assert.ok(check('2.2.2.2', '/test'));
|
||||
});
|
||||
});
|
||||
+21
-2
@@ -11,9 +11,28 @@ cd /opt/resource-monitor
|
||||
echo "📥 Pulling latest code..."
|
||||
git pull origin main
|
||||
|
||||
echo "🔧 Ensuring build tools are present (needed for native modules)..."
|
||||
if command -v apt-get &>/dev/null; then
|
||||
apt-get install -y --no-install-recommends make gcc g++ python3 2>&1 | tail -5
|
||||
elif command -v dnf &>/dev/null; then
|
||||
dnf install -y make gcc gcc-c++ python3 2>&1 | tail -5
|
||||
elif command -v yum &>/dev/null; then
|
||||
yum install -y make gcc gcc-c++ python3 2>&1 | tail -5
|
||||
fi
|
||||
|
||||
echo "📦 Installing dependencies..."
|
||||
rm -rf node_modules package-lock.json
|
||||
npm ci --only=production 2>&1 | tail -20
|
||||
rm -rf node_modules
|
||||
|
||||
# Prefer deterministic installs with npm ci when lockfile is present.
|
||||
if [ -f package-lock.json ]; then
|
||||
if ! npm ci --omit=dev 2>&1 | tail -20; then
|
||||
echo "npm ci failed, falling back to npm install..."
|
||||
npm install --omit=dev 2>&1 | tail -20
|
||||
fi
|
||||
else
|
||||
echo "No package-lock.json found, using npm install..."
|
||||
npm install --omit=dev 2>&1 | tail -20
|
||||
fi
|
||||
|
||||
echo "🔄 Restarting application..."
|
||||
pm2 restart resource-monitor
|
||||
|
||||
Reference in new issue
Block a user