Compare commits

..
Author SHA1 Message Date
Claude dcfd9e9b65 Fix crash loop: disable keep-alive agents and add unhandledRejection guard
The previous fix (agent .on('error')) doesn't reach individual socket
objects — socket-level parse errors still escaped as uncaught exceptions.

Root fix: disable keepAlive on the HTTP/HTTPS monitoring agents. Without
connection reuse there is no stale connection to trigger "Parse Error:
Data after Connection: close". Monitoring intervals are 60s+ so the lack
of connection pooling has no meaningful performance impact.

Belt-and-suspenders: add process.on('unhandledRejection') alongside the
existing uncaughtException guard to catch any async paths that leak.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AQYLFrscmMnzMiXAAANoa
2026-08-07 00:02:01 +00:00
Claude b13a8c343f Fix crash loop: handle HTTP parse errors from keep-alive monitoring agents
Monitored services that send data after Connection: close (malformed HTTP)
were causing unhandled 'Parse Error' exceptions, crashing the process and
triggering a PM2 restart loop (191 restarts observed) which caused 502s.

Two-layer fix:
- monitorService.js: attach .on('error', noop) to the shared HTTP/HTTPS
  keep-alive agents so socket-level parse errors don't escape as uncaught
  exceptions
- index.js: process-level uncaughtException guard that swallows HTTP parse
  errors (HPE_INVALID_CONSTANT / 'Parse Error') while still crashing on
  real fatal errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AQYLFrscmMnzMiXAAANoa
2026-08-06 23:54:00 +00:00
Claude 42f224ab93 Merge remote-tracking branch 'origin/main' into claude/app-load-failure-db-rvlxbc 2026-08-06 23:53:21 +00:00
nmemmertandClaude 0b4797f38c Fix app startup crash: add hasInstallScript to better-sqlite3 lockfile entry (#1)
package-lock.json was missing "hasInstallScript": true for better-sqlite3.
npm ci reads this flag to decide whether to run a package's install scripts;
without it, the native .node binding is never compiled and the server crashes
at startup with "Cannot find module '...better_sqlite3.node'".

Running npm install regenerates the field correctly. npm ci (used in
update-server.sh) can now compile the native module during deployment.


Claude-Session: https://claude.ai/code/session_013AQYLFrscmMnzMiXAAANoa

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-06 19:48:59 -04:00
Claude b710f68d21 Fix app startup crash: add hasInstallScript to better-sqlite3 lockfile entry
package-lock.json was missing "hasInstallScript": true for better-sqlite3.
npm ci reads this flag to decide whether to run a package's install scripts;
without it, the native .node binding is never compiled and the server crashes
at startup with "Cannot find module '...better_sqlite3.node'".

Running npm install regenerates the field correctly. npm ci (used in
update-server.sh) can now compile the native module during deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AQYLFrscmMnzMiXAAANoa
2026-08-06 23:46:22 +00:00
nmemmertandClaude Sonnet 4.6 0f93d16874 Add build-tool install step to update script
Ensures gcc/g++/python3 are present before npm install so
better-sqlite3 can compile its native binding on a fresh Ubuntu server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 18:19:20 -04:00
nmemmertandClaude Sonnet 4.6 f119168399 Full health check: fix startup crash, add subnet input to network discovery
Server fixes:
- database.js: auto-create data/ directory on startup — was crashing on
  fresh clone because the gitignored directory didn't exist
- better-sqlite3 upgraded to latest (13.x) for Node 26 compatibility

Network discovery:
- discoverNetwork() accepts optional subnet param; scans that range instead
  of auto-detecting when provided
- New GET /api/network-subnets endpoint returns detected local interfaces
  so UI can offer quick-select buttons
- Discovery modal: subnet text input + Scan button + auto-detect quick-select;
  local subnet buttons populate once the modal is open
- server/index.js: pass subnet from request body to discoverNetwork()

Infra:
- React client runs on PORT=3456 in dev to avoid port conflicts
- .claude/launch.json added for one-command preview via Express (serves
  the pre-built client, no port conflict with the preview tool)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 17:21:34 -04:00
nmemmertandClaude Sonnet 4.6 59078362da Fix per-resource settings: tags saving, is_public bug, global setting inheritance
Bug fixes:
- tags field was missing from INSERT/UPDATE SQL — tags never persisted
- is_public: `0 !== false` is true in JS, so private resources (is_public=0)
  were silently flipped to public on every edit; fixed with explicit 0/false check

Global setting inheritance:
- One-time DB migration nulls out check_interval and timeout on all existing
  resources so they inherit from global monitoring settings going forward
- Scheduler reads global check_interval and timeout from DB each cycle and
  uses them as the fallback when per-resource values are null
- Create and edit modals: empty field = use global; placeholder shows the
  current global value so it's clear what will be used

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 17:01:03 -04:00
nmemmertandClaude Sonnet 4.6 3a3d32ecac Add slow_alert_consecutive setting to control slow alert sensitivity
Exposes the consecutive-slow-checks requirement as a configurable setting
instead of hardcoding 3. Saved under key 'slow_alert_consecutive' in the
DB, readable via SLOW_ALERT_CONSECUTIVE env var. UI appears in Settings →
Monitoring alongside the existing Consecutive Failures Threshold field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:53:06 -04:00
nmemmertandClaude Sonnet 4.6 dd85a79d55 Fix false-positive slow alerts: require 3 consecutive checks + use P95 baseline
Previously a single check above 2× average response time fired an alert.
This caused false positives from normal network variance.

- scheduler.js: track consecutive slow checks per resource; only alert after
  3 in a row (resets to 0 when response time normalises)
- monitorService.js: calculateResponseTimeBaseline now returns P95 instead
  of mean, making 2× baseline a much more realistic slow threshold

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:50:57 -04:00
nmemmertandClaude Sonnet 4.6 98e0949d08 Fix timezone consistency: apply server timezone everywhere in the app
- Extract getServerTimezone() helper in timeUtils.js; apply it in
  formatChartTime and formatLocalTimeOnly (previously ignored TZ)
- Status.js: remove local formatLocalTime (was bare toLocaleString),
  import the shared util that reads server timezone
- SettingsWizard: immediately update localStorage when timezone is saved
  so new value takes effect without waiting for cache to expire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:48:12 -04:00
nmemmertandClaude Sonnet 4.6 5e98e9aa05 Fix settings: only persist keys present in the request body
The POST /api/settings handler previously wrote ALL settings on every
save — so saving just the Email section would overwrite timezone,
check interval, ntfy topic, etc. with defaults.

Now:
- DB: only UPSERT the keys that are in this specific request body
- process.env: only update env vars for the keys in this request
- .env file: read → patch touched keys → write (not a full rewrite)

Section-scoped saves from the UI no longer clobber unrelated settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:40:29 -04:00
nmemmertandClaude Sonnet 4.6 279cba5115 Fix ntfy: use JSON body instead of headers to support emoji titles
Sending emoji in HTTP headers is not valid ASCII and causes errors.
Switching to ntfy's JSON body API (POST to /topic with {topic, title,
message, priority, tags}) allows full UTF-8 including emoji.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:31:44 -04:00
nmemmertandClaude Sonnet 4.6 d46a37a219 Fix ntfy: URL-encode Title header to avoid non-ASCII rejection
HTTP headers must be ASCII-only. Emoji in the Title header caused
"Invalid character in header content" errors. ntfy.sh accepts
URL-encoded header values, so encodeURIComponent() fixes it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 16:26:31 -04:00
nmemmertandClaude Sonnet 4.6 8d50d3cb61 Fix bugs, UX rough edges, add ntfy.sh + network discovery
Bugs fixed:
- Status page: autoRefresh checkbox now actually pauses the interval
- Dashboard edit modal: hide URL field for heartbeat resources (was always shown as required)
- Dashboard: apply items_per_page, default_sort, and refresh_interval from settings on mount
- Dashboard: call loadResources() immediately on mount (was waiting 15s for fallback)
- Observability: listen for WS 'metrics' messages so tab updates live, not just on poll

UX improvements:
- Dashboard: replaced window.confirm() with inline Confirm/Cancel buttons for single and bulk delete
- Dashboard: skeleton rows shown while initial data loads (no more flash of empty state)
- Notifications: WebSocket moved from window global to useRef; exponential backoff on reconnect
- Notifications: replaced window.confirm() with inline Confirm/Cancel for Clear All
- ResourceDetail: initial API calls run in parallel via Promise.all

New features:
- ntfy.sh push notification channel: enable in Settings, enter topic, get mobile push alerts
- Network Discovery: Actions → Discover Network scans LAN for reachable hosts, probes HTTP ports, and lets you add any found host to monitoring in one click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 13:24:47 -04:00
nmemmertandClaude Sonnet 4.6 de4619992e Fix dark mode: purge stale theme=light from DB on startup
The previous fix changed code defaults to dark but the DB still had
theme='light' stored explicitly — a truthy value that bypassed all
fallbacks. On every restart the server was returning theme:'light',
which the client applied via data-theme="light", making the app white.

- Delete the 'theme' key from DB on startup (one-time migration)
- Always return theme:'dark' from buildSettingsFromDb (not from DB)
- Stop writing theme to DB/env/process.env on settings save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-06 13:04:52 -04:00
nmemmertandClaude Sonnet 4.6 50f4f73af1 Fix white screen caused by theme defaulting to 'light'
The app is a dark-mode design but all theme defaults were 'light'.
Once the theme setting was wired to data-theme, it applied light mode
overrides that turned the app background white while panels stayed dark.

- Changed all server-side theme defaults from 'light' to 'dark'
- Added data-theme="dark" to index.html so the correct theme applies
  before React loads (eliminates any white flash on first render)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 11:09:19 -04:00
nmemmertandClaude Sonnet 4.6 a8fca2d1a1 Fix: SQLite integer 0 rendering as text, NaN avg response display
- maintenance_mode and hasActiveIncident are SQLite integers (0/1), not JS booleans.
  {0 && <span>} evaluates to {0} which React renders as the text "0" in the DOM.
  Fixed with !! coercion on both conditions in renderResourceRow.

- avgResponseTime can be NaN for resources with no checks (e.g. icmp with no data).
  Added || 0 guard in the aggregate average and —/ms conditional in the row display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 11:06:23 -04:00
nmemmertandClaude Sonnet 4.6 b83a19e9cf Add 10 UX/feature improvements across dashboard, status page, and API
- Incident acknowledge: Ack button in dashboard rail and ResourceDetail incidents table
- Status page 90-day uptime history bars (teal ≥99%, amber ≥95%, red <95%, grey = no data)
- Theme setting applied: data-theme attribute wired to saved setting with CSS variables + light mode overrides
- Sparkline replaced with colored status bars (teal=up, red=down, grey=unknown) per check tick
- ResourceDetail chart time-range selector: 1h / 6h / 24h / 7d toggle fetches from checks endpoint
- Bulk actions: checkbox per row, select-all header, bulk delete and maintenance toggle bar
- CSV export adds 6 new fields (http_method, http_body, heartbeat_timeout, is_public, consecutive_failures_threshold, response_time_threshold)
- CSV import handles those 6 new fields on INSERT
- Check detail expansion: click expandable rows in Recent Checks to show full details JSON
- Browser push notifications: request permission opt-in, new Notification() on WS message when tab unfocused

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 11:01:24 -04:00
nmemmertandClaude Sonnet 4.6 5cfea703fb Fix notification panel readability — dark theme styling
Replace hard-coded white/light colors with dark theme palette matching the rest
of the app. Key changes: dark navy background (#1e293b/#0f172a), high-contrast
text (#f1f5f9 titles, #94a3b8 body), teal accent (#14b8a6) for active filter and
unread left-border, red (#f87171) clear button, dark scrollbar, subtle hover
states on items and action buttons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 10:26:09 -04:00
nmemmertandClaude Sonnet 4.6 4fea0a95f5 Add HTTP method/body, alert escalation, baseline alerts, notification grouping, rate limiting, App.js split, and API tests
- HTTP method + request body: GET/POST/PUT/PATCH/DELETE/HEAD selector and body textarea for http/health checks; stored in http_method/http_body columns; monitorService sends correct axios method+data
- Alert escalation: incidents open longer than escalation_hours get forwarded to fallback_webhook and marked escalated=1; hourly cron in scheduler
- Baseline-relative slow alerts: effectiveThreshold = response_time_threshold || (baseline * 2) — adaptive threshold without manual config
- Notification grouping: pendingAlerts collected per check cycle; ≥2 alerts flush as one grouped email+webhook; individual in-app entries still saved per resource
- Rate limiting: in-memory per-IP limiter (20/min heartbeat, 60/min status-page); no external package
- App.js split: Dashboard.js (monitors list + modals) and ResourceDetail.js extracted; App.js reduced from ~1987 to ~80 lines
- formatDuration moved to utils/timeUtils.js; imported by both new components
- DB migrations: http_method, http_body, incidents.escalated columns
- 18 passing API/unit tests (node:test, zero dependencies)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 09:37:31 -04:00
nmemmertandClaude Sonnet 4.6 a6cf6a9762 Add heartbeat monitoring, alert rules, cert badges, webhook templates, and status page visibility
- Heartbeat type: cron jobs POST to /api/heartbeat/:token to stay alive; scheduler marks
  them down if no ping is received within the configured timeout window
- Alert rules: per-resource consecutive_failures_threshold now respected in handleIncident,
  overriding the global setting; GET /api/resources/:id/alert-rules endpoint added
- SSL cert expiry badge: dashboard rows surface days_remaining for TLS monitors, turning
  orange at ≤30 days and red at ≤7 days
- Webhook payload templates: {{name}}/{{status}}/{{url}}/{{type}}/{{message}}/{{timestamp}}
  variables interpolated before POST; Slack-compatible text wrapping as fallback
- Status page visibility: is_public column per resource; GET /api/status-page returns only
  public resources; Status.js updated to use the new endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 09:08:15 -04:00
Nate Emmert 34986b5760 Harden update script dependency install flow 2026-03-10 15:00:45 -04:00
Nate Emmert 85368ba0dc Merge redesign command-center updates 2026-03-10 14:54:50 -04:00
Nate Emmert 0475bf5aa5 Refine dark theme readability and chart visibility across dashboard pages 2026-03-10 14:54:40 -04:00
42 changed files with 4553 additions and 3110 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "SkyWatch",
"runtimeExecutable": "node",
"runtimeArgs": ["server/index.js"],
"port": 3001,
"url": "http://localhost:3001"
}
]
}
+6 -6
View File
@@ -1,15 +1,15 @@
{ {
"files": { "files": {
"main.css": "/static/css/main.69110889.css", "main.css": "/static/css/main.d4111563.css",
"main.js": "/static/js/main.820bab7f.js", "main.js": "/static/js/main.ee340b3d.js",
"static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js", "static/js/453.d456ce70.chunk.js": "/static/js/453.d456ce70.chunk.js",
"index.html": "/index.html", "index.html": "/index.html",
"main.69110889.css.map": "/static/css/main.69110889.css.map", "main.d4111563.css.map": "/static/css/main.d4111563.css.map",
"main.820bab7f.js.map": "/static/js/main.820bab7f.js.map", "main.ee340b3d.js.map": "/static/js/main.ee340b3d.js.map",
"453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map" "453.d456ce70.chunk.js.map": "/static/js/453.d456ce70.chunk.js.map"
}, },
"entrypoints": [ "entrypoints": [
"static/css/main.69110889.css", "static/css/main.d4111563.css",
"static/js/main.820bab7f.js" "static/js/main.ee340b3d.js"
] ]
} }
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" type="image/png" href="/app-icon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="SkyWatch - Infrastructure monitoring and alerting system"/><link rel="apple-touch-icon" href="/app-icon.png"/><link rel="manifest" href="/manifest.json"/><title>SkyWatch</title><script defer="defer" src="/static/js/main.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":"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 truncated
@@ -0,0 +1,2 @@
body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#020617;color:#e2e8f0;font-family:Manrope,IBM Plex Sans,Segoe UI,sans-serif;margin:0}*{box-sizing:border-box}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}@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
-3
View File
@@ -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
+3
View File
@@ -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
@@ -0,0 +1 @@
{"version":3,"file":"static/js/main.ee340b3d.js","mappings":"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 truncated
-95
View File
@@ -3390,36 +3390,6 @@
"url": "https://github.com/sponsors/gregberge" "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": { "node_modules/@testing-library/jest-dom": {
"version": "6.9.1", "version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -3490,13 +3460,6 @@
"node": ">=10.13.0" "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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -6755,16 +6718,6 @@
"node": ">= 0.8" "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": { "node_modules/destroy": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -6879,13 +6832,6 @@
"node": ">=6.0.0" "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": { "node_modules/dom-converter": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -11406,16 +11352,6 @@
"yallist": "^3.0.2" "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": { "node_modules/magic-string": {
"version": "0.25.9", "version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", "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": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
@@ -16709,20 +16628,6 @@
"is-typedarray": "^1.0.0" "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": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" type="image/png" href="%PUBLIC_URL%/app-icon.png" /> <link rel="icon" type="image/png" href="%PUBLIC_URL%/app-icon.png" />
+470 -60
View File
@@ -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 { .App {
min-height: 100vh; min-height: 100vh;
background: background:
radial-gradient(circle at 15% 0%, #dff7f2 0%, transparent 40%), radial-gradient(circle at 15% 0%, rgba(30, 64, 175, 0.2) 0%, transparent 42%),
radial-gradient(circle at 95% 10%, #e8eefc 0%, transparent 38%), radial-gradient(circle at 95% 10%, rgba(15, 118, 110, 0.14) 0%, transparent 36%),
#f8fafc; #020617;
} }
.navbar { .navbar {
background: rgba(255, 255, 255, 0.85); background: rgba(15, 23, 42, 0.82);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
padding: 0.85rem 2rem; padding: 0.85rem 2rem;
color: #0f172a; color: #e2e8f0;
border-bottom: 1px solid #dbe2ea; border-bottom: 1px solid #334155;
box-shadow: 0 4px 18px rgba(15, 23, 42, 0.06); box-shadow: 0 4px 18px rgba(15, 23, 42, 0.06);
position: sticky; position: sticky;
top: 0; top: 0;
@@ -48,7 +151,7 @@
} }
.nav-link { .nav-link {
color: #475569; color: #cbd5e1;
text-decoration: none; text-decoration: none;
font-weight: 600; font-weight: 600;
font-size: 0.92rem; font-size: 0.92rem;
@@ -58,9 +161,9 @@
} }
.nav-link:hover { .nav-link:hover {
background: #ffffff; background: #111827;
color: #0f172a; color: #f8fafc;
box-shadow: inset 0 0 0 1px #dbe2ea; box-shadow: inset 0 0 0 1px #334155;
} }
.settings-link { .settings-link {
@@ -83,9 +186,9 @@
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
font-size: 0.82rem; font-size: 0.82rem;
color: #334155; color: #e2e8f0;
background: #ffffff; background: #111827;
border: 1px solid #dbe2ea; border: 1px solid #334155;
border-radius: 999px; border-radius: 999px;
padding: 0.3rem 0.7rem; padding: 0.3rem 0.7rem;
} }
@@ -108,11 +211,12 @@
margin: 0; margin: 0;
font-size: 1.85rem; font-size: 1.85rem;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: #f8fafc;
} }
.dashboard-subtitle { .dashboard-subtitle {
margin: 0.2rem 0 0; margin: 0.2rem 0 0;
color: #64748b; color: #94a3b8;
font-size: 0.9rem; font-size: 0.9rem;
} }
@@ -123,12 +227,42 @@
align-items: center; 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 { .tag-filter-input {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 10px; border-radius: 10px;
border: 1px solid #cbd5e1; border: 1px solid #334155;
min-width: 180px; min-width: 180px;
background: rgba(255, 255, 255, 0.9); background: #111827;
color: #e2e8f0;
} }
.actions-menu-wrapper { .actions-menu-wrapper {
@@ -279,7 +413,44 @@
gap: 0.4rem; 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 { .cc-group-item {
width: 100%; width: 100%;
background: #0f172a; background: #0f172a;
@@ -293,7 +464,6 @@
cursor: pointer; cursor: pointer;
} }
.cc-incident-item:hover,
.cc-group-item:hover { .cc-group-item:hover {
background: #111f34; background: #111f34;
border-color: #475569; border-color: #475569;
@@ -322,49 +492,60 @@
} }
.resource-list-shell { .resource-list-shell {
background: rgba(255, 255, 255, 0.82); background: rgba(15, 23, 42, 0.9);
border: 1px solid #cbd5e1; border: 1px solid #334155;
border-radius: 12px; border-radius: 12px;
padding: 0.65rem; padding: 0.65rem;
max-height: calc(100vh - 250px);
overflow: auto;
} }
.resource-list-header { .resource-list-header {
display: grid; 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; gap: 1rem;
padding: 0.35rem 0.6rem 0.6rem; padding: 0.35rem 0.6rem 0.6rem;
font-size: 0.72rem; font-size: 0.72rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: #64748b; color: #94a3b8;
border-bottom: 1px solid #e2e8f0; border-bottom: 1px solid #334155;
margin-bottom: 0.45rem; margin-bottom: 0.45rem;
position: sticky;
top: 0;
z-index: 5;
background: rgba(15, 23, 42, 0.98);
} }
.resource-list-count { .resource-list-count {
font-size: 0.75rem; font-size: 0.75rem;
color: #64748b; color: #94a3b8;
margin: 0 0.6rem 0.55rem; 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 { .resource-row {
display: grid; 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; align-items: center;
gap: 1rem; gap: 1rem;
padding: 0.65rem 0.8rem; padding: 0.65rem 0.8rem;
background: rgba(248, 250, 252, 0.96); background: rgba(15, 23, 42, 0.96);
border: 1px solid #dbe2ea; border: 1px solid #334155;
border-radius: 10px; 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; cursor: pointer;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s; transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
} }
.resource-row:hover { .resource-row:hover {
transform: translateY(-1px); transform: translateY(-1px);
border-color: #94a3b8; border-color: #60a5fa;
box-shadow: 0 9px 18px rgba(15, 23, 42, 0.1); box-shadow: 0 12px 22px rgba(2, 6, 23, 0.65);
} }
.row-main { .row-main {
@@ -382,13 +563,14 @@
.row-name { .row-name {
margin: 0; margin: 0;
font-size: 0.98rem; font-size: 0.98rem;
color: #f8fafc;
} }
.resource-group-pill { .resource-group-pill {
padding: 0.15rem 0.5rem; padding: 0.15rem 0.5rem;
border-radius: 999px; border-radius: 999px;
background: #e2e8f0; background: #1e293b;
color: #334155; color: #cbd5e1;
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
} }
@@ -414,6 +596,7 @@
.row-url, .row-url,
.row-type { .row-type {
margin: 0; margin: 0;
color: #94a3b8;
} }
.row-url { .row-url {
@@ -436,6 +619,12 @@
.row-metrics .stat-label { .row-metrics .stat-label {
font-size: 0.64rem; font-size: 0.64rem;
color: #94a3b8;
}
.row-metrics .stat-value {
color: #e2e8f0;
font-weight: 700;
} }
.row-sparkline { .row-sparkline {
@@ -488,11 +677,40 @@
margin: 0 0 0.2rem 0; 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 { .resource-url {
color: #64748b; color: #64748b;
font-size: 0.8rem; font-size: 0.8rem;
word-break: break-all; 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 { .resource-type {
margin: 0.15rem 0 0; margin: 0.15rem 0 0;
@@ -537,7 +755,7 @@
.stat-value { .stat-value {
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
color: #333; color: #e2e8f0;
margin: 0; margin: 0;
} }
@@ -547,7 +765,7 @@
.stat-label { .stat-label {
font-size: 0.75rem; font-size: 0.75rem;
color: #666; color: #94a3b8;
text-transform: uppercase; text-transform: uppercase;
margin: 0; margin: 0;
} }
@@ -567,7 +785,7 @@
.last-check { .last-check {
margin: 0; margin: 0;
font-size: 0.75rem; font-size: 0.75rem;
color: #999; color: #94a3b8;
} }
.group-section { .group-section {
@@ -671,17 +889,48 @@
} }
.btn-icon { .btn-icon {
background: none; background: #0b1325;
border: none; border: 1px solid #334155;
color: #e2e8f0;
min-width: 34px;
min-height: 30px;
border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
opacity: 0.6; opacity: 1;
transition: opacity 0.2s; transition: transform 0.15s, background 0.2s, border-color 0.2s;
} }
.btn-icon:hover { .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 { .btn-sm {
@@ -813,17 +1062,18 @@
} }
.detail-section { .detail-section {
background: rgba(255, 255, 255, 0.92); background: rgba(15, 23, 42, 0.94);
border: 1px solid #e2e8f0; border: 1px solid #334155;
border-radius: 14px; border-radius: 14px;
padding: 1.5rem; padding: 1.5rem;
margin-bottom: 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 { .detail-section h2 {
margin-top: 0; margin-top: 0;
color: #333; color: #f8fafc;
} }
.detail-header-row { .detail-header-row {
@@ -841,7 +1091,7 @@
} }
.resource-url-detail { .resource-url-detail {
color: #64748b; color: #94a3b8;
margin: 0; margin: 0;
} }
@@ -875,7 +1125,15 @@
.filter-control label { .filter-control label {
font-size: 0.9rem; 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 { .controls-pagination {
@@ -896,9 +1154,9 @@
.data-table th { .data-table th {
padding: 0.75rem; padding: 0.75rem;
text-align: left; text-align: left;
border-bottom: 2px solid #dbe2ea; border-bottom: 2px solid #334155;
color: #1e293b; color: #e2e8f0;
background: #f1f5f9; background: #111827;
font-size: 0.86rem; font-size: 0.86rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -906,11 +1164,12 @@
.data-table td { .data-table td {
padding: 0.75rem; padding: 0.75rem;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #1f2937;
color: #e2e8f0;
} }
.data-table tbody tr:hover { .data-table tbody tr:hover {
background: #f0f9ff; background: #1e293b;
} }
.text-right { .text-right {
@@ -918,7 +1177,7 @@
} }
.muted-cell { .muted-cell {
color: #64748b; color: #94a3b8;
} }
.w-25 { .w-25 {
@@ -942,14 +1201,14 @@
.incident-reason-toggle { .incident-reason-toggle {
cursor: pointer; cursor: pointer;
color: #0f766e; color: #93c5fd;
flex: 1; flex: 1;
} }
.expand-indicator { .expand-indicator {
margin-left: 0.5rem; margin-left: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
color: #64748b; color: #94a3b8;
} }
.btn-compact { .btn-compact {
@@ -958,7 +1217,7 @@
} }
.expanded-row { .expanded-row {
background: #f8fafc; background: #0b1325;
} }
.expanded-cell { .expanded-cell {
@@ -966,7 +1225,7 @@
word-break: break-word; word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace;
font-size: 0.9rem; font-size: 0.9rem;
color: #334155; color: #e2e8f0;
} }
.section-title-row { .section-title-row {
@@ -978,7 +1237,7 @@
} }
.empty-copy { .empty-copy {
color: #64748b; color: #94a3b8;
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
} }
@@ -986,6 +1245,10 @@
.chart-container { .chart-container {
margin-top: 1rem; margin-top: 1rem;
height: 300px; height: 300px;
background: #0b1325;
border: 1px solid #334155;
border-radius: 12px;
padding: 0.5rem;
} }
.incident-badge { .incident-badge {
@@ -1002,15 +1265,16 @@
display: inline-block; display: inline-block;
margin-bottom: 1rem; margin-bottom: 1rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
background: #6c757d; background: #1e293b;
color: white; color: #f8fafc;
text-decoration: none; text-decoration: none;
border-radius: 6px; border-radius: 6px;
border: 1px solid #334155;
transition: background 0.2s; transition: background 0.2s;
} }
.back-button:hover { .back-button:hover {
background: #5a6268; background: #334155;
} }
.empty-state { .empty-state {
@@ -1111,6 +1375,16 @@
position: static; position: static;
} }
.resource-list-shell {
max-height: none;
overflow: visible;
}
.resource-list-count,
.resource-list-header {
position: static;
}
.resource-row { .resource-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.7rem; gap: 0.7rem;
@@ -1120,6 +1394,10 @@
display: none; display: none;
} }
.row-checkbox {
display: none;
}
.row-metrics { .row-metrics {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
@@ -1259,3 +1537,135 @@
grid-template-columns: 1fr 1fr; 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
View File
File diff suppressed because it is too large. Load diff
+290
View File
@@ -0,0 +1,290 @@
.cc-page {
padding-top: 1.4rem;
color: #e2e8f0;
}
.cc-alert {
padding: 0.85rem 1rem;
margin-bottom: 1rem;
border-radius: 10px;
border: 1px solid #854d0e;
background: #2b1907;
color: #fcd34d;
}
.cc-page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1.2rem;
flex-wrap: wrap;
}
.cc-page-title {
margin: 0;
font-size: 1.65rem;
letter-spacing: -0.02em;
color: #f8fafc !important;
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
}
.cc-page h2 {
color: #f8fafc;
}
.cc-controls {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.cc-pill-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid #334155;
border-radius: 999px;
background: #111827;
color: #f1f5f9;
font-size: 0.82rem;
}
.cc-pill-toggle input {
accent-color: #3b82f6;
}
.cc-pill-toggle:focus-within,
.cc-input-sm:focus,
.cc-select-sm:focus {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.cc-surface {
background: #0f172a;
border: 1px solid #334155;
border-radius: 12px;
padding: 1rem;
}
.cc-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.65rem;
}
.cc-kpi {
background: #0b1325;
border: 1px solid #334155;
border-radius: 10px;
padding: 0.75rem;
}
.cc-kpi-label {
margin: 0;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #cbd5e1;
}
.cc-kpi-value {
margin: 0.35rem 0 0;
color: #f8fafc;
font-size: 1.45rem;
font-weight: 700;
}
.cc-table-wrap {
overflow: auto;
border: 1px solid #334155;
border-radius: 12px;
background: #0b1325;
}
.cc-table {
width: 100%;
border-collapse: collapse;
}
.cc-table thead tr {
background: #0f172a;
}
.cc-table th {
text-align: left;
padding: 0.75rem;
color: #e2e8f0;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.07em;
border-bottom: 1px solid #334155;
}
.cc-table td {
padding: 0.75rem;
border-bottom: 1px solid #1f2937;
color: #f1f5f9;
}
.cc-table tbody tr:hover {
background: #1e293b;
}
.cc-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1rem;
padding: 1rem;
background: #111827;
border: 1px solid #334155;
border-radius: 10px;
}
.cc-input-sm {
width: 56px;
padding: 0.45rem;
border: 1px solid #334155;
border-radius: 8px;
text-align: center;
background: #111827;
color: #e2e8f0;
}
.cc-select-sm {
padding: 0.45rem;
border: 1px solid #334155;
border-radius: 8px;
background: #111827;
color: #e2e8f0;
}
.cc-empty {
text-align: center;
color: #cbd5e1;
padding: 2.2rem 1rem;
}
.history-resource-card {
margin-bottom: 1.4rem;
}
.history-resource-title {
margin: 0 0 0.45rem;
color: #dbeafe;
}
.history-resource-meta {
margin: 0 0 1rem;
color: #e2e8f0;
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.history-resource-meta strong {
color: #f8fafc;
}
.history-chart-box {
height: 400px;
margin-bottom: 1rem;
background: #111827;
padding: 1rem;
border-radius: 10px;
border: 1px solid #334155;
}
.history-trend-row {
display: flex;
gap: 1rem;
margin-bottom: 0.8rem;
color: #e2e8f0;
flex-wrap: wrap;
}
.notifications-list {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.notification-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
border-radius: 10px;
border: 1px solid #334155;
background: #0b1325;
}
.notification-row.is-unread {
border-color: #1d4ed8;
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.35);
}
.notification-content {
flex: 1;
}
.notification-title {
margin: 0 0 0.4rem;
color: #f8fafc;
font-size: 1rem;
}
.notification-message {
margin: 0 0 0.5rem;
color: #e2e8f0;
}
.notification-meta {
display: flex;
gap: 0.65rem;
flex-wrap: wrap;
color: #cbd5e1;
font-size: 0.84rem;
}
.notification-chip {
background: #111827;
border: 1px solid #334155;
border-radius: 999px;
padding: 0.2rem 0.55rem;
color: #f1f5f9;
}
.notification-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.notification-actions .btn:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
@media (max-width: 768px) {
.cc-controls {
width: 100%;
}
.cc-controls .btn,
.cc-controls .tag-filter-input,
.cc-controls .cc-pill-toggle {
width: 100%;
}
.cc-pagination {
flex-direction: column;
align-items: stretch;
}
}
File diff suppressed because it is too large. Load diff
+55 -59
View File
@@ -12,6 +12,7 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import './App.css'; import './App.css';
import './CommandCenterPages.css';
import { formatChartTime } from './utils/timeUtils'; import { formatChartTime } from './utils/timeUtils';
const asNumberOrZero = (value) => { const asNumberOrZero = (value) => {
@@ -107,23 +108,16 @@ function History() {
if (error) return <div className="container">Error loading history: {error}</div>; if (error) return <div className="container">Error loading history: {error}</div>;
return ( return (
<div className="container"> <div className="container cc-page">
{limitedByRetention && ( {limitedByRetention && (
<div style={{ <div className="cc-alert">
padding: '1rem',
marginBottom: '1rem',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
color: '#856404'
}}>
⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days. ⚠️ Data limited to {effectiveDays} days (server retention setting). Requested {days} days.
</div> </div>
)} )}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}> <div className="cc-page-header">
<h2>Monitoring History</h2> <h2 className="cc-page-title" style={{ color: '#f8fafc', fontWeight: 800 }}>Monitoring History</h2>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}> <div className="cc-controls">
<div style={{ display: 'flex', gap: '0.5rem' }}> <div className="cc-controls">
{[7, 14, 30].map((d) => ( {[7, 14, 30].map((d) => (
<button <button
key={d} key={d}
@@ -136,21 +130,19 @@ function History() {
</button> </button>
))} ))}
</div> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}> <label className="cc-pill-toggle">
<input <input
type="checkbox" type="checkbox"
checked={showAveraged} checked={showAveraged}
onChange={(e) => setShowAveraged(e.target.checked)} onChange={(e) => setShowAveraged(e.target.checked)}
style={{ cursor: 'pointer' }}
/> />
<span>Show Averages</span> <span>Show Averages</span>
</label> </label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', userSelect: 'none', background: '#f5f5f5', padding: '0.5rem 1rem', borderRadius: '4px', border: '1px solid #ddd' }}> <label className="cc-pill-toggle">
<input <input
type="checkbox" type="checkbox"
checked={showTrends} checked={showTrends}
onChange={(e) => setShowTrends(e.target.checked)} onChange={(e) => setShowTrends(e.target.checked)}
style={{ cursor: 'pointer' }}
/> />
<span>Show Week-over-Week Trends</span> <span>Show Week-over-Week Trends</span>
</label> </label>
@@ -169,41 +161,43 @@ function History() {
const chartData = prepareChartData(resource.checks || []); const chartData = prepareChartData(resource.checks || []);
return ( return (
<div key={resource.id} className="detail-section" style={{ marginBottom: '3rem', background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}> <div key={resource.id} className="detail-section cc-surface history-resource-card">
<h3 style={{ marginBottom: '0.5rem', color: '#667eea', fontSize: '1.5rem' }}>{resource.name}</h3> <h3 className="history-resource-title">{resource.name}</h3>
<p style={{ color: '#666', marginBottom: '1.5rem', fontSize: '0.95rem' }}> <p className="history-resource-meta">
<span style={{ marginRight: '1.5rem' }}>Type: <strong style={{ color: '#333' }}>{resource.type.toUpperCase()}</strong></span> <span>Type: <strong>{resource.type.toUpperCase()}</strong></span>
<span style={{ marginRight: '1.5rem' }}>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336' }}>{resource.uptime}%</strong></span> <span>Uptime: <strong style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</strong></span>
<span>Avg Response: <strong style={{ color: '#333' }}>{resource.avgResponseTime}ms</strong></span> <span>Avg Response: <strong>{resource.avgResponseTime}ms</strong></span>
</p> </p>
{chartData.length > 0 ? ( {chartData.length > 0 ? (
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}> <div className="chart-container history-chart-box">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}> <ComposedChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" /> <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis <XAxis
dataKey="label" dataKey="label"
tick={{ fontSize: 12, fill: '#666' }} tick={{ fontSize: 12, fill: '#94a3b8' }}
angle={-45} angle={-45}
textAnchor="end" textAnchor="end"
height={80} height={80}
/> />
<YAxis <YAxis
yAxisId="left" yAxisId="left"
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }} label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
tick={{ fontSize: 12, fill: '#666' }} tick={{ fontSize: 12, fill: '#94a3b8' }}
/> />
<YAxis <YAxis
yAxisId="right" yAxisId="right"
orientation="right" orientation="right"
domain={[0, 1]} domain={[0, 1]}
ticks={[0, 1]} ticks={[0, 1]}
tick={{ fontSize: 12, fill: '#666' }} tick={{ fontSize: 12, fill: '#94a3b8' }}
label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#666' } }} label={{ value: 'Status', angle: 90, position: 'insideRight', style: { fill: '#94a3b8' } }}
/> />
<Tooltip <Tooltip
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }} contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
labelStyle={{ color: '#cbd5e1' }}
itemStyle={{ color: '#e2e8f0' }}
formatter={(value, name) => { formatter={(value, name) => {
if (name === 'Response Time') return `${value}ms`; if (name === 'Response Time') return `${value}ms`;
if (name === 'Status') return value === 1 ? '✓ UP' : '✗ DOWN'; if (name === 'Status') return value === 1 ? '✓ UP' : '✗ DOWN';
@@ -249,18 +243,18 @@ function History() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<div style={{ color: '#999', padding: '2rem', textAlign: 'center', background: '#fafafa', borderRadius: '6px' }}>No check data available</div> <div className="cc-empty">No check data available</div>
)} )}
{showTrends && resource.trendsData && ( {showTrends && resource.trendsData && (
<div className="chart-container" style={{ height: '400px', marginBottom: '1.5rem', background: '#fafafa', padding: '1rem', borderRadius: '6px' }}> <div className="chart-container history-chart-box">
<h4 style={{ marginBottom: '1rem', color: '#667eea' }}>Week-over-Week Comparison</h4> <h4 className="history-resource-title">Week-over-Week Comparison</h4>
{(() => { {(() => {
const comparison = resource.trendsData?.comparison || {}; const comparison = resource.trendsData?.comparison || {};
const responseTimeChange = asNumberOrZero(comparison.response_time_change); const responseTimeChange = asNumberOrZero(comparison.response_time_change);
const uptimeChange = asNumberOrZero(comparison.uptime_change); const uptimeChange = asNumberOrZero(comparison.uptime_change);
return ( return (
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.9rem' }}> <div className="history-trend-row">
<div> <div>
<strong>Response Time Change:</strong>{' '} <strong>Response Time Change:</strong>{' '}
<span style={{ color: responseTimeChange >= 0 ? '#f44336' : '#4caf50' }}> <span style={{ color: responseTimeChange >= 0 ? '#f44336' : '#4caf50' }}>
@@ -280,20 +274,22 @@ function History() {
})()} })()}
<ResponsiveContainer width="100%" height="90%"> <ResponsiveContainer width="100%" height="90%">
<ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}> <ComposedChart data={prepareTrendsChartData(resource.trendsData)} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e0e0e0" /> <CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis <XAxis
dataKey="day" dataKey="day"
tick={{ fontSize: 12, fill: '#666' }} tick={{ fontSize: 12, fill: '#94a3b8' }}
angle={-45} angle={-45}
textAnchor="end" textAnchor="end"
height={80} height={80}
/> />
<YAxis <YAxis
label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#666' } }} label={{ value: 'Response Time (ms)', angle: -90, position: 'insideLeft', style: { fill: '#94a3b8' } }}
tick={{ fontSize: 12, fill: '#666' }} tick={{ fontSize: 12, fill: '#94a3b8' }}
/> />
<Tooltip <Tooltip
contentStyle={{ backgroundColor: '#fff', border: '1px solid #ddd', borderRadius: '4px', padding: '10px' }} contentStyle={{ backgroundColor: '#0f172a', border: '1px solid #334155', borderRadius: '8px', padding: '10px' }}
labelStyle={{ color: '#cbd5e1' }}
itemStyle={{ color: '#e2e8f0' }}
formatter={(value, name) => { formatter={(value, name) => {
if (name === 'Current Period') return `${value}ms`; if (name === 'Current Period') return `${value}ms`;
if (name === 'Previous Period') return `${value}ms`; if (name === 'Previous Period') return `${value}ms`;
@@ -327,22 +323,22 @@ function History() {
</div> </div>
)} )}
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}> <div className="cc-kpi-grid">
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}> <div className="cc-kpi">
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.checks?.length || 0}</p> <p className="cc-kpi-label">Total Checks</p>
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Total Checks</p> <p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{resource.checks?.length || 0}</p>
</div> </div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}> <div className="cc-kpi">
<p className="stat-value" style={{ color: resource.uptime >= 95 ? '#4caf50' : resource.uptime >= 80 ? '#ff9800' : '#f44336', fontSize: '2rem', fontWeight: 'bold' }}>{resource.uptime}%</p> <p className="cc-kpi-label">Uptime</p>
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Uptime</p> <p className="cc-kpi-value" style={{ color: resource.uptime >= 95 ? '#22c55e' : resource.uptime >= 80 ? '#f59e0b' : '#ef4444' }}>{resource.uptime}%</p>
</div> </div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}> <div className="cc-kpi">
<p className="stat-value" style={{ color: '#667eea', fontSize: '2rem', fontWeight: 'bold' }}>{resource.avgResponseTime}ms</p> <p className="cc-kpi-label">Avg Response</p>
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Avg Response</p> <p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{resource.avgResponseTime}ms</p>
</div> </div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1rem', borderRadius: '6px' }}> <div className="cc-kpi">
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2rem', fontWeight: 'bold' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p> <p className="cc-kpi-label">Successful Checks</p>
<p className="stat-label" style={{ color: '#666', fontSize: '0.9rem' }}>Successful Checks</p> <p className="cc-kpi-value" style={{ color: '#34d399' }}>{(resource.checks || []).filter((c) => c.status === 'up').length}/{resource.checks?.length || 0}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -350,7 +346,7 @@ function History() {
})} })}
{totalItems > pageSize && ( {totalItems > pageSize && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}> <div className="cc-pagination">
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))} onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
@@ -358,8 +354,8 @@ function History() {
> >
← Previous ← Previous
</button> </button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div className="cc-controls">
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span> <span>Page</span>
<input <input
type="number" type="number"
value={currentPage} value={currentPage}
@@ -367,14 +363,14 @@ function History() {
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1)); const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
setCurrentPage(page); setCurrentPage(page);
}} }}
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }} className="cc-input-sm"
/> />
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span> <span>of {Math.ceil(totalItems / pageSize)}</span>
</div> </div>
<select <select
value={pageSize} value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }} onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }} className="cc-select-sm"
> >
<option value={5}>5 per page</option> <option value={5}>5 per page</option>
<option value={10}>10 per page</option> <option value={10}>10 per page</option>
+119 -74
View File
@@ -25,134 +25,157 @@
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
background: #dc3545; background: #ef4444;
color: white; color: #fff;
border-radius: 50%; border-radius: 50%;
width: 1.25rem; width: 1.2rem;
height: 1.25rem; height: 1.2rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.65rem; font-size: 0.6rem;
font-weight: bold; font-weight: 700;
line-height: 1; line-height: 1;
} }
/* ── Panel ── */
.notification-popup { .notification-popup {
position: fixed; position: fixed;
top: 60px; top: 60px;
right: 20px; right: 20px;
width: 400px; width: 420px;
max-height: 600px; max-height: 600px;
background: white; background: #1e293b;
border: 1px solid #ddd; border: 1px solid #334155;
border-radius: 8px; border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 1000; z-index: 1000;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
/* ── Header ── */
.notification-header { .notification-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1rem; padding: 0.875rem 1rem;
border-bottom: 1px solid #eee; border-bottom: 1px solid #334155;
background: #f9f9f9; background: #0f172a;
} }
.notification-header h3 { .notification-header h3 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1rem;
color: #333; font-weight: 600;
color: #f1f5f9;
letter-spacing: 0.01em;
} }
.close-btn { .close-btn {
background: none; background: none;
border: none; border: none;
font-size: 1.25rem; font-size: 1.1rem;
cursor: pointer; cursor: pointer;
color: #999; color: #64748b;
padding: 0; padding: 0;
width: 30px; width: 28px;
height: 30px; height: 28px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
} }
.close-btn:hover { .close-btn:hover {
color: #333; color: #e2e8f0;
background: #334155;
} }
/* ── Filters ── */
.notification-filters { .notification-filters {
display: flex; display: flex;
gap: 0.5rem; gap: 0.4rem;
padding: 0.75rem 1rem; padding: 0.6rem 0.875rem;
border-bottom: 1px solid #eee; border-bottom: 1px solid #334155;
background: #fafafa; background: #1e293b;
flex-wrap: wrap; flex-wrap: wrap;
} }
.filter-btn { .filter-btn {
padding: 0.4rem 0.75rem; padding: 0.3rem 0.65rem;
border: 1px solid #ddd; border: 1px solid #334155;
background: white; background: transparent;
border-radius: 4px; color: #94a3b8;
border-radius: 5px;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.8rem;
transition: all 0.2s; font-weight: 500;
transition: all 0.15s;
} }
.filter-btn:hover { .filter-btn:hover {
border-color: #007bff; border-color: #14b8a6;
color: #007bff; color: #14b8a6;
} }
.filter-btn.active { .filter-btn.active {
background: #007bff; background: #14b8a6;
color: white; color: #0f172a;
border-color: #007bff; border-color: #14b8a6;
font-weight: 600;
} }
.clear-btn { .clear-btn {
margin-left: auto; margin-left: auto;
background: #dc3545; background: transparent;
color: white; color: #f87171;
border-color: #dc3545; border-color: #f87171;
} }
.clear-btn:hover { .clear-btn:hover {
background: #c82333; background: #f87171;
border-color: #c82333; color: #0f172a;
border-color: #f87171;
} }
/* ── List ── */
.notification-list { .notification-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
max-height: 450px; max-height: 460px;
} }
.notification-item { .notification-item {
padding: 1rem; padding: 0.875rem 1rem;
border-bottom: 1px solid #eee; border-bottom: 1px solid #1e293b;
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
background: white; background: #1e293b;
transition: background 0.2s; transition: background 0.15s;
cursor: default;
}
.notification-item:last-child {
border-bottom: none;
} }
.notification-item:hover { .notification-item:hover {
background: #f9f9f9; background: #263347;
} }
.notification-item.unread { .notification-item.unread {
background: #f0f8ff; background: #162032;
font-weight: 500; border-left: 3px solid #14b8a6;
} }
.notification-item.unread:hover {
background: #1c2a40;
}
/* ── Content ── */
.notification-content { .notification-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -160,84 +183,106 @@
.notification-title { .notification-title {
font-weight: 600; font-weight: 600;
margin-bottom: 0.25rem; margin-bottom: 0.3rem;
font-size: 0.95rem; font-size: 0.875rem;
color: #333; color: #f1f5f9;
line-height: 1.3;
} }
.notification-message { .notification-message {
font-size: 0.85rem; font-size: 0.8rem;
color: #666; color: #94a3b8;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
word-break: break-word; word-break: break-word;
line-height: 1.45;
} }
.notification-meta { .notification-meta {
display: flex; display: flex;
gap: 0.75rem; gap: 0.5rem;
font-size: 0.75rem; font-size: 0.72rem;
color: #999; color: #64748b;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
} }
.notification-resource { .notification-resource {
background: #f0f0f0; background: #0f172a;
padding: 0.2rem 0.5rem; color: #94a3b8;
padding: 0.15rem 0.45rem;
border-radius: 3px; border-radius: 3px;
border: 1px solid #334155;
font-size: 0.7rem;
} }
.notification-time { .notification-time {
white-space: nowrap; white-space: nowrap;
} }
/* ── Actions ── */
.notification-actions { .notification-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.25rem;
align-items: flex-start; align-items: flex-start;
flex-shrink: 0;
} }
.action-btn { .action-btn {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 0.9rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.4rem;
color: #999; color: #475569;
transition: color 0.2s; border-radius: 4px;
flex-shrink: 0; transition: color 0.15s, background 0.15s;
} }
.action-btn:hover { .action-btn:hover {
color: #333; background: #334155;
color: #e2e8f0;
} }
.action-btn.mark-read:hover { .action-btn.mark-read:hover {
color: #28a745; color: #4ade80;
} }
.action-btn.delete:hover { .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 { .notification-list::-webkit-scrollbar {
width: 6px; width: 5px;
} }
.notification-list::-webkit-scrollbar-track { .notification-list::-webkit-scrollbar-track {
background: #f1f1f1; background: #0f172a;
} }
.notification-list::-webkit-scrollbar-thumb { .notification-list::-webkit-scrollbar-thumb {
background: #888; background: #334155;
border-radius: 3px; border-radius: 3px;
} }
.notification-list::-webkit-scrollbar-thumb:hover { .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) { @media (max-width: 600px) {
.notification-popup { .notification-popup {
position: fixed; position: fixed;
+21 -1
View File
@@ -10,6 +10,9 @@ function NotificationCenter() {
const [filter, setFilter] = useState('all'); // all, unread, read const [filter, setFilter] = useState('all'); // all, unread, read
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wsConnected, setWsConnected] = useState(false); const [wsConnected, setWsConnected] = useState(false);
const [pushPermission, setPushPermission] = useState(
'Notification' in window ? Notification.permission : 'unsupported'
);
useEffect(() => { useEffect(() => {
loadNotifications(); loadNotifications();
@@ -41,8 +44,11 @@ function NotificationCenter() {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (message.type === 'notification') { if (message.type === 'notification') {
// New notification received - reload notifications
loadNotifications(); 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) { } catch (error) {
console.error('Failed to parse WebSocket message:', 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 () => { const handleClearAll = async () => {
if (!window.confirm(`Clear all ${filter} notifications?`)) return; if (!window.confirm(`Clear all ${filter} notifications?`)) return;
@@ -173,6 +185,14 @@ function NotificationCenter() {
)} )}
</div> </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"> <div className="notification-list">
{loading ? ( {loading ? (
<p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>Loading...</p> <p style={{ textAlign: 'center', color: '#999', padding: '1rem' }}>Loading...</p>
+81 -83
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils'; import { formatLocalTime } from './utils/timeUtils';
import './CommandCenterPages.css';
function Notifications() { function Notifications() {
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
@@ -9,50 +10,13 @@ function Notifications() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [pendingClear, setPendingClear] = useState(false);
const itemsPerPage = 50; const itemsPerPage = 50;
const wsRef = useRef(null);
const wsReconnectTimer = useRef(null);
const wsReconnectDelay = useRef(1000);
useEffect(() => { const loadNotifications = useCallback(async () => {
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 () => {
setLoading(true); setLoading(true);
try { try {
const params = { const params = {
@@ -67,11 +31,51 @@ function Notifications() {
setUnreadCount(response.data.unreadCount); setUnreadCount(response.data.unreadCount);
setTotal(response.data.total); setTotal(response.data.total);
} catch (error) { } catch (error) {
console.error('Failed to load notifications:', error); // Notifications load error handled
} finally { } finally {
setLoading(false); 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) => { const handleMarkAsRead = async (notificationId) => {
try { try {
@@ -92,25 +96,26 @@ function Notifications() {
}; };
const handleClearAll = async () => { const handleClearAll = async () => {
if (!window.confirm(`Clear all ${filter} notifications?`)) return;
try { try {
await axios.post('/api/notifications/clear', { type: filter }); await axios.post('/api/notifications/clear', { type: filter });
setCurrentPage(1); setCurrentPage(1);
setPendingClear(false);
loadNotifications(); loadNotifications();
} catch (error) { } catch (error) {
console.error('Failed to clear notifications:', error); setPendingClear(false);
} }
}; };
const totalPages = Math.ceil(total / itemsPerPage); const totalPages = Math.ceil(total / itemsPerPage);
return ( return (
<div className="container" style={{ paddingTop: '2rem' }}> <div className="container cc-page">
<h2>📬 Notifications</h2> <div className="cc-page-header">
<h2 className="cc-page-title">Notifications</h2>
</div>
<div style={{ marginBottom: '2rem', display: 'flex', gap: '1rem', flexWrap: 'wrap', alignItems: 'center' }}> <div className="cc-controls" style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}> <div className="cc-controls">
<button <button
className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`} className={`btn ${filter === 'all' ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => { setFilter('all'); setCurrentPage(1); }} onClick={() => { setFilter('all'); setCurrentPage(1); }}
@@ -132,61 +137,54 @@ function Notifications() {
</div> </div>
{notifications.length > 0 && ( {notifications.length > 0 && (
<button pendingClear ? (
className="btn btn-danger" <div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem' }}>
onClick={handleClearAll} <button className="btn btn-danger" onClick={handleClearAll}>Confirm Clear</button>
style={{ marginLeft: 'auto' }} <button className="btn btn-secondary" onClick={() => setPendingClear(false)}>Cancel</button>
> </div>
Clear All ) : (
</button> <button className="btn btn-danger" onClick={() => setPendingClear(true)} style={{ marginLeft: 'auto' }}>
Clear All
</button>
)
)} )}
</div> </div>
{loading ? ( {loading ? (
<p style={{ textAlign: 'center', color: '#999' }}>Loading...</p> <p className="cc-empty">Loading...</p>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem 1rem', color: '#999' }}> <div className="cc-empty">
<p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p> <p>No {filter === 'unread' ? 'unread ' : filter === 'read' ? 'read ' : ''}notifications</p>
</div> </div>
) : ( ) : (
<> <>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '1rem' }}> <div className="notifications-list">
{notifications.map((notification) => ( {notifications.map((notification) => (
<div <div
key={notification.id} key={notification.id}
style={{ className={`notification-row ${notification.read ? 'is-read' : 'is-unread'}`}
padding: '1rem',
border: '1px solid #ddd',
borderRadius: '6px',
backgroundColor: notification.read ? 'white' : '#f0f8ff',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem'
}}
> >
<div style={{ flex: 1 }}> <div className="notification-content">
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '1rem' }}>{notification.title}</h4> <h4 className="notification-title">{notification.title}</h4>
<p style={{ margin: '0 0 0.5rem 0', color: '#666', fontSize: '0.95rem' }}> <p className="notification-message">
{notification.message} {notification.message}
</p> </p>
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', color: '#999', flexWrap: 'wrap' }}> <div className="notification-meta">
{notification.resource_name && ( {notification.resource_name && (
<span style={{ backgroundColor: '#f0f0f0', padding: '0.25rem 0.5rem', borderRadius: '3px' }}> <span className="notification-chip">
📌 {notification.resource_name} {notification.resource_name}
</span> </span>
)} )}
<span>⏰ {formatLocalTime(notification.created_at)}</span> <span>{formatLocalTime(notification.created_at)}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}> <div className="notification-actions">
{!notification.read && ( {!notification.read && (
<button <button
className="btn btn-sm" className="btn btn-sm"
onClick={() => handleMarkAsRead(notification.id)} onClick={() => handleMarkAsRead(notification.id)}
title="Mark as read" title="Mark as read"
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem' }}
> >
✓ Read ✓ Read
</button> </button>
@@ -195,7 +193,7 @@ function Notifications() {
className="btn btn-sm" className="btn btn-sm"
onClick={() => handleDelete(notification.id)} onClick={() => handleDelete(notification.id)}
title="Delete" title="Delete"
style={{ padding: '0.5rem 0.75rem', fontSize: '0.85rem', background: '#dc3545' }} style={{ background: '#b91c1c' }}
> >
✕ Delete ✕ Delete
</button> </button>
@@ -206,7 +204,7 @@ function Notifications() {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div style={{ marginTop: '2rem', display: 'flex', justifyContent: 'center', gap: '0.5rem', flexWrap: 'wrap' }}> <div className="cc-pagination">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button <button
key={page} key={page}
+57 -39
View File
@@ -2,9 +2,14 @@
padding-top: 2rem; padding-top: 2rem;
} }
.observability-page h2 {
color: #f8fafc;
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
}
.obs-tabs { .obs-tabs {
margin-bottom: 2rem; margin-bottom: 2rem;
border-bottom: 1px solid #dbe2ea; border-bottom: 1px solid #334155;
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
@@ -19,15 +24,20 @@
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
color: #64748b; color: #cbd5e1;
}
.obs-tab:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 2px;
} }
.obs-tab.is-active { .obs-tab.is-active {
border-bottom-color: #0ea5a2; border-bottom-color: #60a5fa;
color: #0f172a; color: #f8fafc;
background: #ffffff; background: #0f172a;
border-color: #dbe2ea; border-color: #334155;
border-bottom-color: #0ea5a2; border-bottom-color: #60a5fa;
} }
.obs-summary-grid { .obs-summary-grid {
@@ -39,30 +49,30 @@
.obs-summary-card { .obs-summary-card {
padding: 1rem; padding: 1rem;
border: 1px solid #dbe2ea; border: 1px solid #334155;
border-radius: 14px; border-radius: 14px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); background: linear-gradient(180deg, #0f172a 0%, #0b1325 100%);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06); box-shadow: none;
} }
.obs-card-label { .obs-card-label {
font-size: 0.85rem; font-size: 0.85rem;
color: #64748b; color: #cbd5e1;
} }
.obs-card-value { .obs-card-value {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 700;
margin-top: 0.45rem; margin-top: 0.45rem;
color: #0f172a; color: #f8fafc;
} }
.obs-card-value.is-danger { .obs-card-value.is-danger {
color: #b91c1c; color: #f87171;
} }
.obs-card-value.is-ok { .obs-card-value.is-ok {
color: #0f766e; color: #34d399;
} }
.obs-table-wrap { .obs-table-wrap {
@@ -81,9 +91,9 @@
.obs-table th { .obs-table th {
text-align: left; text-align: left;
padding: 0.75rem; padding: 0.75rem;
background: #f1f5f9; background: #0f172a;
border-bottom: 2px solid #dbe2ea; border-bottom: 2px solid #334155;
color: #1e293b; color: #e2e8f0;
font-size: 0.84rem; font-size: 0.84rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.02em; letter-spacing: 0.02em;
@@ -91,11 +101,12 @@
.obs-table td { .obs-table td {
padding: 0.75rem; padding: 0.75rem;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #1f2937;
color: #f1f5f9;
} }
.obs-table tbody tr:hover { .obs-table tbody tr:hover {
background: #f0f9ff; background: #1e293b;
} }
.obs-right { .obs-right {
@@ -105,38 +116,38 @@
.obs-rate-pill { .obs-rate-pill {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 4px; border-radius: 4px;
font-size: 0.85rem; font-size: 0.88rem;
font-weight: 600; font-weight: 600;
} }
.obs-rate-pill.is-good { .obs-rate-pill.is-good {
background: #dcfce7; background: #052e16;
color: #166534; color: #4ade80;
} }
.obs-rate-pill.is-warn { .obs-rate-pill.is-warn {
background: #fef3c7; background: #3f2a0f;
color: #92400e; color: #fbbf24;
} }
.obs-empty-copy { .obs-empty-copy {
color: #94a3b8; color: #cbd5e1;
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
} }
.obs-zebra { .obs-zebra {
background: #f8fafc; background: #0b1325;
} }
.obs-error-code { .obs-error-code {
font-size: 0.85rem; font-size: 0.85rem;
color: #b91c1c; color: #f87171;
} }
.obs-muted-sm { .obs-muted-sm {
font-size: 0.85rem; font-size: 0.85rem;
color: #64748b; color: #cbd5e1;
} }
.obs-small { .obs-small {
@@ -146,15 +157,15 @@
.obs-audit-summary { .obs-audit-summary {
margin-bottom: 2rem; margin-bottom: 2rem;
padding: 1rem; padding: 1rem;
background: #f8fafc; background: #0f172a;
border-radius: 12px; border-radius: 12px;
border: 1px solid #dbe2ea; border: 1px solid #334155;
} }
.obs-audit-summary-line { .obs-audit-summary-line {
margin: 0.25rem 0; margin: 0.25rem 0;
font-size: 0.9rem; font-size: 0.9rem;
color: #64748b; color: #e2e8f0;
} }
.obs-filters-grid { .obs-filters-grid {
@@ -166,15 +177,21 @@
.obs-filter-input { .obs-filter-input {
padding: 0.5rem; padding: 0.5rem;
border: 1px solid #dbe2ea; border: 1px solid #334155;
border-radius: 10px; border-radius: 10px;
background: #fff; background: #111827;
color: #e2e8f0;
} }
.obs-filter-input:focus { .obs-filter-input:focus {
outline: none; outline: none;
border-color: #0ea5a2; border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(14, 165, 162, 0.18); 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 { .obs-click-row {
@@ -183,11 +200,11 @@
.obs-toggle-code { .obs-toggle-code {
cursor: pointer; cursor: pointer;
color: #0f766e; color: #bfdbfe;
} }
.obs-expanded-row { .obs-expanded-row {
background: #f0f9ff; background: #111b2e;
} }
.obs-expanded-cell { .obs-expanded-cell {
@@ -196,11 +213,12 @@
.obs-pre { .obs-pre {
margin-top: 0.5rem; margin-top: 0.5rem;
background: #fff; background: #0b1325;
color: #e2e8f0;
padding: 0.75rem; padding: 0.75rem;
border-radius: 8px; border-radius: 8px;
overflow-x: auto; overflow-x: auto;
border: 1px solid #dbe2ea; border: 1px solid #334155;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
+38 -6
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import axios from 'axios'; import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils'; import { formatLocalTime } from './utils/timeUtils';
import './Observability.css'; import './Observability.css';
@@ -9,7 +9,6 @@ function Observability() {
const [errors, setErrors] = useState([]); const [errors, setErrors] = useState([]);
const [auditLogs, setAuditLogs] = useState([]); const [auditLogs, setAuditLogs] = useState([]);
const [auditSummary, setAuditSummary] = useState(null); const [auditSummary, setAuditSummary] = useState(null);
const [loading, setLoading] = useState(false);
const [expandedAuditRow, setExpandedAuditRow] = useState(null); const [expandedAuditRow, setExpandedAuditRow] = useState(null);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
entityType: '', entityType: '',
@@ -17,16 +16,50 @@ function Observability() {
userId: '', userId: '',
action: '' action: ''
}); });
const wsRef = useRef(null);
const wsReconnectTimer = useRef(null);
const wsReconnectDelay = useRef(1000);
useEffect(() => { useEffect(() => {
loadData(); loadData();
const interval = setInterval(loadData, 30000); // Refresh every 30 seconds const interval = setInterval(loadData, 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, filters]); }, [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 () => { const loadData = async () => {
setLoading(true);
try { try {
if (activeTab === 'metrics') { if (activeTab === 'metrics') {
const resp = await axios.get('/api/observability/metrics'); const resp = await axios.get('/api/observability/metrics');
@@ -42,7 +75,6 @@ function Observability() {
} catch (error) { } catch (error) {
// Error loading data // Error loading data
} }
setLoading(false);
}; };
return ( return (
+566
View File
@@ -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
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import './App.css'; import './App.css';
import './CommandCenterPages.css';
function SLA() { function SLA() {
const [slaData, setSlaData] = useState([]); const [slaData, setSlaData] = useState([]);
@@ -66,22 +67,15 @@ function SLA() {
} : null; } : null;
return ( return (
<div className="container"> <div className="container cc-page">
{limitedByRetention && ( {limitedByRetention && (
<div style={{ <div className="cc-alert">
padding: '1rem',
marginBottom: '1rem',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
borderRadius: '4px',
color: '#856404'
}}>
⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days. ⚠️ SLA data limited to {effectiveDays} days (server retention setting). Requested {days} days.
</div> </div>
)} )}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem', flexWrap: 'wrap', gap: '1rem' }}> <div className="cc-page-header">
<h2>SLA Dashboard</h2> <h2 className="cc-page-title">SLA Dashboard</h2>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div className="cc-controls">
{[7, 14, 30, 90].map((d) => ( {[7, 14, 30, 90].map((d) => (
<button <button
key={d} key={d}
@@ -95,33 +89,33 @@ function SLA() {
))} ))}
</div> </div>
{limitedByRetention && ( {limitedByRetention && (
<div style={{ background: '#FFF8E1', color: '#8D6E63', padding: '0.5rem 0.75rem', borderRadius: '6px', border: '1px solid #FFECB3' }}> <div className="cc-alert" style={{ marginBottom: 0 }}>
Limited by data retention ({effectiveDays} days). Increase Retention in Settings to view longer periods. Limited by data retention ({effectiveDays} days). Increase Retention in Settings to view longer periods.
</div> </div>
)} )}
</div> </div>
{overallStats && ( {overallStats && (
<div className="stats-grid" style={{ marginBottom: '2rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}> <div className="cc-kpi-grid" style={{ marginBottom: '1rem' }}>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}> <div className="cc-kpi">
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.totalResources}</p> <p className="cc-kpi-label">Total Resources</p>
<p className="stat-label">Total Resources</p> <p className="cc-kpi-value" style={{ color: '#60a5fa' }}>{overallStats.totalResources}</p>
</div> </div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}> <div className="cc-kpi">
<p className="stat-value" style={{ color: '#4caf50', fontSize: '2.5rem' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p> <p className="cc-kpi-label">Meeting SLA Target</p>
<p className="stat-label">Meeting SLA Target</p> <p className="cc-kpi-value" style={{ color: '#22c55e' }}>{overallStats.meetingTarget}/{overallStats.totalResources}</p>
</div> </div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}> <div className="cc-kpi">
<p className="stat-value" style={{ color: '#667eea', fontSize: '2.5rem' }}>{overallStats.avgUptime}%</p> <p className="cc-kpi-label">Average Uptime</p>
<p className="stat-label">Average Uptime</p> <p className="cc-kpi-value" style={{ color: '#a78bfa' }}>{overallStats.avgUptime}%</p>
</div> </div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}> <div className="cc-kpi">
<p className="stat-value" style={{ color: '#ff9800', fontSize: '2.5rem' }}>{overallStats.totalIncidents}</p> <p className="cc-kpi-label">Total Incidents</p>
<p className="stat-label">Total Incidents</p> <p className="cc-kpi-value" style={{ color: '#f59e0b' }}>{overallStats.totalIncidents}</p>
</div> </div>
<div className="stat" style={{ background: '#f5f5f5', padding: '1.5rem', borderRadius: '8px' }}> <div className="cc-kpi">
<p className="stat-value" style={{ color: '#f44336', fontSize: '2.5rem' }}>{Math.round(overallStats.totalDowntime)}m</p> <p className="cc-kpi-label">Total Downtime</p>
<p className="stat-label">Total Downtime</p> <p className="cc-kpi-value" style={{ color: '#ef4444' }}>{Math.round(overallStats.totalDowntime)}m</p>
</div> </div>
</div> </div>
)} )}
@@ -132,46 +126,46 @@ function SLA() {
<p>Add resources to start tracking SLA metrics</p> <p>Add resources to start tracking SLA metrics</p>
</div> </div>
) : ( ) : (
<div style={{ overflowX: 'auto' }}> <div className="cc-table-wrap">
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#fff', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}> <table className="cc-table">
<thead> <thead>
<tr style={{ background: '#667eea', color: 'white' }}> <tr>
<th style={{ padding: '1rem', textAlign: 'left' }}>Resource</th> <th>Resource</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Target</th> <th style={{ textAlign: 'center' }}>Target</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Actual</th> <th style={{ textAlign: 'center' }}>Actual</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Status</th> <th style={{ textAlign: 'center' }}>Status</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Checks</th> <th style={{ textAlign: 'center' }}>Checks</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Incidents</th> <th style={{ textAlign: 'center' }}>Incidents</th>
<th style={{ padding: '1rem', textAlign: 'center' }}>Downtime</th> <th style={{ textAlign: 'center' }}>Downtime</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{slaData.map((item, idx) => ( {slaData.map((item, idx) => (
<tr key={item.resource_id} style={{ borderBottom: '1px solid #eee', background: idx % 2 === 0 ? '#fafafa' : '#fff' }}> <tr key={item.resource_id} style={{ background: idx % 2 === 0 ? '#0b1325' : '#0f172a' }}>
<td style={{ padding: '1rem', fontWeight: 'bold' }}>{item.resource_name}</td> <td style={{ fontWeight: 'bold' }}>{item.resource_name}</td>
<td style={{ padding: '1rem', textAlign: 'center' }}>{item.sla_target}%</td> <td style={{ textAlign: 'center' }}>{item.sla_target}%</td>
<td style={{ padding: '1rem', textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#4caf50' : '#f44336' }}> <td style={{ textAlign: 'center', fontWeight: 'bold', color: item.meets_target ? '#22c55e' : '#ef4444' }}>
{item.actual_uptime}% {item.actual_uptime}%
</td> </td>
<td style={{ padding: '1rem', textAlign: 'center' }}> <td style={{ textAlign: 'center' }}>
<span style={{ <span style={{
padding: '0.25rem 0.75rem', padding: '0.25rem 0.75rem',
borderRadius: '12px', borderRadius: '12px',
background: item.meets_target ? '#e8f5e9' : '#ffebee', background: item.meets_target ? '#052e16' : '#3f1d1d',
color: item.meets_target ? '#2e7d32' : '#c62828', color: item.meets_target ? '#4ade80' : '#fda4af',
fontSize: '0.875rem', fontSize: '0.875rem',
fontWeight: 'bold' fontWeight: 'bold'
}}> }}>
{item.meets_target ? '✓ PASS' : '✗ FAIL'} {item.meets_target ? '✓ PASS' : '✗ FAIL'}
</span> </span>
</td> </td>
<td style={{ padding: '1rem', textAlign: 'center' }}> <td style={{ textAlign: 'center' }}>
{item.successful_checks}/{item.total_checks} {item.successful_checks}/{item.total_checks}
</td> </td>
<td style={{ padding: '1rem', textAlign: 'center', color: item.incidents > 0 ? '#ff9800' : '#666' }}> <td style={{ textAlign: 'center', color: item.incidents > 0 ? '#f59e0b' : '#94a3b8' }}>
{item.incidents} {item.incidents}
</td> </td>
<td style={{ padding: '1rem', textAlign: 'center' }}> <td style={{ textAlign: 'center' }}>
{item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'} {item.downtime_minutes > 0 ? `${item.downtime_minutes}m` : '-'}
</td> </td>
</tr> </tr>
@@ -180,7 +174,7 @@ function SLA() {
</table> </table>
{totalItems > pageSize && ( {totalItems > pageSize && (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}> <div className="cc-pagination" style={{ marginTop: '1rem' }}>
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))} onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
@@ -188,8 +182,8 @@ function SLA() {
> >
← Previous ← Previous
</button> </button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> <div className="cc-controls">
<span style={{ color: '#666', fontSize: '0.9rem' }}>Page</span> <span>Page</span>
<input <input
type="number" type="number"
value={currentPage} value={currentPage}
@@ -197,14 +191,14 @@ function SLA() {
const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1)); const page = Math.max(1, Math.min(Math.ceil(totalItems / pageSize), parseInt(e.target.value) || 1));
setCurrentPage(page); setCurrentPage(page);
}} }}
style={{ width: '50px', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', textAlign: 'center' }} className="cc-input-sm"
/> />
<span style={{ color: '#666', fontSize: '0.9rem' }}>of {Math.ceil(totalItems / pageSize)}</span> <span>of {Math.ceil(totalItems / pageSize)}</span>
</div> </div>
<select <select
value={pageSize} value={pageSize}
onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }} onChange={(e) => { setPageSize(parseInt(e.target.value)); setCurrentPage(1); }}
style={{ padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px', background: '#fff' }} className="cc-select-sm"
> >
<option value={5}>5 per page</option> <option value={5}>5 per page</option>
<option value={10}>10 per page</option> <option value={10}>10 per page</option>
@@ -223,13 +217,13 @@ function SLA() {
</div> </div>
)} )}
<div style={{ marginTop: '2rem', padding: '1.5rem', background: '#f5f5f5', borderRadius: '8px' }}> <div className="cc-surface" style={{ marginTop: '1rem' }}>
<h3 style={{ marginTop: 0, color: '#667eea' }}>SLA Report Summary</h3> <h3 className="history-resource-title">SLA Report Summary</h3>
<p style={{ color: '#666', lineHeight: '1.6' }}> <p style={{ color: '#cbd5e1', lineHeight: '1.6' }}>
This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{effectiveDays} days</strong>. This report shows the Service Level Agreement (SLA) performance for all monitored resources over the last <strong>{effectiveDays} days</strong>.
Resources meeting their target uptime are marked as <strong style={{ color: '#4caf50' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#f44336' }}>FAIL</strong>. Resources meeting their target uptime are marked as <strong style={{ color: '#22c55e' }}>PASS</strong>, while those below target are marked as <strong style={{ color: '#ef4444' }}>FAIL</strong>.
</p> </p>
<p style={{ color: '#666', lineHeight: '1.6', marginBottom: 0 }}> <p style={{ color: '#cbd5e1', lineHeight: '1.6', marginBottom: 0 }}>
Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets. Overall performance: <strong>{overallStats?.meetingTarget || 0}</strong> out of <strong>{overallStats?.totalResources || 0}</strong> resources ({overallStats ? Math.round((overallStats.meetingTarget / overallStats.totalResources) * 100) : 0}%) are meeting their SLA targets.
</p> </p>
</div> </div>
+77 -39
View File
@@ -1,33 +1,39 @@
.settings-wizard { .settings-wizard {
max-width: 900px; max-width: 980px;
margin: 0 auto; margin: 0 auto;
color: #e2e8f0;
}
.settings-wizard > h2 {
color: #f8fafc;
text-shadow: 0 1px 0 rgba(15, 23, 42, 0.3);
} }
.settings-description { .settings-description {
color: #666; color: #cbd5e1;
margin-bottom: 2rem; margin-bottom: 1.4rem;
} }
.settings-section { .settings-section {
background: white; background: rgba(15, 23, 42, 0.85);
border-radius: 8px; border-radius: 12px;
padding: 2rem; padding: 1.35rem;
margin-bottom: 1.5rem; margin-bottom: 1rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); border: 1px solid #334155;
} }
.settings-header { .settings-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1.5rem; margin-bottom: 1rem;
padding-bottom: 1rem; padding-bottom: 0.8rem;
border-bottom: 2px solid #f0f0f0; border-bottom: 1px solid #334155;
} }
.settings-header h3 { .settings-header h3 {
margin: 0; margin: 0;
color: #333; color: #f8fafc;
} }
.settings-form { .settings-form {
@@ -43,23 +49,43 @@
.form-group small { .form-group small {
display: block; display: block;
color: #999; color: #cbd5e1;
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.settings-wizard .form-group label {
color: #e2e8f0;
}
.settings-wizard .form-group input,
.settings-wizard .form-group select,
.settings-wizard .form-group textarea {
background: #0b1325;
color: #f8fafc;
border: 1px solid #334155;
}
.settings-wizard .form-group input:focus,
.settings-wizard .form-group select:focus,
.settings-wizard .form-group textarea:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
}
.info-box { .info-box {
background: #f8f9fa; background: #0b1325;
border-left: 4px solid #667eea; border-left: 4px solid #60a5fa;
padding: 1rem; padding: 1rem;
margin: 1.5rem 0; margin: 1.5rem 0;
border-radius: 4px; border-radius: 8px;
border: 1px solid #334155;
} }
.info-box strong { .info-box strong {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #333; color: #f8fafc;
} }
.info-box ol { .info-box ol {
@@ -69,11 +95,11 @@
.info-box li { .info-box li {
margin: 0.25rem 0; margin: 0.25rem 0;
color: #666; color: #e2e8f0;
} }
.info-box a { .info-box a {
color: #667eea; color: #bfdbfe;
text-decoration: none; text-decoration: none;
} }
@@ -82,12 +108,14 @@
} }
.info-box pre { .info-box pre {
background: white; background: #111827;
color: #f8fafc;
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: 8px;
overflow-x: auto; overflow-x: auto;
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 0.5rem; margin-top: 0.5rem;
border: 1px solid #334155;
} }
.toggle-switch { .toggle-switch {
@@ -110,7 +138,7 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: #ccc; background-color: #334155;
transition: .4s; transition: .4s;
border-radius: 34px; border-radius: 34px;
} }
@@ -122,13 +150,17 @@
width: 26px; width: 26px;
left: 4px; left: 4px;
bottom: 4px; bottom: 4px;
background-color: white; background-color: #e2e8f0;
transition: .4s; transition: .4s;
border-radius: 50%; border-radius: 50%;
} }
.toggle-switch input:checked + .toggle-slider { .toggle-switch input:checked + .toggle-slider {
background-color: #667eea; background-color: #2563eb;
}
.toggle-switch input:focus + .toggle-slider {
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.28);
} }
.toggle-switch input:checked + .toggle-slider:before { .toggle-switch input:checked + .toggle-slider:before {
@@ -136,13 +168,19 @@
} }
.btn-secondary { .btn-secondary {
background: #6c757d; background: #1e293b;
color: white; color: #f8fafc;
margin-top: 1rem; margin-top: 1rem;
border: 1px solid #334155;
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: #5a6268; background: #334155;
}
.btn-secondary:focus-visible {
outline: 2px solid #60a5fa;
outline-offset: 2px;
} }
.btn-secondary:disabled { .btn-secondary:disabled {
@@ -157,15 +195,15 @@
} }
.test-result.success { .test-result.success {
background: #d4edda; background: #052e16;
color: #155724; color: #86efac;
border: 1px solid #c3e6cb; border: 1px solid #166534;
} }
.test-result.error { .test-result.error {
background: #f8d7da; background: #3f1d1d;
color: #721c24; color: #fda4af;
border: 1px solid #f5c6cb; border: 1px solid #7f1d1d;
} }
.test-result strong { .test-result strong {
@@ -201,15 +239,15 @@
} }
.save-status.success { .save-status.success {
background: #4caf50; background: #166534;
color: white; color: #dcfce7;
border-left: 4px solid #388e3c; border-left: 4px solid #22c55e;
} }
.save-status.error { .save-status.error {
background: #f44336; background: #7f1d1d;
color: white; color: #fee2e2;
border-left: 4px solid #d32f2f; border-left: 4px solid #ef4444;
} }
.save-status strong { .save-status strong {
+132 -5
View File
@@ -13,6 +13,10 @@ function SettingsWizard() {
email_to: '', email_to: '',
webhook_enabled: false, webhook_enabled: false,
webhook_url: '', webhook_url: '',
webhook_template: '',
ntfy_enabled: false,
ntfy_url: 'https://ntfy.sh',
ntfy_topic: '',
check_interval: 60000, check_interval: 60000,
timeout: 5000, timeout: 5000,
timezone: 'UTC', timezone: 'UTC',
@@ -31,6 +35,7 @@ function SettingsWizard() {
items_per_page: 20, items_per_page: 20,
refresh_interval: 5000, refresh_interval: 5000,
incident_failure_threshold: 10, incident_failure_threshold: 10,
slow_alert_consecutive: 3,
}); });
const [testResult, setTestResult] = useState(null); const [testResult, setTestResult] = useState(null);
@@ -57,6 +62,10 @@ function SettingsWizard() {
setSavedMessage(null); setSavedMessage(null);
try { try {
await axios.post('/api/settings', sectionData); 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 }); setSavedMessage({ section: sectionName, success: true });
// Clear message after 3 seconds // Clear message after 3 seconds
setTimeout(() => setSavedMessage(null), 3000); setTimeout(() => setSavedMessage(null), 3000);
@@ -100,9 +109,28 @@ function SettingsWizard() {
}); });
setTestResult({ success: true, message: response.data.message }); setTestResult({ success: true, message: response.data.message });
} catch (error) { } catch (error) {
setTestResult({ setTestResult({
success: false, success: false,
message: error.response?.data?.error || 'Failed to send test webhook' 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 { } finally {
setTesting(false); setTesting(false);
@@ -290,10 +318,22 @@ function SettingsWizard() {
<small>Receives POST requests with incident data when resources go offline/online</small> <small>Receives POST requests with incident data when resources go offline/online</small>
</div> </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"> <div className="info-box">
<strong>💡 Webhook Payload Example:</strong> <strong>Default payload (when no template is set):</strong>
<pre>{JSON.stringify({ <pre>{JSON.stringify({
resource: "ZimaOS Server", resource: "My Server",
url: "https://example.com", url: "https://example.com",
status: "down", status: "down",
message: "Resource is DOWN", message: "Resource is DOWN",
@@ -314,6 +354,7 @@ function SettingsWizard() {
onClick={() => handleSaveSection('Webhook', { onClick={() => handleSaveSection('Webhook', {
webhook_enabled: settings.webhook_enabled, webhook_enabled: settings.webhook_enabled,
webhook_url: settings.webhook_url, webhook_url: settings.webhook_url,
webhook_template: settings.webhook_template,
})} })}
disabled={savingSection === 'Webhook'} disabled={savingSection === 'Webhook'}
> >
@@ -324,6 +365,79 @@ function SettingsWizard() {
)} )}
</div> </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 */} {/* Test Result */}
{testResult && ( {testResult && (
<div className={`test-result ${testResult.success ? 'success' : 'error'}`}> <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> <small>Number of consecutive failures before triggering an alert (default: 3)</small>
</div> </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"> <div className="form-group">
<label>Incident Failure Threshold</label> <label>Incident Failure Threshold</label>
<input <input
@@ -466,6 +592,7 @@ function SettingsWizard() {
grace_period: settings.grace_period, grace_period: settings.grace_period,
downtime_threshold: settings.downtime_threshold, downtime_threshold: settings.downtime_threshold,
incident_failure_threshold: settings.incident_failure_threshold, incident_failure_threshold: settings.incident_failure_threshold,
slow_alert_consecutive: settings.slow_alert_consecutive,
})} })}
disabled={savingSection === 'Monitoring'} disabled={savingSection === 'Monitoring'}
> >
+51
View File
@@ -319,3 +319,54 @@
padding: 0.5rem; 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
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import axios from 'axios'; import axios from 'axios';
import { formatLocalTime } from './utils/timeUtils';
import './Status.css'; import './Status.css';
function Status() { function Status() {
@@ -12,20 +13,17 @@ function Status() {
useEffect(() => { useEffect(() => {
loadStatusData(); loadStatusData();
const interval = setInterval(loadStatusData, 15000); // Refresh every 15 seconds for critical status if (!autoRefresh) return;
const interval = setInterval(loadStatusData, 15000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, [autoRefresh]);
const loadStatusData = async () => { const loadStatusData = async () => {
try { try {
const [resourcesRes, incidentsRes, maintenanceRes] = await Promise.all([ const statusRes = await axios.get('/api/status-page');
axios.get('/api/resources'), setResources(statusRes.data.resources || []);
axios.get('/api/incidents'), setIncidents(statusRes.data.incidents || []);
axios.get('/api/maintenance-windows'), setMaintenanceWindows(statusRes.data.maintenanceWindows || []);
]);
setResources(resourcesRes.data.resources || []);
setIncidents(incidentsRes.data.incidents || []);
setMaintenanceWindows(maintenanceRes.data.maintenanceWindows || []);
setLastUpdated(new Date()); setLastUpdated(new Date());
} catch (error) { } catch (error) {
// Status load error handled // 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 formatDuration = (ms) => {
const seconds = Math.floor(ms / 1000); const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60); const minutes = Math.floor(seconds / 60);
@@ -129,6 +119,19 @@ function Status() {
return m.resource_id === resource.id && now >= start && now <= end; 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 ( return (
<div <div
key={resource.id} key={resource.id}
@@ -149,6 +152,28 @@ function Status() {
</p> </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 && ( {activeIncident && (
<div className="incident-info"> <div className="incident-info">
<p className="incident-title">Active Incident</p> <p className="incident-title">Active Incident</p>
+2 -2
View File
@@ -3,8 +3,8 @@ body {
font-family: 'Manrope', 'IBM Plex Sans', 'Segoe UI', sans-serif; font-family: 'Manrope', 'IBM Plex Sans', 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
color: #0f172a; color: #e2e8f0;
background-color: #f8fafc; background-color: #020617;
} }
* { * {
+44 -47
View File
@@ -9,6 +9,21 @@ export const getCurrentTimezone = () => {
return Intl.DateTimeFormat().resolvedOptions().timeZone; 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 * Format a timestamp to local time string with timezone info
* Uses server timezone from localStorage if available, otherwise browser timezone * Uses server timezone from localStorage if available, otherwise browser timezone
@@ -23,40 +38,19 @@ export const formatLocalTime = (timestamp, options = {}) => {
} }
const date = new Date(ts); const date = new Date(ts);
const tz = getServerTimezone();
// 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 defaultOptions = { const defaultOptions = {
year: 'numeric', year: 'numeric',
month: 'short', month: 'short',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
timeZoneName: 'short' timeZoneName: 'short',
...(tz ? { timeZone: tz } : {})
}; };
if (timeZone) {
defaultOptions.timeZone = timeZone;
}
return date.toLocaleString('en-US', { ...defaultOptions, ...options }); return date.toLocaleString('en-US', { ...defaultOptions, ...options });
}; };
@@ -65,15 +59,17 @@ export const formatLocalTime = (timestamp, options = {}) => {
*/ */
export const formatLocalTimeOnly = (timestamp, options = {}) => { export const formatLocalTimeOnly = (timestamp, options = {}) => {
if (!timestamp) return 'Never'; if (!timestamp) return 'Never';
const date = new Date(timestamp); const date = new Date(timestamp);
const tz = getServerTimezone();
const defaultOptions = { const defaultOptions = {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
timeZoneName: 'short' timeZoneName: 'short',
...(tz ? { timeZone: tz } : {})
}; };
return date.toLocaleTimeString('en-US', { ...defaultOptions, ...options }); return date.toLocaleTimeString('en-US', { ...defaultOptions, ...options });
}; };
@@ -83,26 +79,18 @@ export const formatLocalTimeOnly = (timestamp, options = {}) => {
*/ */
export const formatChartTime = (timestamp) => { export const formatChartTime = (timestamp) => {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(); const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
const tz = getServerTimezone();
// If within last 24 hours, show time only const tzOpt = tz ? { timeZone: tz } : {};
if (diffDays === 0) { if (diffDays === 0) {
return date.toLocaleTimeString('en-US', { return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', ...tzOpt });
hour: '2-digit',
minute: '2-digit'
});
} }
// Otherwise show date and time return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', ...tzOpt });
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}; };
/** /**
@@ -112,10 +100,19 @@ export const getTimezoneInfo = () => {
const tz = getCurrentTimezone(); const tz = getCurrentTimezone();
const now = new Date(); const now = new Date();
const tzDisplay = now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop(); const tzDisplay = now.toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop();
return { return {
timezone: tz, timezone: tz,
abbreviation: tzDisplay, abbreviation: tzDisplay,
offset: now.getTimezoneOffset() 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`;
}
+13 -401
View File
@@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.2", "axios": "^1.6.2",
"better-sqlite3": "^9.2.2", "better-sqlite3": "^13.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
@@ -118,35 +118,17 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/better-sqlite3": {
"version": "9.6.0", "version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "node-addon-api": "^8.0.0"
"prebuild-install": "^7.1.1" },
"engines": {
"node": ">=22"
} }
}, },
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
@@ -162,26 +144,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -230,30 +192,6 @@
"node": ">=8" "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": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "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" "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": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -516,30 +448,6 @@
"ms": "2.0.0" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -568,15 +476,6 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -634,15 +533,6 @@
"node": ">= 0.8" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -713,15 +603,6 @@
"node": ">= 0.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": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -768,12 +649,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -859,12 +734,6 @@
"node": ">= 0.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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -936,12 +805,6 @@
"node": ">= 0.4" "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": { "node_modules/glob-parent": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
@@ -1048,26 +911,6 @@
"node": ">=0.10.0" "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": { "node_modules/ignore-by-default": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "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==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1319,18 +1156,6 @@
"node": ">= 0.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": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -1344,33 +1169,12 @@
"node": "*" "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": { "node_modules/ms": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1380,16 +1184,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-abi": { "node_modules/node-addon-api": {
"version": "3.85.0", "version": "8.9.1",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz",
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==",
"license": "MIT", "license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": { "engines": {
"node": ">=10" "node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/node-cron": { "node_modules/node-cron": {
@@ -1533,15 +1334,6 @@
"node": ">= 0.8" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1579,32 +1371,6 @@
"node": ">=4.0.0" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1631,16 +1397,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
@@ -1680,35 +1436,6 @@
"node": ">= 0.8" "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": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -2005,51 +1732,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/simple-update-notifier": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -2078,15 +1760,6 @@
"node": ">= 0.8" "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": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -2115,15 +1788,6 @@
"node": ">=8" "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": { "node_modules/supports-color": {
"version": "8.1.1", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "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" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -2217,18 +1853,6 @@
"dev": true, "dev": true,
"license": "0BSD" "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": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -2258,12 +1882,6 @@
"node": ">= 0.8" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "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" "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": { "node_modules/ws": {
"version": "8.18.3", "version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
+2 -2
View File
@@ -7,7 +7,7 @@
"start": "node server/index.js", "start": "node server/index.js",
"dev": "concurrently \"npm run server\" \"npm run client\"", "dev": "concurrently \"npm run server\" \"npm run client\"",
"server": "nodemon server/index.js", "server": "nodemon server/index.js",
"client": "cd client && npm start", "client": "cd client && PORT=3456 npm start",
"build": "cd client && npm run build", "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", "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" "install-all": "npm install && cd client && npm install"
@@ -22,7 +22,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.2", "axios": "^1.6.2",
"better-sqlite3": "^9.2.2", "better-sqlite3": "^13.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
+31 -1
View File
@@ -1,7 +1,11 @@
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const path = require('path'); 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 // Create tables
db.exec(` db.exec(`
@@ -311,10 +315,36 @@ try {
db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run(); db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ('incident_failure_threshold', '10')").run();
} catch (err) {} } 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 // Helpful indexes for time-range queries
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_checks_resource_time ON checks(resource_id, checked_at DESC); 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_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; module.exports = db;
+479 -329
View File
@@ -3,8 +3,29 @@ const cors = require('cors');
const path = require('path'); const path = require('path');
const WebSocket = require('ws'); const WebSocket = require('ws');
const http = require('http'); const http = require('http');
const { randomUUID } = require('crypto');
require('dotenv').config(); 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 db = require('./database');
const scheduler = require('./scheduler'); const scheduler = require('./scheduler');
const monitorService = require('./monitorService'); const monitorService = require('./monitorService');
@@ -12,6 +33,7 @@ const notificationService = require('./notificationService');
const cache = require('./cache'); const cache = require('./cache');
const security = require('./securityMiddleware'); const security = require('./securityMiddleware');
const metrics = require('./metrics'); const metrics = require('./metrics');
const { discoverNetwork, getLocalSubnets } = require('./networkDiscovery');
const auditLog = require('./auditLog'); const auditLog = require('./auditLog');
// Initialize notifications table if it doesn't exist // Initialize notifications table if it doesn't exist
@@ -38,6 +60,12 @@ try {
console.error('Error initializing notifications table:', error); 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 app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -105,6 +133,148 @@ function getTimezoneOffset() {
return result; 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(cors());
app.use(express.json()); app.use(express.json());
@@ -135,17 +305,6 @@ app.use((req, res, next) => {
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 // API Routes
// Get all groups // Get all groups
@@ -204,42 +363,24 @@ app.get('/api/resources/:id', (req, res) => {
// Create resource // Create resource
app.post('/api/resources', (req, res) => { 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) { const validationError = validateResourceBody(req.body);
return res.status(400).json({ error: 'Name and URL are required' }); if (validationError) return res.status(400).json({ error: validationError });
}
// 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' });
}
try { try {
const isHeartbeat = (type || 'http') === 'heartbeat';
const token = isHeartbeat ? randomUUID() : null;
const effectiveUrl = isHeartbeat ? `heartbeat://${name}` : url;
const stmt = db.prepare(` 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const result = stmt.run( const result = stmt.run(
name, name,
url, effectiveUrl,
type || 'http', type || 'http',
check_interval || 60000, check_interval || 60000,
timeout || 5000, timeout || 5000,
@@ -252,7 +393,15 @@ app.post('/api/resources', (req, res) => {
sla_target || 99.9, sla_target || 99.9,
email_to || null, email_to || null,
maintenance_mode ? 1 : 0, 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 // Invalidate related cache entries
@@ -262,9 +411,8 @@ app.post('/api/resources', (req, res) => {
// Audit log // Audit log
auditLog.logResourceChange('create', result.lastInsertRowid, { name, url, type, enabled: true }, 'system', req.ip); 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) { } catch (err) {
// Helpful hint if migration missing
const hint = err.message.includes('no column named maintenance_mode') const hint = err.message.includes('no column named maintenance_mode')
? 'Database schema missing maintenance_mode. Restart server to run migrations.' ? 'Database schema missing maintenance_mode. Restart server to run migrations.'
: undefined; : undefined;
@@ -274,21 +422,28 @@ app.post('/api/resources', (req, res) => {
// Update resource // Update resource
app.put('/api/resources/:id', (req, res) => { 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(` const stmt = db.prepare(`
UPDATE resources 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 = ? 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 = ? WHERE id = ?
`); `);
stmt.run( stmt.run(
name, name,
url, effectiveUrl,
type, type,
check_interval, check_interval,
timeout, timeout,
enabled ? 1 : 0, enabled ? 1 : 0,
group_id || null, group_id || null,
http_keyword || null, http_keyword || null,
http_headers || null, http_headers || null,
@@ -299,10 +454,16 @@ app.put('/api/resources/:id', (req, res) => {
email_to || null, email_to || null,
maintenance_mode ? 1 : 0, maintenance_mode ? 1 : 0,
retention_days || null, 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 req.params.id
); );
// Invalidate related cache entries
cache.invalidatePattern('history:'); cache.invalidatePattern('history:');
cache.invalidatePattern('sla:'); 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 // Delete resource
app.delete('/api/resources/:id', (req, res) => { app.delete('/api/resources/:id', (req, res) => {
const resource = db.prepare('SELECT name, url FROM resources WHERE id = ?').get(req.params.id); const resource = db.prepare('SELECT name, url FROM resources WHERE id = ?').get(req.params.id);
if (!resource) return res.status(404).json({ error: 'Resource not found' });
db.prepare('DELETE FROM resources WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM resources WHERE id = ?').run(req.params.id);
// Invalidate related cache entries
cache.invalidatePattern('history:'); cache.invalidatePattern('history:');
cache.invalidatePattern('sla:'); cache.invalidatePattern('sla:');
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
// Audit log
if (resource) {
auditLog.logResourceChange('delete', req.params.id, { name: resource.name, url: resource.url }, 'system', req.ip);
}
res.json({ message: 'Resource deleted' }); res.json({ message: 'Resource deleted' });
}); });
// Get dashboard overview (grouped) // Get dashboard overview (grouped)
app.get('/api/dashboard', (req, res) => { app.get('/api/dashboard', (req, res) => {
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all(); res.json(getDashboardPayload());
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 });
}); });
// Get incidents // Get incidents
@@ -516,90 +663,20 @@ app.post('/api/notifications/clear', (req, res) => {
// Get settings // Get settings
app.get('/api/settings', (req, res) => { 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('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache'); res.set('Pragma', 'no-cache');
res.set('Expires', '0'); res.set('Expires', '0');
// Try to get settings from database first, fallback to process.env
try { try {
const dbSettings = {}; const dbSettings = {};
const rows = db.prepare('SELECT key, value FROM settings').all(); const rows = db.prepare('SELECT key, value FROM settings').all();
for (const row of rows) { for (const row of rows) {
dbSettings[row.key] = row.value; dbSettings[row.key] = row.value;
} }
res.json(buildSettingsFromDb(dbSettings));
// 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);
} catch (error) { } catch (error) {
console.error('Error reading settings:', error); console.error('Error reading settings:', error);
// Fallback to env vars only if database read fails res.json(buildSettingsFromDb({}));
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);
} }
}); });
@@ -638,6 +715,10 @@ app.post('/api/settings', (req, res) => {
refresh_interval, refresh_interval,
theme, theme,
incident_failure_threshold, incident_failure_threshold,
webhook_template,
ntfy_enabled,
ntfy_url,
ntfy_topic,
} = req.body; } = req.body;
// Validate email configuration if enabled // 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' }); return res.status(400).json({ error: 'Timeout must be at least 1000ms' });
} }
const envContent = `EMAIL_ENABLED=${email_enabled || false} // Only persist keys that are explicitly present in this request.
EMAIL_HOST=${email_host || 'smtp.gmail.com'} // The settings UI saves one section at a time, so absent keys must be
EMAIL_PORT=${email_port || 587} // left untouched — otherwise their DB/env values get overwritten with defaults.
EMAIL_USER=${email_user || ''} const body = req.body;
EMAIL_PASS=${email_pass || process.env.EMAIL_PASS || ''} const present = (key) => body[key] !== undefined;
EMAIL_FROM=${email_from || ''}
EMAIL_TO=${email_to || ''} // Field → { envKey, coerce }
WEBHOOK_ENABLED=${webhook_enabled || false} const FIELDS = {
WEBHOOK_URL=${webhook_url || ''} email_enabled: { env: 'EMAIL_ENABLED', coerce: v => String(v) },
CHECK_INTERVAL=${check_interval || 60000} email_host: { env: 'EMAIL_HOST', coerce: v => v || '' },
TIMEOUT=${timeout || 5000} email_port: { env: 'EMAIL_PORT', coerce: v => String(v || 587) },
TIMEZONE=${timezone || 'UTC'} email_user: { env: 'EMAIL_USER', coerce: v => v || '' },
RETENTION_DAYS=${retention_days || 7} email_pass: { env: 'EMAIL_PASS', coerce: v => v || process.env.EMAIL_PASS || '' },
AUTO_CLEANUP_ENABLED=${auto_cleanup_enabled || false} email_from: { env: 'EMAIL_FROM', coerce: v => v || '' },
CONSECUTIVE_FAILURES=${consecutive_failures || 3} email_to: { env: 'EMAIL_TO', coerce: v => v || '' },
GRACE_PERIOD=${grace_period || 300} webhook_enabled: { env: 'WEBHOOK_ENABLED', coerce: v => String(v) },
DOWNTIME_THRESHOLD=${downtime_threshold || 600} webhook_url: { env: 'WEBHOOK_URL', coerce: v => v || '' },
ALERT_RETRY_COUNT=${alert_retry_count || 3} webhook_template: { env: 'WEBHOOK_TEMPLATE', coerce: v => v || '' },
ALERT_RETRY_DELAY=${alert_retry_delay || 60} check_interval: { env: 'CHECK_INTERVAL', coerce: v => String(v || 60000) },
FALLBACK_WEBHOOK=${fallback_webhook || ''} timeout: { env: 'TIMEOUT', coerce: v => String(v || 5000) },
GLOBAL_QUIET_HOURS_START=${global_quiet_hours_start || ''} timezone: { env: 'TIMEZONE', coerce: v => v || 'UTC' },
GLOBAL_QUIET_HOURS_END=${global_quiet_hours_end || ''} retention_days: { env: 'RETENTION_DAYS', coerce: v => String(v || 7) },
ESCALATION_HOURS=${escalation_hours || 4} auto_cleanup_enabled: { env: 'AUTO_CLEANUP_ENABLED', coerce: v => String(v) },
DEFAULT_SORT=${default_sort || 'name'} consecutive_failures: { env: 'CONSECUTIVE_FAILURES', coerce: v => String(v || 3) },
ITEMS_PER_PAGE=${items_per_page || 20} grace_period: { env: 'GRACE_PERIOD', coerce: v => String(v || 300) },
REFRESH_INTERVAL=${refresh_interval || 5000} downtime_threshold: { env: 'DOWNTIME_THRESHOLD', coerce: v => String(v || 600) },
THEME=${theme || 'light'} alert_retry_count: { env: 'ALERT_RETRY_COUNT', coerce: v => String(v || 3) },
INCIDENT_FAILURE_THRESHOLD=${incident_failure_threshold || 10} 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 { 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 // Update process.env — only touched keys
process.env.TIMEZONE = timezone || 'UTC'; for (const u of updates) process.env[u.envKey] = u.value;
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);
// Also save to database for persistence // Merge into .env file (read → patch → write) so restarts stay consistent
const settingsTable = [ let envLines = [];
{ key: 'email_enabled', value: String(email_enabled) }, try { envLines = fs.readFileSync(envPath, 'utf8').split('\n'); } catch (_) {}
{ key: 'email_host', value: email_host }, const envMap = {};
{ key: 'email_port', value: String(email_port) }, for (const line of envLines) {
{ key: 'email_user', value: email_user }, const eq = line.indexOf('=');
{ key: 'email_from', value: email_from }, if (eq > 0) envMap[line.slice(0, eq)] = line.slice(eq + 1);
{ 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);
} }
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({ // Update in-memory notification config for the fields that changed
email_enabled: email_enabled === true || email_enabled === 'true', const notifKeys = ['email_enabled','email_host','email_port','email_user','email_pass',
email_host, 'email_from','email_to','webhook_enabled','webhook_url','webhook_template',
email_port, 'ntfy_enabled','ntfy_url','ntfy_topic'];
email_user, const changedNotif = updates.filter(u => notifKeys.includes(u.key));
email_pass: email_pass || process.env.EMAIL_PASS || '', if (changedNotif.length > 0) {
email_from, const patch = {};
email_to, for (const u of changedNotif) patch[u.key] = u.value;
webhook_enabled: webhook_enabled === true || webhook_enabled === 'true', // Booleans need coercion
webhook_url, 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' }); res.json({ message: 'Settings saved successfully' });
} catch (error) { } 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 // Clear cache endpoint for debugging
app.post('/api/cache/clear', (req, res) => { app.post('/api/cache/clear', (req, res) => {
cache.clear(); 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 // Get transaction steps for a resource
app.get('/api/resources/:id/transaction-steps', (req, res) => { app.get('/api/resources/:id/transaction-steps', (req, res) => {
const { id } = req.params; const { id } = req.params;
@@ -1770,7 +1939,8 @@ app.get('/api/resources/export', (req, res) => {
} }
// CSV header // 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(',')]; const csvRows = [headers.join(',')];
// CSV rows // CSV rows
@@ -1785,6 +1955,12 @@ app.get('/api/resources/export', (req, res) => {
`"${(resource.tags || '').replace(/"/g, '""')}"`, `"${(resource.tags || '').replace(/"/g, '""')}"`,
resource.group_id || '', resource.group_id || '',
`"${(resource.email_to || '').replace(/"/g, '""')}"`, `"${(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(',')); csvRows.push(row.join(','));
}); });
@@ -1974,8 +2150,9 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
} }
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO resources (name, url, type, check_interval, timeout, sla_target, tags, group_id, email_to) INSERT INTO resources (name, url, type, check_interval, timeout, sla_target, tags, group_id, email_to,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) http_method, http_body, heartbeat_timeout, is_public, consecutive_failures_threshold, response_time_threshold)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
stmt.run( stmt.run(
@@ -1987,7 +2164,13 @@ app.post('/api/resources/import', express.text({ type: 'text/csv' }), (req, res)
slaTarget, slaTarget,
row.tags || '', row.tags || '',
row.group_id ? parseInt(row.group_id) : null, 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); 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) // Only serve index.html for non-file routes (no extension)
app.get('*', (req, res, next) => { app.get('*', (req, res, next) => {
// If the request has a file extension, let static middleware handle it or 404 // 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 // WebSocket handler function
function broadcastDashboardUpdate() { function broadcastDashboardUpdate() {
try { try {
const resources = db.prepare('SELECT * FROM resources ORDER BY group_id, name').all(); const message = JSON.stringify({ type: 'dashboard', data: getDashboardPayload() });
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 }
});
wsClients.forEach(client => { wsClients.forEach(client => {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(message); client.send(message);
+57 -13
View File
@@ -31,9 +31,14 @@ function parseHostPort(urlString, fallbackPort) {
class MonitorService { class MonitorService {
constructor() { constructor() {
// Keep-alive agents for pooled HTTP/S connections // Disable keep-alive to avoid "Parse Error: Data after Connection: close"
this.httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 }); // crashes. Keep-alive connection reuse causes the HTTP parser to throw
this.httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 50 }); // 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) // Circuit breaker for HTTP checks (5 failures before opening, 60s reset)
this.httpCircuitBreaker = new CircuitBreaker(5, 60000, this.isTransientError); this.httpCircuitBreaker = new CircuitBreaker(5, 60000, this.isTransientError);
@@ -98,12 +103,36 @@ class MonitorService {
case 'icmp': case 'icmp':
case 'ping': case 'ping':
return this.checkIcmp(resource, startTime, base); return this.checkIcmp(resource, startTime, base);
case 'heartbeat':
return this.checkHeartbeat(resource, startTime, base);
default: default:
// Fallback to HTTP if unknown // Fallback to HTTP if unknown
return this.checkHttp(resource, startTime, base); 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) { async checkHttp(resource, startTime, base) {
let result = { ...base }; let result = { ...base };
const circuitBreaker = this.getResourceCircuitBreaker(resource.id); const circuitBreaker = this.getResourceCircuitBreaker(resource.id);
@@ -121,15 +150,26 @@ class MonitorService {
} }
} }
const isHttps = resource.url.startsWith('https'); const method = (resource.http_method || 'GET').toUpperCase();
const response = await axios.get(resource.url, { const requestConfig = {
method,
url: resource.url,
timeout: resource.timeout, timeout: resource.timeout,
validateStatus: () => true, validateStatus: () => true,
maxRedirects: 5, maxRedirects: 5,
headers, headers,
httpAgent: this.httpAgent, httpAgent: this.httpAgent,
httpsAgent: this.httpsAgent, 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 responseTime = Date.now() - startTime;
const isUp = response.status >= 200 && response.status < 400; const isUp = response.status >= 200 && response.status < 400;
@@ -353,9 +393,12 @@ class MonitorService {
handleIncident(resourceId, isDown) { handleIncident(resourceId, isDown) {
const resource = db.prepare('SELECT * FROM resources WHERE id = ?').get(resourceId); 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 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(` const activeIncident = db.prepare(`
SELECT * FROM incidents SELECT * FROM incidents
@@ -371,8 +414,8 @@ class MonitorService {
LIMIT ? LIMIT ?
`).all(resourceId, threshold); `).all(resourceId, threshold);
const consecutiveFailures = recentChecks.every(c => c.status === 'down') ? recentChecks.length : const firstUpIdx = recentChecks.findIndex(c => c.status === 'up');
recentChecks.findIndex(c => c.status === 'up') + 1; const consecutiveFailures = firstUpIdx === -1 ? recentChecks.length : firstUpIdx;
// Only trigger incident if we've hit the threshold // Only trigger incident if we've hit the threshold
if (consecutiveFailures >= threshold && !activeIncident) { if (consecutiveFailures >= threshold && !activeIncident) {
@@ -727,9 +770,10 @@ class MonitorService {
return null; return null;
} }
// Return average of response times // Use P95 so the baseline reflects realistic worst-case, not the average.
const sum = checks.reduce((acc, c) => acc + (c.response_time || 0), 0); // Threshold is 2× P95, making it very unlikely to trigger on normal variance.
return Math.round(sum / checks.length); 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 // Check if response time is anomalous
+166
View File
@@ -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
View File
@@ -20,13 +20,18 @@ class NotificationService {
email_to: process.env.EMAIL_TO, email_to: process.env.EMAIL_TO,
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true', webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
webhook_url: process.env.WEBHOOK_URL, 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) { setConfig(config) {
this.emailEnabled = !!config.email_enabled; this.emailEnabled = !!config.email_enabled;
this.webhookEnabled = !!config.webhook_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 // Recreate transporter when email config changes; skip if incomplete
if ( if (
@@ -69,10 +74,12 @@ class NotificationService {
} }
const isDown = incident.type === 'started'; const isDown = incident.type === 'started';
const statusEmoji = isDown ? '🔴' : '🟢'; const isSlow = incident.type === 'slow';
const statusText = isDown ? 'DOWN' : 'UP'; const statusEmoji = isDown ? '🔴' : isSlow ? '🟡' : '🟢';
const statusText = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}`; 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 = []; const promises = [];
@@ -100,19 +107,25 @@ class NotificationService {
promises.push(this.sendWebhook(resource, message, incident.type)); promises.push(this.sendWebhook(resource, message, incident.type));
} }
if (this.ntfyEnabled) {
promises.push(this.sendNtfy(resource, incident.type));
}
await Promise.allSettled(promises); await Promise.allSettled(promises);
// Save notification to in-app notification center // 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); this.saveNotification(resource, incident, status, message);
} }
saveNotification(resource, incident, status, message) { saveNotification(resource, incident, status, message) {
try { try {
const db = require('./database'); const db = require('./database');
const title = status === 'down' ? const title = status === 'down'
`🔴 ${resource.name} is DOWN` : ? `🔴 ${resource.name} is DOWN`
`🟢 ${resource.name} is UP`; : status === 'slow'
? `🟡 ${resource.name} is SLOW`
: `🟢 ${resource.name} is UP`;
const result = db.prepare(` const result = db.prepare(`
INSERT INTO notifications (resource_id, incident_id, type, title, message, read) 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) { isQuietHours(resource) {
if (!resource.quiet_hours_start || !resource.quiet_hours_end) { if (!resource.quiet_hours_start || !resource.quiet_hours_end) {
return false; 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>` ? `<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 = ` let htmlContent = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
${logoBlock} ${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>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>URL:</strong> <a href="${resource.url}">${resource.url}</a></p>
<p><strong>Check Type:</strong> ${resource.type || 'http'}</p> <p><strong>Check Type:</strong> ${resource.type || 'http'}</p>
<p><strong>Time:</strong> ${new Date().toLocaleString()}</p> <p><strong>Time:</strong> ${new Date().toLocaleString()}</p>
@@ -221,7 +350,7 @@ class NotificationService {
await this.transporter.sendMail({ await this.transporter.sendMail({
from: this.config.email_from, from: this.config.email_from,
to: emailTo, 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, text: message,
html: htmlContent, html: htmlContent,
attachments: hasLogo attachments: hasLogo
@@ -282,17 +411,64 @@ class NotificationService {
async sendWebhook(resource, message, type) { async sendWebhook(resource, message, type) {
try { try {
await axios.post(this.config.webhook_url || process.env.WEBHOOK_URL, { const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
resource: resource.name, const status = type === 'started' ? 'down' : type === 'slow' ? 'slow' : 'up';
url: resource.url, const template = this.config.webhook_template;
status: type === 'started' ? 'down' : 'up',
message, let payload;
timestamp: new Date().toISOString(), 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) { } catch (error) {
// Webhook error handled silently // 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(); module.exports = new NotificationService();
+133 -65
View File
@@ -7,6 +7,9 @@ class Scheduler {
constructor() { constructor() {
this.jobs = new Map(); this.jobs = new Map();
this.isRunning = false; this.isRunning = false;
this.lastCheckedAt = new Map();
this.slowAlertCooldown = new Map();
this.slowConsecutiveCount = new Map(); // tracks consecutive slow checks per resource
} }
start() { 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 * * *', () => { cron.schedule('0 2 * * *', () => {
this.archiveOldChecks(); 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) // Also run archive on startup (with delay to let DB init)
setTimeout(() => { setTimeout(() => {
this.archiveOldChecks(); this.archiveOldChecks();
this.updateBaselines();
}, 5000); }, 5000);
} }
archiveOldChecks() { archiveOldChecks() {
try { 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; 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) INSERT INTO archived_checks (resource_id, status, response_time, status_code, error_message, details, checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?) SELECT resource_id, status, response_time, status_code, error_message, details, checked_at
`); FROM checks WHERE resource_id = ? AND checked_at < ?
const deleteOld = db.prepare(`
DELETE FROM checks WHERE id = ?
`); `);
const bulkDelete = db.prepare(`DELETE FROM checks WHERE resource_id = ? AND checked_at < ?`);
const transaction = db.transaction(() => { const transaction = db.transaction(() => {
resources.forEach(resource => { resources.forEach(resource => {
// Use per-resource retention if set, otherwise use global
const retentionDays = resource.retention_days || globalRetentionDays; const retentionDays = resource.retention_days || globalRetentionDays;
const cutoffDate = new Date(); const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays); cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0]; const cutoffString = cutoffDate.toISOString().replace('T', ' ').split('.')[0];
bulkArchive.run(resource.id, cutoffString);
// Get old checks for this resource bulkDelete.run(resource.id, cutoffString);
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);
});
}); });
}); });
@@ -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() { async runChecks() {
const resources = db.prepare(` const resources = db.prepare(`SELECT * FROM resources WHERE enabled = 1`).all();
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const jitter = (maxMs = 1500) => Math.floor(Math.random() * maxMs); 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 () => { 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()); await sleep(index * baseSpacingMs + jitter());
try { try {
const result = await monitorService.checkResource(resource); const result = await monitorService.checkResource(effectiveResource);
monitorService.saveCheck(result); monitorService.saveCheck(result);
// Rolling cap per resource (configurable via settings; default 10000) // 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 capSetting = db.prepare(`SELECT value FROM settings WHERE key = 'checks_cap_per_resource'`).get();
const cap = capSetting ? parseInt(capSetting.value) : 10000; const cap = capSetting ? parseInt(capSetting.value) : 10000;
if (Number.isFinite(cap) && cap > 0) { if (Number.isFinite(cap) && cap > 0) {
// Delete oldest rows beyond cap, using id ordering per resource const rowCount = db.prepare('SELECT COUNT(*) as cnt FROM checks WHERE resource_id = ?').get(resource.id).cnt;
db.prepare(` const excess = rowCount - cap;
DELETE FROM checks if (excess > 0) {
WHERE id IN ( db.prepare(`
SELECT id FROM checks DELETE FROM checks WHERE id IN (
WHERE resource_id = ? SELECT id FROM checks WHERE resource_id = ? ORDER BY id ASC LIMIT ?
ORDER BY id ASC
LIMIT (
SELECT MAX(COUNT(*) - ?, 0)
FROM checks WHERE resource_id = ?
) )
) `).run(resource.id, excess);
`).run(resource.id, cap, resource.id); }
} }
} catch (e) { } catch (e) {
// Non-fatal // Non-fatal
} }
const incident = monitorService.handleIncident( const incident = monitorService.handleIncident(resource.id, result.status === 'down');
resource.id,
result.status === 'down'
);
if (incident.type !== 'none') { if (incident.type !== 'none' && !resource.maintenance_mode) {
const stats = monitorService.getResourceStats(resource.id, 24); const stats = monitorService.getResourceStats(resource.id, 24);
const recentChecks = db.prepare(` const recentChecks = db.prepare(`
SELECT response_time, status FROM checks SELECT response_time, status FROM checks
WHERE resource_id = ? WHERE resource_id = ?
ORDER BY checked_at DESC ORDER BY checked_at DESC LIMIT 12
LIMIT 12
`).all(resource.id); `).all(resource.id);
stats.recentChecks = recentChecks.reverse(); stats.recentChecks = recentChecks.reverse();
if (!resource.maintenance_mode) { pendingAlerts.push({ resource, incident, stats });
await notificationService.sendAlert(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) { } catch (error) {
// Check error handled silently // Check error handled silently
@@ -154,6 +220,9 @@ class Scheduler {
await Promise.allSettled(tasks); 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 // Broadcast updated dashboard to all connected WebSocket clients
if (global.broadcastDashboardUpdate) { if (global.broadcastDashboardUpdate) {
global.broadcastDashboardUpdate(); global.broadcastDashboardUpdate();
@@ -161,7 +230,6 @@ class Scheduler {
} }
stop() { stop() {
// Stop the scheduler (cron tasks will continue running but we flag isRunning)
this.isRunning = false; this.isRunning = false;
} }
} }
+144
View File
@@ -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
View File
@@ -11,9 +11,28 @@ cd /opt/resource-monitor
echo "📥 Pulling latest code..." echo "📥 Pulling latest code..."
git pull origin main 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..." echo "📦 Installing dependencies..."
rm -rf node_modules package-lock.json rm -rf node_modules
npm ci --only=production 2>&1 | tail -20
# 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..." echo "🔄 Restarting application..."
pm2 restart resource-monitor pm2 restart resource-monitor