Compare commits

..
Author SHA1 Message Date
mannilakash 8c4780e75b fix: monitor status - initialization 2026-03-24 09:37:02 -04:00
Alexander Holliday c24b41ecd9 Merge pull request #3437 from bluewave-labs/fix/notificaiton-upper-limit
fix: add upper limit to notificationValidation
2026-03-23 10:16:05 -07:00
Alex 94cf4f17d1 add upper limit to notificationValidation 2026-03-23 10:13:43 -07:00
Alexander Holliday 0a6d876f4f Merge pull request #3376 from ashu130698/feat/bulk-edit-notifications
feat: add bulk edit monitor notifications endpoint
2026-03-23 10:11:12 -07:00
ashu130698 833e2b63ad fix: resolve duplicate identifiers and missing interface method from merge 2026-03-21 01:24:52 +05:30
Alexander Holliday bc2bab87ba Merge pull request #3436 from bluewave-labs/fix/remaining-lint-errors
fix: resolve remaining ESlint errors
2026-03-20 12:42:54 -07:00
Alex Holliday ee5f7427e7 resolve remaining ESlint errors 2026-03-20 12:40:27 -07:00
Alexander Holliday d5968b841e Merge branch 'develop' into feat/bulk-edit-notifications 2026-03-20 12:28:57 -07:00
Alexander Holliday 26bac6d35a Merge pull request #3435 from bluewave-labs/fix/migrations
fix: migration lint errors
2026-03-20 12:24:08 -07:00
ashu130698 3e010f1c0d style: run prettier to fix formatting issues after upstream merge 2026-03-21 00:52:36 +05:30
Alex Holliday 1a504a3cd9 fix migraiton lint errors 2026-03-20 12:22:22 -07:00
Alex Holliday 120c5cd472 repo lint errors 2026-03-20 12:16:23 -07:00
Alexander Holliday cdf17bf600 Merge pull request #3433 from bluewave-labs/fix/controllers
fix/controllers
2026-03-20 11:55:59 -07:00
Alex Holliday 7144c20c5b check,auth 2026-03-20 11:52:53 -07:00
Alex Holliday 7456609e35 diagnostic 2026-03-20 11:51:54 -07:00
Alex Holliday 05c293d689 geo 2026-03-20 11:51:17 -07:00
Alex Holliday 80f3168837 invite, inident 2026-03-20 11:50:47 -07:00
Alex Holliday 332300dde4 log controller 2026-03-20 11:48:58 -07:00
Alex Holliday d4a8023a76 monitors 2026-03-20 11:47:22 -07:00
Alex Holliday c7e449d18c notifications 2026-03-20 11:45:04 -07:00
Alex Holliday 0495bef469 queue 2026-03-20 11:43:37 -07:00
Alex Holliday 6fb7ad63f6 settings 2026-03-20 11:42:40 -07:00
Alex Holliday 8322adbb26 statusPageController interface 2026-03-20 11:39:19 -07:00
Alexander Holliday 4470c87d42 Merge pull request #3431 from bluewave-labs/fix/infra-services
fix: infra services
2026-03-20 11:37:19 -07:00
Alexander Holliday 95bda35ec6 Merge pull request #3430 from bluewave-labs/fix/services
fix/services
2026-03-20 11:33:48 -07:00
Alex Holliday 928ab7551e fixes 2026-03-20 11:33:40 -07:00
Alex Holliday 465cea21bc use interface 2026-03-20 11:29:44 -07:00
Alex Holliday 404bd5d06b add interface to AdvancedMathcer, fix type 2026-03-20 11:28:53 -07:00
ashu130698 05814854df Merge branch 'upstream/develop' into feat/bulk-edit-notifications and resolve conflicts 2026-03-20 23:54:33 +05:30
Alex Holliday a4a3249b5b maintenacne window 2026-03-20 11:20:22 -07:00
ashu130698 1e6cf858c4 fix: address PR review feedback and add Zod validation constraints 2026-03-20 23:39:38 +05:30
Alex Holliday 533a8911cd fix errors in inviteService 2026-03-20 11:03:45 -07:00
Alex Holliday 2fb7516ca1 fix return type 2026-03-20 11:00:30 -07:00
Alex Holliday 6d2008e8d3 geo checks service 2026-03-20 10:59:34 -07:00
Alex Holliday 7332b15242 fix diagnosticSerivce lint error 2026-03-20 10:51:06 -07:00
Alexander Holliday d618a861e3 Merge pull request #3429 from bluewave-labs/fix/monitor-service-lint
fix/monitor service lint
2026-03-20 10:43:32 -07:00
Alex Holliday f69752ccb4 remove casts 2026-03-20 10:37:29 -07:00
Alex Holliday 210881a375 remove unused import 2026-03-20 10:17:24 -07:00
Alex Holliday d8ec8d7775 fix validation 2026-03-20 10:09:29 -07:00
Alex Holliday bfd3eee94d monitorService types 2026-03-20 09:58:49 -07:00
Alex Holliday bfbb4c390a Add type for getGeoChecksByMonitorId 2026-03-20 09:47:02 -07:00
Alexander Holliday 1285a641e8 Merge pull request #3428 from bluewave-labs/fix/status-page-swr-config
fix: modifying refreshInterval=10s for statusPage api
2026-03-20 09:38:46 -07:00
mannilakash 78566c0226 fix: modifying refreshInterval=10s for statusPage api 2026-03-19 11:34:26 -04:00
Alexander Holliday f5ba387261 Merge pull request #3427 from bluewave-labs/fix/swagger-post-monitor-schema
fix: updating swagger for createMonitor and /monitor/teams
2026-03-18 14:40:58 -07:00
Alexander Holliday 48b415a67c Merge pull request #3426 from bluewave-labs/fix/swagger-put-monitor
fix: updating swagger - /PUT to /PATCH monitor/{monitorId}
2026-03-18 14:40:34 -07:00
Alexander Holliday b53f0e56ea Merge pull request #3425 from bluewave-labs/feat/status-page-infra-section-fe
feat: status-page infra section FE
2026-03-18 14:40:01 -07:00
Alexander Holliday 07f828a01a Merge pull request #3424 from bluewave-labs/feat/status-page-infra-section-be
feat: status-page infrastructure section BE
2026-03-18 14:38:57 -07:00
mannilakash b09203664d fix: updating swagger for createMonitor and /monitor/teams 2026-03-18 17:30:19 -04:00
mannilakash 5a12105c3d fix: statusPageMonitor removal of infrastructureMonitors[] 2026-03-18 17:02:17 -04:00
mannilakash 22f79144d8 fix: hardcoded statusPage value fix 2026-03-18 16:25:07 -04:00
mannilakash f749ed46a6 fix: updating swagger - schema changes monitor/{monitorId} 2026-03-18 16:11:36 -04:00
mannilakash 5c23ebe315 fix: updating swagger - /PUT to /PATCH monitor/{monitorId} 2026-03-18 15:36:11 -04:00
mannilakash 115fcc67f6 fix: fetching game/grpc/websocket as well for statusPage 2026-03-17 15:14:44 -04:00
mannilakash 622446c308 fix: refactoring monitorsList and minor fixes based on feedback 2026-03-17 15:01:30 -04:00
mannilakash 294a149fd1 fix: minor changes to statusPageValidation and other fixes 2026-03-17 13:43:36 -04:00
mannilakash d3d00353a1 fix: minor refactoring infrastructureMetrics 2026-03-17 06:04:42 -04:00
mannilakash 894b6a7a7d feat: status-page infra section FE 2026-03-16 17:46:13 -04:00
mannilakash 3a9f9f0ce5 feat: status-page infrastructure section BE 2026-03-16 17:14:28 -04:00
Alexander Holliday 45618a2ab0 Merge pull request #3416 from bluewave-labs/fix/heatmap-response-time-fix
fix: modify heatmap response time conditional text
2026-03-13 12:00:13 -07:00
Alexander Holliday d80e232a10 Merge pull request #3418 from bluewave-labs/fix/middleware-typing
fix: middleware typing
2026-03-13 11:41:50 -07:00
ashu130698 1706012e54 fix:format code 2026-03-13 21:40:28 +05:30
Alex Holliday db9d5cf5e4 verifyJWT types 2026-03-12 11:25:32 -07:00
Alex Holliday 52a6b4c9fa overload sanitizeInput 2026-03-12 11:17:39 -07:00
Alex Holliday 9646e7dbdc type handleErrors 2026-03-12 11:12:46 -07:00
mannilakash d6ceedc2dd fix: upstream changes for resolving invalid responseTime 2026-03-12 14:05:31 -04:00
Alexander Holliday 501664509b Merge pull request #3417 from bluewave-labs/fix/check-snapshot
fix: properly type snapshot
2026-03-12 10:51:38 -07:00
Alex Holliday 2ef184c58a properly type snapshot 2026-03-12 10:43:18 -07:00
Alexander Holliday 22f917034d Merge pull request #3415 from bluewave-labs/fix/unset-with-explicit-null
fix: unset with explicit null
2026-03-12 10:12:40 -07:00
Alex Holliday 81f623b632 properly type updates 2026-03-12 10:05:36 -07:00
Alex Holliday d3605b0149 update client validation/pages 2026-03-12 09:54:08 -07:00
Alex Holliday 48e71d4345 use null for unsetting instead of empty string 2026-03-12 09:53:31 -07:00
mannilakash 411270d256 fix: modify heatmap response time conditional text 2026-03-12 12:44:46 -04:00
Alexander Holliday 6404a06999 Merge pull request #3414 from bluewave-labs/fix/settings-controller-typing
fix: SettingsController correct types
2026-03-12 09:38:56 -07:00
Alex Holliday a2bc2d8442 Merge branch 'fix/settings-controller-typing' of github.com:ajhollid/bluewave-uptime into fix/settings-controller-typing 2026-03-12 09:37:37 -07:00
Alex Holliday e9cb302d6e remove dead unions 2026-03-12 09:37:25 -07:00
Alex Holliday fa8764fde2 remove dead union, remove unused undefined -> conversion 2026-03-12 09:37:25 -07:00
Alex Holliday 84ca5db0fa refactor settingsController 2026-03-12 09:37:08 -07:00
Alex Holliday 8ec285ed41 remove dead unions 2026-03-12 09:35:33 -07:00
Alex Holliday d070a33d7f remove dead union, remove unused undefined -> conversion 2026-03-12 09:32:34 -07:00
Alex Holliday aa61e0f321 refacotr settingsController 2026-03-12 09:13:35 -07:00
ashu130698 88a39fe502 Merge branch 'upstream/develop' into feat/bulk-edit-notifications and resolve conflicts 2026-03-12 13:17:40 +05:30
Alexander Holliday a6b92d3c51 Merge pull request #3412 from bluewave-labs/fix/queueController-types
fix: QueueController Types
2026-03-11 14:10:26 -07:00
Alex Holliday 1af609a569 remove unsued route/methods 2026-03-11 14:08:20 -07:00
Alex Holliday 61cc0c2bef remove unused route 2026-03-11 13:51:37 -07:00
Alexander Holliday e4a228e61e Merge pull request #3411 from bluewave-labs/fix/notification-controller-validation
fix: implement NotificationController missing validation
2026-03-11 13:46:51 -07:00
Alex Holliday 76f550843b remove dead check 2026-03-11 13:45:17 -07:00
Alex Holliday 15d8a8ba1f fix test alert type 2026-03-11 13:40:35 -07:00
Alexander Holliday 842fa92cec Merge pull request #3401 from bluewave-labs/fix/rename-breached-to-exceeded
fix: rename "breached" status to "threshold exceeded"
2026-03-11 13:36:53 -07:00
Alex Holliday e878f51887 use service name 2026-03-11 13:30:39 -07:00
gorkem-bwl 9466090332 fix: rename "breached" to "threshold exceeded" in user-facing strings
Only changes user-facing text — no enum, type, or backend status changes.

## Changes
- locales/en.json: "breached" → "threshold exceeded"
- notificationMessageBuilder.ts: notification title/summary/details
- email.ts: email subject line
2026-03-11 16:30:30 -04:00
Alex Holliday 16b267d3a5 implement missing validation 2026-03-11 13:28:48 -07:00
Alexander Holliday 01f2aa294e Merge pull request #3410 from bluewave-labs/fix/monitor-controller-validation
fix: validation in monitor controller
2026-03-11 13:15:41 -07:00
Alex Holliday c156a49cc0 remove dead check 2026-03-11 13:10:09 -07:00
Alex Holliday 20ff76df9d remove unsued route/validation 2026-03-11 13:07:56 -07:00
Alex Holliday ff90953f17 cleanup and tighten validation 2026-03-11 12:57:27 -07:00
Alex Holliday 545926cfcf fix validation in monitor controller 2026-03-11 12:41:27 -07:00
Alexander Holliday 365b6cc60a Merge pull request #3409 from bluewave-labs/fix/mw-controller-typing
fix: type MWController
2026-03-11 12:11:09 -07:00
Alex Holliday 42f6c42ae1 fix discarded validation, tighten types 2026-03-11 12:04:28 -07:00
Alex Holliday 456237ba4e tighten date typing 2026-03-11 12:00:19 -07:00
Alex Holliday e887c2308a type MWController 2026-03-11 11:51:01 -07:00
Alexander Holliday 24d571a8db Merge pull request #3408 from bluewave-labs/fix/logController
fix: log controller
2026-03-11 11:39:01 -07:00
Alexander Holliday d41c5e9ebf Merge pull request #3407 from bluewave-labs/fix/invite-controller-typing
fix: inviteController typing
2026-03-11 11:38:02 -07:00
Alex Holliday d07f259829 properly type logController 2026-03-11 11:37:17 -07:00
Alex Holliday 566011c4e9 fix inviteController typing 2026-03-11 11:35:36 -07:00
Alexander Holliday 3dd0e0a998 Merge pull request #3403 from bluewave-labs/fix/email-body-mjml
fix: empty email body due to TSC ignoring .mjml templates
2026-03-11 11:31:24 -07:00
Alexander Holliday cda0ec3a4c Merge pull request #3406 from bluewave-labs/fix/incident-controller-typing
fix: Incident Controller/Service typing
2026-03-11 11:18:05 -07:00
Alex Holliday d00889ff9b renamed variable 2026-03-11 11:14:44 -07:00
Alex Holliday eafd97b9ad controller, service, validation 2026-03-11 11:10:38 -07:00
Alexander Holliday 7a0e0bccf4 Merge pull request #3405 from bluewave-labs/fix/diagnostic-controller-types
fix: properly type diagnosticController
2026-03-11 10:51:42 -07:00
Alexander Holliday 01401d56b8 Merge pull request #3404 from bluewave-labs/fix/controller-utils-types
fix: controller utils types
2026-03-11 10:51:11 -07:00
Alex Holliday f69b7f6a35 properly type diagnosticController 2026-03-11 10:50:10 -07:00
Alex Holliday cb5316e6f5 fix ssl-checker type 2026-03-11 10:48:12 -07:00
Alexander Holliday 25baff3013 Merge pull request #3400 from bluewave-labs/fix/dns-cache-ttl
fix: set DNS cache TTL to prevent stale lookups
2026-03-11 10:36:08 -07:00
mannilakash bcff3e15a8 fix: updating build script to add .mjml templates 2026-03-11 13:34:25 -04:00
mannilakash 1ad39fcac6 fix: empty email body due to TSC ignoring .mjml templates 2026-03-11 12:42:57 -04:00
Gorkem Cetin 85d2d34a90 Fix links to Checkmate documentation and user guide
Updated links to documentation and user guides in README.
2026-03-11 02:35:54 -04:00
Gorkem Cetin f6d9b7566b Remove project references from README
Removed references to other BlueWave projects from the README.
2026-03-11 01:47:48 -04:00
gorkem-bwl 943ef92053 fix: rename "breached" status to "exceeded" for clarity
The word "breached" in infrastructure monitoring was confusing users
as it implied a security breach. Renamed to "exceeded" which better
describes threshold violations (e.g. "threshold exceeded").

## Changes
- Renamed monitor status value from "breached" to "exceeded" across
  server types, status service, queue helper, notification builder,
  email subject lines, and MongoDB aggregation queries
- Renamed client types, status box/label components, translation key,
  and infrastructure page props to match
- Added DB migration (0005) to update existing monitors with the old
  status value
2026-03-11 01:31:05 -04:00
gorkem-bwl 84cac870d5 fix: set DNS cache TTL to prevent stale lookups
CacheableLookup defaults to maxTtl=Infinity, meaning successful DNS
lookups are cached forever until process restart. This causes monitors
to fail permanently if DNS changes or if a resolver outage causes
ENOTFOUND errors to persist after recovery.

Set maxTtl=300s (5 min) for successful lookups and errorTtl=30s for
failed lookups so the cache recovers automatically.
2026-03-10 19:04:04 -04:00
Alex Holliday 2884ab524d type fetchMonitorCertificate 2026-03-10 11:35:05 -07:00
Alexander Holliday eac470cb68 Merge pull request #3399 from bluewave-labs/fix/auth-controller
fix: type authController correctly, update services accordingly
2026-03-10 11:31:57 -07:00
Alex Holliday a15b9837c6 fix getUserById 2026-03-10 11:29:40 -07:00
Alex Holliday 562d723fcf remove redundant checks, use validated body 2026-03-10 11:25:55 -07:00
Alex Holliday 2fdd5a72f0 type authController correctly, update services accordingly 2026-03-10 11:18:59 -07:00
ashu130698 f9a4659cb2 Merge branch 'develop' into feat/bulk-edit-notifications 2026-03-10 23:27:01 +05:30
Alexander Holliday 3e26387b9d Merge pull request #3398 from bluewave-labs/feat/retention
feat: retention
2026-03-10 10:45:24 -07:00
Alex Holliday dc7d442ace remove unnecessary check 2026-03-10 10:44:02 -07:00
Alex Holliday 839f1beb41 remove __v from client type 2026-03-10 10:37:44 -07:00
Alex Holliday bbdadb0bf5 remove expiry from repository and service 2026-03-10 10:34:09 -07:00
Alex Holliday cadf68740a remove old expiry field, remove vestigal checks 2026-03-10 10:33:18 -07:00
ashu130698 0fc338db2f refactor(backend): move notifications update endpoint to monitors route and handle security feedback 2026-03-10 22:57:55 +05:30
Alex Holliday 6ad0a6a29d validation fix, batch deleteOlderThan 2026-03-10 10:20:23 -07:00
Alex Holliday 77af9d2e93 FE UI, userService type fix 2026-03-09 11:32:01 -07:00
Alex Holliday b1147b8bf9 FE implementation 2026-03-09 11:28:46 -07:00
Alex Holliday 5ab230f77b remove any types 2026-03-09 11:15:50 -07:00
Alex Holliday d9cb81e71d backend implementation 2026-03-09 11:14:40 -07:00
Alexander Holliday 0058772f61 Merge commit from fork
Fixed the Mass Assignment Privilege Escalation in Checkmate
2026-03-09 10:27:39 -07:00
Alexander Holliday bbc2e4073b Merge pull request #3375 from tb102122/feature/teams-notification-channel
add teams
2026-03-09 09:39:56 -07:00
Alexander Holliday 7967b363c2 Merge pull request #3395 from bluewave-labs/fix/demo-monitors
fix: demo monitor import
2026-03-09 09:10:27 -07:00
Aman Rawat 27dffe9fa6 Fixed 2026-03-09 13:12:01 +05:30
tobias.bruckert@mcon-group.com 7213bace08 Merge branch 'feature/teams-notification-channel' of https://github.com/tb102122/Checkmate into feature/teams-notification-channel 2026-03-09 08:43:30 +11:00
tobias.bruckert@mcon-group.com 2cbc667f30 add type checks and remove unneeded vars 2026-03-09 08:43:14 +11:00
Tobias Bruckert 978dc4cda6 Merge branch 'develop' into feature/teams-notification-channel 2026-03-09 08:33:36 +11:00
ashu130698 5578fdf17f Revert "feat(ui): add bulk edit notifications modal and checkmark selection for uptime monitors"
This reverts commit 27dd565bc6.
2026-03-07 10:40:38 +05:30
Alex Holliday a960e3aba2 fix demo monitor import 2026-03-06 14:27:57 -08:00
Alexander Holliday ff1d473b81 Merge pull request #3392 from bluewave-labs/feat/version-bump
bump bersion
2026-03-06 12:48:48 -08:00
Alex Holliday bd384bde4a bump version 2026-03-06 12:47:00 -08:00
Alexander Holliday e7f052dfa0 Merge pull request #3386 from bluewave-labs/feat/remove-team-member
Add ability to remove team members
2026-03-06 12:37:55 -08:00
Alex Holliday 9903c72ff5 merge 2026-03-06 12:36:26 -08:00
Alex Holliday d7d237d37b move button strings to correct location, flex-end 2026-03-06 12:35:38 -08:00
Alexander Holliday 5d03f6f0f0 Merge pull request #3391 from ajhollid/fix/bump-zod
explicitly bump zod
2026-03-06 12:27:34 -08:00
Alex Holliday 38e2a64255 explicitly bump zod 2026-03-06 12:25:32 -08:00
Alexander Holliday 1f56338697 Merge pull request #3390 from bluewave-labs/fix/port-env-var
Fix: restore PORT env var
2026-03-06 12:20:02 -08:00
gorkem-bwl 172467c0a6 fix: address PR review feedback
- Use useIsAdmin and useIsSuperAdmin hooks instead of manual role checks
- Close delete dialog on failure, not just on success
2026-03-06 15:19:01 -05:00
Alexander Holliday ff2b6dff2b Merge pull request #3387 from bluewave-labs/feat/uptime-websockets
Feat: Uptime - Websockets
2026-03-06 12:12:11 -08:00
Br0wnHammer 9f600f87af Fix: Dev Dependencies in Server 2026-03-07 01:40:07 +05:30
Br0wnHammer 7eeff7a396 Fix: Dev Dependencies 2026-03-07 01:37:37 +05:30
Br0wnHammer 125d70c4b1 Format Checks 2026-03-07 01:13:00 +05:30
Br0wnHammer 5ebe46a81c Feat: Websocket Implementation 2026-03-07 01:11:20 +05:30
Br0wnHammer 19174ab3e5 Feat: update filter controls and default types 2026-03-07 01:02:30 +05:30
gorkem-bwl 7c953978ed refactor: simplify EditUser imports and canDeleteUser logic
- Merge duplicate react-router-dom imports into one
- Replace useIsAdmin + useIsSuperAdmin + useSelector with single useSelector
- Remove redundant !includes("superadmin") check from canDeleteUser
2026-03-06 14:31:59 -05:00
Br0wnHammer db5441d0e2 Feat: Frontend - update form hook and create/edit page 2026-03-07 01:01:49 +05:30
Br0wnHammer 1d529e3be4 Feat: Frontend - Add websocket to client types and validations 2026-03-07 01:00:38 +05:30
Br0wnHammer 23247d4319 Feat: Update server validation 2026-03-07 00:59:39 +05:30
Br0wnHammer 216d9b2996 Feat: Backend - Config 2026-03-07 00:58:55 +05:30
Br0wnHammer 62f1e62a26 Feat: Backend service - Implement WebSocket check in NetworkService 2026-03-07 00:57:52 +05:30
Br0wnHammer 55bdae77f9 Feat: Backend types - Register websocket type and payload 2026-03-07 00:56:58 +05:30
gorkem-bwl 82998a7795 feat: add ability to remove team members
## Changes
- Add DELETE /api/v1/auth/users/:userId endpoint (admin + superadmin)
- Add deleteUserById service method with role hierarchy enforcement
- Superadmins can remove admins and regular users
- Admins can only remove regular users
- No one can remove superadmins, demo users, or themselves via this endpoint
- Add "Remove user" button with confirmation dialog on edit user page
- Allow admins to view user details and access the edit user page
- Add i18n strings for remove user button and confirmation dialog

## Access control
- superadmin -> can remove admin, user
- admin -> can remove user only
- admin cannot remove superadmin or other admins (enforced on both backend and frontend)
2026-03-06 14:18:27 -05:00
ashu130698 2a1645baf2 Merge branch 'develop' from upstream into feat/bulk-edit-notifications 2026-03-06 21:42:39 +05:30
ashu130698 8483a7bc55 fix(validation): remove invalid 2026-03-06 21:10:57 +05:30
ashu130698 27dd565bc6 feat(ui): add bulk edit notifications modal and checkmark selection for uptime monitors 2026-03-06 21:07:29 +05:30
ashu130698 c5f0316990 fix(validation): remove invalid zod enum argument 2026-03-06 20:41:33 +05:30
tobias.bruckert@mcon-group.com 08fd09f59b fix build errors 2026-03-06 19:48:19 +11:00
ashu130698 a40a76fb3c fix: resolve Zod build error and run formatter 2026-03-06 13:22:32 +05:30
tobias.bruckert@mcon-group.com 6c421717f8 run formatting 2026-03-06 17:58:04 +11:00
tobias.bruckert@mcon-group.com 083db95151 Merge branch 'feature/teams-notification-channel' of https://github.com/tb102122/Checkmate into feature/teams-notification-channel 2026-03-06 17:49:08 +11:00
tobias.bruckert@mcon-group.com b11ba68ff8 adjust based on review 2026-03-06 17:47:35 +11:00
Tobias Bruckert 12a4112055 Merge branch 'develop' into feature/teams-notification-channel 2026-03-06 13:08:41 +11:00
ashu130698 4d7de2d3a1 fix: resolve merge conflicts, migrate validation to zod 2026-03-05 23:40:49 +05:30
ashu130698 9c17fbe79c refactor: add Joi validation, rename to updateNotifications, add default case 2026-03-05 23:06:41 +05:30
ashu130698 8eca81c8be fix: handle malformed IDs and allow empty set action 2026-03-05 18:02:55 +05:30
ashu130698 eb2d8b8d3b style: format notificationController with prettier 2026-03-05 13:58:53 +05:30
tobias.bruckert@mcon-group.com 33201096b7 add teams 2026-03-05 19:16:08 +11:00
ashu130698 4827f8faec refractor: simplify bulk edit route to PATCH /notifications 2026-03-05 13:06:36 +05:30
ashu130698 9e06758997 added a PATCH/notifications/bulk/monitor 2026-03-03 23:28:11 +05:30
137 changed files with 3344 additions and 1419 deletions

No files matched your search

+7 -14
View File
@@ -46,11 +46,11 @@ You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.
## User's guide
Usage instructions can be found [here](https://docs.checkmate.so/checkmate-2.1). It's still WIP and some of the information there might be outdated as we continuously add features weekly. Rest assured, we are doing our best! :)
Usage instructions can be found [here](https://checkmate.so/docs).
## Installation
See installation instructions in [Checkmate documentation portal](https://docs.checkmate.so/checkmate-2.1/users-guide/quickstart).
See installation instructions in [Checkmate documentation portal](https://checkmate.so/docs).
Alternatively, you can also use [Elestio](https://elest.io/open-source/checkmate), [K8s](./charts/helm/checkmate/INSTALLATION.md), [Sive Host](https://sive.host) (South Africa) or [Pikapods](https://www.pikapods.com/) to quickly spin off a Checkmate instance. If you would like to monitor your server infrastructure, you'll need [Capture agent](https://github.com/bluewave-labs/capture). Capture repository also contains the installation instructions.
@@ -157,12 +157,11 @@ Here's how you can contribute:
0. Star this repo :)
1. Check [Contributor's guideline](https://github.com/bluewave-labs/Checkmate/blob/develop/CONTRIBUTING.md). First timers are encouraged to check `good-first-issue` tag.
2. Check [project structure](https://docs.checkmate.so/checkmate-2.1/developers-guide/general-project-structure) and [high level overview](https://bluewavelabs.gitbook.io/checkmate/developers-guide/high-level-overview).
3. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
4. Open an issue if you believe you've encountered a bug.
5. Check for good-first-issue's if you are a newcomer.
6. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
7. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram).
2. Read a detailed structure of [Checkmate](https://deepwiki.com/bluewave-labs/Checkmate) if you would like to deep dive into the architecture.
3. Open an issue if you believe you've encountered a bug.
4. Check for good-first-issue's if you are a newcomer.
5. Make a pull request to add new features/make quality-of-life improvements/fix bugs.
6. Check out this interactive walkthrough of the `Checkmate` codebase on CodeCanvas [here](https://www.code-canvas.com/?session=unauthenticatedGithub&repo=Checkmate&owner=bluewave-labs&branch=develop&OnboardingTutorial=true). To refine existing dataflow simulation or create new ones, follow the quick tutorial [here](https://docs.code-canvas.com/updating-diagram).
<a href="https://github.com/bluewave-labs/checkmate/graphs/contributors">
<img src="https://contrib.rocks/image?repo=bluewave-labs/checkmate" />
@@ -175,9 +174,3 @@ Here's how you can contribute:
Thanks to [Gitbook](https://gitbook.io/) for giving us a free tier for their documentation platform, and [Poeditor](https://poeditor.com/) providing us a free account to use their i18n services. If you would like to sponsor Checkmate, please send an email to hello@bluewavelabs.ca
If you would like to sponsor a feature, [see this page](https://checkmate.so/sponsored-features).
Also check other developer and contributor-friendly projects of BlueWave:
- [VerifyWise](https://github.com/bluewave-labs/verifywise), the first open source AI governance platform.
- [DataRoom](https://github.com/bluewave-labs/bluewave-dataroom), an secure file sharing application, aka dataroom.
- [Guidefox](https://github.com/bluewave-labs/guidefox), an application that helps new users learn how to use your product via hints, tours, popups and banners.
+6 -23
View File
@@ -44,7 +44,7 @@
"redux-persist": "6.0.0",
"swr": "^2.3.6",
"vite-plugin-svgr": "^4.2.0",
"zod": "^4.1.11"
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "24.5.2",
@@ -83,7 +83,6 @@
"node_modules/@babel/core": {
"version": "7.29.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -354,7 +353,6 @@
"node_modules/@emotion/react": {
"version": "11.14.0",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -392,7 +390,6 @@
"node_modules/@emotion/styled": {
"version": "11.14.1",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1162,7 +1159,6 @@
"node_modules/@mui/material": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.7",
@@ -1267,7 +1263,6 @@
"node_modules/@mui/system": {
"version": "7.3.7",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/private-theming": "^7.3.7",
@@ -2308,7 +2303,6 @@
"version": "24.5.2",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.12.0"
}
@@ -2324,7 +2318,6 @@
"node_modules/@types/react": {
"version": "18.3.27",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2446,7 +2439,6 @@
"version": "8.15.0",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2771,7 +2763,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3203,8 +3194,7 @@
},
"node_modules/dayjs": {
"version": "1.11.13",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -3586,7 +3576,6 @@
"version": "8.57.1",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4331,7 +4320,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -5115,7 +5103,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz",
"integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -5763,7 +5750,6 @@
"node_modules/react-dom": {
"version": "18.3.1",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5775,7 +5761,6 @@
"node_modules/react-hook-form": {
"version": "7.71.1",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -5975,8 +5960,7 @@
},
"node_modules/redux": {
"version": "5.0.1",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-persist": {
"version": "6.0.0",
@@ -6122,11 +6106,10 @@
"license": "Unlicense"
},
"node_modules/rollup": {
"version": "4.59.0",
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -6915,7 +6898,6 @@
"version": "5.9.3",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7051,7 +7033,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -7359,6 +7340,8 @@
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+1 -1
View File
@@ -52,7 +52,7 @@
"redux-persist": "6.0.0",
"swr": "^2.3.6",
"vite-plugin-svgr": "^4.2.0",
"zod": "^4.1.11"
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "24.5.2",
@@ -57,7 +57,8 @@ export const HeatmapResponseTimeTooltip = ({
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography>
<Typography>
{t("common.labels.responseTime")}: {check.originalResponseTime.toFixed()} ms
{t("common.labels.responseTime")}:{" "}
{check?.originalResponseTime?.toFixed() ?? "N/A"} ms
</Typography>
<Typography textTransform={"capitalize"}>
Status:{" "}
+19 -3
View File
@@ -11,20 +11,26 @@ import type { ResponsiveStyleValue } from "@mui/system";
interface SliderInputProps extends SliderProps {
sx?: SliderProps["sx"];
showValue?: boolean;
formatDisplayValue?: (value: number) => string;
}
export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>(
({ sx, showValue = false, ...props }, ref) => {
({ sx, showValue = false, formatDisplayValue, ...props }, ref) => {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
const displayValue =
showValue && formatDisplayValue && typeof props.value === "number"
? formatDisplayValue(props.value)
: props.value;
return (
<Stack
gap={theme.spacing(8)}
direction={"row"}
alignItems={"center"}
>
{showValue && <Typography>{props.value}</Typography>}
{showValue && <Typography>{displayValue}</Typography>}
<Slider
{...props}
ref={ref}
@@ -63,12 +69,21 @@ interface SliderWithLabelProps extends SliderProps {
fieldLabel?: string;
required?: boolean;
showValue?: boolean;
formatDisplayValue?: (value: number) => string;
sliderMaxWidth?: ResponsiveStyleValue<number | string>;
}
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
(
{ fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
{
fieldLabel,
required,
showValue = true,
formatDisplayValue,
value,
sliderMaxWidth = "100%",
...props
},
ref
) => {
const theme = useTheme();
@@ -82,6 +97,7 @@ export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>
<SliderInput
{...props}
showValue={showValue}
formatDisplayValue={formatDisplayValue}
value={value}
ref={ref}
/>
@@ -7,7 +7,7 @@ import type { MonitorType } from "@/Types/Monitor";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
const types = ["http", "ping", "port", "docker", "game", "grpc"];
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
const typeDisplayNames: Record<string, string> = {
http: "HTTP",
ping: "Ping",
@@ -15,6 +15,7 @@ const typeDisplayNames: Record<string, string> = {
docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
};
const statuses = ["up", "down"];
const states = ["active", "paused"];
+8
View File
@@ -103,6 +103,14 @@ export const useMonitorForm = ({
selectedDisks: data?.selectedDisks || [],
};
break;
case "websocket":
defaults = {
...base,
type: "websocket",
url: data?.url || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
default:
defaults = {
...base,
+2 -2
View File
@@ -1,14 +1,14 @@
import { useMemo } from "react";
import { settingsSchema } from "@/Validation/settings";
import type { Settings } from "@/Types/Settings";
import type { SettingsFormData } from "@/Validation/settings";
import type { SettingsFormInput } from "@/Validation/settings";
interface UseSettingsFormOptions {
data?: Settings | null;
}
export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) => {
return useMemo(() => {
const defaults: SettingsFormData = {
const defaults: SettingsFormInput = {
systemEmailIgnoreTLS: data?.systemEmailIgnoreTLS ?? false,
systemEmailRequireTLS: data?.systemEmailRequireTLS ?? false,
systemEmailRejectUnauthorized: data?.systemEmailRejectUnauthorized ?? true,
+2
View File
@@ -27,12 +27,14 @@ export const useStatusPageForm = ({
companyName: data?.companyName || "",
url: data?.url || generateDefaultUrl(),
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
type: data?.type || ["uptime"],
color: data?.color || "#4169E1",
monitors: data?.monitors || [],
isPublished: data?.isPublished ?? false,
showCharts: data?.showCharts ?? true,
showUptimePercentage: data?.showUptimePercentage ?? true,
showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
customCSS: data?.customCSS || "",
logo: transformLogo(data?.logo),
};
+64 -11
View File
@@ -11,12 +11,17 @@ import { ConfigBox, BasePage } from "@/Components/design-elements";
import { UserRoles } from "@/Types/User";
import { useTranslation } from "react-i18next";
import type { UserRole, User } from "@/Types/User";
import { useParams } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { useTheme } from "@mui/material";
import { useEditUserForm } from "@/Hooks/useEditUserForm";
import { useGet, usePatch } from "@/Hooks/UseApi";
import { useGet, usePatch, useDelete } from "@/Hooks/UseApi";
import type { EditUserFormData } from "@/Validation/editUser";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { DialogInput } from "@/Components/inputs/Dialog";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useIsAdmin, useIsSuperAdmin } from "@/Hooks/useIsAdmin";
interface RoleOption {
id: UserRole;
@@ -29,6 +34,12 @@ const EditUserPage = () => {
const { userId } = useParams<{ userId: string }>();
const { resolver, defaults } = useEditUserForm();
const { patch, loading: isSaving } = usePatch();
const { deleteFn, loading: isDeleting } = useDelete();
const navigate = useNavigate();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const currentUser = useSelector((state: RootState) => state.auth.user);
const isSuperAdmin = useIsSuperAdmin();
const isAdmin = useIsAdmin();
const { data: user, isLoading } = useGet<User>(`/auth/users/${userId}`);
@@ -60,6 +71,20 @@ const EditUserPage = () => {
setValue("role", newRoles, { shouldValidate: true });
};
const canDeleteUser =
isAdmin &&
userId !== currentUser?.id &&
!user?.role?.includes("demo") &&
(isSuperAdmin || user?.role?.every((r) => r !== "admin" && r !== "superadmin"));
const handleDeleteUser = async () => {
const result = await deleteFn(`/auth/users/${userId}`);
setShowDeleteDialog(false);
if (result) {
navigate("/account", { state: { tab: "team" } });
}
};
const onSubmit = async (data: EditUserFormData) => {
await patch(`/auth/users/${userId}`, data);
};
@@ -165,17 +190,45 @@ const EditUserPage = () => {
</Stack>
}
/>
<Button
type="submit"
variant="contained"
color="primary"
loading={isSaving}
sx={{ alignSelf: "flex-end", minWidth: 100 }}
<Stack
gap={LAYOUT.XS}
direction="row"
justifyContent={"flex-end"}
width="100%"
>
{t("common.buttons.save")}
</Button>
{canDeleteUser && (
<Button
variant="contained"
color="error"
onClick={() => setShowDeleteDialog(true)}
sx={{ minWidth: 100 }}
>
{t("common.buttons.removeUser")}
</Button>
)}
<Button
type="submit"
variant="contained"
color="primary"
loading={isSaving}
sx={{ minWidth: 100 }}
>
{t("common.buttons.save")}
</Button>
</Stack>
</Stack>
</form>
<DialogInput
open={showDeleteDialog}
title={t("pages.editUser.dialog.removeUser.title")}
content={t("pages.editUser.dialog.removeUser.content", {
name: `${user?.firstName} ${user?.lastName}`,
})}
onCancel={() => setShowDeleteDialog(false)}
onConfirm={handleDeleteUser}
confirmText={t("common.buttons.removeUser")}
loading={isDeleting}
/>
</BasePage>
);
};
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Table } from "@/Components/design-elements";
import type { Header } from "@/Components/design-elements/Table";
import { useIsSuperAdmin } from "@/Hooks/useIsAdmin";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import type { User } from "@/Types/User";
interface TeamTableProps {
@@ -13,7 +13,7 @@ interface TeamTableProps {
export const TeamTable = ({ users }: TeamTableProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const isSuperAdmin = useIsSuperAdmin();
const isAdmin = useIsAdmin();
const headers: Header<User>[] = [
{
@@ -45,7 +45,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
];
const handleRowClick = (row: User) => {
if (isSuperAdmin) {
if (isAdmin) {
navigate(`/account/team/${row.id}`);
}
};
@@ -54,7 +54,7 @@ export const TeamTable = ({ users }: TeamTableProps) => {
<Table
headers={headers}
data={users}
onRowClick={isSuperAdmin ? handleRowClick : undefined}
onRowClick={isAdmin ? handleRowClick : undefined}
/>
);
};
+27 -7
View File
@@ -146,6 +146,17 @@ const getGeneralSettingsConfig = (
showGrpcServiceName: false,
showIgnoreTls: false,
},
websocket: {
urlLabel: t("pages.createMonitor.form.general.option.wsUrl.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.wsUrl.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: true,
},
};
return configs[type] || configs.http;
};
@@ -291,46 +302,53 @@ const CreateMonitorPage = () => {
>
<RadioWithDescription
value="http"
label={t("pages.createMonitor.form.type.optionHttp")}
label={t("pages.common.monitors.monitorTypes.optionHttp")}
description={t(
"pages.createMonitor.form.type.optionHttpDescription"
)}
/>
<RadioWithDescription
value="ping"
label={t("pages.createMonitor.form.type.optionPing")}
label={t("pages.common.monitors.monitorTypes.optionPing")}
description={t(
"pages.createMonitor.form.type.optionPingDescription"
)}
/>
<RadioWithDescription
value="docker"
label={t("pages.createMonitor.form.type.optionDocker")}
label={t("pages.common.monitors.monitorTypes.optionDocker")}
description={t(
"pages.createMonitor.form.type.optionDockerDescription"
)}
/>
<RadioWithDescription
value="port"
label={t("pages.createMonitor.form.type.optionPort")}
label={t("pages.common.monitors.monitorTypes.optionPort")}
description={t(
"pages.createMonitor.form.type.optionPortDescription"
)}
/>
<RadioWithDescription
value="game"
label={t("pages.createMonitor.form.type.optionGame")}
label={t("pages.common.monitors.monitorTypes.optionGame")}
description={t(
"pages.createMonitor.form.type.optionGameDescription"
)}
/>
<RadioWithDescription
value="grpc"
label={t("pages.createMonitor.form.type.optionGrpc")}
label={t("pages.common.monitors.monitorTypes.optionGrpc")}
description={t(
"pages.createMonitor.form.type.optionGrpcDescription"
)}
/>
<RadioWithDescription
value="websocket"
label={t("pages.common.monitors.monitorTypes.optionWebSocket")}
description={t(
"pages.createMonitor.form.type.optionWebSocketDescription"
)}
/>
</RadioGroup>
</FormControl>
)}
@@ -747,7 +765,9 @@ const CreateMonitorPage = () => {
}
/>
{(watchedType === "http" || watchedType === "grpc") && (
{(watchedType === "http" ||
watchedType === "grpc" ||
watchedType === "websocket") && (
<ConfigBox
title={t("pages.createMonitor.form.ignoreTls.title")}
subtitle={t("pages.createMonitor.form.ignoreTls.description")}
+41 -24
View File
@@ -20,7 +20,7 @@ import { useGet, usePatch, usePost, useLazyGet } from "@/Hooks/UseApi";
import { useToast } from "@/Hooks/UseToast";
import { useSettingsForm } from "@/Hooks/useSettingsForm";
import { useIsAdmin } from "@/Hooks/useIsAdmin.js";
import type { SettingsFormData } from "@/Validation/settings";
import type { SettingsFormData, SettingsFormInput } from "@/Validation/settings";
import { useState } from "react";
import { Controller } from "react-hook-form";
import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs";
@@ -37,6 +37,7 @@ import {
} from "@/Features/UI/uiSlice.js";
import timezones from "@/Utils/timezones.json";
import type { RootState } from "@/Types/state";
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
interface Timezone {
id: string;
@@ -88,7 +89,7 @@ export const SettingsPage = () => {
// Initialize form with schema and defaults
const { schema, defaults } = useSettingsForm({ data: fetchedSettings?.settings });
const form = useForm<SettingsFormData>({
const form = useForm<SettingsFormInput, unknown, SettingsFormData>({
resolver: zodResolver(schema),
defaultValues: defaults,
mode: "onChange",
@@ -255,28 +256,7 @@ export const SettingsPage = () => {
delete (dataToSend as any).systemEmailPassword;
}
// Convert undefined to empty string for backend unsetting
const processedData = Object.entries(dataToSend).reduce((acc, [key, value]) => {
const typedKey = key as keyof SettingsFormData;
if (value === undefined) {
(acc as any)[typedKey] = "";
} else if (typeof value === "object" && value !== null) {
// Handle nested objects like globalThresholds
// Convert 0 to "" for unsetting thresholds
(acc as any)[typedKey] = Object.entries(value).reduce(
(nested, [nestedKey, nestedValue]) => ({
...nested,
[nestedKey]: nestedValue === undefined ? "" : nestedValue,
}),
{}
);
} else {
(acc as any)[typedKey] = value;
}
return acc;
}, {} as Partial<SettingsFormData>);
const result = await patch("/settings", processedData as SettingsFormData);
const result = await patch("/settings", dataToSend as SettingsFormData);
if (result?.success) {
// Update API key state from response
@@ -462,6 +442,43 @@ export const SettingsPage = () => {
/>
)}
{/* Check Retention */}
{isAdmin && (
<ConfigBox
title={t("pages.settings.form.retention.title")}
subtitle={t("pages.settings.form.retention.description")}
rightContent={
<Controller
name="checkTTL"
control={form.control}
defaultValue={defaults.checkTTL}
render={({ field }) => (
<SliderWithLabel
{...field}
fieldLabel={t("pages.settings.form.retention.option.days.label")}
min={1}
max={CHECK_TTL_SENTINEL}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 30}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
valueLabelFormat={(value: number) =>
value >= CHECK_TTL_SENTINEL
? t("pages.settings.form.retention.option.days.unlimited")
: `${value}`
}
formatDisplayValue={(value: number) =>
value >= CHECK_TTL_SENTINEL
? t("pages.settings.form.retention.option.days.unlimited")
: `${value}`
}
/>
)}
/>
}
/>
)}
{/* Global Thresholds */}
{isAdmin && (
<ConfigBox
+58 -8
View File
@@ -21,19 +21,28 @@ import {
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useStatusPageForm } from "@/Hooks/useStatusPageForm";
import type { StatusPageFormData } from "@/Validation/statusPage";
import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPageResponse } from "@/Types/StatusPage";
import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
const monitorsUrl = (() => {
const params = new URLSearchParams();
["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach(
(type) => params.append("type", type)
);
return `/monitors/team?${params.toString()}`;
})();
interface TimezoneOption {
_id: string;
name: string;
@@ -49,11 +58,11 @@ const CreateStatusPage = () => {
// Fetch existing status page data when configuring
const { data: statusPageData, isLoading: isLoadingStatusPage } =
useGet<StatusPageResponse>(isCreate ? null : `/status-page/${url}?type=uptime`);
useGet<StatusPageResponse>(
isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure`
);
const { data: monitorsResponse } = useGet<Monitor[]>(
"/monitors/team?type=http&type=ping&type=port&type=docker"
);
const { data: monitorsResponse } = useGet<Monitor[]>(monitorsUrl);
const monitors = monitorsResponse ?? [];
const { post, loading: isSubmittingPost } = usePost();
@@ -81,6 +90,24 @@ const CreateStatusPage = () => {
reset(defaults);
}, [defaults, reset]);
const watchedMonitorIds: string[] = form.watch("monitors") ?? [];
const computedTypes: MonitorDisplayType[] = useMemo(() => {
const selectedMonitors = (watchedMonitorIds ?? [])
.map((id) => monitors.find((m) => m.id === id))
.filter((m): m is Monitor => m !== undefined);
const typesSet = new Set<MonitorDisplayType>();
selectedMonitors.forEach((m) => {
typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime");
});
return typesSet.size ? Array.from(typesSet) : ["uptime"];
}, [watchedMonitorIds, monitors]);
useEffect(() => {
form.setValue("type", computedTypes);
}, [computedTypes]);
const onError = (errors: any) => {
logger.debug("Status page validation errors", errors);
};
@@ -103,7 +130,6 @@ const CreateStatusPage = () => {
const onSubmit = async (data: StatusPageFormData) => {
const fd = new FormData();
fd.append("type", "uptime");
fd.append("isPublished", String(data.isPublished));
if (data.companyName) fd.append("companyName", data.companyName);
if (data.url) fd.append("url", data.url);
@@ -112,11 +138,16 @@ const CreateStatusPage = () => {
fd.append("showCharts", String(data.showCharts));
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
});
data.type.forEach((type) => {
fd.append("type[]", type);
});
// Handle logo upload
if (data.logo === null) {
// Signal to remove the logo
@@ -303,7 +334,9 @@ const CreateStatusPage = () => {
}}
>
<GripVertical size={20} />
<Typography flexGrow={1}>{monitor.name}</Typography>
<Typography
flexGrow={1}
>{`${monitor.name} (${getMonitorTypeLabel(monitor.type, t)})`}</Typography>
<IconButton
size="small"
onClick={() => {
@@ -425,6 +458,23 @@ const CreateStatusPage = () => {
/>
)}
/>
<Controller
name="showInfrastructure"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Checkbox
checked={field.value}
onChange={field.onChange}
/>
}
label={t(
"pages.statusPages.form.features.option.showInfrastructure.label"
)}
/>
)}
/>
{/* <Controller
name="showUptimePercentage"
control={control}
@@ -0,0 +1,246 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Gauge } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import prettyBytes from "pretty-bytes";
import type { Monitor } from "@/Types/Monitor";
import Grid from "@mui/material/Grid";
import useMediaQuery from "@mui/material/useMediaQuery";
import Box from "@mui/material/Box";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
const GAUGE_RADIUS = 60;
const GAUGE_STROKE_WIDTH = 12;
const PERCENTAGE_MULTIPLIER = 100;
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
}
interface MetricDetail {
label: string;
value: string;
}
interface MetricConfig {
key: string;
label: string;
hasData: boolean;
progress: number;
details: MetricDetail[];
}
const MetricDetailRow = ({ label, value }: MetricDetail) => {
const theme = useTheme();
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography variant="body2">{value}</Typography>
</Stack>
);
};
interface MetricItemProps {
label: string;
progress: number;
details?: MetricDetail[];
}
const MetricItem = ({ label, progress, details }: MetricItemProps) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Grid
size={isSmall ? 12 : 4}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
gap: theme.spacing(SPACING.LG),
padding: theme.spacing(LAYOUT.XS),
borderRight: isSmall ? "none" : `1px solid ${theme.palette.divider}`,
borderBottom: isSmall ? `1px solid ${theme.palette.divider}` : "none",
"&:last-child": {
borderRight: "none",
borderBottom: "none",
paddingBottom: theme.spacing(SPACING.LG),
},
}}
>
<Box
display="flex"
flexDirection="column"
alignItems="center"
>
<Gauge
progress={progress}
radius={GAUGE_RADIUS}
strokeWidth={GAUGE_STROKE_WIDTH}
/>
<Typography variant="body2">{label}</Typography>
</Box>
{details && details.length > 0 && (
<Box
width="100%"
paddingX={theme.spacing(LAYOUT.LG)}
>
{details.map((detail) => (
<MetricDetailRow
key={detail.label}
label={detail.label}
value={detail.value}
/>
))}
</Box>
)}
</Grid>
);
};
type LatestCheck = NonNullable<Monitor["recentChecks"]>[number];
const buildCpuMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (!check.cpu || typeof check.cpu.usage_percent !== "number") {
return null;
}
const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER;
return {
key: "cpu",
label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
hasData: true,
progress: usagePercent,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.usage"),
value: `${usagePercent.toFixed(2)}%`,
},
],
};
};
const buildMemoryMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (
!check.memory ||
typeof check.memory.usage_percent !== "number" ||
typeof check.memory.used_bytes !== "number" ||
typeof check.memory.total_bytes !== "number"
) {
return null;
}
return {
key: "memory",
label: t("pages.statusPages.monitorsList.infrastructure.memory"),
hasData: true,
progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.used"),
value: prettyBytes(check.memory.used_bytes ?? 0),
},
{
label: t("pages.statusPages.monitorsList.infrastructure.total"),
value: prettyBytes(check.memory.total_bytes ?? 0),
},
],
};
};
const buildDiskMetric = (
check: LatestCheck,
t: (key: string) => string
): MetricConfig | null => {
if (!check.disk || check.disk.length === 0) {
return null;
}
const disks = check.disk;
const avgUsagePercent =
(disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) *
PERCENTAGE_MULTIPLIER;
const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0);
const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0);
return {
key: "disk",
label: t("pages.statusPages.monitorsList.infrastructure.disk"),
hasData: true,
progress: avgUsagePercent,
details: [
{
label: t("pages.statusPages.monitorsList.infrastructure.used"),
value: prettyBytes(totalUsedBytes),
},
{
label: t("pages.statusPages.monitorsList.infrastructure.total"),
value: prettyBytes(totalTotalBytes),
},
],
};
};
export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
const theme = useTheme();
const { t } = useTranslation();
const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
if (!latestCheck) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
const metrics: MetricConfig[] = [
buildCpuMetric(latestCheck, t),
buildMemoryMetric(latestCheck, t),
buildDiskMetric(latestCheck, t),
].filter((m): m is MetricConfig => m !== null);
if (metrics.length === 0) {
return (
<Typography
variant="body2"
color={theme.palette.text.secondary}
>
{t("pages.statusPages.monitorsList.noData")}
</Typography>
);
}
return (
<Grid
container
alignItems="center"
padding={theme.spacing(LAYOUT.XS)}
>
{metrics.map(({ key, label, progress, details }) => (
<MetricItem
key={key}
label={label}
progress={progress}
details={details}
/>
))}
</Grid>
);
};
@@ -5,13 +5,16 @@ import Typography from "@mui/material/Typography";
import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
import { StatusLabel, BaseBox } from "@/Components/design-elements";
import { SwitchComponent } from "@/Components/inputs";
import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
import { useTheme } from "@mui/material/styles";
import { useTheme, type Theme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { useState } from "react";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage } from "@/Types/StatusPage";
import { getMonitorTypeLabel } from "@/Types/StatusPage";
import type { RootState } from "@/Types/state";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
interface StatusPageMonitor extends Monitor {
checks?: Monitor["recentChecks"];
@@ -22,29 +25,124 @@ interface MonitorsListProps {
monitors: StatusPageMonitor[];
}
export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
const bg =
monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
return {
backgroundColor: bg,
color: theme.palette.background.paper,
padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`,
borderRadius: theme.shape.borderRadius,
};
};
const MonitorHeader = ({
monitor,
showURL,
}: {
monitor: StatusPageMonitor;
showURL: boolean;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const showURL = useSelector((state: RootState) => state.ui?.showURL);
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(LAYOUT.XS)}
mb={theme.spacing(LAYOUT.XS)}
>
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(SPACING.LG)}
mb={theme.spacing(SPACING.SM)}
>
<Typography
variant="h6"
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.name}
</Typography>
<Typography
variant="caption"
sx={getMonitorBadgeStyles(monitor.type ?? "", theme)}
>
{getMonitorTypeLabel(monitor.type, t)}
</Typography>
</Stack>
{showURL && monitor.url && (
<Typography
variant="body2"
color={theme.palette.text.secondary}
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.url}
</Typography>
)}
</Box>
<StatusLabel status={monitor.status} />
</Stack>
);
};
const MonitorContent = ({
monitor,
statusPage,
chartType,
}: {
monitor: StatusPageMonitor;
statusPage: StatusPage;
chartType: string;
}) => {
const theme = useTheme();
if (monitor.type === "hardware") {
if (statusPage.showInfrastructure === false) return null;
return <InfrastructureMetrics monitor={monitor} />;
}
if (statusPage.showCharts === false) return null;
const checks = monitor.checks?.slice().reverse() ?? [];
return (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1, mb: theme.spacing(SPACING.LG) }}>
{chartType === "histogram" ? (
<HistogramResponseTime
height={{ xs: 50, md: 100 }}
gap={{ xs: theme.spacing(SPACING.SM), md: theme.spacing(LAYOUT.SM) }}
checks={checks}
/>
) : (
<HeatmapResponseTime checks={checks} />
)}
</Box>
);
};
export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
const theme = useTheme();
const { t } = useTranslation();
const showURL = useSelector((state: RootState) => state.ui?.showURL);
const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
return (
<Stack gap={theme.spacing(8)}>
{statusPage.showCharts !== false && (
<Stack gap={theme.spacing(LAYOUT.MD)}>
{statusPage.showCharts && (
<Stack
direction={"row"}
alignItems={"center"}
direction="row"
alignItems="center"
gap={theme.spacing(LAYOUT.SM)}
>
<Typography>{t("pages.statusPages.monitorsList.chartTypeHeatmap")}</Typography>
<SwitchComponent
dualOption
value={chartType}
checked={chartType === "histogram"}
onChange={(e) => {
setChartType(e.target.checked ? "histogram" : "heatmap");
}}
onChange={(e) => setChartType(e.target.checked ? "histogram" : "heatmap")}
/>
<Typography>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
@@ -52,59 +150,22 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
</Stack>
)}
{monitors?.map((monitor) => {
return (
<BaseBox
key={monitor.id}
padding={theme.spacing(4)}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
gap={theme.spacing(4)}
mb={statusPage.showCharts !== false ? theme.spacing(4) : 0}
>
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
<Typography
variant="h6"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{monitor.name}
</Typography>
{showURL && (
<Typography
variant="body2"
color="text.secondary"
>
{monitor.url}
</Typography>
)}
</Box>
<StatusLabel status={monitor.status} />
</Stack>
{statusPage.showCharts !== false && (
<Box sx={{ overflow: "hidden", minWidth: 0, flex: 1 }}>
{chartType === "histogram" ? (
<HistogramResponseTime
height={{ xs: 50, md: 100 }}
gap={{ xs: theme.spacing(0.5), md: theme.spacing(5) }}
checks={monitor?.checks?.slice().reverse() ?? []}
/>
) : (
<HeatmapResponseTime
checks={monitor?.checks?.slice().reverse() ?? []}
/>
)}
</Box>
)}
</BaseBox>
);
})}
{monitors.map((monitor) => (
<BaseBox
key={monitor.id}
padding={theme.spacing(LAYOUT.MD)}
>
<MonitorHeader
monitor={monitor}
showURL={showURL}
/>
<MonitorContent
monitor={monitor}
statusPage={statusPage}
chartType={chartType}
/>
</BaseBox>
))}
</Stack>
);
};
+9 -3
View File
@@ -23,16 +23,22 @@ const StatusPageView = () => {
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public");
const apiUrl = url ? `/status-page/${url}?type=uptime` : null;
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
const { data, isLoading, error } = useGet<StatusPageResponse>(apiUrl);
const { data, isLoading, error } = useGet<StatusPageResponse>(
apiUrl,
{},
{
refreshInterval: 10000,
}
);
const statusPage = data?.statusPage;
const monitors = data?.monitors ?? [];
if (!statusPage) return null;
if (monitors?.length === 0) {
if (monitors.length === 0) {
return (
<BasePage
loading={isLoading}
@@ -97,7 +97,7 @@ export const StatusPagesTable = ({
{
id: "type",
content: t("common.table.headers.type"),
render: (row) => row.type,
render: (row) => row.type.join(", "),
},
{
id: "status",
+1 -1
View File
@@ -70,7 +70,7 @@ const UptimeMonitorsPage = () => {
const effectiveTypes =
selectedTypes.length > 0
? selectedTypes
: ["http", "ping", "docker", "port", "game", "grpc"];
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
+3 -8
View File
@@ -1,3 +1,5 @@
export const CHECK_TTL_SENTINEL = 366;
export interface CheckMetadata {
monitorId: string;
teamId: string;
@@ -125,9 +127,6 @@ export interface Check {
timings?: CheckTimings;
statusCode: number;
message: string;
ack: boolean;
ackAt?: string | null;
expiry: string;
cpu?: CheckCpuInfo;
memory?: CheckMemoryInfo;
disk?: CheckDiskInfo[];
@@ -140,7 +139,6 @@ export interface Check {
seo?: number;
performance?: number;
audits?: CheckAudits;
__v: number;
createdAt: string;
updatedAt: string;
}
@@ -243,10 +241,7 @@ export interface ChecksSummary {
downChecks: number;
}
export type CheckSnapshot = Omit<
Check,
"metadata" | "ack" | "ackAt" | "expiry" | "__v" | "updatedAt"
> & {
export type CheckSnapshot = Omit<Check, "metadata" | "__v" | "updatedAt"> & {
originalResponseTime: number;
};
+1
View File
@@ -12,6 +12,7 @@ export const MonitorTypes = [
"port",
"game",
"grpc",
"websocket",
"unknown",
] as const;
export type MonitorType = (typeof MonitorTypes)[number];
+1
View File
@@ -5,6 +5,7 @@ export const NotificationChannels = [
"webhook",
"pager_duty",
"matrix",
"teams",
] as const;
export type NotificationChannel = (typeof NotificationChannels)[number];
+24 -2
View File
@@ -1,10 +1,31 @@
import type { Monitor } from "@/Types/Monitor";
import type { Monitor, MonitorType } from "@/Types/Monitor";
export type MonitorDisplayType = "uptime" | "infrastructure";
export const MONITOR_TYPE_KEYS: Partial<Record<MonitorType, string>> = {
http: "pages.common.monitors.monitorTypes.optionHttp",
ping: "pages.common.monitors.monitorTypes.optionPing",
docker: "pages.common.monitors.monitorTypes.optionDocker",
port: "pages.common.monitors.monitorTypes.optionPort",
game: "pages.common.monitors.monitorTypes.optionGame",
grpc: "pages.common.monitors.monitorTypes.optionGrpc",
websocket: "pages.common.monitors.monitorTypes.optionWebSocket",
hardware: "pages.common.monitors.monitorTypes.optionHardware",
pagespeed: "pages.common.monitors.monitorTypes.optionPagespeed",
};
export const getMonitorTypeLabel = (
type: MonitorType,
t: (key: string) => string
): string => {
const key = MONITOR_TYPE_KEYS[type];
return key ? t(key) : type;
};
export interface StatusPage {
id: string;
userId: string;
teamId: string;
type: string;
type: MonitorDisplayType[];
companyName: string;
url: string;
timezone?: string;
@@ -20,6 +41,7 @@ export interface StatusPage {
showCharts: boolean;
showUptimePercentage: boolean;
showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string;
createdAt: string;
updatedAt: string;
+1
View File
@@ -9,6 +9,7 @@ export const getMonitorPath = (type: MonitorType): string => {
ping: "uptime",
game: "uptime",
grpc: "uptime",
websocket: "uptime",
unknown: "uptime",
docker: "uptime",
hardware: "infrastructure",
+9
View File
@@ -115,6 +115,13 @@ const hardwareSchema = baseSchema.extend({
selectedDisks: z.array(z.string()),
});
// WebSocket monitor schema
const websocketSchema = baseSchema.extend({
type: z.literal("websocket"),
url: z.string().min(1, "WebSocket URL is required"),
ignoreTlsErrors: z.boolean(),
});
// Discriminated union of all monitor types
export const monitorSchema = z.discriminatedUnion("type", [
httpSchema,
@@ -125,6 +132,7 @@ export const monitorSchema = z.discriminatedUnion("type", [
grpcSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
]);
export type MonitorFormData = z.infer<typeof monitorSchema>;
@@ -139,4 +147,5 @@ export {
grpcSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
};
+6
View File
@@ -45,6 +45,11 @@ const matrixSchema = baseSchema.extend({
accessToken: z.string().min(1, "Access token is required"),
});
const teamsSchema = baseSchema.extend({
type: z.literal("teams"),
address: z.string().min(1, "Webhook URL is required").url("Please enter a valid URL"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -52,6 +57,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookSchema,
pagerDutySchema,
matrixSchema,
teamsSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+14 -9
View File
@@ -1,3 +1,4 @@
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
import { z } from "zod";
export const settingsSchema = z.object({
@@ -6,39 +7,42 @@ export const settingsSchema = z.object({
systemEmailRejectUnauthorized: z.boolean(),
systemEmailConnectionHost: z
.string()
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailSecure: z.boolean().optional(),
systemEmailPool: z.boolean().optional(),
showURL: z.boolean().optional(),
checkTTL: z.number().int().min(1, "Please enter a value").max(365, "Maximum 365 days"),
checkTTL: z
.number()
.int()
.min(1, "Please enter a value")
.max(CHECK_TTL_SENTINEL, `Maximum ${CHECK_TTL_SENTINEL}`),
pagespeedApiKey: z
.string()
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailHost: z
.string()
.regex(/^[a-zA-Z0-9.-]*$/, "Invalid hostname or IP address")
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPort: z.number().int().min(1).max(65535).optional(),
systemEmailAddress: z
.string()
.email("Please enter a valid email address")
.or(z.literal(""))
.transform((val) => (val === "" ? undefined : val.toLowerCase().trim()))
.transform((val) => (val === "" ? null : val.toLowerCase().trim()))
.optional(),
systemEmailUser: z
.string()
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailPassword: z
.string()
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
systemEmailTLSServername: z
.string()
.transform((val) => (val.trim() === "" ? undefined : val.trim()))
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
globalThresholds: z.object({
cpu: z.number().int().min(1).max(100),
@@ -48,4 +52,5 @@ export const settingsSchema = z.object({
}),
});
export type SettingsFormInput = z.input<typeof settingsSchema>;
export type SettingsFormData = z.infer<typeof settingsSchema>;
+4
View File
@@ -14,12 +14,16 @@ export const statusPageSchema = z.object({
"URL can only contain lowercase letters, numbers, and hyphens"
),
timezone: z.string().optional(),
type: z
.array(z.enum(["uptime", "infrastructure"]))
.min(1, "At least one type is required"),
color: z.string().min(1, "Color is required"),
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
isPublished: z.boolean(),
showCharts: z.boolean(),
showUptimePercentage: z.boolean(),
showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
customCSS: z.string().optional(),
logo: z
.object({
+8 -1
View File
@@ -288,7 +288,13 @@ const logoImageValidation = joi
.optional(); // Make entire object optional
const statusPageValidation = joi.object({
type: joi.string().valid("uptime").required(),
type: joi
.alternatives()
.try(
joi.string().valid("uptime", "infrastructure"),
joi.array().items(joi.string().valid("uptime", "infrastructure"))
)
.required(),
isPublished: joi.bool(),
companyName: joi
.string()
@@ -317,6 +323,7 @@ const statusPageValidation = joi.object({
showUptimePercentage: joi.boolean(),
showCharts: joi.boolean(),
showAdminLoginLink: joi.boolean(),
showInfrastructure: joi.boolean(),
});
const settingsValidation = joi.object({
+56 -10
View File
@@ -46,7 +46,8 @@
"sendTestEmail": "Send test email",
"exportToJSON": "Export to JSON",
"importFromJSON": "Import from JSON",
"clearFilters": "Clear filters"
"clearFilters": "Clear filters",
"removeUser": "Remove user"
},
"charts": {
"labels": {
@@ -400,12 +401,23 @@
},
"status": {
"down": "down",
"breached": "breached",
"breached": "threshold exceeded",
"initializing": "initializing",
"maintenance": "maintenance",
"paused": "paused",
"total": "total",
"up": "up"
},
"monitorTypes": {
"optionDocker": "Docker",
"optionGame": "Game",
"optionHttp": "HTTP(S)",
"optionPing": "Ping",
"optionPort": "Port",
"optionPagespeed": "PageSpeed",
"optionHardware": "Infrastructure",
"optionGrpc": "gRPC",
"optionWebSocket": "WebSocket"
}
}
},
@@ -487,6 +499,10 @@
"grpcServiceName": {
"label": "Service name",
"placeholder": "e.g. my.service.v1 (leave empty for overall health)"
},
"wsUrl": {
"label": "WebSocket URL",
"placeholder": "wss://example.com/socket"
}
},
"title": "General settings",
@@ -498,6 +514,7 @@
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
"pagespeed": "Track page load performance, Core Web Vitals, and optimization scores for your website.",
"grpc": "Enter the hostname and port of the gRPC server, optionally specify a service name for the health check, and add a display name.",
"websocket": "Enter the WebSocket URL to monitor (e.g., wss://example.com/socket) and add a display name.",
"hardware": "Monitor CPU, memory, disk usage, and temperature for your infrastructure."
}
},
@@ -528,17 +545,12 @@
},
"type": {
"description": "Select the type of check to perform",
"optionDocker": "Docker",
"optionDockerDescription": "Use Docker to monitor if a container is running.",
"optionGame": "Game",
"optionGameDescription": "Monitor if a specific game server is online.",
"optionGrpc": "gRPC",
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
"optionHttp": "HTTP(S)",
"optionWebSocketDescription": "Monitor WebSocket endpoints for connection health and response time.",
"optionHttpDescription": "Use HTTP(S) to monitor your website or API endpoint.",
"optionPing": "Ping",
"optionPingDescription": "Use ICMP Ping to monitor if a server is online.",
"optionPort": "Port",
"optionPortDescription": "Monitor if a specific port on a server is open.",
"title": "Type"
},
@@ -619,6 +631,12 @@
"title": "Roles",
"description": "Assign roles to the user. Multiple roles can be selected."
}
},
"dialog": {
"removeUser": {
"title": "Remove user",
"content": "Are you sure you want to remove {{name}} from your team? This action cannot be undone."
}
}
},
"incidents": {
@@ -1027,7 +1045,7 @@
},
"stats": {
"title": "Monitor history",
"description": "Define how long you want to retain historical data. You can also clear all existing data.",
"description": "Clear all monitoring history and statistics for your team. This action is irreversible.",
"option": {
"clear": {
"label": "Clear all stats. This is irreversible."
@@ -1038,6 +1056,16 @@
"description": "This cannot be undone"
}
},
"retention": {
"title": "Check retention",
"description": "Set how long check data is retained before being automatically cleaned up.",
"option": {
"days": {
"label": "Retention period (days)",
"unlimited": "Unlimited"
}
}
},
"thresholds": {
"title": "Global Thresholds",
"description": "Set global CPU, Memory, Disk, and Temperature thresholds for infrastructure monitoring. These apply to all hardware monitors unless overridden.",
@@ -1154,7 +1182,22 @@
},
"monitorsList": {
"chartTypeHeatmap": "Heatmap",
"chartTypeHistogram": "Histogram"
"chartTypeHistogram": "Histogram",
"noData": "No data available",
"infrastructure": {
"title": "Infrastructure",
"cpu": "CPU",
"memory": "RAM",
"memoryText": "Memory",
"disk": "Disk",
"usage": "Usage",
"used": "Used",
"total": "Total"
},
"uptime": {
"title": "Uptime",
"responseTime": "Response time"
}
},
"statusBar": {
"allDown": "All systems are down",
@@ -1246,6 +1289,9 @@
},
"showAdminLoginLink": {
"label": "Show admin login link"
},
"showInfrastructure": {
"label": "Show infrastructure metrics"
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig(({}) => {
let version = "3.5.0";
let version = "3.5.1";
return {
base: "/",
+4 -1
View File
@@ -32,11 +32,14 @@ export default [
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
projectService: {
allowDefaultProject: ["jest.config.ts"],
},
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], // Ignore unused variables that start with '_'
"@typescript-eslint/no-explicit-any": "warn", // Warn on 'any' types
"mocha/max-top-level-suites": "warn", // Warn if there are too many top-level suites instead of failing
},
+174 -137
View File
@@ -510,42 +510,24 @@
"summary": "Get monitors by team",
"description": "Get monitors filtered by team with optional parameters",
"parameters": [
{
"name": "status",
"in": "query",
"description": "Filter by monitor status (up/down)",
"schema": {
"type": "boolean"
}
},
{
"name": "type",
"in": "query",
"description": "Filter by monitor type",
"schema": {
"type": "string",
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"]
}
},
{
"name": "page",
"in": "query",
"description": "Page number for pagination",
"schema": {
"type": "integer",
"minimum": 1,
"default": 1
}
},
{
"name": "rowsPerPage",
"in": "query",
"description": "Number of monitors per page",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 10
"oneOf": [
{
"type": "string",
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
},
{
"type": "array",
"items": {
"type": "string",
"enum": ["http", "ping", "pagespeed", "docker", "hardware", "port", "game", "grpc", "websocket"]
}
}
]
}
},
{
@@ -555,15 +537,6 @@
"schema": {
"type": "string"
}
},
{
"name": "field",
"in": "query",
"description": "Field to filter on",
"schema": {
"type": "string",
"enum": ["name", "url", "description"]
}
}
],
"responses": {
@@ -756,7 +729,7 @@
}
]
},
"put": {
"patch": {
"tags": ["monitors"],
"summary": "Update monitor",
"description": "Update an existing monitor",
@@ -3568,22 +3541,19 @@
},
"CreateMonitorRequest": {
"type": "object",
"required": ["name", "description", "type", "url", "interval"],
"required": ["name", "type", "url"],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"example": "My Website Monitor"
},
"description": {
"type": "string",
"maxLength": 500,
"example": "Monitors the main website homepage"
},
"type": {
"type": "string",
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port"],
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket"],
"example": "http"
},
"url": {
@@ -3593,14 +3563,34 @@
},
"interval": {
"type": "integer",
"minimum": 30,
"maximum": 3600,
"example": 300,
"description": "Check interval in seconds"
},
"statusWindowSize": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 5
},
"statusWindowThreshold": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 60
},
"isActive": {
"type": "boolean"
},
"ignoreTlsErrors": {
"type": "boolean",
"default": true
"default": false
},
"useAdvancedMatching": {
"type": "boolean",
"default": false
},
"port": {
"type": "integer"
},
"notifications": {
"type": "array",
@@ -3609,51 +3599,62 @@
},
"description": "Array of notification IDs to associate with this monitor"
},
"httpOptions": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "HEAD"],
"default": "GET"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "string",
"description": "Request body for POST/PUT requests"
},
"timeout": {
"type": "integer",
"minimum": 1000,
"maximum": 30000,
"default": 5000,
"description": "Request timeout in milliseconds"
}
}
"secret": {
"type": "string"
},
"assertions": {
"jsonPath": {
"type": "string"
},
"expectedValue": {
"type": "string"
},
"matchMethod": {
"type": "string",
"enum": ["equal", "include", "regex", ""]
},
"cpuAlertThreshold": {
"type": "number"
},
"memoryAlertThreshold": {
"type": "number"
},
"diskAlertThreshold": {
"type": "number"
},
"tempAlertThreshold": {
"type": "number"
},
"gameId": {
"type": "string"
},
"grpcServiceName": {
"type": "string",
"default": ""
},
"selectedDisks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["status-code", "response-time", "body-contains", "header-contains"]
},
"comparison": {
"type": "string",
"enum": ["equals", "not-equals", "greater-than", "less-than", "contains", "not-contains"]
},
"value": {
"type": "string"
}
}
"type": "string"
}
},
"group": {
"type": "string",
"nullable": true,
"maxLength": 50
},
"geoCheckEnabled": {
"type": "boolean"
},
"geoCheckLocations": {
"type": "array",
"items": {
"type": "string",
"enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"]
}
},
"geoCheckInterval": {
"type": "integer",
"minimum": 300000
}
}
},
@@ -3661,21 +3662,32 @@
"type": "object",
"properties": {
"name": {
"type": "string"
},
"type": {
"type": "string",
"minLength": 1,
"maxLength": 100
"enum": ["http", "ping", "pagespeed", "hardware", "docker", "port", "game", "grpc", "websocket", "unknown"]
},
"url": {
"type": "string"
},
"statusWindowSize": {
"type": "integer",
"minimum": 1,
"maximum": 20,
"default": 5
},
"statusWindowThreshold": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 60
},
"description": {
"type": "string",
"maxLength": 500
"type": "string"
},
"interval": {
"type": "integer",
"minimum": 30,
"maximum": 3600
},
"isActive": {
"type": "boolean"
"type": "integer"
},
"notifications": {
"type": "array",
@@ -3683,47 +3695,72 @@
"type": "string"
}
},
"httpOptions": {
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE", "HEAD"]
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "string"
},
"timeout": {
"type": "integer",
"minimum": 1000,
"maximum": 30000
}
}
"secret": {
"type": "string"
},
"assertions": {
"ignoreTlsErrors": {
"type": "boolean"
},
"useAdvancedMatching": {
"type": "boolean"
},
"jsonPath": {
"type": "string"
},
"expectedValue": {
"type": "string"
},
"matchMethod": {
"type": "string",
"enum": ["equal", "include", "regex", ""]
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"cpuAlertThreshold": {
"type": "number"
},
"memoryAlertThreshold": {
"type": "number"
},
"diskAlertThreshold": {
"type": "number"
},
"tempAlertThreshold": {
"type": "number"
},
"gameId": {
"type": "string"
},
"grpcServiceName": {
"type": "string"
},
"selectedDisks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["status-code", "response-time", "body-contains", "header-contains"]
},
"comparison": {
"type": "string",
"enum": ["equals", "not-equals", "greater-than", "less-than", "contains", "not-contains"]
},
"value": {
"type": "string"
}
}
"type": "string"
}
},
"group": {
"type": "string",
"nullable": true,
"maxLength": 50
},
"geoCheckEnabled": {
"type": "boolean"
},
"geoCheckLocations": {
"type": "array",
"items": {
"type": "string",
"enum": ["northAmerica", "southAmerica", "europe", "asia", "africa", "oceania"]
}
},
"geoCheckInterval": {
"type": "integer",
"minimum": 300000
}
}
},
+15 -76
View File
@@ -43,6 +43,7 @@
"super-simple-scheduler": "1.4.5",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -63,6 +64,7 @@
"@types/papaparse": "^5.5.2",
"@types/ping": "0.4.4",
"@types/swagger-ui-express": "4.1.8",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
@@ -871,7 +873,6 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -1501,7 +1502,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -1546,7 +1546,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -4338,7 +4337,6 @@
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -4499,7 +4497,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -4665,6 +4662,16 @@
"@types/webidl-conversions": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -4727,7 +4734,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -5341,7 +5347,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5920,7 +5925,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6863,7 +6867,6 @@
"resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.1.1.tgz",
"integrity": "sha512-fm4D8ti0dQmFPeF8DXSAA//btEmqCOgAc/9Oa3C1LW94h5usNrJEfrON7b4FkPZgnDEn6OUs5NdxiJZmAtGOpQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cssnano-preset-default": "^7.0.9",
"lilconfig": "^3.1.3"
@@ -7631,7 +7634,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7993,7 +7995,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -9638,7 +9639,6 @@
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.2.0",
"@jest/types": "30.2.0",
@@ -12509,7 +12509,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -14443,63 +14442,6 @@
"node": ">=8"
}
},
"node_modules/svgo": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz",
"integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==",
"license": "MIT",
"optional": true,
"dependencies": {
"commander": "^7.2.0",
"css-select": "^5.1.0",
"css-tree": "^2.3.1",
"css-what": "^6.1.0",
"csso": "^5.0.5",
"picocolors": "^1.0.0",
"sax": "^1.5.0"
},
"bin": {
"svgo": "bin/svgo"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/svgo"
}
},
"node_modules/svgo/node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 10"
}
},
"node_modules/svgo/node_modules/css-tree": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"license": "MIT",
"optional": true,
"dependencies": {
"mdn-data": "2.0.30",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/svgo/node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"license": "CC0-1.0",
"optional": true
},
"node_modules/swagger-ui-dist": {
"version": "5.30.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.0.tgz",
@@ -14662,7 +14604,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14831,7 +14772,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -14994,7 +14934,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15628,9 +15567,9 @@
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+3 -1
View File
@@ -8,7 +8,7 @@
"test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand",
"dev": "nodemon --exec tsx src/index.js",
"start": "node --watch ./dist/index.js",
"build": "tsc && tsc-alias",
"build": "tsc && tsc-alias && cp -r src/templates dist/templates",
"lint": "eslint .",
"lint-fix": "eslint --fix .",
"format": "prettier --write .",
@@ -58,6 +58,7 @@
"super-simple-scheduler": "1.4.5",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -78,6 +79,7 @@
"@types/papaparse": "^5.5.2",
"@types/ping": "0.4.4",
"@types/swagger-ui-express": "4.1.8",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"c8": "10.1.3",
+2 -2
View File
@@ -4,7 +4,7 @@ import cors from "cors";
import helmet from "helmet";
import compression from "compression";
import cookieParser from "cookie-parser";
import swaggerUi from "swagger-ui-express";
import swaggerUi, { type JsonObject } from "swagger-ui-express";
import { handleErrors } from "@/middleware/handleErrors.js";
import { generalApiLimiter } from "@/middleware/rateLimiter.js";
import { sanitizeBody, sanitizeQuery } from "@/middleware/sanitization.js";
@@ -24,7 +24,7 @@ export const createApp = ({
controllers: InitializedControllers;
envSettings: EnvConfig;
frontendPath: string;
openApiSpec: any;
openApiSpec: JsonObject;
}) => {
const allowedOrigin = envSettings.clientHost;
const app = express();
+1 -1
View File
@@ -31,7 +31,7 @@ export interface InitializedControllers {
export const initializeControllers = (services: InitializedServices): InitializedControllers => {
return {
authController: new AuthController(services.userService),
monitorController: new MonitorController(services.monitorService),
monitorController: new MonitorController(services.monitorService, services.notificationsService),
settingsController: new SettingsController(services.settingsService, services.emailService),
checkController: new CheckController(services.checkService),
geoCheckController: new GeoCheckController(services.geoChecksService),
+4 -1
View File
@@ -1,6 +1,9 @@
import type { Application } from "express";
import { createVerifyJWT } from "../middleware/verifyJWT.js";
import { createVerifyStatusPageAccess } from "../middleware/verifyStatusPageAccess.js";
import { authApiLimiter } from "../middleware/rateLimiter.js";
import type { InitializedControllers } from "./controllers.js";
import type { InitializedServices } from "./services.js";
import AuthRoutes from "../routes/authRoute.js";
import InviteRoutes from "../routes/inviteRoute.js";
@@ -17,7 +20,7 @@ import NotificationRoutes from "../routes/notificationRoute.js";
import IncidentRoutes from "../routes/incidentRoute.js";
export const setupRoutes = (app: any, controllers: Record<string, any>, services: Record<string, any>) => {
export const setupRoutes = (app: Application, controllers: InitializedControllers, services: InitializedServices) => {
const verifyJWT = createVerifyJWT(services.settingsService);
const authRoutes = new AuthRoutes(controllers.authController, verifyJWT);
const monitorRoutes = new MonitorRoutes(controllers.monitorController);
+8
View File
@@ -27,6 +27,7 @@ import {
DiscordProvider,
PagerDutyProvider,
MatrixProvider,
TeamsProvider,
// Interfaces
INetworkService,
IEmailService,
@@ -58,6 +59,7 @@ import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.
import { PortProvider } from "@/service/infrastructure/network/PortProvider.js";
import { GameProvider } from "@/service/infrastructure/network/GameProvider.js";
import { GrpcProvider } from "@/service/infrastructure/network/GrpcProvider.js";
import { WebSocketProvider } from "@/service/infrastructure/network/WebSocketProvider.js";
// Third-party
import axios from "axios";
@@ -77,6 +79,7 @@ import { games, GameDig } from "gamedig";
import jmespath from "jmespath";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import WebSocket from "ws";
// Repositories
import {
@@ -185,6 +188,7 @@ export const initializeServices = async ({
const portProvider = new PortProvider(net);
const gameProvider = new GameProvider(logger, GameDig);
const grpcProvider = new GrpcProvider(grpc, protoLoader);
const webSocketProvider = new WebSocketProvider(WebSocket);
const networkService = new NetworkService(axios, logger, [
pingProvider,
@@ -195,6 +199,7 @@ export const initializeServices = async ({
portProvider,
gameProvider,
grpcProvider,
webSocketProvider,
]);
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
@@ -224,6 +229,7 @@ export const initializeServices = async ({
const discordProvider = new DiscordProvider(logger);
const pagerDutyProvider = new PagerDutyProvider(logger);
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -234,6 +240,7 @@ export const initializeServices = async ({
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
settingsService,
logger,
notificationMessageBuilder
@@ -245,6 +252,7 @@ export const initializeServices = async ({
statusService,
notificationsService,
checkService,
settingsService,
bufferService,
incidentService,
maintenanceWindowsRepository,
+74 -46
View File
@@ -1,7 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserRoles } from "./controllerUtils.js";
import type { UserRole } from "@/types/user.js";
import { requireTeamId, requireUserEmail, requireUserId, requireUserRoles } from "@/controllers/controllerUtils.js";
import {
registrationBodyValidation,
@@ -20,15 +19,33 @@ import {
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
} from "@/validation/userValidation.js";
import { IUserService } from "@/service/index.js";
const SERVICE_NAME = "authController";
class AuthController {
export interface IAuthController {
registerUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
createUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
loginUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
editUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
checkSuperadminExists(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
requestRecovery(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
validateRecovery(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
resetPassword(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
deleteUser(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
deleteUserById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getAllUsers(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getUserById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
editUserById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
editUserPasswordById(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
}
class AuthController implements IAuthController {
static SERVICE_NAME = SERVICE_NAME;
private userService: any;
private userService: IUserService;
constructor(userService: any) {
constructor(userService: IUserService) {
this.userService = userService;
}
@@ -41,11 +58,12 @@ class AuthController {
const newUser = req.body.user;
const newUserToken = req.body.token;
if (newUser?.email) {
newUser.email = newUser.email.toLowerCase();
const newUserEmail = requireUserEmail(newUser.email);
newUser.email = newUserEmail.toLowerCase();
}
registrationBodyValidation.parse(newUser);
const validatedBody = registrationBodyValidation.parse(newUser);
const { user, token } = await this.userService.registerUser(newUser, newUserToken, req.file);
const { user, token } = await this.userService.registerUser(validatedBody, newUserToken, req?.file ?? null);
res.status(200).json({
success: true,
msg: "User registered successfully",
@@ -62,11 +80,11 @@ class AuthController {
if (userData?.email) {
userData.email = userData.email.toLowerCase();
}
createUserBodyValidation.parse(userData);
const validatedBody = createUserBodyValidation.parse(userData);
const teamId = requireTeamId(req.user?.teamId);
const actorRoles = requireUserRoles(req.user?.role) as UserRole[];
const newUser = await this.userService.createUser(userData, teamId, actorRoles, req.file);
const actorRoles = requireUserRoles(req.user?.role);
const newUser = await this.userService.createUser(validatedBody, teamId, actorRoles, req?.file ?? null);
res.status(201).json({
success: true,
msg: "User created successfully",
@@ -100,8 +118,10 @@ class AuthController {
editUser = async (req: Request, res: Response, next: NextFunction) => {
try {
editUserBodyValidation.parse(req.body);
const updatedUser = await this.userService.editUser(req.body, req.file, req.user);
const validatedBody = editUserBodyValidation.parse(req.body);
const userId = requireUserId(req.user?.id);
const userEmail = requireUserEmail(req.user?.email);
const updatedUser = await this.userService.editUser(validatedBody, req?.file ?? null, userId, userEmail);
res.status(200).json({
success: true,
@@ -170,7 +190,15 @@ class AuthController {
deleteUser = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.userService.deleteUser(req.user);
const userId = requireUserId(req.user?.id);
const teamId = requireTeamId(req.user?.teamId);
const roles = requireUserRoles(req.user?.role);
await this.userService.deleteUser({
userId,
teamId,
roles,
});
return res.status(200).json({
success: true,
msg: "User deleted successfully",
@@ -180,6 +208,23 @@ class AuthController {
}
};
deleteUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getUserByIdParamValidation.parse(req.params);
const targetUserId = validatedParams.userId;
const actorId = requireUserId(req.user?.id);
const actorTeamId = requireTeamId(req.user?.teamId);
const actorRoles = requireUserRoles(req.user?.role);
await this.userService.deleteUserById({ actorId, actorTeamId, actorRoles, targetUserId });
return res.status(200).json({
success: true,
msg: "User removed successfully",
});
} catch (error) {
next(error);
}
};
getAllUsers = async (req: Request, res: Response, next: NextFunction) => {
try {
const allUsers = await this.userService.getAllUsers();
@@ -195,19 +240,9 @@ class AuthController {
getUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
getUserByIdParamValidation.parse(req.params);
const userId = req?.params?.userId;
const roles = req?.user?.role;
if (!userId) {
throw new Error("No user ID in request");
}
if (!roles || roles.length === 0) {
throw new Error("No roles in request");
}
const user = await this.userService.getUserById(roles, userId);
const validatedParams = getUserByIdParamValidation.parse(req.params);
const actorRoles = requireUserRoles(req.user?.role);
const user = await this.userService.getUserById(actorRoles, validatedParams.userId);
return res.status(200).json({ success: true, msg: "ok", data: user });
} catch (error) {
@@ -217,24 +252,19 @@ class AuthController {
editUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
const roles = req?.user?.role;
const actorRoles = requireUserRoles(req.user?.role);
const actorId = requireUserId(req.user?.id);
if (!roles || !roles.includes("superadmin")) {
if (!actorRoles.includes("superadmin")) {
throw new AppError({ message: "Unauthorized", status: 403 });
}
const userId = req.params.userId as string;
const user = { ...req.body };
editUserByIdParamValidation.parse(req.params);
const validatedParams = editUserByIdParamValidation.parse(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user?.id) {
editSuperadminUserByIdBodyValidation.parse(req.body);
} else {
editUserByIdBodyValidation.parse(req.body);
}
const validatedBody =
validatedParams.userId === actorId ? editSuperadminUserByIdBodyValidation.parse(req.body) : editUserByIdBodyValidation.parse(req.body);
await this.userService.editUserById(userId, user);
await this.userService.editUserById(validatedParams.userId, validatedBody);
return res.status(200).json({ success: true, msg: "ok" });
} catch (error) {
next(error);
@@ -243,16 +273,14 @@ class AuthController {
editUserPasswordById = async (req: Request, res: Response, next: NextFunction) => {
try {
const roles = req?.user?.role;
if (!roles || !roles.includes("superadmin")) {
const actorRoles = requireUserRoles(req.user?.role);
if (!actorRoles.includes("superadmin")) {
throw new AppError({ message: "Unauthorized", status: 403 });
}
const userId = req.params.userId as string;
editUserByIdParamValidation.parse(req.params);
editUserPasswordByIdBodyValidation.parse(req.body);
const updatedPassword = req.body.password;
await this.userService.setPasswordByUserId(userId, updatedPassword);
const validatedParams = editUserByIdParamValidation.parse(req.params);
const validatedBody = editUserPasswordByIdBodyValidation.parse(req.body);
await this.userService.setPasswordByUserId(validatedParams.userId, validatedBody.password);
return res.status(200).json({ success: true, msg: "Password reset successfully" });
} catch (error) {
next(error);
+47 -38
View File
@@ -4,18 +4,28 @@ import {
getChecksParamValidation,
getChecksQueryValidation,
getTeamChecksQueryValidation,
getChecksSummaryByTeamIdQueryValidation,
deleteChecksParamValidation,
deleteChecksByTeamIdParamValidation,
updateChecksTTLBodyValidation,
} from "@/validation/checkValidation.js";
import { ICheckService } from "@/service/index.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
const SERVICE_NAME = "checkController";
class CheckController {
export interface ICheckController {
serviceName: string;
getChecksByMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getChecksByTeam: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getChecksSummaryByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteChecks: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class CheckController implements ICheckController {
static SERVICE_NAME = SERVICE_NAME;
private checkService: any;
constructor(checkService: any) {
private checkService: ICheckService;
constructor(checkService: ICheckService) {
this.checkService = checkService;
}
@@ -25,13 +35,20 @@ class CheckController {
getChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
getChecksParamValidation.parse(req.params);
getChecksQueryValidation.parse(req.query);
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
const result = await this.checkService.getChecksByMonitor({
monitorId: req?.params?.monitorId,
query: req?.query,
teamId: req?.user?.teamId,
monitorId: validatedParams.monitorId,
teamId,
sortOrder: validatedQuery.sortOrder,
dateRange: validatedQuery.dateRange,
filter: validatedQuery.filter,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
status: validatedQuery.status,
});
return res.status(200).json({
@@ -46,10 +63,16 @@ class CheckController {
getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => {
try {
getTeamChecksQueryValidation.parse(req.query);
const validatedQuery = getTeamChecksQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
const checkData = await this.checkService.getChecksByTeam({
teamId: req?.user?.teamId,
query: req?.query,
teamId,
sortOrder: validatedQuery.sortOrder,
dateRange: validatedQuery.dateRange,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
filter: validatedQuery.filter,
});
return res.status(200).json({
success: true,
@@ -63,8 +86,11 @@ class CheckController {
getChecksSummaryByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const dateRange = req.query?.dateRange || "hour";
const summary = await this.checkService.getChecksSummaryByTeamId({ teamId: req?.user?.teamId, dateRange });
const validatedQuery = getChecksSummaryByTeamIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
const dateRange = validatedQuery.dateRange ?? "hour";
const summary = await this.checkService.getChecksSummaryByTeamId({ teamId, dateRange });
return res.status(200).json({
success: true,
msg: "Checks summary retrieved successfully",
@@ -77,11 +103,12 @@ class CheckController {
deleteChecks = async (req: Request, res: Response, next: NextFunction) => {
try {
deleteChecksParamValidation.parse(req.params);
const validatedParams = deleteChecksParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const deletedCount = await this.checkService.deleteChecks({
monitorId: req.params.monitorId as string,
teamId: req?.user?.teamId,
monitorId: validatedParams.monitorId,
teamId,
});
return res.status(200).json({
@@ -96,9 +123,9 @@ class CheckController {
deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
deleteChecksByTeamIdParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId: req?.user?.teamId });
const deletedCount = await this.checkService.deleteChecksByTeamId({ teamId });
return res.status(200).json({
success: true,
@@ -109,24 +136,6 @@ class CheckController {
next(error);
}
};
updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => {
try {
updateChecksTTLBodyValidation.parse(req.body);
await this.checkService.updateChecksTTL({
teamId: req?.user?.teamId,
ttl: req?.body?.ttl,
});
return res.status(200).json({
success: true,
msg: "Checks TTL updated successfully",
});
} catch (error) {
next(error);
}
};
}
export default CheckController;
+22 -27
View File
@@ -1,24 +1,25 @@
import { AppError } from "@/utils/AppError.js";
import { type MonitorType, MonitorTypes } from "@/types/index.js";
import { Monitor, type MonitorType, MonitorTypes, UserRole } from "@/types/index.js";
import sslChecker, { SSLDetails } from "ssl-checker";
type SSLCheckerType = typeof sslChecker;
const fetchMonitorCertificate = async (sslChecker: any, monitor: any): Promise<any> => {
export const fetchMonitorCertificate = async (checker: SSLCheckerType, monitor: Monitor): Promise<SSLDetails> => {
const monitorUrl = new URL(monitor.url);
const hostname = monitorUrl.hostname;
const cert = await sslChecker(hostname);
// Throw an error if no cert or if cert.validTo is not present
const cert = await checker(hostname);
if (cert?.validTo === null || cert?.validTo === undefined) {
throw new Error("Certificate not found");
}
return cert;
};
const requireString = (value: unknown, fieldName: string): string => {
export const requireString = (value: unknown, fieldName: string): string => {
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
throw new AppError({ message: `${fieldName} is required`, status: 400 });
};
const optionalString = (value: unknown, fieldName: string): string | undefined => {
export const optionalString = (value: unknown, fieldName: string): string | undefined => {
if (value === undefined) {
return undefined;
}
@@ -28,7 +29,7 @@ const optionalString = (value: unknown, fieldName: string): string | undefined =
throw new AppError({ message: `${fieldName} must be a string`, status: 400 });
};
const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
export const optionalNumber = (value: unknown, fieldName: string): number | undefined => {
if (value === undefined) {
return undefined;
}
@@ -44,7 +45,7 @@ const optionalNumber = (value: unknown, fieldName: string): number | undefined =
throw new AppError({ message: `${fieldName} must be a number`, status: 400 });
};
const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
export const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined => {
if (value === undefined) {
return undefined;
}
@@ -62,7 +63,7 @@ const optionalBoolean = (value: unknown, fieldName: string): boolean | undefined
throw new AppError({ message: `${fieldName} must be a boolean`, status: 400 });
};
const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
export const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | undefined => {
const parseSingle = (input: unknown): MonitorType => {
if (typeof input !== "string") {
throw new AppError({ message: "Monitor type must be a string", status: 400 });
@@ -82,7 +83,7 @@ const parseMonitorTypeFilter = (value: unknown): MonitorType | MonitorType[] | u
return parseSingle(value);
};
const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
export const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
if (value === undefined) {
return undefined;
}
@@ -92,42 +93,36 @@ const parseSortOrder = (value: unknown): "asc" | "desc" | undefined => {
throw new AppError({ message: "order must be either 'asc' or 'desc'", status: 400 });
};
const requireTeamId = (teamId?: string): string => {
export const requireTeamId = (teamId?: string): string => {
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
return teamId;
};
const requireUserId = (userId?: string): string => {
export const requireUserId = (userId?: string): string => {
if (!userId) {
throw new AppError({ message: "User ID is required", status: 400 });
}
return userId;
};
const requireUserEmail = (userEmail?: string): string => {
export const requireUserEmail = (userEmail?: string): string => {
if (!userEmail) {
throw new AppError({ message: "User email is required", status: 400 });
}
return userEmail;
};
export const requireUserRoles = (userRoles?: string[]): string[] => {
export const requireFirstName = (firstName?: string): string => {
if (!firstName) {
throw new AppError({ message: "First name is required", status: 400 });
}
return firstName;
};
export const requireUserRoles = (userRoles?: UserRole[]): UserRole[] => {
if (!userRoles || userRoles.length === 0) {
throw new AppError({ message: "User roles are required", status: 400 });
}
return userRoles;
};
export {
fetchMonitorCertificate,
requireString,
optionalString,
optionalNumber,
optionalBoolean,
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
requireUserId,
requireUserEmail,
};
@@ -1,12 +1,18 @@
import { IDiagnosticService } from "@/service/index.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "diagnosticController";
class DiagnosticController {
export interface IDiagnosticController {
getSystemStats: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class DiagnosticController implements IDiagnosticController {
static SERVICE_NAME = SERVICE_NAME;
private diagnosticService: any;
private diagnosticService: IDiagnosticService;
constructor(diagnosticService: any) {
constructor(diagnosticService: IDiagnosticService) {
this.diagnosticService = diagnosticService;
}
+27 -6
View File
@@ -1,10 +1,15 @@
import { Request, Response, NextFunction } from "express";
import { getChecksParamValidation, getChecksQueryValidation } from "@/validation/checkValidation.js";
import type { IGeoChecksService } from "@/service/business/geoChecksService.js";
import { requireTeamId } from "./controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "geoCheckController";
class GeoCheckController {
export interface IGeoCheckController {
getGeoChecksByMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class GeoCheckController implements IGeoCheckController {
static SERVICE_NAME = SERVICE_NAME;
private geoChecksService: IGeoChecksService;
@@ -18,13 +23,29 @@ class GeoCheckController {
getGeoChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
getChecksParamValidation.parse(req.params);
getChecksQueryValidation.parse(req.query);
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
if (!validatedQuery.continent || validatedQuery.continent.length === 0) {
throw new AppError({
message: "At least one continent must be specified",
service: SERVICE_NAME,
method: "getGeoChecksByMonitor",
details: { monitorId: validatedParams.monitorId },
status: 400,
});
}
const teamId = requireTeamId(req.user?.teamId);
const result = await this.geoChecksService.getGeoChecksByMonitor({
monitorId: req?.params?.monitorId as string,
query: req?.query,
teamId: req?.user?.teamId as string,
monitorId: validatedParams.monitorId,
teamId: teamId,
sortOrder: validatedQuery.sortOrder,
dateRange: validatedQuery.dateRange,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
continent: validatedQuery.continent,
});
return res.status(200).json({
+22 -21
View File
@@ -1,35 +1,37 @@
import { AppError } from "@/utils/AppError.js";
import { Request, Response, NextFunction } from "express";
import { requireTeamId, requireUserId, requireUserEmail } from "./controllerUtils.js";
import { IIncidentService } from "@/service/index.js";
import { getIncidentsByTeamQueryValidation, getIncidentSummaryQueryValidation } from "@/validation/incidentValidation.js";
const SERVICE_NAME = "incidentController";
const SERVICE_NAME = "IncidentController";
class IncidentController {
static SERVICE_NAME = SERVICE_NAME;
private incidentService: any;
constructor(incidentService: any) {
export interface IIncidentController {
getIncidentsByTeam: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getIncidentSummary: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getIncidentById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
resolveIncidentManually: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class IncidentController implements IIncidentController {
private incidentService: IIncidentService;
constructor(incidentService: IIncidentService) {
this.incidentService = incidentService;
}
get serviceName() {
return IncidentController.SERVICE_NAME;
}
getIncidentsByTeam = async (req: Request, res: Response, next: NextFunction) => {
try {
const { sortOrder, dateRange, page, rowsPerPage, status, monitorId, resolutionType } = req.query || {};
const validatedQuery = getIncidentsByTeamQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
const result = await this.incidentService.getIncidentsByTeam(
teamId,
sortOrder,
dateRange,
page,
rowsPerPage,
status,
monitorId,
resolutionType
validatedQuery.sortOrder,
validatedQuery.dateRange,
validatedQuery.page,
validatedQuery.rowsPerPage,
validatedQuery.status,
validatedQuery.monitorId,
validatedQuery.resolutionType
);
return res.status(200).json({
@@ -45,10 +47,9 @@ class IncidentController {
getIncidentSummary = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const validatedQuery = getIncidentSummaryQueryValidation.parse(req.query);
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;
const summary = await this.incidentService.getIncidentSummary(teamId, limit);
const summary = await this.incidentService.getIncidentSummary(teamId, validatedQuery.limit);
return res.status(200).json({
success: true,
msg: "Incident summary retrieved successfully",
+15 -7
View File
@@ -1,12 +1,19 @@
import { Request, Response, NextFunction } from "express";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.js";
import { requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
import { requireFirstName, requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
import { IInviteService } from "@/service/index.js";
const SERVICE_NAME = "inviteController";
class InviteController {
export interface IInviteController {
getInviteToken: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
sendInviteEmail: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
verifyInviteToken: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class InviteController implements IInviteController {
static SERVICE_NAME = SERVICE_NAME;
private inviteService: any;
constructor(inviteService: any) {
private inviteService: IInviteService;
constructor(inviteService: IInviteService) {
this.inviteService = inviteService;
}
@@ -34,8 +41,9 @@ class InviteController {
sendInviteEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req?.user?.teamId);
const userRoles = requireUserRoles(req?.user?.role);
const teamId = requireTeamId(req.user?.teamId);
const userRoles = requireUserRoles(req.user?.role);
const firstName = requireFirstName(req.user?.firstName);
const inviteRequest = req.body;
inviteRequest.teamId = teamId;
@@ -43,7 +51,7 @@ class InviteController {
const inviteToken = await this.inviteService.sendInviteEmail({
invite: inviteRequest,
firstName: req?.user?.firstName,
firstName,
userRoles,
});
return res.status(200).json({
+8 -3
View File
@@ -1,11 +1,16 @@
import { ILogger } from "@/utils/logger.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "LogController";
export interface ILogController {
getLogs: (req: Request, res: Response, next: NextFunction) => Promise<void>;
}
class LogController {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
constructor(logger: any) {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
get serviceName() {
@@ -14,7 +19,7 @@ class LogController {
getLogs = async (req: Request, res: Response, next: NextFunction) => {
try {
const logs = await this.logger.getLogs();
const logs = this.logger.getLogs();
res.status(200).json({
success: true,
msg: "Logs fetched successfully",
@@ -9,26 +9,37 @@ import {
deleteMaintenanceWindowByIdParamValidation,
} from "@/validation/maintenanceWindowValidation.js";
import { requireTeamId } from "@/controllers/controllerUtils.js";
import { IMaintenanceWindowService } from "@/service/index.js";
const SERVICE_NAME = "maintenanceWindowController";
class MaintenanceWindowController {
static SERVICE_NAME = SERVICE_NAME;
private maintenanceWindowService: any;
constructor(maintenanceWindowService: any) {
export interface IMaintenanceWindowController {
createMaintenanceWindows: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMaintenanceWindowById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMaintenanceWindowsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMaintenanceWindowsByMonitorId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteMaintenanceWindow: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editMaintenanceWindow: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class MaintenanceWindowController implements IMaintenanceWindowController {
private maintenanceWindowService: IMaintenanceWindowService;
constructor(maintenanceWindowService: IMaintenanceWindowService) {
this.maintenanceWindowService = maintenanceWindowService;
}
get serviceName() {
return MaintenanceWindowController.SERVICE_NAME;
}
createMaintenanceWindows = async (req: Request, res: Response, next: NextFunction) => {
try {
createMaintenanceWindowBodyValidation.parse(req.body);
const validatedBody = createMaintenanceWindowBodyValidation.parse(req.body);
const teamId = requireTeamId(req?.user?.teamId);
await this.maintenanceWindowService.createMaintenanceWindow({ teamId, body: req.body });
const monitorIDs = validatedBody.monitors;
const name = validatedBody.name;
const active = validatedBody.active ?? true;
const duration = validatedBody.duration;
const durationUnit = validatedBody.durationUnit;
const repeat = validatedBody.repeat;
const start = validatedBody.start;
const end = validatedBody.end;
await this.maintenanceWindowService.createMaintenanceWindow({ teamId, monitorIDs, name, active, duration, durationUnit, repeat, start, end });
return res.status(200).json({
success: true,
@@ -40,11 +51,11 @@ class MaintenanceWindowController {
};
getMaintenanceWindowById = async (req: Request, res: Response, next: NextFunction) => {
try {
getMaintenanceWindowByIdParamValidation.parse(req.params);
const validatedParams = getMaintenanceWindowByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: req.params.id as string, teamId });
const maintenanceWindow = await this.maintenanceWindowService.getMaintenanceWindowById({ id: validatedParams.id, teamId });
return res.status(200).json({
success: true,
@@ -58,11 +69,18 @@ class MaintenanceWindowController {
getMaintenanceWindowsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMaintenanceWindowsByTeamIdQueryValidation.parse(req.query);
const validatedQuery = getMaintenanceWindowsByTeamIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req?.user?.teamId);
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByTeamId({ teamId, query: req.query });
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByTeamId({
teamId,
active: validatedQuery.active,
page: validatedQuery.page,
rowsPerPage: validatedQuery.rowsPerPage,
field: validatedQuery.field,
order: validatedQuery.order,
});
return res.status(200).json({
success: true,
@@ -76,12 +94,12 @@ class MaintenanceWindowController {
getMaintenanceWindowsByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMaintenanceWindowsByMonitorIdParamValidation.parse(req.params);
const validatedParams = getMaintenanceWindowsByMonitorIdParamValidation.parse(req.params);
const teamId = requireTeamId(req?.user?.teamId);
const maintenanceWindows = await this.maintenanceWindowService.getMaintenanceWindowsByMonitorId({
monitorId: req.params.monitorId as string,
monitorId: validatedParams.monitorId,
teamId,
});
@@ -96,11 +114,11 @@ class MaintenanceWindowController {
};
deleteMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
deleteMaintenanceWindowByIdParamValidation.parse(req.params);
const validatedParams = deleteMaintenanceWindowByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req?.user?.teamId);
await this.maintenanceWindowService.deleteMaintenanceWindow({ id: req.params.id as string, teamId });
await this.maintenanceWindowService.deleteMaintenanceWindow({ id: validatedParams.id, teamId });
return res.status(200).json({
success: true,
@@ -113,14 +131,14 @@ class MaintenanceWindowController {
editMaintenanceWindow = async (req: Request, res: Response, next: NextFunction) => {
try {
editMaintenanceWindowByIdParamValidation.parse(req.params);
editMaintenanceByIdWindowBodyValidation.parse(req.body);
const validatedParams = editMaintenanceWindowByIdParamValidation.parse(req.params);
const validatedBody = editMaintenanceByIdWindowBodyValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const editedMaintenanceWindow = await this.maintenanceWindowService.editMaintenanceWindow({
id: req.params.id as string,
body: req.body,
id: validatedParams.id,
body: validatedBody,
teamId,
});
return res.status(200).json({
+136 -122
View File
@@ -1,9 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { updateNotificationsValidation } from "@/validation/notificationValidation.js";
import {
getMonitorByIdParamValidation,
getMonitorByIdQueryValidation,
getMonitorsByTeamIdParamValidation,
getMonitorsByTeamIdQueryValidation,
getMonitorsWithChecksQueryValidation,
createMonitorBodyValidation,
@@ -12,31 +11,48 @@ import {
getCertificateParamValidation,
getHardwareDetailsByIdParamValidation,
getHardwareDetailsByIdQueryValidation,
getUptimeDetailsByIdParamValidation,
getUptimeDetailsByIdQueryValidation,
importMonitorsBodyValidation,
} from "@/validation/monitorValidation.js";
import sslChecker from "ssl-checker";
import {
fetchMonitorCertificate,
requireString,
optionalString,
optionalNumber,
optionalBoolean,
parseMonitorTypeFilter,
parseSortOrder,
requireTeamId,
requireUserId,
} from "./controllerUtils.js";
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorService } from "@/service/index.js";
import { IMonitorService, INotificationsService } from "@/service/index.js";
import { GeoContinent } from "@/types/geoCheck.js";
const SERVICE_NAME = "monitorController";
class MonitorController {
export interface IMonitorController {
getMonitorCertificate: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getUptimeDetailsById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getHardwareDetailsById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getPageSpeedDetailsById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getGeoChecksByMonitorId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
createMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
importMonitorsFromJSON: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
exportMonitorsToJSON: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getAllGames: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getGroupsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
updateNotifications: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class MonitorController implements IMonitorController {
static SERVICE_NAME = SERVICE_NAME;
private monitorService: IMonitorService;
private notificationsService: INotificationsService;
constructor(monitorService: IMonitorService) {
constructor(monitorService: IMonitorService, notificationsService: INotificationsService) {
this.monitorService = monitorService;
this.notificationsService = notificationsService;
}
get serviceName() {
@@ -45,10 +61,9 @@ class MonitorController {
getMonitorCertificate = async (req: Request, res: Response, next: NextFunction) => {
try {
getCertificateParamValidation.parse(req.params);
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req.params?.monitorId, "Monitor ID");
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
const validatedParams = getCertificateParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: validatedParams.monitorId });
const certificate = await fetchMonitorCertificate(sslChecker, monitor);
return res.status(200).json({
@@ -65,16 +80,18 @@ class MonitorController {
getUptimeDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const normalize = optionalBoolean(req?.query?.normalize, "normalize");
const teamId = requireTeamId(req?.user?.teamId);
const validatedParams = getUptimeDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getUptimeDetailsByIdQueryValidation.parse(req.query);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange;
const teamId = requireTeamId(req.user?.teamId);
const data = await this.monitorService.getUptimeDetailsById({
teamId,
monitorId,
dateRange,
normalize,
});
return res.status(200).json({
success: true,
@@ -88,12 +105,12 @@ class MonitorController {
getHardwareDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
getHardwareDetailsByIdParamValidation.parse(req.params);
getHardwareDetailsByIdQueryValidation.parse(req.query);
const validatedParams = getHardwareDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getHardwareDetailsByIdQueryValidation.parse(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = optionalString(req?.query?.dateRange, "dateRange") || "recent";
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange || "recent";
const teamId = requireTeamId(req.user?.teamId);
const data = await this.monitorService.getHardwareDetailsById({
teamId,
@@ -112,12 +129,12 @@ class MonitorController {
};
getPageSpeedDetailsById = async (req: Request, res: Response, next: NextFunction) => {
try {
getHardwareDetailsByIdParamValidation.parse(req.params);
getHardwareDetailsByIdQueryValidation.parse(req.query);
const validatedParams = getHardwareDetailsByIdParamValidation.parse(req.params);
const validatedQuery = getHardwareDetailsByIdQueryValidation.parse(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange || "recent";
const teamId = requireTeamId(req.user?.teamId);
const data = await this.monitorService.getPageSpeedDetailsById({
teamId,
@@ -137,18 +154,14 @@ class MonitorController {
getGeoChecksByMonitorId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorByIdParamValidation.parse(req.params);
getMonitorByIdQueryValidation.parse(req.query);
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const validatedQuery = getMonitorByIdQueryValidation.parse(req.query);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const dateRange = requireString(req?.query?.dateRange, "dateRange");
const continentParam = req?.query?.continent;
const continents = continentParam
? Array.isArray(continentParam)
? (continentParam as GeoContinent[])
: [continentParam as GeoContinent]
: undefined;
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = validatedParams.monitorId;
const dateRange = validatedQuery.dateRange || "recent";
const continentParam = validatedQuery.continent;
const continents = continentParam ? (Array.isArray(continentParam) ? continentParam : [continentParam]) : undefined;
const teamId = requireTeamId(req.user?.teamId);
const data = await this.monitorService.getGeoChecksByMonitorId({
teamId,
@@ -169,11 +182,10 @@ class MonitorController {
getMonitorById = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorByIdParamValidation.parse(req.params);
getMonitorByIdQueryValidation.parse(req.query);
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req.user?.teamId);
const monitorId = validatedParams.monitorId;
const monitor = await this.monitorService.getMonitorById({ teamId, monitorId });
@@ -189,12 +201,12 @@ class MonitorController {
createMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
createMonitorBodyValidation.parse(req.body);
const validatedBody = createMonitorBodyValidation.parse(req.body);
const userId = requireString(req?.user?.id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const userId = requireUserId(req.user?.id);
const teamId = requireTeamId(req.user?.teamId);
const monitor = await this.monitorService.createMonitor(teamId, userId, req.body);
const monitor = await this.monitorService.createMonitor(teamId, userId, validatedBody);
return res.status(200).json({
success: true,
@@ -208,14 +220,10 @@ class MonitorController {
importMonitorsFromJSON = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req?.user?.teamId);
const userId = requireUserId(req?.user?.id);
const { monitors } = req.body;
if (!monitors || !Array.isArray(monitors)) {
throw new AppError({ message: "Invalid request: monitors array is required", status: 400 });
}
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const validatedBody = importMonitorsBodyValidation.parse(req.body);
const monitors = validatedBody.monitors;
const result = await this.monitorService.importMonitorsFromJSON({ teamId, userId, monitors });
@@ -231,9 +239,9 @@ class MonitorController {
deleteMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorByIdParamValidation.parse(req.params);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const monitorId = validatedParams.monitorId;
const deletedMonitor = await this.monitorService.deleteMonitor({ teamId, monitorId });
@@ -249,8 +257,7 @@ class MonitorController {
deleteAllMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req?.user?.teamId);
const teamId = requireTeamId(req.user?.teamId);
const deletedCount = await this.monitorService.deleteAllMonitors({ teamId });
return res.status(200).json({
@@ -264,12 +271,12 @@ class MonitorController {
editMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorByIdParamValidation.parse(req.params);
editMonitorBodyValidation.parse(req.body);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const validatedParams = getMonitorByIdParamValidation.parse(req.params);
const validatedBody = editMonitorBodyValidation.parse(req.body);
const monitorId = validatedParams.monitorId;
const teamId = requireTeamId(req.user?.teamId);
const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: req.body });
const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: validatedBody });
return res.status(200).json({
success: true,
@@ -283,10 +290,10 @@ class MonitorController {
pauseMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
pauseMonitorParamValidation.parse(req.params);
const validatedParams = pauseMonitorParamValidation.parse(req.params);
const monitorId = requireString(req?.params?.monitorId, "Monitor ID");
const teamId = requireTeamId(req?.user?.teamId);
const monitorId = validatedParams.monitorId;
const teamId = requireTeamId(req.user?.teamId);
const monitor = await this.monitorService.pauseMonitor({ teamId, monitorId });
@@ -302,8 +309,8 @@ class MonitorController {
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
try {
const id = requireString(req?.user?.id, "User ID");
const teamId = requireTeamId(req?.user?.teamId);
const id = requireUserId(req.user?.id);
const teamId = requireTeamId(req.user?.teamId);
const demoMonitors = await this.monitorService.addDemoMonitors({ userId: id, teamId });
return res.status(200).json({
@@ -316,32 +323,13 @@ class MonitorController {
}
};
sendTestEmail = async (req: Request, res: Response, next: NextFunction) => {
try {
const { to } = req.body;
if (!to || typeof to !== "string") {
throw new AppError({ message: "Invalid 'to' email address", status: 400 });
}
const messageId = await this.monitorService.sendTestEmail({ to });
return res.status(200).json({
success: true,
msg: "Test email sent successfully",
data: { messageId },
});
} catch (error) {
next(error);
}
};
getMonitorsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorsByTeamIdParamValidation.parse(req.params);
getMonitorsByTeamIdQueryValidation.parse(req.query);
const validatedQuery = getMonitorsByTeamIdQueryValidation.parse(req.query);
const teamId = requireTeamId(req?.user?.teamId);
const type = parseMonitorTypeFilter(req.query?.type);
const filter = optionalString(req.query?.filter, "filter");
const teamId = requireTeamId(req.user?.teamId);
const type = validatedQuery.type;
const filter = validatedQuery.filter;
const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, type, filter });
@@ -357,17 +345,16 @@ class MonitorController {
getMonitorsWithChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
getMonitorsByTeamIdParamValidation.parse(req.params);
getMonitorsWithChecksQueryValidation.parse(req.query);
const explain = optionalBoolean(req?.query?.explain, "explain");
const limit = optionalNumber(req?.query?.limit, "limit");
const page = optionalNumber(req?.query?.page, "page");
const rowsPerPage = optionalNumber(req?.query?.rowsPerPage, "rowsPerPage");
const filter = optionalString(req?.query?.filter, "filter");
const field = optionalString(req?.query?.field, "field");
const order = parseSortOrder(req?.query?.order);
const type = parseMonitorTypeFilter(req?.query?.type);
const teamId = requireTeamId(req?.user?.teamId);
const validatedQuery = getMonitorsWithChecksQueryValidation.parse(req.query);
const limit = validatedQuery.limit;
const page = validatedQuery.page;
const rowsPerPage = validatedQuery.rowsPerPage;
const filter = validatedQuery.filter;
const field = validatedQuery.field;
const order = validatedQuery.order;
const type = validatedQuery.type;
const teamId = requireTeamId(req.user?.teamId);
const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({
teamId,
@@ -378,7 +365,6 @@ class MonitorController {
filter,
field,
order,
explain,
});
return res.status(200).json({
@@ -392,11 +378,7 @@ class MonitorController {
exportMonitorsToJSON = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req.user?.teamId);
const json = await this.monitorService.exportMonitorsToJSON({ teamId });
return res.status(200).json({
@@ -424,11 +406,7 @@ class MonitorController {
getGroupsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req.user?.teamId);
const groups = await this.monitorService.getGroupsByTeamId({ teamId });
return res.status(200).json({
@@ -439,6 +417,42 @@ class MonitorController {
next(error);
}
};
updateNotifications = async (req: Request, res: Response, next: NextFunction) => {
try {
updateNotificationsValidation.parse(req.body);
const teamId = requireTeamId(req.user?.teamId);
const { monitorIds, notificationIds, action } = req.body;
// Verify all requested notification IDs actually belong to this team
const teamNotifications = await this.notificationsService.findNotificationsByTeamId(teamId);
const validNotificationIds = teamNotifications.map((n) => n.id);
const invalidIds = notificationIds.filter((id: string) => !validNotificationIds.includes(id));
if (invalidIds.length > 0) {
throw new AppError({
message: `The following notification IDs are invalid or do not belong to your team: ${invalidIds.join(", ")}`,
status: 403,
});
}
const modifiedCount = await this.monitorService.updateNotifications({
teamId,
monitorIds,
notificationIds,
action,
});
return res.status(200).json({
success: true,
msg: `Notifications updated successfully on ${modifiedCount} monitor(s)`,
data: { modifiedCount },
});
} catch (error) {
next(error);
}
};
}
export default MonitorController;
@@ -1,16 +1,30 @@
import { Request, Response, NextFunction } from "express";
import { Notification } from "@/types/index.js";
import { createNotificationBodyValidation } from "@/validation/notificationValidation.js";
import {
createNotificationBodyValidation,
deleteNotificationParamValidation,
getNotificationByIdParamValidation,
testNotificationBodyValidation,
editNotificationParamValidation,
testAllNotificationsBodyValidation,
} from "@/validation/notificationValidation.js";
import { AppError } from "@/utils/AppError.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { INotificationsService } from "@/service/index.js";
import { requireTeamId } from "./controllerUtils.js";
import { requireTeamId, requireUserId } from "./controllerUtils.js";
import { IMonitorsRepository } from "@/repositories/index.js";
const SERVICE_NAME = "NotificationController";
class NotificationController {
static SERVICE_NAME = SERVICE_NAME;
export interface INotificationController {
testNotification: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
createNotification: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getNotificationsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
deleteNotification: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getNotificationById: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
editNotification: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
testAllNotifications: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
class NotificationController implements INotificationController {
private notificationsService: INotificationsService;
private monitorsRepository: IMonitorsRepository;
constructor(notificationsService: INotificationsService, monitorsRepository: IMonitorsRepository) {
@@ -18,13 +32,9 @@ class NotificationController {
this.monitorsRepository = monitorsRepository;
}
get serviceName() {
return NotificationController.SERVICE_NAME;
}
testNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const notification: Notification = req.body;
const notification = testNotificationBodyValidation.parse(req.body);
const success = await this.notificationsService.sendTestNotification(notification);
if (!success) {
@@ -34,6 +44,7 @@ class NotificationController {
return res.status(200).json({
success: true,
msg: "Notification sent successfully",
details: { service: SERVICE_NAME },
});
} catch (error) {
next(error);
@@ -42,23 +53,12 @@ class NotificationController {
createNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
createNotificationBodyValidation.parse(req.body);
const validatedBody = createNotificationBodyValidation.parse(req.body);
const body = req.body;
const teamId = requireTeamId(req.user?.teamId);
const userId = requireUserId(req.user?.id);
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const userId = req?.user?.id;
if (!userId) {
throw new AppError({ message: "User ID is required", status: 400 });
}
body.userId = userId;
body.teamId = teamId;
const notification = await this.notificationsService.createNotification(body);
const notification = await this.notificationsService.createNotification(validatedBody, userId, teamId);
return res.status(200).json({
success: true,
msg: "Notification created successfully",
@@ -71,11 +71,7 @@ class NotificationController {
getNotificationsByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req.user?.teamId);
const notifications = await this.notificationsService.findNotificationsByTeamId(teamId);
return res.status(200).json({
@@ -90,17 +86,10 @@ class NotificationController {
deleteNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const teamId = requireTeamId(req.user?.teamId);
const validatedParams = deleteNotificationParamValidation.parse(req.params);
const notificationId = req.params.id as string;
if (!notificationId) {
throw new AppError({ message: "Notification ID is required", status: 400 });
}
await this.notificationsService.deleteById(notificationId, teamId);
await this.notificationsService.deleteById(validatedParams.id, teamId);
return res.status(200).json({
success: true,
msg: "Notification deleted successfully",
@@ -113,12 +102,9 @@ class NotificationController {
getNotificationById = async (req: Request, res: Response, next: NextFunction) => {
try {
const teamId = requireTeamId(req.user?.teamId);
const notificationId = req.params.id as string;
if (!notificationId) {
throw new AppError({ message: "Notification ID is required", status: 400 });
}
const validatedParams = getNotificationByIdParamValidation.parse(req.params);
const notification = await this.notificationsService.findById(notificationId, teamId);
const notification = await this.notificationsService.findById(validatedParams.id, teamId);
return res.status(200).json({
success: true,
@@ -132,14 +118,13 @@ class NotificationController {
editNotification = async (req: Request, res: Response, next: NextFunction) => {
try {
createNotificationBodyValidation.parse(req.body);
const validatedBody = createNotificationBodyValidation.parse(req.body);
const validatedParams = editNotificationParamValidation.parse(req.params);
const teamId = requireTeamId(req.user?.teamId);
const notificationId = req.params.id as string;
if (!notificationId) {
throw new AppError({ message: "Notification ID is required", status: 400 });
}
const editedNotification = await this.notificationsService.updateById(notificationId, teamId, req.body);
const notificationId = validatedParams.id;
const editedNotification = await this.notificationsService.updateById(notificationId, teamId, validatedBody);
return res.status(200).json({
success: true,
msg: "Notification updated successfully",
@@ -152,15 +137,13 @@ class NotificationController {
testAllNotifications = async (req: Request, res: Response, next: NextFunction) => {
try {
const monitorId = req.body.monitorId;
const teamId = req?.user?.teamId;
if (!teamId) {
throw new AppError({ message: "Team ID is required", status: 400 });
}
const validatedBody = testAllNotificationsBodyValidation.parse(req.body);
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
const teamId = requireTeamId(req.user?.teamId);
const monitor = await this.monitorsRepository.findById(validatedBody.monitorId, teamId);
const notifications = monitor.notifications || [];
const notifications = monitor.notifications;
if (notifications.length === 0) {
throw new AppError({ message: "No notifications", status: 400 });
}
+11 -28
View File
@@ -1,11 +1,19 @@
import { ISuperSimpleQueue } from "@/service/index.js";
import { Request, Response, NextFunction } from "express";
const SERVICE_NAME = "JobQueueController";
class JobQueueController {
export interface IJobQueueController {
getMetrics(req: Request, res: Response, next: NextFunction): Promise<void>;
getJobs(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getAllMetrics(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
flushQueue(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
}
class JobQueueController implements IJobQueueController {
static SERVICE_NAME = SERVICE_NAME;
private jobQueue: any;
constructor(jobQueue: any) {
private jobQueue: ISuperSimpleQueue;
constructor(jobQueue: ISuperSimpleQueue) {
this.jobQueue = jobQueue;
}
@@ -53,18 +61,6 @@ class JobQueueController {
}
};
addJob = async (req: Request, res: Response, next: NextFunction) => {
try {
await this.jobQueue.addJob(Math.random().toString(36).substring(7));
return res.status(200).json({
success: true,
msg: "Job added to queue successfully",
});
} catch (error) {
next(error);
}
};
flushQueue = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.jobQueue.flushQueues();
@@ -77,18 +73,5 @@ class JobQueueController {
next(error);
}
};
checkQueueHealth = async (req: Request, res: Response, next: NextFunction) => {
try {
const stuckQueues = await this.jobQueue.checkQueueHealth();
return res.status(200).json({
success: true,
msg: "Queue health checked successfully",
data: stuckQueues,
});
} catch (error) {
next(error);
}
};
}
export default JobQueueController;
+21 -9
View File
@@ -2,14 +2,23 @@ import { Request, Response, NextFunction } from "express";
import { updateAppSettingsBodyValidation } from "@/validation/settingsValidation.js";
import { sendTestEmailBodyValidation } from "@/validation/notificationValidation.js";
import { AppError } from "@/utils/AppError.js";
import { IEmailService, ISettingsService } from "@/service/index.js";
import { Settings } from "@/types/settings.js";
const SERVICE_NAME = "SettingsController";
class SettingsController {
export interface ISettingsController {
serviceName: string;
getAppSettings(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
updateAppSettings(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
sendTestEmail(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
}
class SettingsController implements ISettingsController {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: any;
private emailService: any;
constructor(settingsService: any, emailService: any) {
private settingsService: ISettingsService;
private emailService: IEmailService;
constructor(settingsService: ISettingsService, emailService: IEmailService) {
this.settingsService = settingsService;
this.emailService = emailService;
}
@@ -18,11 +27,11 @@ class SettingsController {
return SettingsController.SERVICE_NAME;
}
buildAppSettings = (dbSettings: any) => {
const sanitizedSettings = { ...dbSettings };
buildAppSettings = (dbSettings: Settings) => {
const sanitizedSettings: Record<string, unknown> = { ...dbSettings };
delete sanitizedSettings.version;
delete sanitizedSettings.jwtSecret;
const returnSettings = {
const returnSettings: Record<string, unknown | null> = {
pagespeedKeySet: false,
emailPasswordSet: false,
settings: null,
@@ -57,9 +66,9 @@ class SettingsController {
updateAppSettings = async (req: Request, res: Response, next: NextFunction) => {
try {
updateAppSettingsBodyValidation.parse(req.body);
const validatedBody = updateAppSettingsBodyValidation.parse(req.body);
const updatedSettings = await this.settingsService.updateDbSettings(req.body);
const updatedSettings = await this.settingsService.updateDbSettings(validatedBody);
const returnSettings = this.buildAppSettings(updatedSettings);
return res.status(200).json({
success: true,
@@ -95,6 +104,9 @@ class SettingsController {
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
if (!html) {
throw new AppError({ message: "Failed to build email template.", status: 500 });
}
const messageId = await this.emailService.sendEmail(to, subject, html, {
systemEmailHost,
systemEmailPort,
+11 -2
View File
@@ -15,7 +15,16 @@ import { NormalizeData } from "@/utils/dataUtils.js";
const SERVICE_NAME = "statusPageController";
class StatusPageController {
export interface IStatusPageController {
readonly serviceName: string;
createStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
updateStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getStatusPageByUrl(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
getStatusPagesByTeamId(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
deleteStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
}
class StatusPageController implements IStatusPageController {
static SERVICE_NAME = SERVICE_NAME;
private statusPageService: IStatusPageService;
private monitorsRepository: IMonitorsRepository;
@@ -66,7 +75,7 @@ class StatusPageController {
if (statusPage === null) {
throw new AppError({ message: "Status page not found", status: 404 });
}
return res.status(200).json({
res.status(200).json({
success: true,
msg: "Status page updated successfully",
data: statusPage,
@@ -1,4 +1,5 @@
import mongoose from "mongoose";
import type { Document, AnyBulkWriteOperation } from "mongodb";
const CHECKS_COLLECTION = "checks";
const BACKUP_COLLECTION = "checks_backup";
@@ -85,7 +86,7 @@ const createTimeSeriesCollection = async (backedUp: boolean) => {
}
};
const validateDocument = (doc: any): boolean => {
const validateDocument = (doc: Document): boolean => {
if (!doc.createdAt) {
return false;
}
@@ -95,7 +96,7 @@ const validateDocument = (doc: any): boolean => {
return true;
};
const storeFailedDocument = async (doc: any, reason: string) => {
const storeFailedDocument = async (doc: Document, reason: string) => {
const db = getDb();
await db.collection(FAILED_DOCS_COLLECTION).insertOne({
originalDoc: doc,
@@ -124,8 +125,8 @@ const migrateBackupData = async (backedUp: boolean): Promise<MigrationStats> =>
stats.totalSource = await source.countDocuments();
const cursor = source.find().addCursorFlag("noCursorTimeout", true);
const operations: any[] = [];
const invalidDocs: any[] = [];
const operations: AnyBulkWriteOperation<Document>[] = [];
const invalidDocs: Document[] = [];
while (await cursor.hasNext()) {
const doc = await cursor.next();
@@ -139,6 +140,7 @@ const migrateBackupData = async (backedUp: boolean): Promise<MigrationStats> =>
}
const { _id, monitorId, teamId, type, ...rest } = doc;
void _id;
const metadata = { monitorId, teamId, type };
operations.push({ insertOne: { document: { ...rest, metadata } } });
@@ -42,10 +42,10 @@ export async function cleanupDuplicateMonitorStats(): Promise<void> {
const monitorId = duplicate._id;
const { ids, updatedAts } = duplicate;
type DocPair = { id: any; updatedAt: Date };
type DocPair = { id: string; updatedAt: Date };
// Create array of {id, updatedAt} pairs and sort by updatedAt descending
const docs: DocPair[] = ids.map((id: any, index: number) => ({
const docs: DocPair[] = ids.map((id: string, index: number) => ({
id,
updatedAt: updatedAts[index],
}));
@@ -37,7 +37,8 @@ export async function fixInfrastructureThresholds(): Promise<void> {
const update: Record<string, number> = {};
for (const field of thresholdFields) {
if ((monitor as any)[field] === 0 || (monitor as any)[field] === 5) {
const value = monitor.get(field);
if (value === 0 || value === 5) {
update[field] = 100;
}
}
@@ -0,0 +1,45 @@
import mongoose from "mongoose";
import { logger } from "@/utils/logger.js";
import StatusPageModel from "../models/StatusPage.js";
export async function migrateStatusPageTypeToArray(): Promise<void> {
const SERVICE_NAME = "Migration:MigrateStatusPageTypeToArray";
try {
logger.info({ service: SERVICE_NAME, message: "Starting migration of StatusPage type field from string to array" });
const db = mongoose.connection.db;
if (!db) {
throw new Error("Database connection is not initialized");
}
const result = await StatusPageModel.updateMany(
{
$expr: {
$eq: [{ $type: "$type" }, "string"], // only scalar string, not existing array
},
},
[
{
$set: {
type: ["$type"],
},
},
]
);
if (result.modifiedCount === 0) {
logger.info({ service: SERVICE_NAME, message: "No StatusPage documents needed migration" });
return;
}
logger.info({
service: SERVICE_NAME,
message: `Migration complete. Converted ${result.modifiedCount} StatusPage document(s) from string type to array type`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({ service: SERVICE_NAME, message: `Error during StatusPage type migration: ${errorMessage}` });
throw error;
}
}
+5 -2
View File
@@ -3,6 +3,8 @@ import { convertChecksToTimeSeries } from "./0002_convertChecksToTimeSeries.js";
import { cleanupDuplicateMonitorStats } from "./0003_cleanupDuplicateMonitorStats.js";
import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds.js";
import MigrationModel from "../models/Migration.js";
import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js";
import type { ILogger } from "@/utils/logger.js";
type MigrationEntry = {
name: string;
@@ -14,9 +16,10 @@ const migrations: MigrationEntry[] = [
{ name: "0002_convertChecksToTimeSeries", execute: convertChecksToTimeSeries },
{ name: "0003_cleanupDuplicateMonitorStats", execute: cleanupDuplicateMonitorStats },
{ name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds },
{ name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray },
];
const runMigrations = async (logger?: { info: Function; error: Function }) => {
const runMigrations = async (logger?: ILogger) => {
try {
logger?.info({ message: "Running migrations", service: "Migrations" });
for (const migration of migrations) {
@@ -43,7 +46,7 @@ const runMigrations = async (logger?: { info: Function; error: Function }) => {
logger?.info({ message: "Migrations completed", service: "Migrations" });
} catch (error) {
const err = error as Error;
logger?.error({ message: "Migration failed", service: "Migrations", details: err?.message, stack: err?.stack });
logger?.error({ message: "Migration failed", service: "Migrations", details: { error: err?.message }, stack: err?.stack });
throw error;
}
};
+2 -16
View File
@@ -21,13 +21,10 @@ type CheckMetadataDocument = Omit<CheckMetadata, "monitorId" | "teamId"> & {
type: MonitorType;
};
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "expiry" | "ackAt" | "createdAt" | "updatedAt"> & {
type CheckDocumentBase = Omit<Check, "id" | "metadata" | "createdAt" | "updatedAt"> & {
metadata: CheckMetadataDocument;
expiry: Date;
ackAt?: Date | null;
createdAt: Date;
updatedAt: Date;
__v: number;
};
interface CheckDocument extends CheckDocumentBase {
@@ -225,18 +222,7 @@ const CheckSchema = new Schema<CheckDocument>(
message: {
type: String,
},
expiry: {
type: Date,
default: Date.now,
},
ack: {
type: Boolean,
default: false,
},
ackAt: {
type: Date,
default: undefined,
},
cpu: {
type: cpuSchema,
default: () => ({}),
+1 -1
View File
@@ -1,5 +1,5 @@
import { Schema, model, type Types } from "mongoose";
import type { MaintenanceWindow, DurationUnit } from "@/types/maintenanceWindow.js";
import type { MaintenanceWindow } from "@/types/maintenanceWindow.js";
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
monitorId: Types.ObjectId;
+168 -2
View File
@@ -1,6 +1,18 @@
import { Schema, model, Types, type UpdateQuery } from "mongoose";
import { Schema, model, Types } from "mongoose";
import type { Monitor, MonitorMatchMethod, CheckSnapshot } from "@/types/monitor.js";
import { MonitorTypes, MonitorStatuses } from "@/types/monitor.js";
import type {
CheckAudits,
CheckCaptureInfo,
CheckCpuInfo,
CheckDiskInfo,
CheckErrorInfo,
CheckHostInfo,
CheckMemoryInfo,
CheckNetworkInterfaceInfo,
GotTimings,
ILighthouseAudit,
} from "@/types/check.js";
type CheckSnapshotDocument = Omit<CheckSnapshot, "createdAt"> & { createdAt: Date };
@@ -23,13 +35,167 @@ interface MonitorDocument extends MonitorDocumentBase {
updatedAt: Date;
}
const snapshotTimingPhasesSchema = new Schema<GotTimings["phases"]>(
{
wait: { type: Number },
dns: { type: Number },
tcp: { type: Number },
tls: { type: Number },
request: { type: Number },
firstByte: { type: Number },
download: { type: Number },
total: { type: Number },
},
{ _id: false }
);
const snapshotTimingsSchema = new Schema<GotTimings>(
{
start: { type: Number },
socket: { type: Number },
lookup: { type: Number },
connect: { type: Number },
secureConnect: { type: Number },
upload: { type: Number },
response: { type: Number },
end: { type: Number },
phases: { type: snapshotTimingPhasesSchema },
},
{ _id: false }
);
const snapshotCpuSchema = new Schema<CheckCpuInfo>(
{
physical_core: { type: Number },
logical_core: { type: Number },
frequency: { type: Number },
current_frequency: { type: Number },
temperature: { type: [Number] },
free_percent: { type: Number },
usage_percent: { type: Number },
},
{ _id: false }
);
const snapshotMemorySchema = new Schema<CheckMemoryInfo>(
{
total_bytes: { type: Number },
available_bytes: { type: Number },
used_bytes: { type: Number },
usage_percent: { type: Number },
},
{ _id: false }
);
const snapshotDiskSchema = new Schema<CheckDiskInfo>(
{
device: { type: String },
mountpoint: { type: String },
total_bytes: { type: Number },
free_bytes: { type: Number },
used_bytes: { type: Number },
usage_percent: { type: Number },
total_inodes: { type: Number },
free_inodes: { type: Number },
used_inodes: { type: Number },
inodes_usage_percent: { type: Number },
read_bytes: { type: Number },
write_bytes: { type: Number },
read_time: { type: Number },
write_time: { type: Number },
},
{ _id: false }
);
const snapshotHostSchema = new Schema<CheckHostInfo>(
{
os: { type: String },
platform: { type: String },
kernel_version: { type: String },
pretty_name: { type: String },
},
{ _id: false }
);
const snapshotErrorSchema = new Schema<CheckErrorInfo>(
{
metric: { type: [String] },
err: { type: String },
},
{ _id: false }
);
const snapshotCaptureSchema = new Schema<CheckCaptureInfo>(
{
version: { type: String },
mode: { type: String },
},
{ _id: false }
);
const snapshotNetworkInterfaceSchema = new Schema<CheckNetworkInterfaceInfo>(
{
name: { type: String },
bytes_sent: { type: Number },
bytes_recv: { type: Number },
packets_sent: { type: Number },
packets_recv: { type: Number },
err_in: { type: Number },
err_out: { type: Number },
drop_in: { type: Number },
drop_out: { type: Number },
fifo_in: { type: Number },
fifo_out: { type: Number },
},
{ _id: false }
);
const snapshotLighthouseAuditSchema = new Schema<ILighthouseAudit>(
{
id: { type: String },
title: { type: String },
score: { type: Number },
displayValue: { type: String },
numericValue: { type: Number },
numericUnit: { type: String },
},
{ _id: false }
);
const snapshotAuditsSchema = new Schema<CheckAudits>(
{
cls: { type: snapshotLighthouseAuditSchema },
si: { type: snapshotLighthouseAuditSchema },
fcp: { type: snapshotLighthouseAuditSchema },
lcp: { type: snapshotLighthouseAuditSchema },
tbt: { type: snapshotLighthouseAuditSchema },
},
{ _id: false }
);
const checkSnapshotSchema = new Schema<CheckSnapshotDocument>(
{
id: { type: String, required: true },
status: { type: Boolean, required: true },
responseTime: { type: Number },
timings: { type: snapshotTimingsSchema },
statusCode: { type: Number },
message: { type: String },
cpu: { type: snapshotCpuSchema },
memory: { type: snapshotMemorySchema },
disk: { type: [snapshotDiskSchema] },
host: { type: snapshotHostSchema },
errors: { type: [snapshotErrorSchema] },
capture: { type: snapshotCaptureSchema },
net: { type: [snapshotNetworkInterfaceSchema] },
accessibility: { type: Number },
bestPractices: { type: Number },
seo: { type: Number },
performance: { type: Number },
audits: { type: snapshotAuditsSchema },
createdAt: { type: Date, required: true },
},
{ _id: false, strict: false }
{ _id: false }
);
const MonitorSchema = new Schema<MonitorDocument>(
+1 -1
View File
@@ -25,7 +25,7 @@ const NotificationSchema = new Schema<NotificationDocument>(
},
type: {
type: String,
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix"] as NotificationChannel[],
enum: ["email", "slack", "discord", "webhook", "pager_duty", "matrix", "teams"] as NotificationChannel[],
required: true,
},
notificationName: {
+6 -2
View File
@@ -43,9 +43,9 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
required: true,
},
type: {
type: String,
type: [String],
required: true,
default: "uptime",
default: ["uptime"],
enum: StatusPageTypes,
},
companyName: {
@@ -100,6 +100,10 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
type: Boolean,
default: false,
},
showInfrastructure: {
type: Boolean,
default: false,
},
customCSS: {
type: String,
default: "",
+9 -6
View File
@@ -1,15 +1,18 @@
import type { NextFunction, Request, Response } from "express";
import { logger } from "@/utils/logger.js";
import { AppError } from "@/utils/AppError.js";
const handleErrors = (error: any, req: Request, res: Response, next: NextFunction) => {
const status = error.status || 500;
const message = error.message || "Server error";
const service = error.service || "unknownService";
const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
const status = error instanceof AppError ? error.status || 500 : 500;
const message = error instanceof AppError ? error.message : "Server error";
const service = error instanceof AppError ? error.service : "unknownService";
const method = error instanceof AppError ? error.method : "unknownMethod";
logger.error({
message: message,
service: service,
method: error.method,
stack: error.stack,
method: method,
stack: error instanceof AppError ? error.stack : undefined,
details: error instanceof AppError ? error.details : undefined,
});
res.status(status).json({
status,
+12 -5
View File
@@ -1,7 +1,9 @@
import type { Request, Response, NextFunction } from "express";
import DOMPurify from "isomorphic-dompurify";
export const sanitizeInput = (input: any, options = {}) => {
export function sanitizeInput(input: string, options?: object): string;
export function sanitizeInput(input: unknown, options?: object): unknown;
export function sanitizeInput(input: unknown, options = {}): unknown {
if (typeof input !== "string") {
return input;
}
@@ -15,9 +17,9 @@ export const sanitizeInput = (input: any, options = {}) => {
};
return DOMPurify.sanitize(input, defaultConfig);
};
}
export const sanitizeObject = (obj: Record<string, any>, options = {}): Record<string, any> => {
export const sanitizeObject = (obj: unknown, options = {}): unknown => {
if (typeof obj !== "object" || obj === null) {
return obj;
}
@@ -26,7 +28,7 @@ export const sanitizeObject = (obj: Record<string, any>, options = {}): Record<s
return obj.map((item) => sanitizeObject(item, options));
}
const sanitized: Record<string, any> = {};
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (typeof value === "string") {
sanitized[key] = sanitizeInput(value, options);
@@ -52,7 +54,12 @@ export const sanitizeBody = (options = {}): ((req: Request, res: Response, next:
export const sanitizeQuery = (options = {}): ((req: Request, res: Response, next: NextFunction) => void) => {
return (req: Request, res: Response, next: NextFunction) => {
if (req.query && typeof req.query === "object") {
req.query = sanitizeObject(req.query, options);
for (const key of Object.keys(req.query)) {
const value = req.query[key];
if (typeof value === "string") {
req.query[key] = sanitizeInput(value, options);
}
}
}
next();
};
+13 -7
View File
@@ -2,12 +2,17 @@ import { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";
import { AppError } from "@/utils/AppError.js";
import { Settings } from "@/types/settings.js";
import type { ISettingsService } from "@/service/system/settingsService.js";
import type { User } from "@/types/user.js";
const SERVICE_NAME = "verifyJWT";
const TOKEN_PREFIX = "Bearer ";
export const createVerifyJWT = (settingsService: { getSettings: () => Settings }) => {
const isUser = (payload: unknown): payload is User => {
return typeof payload === "object" && payload !== null && "id" in payload && "teamId" in payload && "role" in payload;
};
export const createVerifyJWT = (settingsService: ISettingsService) => {
return (req: Request, res: Response, next: NextFunction) => {
const token = req.headers["authorization"];
// Make sure a token is provided
@@ -31,21 +36,22 @@ export const createVerifyJWT = (settingsService: { getSettings: () => Settings }
next(error);
return;
}
jwt.verify(parsedToken, jwtSecret, (err: any, decoded: any) => {
jwt.verify(parsedToken, jwtSecret, (err: jwt.VerifyErrors | null, decoded: jwt.JwtPayload | string | undefined) => {
if (err) {
const error = new AppError({
message: "Failed to authenticate token",
details: err,
message: err instanceof Error ? err.message : "Failed to authenticate token",
status: 401,
service: SERVICE_NAME,
details: err instanceof Error ? { error: err } : undefined,
method: "verifyJWT",
});
next(error);
return;
} else {
// Token is valid, carry on
} else if (isUser(decoded)) {
req.user = decoded;
next();
} else {
next(new AppError({ message: "Invalid token payload", status: 401, service: SERVICE_NAME, method: "verifyJWT" }));
}
});
};
@@ -47,4 +47,5 @@ export interface IChecksRepository {
deleteByMonitorId(monitorId: string): Promise<number>;
deleteByTeamId(teamId: string): Promise<number>;
deleteByMonitorIdsNotIn(monitorIds: string[]): Promise<number>;
deleteOlderThan(date: Date): Promise<number>;
}
@@ -13,9 +13,10 @@ import type {
GotTimings,
MonitorType,
} from "@/types/index.js";
import { CheckModel, MonitorModel, type CheckDocument } from "@/db/models/index.js";
import { CheckModel, type CheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "StatusService";
@@ -26,8 +27,8 @@ type HardwareUpChecks = { totalChecks: number };
class MongoChecksRepository implements IChecksRepository {
static SERVICE_NAME = SERVICE_NAME;
private logger: any;
constructor(logger: any) {
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
}
@@ -46,13 +47,6 @@ class MongoChecksRepository implements IChecksRepository {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
const toOptionalDateString = (value?: Date | string | null): string | undefined => {
if (!value) {
return undefined;
}
return toDateString(value);
};
const mapTimings = (timings?: GotTimings): GotTimings => {
const phases = timings?.phases ?? {
wait: 0,
@@ -171,9 +165,6 @@ class MongoChecksRepository implements IChecksRepository {
timings: mapTimings(doc.timings),
statusCode: doc.statusCode ?? 0,
message: doc.message ?? "",
ack: doc.ack ?? false,
ackAt: toOptionalDateString(doc.ackAt),
expiry: toDateString(doc.expiry),
cpu: mapCpu(doc.cpu),
memory: mapMemory(doc.memory),
disk: mapDisks(doc.disk),
@@ -186,7 +177,6 @@ class MongoChecksRepository implements IChecksRepository {
seo: doc.seo,
performance: doc.performance,
audits: mapAudits(doc.audits),
__v: doc.__v ?? 0,
createdAt: toDateString(doc.createdAt),
updatedAt: toDateString(doc.updatedAt),
};
@@ -237,7 +227,7 @@ class MongoChecksRepository implements IChecksRepository {
status: boolean | undefined
) => {
// Match
const matchStage: Record<string, any> = {
const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(typeof status !== "undefined" && { status }),
...(getDateForRange(dateRange) && {
@@ -289,7 +279,7 @@ class MongoChecksRepository implements IChecksRepository {
};
findByTeamId = async (sortOrder: string, dateRange: string, filter: string, page: number, rowsPerPage: number, teamId: string) => {
const matchStage: Record<string, any> = {
const matchStage: Record<string, unknown> = {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
...(getDateForRange(dateRange) && {
createdAt: {
@@ -358,7 +348,7 @@ class MongoChecksRepository implements IChecksRepository {
);
const mapped = results.reduce<LatestChecksMap>((acc, { monitorId, docs }) => {
acc[monitorId] = docs.map((doc: any) => this.toEntity(doc as CheckDocument));
acc[monitorId] = docs.map((doc: CheckDocument) => this.toEntity(doc));
return acc;
}, {});
return mapped;
@@ -412,6 +402,31 @@ class MongoChecksRepository implements IChecksRepository {
return result.deletedCount ?? 0;
};
deleteOlderThan = async (cutoffDate: Date, batchDays: number = 30): Promise<number> => {
// Find the oldest check that is older than the cutoff
const oldest = await CheckModel.findOne({ createdAt: { $lt: cutoffDate } })
.sort({ createdAt: 1 })
.select({ createdAt: 1 });
if (!oldest?.createdAt) return 0;
let totalDeleted = 0;
let batchStart = new Date(oldest.createdAt);
const batchMs = batchDays * 24 * 60 * 60 * 1000;
// Delete in 30 day chunks until we reach the cutoff
while (batchStart < cutoffDate) {
// Advance startTime by batchMs to get to the end of last batch
const nextStart = batchStart.getTime() + batchMs;
const batchEnd = new Date(Math.min(nextStart, cutoffDate.getTime()));
const result = await CheckModel.deleteMany({
createdAt: { $gte: batchStart, $lt: batchEnd },
});
totalDeleted += result.deletedCount ?? 0;
batchStart = batchEnd;
}
return totalDeleted;
};
private findUptimeDateRangeChecks = async (
monitorType: Exclude<MonitorType, "hardware" | "pagespeed">,
monitorObjectId: mongoose.Types.ObjectId,
@@ -1,8 +1,8 @@
import { IGeoChecksRepository } from "./IGeoChecksRepository.js";
import type { GeoCheck, GeoCheckMetadata, GeoCheckResult, GroupedGeoCheck, GeoContinent, FlatGeoCheck } from "@/types/geoCheck.js";
import type { FlatGeoChecksQueryResult } from "./IGeoChecksRepository.js";
import { GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js";
import mongoose from "mongoose";
import { GeoCheckMetadataDocument, GeoCheckModel, type GeoCheckDocument } from "@/db/models/index.js";
import mongoose, { PipelineStage } from "mongoose";
import { getDateForRange } from "@/utils/dataUtils.js";
import { ILogger } from "@/utils/logger.js";
@@ -31,13 +31,13 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
};
const mapMetadata = (metadata: any): GeoCheckMetadata => ({
const mapMetadata = (metadata: GeoCheckMetadataDocument): GeoCheckMetadata => ({
monitorId: toStringId(metadata.monitorId),
teamId: toStringId(metadata.teamId),
type: metadata.type,
});
const mapResults = (results: any[]): GeoCheckResult[] => {
const mapResults = (results: GeoCheckResult[]): GeoCheckResult[] => {
if (!results || !Array.isArray(results)) {
return [];
}
@@ -89,12 +89,12 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
}))
);
return docs.map((doc) => this.toEntity(doc));
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
message: `Failed to createGeoChecks: ${error.message}`,
message: error instanceof Error ? `Failed to createGeoChecks: ${error.message}` : "Failed to createGeoChecks",
service: SERVICE_NAME,
method: "createGeoChecks",
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -109,7 +109,7 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
continents?: GeoContinent[]
): Promise<FlatGeoChecksQueryResult> => {
try {
const matchStage: Record<string, any> = {
const matchStage: Record<string, unknown> = {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
...(getDateForRange(dateRange) && {
createdAt: {
@@ -125,22 +125,8 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
skip = page * rowsPerPage;
}
if (continents && continents.length > 0) {
const pipeline: any[] = [
{ $match: matchStage },
{ $unwind: "$results" },
{
$match: {
"results.location.continent": { $in: continents },
},
},
];
} else {
const pipeline: any[] = [{ $match: matchStage }, { $unwind: "$results" }];
}
// Common pipeline stages for both paths
const pipeline: any[] = [
const pipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
// Filter by continent if specified
@@ -174,7 +160,7 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
];
// Count pipeline
const countPipeline: any[] = [
const countPipeline: PipelineStage[] = [
{ $match: matchStage },
{ $unwind: "$results" },
...(continents && continents.length > 0
@@ -206,12 +192,12 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
}));
return { geoChecksCount, geoChecks };
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
message: `Error finding geo checks by monitor ID: ${error.message}`,
message: error instanceof Error ? `Error finding geo checks by monitor ID: ${error.message}` : "Error finding geo checks by monitor ID",
service: SERVICE_NAME,
method: "findByMonitorId",
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -227,11 +213,15 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
},
}).sort({ createdAt: -1 });
return docs.map(this.toEntity);
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
message: `Error finding geo checks by monitor ID and date range: ${error.message}`,
message:
error instanceof Error
? `Error finding geo checks by monitor ID and date range: ${error.message}`
: "Error finding geo checks by monitor ID and date range",
service: SERVICE_NAME,
method: "findByMonitorIdAndDateRange",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -245,7 +235,7 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
continents?: GeoContinent[]
): Promise<GroupedGeoCheck[]> => {
try {
const pipeline: any[] = [
const pipeline: PipelineStage[] = [
// Match geo checks for this monitor in date range
{
$match: {
@@ -325,11 +315,12 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
const results = await GeoCheckModel.aggregate(pipeline);
return results as GroupedGeoCheck[];
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
message: `Error finding grouped geo checks: ${error.message}`,
message: error instanceof Error ? `Error finding grouped geo checks: ${error.message}` : "Error finding grouped geo checks",
service: SERVICE_NAME,
method: "findGroupedByMonitorIdAndDateRange",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -341,11 +332,12 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
"metadata.monitorId": new mongoose.Types.ObjectId(monitorId),
});
return result.deletedCount || 0;
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
message: `Error deleting geo checks by monitor ID: ${error.message}`,
message: error instanceof Error ? `Error deleting geo checks by monitor ID: ${error.message}` : "Error deleting geo checks by monitor ID",
service: SERVICE_NAME,
method: "deleteByMonitorId",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -357,11 +349,12 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
"metadata.teamId": new mongoose.Types.ObjectId(teamId),
});
return result.deletedCount || 0;
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
message: `Error deleting geo checks by team ID: ${error.message}`,
message: error instanceof Error ? `Error deleting geo checks by team ID: ${error.message}` : "Error deleting geo checks by team ID",
service: SERVICE_NAME,
method: "deleteByTeamId",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -372,11 +365,12 @@ class MongoGeoChecksRepository implements IGeoChecksRepository {
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
const result = await GeoCheckModel.deleteMany({ "metadata.monitorId": { $nin: objectIds } });
return result.deletedCount || 0;
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
message: `Error deleting orphaned geo checks: ${error.message}`,
message: error instanceof Error ? `Error deleting orphaned geo checks: ${error.message}` : "Error deleting orphaned geo checks",
service: SERVICE_NAME,
method: "deleteByMonitorIdsNotIn",
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -32,8 +32,8 @@ class MongoIncidentRepository implements IIncidentsRepository {
status?: boolean;
monitorId?: string;
resolutionType?: string;
}): Record<string, any> {
const matchStage: Record<string, any> = {
}): Record<string, unknown> {
const matchStage: Record<string, unknown> = {
teamId: new mongoose.Types.ObjectId(teamId),
...(status !== undefined && { status }),
...(monitorId && { monitorId: new mongoose.Types.ObjectId(monitorId) }),
@@ -5,12 +5,12 @@ export interface IMaintenanceWindowsRepository {
// fetch
findById(id: string, teamId: string): Promise<MaintenanceWindow>;
findByMonitorId(monitorId: string, teamId: string): Promise<MaintenanceWindow[]>;
findByTeamId(teamId: string, active: boolean, page: number, rowsPerPage: number, field: string, order: string): Promise<MaintenanceWindow[]>;
findByTeamId(teamId: string, page: number, rowsPerPage: number, field?: string, order?: string, active?: boolean): Promise<MaintenanceWindow[]>;
// update
updateById(id: string, teamId: string, data: Partial<MaintenanceWindow>): Promise<MaintenanceWindow>;
// delete
deleteById(id: string, teamId: string): Promise<MaintenanceWindow>;
// other
countByTeamId(teamId: string, active: boolean): Promise<number>;
countByTeamId(teamId: string, active?: boolean): Promise<number>;
}
@@ -1,7 +1,7 @@
import type { MaintenanceWindow } from "@/types/index.js";
import { type MaintenanceWindowDocument, MaintenanceWindowModel } from "@/db/models/index.js";
import { IMaintenanceWindowsRepository } from "./IMaintenanceWindowsRepository.js";
import mongoose from "mongoose";
import mongoose, { SortOrder } from "mongoose";
import { AppError } from "@/utils/AppError.js";
class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository {
@@ -75,13 +75,13 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
findByTeamId = async (
teamId: string,
active: boolean,
page: number,
rowsPerPage: number,
field: string,
order: string
field?: string,
order?: string,
active?: boolean
): Promise<MaintenanceWindow[]> => {
const maintenanceQuery: Record<string, any> = { teamId };
const maintenanceQuery: Record<string, unknown> = { teamId };
if (active !== undefined) maintenanceQuery.active = active;
@@ -92,7 +92,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
}
// Sorting
const sort: Record<string, any> = {};
const sort: Record<string, SortOrder> = {};
if (field !== undefined && order !== undefined) {
sort[field] = order === "asc" ? 1 : -1;
}
@@ -126,8 +126,8 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
}
return this.toEntity(deleted);
};
countByTeamId = async (teamId: string, active: boolean) => {
const maintenanceQuery: Record<string, any> = { teamId };
countByTeamId = async (teamId: string, active?: boolean) => {
const maintenanceQuery: Record<string, unknown> = { teamId };
if (active !== undefined) maintenanceQuery.active = active;
@@ -41,6 +41,7 @@ export interface IMonitorsRepository {
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
findGroupsByTeamId(teamId: string): Promise<string[]>;
removeNotificationFromMonitors(notificationId: string): Promise<void>;
updateNotifications(teamId: string, monitorIds: string[], notificationIds: string[], action: "add" | "remove" | "set"): Promise<number>;
deleteByTeamIdsNotIn(teamIds: string[]): Promise<number>;
findAllMonitorIds(): Promise<string[]>;
}
@@ -1,8 +1,9 @@
import { MonitorModel } from "@/db/models/index.js";
import type { MonitorDocument, CheckSnapshotDocument } from "@/db/models/index.js";
import type { Monitor, MonitorsSummary, CheckSnapshot, Check } from "@/types/index.js";
import type { Monitor, MonitorsSummary, CheckSnapshot } from "@/types/index.js";
import mongoose, { type FilterQuery, type PipelineStage } from "mongoose";
import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js";
import { MongoBulkWriteError } from "mongodb";
import { AppError } from "@/utils/AppError.js";
class MongoMonitorsRepository implements IMonitorsRepository {
@@ -20,12 +21,9 @@ class MongoMonitorsRepository implements IMonitorsRepository {
try {
const inserted = await MonitorModel.insertMany(payload, { ordered: false });
return this.mapDocuments(inserted);
} catch (error: any) {
if (error.name === "MongoBulkWriteError" || error.name === "BulkWriteError") {
const insertedDocs = error.insertedDocs || [];
if (insertedDocs.length > 0) {
return this.mapDocuments(insertedDocs);
}
} catch (error: unknown) {
if (error instanceof MongoBulkWriteError && "insertedDocs" in error && Array.isArray(error.insertedDocs) && error.insertedDocs.length > 0) {
return this.mapDocuments(error.insertedDocs);
}
throw error;
}
@@ -46,7 +44,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
};
findByTeamId = async (teamId: string, config: TeamQueryConfig): Promise<Monitor[] | null> => {
const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type, limit } = config ?? {};
const { page = 0, rowsPerPage = 0, filter, field = "createdAt", order = "desc", type } = config ?? {};
const query: Record<string, unknown> = {
teamId: new mongoose.Types.ObjectId(teamId),
@@ -298,6 +296,41 @@ class MongoMonitorsRepository implements IMonitorsRepository {
await MonitorModel.updateMany({ notifications: notificationId }, { $pull: { notifications: notificationId } });
};
updateNotifications = async (
teamId: string,
monitorIds: string[],
notificationIds: string[],
action: "add" | "remove" | "set"
): Promise<number> => {
let objectIds;
let notificationObjectIds;
try {
objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
notificationObjectIds = notificationIds.map((id) => new mongoose.Types.ObjectId(id));
} catch {
throw new AppError({ message: "One or more monitor or notification IDs are invalid", status: 400 });
}
const filter = { _id: { $in: objectIds }, teamId: new mongoose.Types.ObjectId(teamId) };
let update;
switch (action) {
case "add":
update = { $addToSet: { notifications: { $each: notificationObjectIds } } };
break;
case "remove":
update = { $pull: { notifications: { $in: notificationObjectIds } } };
break;
case "set":
update = { $set: { notifications: notificationObjectIds } };
break;
default:
throw new AppError({ message: `Invalid action: ${action}`, status: 400 });
}
const result = await MonitorModel.updateMany(filter, update);
return result.modifiedCount;
};
private mapDocuments = (documents: MonitorDocument[]): Monitor[] => {
if (!documents?.length) {
return [];
@@ -354,7 +387,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
@@ -363,7 +396,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
};
};
private toEntityWithChecks = (doc: any): Monitor => {
private toEntityWithChecks = (doc: MonitorDocument): Monitor => {
const toStringId = (value: unknown): string => {
if (value instanceof mongoose.Types.ObjectId) {
return value.toString();
@@ -413,7 +446,7 @@ class MongoMonitorsRepository implements IMonitorsRepository {
gameId: doc.gameId ?? undefined,
grpcServiceName: doc.grpcServiceName ?? undefined,
group: doc.group ?? null,
recentChecks: (doc.recentChecks ?? []).map((check: any) => this.toCheckSnapshot(check)),
recentChecks: (doc.recentChecks ?? []).map((check: CheckSnapshotDocument) => this.toCheckSnapshot(check)),
geoCheckEnabled: doc.geoCheckEnabled ?? false,
geoCheckLocations: doc.geoCheckLocations ?? [],
geoCheckInterval: doc.geoCheckInterval ?? 300000,
@@ -1,11 +1,11 @@
import type { Settings } from "@/types/index.js";
import type { Settings, SettingsUpdate } from "@/types/index.js";
export interface ISettingsRepository {
// create
create(settings: Partial<Settings>): Promise<Settings>;
// fetch
findSingleton(): Promise<Settings | null>;
// update
update(settings: Partial<Settings>): Promise<Settings>;
update(settings: SettingsUpdate): Promise<Settings>;
// delete
deleteLegacy: () => Promise<boolean>;
}
@@ -1,6 +1,6 @@
import mongoose from "mongoose";
import mongoose, { type UpdateQuery } from "mongoose";
import { ISettingsRepository } from "@/repositories/settings/ISettingsRepository.js";
import type { Settings } from "@/types/index.js";
import type { Settings, SettingsUpdate } from "@/types/index.js";
import { AppSettingsModel, type AppSettingsDocument } from "@/db/models/index.js";
class MongoSettingsRepository implements ISettingsRepository {
@@ -59,27 +59,23 @@ class MongoSettingsRepository implements ISettingsRepository {
return this.toEntity(settings);
};
update = async (settings: Partial<Settings>) => {
const update: Record<string, any> = { $set: {}, $unset: {} };
update = async (settings: SettingsUpdate) => {
const $set: Record<string, unknown> = {};
const $unset: Record<string, string> = {};
// Iterate through settings and separate into $set and $unset
Object.entries(settings).forEach(([key, value]) => {
if (value === undefined || value === "") {
// Unset undefined or empty string values
update.$unset[key] = "";
if (value === undefined || value === null) {
$unset[key] = "";
} else {
// Set defined values
update.$set[key] = value;
$set[key] = value;
}
});
// Clean up empty operators
if (Object.keys(update.$set).length === 0) {
delete update.$set;
}
if (Object.keys(update.$unset).length === 0) {
delete update.$unset;
}
const update: UpdateQuery<AppSettingsDocument> = {
...(Object.keys($set).length > 0 && { $set }),
...(Object.keys($unset).length > 0 && { $unset }),
};
await AppSettingsModel.findOneAndUpdate({}, update, {
upsert: true,
@@ -57,6 +57,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
showCharts: doc.showCharts,
showUptimePercentage: doc.showUptimePercentage,
showAdminLoginLink: doc.showAdminLoginLink,
showInfrastructure: doc.showInfrastructure,
customCSS: doc.customCSS,
createdAt: this.toDateString(doc.createdAt),
updatedAt: this.toDateString(doc.updatedAt),
@@ -71,7 +72,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
};
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
const { logo: _logo, ...restData } = data;
const { logo, ...restData } = data;
void logo;
const statusPage = new StatusPageModel({
...restData,
userId,
@@ -109,7 +111,8 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
image: Express.Multer.File | undefined,
patch: Partial<StatusPage> & { removeLogo?: string }
): Promise<StatusPage> => {
const { logo: _logo, removeLogo, ...restPatch } = patch;
const { logo, removeLogo, ...restPatch } = patch;
void logo;
const updateData: StatusPageUpdateData = { ...restPatch };
if (image) {
updateData.logo = {
@@ -117,7 +117,7 @@ class MongoUsersRepository implements IUsersRepository {
delete candidateUser.deleteProfileImage;
const updateQuery: Record<string, any> = { $set: candidateUser };
const updateQuery: Record<string, unknown> = { $set: candidateUser };
if (unsetFields) {
updateQuery.$unset = unsetFields;
}
+5 -3
View File
@@ -1,14 +1,15 @@
import { Router, RequestHandler } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import multer from "multer";
import { IAuthController } from "@/controllers/authController.js";
const upload = multer();
class AuthRoutes {
private router: Router;
private authController: any;
private authController: IAuthController;
constructor(authController: any, verifyJWT: RequestHandler) {
constructor(authController: IAuthController, verifyJWT: RequestHandler) {
this.router = Router();
this.authController = authController;
this.initRoutes(verifyJWT);
@@ -26,9 +27,10 @@ class AuthRoutes {
this.router.get("/users", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getAllUsers);
this.router.post("/users", verifyJWT, isAllowed(["superadmin"]), upload.single("profileImage"), this.authController.createUser);
this.router.get("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.getUserById);
this.router.get("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.getUserById);
this.router.patch("/users/:userId", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserById);
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
this.router.patch("/user", verifyJWT, upload.single("profileImage"), this.authController.editUser);
this.router.delete("/user", verifyJWT, this.authController.deleteUser);
+3 -3
View File
@@ -1,12 +1,13 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { ICheckController } from "@/controllers/checkController.js";
class CheckRoutes {
private router: Router;
private checkController: any;
private checkController: ICheckController;
constructor(checkController: any) {
constructor(checkController: ICheckController) {
this.router = Router();
this.checkController = checkController;
this.initRoutes();
@@ -15,7 +16,6 @@ class CheckRoutes {
initRoutes() {
this.router.get("/team/summary", this.checkController.getChecksSummaryByTeamId);
this.router.get("/team", this.checkController.getChecksByTeam);
this.router.put("/team/ttl", isAllowed(["admin", "superadmin"]), this.checkController.updateChecksTTL);
this.router.delete("/team", isAllowed(["admin", "superadmin"]), this.checkController.deleteChecksByTeamId);
this.router.get("/:monitorId", this.checkController.getChecksByMonitor);
this.router.delete("/:monitorId", this.checkController.deleteChecks);
+3 -2
View File
@@ -1,11 +1,12 @@
import { RequestHandler, Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { IDiagnosticController } from "@/controllers/diagnosticController.js";
class DiagnosticRoutes {
private router: Router;
private diagnosticController: any;
private diagnosticController: IDiagnosticController;
constructor(diagnosticController: any, verifyJWT: RequestHandler) {
constructor(diagnosticController: IDiagnosticController, verifyJWT: RequestHandler) {
this.router = Router();
this.diagnosticController = diagnosticController;
this.initRoutes(verifyJWT);
+3 -2
View File
@@ -1,10 +1,11 @@
import { IGeoCheckController } from "@/controllers/geoCheckController.js";
import { Router } from "express";
class GeoCheckRoutes {
private router: Router;
private geoCheckController: any;
private geoCheckController: IGeoCheckController;
constructor(geoCheckController: any) {
constructor(geoCheckController: IGeoCheckController) {
this.router = Router();
this.geoCheckController = geoCheckController;
this.initRoutes();
+3 -2
View File
@@ -1,11 +1,12 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { IIncidentController } from "@/controllers/incidentController.js";
class IncidentRoutes {
private router: Router;
private incidentController: any;
private incidentController: IIncidentController;
constructor(incidentController: any) {
constructor(incidentController: IIncidentController) {
this.router = Router();
this.incidentController = incidentController;
this.initRoutes();
+3 -2
View File
@@ -1,11 +1,12 @@
import { RequestHandler, Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { IInviteController } from "@/controllers/inviteController.js";
class InviteRoutes {
private router: Router;
private inviteController: any;
private inviteController: IInviteController;
constructor(inviteController: any, verifyJWT: RequestHandler) {
constructor(inviteController: IInviteController, verifyJWT: RequestHandler) {
this.router = Router();
this.inviteController = inviteController;
this.initRoutes(verifyJWT);
+4 -3
View File
@@ -1,10 +1,11 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { isAllowed } from "@/middleware/isAllowed.js";
import { ILogController } from "@/controllers/logController.js";
class LogRoutes {
private router: Router;
private logController: any;
private logController: ILogController;
constructor(logController: any) {
constructor(logController: ILogController) {
this.router = Router();
this.logController = logController;
this.initRoutes();
+3 -2
View File
@@ -1,10 +1,11 @@
import { IMaintenanceWindowController } from "@/controllers/maintenanceWindowController.js";
import { Router } from "express";
class MaintenanceWindowRoutes {
private router: Router;
private mwController: any;
private mwController: IMaintenanceWindowController;
constructor(maintenanceWindowController: any) {
constructor(maintenanceWindowController: IMaintenanceWindowController) {
this.router = Router();
this.mwController = maintenanceWindowController;
this.initRoutes();
+5 -9
View File
@@ -1,15 +1,11 @@
import { Router } from "express";
import { isAllowed } from "@/middleware/isAllowed.js";
import multer from "multer";
import { fetchMonitorCertificate } from "@/controllers/controllerUtils.js";
const upload = multer({
storage: multer.memoryStorage(), // Store file in memory as Buffer
});
import { IMonitorController } from "@/controllers/monitorController.js";
class MonitorRoutes {
private router: Router;
private monitorController: any;
constructor(monitorController: any) {
private monitorController: IMonitorController;
constructor(monitorController: IMonitorController) {
this.router = Router();
this.monitorController = monitorController;
this.initRoutes();
@@ -38,10 +34,11 @@ class MonitorRoutes {
// Util routes
this.router.get("/certificate/:monitorId", (req, res, next) => {
this.monitorController.getMonitorCertificate(req, res, next, fetchMonitorCertificate);
this.monitorController.getMonitorCertificate(req, res, next);
});
// General monitor CRUD routes
this.router.patch("/notifications", isAllowed(["admin", "superadmin"]), this.monitorController.updateNotifications);
this.router.post("/", isAllowed(["admin", "superadmin"]), this.monitorController.createMonitor);
this.router.delete("/", isAllowed(["superadmin"]), this.monitorController.deleteAllMonitors);
@@ -50,7 +47,6 @@ class MonitorRoutes {
this.router.get("/export/json", isAllowed(["admin", "superadmin"]), this.monitorController.exportMonitorsToJSON);
this.router.post("/import/json", isAllowed(["admin", "superadmin"]), this.monitorController.importMonitorsFromJSON);
this.router.post("/test-email", isAllowed(["admin", "superadmin"]), this.monitorController.sendTestEmail);
this.router.get("/games", this.monitorController.getAllGames);
// Individual monitor CRUD routes
+3 -2
View File
@@ -1,9 +1,10 @@
import { INotificationController } from "@/controllers/notificationController.js";
import { Router } from "express";
class NotificationRoutes {
private router: Router;
private notificationController: any;
private notificationController: INotificationController;
constructor(notificationController: any) {
constructor(notificationController: INotificationController) {
this.router = Router();
this.notificationController = notificationController;
this.initializeRoutes();
+4 -5
View File
@@ -1,20 +1,19 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { isAllowed } from "@/middleware/isAllowed.js";
import { IJobQueueController } from "@/controllers/queueController.js";
class QueueRoutes {
private router: Router;
private queueController: any;
private queueController: IJobQueueController;
constructor(queueController: any) {
constructor(queueController: IJobQueueController) {
this.router = Router();
this.queueController = queueController;
this.initRoutes();
}
initRoutes() {
this.router.get("/jobs", isAllowed(["admin", "superadmin"]), this.queueController.getJobs);
this.router.post("/jobs", isAllowed(["admin", "superadmin"]), this.queueController.addJob);
this.router.get("/metrics", isAllowed(["admin", "superadmin"]), this.queueController.getMetrics);
this.router.get("/health", isAllowed(["admin", "superadmin"]), this.queueController.checkQueueHealth);
this.router.get("/all-metrics", isAllowed(["admin", "superadmin"]), this.queueController.getAllMetrics);
this.router.post("/flush", isAllowed(["admin", "superadmin"]), this.queueController.flushQueue);
}
+3 -2
View File
@@ -1,11 +1,12 @@
import { Router } from "express";
import { isAllowed } from "../middleware/isAllowed.js";
import { ISettingsController } from "@/controllers/settingsController.js";
class SettingsRoutes {
private router: Router;
private settingsController: any;
private settingsController: ISettingsController;
constructor(settingsController: any) {
constructor(settingsController: ISettingsController) {
this.router = Router();
this.settingsController = settingsController;
this.initRoutes();
+3 -2
View File
@@ -1,12 +1,13 @@
import { IStatusPageController } from "@/controllers/statusPageController.js";
import { RequestHandler, Router } from "express";
import multer from "multer";
const upload = multer();
class StatusPageRoutes {
private router: Router;
private statusPageController: any;
private statusPageController: IStatusPageController;
constructor(statusPageController: any, verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) {
constructor(statusPageController: IStatusPageController, verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) {
this.router = Router();
this.statusPageController = statusPageController;
this.initRoutes(verifyJWT, verifyStatusPageAccess);
+76 -53
View File
@@ -1,22 +1,45 @@
import { Types } from "mongoose";
import { IChecksRepository, IMonitorsRepository } from "@/repositories/index.js";
import type { MonitorStatusResponse, CheckErrorInfo, Check, ILighthouseAudit, ChecksQueryResult, ChecksSummary } from "@/types/index.js";
import type {
MonitorStatusResponse,
CheckErrorInfo,
Check,
ILighthouseAudit,
ChecksQueryResult,
ChecksSummary,
MonitorPayloadMap,
} from "@/types/index.js";
import type { HardwareStatusPayload, PageSpeedStatusPayload } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ParseBoolean } from "@/utils/utils.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "checkService";
export interface ICheckService {
createChecks(checks: Check[]): Promise<Check[]>;
buildCheck(statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>): Partial<Check> | undefined;
getChecksByMonitor(params: { monitorId: string; query: Record<string, string>; teamId: string }): Promise<ChecksQueryResult>;
getChecksByTeam(params: { teamId: string; query: Record<string, string> }): Promise<ChecksQueryResult>;
buildCheck(statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined;
getChecksByMonitor(params: {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: string;
status?: boolean;
}): Promise<ChecksQueryResult>;
getChecksByTeam(params: {
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: string;
}): Promise<ChecksQueryResult>;
getChecksSummaryByTeamId(params: { teamId: string; dateRange: string }): Promise<ChecksSummary>;
deleteChecks(params: { monitorId: string; teamId: string }): Promise<number>;
deleteChecksByTeamId(params: { teamId: string }): Promise<number>;
updateChecksTTL(params: { teamId: string; ttl: string }): Promise<void>;
deleteOlderThan(date: Date): Promise<number>;
}
export class CheckService implements ICheckService {
@@ -39,12 +62,14 @@ export class CheckService implements ICheckService {
return this.checksRepository.createChecks(checks);
};
buildCheck = (statusResponse: MonitorStatusResponse<PageSpeedStatusPayload | HardwareStatusPayload | undefined>) => {
buildCheck = (statusResponse: MonitorStatusResponse<MonitorPayloadMap[keyof MonitorPayloadMap]>): Check | undefined => {
const { monitorId, teamId, type, status, responseTime, code, message, payload, timings } = statusResponse;
const check: Partial<Check> = {
const now = new Date().toISOString();
const check: Check = {
id: new Types.ObjectId().toString(),
createdAt: new Date().toISOString(),
createdAt: now,
updatedAt: now,
metadata: {
monitorId,
teamId,
@@ -112,7 +137,25 @@ export class CheckService implements ICheckService {
return check;
};
getChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: Record<string, string>; teamId: string }) => {
getChecksByMonitor = async ({
monitorId,
teamId,
sortOrder,
dateRange,
filter,
page,
rowsPerPage,
status,
}: {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
status?: boolean;
filter?: string;
}) => {
if (!monitorId) {
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "getChecksByMonitor", status: 400 });
}
@@ -123,74 +166,54 @@ export class CheckService implements ICheckService {
// For verification, throws an error if monitor doesn't belong to team
await this.monitorsRepository.findById(monitorId, teamId);
const { sortOrder, dateRange, filter, page, rowsPerPage, status } = query;
if (!sortOrder || !dateRange || !page || !rowsPerPage) {
throw new AppError({
message: `Missing required query parameters sortOrder: ${sortOrder}, dateRange: ${dateRange}, filter: ${filter}, page: ${page}, rowsPerPage: ${rowsPerPage}`,
service: SERVICE_NAME,
method: "getChecksByMonitor",
status: 400,
});
}
const parsedStatus = typeof status === "undefined" ? status : ParseBoolean(status);
const parsedPage = page ? parseInt(page) : 0;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5;
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5;
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, parsedStatus);
const result = await this.checksRepository.findByMonitorId(monitorId, sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, status);
return result;
};
getChecksByTeam = async ({ teamId, query }: { teamId: string; query: Record<string, string> }) => {
const { sortOrder, dateRange, filter, page, rowsPerPage } = query;
if (!sortOrder || !dateRange || !page || !rowsPerPage) {
throw new AppError({ message: "Missing required query parameters", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 });
}
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksByTeam", status: 400 });
}
const parsedPage = page ? parseInt(page) : 0;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : 5;
getChecksByTeam = async ({
teamId,
sortOrder,
dateRange,
filter,
page,
rowsPerPage,
}: {
teamId: string;
sortOrder: string;
dateRange: string;
page: number;
rowsPerPage: number;
filter?: string;
}) => {
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 5;
const checkData = await this.checksRepository.findByTeamId(sortOrder, dateRange, filter, parsedPage, parsedRowsPerPage, teamId);
return checkData;
};
getChecksSummaryByTeamId = async ({ teamId, dateRange }: { teamId: string; dateRange: string }) => {
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getChecksSummaryByTeamId", status: 400 });
}
const summary = await this.checksRepository.findSummaryByTeamId(teamId, dateRange);
return summary;
};
deleteChecks = async ({ monitorId, teamId }: { monitorId: string; teamId: string }) => {
if (!monitorId) {
throw new AppError({ message: "No monitor ID in request", service: SERVICE_NAME, method: "deleteChecks", status: 400 });
}
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteChecks", status: 400 });
}
// For verification, throws an error if monitor doesn't belong to team
await this.monitorsRepository.findById(monitorId, teamId);
const deletedCount = await this.checksRepository.deleteByMonitorId(monitorId);
return deletedCount;
};
deleteChecksByTeamId = async ({ teamId }: { teamId: string }) => {
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteChecksByTeamId", status: 400 });
}
const deletedCount = await this.checksRepository.deleteByTeamId(teamId);
return deletedCount;
};
updateChecksTTL = async ({ teamId, ttl }: { teamId: string; ttl: string }) => {
throw new AppError({ message: "Not implemented", service: SERVICE_NAME, method: "updateChecksTTL", status: 500, details: { teamId, ttl } });
deleteOlderThan = async (date: Date) => {
const deletedCount = await this.checksRepository.deleteOlderThan(date);
return deletedCount;
};
}
@@ -24,8 +24,7 @@ export class DiagnosticService implements IDiagnosticService {
* Clears performance marks after each measurement to prevent memory leaks.
*/
const obs = new PerformanceObserver((items) => {
// Get the first entry but we don't need to store it
items.getEntries()[0];
items.getEntries();
performance.clearMarks();
});
obs.observe({ entryTypes: ["measure"] });
@@ -1,7 +1,7 @@
import type { Monitor, GeoCheck } from "@/types/index.js";
import type { GeoCheckResult } from "@/types/geoCheck.js";
import type { GeoCheckResult, GeoContinent } from "@/types/geoCheck.js";
import { Types } from "mongoose";
import type { IGeoChecksRepository } from "@/repositories/index.js";
import type { FlatGeoChecksQueryResult, IGeoChecksRepository } from "@/repositories/index.js";
import type { IMonitorsRepository } from "@/repositories/index.js";
import type { IGlobalPingService } from "@/service/infrastructure/globalPingService.js";
import type { ILogger } from "@/utils/logger.js";
@@ -13,7 +13,15 @@ export interface IGeoChecksService {
readonly serviceName: string;
buildGeoCheck(monitor: Monitor): Promise<GeoCheck | null>;
createGeoChecks(geoChecks: GeoCheck[]): Promise<GeoCheck[]>;
getGeoChecksByMonitor(args: { monitorId: string; query: any; teamId: string }): Promise<any>;
getGeoChecksByMonitor(args: {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
page?: number;
rowsPerPage?: number;
continent: GeoContinent | GeoContinent[];
}): Promise<FlatGeoChecksQueryResult>;
}
export class GeoChecksService implements IGeoChecksService {
@@ -139,7 +147,23 @@ export class GeoChecksService implements IGeoChecksService {
return this.geoChecksRepository.createGeoChecks(geoChecks);
};
getGeoChecksByMonitor = async ({ monitorId, query, teamId }: { monitorId: string; query: any; teamId: string }) => {
getGeoChecksByMonitor = async ({
monitorId,
teamId,
sortOrder,
dateRange,
page,
rowsPerPage,
continent,
}: {
monitorId: string;
teamId: string;
sortOrder: string;
dateRange: string;
page?: number;
rowsPerPage?: number;
continent: GeoContinent | GeoContinent[];
}) => {
if (!monitorId) {
throw new AppError({
message: "No monitor ID in request",
@@ -167,10 +191,9 @@ export class GeoChecksService implements IGeoChecksService {
});
}
const { sortOrder, dateRange, page, rowsPerPage, continent } = query;
const continents = continent ? (Array.isArray(continent) ? continent : [continent]) : undefined;
const parsedPage = page ? parseInt(page) : page;
const parsedRowsPerPage = rowsPerPage ? parseInt(rowsPerPage) : rowsPerPage;
const parsedPage = page || 0;
const parsedRowsPerPage = rowsPerPage || 5;
const result = await this.geoChecksRepository.findByMonitorId(monitorId, sortOrder, dateRange, parsedPage, parsedRowsPerPage, continents);
return result;
};
+29 -31
View File
@@ -2,7 +2,6 @@ const SERVICE_NAME = "incidentService";
import type { Monitor } from "@/types/monitor.js";
import type { MonitorStatusResponse } from "@/types/network.js";
import { AppError } from "@/utils/AppError.js";
import { ParseBoolean } from "@/utils/utils.js";
import { getDateForRange } from "@/utils/dataUtils.js";
import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js";
import type { Incident, IncidentSummary, User } from "@/types/index.js";
@@ -22,13 +21,13 @@ export interface IIncidentService {
teamId: string,
sortOrder: string,
dateRange: string,
page: string,
rowsPerPage: string,
status: string,
monitorId: string,
resolutionType: string
page: number,
rowsPerPage: number,
status: boolean | undefined,
monitorId: string | undefined,
resolutionType: string | undefined
): Promise<{ incidents: Incident[]; count: number }>;
getIncidentSummary(teamId: string, limit?: string): Promise<IncidentSummary>;
getIncidentSummary(teamId: string, limit?: number): Promise<IncidentSummary>;
getIncidentById(incidentId: string, teamId: string): Promise<{ incident: Incident; monitor: Monitor; user: User | null }>;
}
@@ -164,13 +163,13 @@ export class IncidentService implements IIncidentService {
});
return resolvedIncident;
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
service: SERVICE_NAME,
method: "resolveIncident",
message: error.message,
message: error instanceof Error ? error.message : "Unknown error",
details: { id: incidentId },
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -180,11 +179,11 @@ export class IncidentService implements IIncidentService {
teamId: string,
sortOrder: string,
dateRange: string,
page: string,
rowsPerPage: string,
status: string,
monitorId: string,
resolutionType: string
page: number,
rowsPerPage: number,
status: boolean | undefined,
monitorId: string | undefined,
resolutionType: string | undefined
) => {
try {
if (!teamId) {
@@ -193,9 +192,8 @@ export class IncidentService implements IIncidentService {
const startDate = getDateForRange(dateRange);
const parsedPage = Number.isFinite(parseInt(page)) ? parseInt(page) : 0;
const parsedRowsPerPage = Number.isFinite(parseInt(rowsPerPage)) ? parseInt(rowsPerPage) : 20;
const parsedStatus = typeof status === "undefined" ? undefined : ParseBoolean(status);
const parsedPage = page ?? 0;
const parsedRowsPerPage = rowsPerPage ?? 20;
const incidents = await this.incidentsRepository.findByTeamId(
teamId,
@@ -203,43 +201,43 @@ export class IncidentService implements IIncidentService {
parsedPage,
parsedRowsPerPage,
sortOrder,
parsedStatus,
status,
monitorId,
resolutionType
);
const count = await this.incidentsRepository.countByTeamId(teamId, startDate, parsedStatus, monitorId, resolutionType);
const count = await this.incidentsRepository.countByTeamId(teamId, startDate, status, monitorId, resolutionType);
return { incidents, count };
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
service: SERVICE_NAME,
method: "getIncidentsByTeam",
message: error.message,
message: error instanceof Error ? error.message : "Unknown error",
details: { teamId },
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
};
getIncidentSummary = async (teamId: string, limit?: string) => {
getIncidentSummary = async (teamId: string, limit?: number) => {
try {
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "getIncidentSummary", status: 400 });
}
const parsedLimit = limit && Number.isFinite(parseInt(limit, 10)) ? parseInt(limit, 10) : 10;
const parsedLimit = limit ?? 10;
const summary = await this.incidentsRepository.findSummaryByTeamId(teamId, parsedLimit);
return summary;
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
service: SERVICE_NAME,
method: "getIncidentSummary",
message: error.message,
message: error instanceof Error ? error.message : "Unknown error",
details: { teamId },
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
@@ -254,13 +252,13 @@ export class IncidentService implements IIncidentService {
user = await this.usersRepository.findById(incident.resolvedBy);
}
return { incident, monitor, user };
} catch (error: any) {
} catch (error: unknown) {
this.logger.error({
service: SERVICE_NAME,
method: "getIncidentById",
message: error.message,
message: error instanceof Error ? error.message : "Unknown error",
details: { incidentId },
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
+26 -6
View File
@@ -2,6 +2,8 @@ import type { Invite, UserRole } from "@/types/index.js";
import { canManageRole } from "@/types/user.js";
import type { IInvitesRepository } from "@/repositories/index.js";
import { AppError } from "@/utils/AppError.js";
import { ISettingsService } from "../system/settingsService.js";
import { IEmailService } from "../infrastructure/emailService.js";
const SERVICE_NAME = "inviteService";
@@ -14,8 +16,8 @@ export interface IInviteService {
export class InviteService implements IInviteService {
static SERVICE_NAME = SERVICE_NAME;
private settingsService: any;
private emailService: any;
private settingsService: ISettingsService;
private emailService: IEmailService;
private invitesRepository: IInvitesRepository;
constructor({
@@ -24,8 +26,8 @@ export class InviteService implements IInviteService {
emailService,
}: {
invitesRepository: IInvitesRepository;
settingsService: any;
emailService: any;
settingsService: ISettingsService;
emailService: IEmailService;
}) {
this.invitesRepository = invitesRepository;
this.settingsService = settingsService;
@@ -57,8 +59,16 @@ export class InviteService implements IInviteService {
return inviteToken;
};
sendInviteEmail = async ({ invite, firstName, userRoles }: { invite: Partial<Invite>; firstName: any; userRoles: UserRole[] }) => {
sendInviteEmail = async ({ invite, firstName, userRoles }: { invite: Partial<Invite>; firstName: string; userRoles: UserRole[] }) => {
const inviteRoles = invite.role ?? [];
if (!invite.email) {
throw new AppError({
message: "Invite email is required to send an invite",
service: SERVICE_NAME,
method: "sendInviteEmail",
status: 400,
});
}
for (const targetRole of inviteRoles) {
const canManage = userRoles.some((actorRole) => canManageRole(actorRole, targetRole));
@@ -66,7 +76,7 @@ export class InviteService implements IInviteService {
throw new AppError({
message: "You do not have permission to create this invite",
service: SERVICE_NAME,
method: "getInviteToken",
method: "sendInviteEmail",
status: 403,
});
}
@@ -79,6 +89,16 @@ export class InviteService implements IInviteService {
name: firstName,
link: `${clientHost}/register/${inviteToken.token}`,
});
if (!html) {
throw new AppError({
message: "Failed to build invite e-mail... Please verify your settings.",
service: SERVICE_NAME,
method: "sendInviteEmail",
status: 500,
});
}
const result = await this.emailService.sendEmail(invite.email, "Welcome to Uptime Monitor", html);
if (!result) {
throw new AppError({
@@ -1,16 +1,29 @@
import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js";
import type { MaintenanceWindow } from "@/types/index.js";
import { ParseBoolean } from "@/utils/utils.js";
import type { DurationUnit, MaintenanceWindow } from "@/types/index.js";
import { AppError } from "@/utils/AppError.js";
const SERVICE_NAME = "maintenanceWindowService";
export interface IMaintenanceWindowService {
createMaintenanceWindow(params: { teamId: string; body: Partial<MaintenanceWindow> & { monitors: string[] } }): Promise<void>;
createMaintenanceWindow(params: {
teamId: string;
monitorIDs: string[];
name: string;
active: boolean;
duration: number;
durationUnit: DurationUnit;
repeat: number;
start: string;
end: string;
}): Promise<void>;
getMaintenanceWindowById(params: { id: string; teamId: string }): Promise<MaintenanceWindow>;
getMaintenanceWindowsByTeamId(params: {
teamId: string;
query: Record<string, string>;
active?: boolean;
page?: number;
rowsPerPage?: number;
field?: string;
order?: string;
}): Promise<{ maintenanceWindows: MaintenanceWindow[]; maintenanceWindowCount: number }>;
getMaintenanceWindowsByMonitorId(params: { monitorId: string; teamId: string }): Promise<MaintenanceWindow[]>;
deleteMaintenanceWindow(params: { id: string; teamId: string }): Promise<MaintenanceWindow>;
@@ -37,9 +50,28 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
return MaintenanceWindowService.SERVICE_NAME;
}
createMaintenanceWindow = async ({ teamId, body }: { teamId: string; body: any }) => {
const monitorIds = body.monitors;
const monitors = await this.monitorsRepository.findByIds(monitorIds);
createMaintenanceWindow = async ({
teamId,
monitorIDs,
name,
active,
duration,
durationUnit,
repeat,
start,
end,
}: {
teamId: string;
monitorIDs: string[];
name: string;
active: boolean;
duration: number;
durationUnit: DurationUnit;
repeat: number;
start: string;
end: string;
}) => {
const monitors = await this.monitorsRepository.findByIds(monitorIDs);
const unauthorizedMonitors = monitors.filter((monitor) => monitor.teamId !== teamId);
@@ -52,17 +84,17 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
});
}
const dbTransactions = monitorIds.map((monitorId: string) => {
const dbTransactions = monitorIDs.map((monitorId: string) => {
return this.maintenanceWindowsRepository.create({
teamId,
monitorId,
name: body.name,
active: body.active ? body.active : true,
duration: body.duration,
durationUnit: body.durationUnit,
repeat: body.repeat,
start: body.start,
end: body.end,
name: name,
active: active,
duration: duration,
durationUnit: durationUnit,
repeat: repeat,
start: start,
end: end,
});
});
await Promise.all(dbTransactions);
@@ -72,14 +104,25 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
return await this.maintenanceWindowsRepository.findById(id, teamId);
};
getMaintenanceWindowsByTeamId = async ({ teamId, query }: { teamId: string; query: any }) => {
let { active, page, rowsPerPage, field, order } = query || {};
getMaintenanceWindowsByTeamId = async ({
teamId,
active,
page,
rowsPerPage,
field,
order,
}: {
teamId: string;
active?: boolean;
page?: number;
rowsPerPage?: number;
field?: string;
order?: string;
}) => {
page = page ?? 0;
rowsPerPage = rowsPerPage ?? 10;
active = typeof active === "undefined" ? undefined : ParseBoolean(active);
page = parseInt(page) || 0;
rowsPerPage = parseInt(rowsPerPage) || 10;
const maintenanceWindows = await this.maintenanceWindowsRepository.findByTeamId(teamId, active, page, rowsPerPage, field, order);
const maintenanceWindows = await this.maintenanceWindowsRepository.findByTeamId(teamId, page, rowsPerPage, field, order, active);
const maintenanceWindowCount = await this.maintenanceWindowsRepository.countByTeamId(teamId, active);
return { maintenanceWindows, maintenanceWindowCount };
};
@@ -92,7 +135,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService {
return await this.maintenanceWindowsRepository.deleteById(id, teamId);
};
editMaintenanceWindow = async ({ id, teamId, body }: { id: string; teamId: string; body: any }) => {
editMaintenanceWindow = async ({ id, teamId, body }: { id: string; teamId: string; body: Partial<MaintenanceWindow> }) => {
return await this.maintenanceWindowsRepository.updateById(id, teamId, body);
};
}
+93 -84
View File
@@ -7,6 +7,7 @@ import type {
HardwareDetailsResult,
PageSpeedDetailsResult,
GamesMap,
GroupedGeoCheckResult,
} from "@/types/monitor.js";
import { supportsGeoCheck } from "@/types/monitor.js";
import type { GeoContinent } from "@/types/geoCheck.js";
@@ -18,11 +19,12 @@ import type {
IMonitorStatsRepository,
IStatusPagesRepository,
} from "@/repositories/index.js";
import fs from "fs";
import { fileURLToPath } from "url";
import path from "path";
import demoMonitorsData from "@/utils/demoMonitors.json" with { type: "json" };
import { AppError } from "@/utils/AppError.js";
import type { ImportedMonitor } from "@/validation/monitorValidation.js";
import { ISuperSimpleQueue } from "../infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import { IEmailService } from "../infrastructure/emailService.js";
import { ILogger } from "@/utils/logger.js";
const SERVICE_NAME = "MonitorService";
type DateRangeKey = "recent" | "day" | "week" | "month" | "all";
@@ -31,15 +33,20 @@ export interface IMonitorService {
readonly serviceName: string;
// create
createMonitor(teamId: string, userId: string, body: Monitor): Promise<void>;
createMonitors(monitors: Array<Monitor>, userId: string, teamId: string): Promise<Monitor[] | null>;
createMonitor(teamId: string, userId: string, body: Partial<Monitor>): Promise<void>;
createMonitors(monitors: Array<Monitor>): Promise<Monitor[] | null>;
addDemoMonitors(args: { userId: string; teamId: string }): Promise<Monitor[]>;
// read
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string; normalize?: boolean }): Promise<UptimeDetailsResult>;
getUptimeDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<UptimeDetailsResult>;
getHardwareDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<HardwareDetailsResult>;
getPageSpeedDetailsById(args: { teamId: string; monitorId: string; dateRange: string }): Promise<PageSpeedDetailsResult>;
getGeoChecksByMonitorId(args: { teamId: string; monitorId: string; dateRange: string; continents?: GeoContinent[] }): Promise<any>;
getGeoChecksByMonitorId(args: {
teamId: string;
monitorId: string;
dateRange: string;
continents?: GeoContinent[];
}): Promise<GroupedGeoCheckResult>;
getMonitorById(args: { teamId: string; monitorId: string }): Promise<Monitor>;
getMonitorsByTeamId(args: {
teamId: string;
@@ -60,32 +67,33 @@ export interface IMonitorService {
filter?: string;
field?: string;
order?: "asc" | "desc";
explain?: boolean;
}): Promise<MonitorsWithChecksByTeamIdResult>;
getAllGames(): GamesMap;
getGroupsByTeamId(args: { teamId: string }): Promise<string[]>;
// update
editMonitor(args: { teamId: string; monitorId: string; body: Monitor }): Promise<Monitor>;
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
// delete
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
deleteAllMonitors(args: { teamId: string }): Promise<number>;
// notifications
updateNotifications(args: { teamId: string; monitorIds: string[]; notificationIds: string[]; action: "add" | "remove" | "set" }): Promise<number>;
// other
sendTestEmail(args: { to: string }): Promise<string>;
exportMonitorsToJSON(args: { teamId: string }): Promise<Monitor[]>;
importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: any[] }): Promise<{ imported: number; errors: string[] }>;
importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: ImportedMonitor[] }): Promise<{ imported: number; errors: string[] }>;
}
export class MonitorService implements IMonitorService {
static SERVICE_NAME = SERVICE_NAME;
private jobQueue: ISuperSimpleQueue;
private emailService: any;
private logger: any;
private games: any;
private emailService: IEmailService;
private logger: ILogger;
private games: GamesMap;
private monitorsRepository: IMonitorsRepository;
private checksRepository: IChecksRepository;
private geoChecksRepository: IGeoChecksRepository;
@@ -106,9 +114,9 @@ export class MonitorService implements IMonitorService {
incidentsRepository,
}: {
jobQueue: ISuperSimpleQueue;
emailService: any;
logger: any;
games: any;
emailService: IEmailService;
logger: ILogger;
games: GamesMap;
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
@@ -166,7 +174,7 @@ export class MonitorService implements IMonitorService {
this.jobQueue.addJob(monitor.id, monitor);
};
createMonitors = async (monitors: Array<Monitor>, userId: string, teamId: string): Promise<Monitor[] | null> => {
createMonitors = async (monitors: Array<Monitor>): Promise<Monitor[] | null> => {
const createdMonitors = await this.monitorsRepository.createMonitors(monitors);
if (!monitors || monitors.length === 0) {
throw new AppError({ message: "Failed to create monitors", status: 500, service: SERVICE_NAME, method: "createMonitors" });
@@ -176,24 +184,18 @@ export class MonitorService implements IMonitorService {
return createdMonitors;
};
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<any[]> => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const demoMonitorsPath = path.resolve(__dirname, "../../utils/demoMonitors.json");
const demoData = JSON.parse(fs.readFileSync(demoMonitorsPath, "utf8"));
const monitors: Monitor[] = demoData.map((monitor: Monitor) => {
return {
userId,
teamId,
name: monitor.name,
description: monitor.name,
type: "http",
url: monitor.url,
interval: 60000,
};
});
const demoMonitors = await this.monitorsRepository.createMonitors(monitors);
addDemoMonitors = async ({ userId, teamId }: { userId: string; teamId: string }): Promise<Monitor[]> => {
const demoData = demoMonitorsData;
const monitors = demoData.map((monitor) => ({
userId,
teamId,
name: monitor.name,
description: monitor.name,
type: "http" as const,
url: monitor.url,
interval: 60000,
}));
const demoMonitors = await this.monitorsRepository.createMonitors(monitors as unknown as Monitor[]);
await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor.id, monitor)));
return demoMonitors;
@@ -203,12 +205,10 @@ export class MonitorService implements IMonitorService {
teamId,
monitorId,
dateRange,
normalize,
}: {
teamId: string;
monitorId: string;
dateRange: string;
normalize?: boolean;
}): Promise<UptimeDetailsResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
@@ -227,7 +227,8 @@ export class MonitorService implements IMonitorService {
checksData.monitorType !== "docker" &&
checksData.monitorType !== "port" &&
checksData.monitorType !== "game" &&
checksData.monitorType !== "grpc"
checksData.monitorType !== "grpc" &&
checksData.monitorType !== "websocket"
) {
throw new AppError({ message: `${monitor.type} monitors are not supported for uptime details`, status: 400 });
}
@@ -335,7 +336,7 @@ export class MonitorService implements IMonitorService {
monitorId: string;
dateRange: string;
continents?: GeoContinent[];
}): Promise<any> => {
}): Promise<GroupedGeoCheckResult> => {
const monitor = await this.monitorsRepository.findById(monitorId, teamId);
if (!monitor) {
throw new AppError({ message: `Monitor with ID ${monitorId} not found.`, status: 404 });
@@ -388,7 +389,6 @@ export class MonitorService implements IMonitorService {
filter,
field,
order,
explain,
}: {
teamId: string;
limit?: number;
@@ -398,7 +398,6 @@ export class MonitorService implements IMonitorService {
filter?: string;
field?: string;
order?: "asc" | "desc";
explain?: boolean;
}): Promise<MonitorsWithChecksByTeamIdResult> => {
const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type });
const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter });
@@ -437,55 +436,81 @@ export class MonitorService implements IMonitorService {
return groups;
};
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Monitor }) => {
editMonitor = async ({ teamId, monitorId, body }: { teamId: string; monitorId: string; body: Partial<Monitor> }) => {
const editedMonitor = await this.monitorsRepository.updateById(monitorId, teamId, body);
await this.jobQueue.updateJob(editedMonitor);
return editedMonitor;
};
updateNotifications = async ({
teamId,
monitorIds,
notificationIds,
action,
}: {
teamId: string;
monitorIds: string[];
notificationIds: string[];
action: "add" | "remove" | "set";
}): Promise<number> => {
const modifiedCount = await this.monitorsRepository.updateNotifications(teamId, monitorIds, notificationIds, action);
// If notifications were updated, we should update the jobs in the queue
if (modifiedCount > 0) {
const monitors = await this.monitorsRepository.findByIds(monitorIds);
await Promise.all(monitors.map((monitor) => this.jobQueue.updateJob(monitor)));
}
return modifiedCount;
};
pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.togglePauseById(monitorId, teamId);
monitor.isActive === true ? await this.jobQueue.resumeJob(monitor) : await this.jobQueue.pauseJob(monitor);
if (monitor.isActive) {
await this.jobQueue.resumeJob(monitor);
} else {
await this.jobQueue.pauseJob(monitor);
}
return monitor;
};
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: any) => {
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err.stack,
stack: err instanceof Error ? err.stack : undefined,
});
});
await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: any) => {
await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting checks for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err.stack,
stack: err instanceof Error ? err.stack : undefined,
});
});
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id).catch((err: any) => {
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error removing monitor ${monitor.id} with name ${monitor.name} from status pages`,
service: SERVICE_NAME,
stack: err.stack,
stack: err instanceof Error ? err.stack : undefined,
});
});
await this.incidentsRepository.deleteByMonitorId(monitor.id, teamId).catch((err: any) => {
await this.incidentsRepository.deleteByMonitorId(monitor.id, teamId).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting incidents for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err.stack,
stack: err instanceof Error ? err.stack : undefined,
});
});
await this.geoChecksRepository.deleteByMonitorId(monitor.id).catch((err: any) => {
await this.geoChecksRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
this.logger.warn({
message: `Error deleting geo checks for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
stack: err.stack,
stack: err instanceof Error ? err.stack : undefined,
});
});
@@ -503,12 +528,12 @@ export class MonitorService implements IMonitorService {
await this.geoChecksRepository.deleteByMonitorId(monitor.id);
await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id);
await this.monitorStatsRepository.deleteByMonitorId(monitor.id);
} catch (error: any) {
} catch (error: unknown) {
this.logger.warn({
message: `Error deleting associated records for monitor ${monitor.id} with name ${monitor.name}`,
service: SERVICE_NAME,
method: "deleteAllMonitors",
stack: error.stack,
stack: error instanceof Error ? error.stack : undefined,
});
}
})
@@ -516,21 +541,7 @@ export class MonitorService implements IMonitorService {
return deletedCount;
};
sendTestEmail = async ({ to }: { to: string }): Promise<string> => {
const subject = "Test email from Checkmate";
const context = { testName: "Monitoring System" };
const html = await this.emailService.buildEmail("testEmailTemplate", context);
const messageId = await this.emailService.sendEmail(to, subject, html);
if (!messageId) {
throw new AppError({ message: "Failed to send test email.", service: SERVICE_NAME, method: "sendTestEmail", status: 500 });
}
return messageId;
};
exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<any[]> => {
exportMonitorsToJSON = async ({ teamId }: { teamId: string }): Promise<Monitor[]> => {
const monitors = await this.monitorsRepository.findByTeamId(teamId, {});
if (!monitors || monitors.length === 0) {
@@ -547,23 +558,21 @@ export class MonitorService implements IMonitorService {
}: {
teamId: string;
userId: string;
monitors: any[];
monitors: ImportedMonitor[];
}): Promise<{ imported: number; errors: string[] }> => {
const errors: string[] = [];
const cleanedMonitors = monitors.map((monitor) => {
const cleanData = { ...monitor };
delete cleanData.id;
delete cleanData._id;
delete cleanData.createdAt;
delete cleanData.updatedAt;
delete cleanData.recentChecks;
// Monitors must belong to current team
cleanData.teamId = teamId;
return cleanData;
});
const cleanedMonitors: Monitor[] = monitors.map((monitor) => ({
...monitor,
id: "",
teamId,
userId,
recentChecks: [],
createdAt: "",
updatedAt: "",
}));
const createdMonitors = await this.createMonitors(cleanedMonitors, userId, teamId);
const createdMonitors = await this.createMonitors(cleanedMonitors);
if (!createdMonitors || createdMonitors.length === 0) {
throw new AppError({
+88 -43
View File
@@ -12,23 +12,31 @@ import bcrypt from "bcryptjs";
import { AppError } from "@/utils/AppError.js";
import { ISuperSimpleQueue } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import { IEmailService } from "@/service/infrastructure/emailService.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { EnvConfig, ISettingsService } from "@/service/system/settingsService.js";
import { ILogger } from "@/utils/logger.js";
import jwt from "jsonwebtoken";
import crypto from "crypto";
type CryptoType = typeof crypto;
type JwtType = typeof jwt;
const SERVICE_NAME = "userService";
export interface IUserService {
issueToken(payload: Partial<User>, appSettings: { jwtTTL?: string; jwtSecret?: string }): string;
registerUser(user: Partial<User>, inviteToken: string, file: unknown): Promise<{ user: User; token: string }>;
createUser(userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: unknown): Promise<User>;
issueToken(payload: Partial<User>, appSettings: EnvConfig): string;
registerUser(user: Partial<User>, inviteToken: string, file: Express.Multer.File | null): Promise<{ user: User; token: string }>;
createUser(userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: Express.Multer.File | null): Promise<User>;
loginUser(email: string, password: string): Promise<{ user: User; token: string }>;
editUser(updates: Partial<User & { newPassword?: string }>, file: unknown, currentUser: User): Promise<User>;
editUser(
updates: Partial<User & { newPassword?: string }>,
file: Express.Multer.File | null,
currentUserId: string,
currentUserEmail: string
): Promise<User>;
checkSuperadminExists(): Promise<boolean>;
requestRecovery(email: string): Promise<string | false | undefined>;
validateRecovery(recoveryToken: string): Promise<void>;
resetPassword(password: string, recoveryToken: string): Promise<{ user: User; token: string }>;
deleteUser(user: User): Promise<void>;
deleteUser(params: { userId: string; teamId: string; roles: UserRole[] }): Promise<void>;
deleteUserById(params: { actorId: string; actorTeamId: string; actorRoles: UserRole[]; targetUserId: string }): Promise<void>;
getAllUsers(): Promise<User[]>;
getUserById(roles: UserRole[], userId: string): Promise<User>;
editUserById(userId: string, patch: Partial<User>): Promise<void>;
@@ -48,7 +56,7 @@ export class UserService implements IUserService {
private logger: ILogger;
private jwt: JwtType;
private jobQueue: ISuperSimpleQueue;
private crypto: any;
private crypto: CryptoType;
private monitorsRepository: IMonitorsRepository;
private usersRepository: IUsersRepository;
private invitesRepository: IInvitesRepository;
@@ -70,7 +78,7 @@ export class UserService implements IUserService {
settingsRepository,
teamsRepository,
}: {
crypto: any;
crypto: CryptoType;
emailService: IEmailService;
settingsService: ISettingsService;
logger: ILogger;
@@ -101,14 +109,14 @@ export class UserService implements IUserService {
return UserService.SERVICE_NAME;
}
issueToken = (payload: any, appSettings: any) => {
issueToken = (payload: Partial<User>, appSettings: EnvConfig) => {
const tokenTTL = appSettings?.jwtTTL ?? "2h";
const tokenSecret = appSettings?.jwtSecret;
const payloadData = payload;
return this.jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL });
};
registerUser = async (user: Partial<User>, inviteToken: string, file: any) => {
registerUser = async (user: Partial<User>, inviteToken: string, file: Express.Multer.File | null) => {
// Create a new user
// If superAdmin exists, a token should be attached to all further register requests
const superAdminExists = await this.usersRepository.findSuperAdmin();
@@ -135,7 +143,7 @@ export class UserService implements IUserService {
user.password = this.hashPassword(user.password);
}
const newUser = await this.usersRepository.create(user, file);
const newUser = await this.usersRepository.create(user as User, file);
this.logger.debug({
message: "New user created",
@@ -178,7 +186,7 @@ export class UserService implements IUserService {
return { user: newUser, token };
};
createUser = async (userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: any) => {
createUser = async (userData: Partial<User>, teamId: string, actorRoles: UserRole[], file: Express.Multer.File | null) => {
// Validate that the creator can assign the requested roles
const targetRoles = userData.role ?? [];
for (const targetRole of targetRoles) {
@@ -199,7 +207,7 @@ export class UserService implements IUserService {
userData.password = this.hashPassword(userData.password);
}
const newUser = await this.usersRepository.create(userData, file);
const newUser = await this.usersRepository.create(userData as User, file);
this.logger.debug({
message: "New user created by superadmin",
@@ -238,14 +246,19 @@ export class UserService implements IUserService {
return { user: userWithoutPassword, token };
};
editUser = async (updates: Partial<User & { newPassword?: string }>, file: any, currentUser: any) => {
editUser = async (
updates: Partial<User & { newPassword?: string }>,
file: Express.Multer.File | null,
currentUserId: string,
currentUserEmail: string
) => {
// Change Password check
if (updates?.password && updates?.newPassword) {
// Get user's email
// Add user email to body for DB operation
updates.email = currentUser.email;
updates.email = currentUserEmail;
// Get user
const user = await this.usersRepository.findByEmail(currentUser.email);
const user = await this.usersRepository.findByEmail(currentUserEmail);
// Compare passwords
const match = await bcrypt.compare(updates?.password, user.password);
// If not a match, throw a 403
@@ -258,7 +271,7 @@ export class UserService implements IUserService {
delete updates.newPassword;
}
return await this.usersRepository.updateById(currentUser.id, updates, file);
return await this.usersRepository.updateById(currentUserId, updates, file);
};
checkSuperadminExists = async () => {
@@ -272,8 +285,8 @@ export class UserService implements IUserService {
await this.recoveryTokensRepository.deleteManyByEmail(email);
const recoveryToken = await this.recoveryTokensRepository.create(email);
const name = user.firstName;
const { clientHost } = this.settingsService.getSettings();
const url = `${clientHost}/set-new-password/${recoveryToken.token}`;
const settings = this.settingsService.getSettings();
const url = `${settings.clientHost!}/set-new-password/${recoveryToken.token}`;
const html = await this.emailService.buildEmail("passwordResetTemplate", {
name,
@@ -318,24 +331,7 @@ export class UserService implements IUserService {
return { user: existingUser, token };
};
deleteUser = async (user: User) => {
const email = user?.email;
if (!email) {
throw new AppError({ message: "No email in request", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
const teamId = user?.teamId;
const userId = user?.id;
if (!teamId) {
throw new AppError({ message: "No team ID in request", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
if (!userId) {
throw new AppError({ message: "No user ID in request", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
const roles = user?.role;
deleteUser = async ({ userId, teamId, roles }: { userId: string; teamId: string; roles: UserRole[] }) => {
if (roles.includes("demo")) {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUser", status: 400 });
}
@@ -348,7 +344,7 @@ export class UserService implements IUserService {
if (res && res.length > 0) {
await Promise.all(
res.map(async (monitor) => {
await this.jobQueue.deleteJob(monitor.id);
await this.jobQueue.deleteJob(monitor);
})
);
}
@@ -357,24 +353,73 @@ export class UserService implements IUserService {
await this.usersRepository.deleteById(userId);
};
deleteUserById = async ({
actorId,
actorRoles,
actorTeamId,
targetUserId,
}: {
actorId: string;
actorTeamId: string;
actorRoles: UserRole[];
targetUserId: string;
}) => {
if (actorId === targetUserId) {
throw new AppError({ message: "Cannot delete your own account from here", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
}
const targetUser = await this.usersRepository.findById(targetUserId);
if (targetUser.teamId !== actorTeamId) {
throw new AppError({ message: "User is not on your team", service: SERVICE_NAME, method: "deleteUserById", status: 403 });
}
if (targetUser.role.includes("demo")) {
throw new AppError({ message: "Demo user cannot be deleted", service: SERVICE_NAME, method: "deleteUserById", status: 400 });
}
const targetRoles = targetUser.role;
// Check actor can manage all of target's roles
for (const targetRole of targetRoles) {
const canManage = actorRoles.some((actorRole) => canManageRole(actorRole, targetRole));
if (!canManage) {
throw new AppError({
message: "You do not have permission to remove this user",
service: SERVICE_NAME,
method: "deleteUserById",
status: 403,
});
}
}
await this.usersRepository.deleteById(targetUserId);
this.logger.info({
message: `User ${targetUserId} deleted by ${actorId}`,
service: SERVICE_NAME,
method: "deleteUserById",
});
};
getAllUsers = async () => {
return await this.usersRepository.findAll();
};
getUserById = async (roles: any, userId: any) => {
if (!roles.includes("superadmin")) {
throw new AppError({ message: "User is not a superadmin", service: SERVICE_NAME, status: 403 });
getUserById = async (roles: UserRole[], userId: string) => {
if (!roles.includes("superadmin") && !roles.includes("admin")) {
throw new AppError({ message: "Insufficient permissions", service: SERVICE_NAME, status: 403 });
}
const user = await this.usersRepository.findById(userId);
return user;
};
editUserById = async (userId: any, patch: Partial<User>) => {
editUserById = async (userId: string, patch: Partial<User>) => {
await this.usersRepository.updateById(userId, patch, null);
};
setPasswordByUserId = async (userId: any, password: string) => {
setPasswordByUserId = async (userId: string, password: string) => {
const hashedPassword = this.hashPassword(password);
const updatedUser = await this.usersRepository.updateById(userId, { password: hashedPassword }, null);
return updatedUser;
+1
View File
@@ -27,6 +27,7 @@ export * from "@/service/infrastructure/notificationProviders/INotificationProvi
export * from "@/service/infrastructure/notificationProviders/matrix.js";
export * from "@/service/infrastructure/notificationProviders/pagerduty.js";
export * from "@/service/infrastructure/notificationProviders/slack.js";
export * from "@/service/infrastructure/notificationProviders/teams.js";
export * from "@/service/infrastructure/notificationProviders/webhook.js";
// System services
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more