Compare commits

...
408 Commits
Author SHA1 Message Date
Alex Holliday 22498756cd add remaining zh-tw strings 2026-04-07 09:46:07 -07:00
Alexander Holliday 7c61ca490c Merge pull request #3461 from bluewave-labs/fix/i18n-locale-migration
fix: restructure and translate all i18n locale files
2026-04-03 20:46:13 -07:00
gorkem-bwl 96f9360280 style: format migration script with prettier 2026-04-03 23:43:34 -04:00
gorkem-bwl eb4a92c84f fix: restructure and translate all i18n locale files
All 15 non-English locale files had a structural mismatch with en.json:
- en.json used nested keys (common.buttons.cancel)
- Other locales used flat keys (cancel, DeleteAccountButton)
- Result: 0.3% effective translation coverage at runtime

## Changes

Migration script (client/scripts/migrate-locales.ts):
- Restructures all locale files to match en.json's nested key structure
- Recovers existing translations via safe key-matching strategies
- Backs up originals to locales/backup/

Translation of all 15 languages:
- ar (94.6%), cs (91.6%), de (89.4%), es (92.6%), fi (94.2%)
- fr (86.7%), ja (93.3%), pt-BR (91.0%), ru (93.0%), th (93.4%)
- tr (91.9%), uk (94.3%), vi (92.5%), zh-CN (94.0%), zh-TW (94.2%)

Keys intentionally left in English are technical terms (HTTP, CPU,
Docker, gRPC, etc.), placeholder examples, and brand names.

en.json was NOT modified.
2026-04-03 23:35:14 -04:00
Alexander Holliday 42e82af9bb Merge pull request #3458 from bluewave-labs/feat/scheduler/1.9.1
feat: bump scheduler, consume scheduler events
2026-04-03 15:38:12 -07:00
Alex 7636d2d090 error -> warn on job fail, register listeners before starting scheduler 2026-04-03 15:36:37 -07:00
Alex Holliday 72728748f8 bump scheduler, consume scheduler events 2026-04-03 13:21:16 -07:00
Alexander Holliday 4222d39a8b Merge pull request #3456 from bluewave-labs/fix/job-queue
fix geocheck update logic
2026-04-01 11:04:50 -07:00
Alex Holliday fb69e29adf fix geocheck update logic 2026-04-01 11:00:17 -07:00
Alexander Holliday 0d90083e63 Merge pull request #3455 from mayssagl/develop
Fix outdated links and add Prerequisites section
2026-04-01 10:10:48 -07:00
Alexander Holliday 8ab049aa6c Merge pull request #3447 from bluewave-labs/feat/dlq
Feat(DLQ): Add DLQ data layer -> types, model, and repository
2026-03-27 12:35:31 -07:00
Alexander Holliday 69b6955ced Merge pull request #3439 from LeC-D/fix/telegram-notification-type
feat(notifications): add Telegram notification channel (#2753)
2026-03-27 12:04:01 -07:00
LeC-D 04dfff4ebd fix(notifications): remove redundant defaultValue prop and fix incident URL in telegram provider
- Remove redundant 'defaultValue' prop on Telegram chat ID Controller in create/index.tsx;
  the form already initialises via 'defaultValues: defaults' at the useForm level, making
  the per-Controller prop unnecessary and inconsistent with the reviewer's expectation.
- Fix 'View Incident' URL in telegram.ts: was pointing to the infrastructure details page
  (/infrastructure/:id) instead of the incidents page (/incidents/:id).
2026-03-27 14:19:27 -04:00
Aryaman Sharma 15b0deb0bd Merge branch 'develop' into feat/dlq 2026-03-27 23:24:45 +05:30
Br0wnHammer a895912b99 refactor(dlq): inline DLQItemDocumentBase into DLQItemDocument 2026-03-27 23:23:01 +05:30
Alexander Holliday 22d2626a93 Merge pull request #3441 from bluewave-labs/feat/timescaledb
feat: timescaledb
2026-03-27 10:40:17 -07:00
Br0wnHammer 214fc4899c feat(dlq): add MongoDLQRepository with retry polling and grouped count queries 2026-03-27 20:08:18 +05:30
Br0wnHammer e6088fe06f feat(dlq): add IDLQRepository interface for dead letter queue data access 2026-03-27 20:07:00 +05:30
Br0wnHammer 60b1d95b74 feat(dlq): add DLQItem Mongoose model with indexes for retry polling and cleanup 2026-03-27 20:06:12 +05:30
Br0wnHammer 94ca9896db feat(dlq): add DLQItem type definitions for dead letter queue 2026-03-27 20:05:03 +05:30
LeC-D 13a7ae82e3 fix(notifications): remove type casts with exhaustive type narrowing in useNotificationForm
Replace the loose NotificationDefaults intermediate type and 'as NotificationFormData'
cast with an exhaustive buildDefaults() helper that narrows each branch of the
discriminated union explicitly. TypeScript now infers the return type correctly from
the discriminated union without any casts, satisfying strong typing for all channels
(email, slack, discord, webhook, pager_duty, teams, matrix, telegram).
2026-03-26 14:07:50 -04:00
Alex Holliday de3af64254 migrations, gitignore 2026-03-26 10:57:20 -07:00
Alex Holliday 317d531775 remove comments 2026-03-26 10:44:59 -07:00
Alex Holliday 2b2db2df6f set default all for incident validation 2026-03-26 10:33:19 -07:00
Alex Holliday c558c4b635 use ca tables 2026-03-26 10:30:15 -07:00
Alex Holliday be29cce788 db types 2026-03-26 09:47:10 -07:00
Alex Holliday e87879970a fix TS errors 2026-03-26 09:30:20 -07:00
Leandre Chamberland-Dozois 4c94a7c21e fix: resolve TypeScript build errors in notification form types 2026-03-25 19:13:37 -04:00
LeC-D 40968c370b refactor(notifications): hoist default values, remove else block in useNotificationForm
Per @ajhollid review: define defaults up-front with a NotificationDefaults type
and eliminate the trailing else branch entirely. The if/else-if blocks only
override when the type is matrix or telegram.
2026-03-25 18:06:26 -04:00
LeC-DandClaude Opus 4.6 a864bc0c05 fix: run format and fix build errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:52:48 -04:00
Alex Holliday 62e86434c2 checks repo, barrel export 2026-03-25 13:52:18 -07:00
Alex Holliday 07957d3a1f geo-checks repo 2026-03-25 13:52:10 -07:00
Alex Holliday bc088c3589 add incidents repo, rename old repo 2026-03-25 13:51:57 -07:00
Alex Holliday 5ea86d8e71 add inivtes repo 2026-03-25 13:51:37 -07:00
Alex Holliday 0944dba386 add maintenance-windows repo 2026-03-25 13:51:23 -07:00
Alex Holliday 86acc0c30a add monitor-stats repo 2026-03-25 13:51:12 -07:00
Alex Holliday dcdde845db monitors rep 2026-03-25 13:51:03 -07:00
Alex Holliday 3039fb9b69 nottificaitons repo 2026-03-25 13:50:46 -07:00
Alex Holliday 729600a86f recovery tokens repo 2026-03-25 13:50:33 -07:00
Alex Holliday 51853cb360 add settings repo 2026-03-25 13:50:22 -07:00
Alex Holliday f7db732d67 add status-pages repo 2026-03-25 13:50:12 -07:00
Alex Holliday 9e686a2319 add user repo 2026-03-25 13:49:56 -07:00
Alex Holliday da6f03ba59 add teams repo 2026-03-25 13:49:46 -07:00
Alex Holliday 276b11603e throw error on missing continent 2026-03-25 13:49:13 -07:00
Alex Holliday f09f0630dc update error handler 2026-03-25 13:48:50 -07:00
Alex Holliday 1defa52ae8 update config 2026-03-25 13:48:36 -07:00
Alex Holliday f43b7584a6 add PG dependencies 2026-03-25 13:47:56 -07:00
Alex Holliday ee74584699 Add method to interface 2026-03-25 13:47:36 -07:00
Alex Holliday 2422fcc83b update gitignore 2026-03-25 13:47:14 -07:00
Alex Holliday 0ecc2e21e5 Implemement TimescaleDB class 2026-03-25 13:47:01 -07:00
LeC-D fe242b1977 fix: replace nested ternaries and remove type casts per review
- useNotificationForm: replace nested ternary with if/else if/else block
- TelegramProvider: type error as unknown and access properties safely
  via instanceof checks instead of casting to HTTPError
2026-03-25 14:03:49 -04:00
LeC-D d1f1d31ea2 fix(i18n): revert en.json to upstream format, keep only telegram keys
The previous commit reformatted the entire en.json (spaces → kept as-is
but accumulated unrelated upstream changes), creating a 1.3k line diff.

Restore the file to match upstream/develop exactly and add only the
Telegram-specific i18n keys required by this PR.
2026-03-24 17:22:34 -04:00
Léo eacbb227c3 feat(notifications): add Telegram notification channel (#2753)
Add Telegram as a new notification type, allowing users to receive
monitor alerts via a Telegram bot.

Configuration requires:
- Bot token (obtained from @BotFather)
- Chat ID (user, group, or channel)

Changes:
- TelegramProvider: sends HTML-formatted messages via Telegram Bot API
- NotificationsService: route telegram type to TelegramProvider
- Notification model/types: add 'telegram' to NotificationChannel enum
- Server validation: telegram schema (accessToken + address/chat_id)
- Client Types/Validation: telegram schema with accessToken + address
- useNotificationForm: handle telegram defaults
- Notifications create page: render Telegram config UI
- en.json: add telegram i18n keys
2026-03-24 10:15:46 -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
Mayssaaa gloulou ec129e3604 Fix outdated links and add Prerequisites section 2026-03-23 13:49:08 +01: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
Alex Holliday afd6a12e7d restore PORT env var 2026-03-06 12:07:18 -08:00
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
Alexander Holliday 9fe8b7572f Merge pull request #3374 from bluewave-labs/fix/dark-mode-background-color
Fix dark mode background to use neutral gray
2026-03-06 11:14:21 -08:00
Alex Holliday a424092fbd remove unused imports 2026-03-06 11:12:52 -08:00
Alexander Holliday ad427dc765 Merge pull request #3385 from bluewave-labs/feat/globalping-ping-type
feat: globalping ping type
2026-03-06 11:10:45 -08:00
Alex Holliday 116eb80fc9 format 2026-03-06 11:07:48 -08:00
Alex Holliday c094a5fd73 format 2026-03-06 11:02:20 -08:00
Alex Holliday 206e28d1b9 user helper 2026-03-06 11:01:38 -08:00
Alex Holliday 2f1a164c26 remove rtt 2026-03-06 10:46:00 -08:00
Alex Holliday 124a8c0b07 ping type globalping checks 2026-03-06 10:37:29 -08:00
Alexander Holliday 188445c57a Merge pull request #3384 from bluewave-labs/fix/prettier
fix prettier hook
2026-03-06 09:46:08 -08:00
Alex Holliday 35f018623f fix prettier hook 2026-03-06 09:45:33 -08:00
Alexander Holliday d64526fed2 Merge pull request #3383 from bluewave-labs/fix/providers
fix: providers
2026-03-06 09:39:14 -08:00
Alex Holliday 9a8a175f7c format 2026-03-06 09:37:45 -08:00
Alex Holliday 6ecb27d522 remove buildStatusResponse 2026-03-06 09:35:52 -08:00
Alex Holliday 24b9b64979 manually build response 2026-03-06 09:35:07 -08:00
Alexander Holliday a4e4e1313e Merge pull request #3382 from bluewave-labs/fix/ts-errors
fix: resolve ts errors
2026-03-06 09:19:02 -08:00
Alex Holliday 990dc7875a format 2026-03-06 09:16:22 -08:00
Alex Holliday 69e98a51ec resolve ts errors 2026-03-06 09:14:12 -08: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
Alexander Holliday 3063b9cf22 Merge pull request #3379 from bluewave-labs/fix/network-providers
fix: network providers
2026-03-05 14:52:33 -08:00
Alex Holliday bd32854c7a cleanup services 2026-03-05 14:46:28 -08:00
Alex Holliday 0718e63e2f cleanup network service 2026-03-05 14:11:11 -08:00
Alexander Holliday cdb70f9171 Merge pull request #3378 from bluewave-labs/fix/email
Fix/email
2026-03-05 14:01:03 -08:00
Alex Holliday 69e07975e3 bump dependencies 2026-03-05 13:57:45 -08:00
Alex Holliday c5c9a1a88b fix missing await 2026-03-05 13:57:39 -08:00
Alexander Holliday 31efa791bd Merge pull request #3377 from bluewave-labs/fix/providers
fix/providers
2026-03-05 13:23:27 -08:00
Alex Holliday f8c0b57d21 remove unused imports 2026-03-05 19:37:32 +00:00
Alex Holliday 605c969d2f merge commit 2026-03-05 19:37:03 +00:00
Alex Holliday ad7d4e54c3 cleanup 2026-03-05 19:34:57 +00:00
Alex Holliday c7689cc64a grpc 2026-03-05 19:18:29 +00:00
Alex Holliday 27c44a0fbd config 2026-03-05 18:48:19 +00:00
Alex Holliday 190d9dd2b1 gameProvider 2026-03-05 18:45:27 +00:00
Alex Holliday 49672db563 port provider 2026-03-05 18:35:19 +00:00
Alex Holliday 80603c9a42 port provider 2026-03-05 18:30:06 +00:00
Alex Holliday f3b156f2d0 docker provider 2026-03-05 18:19:03 +00:00
ashu130698 4d7de2d3a1 fix: resolve merge conflicts, migrate validation to zod 2026-03-05 23:40:49 +05:30
Alex Holliday ec82efebb4 pagespeed provider, hardware provider 2026-03-05 17:54:16 +00:00
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
gorkem-bwl a1068f1d7e refactor: move dark background colors to colors object
Addresses review feedback — define gray900 and gray850 in the colors
object and reference them in the dark palette.
2026-03-04 19:56:54 -05:00
Alex Holliday 8dd846d6cd harden http provider 2026-03-04 23:36:36 +00:00
Alex Holliday 82175f166f pingProvider, httpProvider 2026-03-04 23:24:48 +00:00
Alex Holliday 3c99809e8e rix reveresed checks 2026-03-04 23:24:05 +00:00
Alexander Holliday 26db2b3a41 Merge pull request #3371 from bluewave-labs/fix/infrastructure-threshold-migration
Fix infrastructure threshold defaults via migration (#3327)
2026-03-04 14:28:49 -08:00
Alexander Holliday 232a71c502 Merge pull request #3369 from bluewave-labs/fix/swagger-dynamic-server-url
fix: dynamically set Swagger UI server URL from request
2026-03-04 14:28:06 -08:00
gorkem-bwl 8df263fd93 fix: also migrate threshold value of 0 to 100, fix formatting
Addresses review feedback — thresholds set to 0 also caused issues
and should be migrated to 100 (disabled) alongside the value of 5.
2026-03-04 16:44:20 -05:00
gorkem-bwl d2f3335b1a fix: add type annotations to Swagger middleware parameters
Fixes TypeScript build error TS7006 for implicit 'any' types on req, res, next.
2026-03-04 16:32:01 -05:00
gorkem-bwl 75eca743b4 fix: use neutral gray background for dark mode
## Changes
- Replace MUI default blue-tinted dark background with neutral grays
  (#151518 for default, #1c1c21 for paper/cards)
- Remove decorative blue/purple SVG blob overlay from dark mode background

## Benefits
- Dark mode now has a clean, neutral gray appearance instead of a blue tint
2026-03-04 16:30:19 -05:00
Alexander Holliday 7bfd68e374 Merge pull request #3372 from bluewave-labs/fix/ipv6-support
Fix IPv6 address handling in ping monitor (#3053)
2026-03-04 13:00:15 -08:00
gorkem-bwl b2e2b11ef2 fix: support IPv6 addresses in ping monitor
## Changes
- Fixed ping host sanitization regex that stripped everything after
  the first colon, which destroyed IPv6 addresses like 2001:db8::1
- New regex properly handles bracketed IPv6 ([::1]:port), bare IPv6,
  and IPv4/hostname:port formats separately

## Closes
Fixes #3053
2026-03-04 15:41:01 -05:00
gorkem-bwl 93b9abd508 fix: add migration to reset infrastructure thresholds from 5 to 100
## Changes
- Added migration 0004_fixInfrastructureThresholds that finds hardware
  monitors with threshold values of 5 (the old incorrect default) and
  updates them to 100 (effectively disabled)
- Runs automatically on server startup like other migrations

## Closes
Fixes #3327
2026-03-04 15:38:34 -05:00
gorkem-bwl 888b399a4f fix: dynamically set Swagger UI server URL from request
## Changes
- Swagger UI now prepends the current server URL (protocol + host) to
  the servers list, so it works correctly in remote/Docker environments
  instead of always pointing to localhost.

## Closes
Fixes #3339
2026-03-04 15:29:44 -05:00
Alexander Holliday 189293b04d Merge pull request #3366 from bluewave-labs/fix/eslint-warnings
fix: eslint warnings
2026-03-04 10:29:35 -08:00
Alex Holliday 13e9185011 bump version 2026-03-04 18:24:06 +00:00
Alex Holliday a7cf78cecc emailService 2026-03-04 18:16:31 +00:00
Alex Holliday 0b30e23783 globalPing 2026-03-04 17:50:27 +00:00
Alexander Holliday 1155619835 Merge pull request #3365 from bluewave-labs/fix/eslint
fix: eslint
2026-03-04 09:47:51 -08:00
Alex Holliday e0608ad3ed notificationMessageBuilder 2026-03-04 17:43:25 +00:00
Alex Holliday d665e95f02 discord 2026-03-04 17:39:31 +00:00
Alex Holliday afc27c804d email 2026-03-04 17:32:50 +00:00
Alex Holliday 1deb22bf63 pagerduty 2026-03-04 17:17:52 +00:00
Alex Holliday af8b128ac2 notificaiton providers 2026-03-03 23:27:48 +00:00
Alex Holliday 00150ba6f4 checkService, bufferService 2026-03-03 23:19:54 +00:00
Alex Holliday 7f04dd5d9a checksService 2026-03-03 23:09:10 +00:00
Alex Holliday ab5389190d stautsService and settingsService 2026-03-03 23:01:35 +00:00
Alexander Holliday cba9f43032 Merge pull request #3362 from bluewave-labs/fix/types
Fix/types
2026-03-03 13:41:05 -08:00
Alex Holliday 1cd6e723cc netowrk-service 2026-03-03 21:37:08 +00:00
Alex Holliday f79a87f98a resolve remaining TS errors 2026-03-03 19:34:28 +00:00
Alex Holliday a0ea50d745 correct return types 2026-03-03 18:50:10 +00:00
Alex Holliday bfa11216f0 correct payload type for monitor response 2026-03-03 18:29:17 +00:00
Alex Holliday 07a2f36dd7 app error 2026-03-03 18:27:42 +00:00
Alex Holliday 8e44718787 logger, imageProcessing 2026-03-03 18:26:42 +00:00
ashu130698 9e06758997 added a PATCH/notifications/bulk/monitor 2026-03-03 23:28:11 +05:30
Alex Holliday 83572aa12a eslint update 2026-03-03 17:43:57 +00:00
Alexander Holliday 332a21cd99 Merge pull request #3361 from bluewave-labs/fix/logs
replace log statement
2026-03-03 09:20:22 -08:00
Alex Holliday 128d1d12b0 replace log statement 2026-03-02 23:04:20 +00:00
Alexander Holliday f73b07cd3c Merge pull request #3353 from bluewave-labs/feat/swr-global
feat: add global swr config
2026-03-02 14:50:44 -08:00
Alex Holliday a7d14e0e43 add global swr config 2026-03-02 22:37:58 +00:00
Alexander Holliday 6622a990be Merge pull request #3352 from bluewave-labs/fix/env-var-validation
Fix/env var validation
2026-03-02 14:01:35 -08:00
Alex Holliday 3854931682 remove comment 2026-03-02 22:00:03 +00:00
Alex Holliday 09534757a2 validation 2026-03-02 21:52:22 +00:00
Alexander Holliday 7b2ce0e350 Merge pull request #3350 from bluewave-labs/fix/server-validation
fix: server validation
2026-03-02 13:40:56 -08:00
Alexander Holliday 19887332c6 Merge pull request #3349 from bluewave-labs/fix/remove-monitor-swagger-api
fix: removing GET /monitors from swagger docs
2026-03-02 13:35:57 -08:00
Alex Holliday a1858e8302 remove passthrough, remove redudnant refine 2026-03-02 21:35:08 +00:00
Alex Holliday 9fd006cfff remove joi 2026-03-02 19:42:47 +00:00
Alex Holliday 75a7e144cd remove comments 2026-03-02 19:26:20 +00:00
Alex Holliday 16cf23baf1 status page validation 2026-03-02 19:25:34 +00:00
Alex Holliday 8236b02034 remove comments 2026-03-02 19:06:32 +00:00
Alex Holliday 64242322a2 add try/catch block 2026-03-02 18:46:21 +00:00
Alex Holliday c1e03a45b0 settings 2026-03-02 18:03:10 +00:00
Alex Holliday f1466df56c update monitor validation 2026-03-02 17:54:52 +00:00
mannilakash 3654c58469 fix: removing GET /monitors from swagger docs 2026-03-02 12:48:04 -05:00
Alex Holliday b997c43bd5 check, monitor, maintenance 2026-03-02 17:46:33 +00:00
Alex Holliday a5d3c95145 validation refactoring 2026-03-02 17:29:48 +00:00
Gorkem Cetin 003106348a Change demo username in README
Updated demo username in README.
2026-03-02 02:22:46 -05:00
Alex Holliday c3a41272a3 validation refactoring 2026-03-01 17:35:00 +00:00
Alexander Holliday 0bff26fe47 Merge pull request #3346 from bluewave-labs/fix/hardcoded-dimensions
Fix/hardcoded dimensions
2026-03-01 07:19:44 -08:00
Alex Holliday 9169410c1a fallback and histogram 2026-02-28 22:03:30 +00:00
Alex Holliday 9174e66134 sidebar and starprompt 2026-02-28 21:48:42 +00:00
Alex Holliday 04c3c57a59 card-details 2026-02-28 21:27:33 +00:00
Alex Holliday f69b6bbf31 create status page 2026-02-28 21:04:46 +00:00
Alex Holliday 05aa1e7112 authfooter 2026-02-28 20:57:44 +00:00
Alexander Holliday 8aead0a00c Merge pull request #3332 from bluewave-labs/fix/pagespeed-sort
Pagespeed filter sorting fix
2026-02-28 12:51:49 -08:00
Alex Holliday db8ed2994a maintenance create 2026-02-28 20:42:10 +00:00
Alex Holliday 8571ccea78 settings page 2026-02-28 20:25:49 +00:00
Alex Holliday 4d273a6dce table and createMonitor 2026-02-28 16:30:04 +00:00
Alex Holliday 4aabc16096 theme constants 2026-02-28 16:16:14 +00:00
Alexander Holliday 94f9c3d118 Merge pull request #3326 from bluewave-labs/fix/uptime/row-ui
Fix: Uptime Table Body Placement
2026-02-28 08:04:50 -08:00
Alexander Holliday a719364e40 Merge pull request #3344 from bluewave-labs/fix/logging
fix/logging
2026-02-27 18:07:53 -08:00
Alex Holliday 308a439790 apply logger 2026-02-28 01:55:41 +00:00
Alex Holliday 42a001b24e logger 2026-02-28 01:04:35 +00:00
Alexander Holliday f48fc24b9e Merge pull request #3342 from bluewave-labs/fix/remove-on-row-click
remove on row click
2026-02-26 14:22:41 -08:00
Alex Holliday e3057ad039 remove unused import 2026-02-26 22:21:19 +00:00
Alex Holliday 32f41a3cc4 remove unused import 2026-02-26 22:11:32 +00:00
Alex Holliday 177d6f80d0 remove on row click 2026-02-26 22:08:14 +00:00
Alexander Holliday ecef55f1b3 Merge pull request #3341 from bluewave-labs/fix/global-thresholds
Fix/global thresholds
2026-02-26 11:36:21 -08:00
Alex Holliday dede689990 global threshold slider fix 2026-02-26 19:31:12 +00:00
Alex Holliday 85edea713a replace textfields with sliders 2026-02-26 19:07:43 +00:00
Alexander Holliday 7e3da7add7 Merge pull request #3304 from bluewave-labs/feat/grpc-monitoring
Feat: gRPC Monitoring
2026-02-26 09:34:04 -08:00
Alex Holliday dfeba1e4fb resolve conflicts 2026-02-26 17:32:11 +00:00
Alexander Holliday 0442c8dd0f Merge pull request #3336 from bluewave-labs/feat/globalping-map
Feat/globalping map
2026-02-25 14:00:03 -08:00
Alex Holliday 7960f1e82c popup close button 2026-02-25 21:58:48 +00:00
Alexander Holliday f7f1602423 Merge pull request #3335 from bluewave-labs/feat/globalping-map
feat: globalping map
2026-02-25 13:57:12 -08:00
Alex Holliday 78d1451621 hide attribution 2026-02-25 21:49:01 +00:00
Alex Holliday a9f005b2c4 move css styles 2026-02-25 21:44:09 +00:00
Alex Holliday b68214fc47 dark/light mode 2026-02-25 21:20:45 +00:00
Alex Holliday 64c0c93c3e chart 2026-02-25 21:14:17 +00:00
Alexander Holliday 40a1f0c6e0 Merge pull request #3334 from bluewave-labs/feat/global-ping-implementation
remove comment
2026-02-25 12:18:01 -08:00
Alex Holliday 1bb2dd323a remove comment 2026-02-25 20:17:34 +00:00
Alexander Holliday 17238c982e Merge pull request #3333 from bluewave-labs/feat/global-ping-implementation
feat: global ping implementation
2026-02-25 12:17:12 -08:00
Alex Holliday cc1d63f91a remove comment 2026-02-25 20:14:31 +00:00
Alex Holliday ba6e931c59 date range extraction, minor fixes 2026-02-25 20:13:07 +00:00
Alex Holliday 885acf6fe6 all continents in table 2026-02-25 19:34:24 +00:00
Alex Holliday 477c30286d show all checks 2026-02-25 18:43:12 +00:00
Alex Holliday 88e28b9819 cleanup on delete, cleanup orphans 2026-02-24 23:28:27 +00:00
Alex Holliday e7ba3cb5f9 checks route, checks table 2026-02-24 23:20:36 +00:00
Alex Holliday 651fbd7cbb add graph 2026-02-24 22:45:59 +00:00
Alex Holliday 64fdd04cc7 conitnent based query 2026-02-24 22:30:28 +00:00
Alex Holliday c3e4b5b95f ui initial commit 2026-02-24 22:16:02 +00:00
Alex Holliday 10313102ee use correct validation 2026-02-24 21:45:55 +00:00
Alexander Holliday 868cbae66f Merge pull request #3331 from bluewave-labs/fix/uptime-filter-status
Fix: Uptime Status Filter
2026-02-24 13:33:15 -08:00
Alex Holliday 2989968fa1 button string 2026-02-24 13:31:36 -08:00
Alex Holliday 89a7fdbe34 cleanup imports 2026-02-24 19:36:45 +00:00
Alex Holliday 432d1d8ccf remove circular dep 2026-02-24 19:19:32 +00:00
Alex Holliday 5a74411ea2 geo bufer 2026-02-24 18:44:47 +00:00
Alex Holliday 705ecf41c2 rename interface 2026-02-24 18:19:51 +00:00
Alex Holliday 1d4e9d6806 geochecks iniital commit 2026-02-24 18:10:20 +00:00
Alex Holliday 8e37ed7ba2 geo jobs scheduling 2026-02-24 17:55:06 +00:00
Alex Holliday b870f25d1a geo job template 2026-02-24 17:39:09 +00:00
Alex Holliday 7d3abb3f98 map fields 2026-02-24 17:18:22 +00:00
mannilakash 9baa6692dd fix: pagespeed filter sorting fix 2026-02-24 07:45:12 -05:00
Br0wnHammer dd7dfba1cf Fix: Uptime Status Filter 2026-02-24 15:05:27 +05:30
Alex Holliday 858aecce62 fe config box 2026-02-23 18:08:01 +00:00
Alex Holliday 42fdc47d86 initial commit 2026-02-23 17:48:53 +00:00
Br0wnHammer af8f913e62 Fix: Uptime Table Body Placement 2026-02-23 13:38:26 +05:30
Br0wnHammer ea086d6956 Fix: PR comments 2026-02-23 10:25:01 +05:30
Br0wnHammer 22b33d789c Fix: Add grpc in checkData 2026-02-23 10:25:00 +05:30
Br0wnHammer 42e7bc1dbf Feat: Add gRPC to filters 2026-02-23 10:25:00 +05:30
Br0wnHammer 13aa600852 Feat: Add gRPC to CreateMonitor UI 2026-02-23 10:25:00 +05:30
Br0wnHammer c06f81ef76 Feat: Add gRPC to client validation 2026-02-23 10:25:00 +05:30
Br0wnHammer c95db26437 Feat: Update backend Joi validation 2026-02-23 10:25:00 +05:30
Br0wnHammer 94a85f8bbb Feat: Impement requestGrpc in NetworkService 2026-02-23 10:25:00 +05:30
Br0wnHammer 05f8e6a07e Feat: Add GrpcStatus to network and Mongoose schema 2026-02-23 10:25:00 +05:30
Br0wnHammer dac78c7647 Feat: Add gRPC to monitortypes 2026-02-23 10:25:00 +05:30
Alexander Holliday 2d023de568 Merge pull request #3325 from bluewave-labs/feat/pagespeed-date-range
feat: pagespeed date range
2026-02-21 13:09:58 -08:00
Alex Holliday f2c88cfd23 fe 2026-02-21 21:05:51 +00:00
Alex Holliday 910c675a49 aggregate pagespeed 2026-02-21 20:52:47 +00:00
Alex Holliday 882e91f6a7 hook up header 2026-02-21 20:31:56 +00:00
Alexander Holliday 06be65ec52 Merge pull request #3324 from bluewave-labs/fix/import
fix: import
2026-02-21 08:20:57 -08:00
Alex Holliday ea54736a39 remove string 2026-02-21 16:19:43 +00:00
Alex Holliday da38f2ee76 createBulkMonitors -> CreateMonitors 2026-02-21 16:17:23 +00:00
Alex Holliday 83d56f1ff6 fe->be 2026-02-21 16:03:45 +00:00
Alex Holliday f94afb63c6 backend 2026-02-21 03:43:11 +00:00
Alex Holliday 673f47983a FE implementation 2026-02-21 03:14:11 +00:00
Alex Holliday b708e83cce add TODOs 2026-02-20 15:11:37 -08:00
267 changed files with 35833 additions and 20349 deletions

No files matched your search

+5 -1
View File
@@ -3,6 +3,10 @@
.VSCodeCounter
*.sh
mongo
timescaledb
node_modules/
docs/architecture
docs/reviews
docs/reviews
docs/todo
docs/frontend
docs/timescale
+14 -17
View File
@@ -42,15 +42,19 @@ Checkmate has been stress-tested with 1000+ active monitors without any particul
## Demo
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is uptimedemo@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
You can see the latest build of [Checkmate](https://checkmate-demo.bluewavelabs.ca/) in action. The username is demouser@demo.com and the password is Demouser1! (just a note that we update the demo server from time to time, so if it doesn't work for you, please ping us on the Discussions channel).
## 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).
## Prerequisites
- [Docker](https://www.docker.com/) installed
- [Git](https://git-scm.com/) installed
## 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.
@@ -80,7 +84,7 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
- [GitHub Discussions](https://github.com/bluewave-labs/Checkmate/discussions) (we check here from time to time)
Feel free to ask questions or share your ideas - we'd love to hear from you!
@@ -157,27 +161,20 @@ 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" />
</a>
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/bluewave-uptime&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=bluewave-labs/checkmate&type=Date)](https://star-history.com/#bluewave-labs/Checkmate&Date)
## Our sponsors
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.
+690 -123
View File
File diff suppressed because it is too large. Load diff
+6 -3
View File
@@ -13,7 +13,7 @@
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "npm run format"
"**/*": "prettier --write"
},
"dependencies": {
"@emotion/react": "^11.13.3",
@@ -25,6 +25,7 @@
"@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"@types/maplibre-gl": "^1.13.2",
"axios": "^1.7.4",
"dayjs": "1.11.13",
"flag-icons": "7.3.2",
@@ -33,6 +34,7 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"maplibre-gl": "^5.19.0",
"mui-color-input": "^7.0.0",
"pretty-bytes": "^7.1.0",
"pretty-ms": "^9.3.0",
@@ -41,6 +43,7 @@
"react-hook-form": "^7.63.0",
"react-i18next": "^15.4.0",
"react-icons": "5.5.0",
"react-map-gl": "^8.1.0",
"react-redux": "9.2.0",
"react-router": "^6.23.0",
"react-router-dom": "^6.23.1",
@@ -49,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",
@@ -63,6 +66,6 @@
"lint-staged": "^16.2.7",
"prettier": "^3.3.3",
"typescript": "5.9.3",
"vite": "6.3.6"
"vite": "6.4.1"
}
}
+258
View File
@@ -0,0 +1,258 @@
/**
* Locale Migration Script
*
* Restructures all non-English locale files to match en.json's nested key structure.
* Recovers translations via safe key-matching strategies, uses English placeholders
* for unmatched keys.
*
* Usage: npx tsx client/scripts/migrate-locales.ts
*
* SAFETY: en.json is NEVER modified. Originals are backed up before overwriting.
*/
import fs from "fs";
import path from "path";
const LOCALES_DIR = path.resolve(import.meta.dirname, "../src/locales");
const BACKUP_DIR = path.join(LOCALES_DIR, "backup");
const REPORT_PATH = path.join(LOCALES_DIR, "migration-report.json");
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Flatten a nested object into { "a.b.c": value } pairs */
function flattenKeys(obj: Record<string, unknown>, prefix = ""): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
Object.assign(result, flattenKeys(value as Record<string, unknown>, fullPath));
} else {
result[fullPath] = String(value);
}
}
return result;
}
/** Set a value at a dot-separated path in a nested object */
function setNestedValue(
obj: Record<string, unknown>,
keyPath: string,
value: string
): void {
const parts = keyPath.split(".");
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
// ── Build mapping indexes from en.json ───────────────────────────────────────
function buildIndexes(enFlat: Record<string, string>) {
const enKeys = Object.keys(enFlat);
// last segment -> en key paths
const lastSegIndex: Record<string, string[]> = {};
// last 2 segments -> en key paths
const suffixIndex: Record<string, string[]> = {};
for (const keyPath of enKeys) {
const parts = keyPath.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
if (!lastSegIndex[lastSeg]) lastSegIndex[lastSeg] = [];
lastSegIndex[lastSeg].push(keyPath);
if (parts.length >= 2) {
const suffix = parts.slice(-2).join(".").toLowerCase();
if (!suffixIndex[suffix]) suffixIndex[suffix] = [];
suffixIndex[suffix].push(keyPath);
}
}
return { lastSegIndex, suffixIndex };
}
// ── Mapping logic ────────────────────────────────────────────────────────────
type MatchStrategy = "exact" | "suffix-2" | "unique-name" | "english-placeholder";
interface KeyMatch {
enKey: string;
value: string;
strategy: MatchStrategy;
}
function buildMigratedLocale(
enFlat: Record<string, string>,
langFlat: Record<string, string>,
indexes: ReturnType<typeof buildIndexes>
): { matches: KeyMatch[]; result: Record<string, unknown> } {
const { lastSegIndex, suffixIndex } = indexes;
const matches: KeyMatch[] = [];
const result: Record<string, unknown> = {};
// Build reverse lookup: for each orphan key in lang, index by last segment and suffix
const langByLastSeg: Record<string, { key: string; value: string }[]> = {};
const langBySuffix: Record<string, { key: string; value: string }[]> = {};
for (const [langKey, langValue] of Object.entries(langFlat)) {
const parts = langKey.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
if (!langByLastSeg[lastSeg]) langByLastSeg[lastSeg] = [];
langByLastSeg[lastSeg].push({ key: langKey, value: langValue });
if (parts.length >= 2) {
const suffix = parts.slice(-2).join(".").toLowerCase();
if (!langBySuffix[suffix]) langBySuffix[suffix] = [];
langBySuffix[suffix].push({ key: langKey, value: langValue });
}
}
for (const enKey of Object.keys(enFlat)) {
const enValue = enFlat[enKey];
const parts = enKey.split(".");
const lastSeg = parts[parts.length - 1].toLowerCase();
const suffix = parts.length >= 2 ? parts.slice(-2).join(".").toLowerCase() : null;
// Strategy 1: Exact path match
if (langFlat[enKey] !== undefined) {
const value = langFlat[enKey];
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "exact" });
continue;
}
// Strategy 2: 2-segment suffix match (lang key suffix matches en key suffix, both unique)
if (suffix) {
const enCandidates = suffixIndex[suffix];
const langCandidates = langBySuffix[suffix];
if (
enCandidates &&
enCandidates.length === 1 &&
langCandidates &&
langCandidates.length === 1
) {
const value = langCandidates[0].value;
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "suffix-2" });
continue;
}
}
// Strategy 3: Unique last-segment match
const enCandidates = lastSegIndex[lastSeg];
const langCandidates = langByLastSeg[lastSeg];
if (
enCandidates &&
enCandidates.length === 1 &&
langCandidates &&
langCandidates.length === 1
) {
const value = langCandidates[0].value;
setNestedValue(result, enKey, value);
matches.push({ enKey, value, strategy: "unique-name" });
continue;
}
// No match — use English placeholder
setNestedValue(result, enKey, enValue);
matches.push({ enKey, value: enValue, strategy: "english-placeholder" });
}
return { matches, result };
}
// ── Main ─────────────────────────────────────────────────────────────────────
function main() {
// Load en.json
const enPath = path.join(LOCALES_DIR, "en.json");
const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
const enFlat = flattenKeys(en);
const enKeyCount = Object.keys(enFlat).length;
const indexes = buildIndexes(enFlat);
console.log(`English: ${enKeyCount} keys (source of truth — NOT modified)\n`);
// Find all non-English locale files
const localeFiles = fs
.readdirSync(LOCALES_DIR)
.filter(
(f: string) =>
f.endsWith(".json") && f !== "en.json" && f !== "migration-report.json"
);
if (localeFiles.length === 0) {
console.log("No locale files to migrate.");
return;
}
// Create backup directory
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
const report: Record<
string,
{
total: number;
exact: number;
suffix: number;
uniqueName: number;
placeholder: number;
}
> = {};
for (const file of localeFiles) {
const lang = file.replace(".json", "");
const filePath = path.join(LOCALES_DIR, file);
// Backup original
const backupPath = path.join(BACKUP_DIR, file);
fs.copyFileSync(filePath, backupPath);
// Load and flatten
const langData = JSON.parse(fs.readFileSync(filePath, "utf8"));
const langFlat = flattenKeys(langData);
// Migrate
const { matches, result } = buildMigratedLocale(enFlat, langFlat, indexes);
// Write migrated file
fs.writeFileSync(filePath, JSON.stringify(result, null, "\t") + "\n", "utf8");
// Compute stats
const stats = {
total: enKeyCount,
exact: matches.filter((m) => m.strategy === "exact").length,
suffix: matches.filter((m) => m.strategy === "suffix-2").length,
uniqueName: matches.filter((m) => m.strategy === "unique-name").length,
placeholder: matches.filter((m) => m.strategy === "english-placeholder").length,
};
report[lang] = stats;
const translated = stats.exact + stats.suffix + stats.uniqueName;
const pct = ((translated / enKeyCount) * 100).toFixed(1);
console.log(
`${lang.padEnd(8)} ${String(translated).padStart(4)} translated (${pct}%) | ` +
`exact: ${stats.exact} suffix: ${stats.suffix} name: ${stats.uniqueName} ` +
`placeholder: ${stats.placeholder}`
);
}
// Write report
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t") + "\n", "utf8");
const totalLangs = localeFiles.length;
console.log(
`\nMigrated ${totalLangs} locale files. Originals backed up to locales/backup/`
);
console.log(`Report written to ${REPORT_PATH}`);
}
main();
+36 -23
View File
@@ -4,39 +4,52 @@ import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
import { ThemeProvider } from "@emotion/react";
import { CssBaseline, GlobalStyles } from "@mui/material";
import { SWRConfig } from "swr";
import { Routes } from "./Routes";
import AppLayout from "@/Components/layout/AppLayout";
import type { RootState } from "@/Types/state";
import { lightTheme, darkTheme } from "@/Utils/Theme/Theme";
const swrConfig = {
dedupingInterval: 5000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
shouldRetryOnError: false,
errorRetryCount: 2,
errorRetryInterval: 1000,
focusThrottleInterval: 5000,
};
function App() {
const mode = useSelector((state: RootState) => state.ui.mode);
const theme = mode === "light" ? lightTheme : darkTheme;
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<GlobalStyles
styles={{
body: {
backgroundColor: theme.palette.background.default,
},
}}
/>
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer
newestOnTop={true}
theme={mode}
style={
{
"--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1",
} as CSSProperties
}
/>
</ThemeProvider>
<SWRConfig value={swrConfig}>
<ThemeProvider theme={theme}>
<CssBaseline />
<GlobalStyles
styles={{
body: {
backgroundColor: theme.palette.background.default,
},
}}
/>
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer
newestOnTop={true}
theme={mode}
style={
{
"--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1",
} as CSSProperties
}
/>
</ThemeProvider>
</SWRConfig>
);
}
@@ -32,7 +32,7 @@ export const HeatmapResponseTime = ({
if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-25).reverse();
const latestChecks = checks.slice(-25);
let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== 25) {
@@ -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:{" "}
@@ -2,6 +2,7 @@ import { BaseBox } from ".";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { LAYOUT } from "@/Utils/Theme/constants";
import type { ResponsiveStyleValue } from "@mui/system";
import { useTheme } from "@mui/material/styles";
@@ -26,7 +27,7 @@ export const BaseChart = ({
return (
<BaseBox
sx={{
padding: padding ?? theme.spacing(8),
padding: padding ?? theme.spacing(LAYOUT.MD),
display: "flex",
flex: 1,
width: width,
@@ -34,13 +35,13 @@ export const BaseChart = ({
}}
>
<Stack
gap={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)}
flex={1}
>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
>
{icon && (
<BaseBox
@@ -3,6 +3,7 @@ import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import {
ErrorFallback,
EmptyFallback,
@@ -91,7 +92,7 @@ export const BasePage = ({
return (
<Stack
spacing={theme.spacing(10)}
spacing={theme.spacing(LAYOUT.LG)}
{...props}
>
<Breadcrumb breadcrumbOverride={breadcrumbOverride} />
@@ -220,7 +221,7 @@ export const BaseAuthPage = ({
return (
<BasePage
breadcrumbOverride={[]}
gap={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)}
alignItems={"center"}
justifyContent={"center"}
minHeight="100vh"
@@ -229,7 +230,7 @@ export const BaseAuthPage = ({
>
<HeaderAuthControls
hideLogo
py={theme.spacing(4)}
py={theme.spacing(LAYOUT.XS)}
position={"absolute"}
top={0}
left={0}
@@ -243,14 +244,14 @@ export const BaseAuthPage = ({
<Stack alignItems={"center"}>
<Typography
variant="h1"
mb={theme.spacing(2)}
mb={theme.spacing(SPACING.LG)}
>
{title}
</Typography>
<Typography variant="h2">{subtitle}</Typography>
</Stack>
<Stack
gap={theme.spacing(8)}
gap={theme.spacing(LAYOUT.MD)}
width={{
xs: "80%",
md: "25%",
@@ -2,6 +2,7 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Check } from "lucide-react";
import { useTheme } from "@mui/material/styles";
import { LAYOUT } from "@/Utils/Theme/constants";
export const BulletPointCheck = ({
text,
@@ -21,7 +22,7 @@ export const BulletPointCheck = ({
return (
<Stack
direction="row"
gap={theme.spacing(6)}
gap={theme.spacing(LAYOUT.SM)}
alignItems="center"
>
<Check
@@ -2,6 +2,7 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BulletPointCheck, SkeletonCard } from "@/Components/design-elements";
import { Button } from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useNavigate } from "react-router";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -27,7 +28,7 @@ export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
lg: "50%",
xl: "40%",
}}
padding={{ xs: theme.spacing(8), md: theme.spacing(16) }}
padding={{ xs: theme.spacing(LAYOUT.MD), md: theme.spacing(LAYOUT.XXL) }}
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
@@ -55,7 +56,7 @@ export const ErrorFallback = ({
<BaseFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
marginY={theme.spacing(LAYOUT.XS)}
color={theme.palette.text.secondary}
>
{title}
@@ -81,7 +82,7 @@ export const EmptyFallback = ({
return (
<BaseFallback>
<Stack
gap={theme.spacing(10)}
gap={theme.spacing(LAYOUT.MD)}
zIndex={1}
alignItems="center"
>
@@ -89,7 +90,7 @@ export const EmptyFallback = ({
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(2),
gap: theme.spacing(SPACING.MD),
maxWidth: "1100px",
width: "100%",
}}
@@ -134,7 +135,7 @@ export const EmptyMonitorFallback = ({
return (
<BaseFallback>
<Stack
gap={theme.spacing(10)}
gap={theme.spacing(LAYOUT.MD)}
zIndex={1}
alignItems="center"
>
@@ -147,7 +148,7 @@ export const EmptyMonitorFallback = ({
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(2),
gap: theme.spacing(SPACING.MD),
maxWidth: "1100px",
width: "100%",
}}
@@ -3,6 +3,7 @@ import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import { LAYOUT } from "@/Utils/Theme/constants";
export const SplitBox = ({
left,
right,
@@ -18,10 +19,10 @@ export const SplitBox = ({
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={theme.spacing(2)}
borderRadius={theme.shape.borderRadius}
>
<Box
padding={theme.spacing(15)}
padding={theme.spacing(LAYOUT.XXL)}
borderRight={isSmall ? 0 : 1}
borderBottom={isSmall ? 1 : 0}
borderColor={theme.palette.divider}
@@ -37,7 +38,7 @@ export const SplitBox = ({
</Box>
<Box
flex={1}
padding={theme.spacing(15)}
padding={theme.spacing(LAYOUT.XXL)}
>
{right}
</Box>
@@ -60,7 +61,7 @@ export const ConfigBox = ({
return (
<SplitBox
left={
<Stack spacing={theme.spacing(4)}>
<Stack spacing={theme.spacing(LAYOUT.XS)}>
<Typography
textTransform={"capitalize"}
component="h2"
+11 -10
View File
@@ -29,6 +29,7 @@ import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
import { useTranslation } from "react-i18next";
import { useState, Fragment, useEffect } from "react";
import { useTheme } from "@mui/material/styles";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import type { SxProps, Theme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
@@ -90,20 +91,20 @@ export function DataTable<
// Return stack of cards for small screens
if (isSmall && cardsOnSmallScreens) {
return (
<Stack spacing={theme.spacing(4)}>
<Stack spacing={theme.spacing(LAYOUT.XS)}>
{data.map((row) => {
const key = row.id || row._id || Math.random();
keys.push(key);
return (
<Stack
onClick={() => (onRowClick ? onRowClick(row) : null)}
spacing={theme.spacing(4)}
spacing={theme.spacing(LAYOUT.XS)}
sx={{
borderStyle: "solid",
borderWidth: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(4),
padding: theme.spacing(LAYOUT.XS),
cursor: onRowClick ? "pointer" : "default",
}}
key={key}
@@ -155,7 +156,7 @@ export function DataTable<
<Table
sx={{
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
paddingLeft: theme.spacing(8),
paddingLeft: theme.spacing(LAYOUT.MD),
},
"& .MuiTableCell-root": {
borderBottom: `1px solid ${theme.palette.divider}`,
@@ -168,13 +169,13 @@ export function DataTable<
color: theme.palette.text.secondary,
fontWeight: 500,
textTransform: "uppercase",
padding: `${theme.spacing(2)} ${theme.spacing(8)}`,
padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`,
fontSize: theme.typography.fontSize,
},
"& :is(td)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
padding: `${theme.spacing(7)} ${theme.spacing(8)}`,
padding: `${theme.spacing(LAYOUT.MD)} ${theme.spacing(LAYOUT.MD)}`,
fontSize: theme.typography.fontSize,
overflowX: "hidden",
},
@@ -514,7 +515,7 @@ export const Pagination = ({ ...props }: PaginationProps) => {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridAutoRows: "auto",
rowGap: theme.spacing(1),
rowGap: theme.spacing(SPACING.SM),
alignItems: "center",
justifyItems: "center",
paddingLeft: 0,
@@ -556,8 +557,8 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
alignItems={"center"}
justifyContent={"center"}
sx={{
py: theme.spacing(12),
px: theme.spacing(4),
py: theme.spacing(LAYOUT.XL),
px: theme.spacing(LAYOUT.XS),
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
@@ -574,7 +575,7 @@ const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) =>
display: "flex",
alignItems: "center",
justifyContent: "center",
mb: theme.spacing(2),
mb: theme.spacing(SPACING.LG),
}}
>
<Icon
+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}
/>
@@ -1,9 +1,6 @@
import Box from "@mui/material/Box";
import { useState, useEffect, useRef } from "react";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import BackgroundSVG from "@/assets/Images/background.svg";
import type { RootState } from "@/Types/state";
import { OfflineBanner } from "@/Components/design-elements";
import { setServerUnreachableCallback, get } from "@/Utils/ApiClient";
@@ -13,7 +10,6 @@ interface AppLayoutProps {
const AppLayout = ({ children }: AppLayoutProps) => {
const theme = useTheme();
const mode = useSelector((state: RootState) => state.ui.mode);
const [serverUnreachable, setServerUnreachable] = useState(false);
const retryIntervalRef = useRef<number | null>(null);
@@ -47,10 +43,6 @@ const AppLayout = ({ children }: AppLayoutProps) => {
sx={{
minHeight: "100vh",
backgroundColor: theme.palette.background.default,
backgroundImage: mode === "dark" ? `url("${BackgroundSVG}")` : "none",
backgroundSize: "100% 100%",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<OfflineBanner visible={serverUnreachable} />
@@ -5,8 +5,18 @@ import useMediaQuery from "@mui/material/useMediaQuery";
import type { MonitorType } from "@/Types/Monitor";
import { Typography, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
const types = ["http", "ping", "port", "docker", "game"];
const types = ["http", "ping", "port", "docker", "game", "grpc", "websocket"];
const typeDisplayNames: Record<string, string> = {
http: "HTTP",
ping: "Ping",
port: "Port",
docker: "Docker",
game: "Game",
grpc: "gRPC",
websocket: "WebSocket",
};
const statuses = ["up", "down"];
const states = ["active", "paused"];
@@ -30,6 +40,7 @@ export const ControlsFilter = ({
onClearFilters: () => void;
}) => {
const theme = useTheme();
const { t } = useTranslation();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
(selectedTypes?.length ?? 0) > 0 || selectedStatus !== "" || selectedState !== "";
@@ -50,7 +61,7 @@ export const ControlsFilter = ({
key={type}
value={type}
>
<Typography textTransform={"capitalize"}>{type}</Typography>
<Typography>{typeDisplayNames[type] ?? type}</Typography>
</MenuItem>
))}
</Select>
@@ -88,7 +99,7 @@ export const ControlsFilter = ({
variant="contained"
onClick={onClearFilters}
>
Clear Filters
{t("common.buttons.clearFilters")}
</Button>
)}
</Stack>
@@ -0,0 +1,203 @@
import { useRef, useEffect, useState } from "react";
import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material";
import Map, { Marker, Popup } from "react-map-gl/maplibre";
import type { MapRef } from "react-map-gl/maplibre";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import "maplibre-gl/dist/maplibre-gl.css";
interface GeoChecksMapProps {
geoChecks: FlatGeoCheck[];
}
export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
const mapRef = useRef<MapRef>(null);
const [selectedCheck, setSelectedCheck] = useState<FlatGeoCheck | null>(null);
const theme = useTheme();
const isDarkMode = theme.palette.mode === "dark";
const mapPopupStyles = (
<GlobalStyles
styles={{
".maplibregl-popup-content": {
background: "transparent !important",
padding: "0 !important",
boxShadow: "none !important",
},
".maplibregl-popup-tip": {
display: "none",
},
".maplibregl-ctrl-attrib": {
display: "none",
},
".maplibregl-ctrl-logo": {
display: "none",
},
".maplibregl-popup-close-button": {
color: theme.palette.text.primary,
background: "transparent",
fontSize: "20px",
padding: "4px",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
}}
/>
);
useEffect(() => {
if (geoChecks.length === 0 || !mapRef.current) return;
const bounds = geoChecks.reduce(
(acc, check) => {
return {
minLng: Math.min(acc.minLng, check.location.longitude),
maxLng: Math.max(acc.maxLng, check.location.longitude),
minLat: Math.min(acc.minLat, check.location.latitude),
maxLat: Math.max(acc.maxLat, check.location.latitude),
};
},
{
minLng: Infinity,
maxLng: -Infinity,
minLat: Infinity,
maxLat: -Infinity,
}
);
if (bounds.minLng !== Infinity) {
mapRef.current.fitBounds(
[
[bounds.minLng, bounds.minLat],
[bounds.maxLng, bounds.maxLat],
],
{ padding: 50, duration: 1000 }
);
}
}, [geoChecks]);
const getMarkerColor = (status: boolean): string => {
return status ? theme.palette.success.main : theme.palette.error.main;
};
const formatResponseTime = (timing: number): string => {
return `${timing.toFixed(0)}ms`;
};
return (
<>
{mapPopupStyles}
<Box
height={500}
width={"100%"}
borderRadius={theme.shape.borderRadius}
overflow={"hidden"}
>
<Map
ref={mapRef}
initialViewState={{
longitude: 0,
latitude: 20,
zoom: 1.5,
}}
style={{ height: "100%", width: "100%" }}
mapStyle={
isDarkMode
? "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
}
>
{geoChecks.map((check, index) => (
<Marker
key={`${check.id}-${index}`}
longitude={check.location.longitude}
latitude={check.location.latitude}
anchor="bottom"
onClick={(e: any) => {
e.originalEvent.stopPropagation();
setSelectedCheck(check);
}}
>
<div
style={{
width: "10px",
height: "10px",
borderRadius: "50%",
backgroundColor: getMarkerColor(check.status),
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
cursor: "pointer",
}}
/>
</Marker>
))}
{selectedCheck && (
<Popup
longitude={selectedCheck.location.longitude}
latitude={selectedCheck.location.latitude}
anchor="top"
onClose={() => setSelectedCheck(null)}
closeOnClick={false}
>
<Box
sx={{
minWidth: 200,
p: theme.spacing(4),
bgcolor: theme.palette.background.paper,
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: 1,
}}
>
<Typography
variant="subtitle1"
fontWeight="bold"
gutterBottom
>
{selectedCheck.location.city}, {selectedCheck.location.country}
</Typography>
<Stack spacing={0.5}>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status:
</Typography>{" "}
{selectedCheck.status ? "Up" : "Down"}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Status Code:
</Typography>{" "}
{selectedCheck.statusCode}
</Typography>
<Typography variant="body2">
<Typography
component="span"
fontWeight="medium"
>
Response Time:
</Typography>{" "}
{formatResponseTime(selectedCheck.timings.total)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ mt: 0.5 }}
>
{new Date(selectedCheck.createdAt).toLocaleString()}
</Typography>
</Stack>
</Box>
</Popup>
)}
</Map>
</Box>
</>
);
};
@@ -0,0 +1,54 @@
import Stack from "@mui/material/Stack";
import { Tabs, Tab } from "@/Components/design-elements";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { GeoContinent } from "@/Types/GeoCheck";
interface HeaderGeoTabsProps {
geoCheckEnabled: boolean;
locations: GeoContinent[] | undefined;
selectedLocation: GeoContinent;
onLocationChange: (location: GeoContinent) => void;
}
export const HeaderGeoTabs = ({
geoCheckEnabled,
locations,
selectedLocation,
onLocationChange,
}: HeaderGeoTabsProps) => {
const { t } = useTranslation();
const theme = useTheme();
if (!geoCheckEnabled || !locations || locations.length === 0) {
return null;
}
const handleChange = (_event: React.SyntheticEvent, newValue: GeoContinent) => {
onLocationChange(newValue);
};
return (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"flex-start"}
>
<Tabs
value={selectedLocation}
onChange={handleChange}
>
{locations.map((location) => (
<Tab
key={location}
label={t(
`pages.createMonitor.form.geoChecks.option.locations.options.${location}`
)}
value={location}
/>
))}
</Tabs>
</Stack>
);
};
@@ -12,7 +12,7 @@ import {
import { HistogramPageSpeedScoresTooltip } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles";
import type { CheckSnapshot } from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { Palette } from "@mui/material/styles";
type PaletteColorKey = Extract<
keyof Palette,
@@ -52,7 +52,7 @@ export const HistogramPageSpeedDetails = ({
checks,
range,
}: {
checks: CheckSnapshot[];
checks: PageSpeedGroupedCheck[];
range: string;
}) => {
const theme = useTheme();
@@ -73,7 +73,7 @@ export const HistogramPageSpeedDetails = ({
>
<AreaChart data={checks}>
<XAxis
dataKey={"createdAt"}
dataKey={"bucketDate"}
tick={(props) => (
<XTick
{...props}
@@ -2,6 +2,7 @@ import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { ConfigItem } from "@/Components/monitors";
import type { TooltipProps } from "recharts";
@@ -30,8 +31,8 @@ export const HistogramPageSpeedScoresTooltip = ({
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
py: theme.spacing(SPACING.LG),
px: theme.spacing(LAYOUT.XS),
}}
>
<Typography
@@ -54,12 +55,12 @@ export const HistogramPageSpeedScoresTooltip = ({
key={`${key}-tooltip`}
direction="row"
alignItems="center"
gap={theme.spacing(3)}
mt={theme.spacing(1)}
gap={theme.spacing(SPACING.XXL)}
mt={theme.spacing(SPACING.SM)}
>
<Box
width={theme.spacing(4)}
height={theme.spacing(4)}
width={theme.spacing(LAYOUT.XS)}
height={theme.spacing(LAYOUT.XS)}
sx={{ borderRadius: "50%", backgroundColor: dotColor }}
/>
<Typography
+2
View File
@@ -1,6 +1,8 @@
export * from "./ControlsFilter";
export * from "./MonitorStatBoxes";
export * from "./HeaderMonitorControls";
export * from "./HeaderGeoTabs";
export * from "./GeoChecksMap";
export * from "./charts/HistogramStatus";
export * from "./charts/RadialAvgResponse";
export * from "./charts/HistogramDetails";
+14 -8
View File
@@ -5,6 +5,7 @@ import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { useTheme } from "@mui/material";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useSelector, useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useState } from "react";
@@ -64,9 +65,9 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
});
const menuItemSx = {
gap: theme.spacing(2),
gap: theme.spacing(LAYOUT.MD),
borderRadius: theme.shape.borderRadius,
pl: theme.spacing(4),
pl: theme.spacing(LAYOUT.XS),
"& svg": { stroke: theme.palette.text.secondary },
};
@@ -74,9 +75,9 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Stack
direction="row"
alignItems="center"
py={theme.spacing(4)}
px={theme.spacing(8)}
gap={theme.spacing(4)}
py={theme.spacing(LAYOUT.XS)}
px={theme.spacing(LAYOUT.MD)}
gap={theme.spacing(LAYOUT.MD)}
>
<Avatar
small
@@ -88,7 +89,7 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
gap={theme.spacing(SPACING.MD)}
minWidth={0}
flex={1}
>
@@ -132,10 +133,15 @@ export const AuthFooter = ({ collapsed, accountMenuItems }: AuthFooterProps) =>
disableScrollLock
anchorOrigin={{ vertical: "top", horizontal: "right" }}
slotProps={{
paper: { sx: { mt: theme.spacing(-4), ml: collapsed ? theme.spacing(2) : 0 } },
paper: {
sx: {
mt: theme.spacing(-LAYOUT.XS),
ml: collapsed ? theme.spacing(SPACING.LG) : 0,
},
},
}}
MenuListProps={{ sx: { p: 2, "& li": { m: 0 } } }}
sx={{ ml: theme.spacing(4) }}
sx={{ ml: theme.spacing(LAYOUT.XS) }}
>
{collapsed && (
<Box
+10 -12
View File
@@ -1,6 +1,7 @@
import { Typography, IconButton, Stack, Box } from "@mui/material";
import { Icon } from "@/Components/design-elements";
import { X } from "lucide-react";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useTheme } from "@mui/material";
import { useSelector, useDispatch } from "react-redux";
@@ -34,25 +35,22 @@ export const StarPrompt = ({
return (
<Stack
direction="column"
sx={{
width: "100%",
padding: `${theme.spacing(6)} ${theme.spacing(6)}`,
borderTop: `1px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`,
borderRadius: 0,
gap: theme.spacing(1.5),
}}
gap={theme.spacing(LAYOUT.MD)}
padding={theme.spacing(LAYOUT.SM)}
width={"100%"}
borderTop={`1px solid ${theme.palette.divider}`}
borderBottom={`1px solid ${theme.palette.divider}`}
>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
width="100%"
pl={theme.spacing(4)}
pl={theme.spacing(LAYOUT.XS)}
>
<Typography
variant="subtitle2"
mt={theme.spacing(3)}
mt={theme.spacing(SPACING.XXL)}
>
{t("components.sidebar.starPrompt.title")}
</Typography>
@@ -78,7 +76,7 @@ export const StarPrompt = ({
<Typography
sx={{
mb: 1,
px: theme.spacing(4),
px: theme.spacing(LAYOUT.XS),
}}
>
{t("components.sidebar.starPrompt.description")}
@@ -96,7 +94,7 @@ export const StarPrompt = ({
"&:hover": {
opacity: 0.8,
},
pl: theme.spacing(4),
pl: theme.spacing(LAYOUT.XS),
filter: mode === "dark" ? "invert(1)" : "none",
}}
/>
+8 -7
View File
@@ -3,6 +3,7 @@ import Backdrop from "@mui/material/Backdrop";
import Stack from "@mui/material/Stack";
import List from "@mui/material/List";
import Divider from "@mui/material/Divider";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useSidebar } from "@/Hooks/useSidebar.js";
import { Logo } from "@/Components/sidebar/Logo";
import { getMenu, getBottomMenu, getAccountMenu } from "@/Components/sidebar/Menu";
@@ -65,9 +66,9 @@ export const Sidebar = () => {
left={0}
minHeight={"100vh"}
maxHeight={"100vh"}
paddingTop={theme.spacing(6)}
paddingBottom={theme.spacing(6)}
gap={theme.spacing(6)}
paddingTop={theme.spacing(LAYOUT.SM)}
paddingBottom={theme.spacing(LAYOUT.SM)}
gap={theme.spacing(LAYOUT.SM)}
borderRight={`1px solid ${theme.palette.divider}`}
width={width}
sx={{
@@ -81,13 +82,13 @@ export const Sidebar = () => {
component="nav"
disablePadding
sx={{
px: theme.spacing(6),
px: theme.spacing(LAYOUT.SM),
flex: 1,
}}
>
<Logo
pt={theme.spacing(8)}
pb={theme.spacing(10)}
pt={theme.spacing(LAYOUT.MD)}
pb={theme.spacing(LAYOUT.MD)}
/>
{menu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
@@ -105,7 +106,7 @@ export const Sidebar = () => {
<List
component="nav"
disablePadding
sx={{ px: theme.spacing(6) }}
sx={{ px: theme.spacing(LAYOUT.SM) }}
>
{bottomMenu.map((item) => {
const selected = location.pathname.startsWith(`/${item.path}`);
+7 -6
View File
@@ -4,6 +4,7 @@ import type { AxiosRequestConfig } from "axios";
import { get, patch, post, put, deleteOp } from "@/Utils/ApiClient";
import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
import { logger } from "@/Utils/logger";
interface ApiResponse<T> {
success: boolean;
@@ -17,7 +18,7 @@ const fetcher = async <T>(url: string, config?: AxiosRequestConfig) => {
};
export const useGet = <T>(
url: string | null,
url: string | null | undefined,
axiosConfig?: AxiosRequestConfig,
swrConfig?: SWRConfiguration
) => {
@@ -61,7 +62,7 @@ export const usePost = <B = any, R = any>() => {
return res.data;
} catch (err: any) {
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
console.error(err);
logger.error("POST request failed", err, { endpoint, body });
toastError(errMsg);
setError(errMsg);
return null;
@@ -96,7 +97,7 @@ export const usePatch = <B = any, R = any>() => {
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
logger.error("PATCH request failed", err, { endpoint, body });
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
@@ -132,7 +133,7 @@ export const usePut = <B = any, R = any>() => {
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
logger.error("PUT request failed", err, { endpoint, body });
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
@@ -167,7 +168,7 @@ export const useDelete = <R = any>() => {
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
logger.error("DELETE request failed", err, { endpoint });
const errMsg = err?.response?.data?.message || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
@@ -201,7 +202,7 @@ export const useLazyGet = <R = any>() => {
});
return res.data;
} catch (err: any) {
console.error(err);
logger.error("GET request failed", err, { endpoint });
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
+21
View File
@@ -14,6 +14,9 @@ const getBaseDefaults = (data?: Monitor | null) => ({
notifications: data?.notifications || [],
statusWindowSize: data?.statusWindowSize || 5,
statusWindowThreshold: data?.statusWindowThreshold || 60,
geoCheckEnabled: data?.geoCheckEnabled ?? false,
geoCheckLocations: data?.geoCheckLocations || [],
geoCheckInterval: data?.geoCheckInterval || 300000,
});
export const useMonitorForm = ({
@@ -70,6 +73,16 @@ export const useMonitorForm = ({
gameId: data?.gameId || "",
};
break;
case "grpc":
defaults = {
...base,
type: "grpc",
url: data?.url || "",
port: data?.port || 50051,
grpcServiceName: data?.grpcServiceName || "",
ignoreTlsErrors: data?.ignoreTlsErrors || false,
};
break;
case "pagespeed":
defaults = {
...base,
@@ -90,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,
+63 -15
View File
@@ -1,28 +1,76 @@
import { useMemo } from "react";
import { notificationSchema } from "@/Validation/notifications";
import type { NotificationFormData } from "@/Validation/notifications";
import type { Notification } from "@/Types/Notification";
interface UseNotificationFormOptions {
data?: Notification | null;
}
function buildDefaults(data: Notification | null): NotificationFormData {
if (data?.type === "matrix") {
return {
type: "matrix",
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "telegram") {
return {
type: "telegram",
notificationName: data.notificationName || "",
address: data.address || "",
accessToken: data.accessToken || "",
};
}
if (data?.type === "slack") {
return {
type: "slack",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "discord") {
return {
type: "discord",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "webhook") {
return {
type: "webhook",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "pager_duty") {
return {
type: "pager_duty",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
if (data?.type === "teams") {
return {
type: "teams",
notificationName: data.notificationName || "",
address: data.address || "",
};
}
// Default: email (covers both data === null and data.type === "email")
return {
type: "email",
notificationName: data?.notificationName || "",
address: data?.address || "",
};
}
export const useNotificationForm = ({ data = null }: UseNotificationFormOptions = {}) => {
return useMemo(() => {
const defaults =
data?.type === "matrix"
? {
type: "matrix" as const,
notificationName: data.notificationName || "",
homeserverUrl: data.homeserverUrl || "",
roomId: data.roomId || "",
accessToken: data.accessToken || "",
}
: {
type: (data?.type || "email") as Exclude<Notification["type"], "matrix">,
notificationName: data?.notificationName || "",
address: data?.address || "",
};
const defaults = buildDefaults(data);
return { schema: notificationSchema, defaults };
}, [data]);
};
+18 -6
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,
@@ -22,10 +22,22 @@ export const useSettingsForm = ({ data = null }: UseSettingsFormOptions = {}) =>
systemEmailTLSServername: data?.systemEmailTLSServername || "",
systemEmailPort: data?.systemEmailPort,
globalThresholds: {
cpu: data?.globalThresholds?.cpu ?? 0,
memory: data?.globalThresholds?.memory ?? 0,
disk: data?.globalThresholds?.disk ?? 0,
temperature: data?.globalThresholds?.temperature ?? 0,
cpu:
data?.globalThresholds?.cpu && data.globalThresholds.cpu >= 1
? data.globalThresholds.cpu
: 80,
memory:
data?.globalThresholds?.memory && data.globalThresholds.memory >= 1
? data.globalThresholds.memory
: 80,
disk:
data?.globalThresholds?.disk && data.globalThresholds.disk >= 1
? data.globalThresholds.disk
: 80,
temperature:
data?.globalThresholds?.temperature && data.globalThresholds.temperature >= 1
? data.globalThresholds.temperature
: 80,
},
checkTTL: data?.checkTTL ?? 30,
pagespeedApiKey: "",
+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}
/>
);
};
@@ -5,7 +5,6 @@ import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { useNavigate } from "react-router";
import type { Check } from "@/Types/Check";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
@@ -29,7 +28,6 @@ export const ChecksTable = ({
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const navigate = useNavigate();
const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header<Check>[] = [
@@ -109,9 +107,6 @@ export const ChecksTable = ({
<Table
headers={headers}
data={checks}
onRowClick={(row) => {
navigate(`/checks/${row.id}`);
}}
emptyViewText={t("pages.checks.table.empty")}
/>
<Pagination
+238 -17
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { useEffect } from "react";
import { logger } from "@/Utils/logger";
import { useParams, useLocation, useNavigate } from "react-router";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -15,6 +16,7 @@ import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import { Trash2 } from "lucide-react";
import { HeaderDeleteControls } from "@/Components/monitors";
import { GeoContinents } from "@/Types/GeoCheck";
import { BasePage, ConfigBox } from "@/Components/design-elements";
import {
@@ -27,9 +29,15 @@ import {
SliderWithLabel,
Dialog,
} from "@/Components/inputs";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi";
import { useMonitorForm } from "@/Hooks/useMonitorForm";
import type { Monitor, MonitorType, GamesMap } from "@/Types/Monitor";
import {
type Monitor,
type MonitorType,
type GamesMap,
supportsGeoCheck,
} from "@/Types/Monitor";
import type { Notification } from "@/Types/Notification";
import type { MonitorFormData } from "@/Validation/monitor";
@@ -41,6 +49,8 @@ interface GeneralSettingsConfig {
showPort: boolean;
showGameSelect: boolean;
showSecret: boolean;
showGrpcServiceName: boolean;
showIgnoreTls: boolean;
}
const getGeneralSettingsConfig = (
@@ -56,6 +66,8 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
ping: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
@@ -65,6 +77,8 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
docker: {
urlLabel: t("pages.createMonitor.form.general.option.container.label"),
@@ -74,6 +88,8 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
port: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -83,6 +99,8 @@ const getGeneralSettingsConfig = (
showPort: true,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
game: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -92,6 +110,19 @@ const getGeneralSettingsConfig = (
showPort: true,
showGameSelect: true,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
grpc: {
urlLabel: t("pages.createMonitor.form.general.option.host.label"),
urlPlaceholder: t("pages.createMonitor.form.general.option.host.placeholder"),
namePlaceholder: t("pages.createMonitor.form.general.option.name.placeholder"),
showUrl: true,
showPort: true,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: true,
showIgnoreTls: true,
},
pagespeed: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -101,6 +132,8 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: false,
showGrpcServiceName: false,
showIgnoreTls: false,
},
hardware: {
urlLabel: t("pages.createMonitor.form.general.option.url.label"),
@@ -110,6 +143,19 @@ const getGeneralSettingsConfig = (
showPort: false,
showGameSelect: false,
showSecret: true,
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;
@@ -165,6 +211,7 @@ const CreateMonitorPage = () => {
const watchedType = watch("type") as MonitorType;
const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean;
const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean;
useEffect(() => {
clearErrors();
@@ -224,7 +271,7 @@ const CreateMonitorPage = () => {
};
const onError = (errors: unknown) => {
console.log(errors);
logger.debug("Monitor creation validation errors", errors);
};
return (
@@ -251,43 +298,57 @@ const CreateMonitorPage = () => {
<FormControl error={!!fieldState.error}>
<RadioGroup
{...field}
sx={{ gap: theme.spacing(6) }}
sx={{ gap: theme.spacing(LAYOUT.MD) }}
>
<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.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>
)}
@@ -300,7 +361,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.general.title")}
subtitle={t(`pages.createMonitor.form.general.description.${watchedType}`)}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
{/* URL/Host/Container field - not shown for hardware */}
{generalSettingsConfig.showUrl && (
<Controller
@@ -376,6 +437,30 @@ const CreateMonitorPage = () => {
/>
)}
{/* gRPC Service Name field - only for grpc type */}
{generalSettingsConfig.showGrpcServiceName && (
<Controller
name="grpcServiceName"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
value={field.value ?? ""}
type="text"
fieldLabel={t(
"pages.createMonitor.form.general.option.grpcServiceName.label"
)}
placeholder={t(
"pages.createMonitor.form.general.option.grpcServiceName.placeholder"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
)}
{/* Secret field - only for hardware type */}
{generalSettingsConfig.showSecret && (
<Controller
@@ -495,7 +580,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.thresholds.title")}
subtitle={t("pages.createMonitor.form.thresholds.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="cpuAlertThreshold"
control={control}
@@ -577,7 +662,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.incidents.title")}
subtitle={t("pages.createMonitor.form.incidents.description")}
rightContent={
<Stack spacing={theme.spacing(12)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="statusWindowSize"
control={control}
@@ -629,7 +714,7 @@ const CreateMonitorPage = () => {
(field.value ?? []).includes(n.id)
);
return (
<Stack spacing={theme.spacing(4)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={notificationOptions}
@@ -680,7 +765,9 @@ const CreateMonitorPage = () => {
}
/>
{watchedType === "http" && (
{(watchedType === "http" ||
watchedType === "grpc" ||
watchedType === "websocket") && (
<ConfigBox
title={t("pages.createMonitor.form.ignoreTls.title")}
subtitle={t("pages.createMonitor.form.ignoreTls.description")}
@@ -692,7 +779,7 @@ const CreateMonitorPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
@@ -713,7 +800,7 @@ const CreateMonitorPage = () => {
title={t("pages.createMonitor.form.advanced.title")}
subtitle={t("pages.createMonitor.form.advanced.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="useAdvancedMatching"
control={control}
@@ -721,7 +808,7 @@ const CreateMonitorPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
@@ -736,7 +823,7 @@ const CreateMonitorPage = () => {
)}
/>
{watchedUseAdvancedMatching && (
<Stack spacing={theme.spacing(8)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="matchMethod"
control={control}
@@ -823,6 +910,140 @@ const CreateMonitorPage = () => {
/>
)}
{supportsGeoCheck(watchedType) && (
<ConfigBox
title={t("pages.createMonitor.form.geoChecks.title")}
subtitle={t("pages.createMonitor.form.geoChecks.description")}
rightContent={
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="geoCheckEnabled"
control={control}
render={({ field }) => (
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(SPACING.LG)}
>
<Switch
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
<Typography>
{t("pages.createMonitor.form.geoChecks.option.enabled.label")}
</Typography>
</Stack>
)}
/>
{watchGeoCheckEnabled && (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="geoCheckLocations"
control={control}
render={({ field }) => {
// Map continents to have 'name' property for Autocomplete
const locationOptions = GeoContinents.map((continent) => ({
id: continent,
name: t(
`pages.createMonitor.form.geoChecks.option.locations.options.${continent}`
),
}));
const selectedLocations = locationOptions.filter((loc) =>
(field.value ?? []).includes(loc.id)
);
return (
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={locationOptions}
value={selectedLocations}
getOptionLabel={(option) => option.name}
onChange={(_: unknown, newValue: typeof locationOptions) => {
field.onChange(newValue.map((loc) => loc.id));
}}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.locations.label"
)}
/>
{selectedLocations.length > 0 && (
<Stack
flex={1}
width="100%"
>
{selectedLocations.map((location, index) => (
<Stack
direction="row"
alignItems="center"
key={location.id}
width="100%"
>
<Typography flexGrow={1}>{location.name}</Typography>
<IconButton
size="small"
onClick={() => {
field.onChange(
(field.value ?? []).filter(
(id: string) => id !== location.id
)
);
}}
aria-label="Remove location"
>
<Trash2 size={16} />
</IconButton>
{index < selectedLocations.length - 1 && <Divider />}
</Stack>
))}
</Stack>
)}
</Stack>
);
}}
/>
<Controller
name="geoCheckInterval"
control={control}
render={({ field }) => (
<Select
{...field}
value={field.value ?? 300000}
fieldLabel={t(
"pages.createMonitor.form.geoChecks.option.interval.label"
)}
>
<MenuItem value={300000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.fiveMinutes"
)}
</MenuItem>
<MenuItem value={600000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.tenMinutes"
)}
</MenuItem>
<MenuItem value={900000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.fifteenMinutes"
)}
</MenuItem>
<MenuItem value={1800000}>
{t(
"pages.createMonitor.form.geoChecks.option.interval.value.thirtyMinutes"
)}
</MenuItem>
</Select>
)}
/>
</Stack>
)}
</Stack>
}
/>
)}
<Stack
direction="row"
justifyContent="flex-end"
@@ -3,6 +3,7 @@ import Grid from "@mui/material/Grid";
import Divider from "@mui/material/Divider";
import Typography from "@mui/material/Typography";
import { BaseBox, ValueLabel } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
import type { Incident } from "@/Types/Incident";
@@ -29,14 +30,14 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
}
return (
<Stack
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.MD)}
sx={sx}
>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.title")}
</Typography>
<BaseBox padding={8}>
<Stack gap={theme.spacing(4)}>
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.overview")}
</Typography>
@@ -44,7 +45,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
<Grid
container
spacing={theme.spacing(4)}
spacing={theme.spacing(LAYOUT.MD)}
alignItems="center"
>
<Grid size={2}>{t("pages.incidents.dialog.details.status")}</Grid>
@@ -75,15 +76,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</Grid>
</Stack>
</BaseBox>
<BaseBox padding={8}>
<Stack gap={theme.spacing(4)}>
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.analysis")}
</Typography>
<Divider />
<Grid
container
spacing={theme.spacing(2)}
spacing={theme.spacing(LAYOUT.MD)}
>
<Grid size={6}>
<Typography>{t("pages.incidents.dialog.details.timeline")}</Typography>
@@ -127,15 +128,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
</Stack>
</BaseBox>
{!incident.status && (
<BaseBox padding={8}>
<Stack gap={theme.spacing(4)}>
<BaseBox padding={LAYOUT.MD}>
<Stack gap={theme.spacing(LAYOUT.XS)}>
<Typography textTransform={"uppercase"}>
{t("pages.incidents.dialog.details.resolutionDetails")}
</Typography>
<Divider />
<Grid
container
spacing={theme.spacing(2)}
spacing={theme.spacing(LAYOUT.MD)}
alignItems="center"
>
<Grid size={2}>
+1 -1
View File
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
const LogsPage = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<number>(2);
const [activeTab, setActiveTab] = useState<number>(1);
return (
<BasePage>
<Tabs
+11 -10
View File
@@ -1,7 +1,9 @@
import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import { logger } from "@/Utils/logger";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import { BasePage, ConfigBox } from "@/Components/design-elements";
import {
TextField,
@@ -108,7 +110,7 @@ const CreateMaintenanceWindowPage = () => {
const isLoading = isPosting || isPatching;
const onError = (errors: any) => {
console.error("Form submission errors:", errors);
logger.error("Maintenance form submission failed", undefined, { errors });
};
return (
@@ -120,7 +122,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.general.title")}
subtitle={t("pages.maintenanceWindow.form.general.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="name"
control={control}
@@ -170,7 +172,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.startDate.title")}
subtitle={t("pages.maintenanceWindow.form.startDate.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startDate"
control={control}
@@ -197,7 +199,7 @@ const CreateMaintenanceWindowPage = () => {
title={t("pages.maintenanceWindow.form.startTime.title")}
subtitle={t("pages.maintenanceWindow.form.startTime.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="startTime"
control={control}
@@ -218,7 +220,7 @@ const CreateMaintenanceWindowPage = () => {
<Stack
direction="row"
alignItems="flex-start"
spacing={theme.spacing(4)}
spacing={theme.spacing(LAYOUT.MD)}
>
<Controller
name="duration"
@@ -283,7 +285,7 @@ const CreateMaintenanceWindowPage = () => {
.filter((m): m is Monitor => m !== undefined);
return (
<Stack spacing={theme.spacing(4)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitorsList}
@@ -317,9 +319,8 @@ const CreateMaintenanceWindowPage = () => {
key={monitor.id}
direction="row"
alignItems="center"
spacing={theme.spacing(4)}
padding={theme.spacing(4)}
marginTop={theme.spacing(2)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
@@ -350,7 +351,7 @@ const CreateMaintenanceWindowPage = () => {
<Stack
direction="row"
justifyContent="flex-end"
spacing={theme.spacing(2)}
spacing={theme.spacing(SPACING.LG)}
>
<Button
loading={isLoading}
@@ -147,7 +147,7 @@ const NotificationsCreatePage = () => {
/>
}
/>
{watchedType !== "matrix" && (
{watchedType !== "matrix" && watchedType !== "telegram" && (
<ConfigBox
title={addressConfig.title}
subtitle={addressConfig.description}
@@ -155,7 +155,7 @@ const NotificationsCreatePage = () => {
<Controller
name="address"
control={control}
defaultValue={defaults.address}
defaultValue={"address" in defaults ? defaults.address : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -171,6 +171,49 @@ const NotificationsCreatePage = () => {
}
/>
)}
{watchedType === "telegram" && (
<ConfigBox
title={t("pages.notifications.form.telegram.title")}
subtitle={t("pages.notifications.form.telegram.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Controller
name="accessToken"
control={control}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionBotToken")}
placeholder={t(
"pages.notifications.form.telegram.placeholderBotToken"
)}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
<Controller
name="address"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
type="text"
fieldLabel={t("pages.notifications.form.telegram.optionChatId")}
placeholder={t("pages.notifications.form.telegram.placeholderChatId")}
fullWidth
error={!!fieldState.error}
helperText={fieldState.error?.message ?? ""}
/>
)}
/>
</Stack>
}
/>
)}
{watchedType === "matrix" && (
<ConfigBox
title={t("pages.notifications.form.matrix.title")}
@@ -180,7 +223,7 @@ const NotificationsCreatePage = () => {
<Controller
name="homeserverUrl"
control={control}
defaultValue={defaults.homeserverUrl}
defaultValue={"homeserverUrl" in defaults ? defaults.homeserverUrl : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -196,7 +239,7 @@ const NotificationsCreatePage = () => {
<Controller
name="roomId"
control={control}
defaultValue={defaults.roomId}
defaultValue={"roomId" in defaults ? defaults.roomId : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
@@ -212,7 +255,7 @@ const NotificationsCreatePage = () => {
<Controller
name="accessToken"
control={control}
defaultValue={defaults.accessToken}
defaultValue={"accessToken" in defaults ? defaults.accessToken : ""}
render={({ field, fieldState }) => (
<TextField
{...field}
+24 -4
View File
@@ -1,4 +1,5 @@
import { BasePage } from "@/Components/design-elements";
import { HeaderTimeRange } from "@/Components/common";
import type { PageSpeedDetailsResponse } from "@/Types/Monitor";
import Stack from "@mui/material/Stack";
import {
@@ -13,23 +14,36 @@ import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi";
import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material";
import { useState, useMemo } from "react";
const PageSpeedDetails = () => {
const { monitorId } = useParams();
const isAdmin = useIsAdmin();
const theme = useTheme();
const [dateRange, setDateRange] = useState<string>("day");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
return null;
}
const params = new URLSearchParams();
params.append("dateRange", dateRange);
return `/monitors/pagespeed/details/${monitorId}?${params.toString()}`;
}, [monitorId, dateRange]);
const {
data: monitorData,
isLoading,
error,
refetch,
} = useGet<PageSpeedDetailsResponse>(
monitorId ? `/monitors/pagespeed/details/${monitorId}?dateRange=day` : null,
monitorDetailsUrl,
{},
{ keepPreviousData: true, refreshInterval: 30000 }
);
const monitor = monitorData?.monitor;
const monitor = monitorData?.monitorData?.monitor;
const groupedChecks = monitorData?.monitorData?.groupedChecks || [];
const monitorStats = monitorData?.monitorStats || null;
return (
@@ -47,9 +61,15 @@ const PageSpeedDetails = () => {
monitor={monitor}
monitorStats={monitorStats}
/>
<HeaderTimeRange
isLoading={isLoading}
hasDateRange={true}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<HistogramPageSpeedDetails
checks={monitor?.recentChecks || []}
range="day"
checks={groupedChecks}
range={dateRange}
/>
<Stack
direction={{ xs: "column", md: "row" }}
+23 -9
View File
@@ -8,7 +8,7 @@ import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/Pa
import type { Monitor } from "@/Types/Monitor";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet, useDelete } from "@/Hooks/UseApi";
import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
@@ -20,14 +20,6 @@ const PageSpeedMonitorsPage = () => {
const isAdmin = useIsAdmin();
const { deleteFn, loading: isDeleting } = useDelete();
const monitorsUrl = "/monitors/team/with-checks?type=pagespeed&limit=10";
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(monitorsUrl, {}, { refreshInterval: 30000 });
const settingsUrl = "/settings";
const {
data: settingsData,
@@ -42,6 +34,28 @@ const PageSpeedMonitorsPage = () => {
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const monitorsWithChecksUrl = useMemo(() => {
const params = new URLSearchParams();
params.append("type", "pagespeed");
params.append("limit", "25");
if (page !== undefined) params.append("page", String(page));
if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
if (sortField) params.append("field", sortField);
if (sortOrder) params.append("order", sortOrder);
return `/monitors/team/with-checks?${params.toString()}`;
}, [page, rowsPerPage, sortField, sortOrder]);
const {
data: monitorsData,
isLoading: monitorsIsLoading,
error: monitorsError,
refetch,
} = useGet<MonitorsWithChecksResponse>(
monitorsWithChecksUrl,
{},
{ refreshInterval: 30000 }
);
const monitors = monitorsData?.monitors;
const monitorsCount = monitorsData?.count ?? 0;
const summary = monitorsData?.summary ?? null;
+183 -106
View File
@@ -1,6 +1,15 @@
import { BasePage, ConfigBox, TextLink } from "@/Components/design-elements";
import { Autocomplete, Select, Dialog, SwitchComponent } from "@/Components/inputs";
import { Stack, useTheme, MenuItem, Link, type SelectChangeEvent } from "@mui/material";
import { logger } from "@/Utils/logger";
import { LAYOUT } from "@/Utils/Theme/constants";
import {
Stack,
useTheme,
MenuItem,
Link,
Alert,
type SelectChangeEvent,
} from "@mui/material";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -8,12 +17,13 @@ import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux";
import DummyChart from "@/Pages/Settings/DummyChart";
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 } from "@/Components/inputs";
import { TextField, Button, FieldLabel, SliderWithLabel } from "@/Components/inputs";
import { Box, Typography } from "@mui/material";
import { useDelete } from "@/Hooks/UseApi";
@@ -27,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;
@@ -44,10 +55,13 @@ export const SettingsPage = () => {
const { t, i18n } = useTranslation();
const dispatch = useDispatch();
const isAdmin = useIsAdmin();
const { toastError } = useToast();
// Local state for demo monitors dialog
const [isDemoMonitorsDialogOpen, setIsDemoMonitorsDialogOpen] = useState(false);
const { post: postDemoMonitors, loading: isPostingDemoMonitors } = usePost();
const { deleteFn: deleteAllMonitors, loading: isDeletingAllMonitors } = useDelete();
// Import monitors functionality
const { post: importMonitors, loading: isImportingMonitors } = usePost();
// Fetch settings data from API
const { data: fetchedSettings } = useGet<SettingsResponse>("/settings");
@@ -75,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",
@@ -202,6 +216,36 @@ export const SettingsPage = () => {
URL.revokeObjectURL(url);
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
if (file.type !== "application/json") {
toastError("Please select a valid JSON file");
event.target.value = "";
}
try {
const text = await file.text();
const monitors = JSON.parse(text);
if (!Array.isArray(monitors)) {
toastError("Invalid file format: expected an array of monitors");
event.target.value = "";
return;
}
await importMonitors("/monitors/import/json", { monitors });
event.target.value = "";
} catch (error) {
toastError("Error parsing JSON file. Please check the file format.");
event.target.value = "";
}
};
const onSubmit = async (data: SettingsFormData) => {
// Don't send pagespeedApiKey if it's already set and user hasn't clicked reset
const dataToSend = { ...data };
@@ -212,29 +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 === 0 ? "" : 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
@@ -248,7 +270,7 @@ export const SettingsPage = () => {
};
const onError = (errors: unknown) => {
console.log("Form validation errors:", errors);
logger.debug("Form validation errors", errors);
};
const languages = Object.keys(i18n.options.resources || {});
@@ -258,7 +280,7 @@ export const SettingsPage = () => {
component="form"
onSubmit={form.handleSubmit(onSubmit, onError)}
>
<Stack gap={theme.spacing(8)}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<ConfigBox
title={t("pages.settings.form.timezone.title")}
subtitle={t("pages.settings.form.timezone.description")}
@@ -281,7 +303,7 @@ export const SettingsPage = () => {
title={t("pages.settings.form.ui.title")}
subtitle={t("pages.settings.form.ui.description")}
rightContent={
<Stack gap={theme.spacing(8)}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<Select
value={mode}
onChange={handleModeChange}
@@ -420,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
@@ -431,24 +490,16 @@ export const SettingsPage = () => {
name="globalThresholds.cpu"
control={form.control}
defaultValue={defaults.globalThresholds?.cpu}
render={({ field, fieldState }) => (
<TextField
render={({ field }) => (
<SliderWithLabel
{...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t("pages.settings.form.thresholds.option.cpu.label")}
type="number"
inputProps={{ min: 0 }}
placeholder={t(
"pages.settings.form.thresholds.option.cpu.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
min={1}
max={100}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 1}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
/>
)}
/>
@@ -456,24 +507,16 @@ export const SettingsPage = () => {
name="globalThresholds.memory"
control={form.control}
defaultValue={defaults.globalThresholds?.memory}
render={({ field, fieldState }) => (
<TextField
render={({ field }) => (
<SliderWithLabel
{...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t("pages.settings.form.thresholds.option.memory.label")}
type="number"
inputProps={{ min: 0 }}
placeholder={t(
"pages.settings.form.thresholds.option.memory.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
min={1}
max={100}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 1}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
/>
)}
/>
@@ -481,24 +524,16 @@ export const SettingsPage = () => {
name="globalThresholds.disk"
control={form.control}
defaultValue={defaults.globalThresholds?.disk}
render={({ field, fieldState }) => (
<TextField
render={({ field }) => (
<SliderWithLabel
{...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t("pages.settings.form.thresholds.option.disk.label")}
type="number"
inputProps={{ min: 0 }}
placeholder={t(
"pages.settings.form.thresholds.option.disk.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
min={1}
max={100}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 1}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
/>
)}
/>
@@ -506,26 +541,18 @@ export const SettingsPage = () => {
name="globalThresholds.temperature"
control={form.control}
defaultValue={defaults.globalThresholds?.temperature}
render={({ field, fieldState }) => (
<TextField
render={({ field }) => (
<SliderWithLabel
{...field}
value={
field.value === undefined || field.value === 0 ? "" : field.value
}
onChange={(e) => {
const val = e.target.value;
field.onChange(val === "" ? 0 : Number(val));
}}
fieldLabel={t(
"pages.settings.form.thresholds.option.temperature.label"
)}
type="number"
inputProps={{ min: 0 }}
placeholder={t(
"pages.settings.form.thresholds.option.temperature.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
min={1}
max={150}
sliderMaxWidth={{ xs: "100%", md: "50%" }}
value={field.value || 1}
onChange={(_, value) => field.onChange(value)}
valueLabelDisplay="auto"
/>
)}
/>
@@ -541,7 +568,7 @@ export const SettingsPage = () => {
title={t("pages.settings.form.email.title")}
subtitle={t("pages.settings.form.email.description")}
leftContent={
<Stack gap={theme.spacing(8)}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
<TextLink
text={t("pages.settings.form.email.descriptionTransport")}
linkText={t("pages.settings.form.email.descriptionTransportLink")}
@@ -589,7 +616,7 @@ export const SettingsPage = () => {
</Stack>
}
rightContent={
<Stack gap={theme.spacing(10)}>
<Stack gap={theme.spacing(LAYOUT.MD)}>
{/* Email Host */}
<Controller
name="systemEmailHost"
@@ -676,7 +703,7 @@ export const SettingsPage = () => {
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(4)}
gap={theme.spacing(LAYOUT.XS)}
>
<Button
variant="contained"
@@ -751,7 +778,7 @@ export const SettingsPage = () => {
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(4),
gap: theme.spacing(LAYOUT.XS),
}}
>
{[
@@ -866,17 +893,34 @@ export const SettingsPage = () => {
{/* Export Monitors - Admin Only */}
{isAdmin && (
<ConfigBox
title={t("pages.settings.form.exportMonitors.title")}
subtitle={t("pages.settings.form.exportMonitors.description")}
title={t("pages.settings.form.importExportMonitors.title")}
subtitle={t("pages.settings.form.importExportMonitors.description")}
rightContent={
<Box>
<Stack
gap={theme.spacing(LAYOUT.MD)}
direction={"row"}
>
<input
id="monitor-import-input"
type="file"
accept=".json"
style={{ display: "none" }}
onChange={handleFileSelect}
/>
<Button
variant="contained"
onClick={() => document.getElementById("monitor-import-input")?.click()}
disabled={isImportingMonitors}
>
{t("common.buttons.importFromJSON")}
</Button>
<Button
variant="contained"
onClick={handleExportMonitors}
>
{t("common.buttons.exportToJSON")}
</Button>
</Box>
</Stack>
}
/>
)}
@@ -939,13 +983,46 @@ export const SettingsPage = () => {
bottom: 0,
backgroundColor: theme.palette.background.paper,
borderTop: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(8),
marginLeft: theme.spacing(-8),
marginRight: theme.spacing(-8),
marginBottom: theme.spacing(-8),
padding: theme.spacing(LAYOUT.MD),
marginLeft: theme.spacing(-LAYOUT.MD),
marginRight: theme.spacing(-LAYOUT.MD),
marginBottom: theme.spacing(-LAYOUT.MD),
zIndex: 1000,
}}
>
{/* Validation Error Display */}
{Object.keys(form.formState.errors).length > 0 && (
<Alert
severity="error"
sx={{ mb: 2, flexGrow: 1, mr: 2 }}
>
<Typography
variant="body2"
sx={{ fontWeight: 600, mb: 1 }}
>
{t("pages.settings.form.validation.errorMessage")}
</Typography>
<Box
component="ul"
sx={{ m: 0, pl: 2 }}
>
{Object.entries(form.formState.errors).map(([field, error]) => {
const message =
typeof error === "object" && error?.message
? error.message
: "Invalid value";
return (
<li key={field}>
<Typography variant="body2">
<strong>{field}:</strong> {message}
</Typography>
</li>
);
})}
</Box>
</Alert>
)}
<Button
loading={isSaving}
type="submit"
+70 -18
View File
@@ -1,5 +1,7 @@
import { BasePage, ConfigBox } from "@/Components/design-elements";
import Stack from "@mui/material/Stack";
import { logger } from "@/Utils/logger";
import { SPACING, LAYOUT } from "@/Utils/Theme/constants";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import FormControlLabel from "@mui/material/FormControlLabel";
@@ -19,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;
@@ -47,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();
@@ -79,8 +90,26 @@ 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) => {
console.log(errors);
logger.debug("Status page validation errors", errors);
};
const handleDeleteClick = () => {
@@ -101,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);
@@ -110,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
@@ -128,7 +161,7 @@ const CreateStatusPage = () => {
fd.append("logo", imageResult.data);
URL.revokeObjectURL(data.logo.data);
} catch (e) {
console.error("Error fetching logo blob:", e);
logger.error("Failed to fetch logo blob", e instanceof Error ? e : undefined);
}
}
}
@@ -165,7 +198,7 @@ const CreateStatusPage = () => {
<Stack
direction="row"
alignItems="center"
spacing={theme.spacing(2)}
spacing={theme.spacing(SPACING.MD)}
>
<Controller
name="isPublished"
@@ -187,7 +220,7 @@ const CreateStatusPage = () => {
title={t("pages.statusPages.form.basicInfo.title")}
subtitle={t("pages.statusPages.form.basicInfo.description")}
rightContent={
<Stack spacing={theme.spacing(6)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="companyName"
control={control}
@@ -242,7 +275,7 @@ const CreateStatusPage = () => {
};
return (
<Stack spacing={theme.spacing(4)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Autocomplete
multiple
options={monitors}
@@ -290,9 +323,9 @@ const CreateStatusPage = () => {
{...provided.dragHandleProps}
direction="row"
alignItems="center"
spacing={theme.spacing(4)}
padding={theme.spacing(4)}
marginTop={theme.spacing(2)}
spacing={theme.spacing(LAYOUT.XS)}
padding={theme.spacing(LAYOUT.XS)}
marginTop={theme.spacing(SPACING.LG)}
borderRadius={1}
sx={{
border: `1px solid ${theme.palette.divider}`,
@@ -301,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={() => {
@@ -366,7 +401,7 @@ const CreateStatusPage = () => {
title={t("pages.statusPages.form.appearance.title")}
subtitle={t("pages.statusPages.form.appearance.description")}
rightContent={
<Stack spacing={theme.spacing(8)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Stack alignItems={"center"}>
<Controller
name="logo"
@@ -407,7 +442,7 @@ const CreateStatusPage = () => {
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
rightContent={
<Stack spacing={theme.spacing(2)}>
<Stack spacing={theme.spacing(LAYOUT.MD)}>
<Controller
name="showCharts"
control={control}
@@ -423,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",
@@ -20,7 +20,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
},
{
id: "date",
content: t("pages.checks.table.headers.dateTime"),
content: t("common.table.headers.dateTime"),
render: (row) => {
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
},
@@ -0,0 +1,107 @@
import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
import prettyMilliseconds from "pretty-ms";
const getHeaders = (t: Function, uiTimezone: string) => {
const headers: Header<FlatGeoCheck>[] = [
{
id: "status",
content: t("common.table.headers.status"),
render: (row) => {
const status = row.status ? "up" : "down";
return <StatusLabel status={status} />;
},
},
{
id: "date",
content: t("common.table.headers.dateTime"),
render: (row) => {
return formatDateWithTz(row.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone);
},
},
{
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
return row.statusCode || "N/A";
},
},
{
id: "location",
content: t("pages.checks.table.headers.location"),
render: (row) => {
const location = row.location;
if (!location) return "N/A";
const { continent, country, city } = location;
return `${continent} - ${country}, ${city}`;
},
},
{
id: "responseTime",
content: t("common.table.headers.responseTime"),
render: (row) => {
if (!row.timings?.total) return "N/A";
return prettyMilliseconds(row.timings.total, { compact: true });
},
},
];
return headers;
};
export const GeoChecksTable = ({
geoChecks,
count,
page,
setPage,
rowsPerPage,
setRowsPerPage,
}: {
geoChecks: FlatGeoCheck[];
count: number;
page: number;
setPage: (page: number) => void;
rowsPerPage: number;
setRowsPerPage: (rowsPerPage: number) => void;
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const headers = getHeaders(t, uiTimezone);
const handlePageChange = (
_e: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleRowsPerPageChange = (
e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>
) => {
const value = Number(e.target.value);
setPage(0);
setRowsPerPage(value);
};
return (
<Box>
<Table
headers={headers}
data={geoChecks}
/>
<Pagination
component="div"
count={count}
page={page}
rowsPerPage={rowsPerPage}
onPageChange={handlePageChange}
onRowsPerPageChange={handleRowsPerPageChange}
/>
</Box>
);
};
+95 -4
View File
@@ -6,9 +6,12 @@ import {
RadialAvgResponse,
HistogramDetails,
HeaderMonitorControls,
HeaderGeoTabs,
GeoChecksMap,
} from "@/Components/monitors";
import { TrendingUp, AlertTriangle } from "lucide-react";
import { ChecksTable } from "@/Pages/Uptime/Details/Components/ChecksTable";
import { GeoChecksTable } from "@/Pages/Uptime/Details/Components/GeoChecksTable";
import { MonitorStatBoxes } from "@/Components/monitors";
import { useTheme } from "@mui/material/styles";
@@ -17,11 +20,17 @@ import { useState, useMemo } from "react";
import { useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { useGet } from "@/Hooks/UseApi";
import type { MonitorDetailsResponse } from "@/Types/Monitor";
import { type MonitorDetailsResponse, supportsGeoCheck } from "@/Types/Monitor";
import type { ChecksResponse } from "@/Types/Check";
import type {
GeoChecksResult,
FlatGeoChecksResponse,
GeoContinent,
} from "@/Types/GeoCheck";
import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { t } from "i18next";
import { Typography } from "@mui/material";
const certificateDateFormat = "MMM D, YYYY h A";
@@ -37,7 +46,10 @@ const UptimeDetailsPage = () => {
const [page, setPage] = useState<number>(0);
const [rowsPerPage, setRowsPerPage] = useState<number>(5);
const [geoPage, setGeoPage] = useState<number>(0);
const [geoRowsPerPage, setGeoRowsPerPage] = useState<number>(5);
const [dateRange, setDateRange] = useState<string>("recent");
const [selectedLocation, setSelectedLocation] = useState<GeoContinent>("NA");
const monitorDetailsUrl = useMemo(() => {
if (!monitorId) {
@@ -56,7 +68,7 @@ const UptimeDetailsPage = () => {
} = useGet<MonitorDetailsResponse>(
monitorDetailsUrl,
{},
{ refreshInterval: 10000, keepPreviousData: true }
{ refreshInterval: 10000, keepPreviousData: true, revalidateOnFocus: false }
);
const monitorData = monitorDetailsData?.monitorData;
@@ -71,7 +83,11 @@ const UptimeDetailsPage = () => {
return `/monitors/certificate/${monitorId}`;
}, [monitorId, monitor?.type]);
const { data: certificateData } = useGet<CertificateResponse>(certificateUrl);
const { data: certificateData } = useGet<CertificateResponse>(
certificateUrl,
{},
{ revalidateOnFocus: false }
);
const certificateExpiry = useMemo(() => {
if (!certificateData?.certificateDate) {
@@ -102,9 +118,58 @@ const UptimeDetailsPage = () => {
const { data: checksData, isLoading: checksIsLoading } = useGet<ChecksResponse>(
checksUrl,
{},
{ keepPreviousData: true }
{ keepPreviousData: true, revalidateOnFocus: false }
);
const geoChecksUrl = useMemo(() => {
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
return null;
}
const params = new URLSearchParams();
params.append("dateRange", dateRange);
params.append("continent", selectedLocation);
return `/monitors/${monitorId}/geo-checks?${params.toString()}`;
}, [monitorId, monitor?.type, monitor?.geoCheckEnabled, dateRange, selectedLocation]);
const { data: geoGroupedData } = useGet<GeoChecksResult>(
geoChecksUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
);
const geoGroupedChecks = geoGroupedData?.groupedGeoChecks ?? [];
// Fetch paginated geo checks for the table
const geoChecksTableUrl = useMemo(() => {
if (!monitorId || !supportsGeoCheck(monitor?.type) || !monitor?.geoCheckEnabled) {
return null;
}
const params = new URLSearchParams();
params.append("sortOrder", "desc");
params.append("dateRange", dateRange);
params.append("page", String(geoPage));
params.append("rowsPerPage", String(geoRowsPerPage));
return `/geo-checks/${monitorId}?${params.toString()}`;
}, [
monitorId,
monitor?.type,
monitor?.geoCheckEnabled,
dateRange,
geoPage,
geoRowsPerPage,
]);
const { data: geoChecksTableData } = useGet<FlatGeoChecksResponse>(
geoChecksTableUrl,
{},
{ keepPreviousData: true, revalidateOnFocus: false }
);
const geoChecksForTable = geoChecksTableData?.geoChecks ?? [];
const geoChecksCount = geoChecksTableData?.geoChecksCount ?? 0;
const geoLocations = monitor?.geoCheckLocations;
const checks = checksData?.checks ?? [];
const checksCount = checksData?.checksCount ?? 0;
@@ -127,6 +192,7 @@ const UptimeDetailsPage = () => {
dateRange={dateRange}
setDateRange={setDateRange}
/>
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
@@ -160,6 +226,31 @@ const UptimeDetailsPage = () => {
rowsPerPage={rowsPerPage}
setRowsPerPage={setRowsPerPage}
/>
{monitor?.geoCheckEnabled && (
<>
<Typography variant="h1">Location breakdown</Typography>
<HeaderGeoTabs
geoCheckEnabled={monitor?.geoCheckEnabled ?? false}
locations={geoLocations}
selectedLocation={selectedLocation}
onLocationChange={setSelectedLocation}
/>
<HistogramDetails
checks={geoGroupedChecks}
range={dateRange}
/>
<GeoChecksTable
geoChecks={geoChecksForTable}
count={geoChecksCount}
page={geoPage}
setPage={setGeoPage}
rowsPerPage={geoRowsPerPage}
setRowsPerPage={setGeoRowsPerPage}
/>
<GeoChecksMap geoChecks={geoChecksForTable} />
</>
)}
</BasePage>
);
};
@@ -181,6 +181,7 @@ export const MonitorTable = ({
onClick={(e) => handleSort(e, "status")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.status")}
{renderSortIcon(sortField === "status")}
</Stack>
@@ -211,6 +212,7 @@ export const MonitorTable = ({
onClick={(e) => handleSort(e, "type")}
sx={{ cursor: "pointer" }}
>
<Box width={theme.spacing(8)} />
{t("common.table.headers.type")}
{renderSortIcon(sortField === "type")}
</Stack>
+6 -4
View File
@@ -42,11 +42,11 @@ const UptimeMonitorsPage = () => {
const debouncedSearch = useDebounce<string>(search, 300);
// Convert filter selections to API filter values
// Status: "up" -> true, "down" -> false
// Status: pass "up"/"down" directly to the API
// State: "active" -> true, "paused" -> false
const toFilterStatus = useMemo(() => {
if (selectedStatus === "up") return "true";
if (selectedStatus === "down") return "false";
if (selectedStatus === "up") return "up";
if (selectedStatus === "down") return "down";
return undefined;
}, [selectedStatus]);
@@ -68,7 +68,9 @@ const UptimeMonitorsPage = () => {
// Default to all types when none selected
const effectiveTypes =
selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port", "game"];
selectedTypes.length > 0
? selectedTypes
: ["http", "ping", "docker", "port", "game", "grpc", "websocket"];
// Build URL for monitors with checks
const monitorsWithChecksUrl = useMemo(() => {
+12 -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;
}
@@ -151,6 +149,15 @@ export interface GroupedCheck {
totalChecks: number;
}
export interface PageSpeedGroupedCheck {
bucketDate: string;
performance: number;
accessibility: number;
bestPractices: number;
seo: number;
totalChecks: number;
}
export interface LatestCheck {
status: boolean;
responseTime: number;
@@ -234,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;
};
+80
View File
@@ -0,0 +1,80 @@
export const GeoContinents = ["EU", "NA", "AS", "SA", "AF", "OC"] as const;
export type GeoContinent = (typeof GeoContinents)[number];
export interface GeoCheckMetadata {
monitorId: string;
teamId: string;
type: string;
}
export interface GeoCheckTimings {
total: number;
dns: number;
tcp: number;
tls: number;
firstByte: number;
download: number;
}
export interface GeoCheckLocation {
continent: GeoContinent;
region: string;
country: string;
state: string;
city: string;
longitude: number;
latitude: number;
}
export interface GeoCheckResult {
location: GeoCheckLocation;
status: boolean;
statusCode: number;
timings: GeoCheckTimings;
}
export interface GeoCheck {
id: string;
metadata: GeoCheckMetadata;
results: GeoCheckResult[];
expiry: string;
__v: number;
createdAt: string;
updatedAt: string;
}
export interface FlatGeoCheck {
id: string;
monitorId: string;
teamId: string;
type: string;
location: GeoCheckLocation;
status: boolean;
statusCode: number;
timings: GeoCheckTimings;
createdAt: string;
updatedAt: string;
}
export interface GroupedGeoCheck {
bucketDate: string;
continent: GeoContinent;
avgResponseTime: number;
totalChecks: number;
uptimePercentage: number;
}
export interface GeoChecksResult {
monitorType: string;
groupedGeoChecks: GroupedGeoCheck[];
}
export interface GeoChecksResponse {
geoChecks: GeoCheck[];
geoChecksCount: number;
}
export interface FlatGeoChecksResponse {
geoChecks: FlatGeoCheck[];
geoChecksCount: number;
}
+22 -1
View File
@@ -1,4 +1,7 @@
import type { GroupedCheck, CheckSnapshot } from "@/Types/Check";
import type { PageSpeedGroupedCheck } from "@/Types/Check";
import type { GeoContinent } from "@/Types/GeoCheck";
export type { GeoContinent } from "@/Types/GeoCheck";
export const MonitorTypes = [
"http",
@@ -8,10 +11,21 @@ export const MonitorTypes = [
"docker",
"port",
"game",
"grpc",
"websocket",
"unknown",
] as const;
export type MonitorType = (typeof MonitorTypes)[number];
export const GeoCheckSupportedTypes: readonly MonitorType[] = ["http", "ping"] as const;
export const supportsGeoCheck = (type: MonitorType | undefined): boolean => {
if (!type) {
return false;
}
return GeoCheckSupportedTypes.includes(type);
};
export const MonitorStatuses = [
"up",
"down",
@@ -57,7 +71,11 @@ export interface Monitor {
tempAlertCounter: number;
selectedDisks: string[];
gameId?: string;
grpcServiceName?: string;
group: string | null;
geoCheckEnabled?: boolean;
geoCheckLocations?: GeoContinent[];
geoCheckInterval?: number;
recentChecks: CheckSnapshot[];
createdAt: string;
updatedAt: string;
@@ -112,7 +130,10 @@ export interface MonitorDetailsResponse {
}
export interface PageSpeedDetailsResponse {
monitor: MonitorWithChecks;
monitorData: {
monitor: Monitor;
groupedChecks: PageSpeedGroupedCheck[];
};
monitorStats: MonitorStats | null;
}
+2
View File
@@ -5,6 +5,8 @@ export const NotificationChannels = [
"webhook",
"pager_duty",
"matrix",
"teams",
"telegram",
] 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;
+6
View File
@@ -4,6 +4,7 @@ declare const __APP_VERSION__: string;
interface ImportMetaEnv {
readonly VITE_APP_API_V2_BASE_URL?: string;
readonly VITE_APP_LOG_LEVEL?: "debug" | "info" | "warn" | "error";
}
interface ImportMeta {
@@ -17,3 +18,8 @@ declare module "*.svg?react" {
>;
export default ReactComponent;
}
declare module "*.css" {
const content: string;
export default content;
}
+2
View File
@@ -8,6 +8,8 @@ export const getMonitorPath = (type: MonitorType): string => {
port: "uptime",
ping: "uptime",
game: "uptime",
grpc: "uptime",
websocket: "uptime",
unknown: "uptime",
docker: "uptime",
hardware: "infrastructure",
+6
View File
@@ -12,6 +12,8 @@ export const typographyLevels = {
export const colors = {
gray200: "#EFEFEF",
gray700: "#313131",
gray900: "#151518",
gray850: "#1c1c21",
blueBlueWave: "#1570EF",
};
@@ -31,4 +33,8 @@ export const darkPalette = {
secondary: {
main: colors.gray700,
},
background: {
default: colors.gray900,
paper: colors.gray850,
},
};
+17
View File
@@ -0,0 +1,17 @@
export const SPACING = {
XS: 0.5,
SM: 1,
MD: 1.5,
LG: 2,
XL: 2.5,
XXL: 3,
} as const;
export const LAYOUT = {
XS: 4,
SM: 6,
MD: 8,
LG: 10,
XL: 12,
XXL: 16,
} as const;
+137
View File
@@ -0,0 +1,137 @@
import type { LogLevel } from "@/Types/Log";
interface LogContext {
[key: string]: any;
}
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
interface ErrorLogData {
timestamp: string;
level: LogLevel;
message: string;
error?: {
message: string;
name: string;
stack?: string;
};
context?: LogContext;
url: string;
userAgent: string;
}
const configuredLevel: LogLevel = import.meta.env.VITE_APP_LOG_LEVEL || "error";
const configuredPriority = LOG_LEVEL_PRIORITY[configuredLevel];
const shouldLog = (level: LogLevel): boolean => {
return LOG_LEVEL_PRIORITY[level] >= configuredPriority;
};
const formatError = (error?: Error) => {
if (!error) return undefined;
return {
message: error.message,
name: error.name,
stack: error.stack,
};
};
const createLogData = (
level: LogLevel,
message: string,
error?: Error,
context?: LogContext
): ErrorLogData => {
return {
timestamp: new Date().toISOString(),
level,
message,
error: formatError(error),
context,
url: window.location.href,
userAgent: navigator.userAgent,
};
};
const error = (message: string, error?: Error, context?: LogContext): void => {
if (!shouldLog("error")) return;
const logData = createLogData("error", message, error, context);
if (configuredLevel === "debug") {
console.group(`ERROR: ${message}`);
if (error) {
console.error("Error:", error);
}
if (context && Object.keys(context).length > 0) {
console.log("Context:", context);
}
console.log("URL:", logData.url);
console.log("Timestamp:", logData.timestamp);
console.groupEnd();
} else {
console.error(JSON.stringify(logData));
}
};
const warn = (message: string, context?: LogContext): void => {
if (!shouldLog("warn")) return;
const logData = createLogData("warn", message, undefined, context);
if (configuredLevel === "debug") {
console.group(`WARN: ${message}`);
if (context && Object.keys(context).length > 0) {
console.log("Context:", context);
}
console.groupEnd();
} else {
console.warn(JSON.stringify(logData));
}
};
const debug = (message: string, data?: any): void => {
if (!shouldLog("debug")) return;
console.group(`DEBUG: ${message}`);
if (data !== undefined) {
console.log(data);
}
console.groupEnd();
};
const info = (message: string, data?: any): void => {
if (!shouldLog("info")) return;
if (configuredLevel === "debug") {
console.log(`INFO: ${message}`, data);
} else {
const logData = {
timestamp: new Date().toISOString(),
level: "info" as LogLevel,
message,
data,
};
console.log(JSON.stringify(logData));
}
};
export interface ILogger {
error(message: string, error?: Error, context?: LogContext): void;
warn(message: string, context?: LogContext): void;
debug(message: string, data?: any): void;
info(message: string, data?: any): void;
}
export const logger: ILogger = {
error,
warn,
debug,
info,
};
+30
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { GeoContinents } from "@/Types/GeoCheck";
// URL schema with custom error message
const urlSchema = z.url({ message: "Please enter a valid URL" });
@@ -20,6 +21,12 @@ const baseSchema = z.object({
.number({ message: "Threshold percentage is required" })
.min(1, "Incident percentage must be at least 1")
.max(100, "Incident percentage must be at most 100"),
geoCheckEnabled: z.boolean().optional(),
geoCheckLocations: z.array(z.enum(GeoContinents)).optional(),
geoCheckInterval: z
.number()
.min(300000, "Interval must be at least 5 minutes")
.optional(),
});
// HTTP monitor schema
@@ -66,6 +73,18 @@ const gameSchema = baseSchema.extend({
gameId: z.string().min(1, "Game type is required"),
});
// gRPC monitor schema
const grpcSchema = baseSchema.extend({
type: z.literal("grpc"),
url: z.string().min(1, "Host is required"),
port: z
.number()
.min(1, "Port must be at least 1")
.max(65535, "Port must be at most 65535"),
grpcServiceName: z.string().optional(),
ignoreTlsErrors: z.boolean(),
});
// PageSpeed monitor schema
const pagespeedSchema = baseSchema.extend({
type: z.literal("pagespeed"),
@@ -96,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,
@@ -103,8 +129,10 @@ export const monitorSchema = z.discriminatedUnion("type", [
portSchema,
dockerSchema,
gameSchema,
grpcSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
]);
export type MonitorFormData = z.infer<typeof monitorSchema>;
@@ -116,6 +144,8 @@ export {
portSchema,
dockerSchema,
gameSchema,
grpcSchema,
pagespeedSchema,
hardwareSchema,
websocketSchema,
};
+13
View File
@@ -45,6 +45,17 @@ 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"),
});
const telegramSchema = baseSchema.extend({
type: z.literal("telegram"),
address: z.string().min(1, "Chat ID is required"),
accessToken: z.string().min(1, "Bot token is required"),
});
export const notificationSchema = z.discriminatedUnion("type", [
emailSchema,
slackSchema,
@@ -52,6 +63,8 @@ export const notificationSchema = z.discriminatedUnion("type", [
webhookSchema,
pagerDutySchema,
matrixSchema,
teamsSchema,
telegramSchema,
]);
export type NotificationFormData = z.infer<typeof notificationSchema>;
+20 -37
View File
@@ -1,3 +1,4 @@
import { CHECK_TTL_SENTINEL } from "@/Types/Check";
import { z } from "zod";
export const settingsSchema = z.object({
@@ -6,68 +7,50 @@ 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()))
.optional(),
globalThresholds: z
.object({
cpu: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(100, "Maximum value is 100")
.optional(),
memory: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(100, "Maximum value is 100")
.optional(),
disk: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(100, "Maximum value is 100")
.optional(),
temperature: z
.number()
.int()
.min(1, "Please enter a valid value")
.max(150, "Maximum value is 150")
.optional(),
})
.transform((val) => (val.trim() === "" ? null : val.trim()))
.optional(),
globalThresholds: z.object({
cpu: z.number().int().min(1).max(100),
memory: z.number().int().min(1).max(100),
disk: z.number().int().min(1).max(100),
temperature: z.number().int().min(1).max(150),
}),
});
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({
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+114 -15
View File
@@ -44,7 +44,10 @@
"addDemo": "Add demo monitors",
"removeMonitors": "Remove monitors",
"sendTestEmail": "Send test email",
"exportToJSON": "Export to JSON"
"exportToJSON": "Export to JSON",
"importFromJSON": "Import from JSON",
"clearFilters": "Clear filters",
"removeUser": "Remove user"
},
"charts": {
"labels": {
@@ -114,7 +117,8 @@
"type": "Type",
"url": "Url",
"interval": "Interval",
"active": "Active"
"active": "Active",
"responseTime": "Response time"
}
}
},
@@ -371,8 +375,8 @@
"table": {
"empty": "No down checks in this time range",
"headers": {
"dateTime": "Date & time",
"statusCode": "Status code"
"statusCode": "Status code",
"location": "Location"
}
}
},
@@ -397,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"
}
}
},
@@ -480,6 +495,14 @@
"port": {
"label": "Port to monitor",
"placeholder": 80
},
"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",
@@ -490,6 +513,8 @@
"docker": "Enter the Docker container name or ID. You can use either the container name (e.g., my-app) or the container ID (full 64-char ID or short ID).",
"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."
}
},
@@ -520,15 +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.",
"optionHttp": "HTTP(S)",
"optionGrpcDescription": "Monitor gRPC services using the standard Health Checking Protocol.",
"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"
},
@@ -550,6 +572,36 @@
}
}
},
"geoChecks": {
"title": "Geo-Distributed Checks",
"description": "Run checks from multiple geographic locations to monitor global availability and performance.",
"option": {
"enabled": {
"label": "Enable geo-distributed checks"
},
"locations": {
"label": "Locations",
"placeholder": "Select locations",
"options": {
"EU": "Europe",
"NA": "North America",
"AS": "Asia",
"SA": "South America",
"AF": "Africa",
"OC": "Oceania"
}
},
"interval": {
"label": "Check interval",
"value": {
"fiveMinutes": "5 minutes",
"tenMinutes": "10 minutes",
"fifteenMinutes": "15 minutes",
"thirtyMinutes": "30 minutes"
}
}
}
},
"url": {
"title": "Monitor IP/URL on Status Page",
"description": "Display the IP address or URL of monitor on the public Status page. If it's disabled, only the monitor name will be shown to protect sensitive information.",
@@ -579,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": {
@@ -619,7 +677,6 @@
},
"filters": {
"allMonitors": "All monitors",
"clearFilters": "Clear filters",
"monitor": "Monitor",
"resolutionType": "Resolution type",
"resolutionTypes": {
@@ -894,6 +951,14 @@
"description": "Select the type of notification channel to create.",
"optionType": "Type",
"title": "Channel type"
},
"telegram": {
"title": "Telegram configuration",
"description": "Configure your Telegram bot to send notifications.",
"optionBotToken": "Bot token",
"placeholderBotToken": "123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ",
"optionChatId": "Chat ID",
"placeholderChatId": "-1001234567890"
}
},
"table": {
@@ -988,7 +1053,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."
@@ -999,6 +1064,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.",
@@ -1086,12 +1161,18 @@
}
},
"exportMonitors": {
"title": "Export monitors",
"description": "Export your monitors data as a JSON file for backup or transfer."
"title": "Export monitors"
},
"importExportMonitors": {
"title": "Import / Export monitors",
"description": "Import or export your monitors data as a JSON file for backup or transfer."
},
"about": {
"title": "About",
"developedBy": "Developed by Bluewave Labs"
},
"validation": {
"errorMessage": "Please fix the following validation errors:"
}
}
},
@@ -1109,7 +1190,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",
@@ -1201,6 +1297,9 @@
},
"showAdminLoginLink": {
"label": "Show admin login link"
},
"showInfrastructure": {
"label": "Show infrastructure metrics"
}
}
}
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1271 -1085
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1271 -1106
View File
File diff suppressed because it is too large. Load diff
+1281 -1084
View File
File diff suppressed because it is too large. Load diff
+1268 -801
View File
File diff suppressed because it is too large. Load diff
+1268 -801
View File
File diff suppressed because it is too large. Load diff
+1271 -1098
View File
File diff suppressed because it is too large. Load diff
+1299 -1088
View File
File diff suppressed because it is too large. Load diff
+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.4.0";
let version = "3.5.1";
return {
base: "/",
+26
View File
@@ -0,0 +1,26 @@
# ==============================================
# Checkmate Server Configuration
# ==============================================
# Copy this file to .env and fill in your values
# Server Configuration
# --------------------
NODE_ENV=development
LOG_LEVEL=debug
# Database
# --------
DB_CONNECTION_STRING=mongodb://localhost:27017/uptime_db
# JWT Authentication
# ------------------
JWT_SECRET=my_secret
TOKEN_TTL=99d
# Client Configuration
# --------------------
CLIENT_HOST=http://localhost:5173
# Optional Configuration
# ----------------------
ORIGIN=localhost
+17
View File
@@ -1,6 +1,7 @@
import globals from "globals";
import pluginJs from "@eslint/js";
import mochaPlugin from "eslint-plugin-mocha";
import tseslint from "typescript-eslint";
/*
Please do not forget to look at the latest eslint configurations and rules.
@@ -10,6 +11,9 @@ ESlint v9 configuration is different than v8.
/** @type {import('eslint').Linter.Config[]} */
export default [
{
ignores: ["dist/**", "node_modules/**", "coverage/**", "test/**"],
},
{
languageOptions: {
globals: {
@@ -21,9 +25,22 @@ export default [
},
},
pluginJs.configs.recommended, // Core JS rules
...tseslint.configs.recommended, // TypeScript recommended rules
mochaPlugin.configs.flat.recommended, // Mocha rules
{
files: ["**/*.ts", "**/*.tsx"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
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 -177
View File
@@ -432,46 +432,6 @@
}
},
"/monitors": {
"get": {
"tags": ["monitors"],
"summary": "Get all monitors",
"description": "Retrieve all monitors for the authenticated user",
"responses": {
"200": {
"description": "Monitors retrieved successfully",
"content": {
"application/json": {
"schema": {
"allOf": [
{
"$ref": "#/components/schemas/SuccessResponse"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Monitor"
}
}
}
}
]
}
}
}
},
"500": {
"$ref": "#/components/responses/InternalServerError"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": ["monitors"],
"summary": "Create monitor",
@@ -550,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"]
}
}
]
}
},
{
@@ -595,15 +537,6 @@
"schema": {
"type": "string"
}
},
{
"name": "field",
"in": "query",
"description": "Field to filter on",
"schema": {
"type": "string",
"enum": ["name", "url", "description"]
}
}
],
"responses": {
@@ -796,7 +729,7 @@
}
]
},
"put": {
"patch": {
"tags": ["monitors"],
"summary": "Update monitor",
"description": "Update an existing monitor",
@@ -3608,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": {
@@ -3633,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",
@@ -3649,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
}
}
},
@@ -3701,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",
@@ -3723,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
}
}
},
+1341 -898
View File
File diff suppressed because it is too large. Load diff
+17 -8
View File
@@ -8,14 +8,14 @@
"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 .",
"format-check": "prettier --check ."
},
"lint-staged": {
"**/*": "npm run format"
"**/*": "prettier --write"
},
"keywords": [],
"author": "",
@@ -24,6 +24,8 @@
"node": ">=20"
},
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@pulsecron/pulse": "1.6.8",
"axios": "^1.7.2",
"bcryptjs": "3.0.2",
@@ -36,27 +38,29 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "8.0.1",
"gamedig": "^5.3.1",
"gamedig": "5.2.0",
"got": "14.4.7",
"handlebars": "^4.7.8",
"helmet": "^8.0.0",
"ioredis": "^5.4.2",
"isomorphic-dompurify": "^2.26.0",
"jmespath": "^0.16.0",
"joi": "^17.13.1",
"jsdom": "^26.1.0",
"jsonwebtoken": "9.0.2",
"mailersend": "^2.2.0",
"mjml": "^5.0.0-alpha.4",
"mongoose": "^8.3.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.14",
"nodemailer": "8.0.1",
"pg": "8.20.0",
"ping": "0.4.4",
"sharp": "0.33.5",
"ssl-checker": "2.0.10",
"super-simple-scheduler": "1.4.5",
"super-simple-scheduler": "1.9.1",
"swagger-ui-express": "5.0.1",
"winston": "^3.13.0"
"winston": "^3.13.0",
"ws": "^8.19.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
@@ -74,8 +78,12 @@
"@types/multer": "^2.0.0",
"@types/nodemailer": "7.0.1",
"@types/papaparse": "^5.5.2",
"@types/pg": "8.20.0",
"@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",
"eslint": "^9.17.0",
"eslint-plugin-mocha": "^10.5.0",
@@ -89,6 +97,7 @@
"ts-node": "^10.9.2",
"tsc-alias": "1.8.16",
"tsx": "4.20.5",
"typescript": "5.9.2"
"typescript": "5.9.2",
"typescript-eslint": "^8.56.1"
}
}
+1 -3
View File
@@ -3,8 +3,6 @@ import { MonitorModel } from "../dist/db/models/Monitor.js";
import { CheckModel } from "../dist/db/models/Check.js";
const DEFAULT_MONITOR_ID = "000000000000000000000001";
const DEFAULT_TEAM_ID = "0000000000000000000000aa";
const DEFAULT_USER_ID = "0000000000000000000000bb";
const DEFAULT_MONITOR_TYPE = "http";
const DEFAULT_TOTAL = 1_000_000;
const DEFAULT_BATCH_SIZE = 5_000;
@@ -12,7 +10,7 @@ const DEFAULT_BATCH_SIZE = 5_000;
const parseObjectId = (value, fallback) => {
try {
return new mongoose.Types.ObjectId(value || fallback);
} catch (error) {
} catch {
console.warn(`Invalid ObjectId '${value}', falling back to '${fallback}'.`);
return new mongoose.Types.ObjectId(fallback);
}
+18 -4
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();
@@ -74,8 +74,22 @@ export const createApp = ({
},
})
);
// Swagger UI
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openApiSpec));
// Swagger UI — dynamically set server URL from request
app.use("/api-docs", swaggerUi.serve, (req: express.Request, res: express.Response, next: express.NextFunction) => {
const protocol = req.protocol;
const host = req.get("host");
const dynamicSpec = {
...openApiSpec,
servers: [
{
url: `${protocol}://${host}/api/v1`,
description: "Current Server",
},
...openApiSpec.servers,
],
};
swaggerUi.setup(dynamicSpec)(req, res, next);
});
app.use("/api/v1/health", (req, res) => {
res.json({
+4 -1
View File
@@ -2,6 +2,7 @@ import MonitorController from "../controllers/monitorController.js";
import AuthController from "../controllers/authController.js";
import SettingsController from "../controllers/settingsController.js";
import CheckController from "../controllers/checkController.js";
import GeoCheckController from "../controllers/geoCheckController.js";
import InviteController from "../controllers/inviteController.js";
import MaintenanceWindowController from "../controllers/maintenanceWindowController.js";
import QueueController from "../controllers/queueController.js";
@@ -17,6 +18,7 @@ export interface InitializedControllers {
monitorController: MonitorController;
settingsController: SettingsController;
checkController: CheckController;
geoCheckController: GeoCheckController;
inviteController: InviteController;
maintenanceWindowController: MaintenanceWindowController;
queueController: QueueController;
@@ -29,9 +31,10 @@ 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),
inviteController: new InviteController(services.inviteService),
maintenanceWindowController: new MaintenanceWindowController(services.maintenanceWindowService),
queueController: new QueueController(services.jobQueue),
+7 -1
View File
@@ -1,11 +1,15 @@
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";
import MonitorRoutes from "../routes/monitorRoute.js";
import CheckRoutes from "../routes/checkRoute.js";
import GeoCheckRoutes from "../routes/geoCheckRoutes.js";
import SettingsRoutes from "../routes/settingsRoute.js";
import MaintenanceWindowRoutes from "../routes/maintenanceWindowRoute.js";
import StatusPageRoutes from "../routes/statusPageRoute.js";
@@ -16,12 +20,13 @@ 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);
const settingsRoutes = new SettingsRoutes(controllers.settingsController);
const checkRoutes = new CheckRoutes(controllers.checkController);
const geoCheckRoutes = new GeoCheckRoutes(controllers.geoCheckController);
const inviteRoutes = new InviteRoutes(controllers.inviteController, verifyJWT);
const maintenanceWindowRoutes = new MaintenanceWindowRoutes(controllers.maintenanceWindowController);
const queueRoutes = new QueueRoutes(controllers.queueController);
@@ -36,6 +41,7 @@ export const setupRoutes = (app: any, controllers: Record<string, any>, services
app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter());
app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter());
app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter());
app.use("/api/v1/geo-checks", verifyJWT, geoCheckRoutes.getRouter());
app.use("/api/v1/invite", inviteRoutes.getRouter());
app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter());
app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter());
+207 -83
View File
@@ -1,34 +1,72 @@
import MongoDB from "../db/MongoDB.js";
import NetworkService from "../service/infrastructure/networkService.js";
import EmailService from "../service/infrastructure/emailService.js";
import BufferService from "../service/infrastructure/bufferService.js";
import { IDb } from "@/db/IDb.js";
import {
// Service classes
NetworkService,
EmailService,
BufferService,
GlobalPingService,
SuperSimpleQueue,
SuperSimpleQueueHelper,
NotificationsService,
StatusService,
NotificationMessageBuilder,
MonitorService,
StatusPageService,
UserService,
CheckService,
GeoChecksService,
DiagnosticService,
InviteService,
MaintenanceWindowService,
IncidentService,
// Notification providers
WebhookProvider,
SlackProvider,
EmailProvider,
DiscordProvider,
PagerDutyProvider,
MatrixProvider,
TeamsProvider,
TelegramProvider,
// Interfaces
INetworkService,
IEmailService,
IBufferService,
ISuperSimpleQueue,
INotificationsService,
IStatusService,
IMonitorService,
IUserService,
ICheckService,
IGeoChecksService,
IDiagnosticService,
IInviteService,
IMaintenanceWindowService,
IStatusPageService,
IIncidentService,
INotificationMessageBuilder,
ISettingsService,
SettingsService,
EnvConfig,
} from "@/service/index.js";
import SuperSimpleQueueHelper from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js";
import SuperSimpleQueue from "../service/infrastructure/SuperSimpleQueue/SuperSimpleQueue.js";
import UserService from "../service/business/userService.js";
import CheckService from "../service/business/checkService.js";
import DiagnosticService from "../service/business/diagnosticService.js";
import InviteService from "../service/business/inviteService.js";
import MaintenanceWindowService from "../service/business/maintenanceWindowService.js";
import { MonitorService } from "@/service/index.js";
import { StatusPageService, IStatusPageService } from "../service/business/statusPageService.js";
import IncidentService from "../service/business/incidentService.js";
import { NotificationMessageBuilder, INotificationMessageBuilder } from "../service/infrastructure/notificationMessageBuilder.js";
// Network providers
import { PingProvider } from "@/service/infrastructure/network/PingProvider.js";
import { HttpProvider } from "@/service/infrastructure/network/HttpProvider.js";
import { AdvancedMatcher } from "@/service/infrastructure/network/AdvancedMatcher.js";
import { PageSpeedProvider } from "@/service/infrastructure/network/PageSpeedProvider.js";
import { HardwareProvider } from "@/service/infrastructure/network/HardwareProvider.js";
import { DockerProvider } from "@/service/infrastructure/network/DockerProvider.js";
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";
import got from "got";
import ping from "ping";
import http from "http";
import https from "https";
import Docker from "dockerode";
import net from "net";
import fs from "fs";
@@ -41,22 +79,41 @@ import jwt from "jsonwebtoken";
import crypto from "crypto";
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
// Repositories
import {
MongoMonitorsRepository,
MongoChecksRepository,
MongoGeoChecksRepository,
MongoMonitorStatsRepository,
MongoStatusPagesRepository,
MongoUsersRepository,
MongoInvitesRepository,
MongoRecoveryTokensRepository,
MongoNotificationsRepository,
MongoIncidentRepository,
MongoIncidentsRepository,
MongoTeamsRepository,
MongoMaintenanceWindowsRepository,
MongoSettingsRepository,
TimescaleMonitorsRepository,
TimescaleChecksRepository,
TimescaleGeoChecksRepository,
TimescaleMonitorStatsRepository,
TimescaleStatusPagesRepository,
TimescaleUsersRepository,
TimescaleInvitesRepository,
TimescaleRecoveryTokensRepository,
TimescaleNotificationsRepository,
TimescaleIncidentsRepository,
TimescaleTeamsRepository,
TimescaleMaintenanceWindowsRepository,
TimescaleSettingsRepository,
IMonitorsRepository,
IChecksRepository,
IGeoChecksRepository,
IMonitorStatsRepository,
IStatusPagesRepository,
IUsersRepository,
@@ -69,24 +126,26 @@ import {
IMaintenanceWindowsRepository,
} from "@/repositories/index.js";
import { ILogger } from "@/utils/logger.js";
import { EnvConfig } from "@/service/system/settingsService.js";
import TimescaleDB from "@/db/TimescaleDB.js";
import { AppError } from "@/utils/AppError.js";
export type InitializedServices = {
settingsService: any;
db: any;
networkService: any;
emailService: any;
bufferService: any;
statusService: any;
jobQueue: any;
userService: any;
checkService: any;
diagnosticService: any;
inviteService: any;
maintenanceWindowService: any;
monitorService: any;
incidentService: any;
logger: any;
settingsService: ISettingsService;
db: IDb;
networkService: INetworkService;
emailService: IEmailService;
bufferService: IBufferService;
statusService: IStatusService;
jobQueue: ISuperSimpleQueue;
userService: IUserService;
checkService: ICheckService;
geoChecksService: IGeoChecksService;
diagnosticService: IDiagnosticService;
inviteService: IInviteService;
maintenanceWindowService: IMaintenanceWindowService;
monitorService: IMonitorService;
incidentService: IIncidentService;
logger: ILogger;
notificationsService: INotificationsService;
statusPageService: IStatusPageService;
notificationMessageBuilder: INotificationMessageBuilder;
@@ -94,6 +153,7 @@ export type InitializedServices = {
// Repositories
monitorsRepository: IMonitorsRepository;
checksRepository: IChecksRepository;
geoChecksRepository: IGeoChecksRepository;
monitorStatsRepository: IMonitorStatsRepository;
statusPagesRepository: IStatusPagesRepository;
usersRepository: IUsersRepository;
@@ -110,73 +170,134 @@ export const initializeServices = async ({
logger,
envSettings,
settingsService,
settingsRepository,
}: {
logger: ILogger;
envSettings: EnvConfig;
settingsService: any;
settingsRepository: ISettingsRepository;
settingsService: ISettingsService;
}): Promise<InitializedServices> => {
// Create DB
const db = new MongoDB(logger, envSettings);
const dbType = envSettings.dbType;
let db: IDb | null = null;
if (dbType === "mongodb") {
db = new MongoDB(logger, envSettings);
} else if (dbType === "timescaledb") {
db = new TimescaleDB(logger, envSettings);
}
if (!db) {
throw new AppError({ message: "Unsupported database type", status: 500 });
}
await db.connect();
// Repositories
const monitorsRepository = new MongoMonitorsRepository();
const checksRepository = new MongoChecksRepository(logger);
const monitorStatsRepository = new MongoMonitorStatsRepository();
const statusPagesRepository = new MongoStatusPagesRepository();
const usersRepository = new MongoUsersRepository();
const invitesRepository = new MongoInvitesRepository();
const recoveryTokensRepository = new MongoRecoveryTokensRepository();
const notificationsRepository = new MongoNotificationsRepository();
const incidentsRepository = new MongoIncidentRepository();
const teamsRepository = new MongoTeamsRepository();
const maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
let monitorsRepository: IMonitorsRepository;
let checksRepository: IChecksRepository;
let geoChecksRepository: IGeoChecksRepository;
let monitorStatsRepository: IMonitorStatsRepository;
let statusPagesRepository: IStatusPagesRepository;
let usersRepository: IUsersRepository;
let invitesRepository: IInvitesRepository;
let recoveryTokensRepository: IRecoveryTokensRepository;
let settingsRepository: ISettingsRepository;
let notificationsRepository: INotificationsRepository;
let incidentsRepository: IIncidentsRepository;
let teamsRepository: ITeamsRepository;
let maintenanceWindowsRepository: IMaintenanceWindowsRepository;
const networkService = new NetworkService({
axios,
got,
https,
jmespath,
GameDig,
ping,
logger,
http,
Docker,
net,
settingsService,
});
// Repositories
if (dbType === "mongodb") {
monitorsRepository = new MongoMonitorsRepository();
checksRepository = new MongoChecksRepository(logger);
geoChecksRepository = new MongoGeoChecksRepository(logger);
monitorStatsRepository = new MongoMonitorStatsRepository();
statusPagesRepository = new MongoStatusPagesRepository();
usersRepository = new MongoUsersRepository();
invitesRepository = new MongoInvitesRepository();
recoveryTokensRepository = new MongoRecoveryTokensRepository();
settingsRepository = new MongoSettingsRepository();
notificationsRepository = new MongoNotificationsRepository();
incidentsRepository = new MongoIncidentsRepository();
teamsRepository = new MongoTeamsRepository();
maintenanceWindowsRepository = new MongoMaintenanceWindowsRepository();
} else {
const pool = db.getPool();
if (!pool) {
throw new Error("Failed to get database pool");
}
monitorsRepository = new TimescaleMonitorsRepository(pool);
checksRepository = new TimescaleChecksRepository(pool);
geoChecksRepository = new TimescaleGeoChecksRepository(pool);
monitorStatsRepository = new TimescaleMonitorStatsRepository(pool);
statusPagesRepository = new TimescaleStatusPagesRepository(pool);
usersRepository = new TimescaleUsersRepository(pool);
invitesRepository = new TimescaleInvitesRepository(pool);
recoveryTokensRepository = new TimescaleRecoveryTokensRepository(pool);
settingsRepository = new TimescaleSettingsRepository(pool);
notificationsRepository = new TimescaleNotificationsRepository(pool);
incidentsRepository = new TimescaleIncidentsRepository(pool);
teamsRepository = new TimescaleTeamsRepository(pool);
maintenanceWindowsRepository = new TimescaleMaintenanceWindowsRepository(pool);
}
// Inject settings repository into settings service (now that DB is connected)
(settingsService as SettingsService).setRepository(settingsRepository);
// Network providers
const pingProvider = new PingProvider(ping);
const httpProvider = new HttpProvider(got, new AdvancedMatcher(jmespath));
const pageSpeedProvider = new PageSpeedProvider(httpProvider, settingsService, logger);
const hardwareProvider = new HardwareProvider(httpProvider);
const dockerProvider = new DockerProvider(logger, Docker);
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,
httpProvider,
pageSpeedProvider,
hardwareProvider,
dockerProvider,
portProvider,
gameProvider,
grpcProvider,
webSocketProvider,
]);
const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger);
const notificationMessageBuilder = new NotificationMessageBuilder();
const incidentService = new IncidentService({
const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder);
const checkService = new CheckService(monitorsRepository, logger, checksRepository);
const globalPingService = new GlobalPingService(logger);
const geoChecksService = new GeoChecksService({
logger,
incidentsRepository,
geoChecksRepository,
globalPingService,
monitorsRepository,
usersRepository,
notificationMessageBuilder,
});
const checkService = new CheckService({
monitorsRepository,
logger,
checksRepository,
});
const bufferService = new BufferService({ logger, checkService, settingsService });
const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService);
const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository);
// Notification providers
const webhookProvider = new WebhookProvider(logger);
const slackProvider = new SlackProvider(logger);
const emailProvider = new EmailProvider(emailService, logger);
const discordProvider = new DiscordProvider(logger);
const pagerDutyProvider = new PagerDutyProvider(logger);
const matrixProvider = new MatrixProvider(logger);
const teamsProvider = new TeamsProvider(logger);
const telegramProvider = new TelegramProvider(logger);
const notificationsService = new NotificationsService(
notificationsRepository,
@@ -187,18 +308,21 @@ export const initializeServices = async ({
discordProvider,
pagerDutyProvider,
matrixProvider,
teamsProvider,
telegramProvider,
settingsService,
logger,
notificationMessageBuilder
);
const superSimpleQueueHelper = new SuperSimpleQueueHelper({
const superSimpleQueueHelper = new SuperSimpleQueueHelper(
logger,
networkService,
statusService,
notificationsService,
checkService,
buffer: bufferService,
settingsService,
bufferService,
incidentService,
maintenanceWindowsRepository,
monitorsRepository,
@@ -206,13 +330,11 @@ export const initializeServices = async ({
monitorStatsRepository,
checksRepository,
incidentsRepository,
});
geoChecksService,
geoChecksRepository
);
const superSimpleQueue = await SuperSimpleQueue.create({
logger,
helper: superSimpleQueueHelper,
monitorsRepository,
});
const superSimpleQueue = await SuperSimpleQueue.create(logger, superSimpleQueueHelper, monitorsRepository);
// Business services
const userService = new UserService({
@@ -247,6 +369,7 @@ export const initializeServices = async ({
games,
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
incidentsRepository,
@@ -255,7 +378,6 @@ export const initializeServices = async ({
const statusPageService = new StatusPageService(statusPagesRepository);
const services = {
//v1
settingsService,
db,
networkService,
@@ -265,6 +387,7 @@ export const initializeServices = async ({
jobQueue: superSimpleQueue,
userService,
checkService,
geoChecksService,
diagnosticService,
inviteService,
maintenanceWindowService,
@@ -278,6 +401,7 @@ export const initializeServices = async ({
// Repositories
monitorsRepository,
checksRepository,
geoChecksRepository,
monitorStatsRepository,
statusPagesRepository,
usersRepository,
+84 -53
View File
@@ -1,31 +1,51 @@
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,
loginValidation,
editUserBodyValidation,
createUserBodyValidation,
recoveryValidation,
recoveryTokenBodyValidation,
newPasswordValidation,
} from "@/validation/authValidation.js";
import {
editUserBodyValidation,
createUserBodyValidation,
getUserByIdParamValidation,
editUserByIdParamValidation,
editUserByIdBodyValidation,
editSuperadminUserByIdBodyValidation,
editUserPasswordByIdBodyValidation,
} from "@/validation/joi.js";
} 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;
}
@@ -38,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();
}
await registrationBodyValidation.validateAsync(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",
@@ -59,11 +80,11 @@ class AuthController {
if (userData?.email) {
userData.email = userData.email.toLowerCase();
}
await createUserBodyValidation.validateAsync(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",
@@ -79,7 +100,7 @@ class AuthController {
if (req.body?.email) {
req.body.email = req.body.email?.toLowerCase();
}
await loginValidation.validateAsync(req.body);
loginValidation.parse(req.body);
const { user, token } = await this.userService.loginUser(req.body.email, req.body.password);
return res.status(200).json({
@@ -97,8 +118,10 @@ class AuthController {
editUser = async (req: Request, res: Response, next: NextFunction) => {
try {
await editUserBodyValidation.validateAsync(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,
@@ -125,7 +148,7 @@ class AuthController {
requestRecovery = async (req: Request, res: Response, next: NextFunction) => {
try {
await recoveryValidation.validateAsync(req.body);
recoveryValidation.parse(req.body);
const email = req?.body?.email;
const msgId = await this.userService.requestRecovery(email);
return res.status(200).json({
@@ -140,7 +163,7 @@ class AuthController {
validateRecovery = async (req: Request, res: Response, next: NextFunction) => {
try {
await recoveryTokenBodyValidation.validateAsync(req.body);
recoveryTokenBodyValidation.parse(req.body);
await this.userService.validateRecovery(req.body.recoveryToken);
return res.status(200).json({
success: true,
@@ -153,7 +176,7 @@ class AuthController {
resetPassword = async (req: Request, res: Response, next: NextFunction) => {
try {
await newPasswordValidation.validateAsync(req.body);
newPasswordValidation.parse(req.body);
const { user, token } = await this.userService.resetPassword(req.body.password, req.body.recoveryToken);
return res.status(200).json({
success: true,
@@ -167,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",
@@ -177,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();
@@ -192,19 +240,9 @@ class AuthController {
getUserById = async (req: Request, res: Response, next: NextFunction) => {
try {
await getUserByIdParamValidation.validateAsync(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) {
@@ -214,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 };
await editUserByIdParamValidation.validateAsync(req.params);
const validatedParams = editUserByIdParamValidation.parse(req.params);
// If this is superadmin self edit, allow "superadmin" role
if (userId === req.user?.id) {
await editSuperadminUserByIdBodyValidation.validateAsync(req.body);
} else {
await editUserByIdBodyValidation.validateAsync(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);
@@ -240,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;
await editUserByIdParamValidation.validateAsync(req.params);
await editUserPasswordByIdBodyValidation.validateAsync(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);
+48 -42
View File
@@ -4,21 +4,28 @@ import {
getChecksParamValidation,
getChecksQueryValidation,
getTeamChecksQueryValidation,
getChecksSummaryByTeamIdQueryValidation,
deleteChecksParamValidation,
deleteChecksByTeamIdParamValidation,
updateChecksTTLBodyValidation,
ackCheckBodyValidation,
ackAllChecksParamValidation,
ackAllChecksBodyValidation,
} from "@/validation/joi.js";
} 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;
}
@@ -28,13 +35,20 @@ class CheckController {
getChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
await getChecksParamValidation.validateAsync(req.params);
await getChecksQueryValidation.validateAsync(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({
@@ -49,10 +63,16 @@ class CheckController {
getChecksByTeam = async (req: Request, res: Response, next: NextFunction) => {
try {
await getTeamChecksQueryValidation.validateAsync(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,
@@ -66,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",
@@ -80,11 +103,12 @@ class CheckController {
deleteChecks = async (req: Request, res: Response, next: NextFunction) => {
try {
await deleteChecksParamValidation.validateAsync(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({
@@ -99,9 +123,9 @@ class CheckController {
deleteChecksByTeamId = async (req: Request, res: Response, next: NextFunction) => {
try {
await deleteChecksByTeamIdParamValidation.validateAsync(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,
@@ -112,24 +136,6 @@ class CheckController {
next(error);
}
};
updateChecksTTL = async (req: Request, res: Response, next: NextFunction) => {
try {
await updateChecksTTLBodyValidation.validateAsync(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;
}
@@ -0,0 +1,52 @@
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";
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;
constructor(geoChecksService: IGeoChecksService) {
this.geoChecksService = geoChecksService;
}
get serviceName() {
return GeoCheckController.SERVICE_NAME;
}
getGeoChecksByMonitor = async (req: Request, res: Response, next: NextFunction) => {
try {
const validatedParams = getChecksParamValidation.parse(req.params);
const validatedQuery = getChecksQueryValidation.parse(req.query);
const teamId = requireTeamId(req.user?.teamId);
const result = await this.geoChecksService.getGeoChecksByMonitor({
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({
success: true,
msg: "Geo checks retrieved successfully",
data: result,
});
} catch (error) {
next(error);
}
};
}
export default GeoCheckController;
+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",
+19 -11
View File
@@ -1,12 +1,19 @@
import { Request, Response, NextFunction } from "express";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/joi.js";
import { requireTeamId, requireUserRoles } from "@/controllers/controllerUtils.js";
import { inviteBodyValidation, inviteVerificationBodyValidation } from "@/validation/authValidation.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;
}
@@ -20,7 +27,7 @@ class InviteController {
const userRoles = requireUserRoles(req.user?.role);
const invite = req.body;
invite.teamId = teamId;
await inviteBodyValidation.validateAsync(invite);
inviteBodyValidation.parse(invite);
const inviteToken = await this.inviteService.getInviteToken({ invite, teamId, userRoles });
return res.status(200).json({
success: true,
@@ -34,16 +41,17 @@ 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;
await inviteBodyValidation.validateAsync(inviteRequest);
inviteBodyValidation.parse(inviteRequest);
const inviteToken = await this.inviteService.sendInviteEmail({
invite: inviteRequest,
firstName: req?.user?.firstName,
firstName,
userRoles,
});
return res.status(200).json({
@@ -58,7 +66,7 @@ class InviteController {
verifyInviteToken = async (req: Request, res: Response, next: NextFunction) => {
try {
await inviteVerificationBodyValidation.validateAsync(req.body);
inviteVerificationBodyValidation.parse(req.body);
const invite = await this.inviteService.verifyInviteToken({ inviteToken: req?.body?.token });
return res.status(200).json({
success: true,
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff. Show more