Compare commits

...
Author SHA1 Message Date
Alexander Holliday 1244ff6d93 Merge pull request #3212 from bluewave-labs/develop
develop -> master
2026-01-27 13:58:48 -08:00
Alexander Holliday 53f420cf56 Merge pull request #3211 from bluewave-labs/fix/string-cast
cast to string
2026-01-27 13:58:11 -08:00
Alex Holliday 589825c1c5 format 2026-01-27 21:57:55 +00:00
Alex Holliday 6efc05879a cast to string 2026-01-27 21:54:20 +00:00
Alexander Holliday c0ea804c98 Merge pull request #3209 from bluewave-labs/develop
develop -> master
2026-01-27 13:40:25 -08:00
Alexander Holliday 20dccb140e Merge pull request #3208 from bluewave-labs/fix/docker
docker fix
2026-01-27 13:39:44 -08:00
Alex Holliday 8dfedb8a52 docker fix 2026-01-27 21:26:27 +00:00
Alexander Holliday c21cf29db6 Merge pull request #3207 from bluewave-labs/develop
develop -> master
2026-01-27 13:20:18 -08:00
Alexander Holliday d48cdaca9c Merge pull request #3205 from bluewave-labs/fix/types
fix types
2026-01-27 13:19:13 -08:00
Alex Holliday 36a53ee80b fix types 2026-01-27 21:17:57 +00:00
Alexander Holliday 3d0da70ce6 Merge pull request #3204 from bluewave-labs/develop
develop -> master
2026-01-27 13:09:47 -08:00
Alexander Holliday 4400ca703d Merge pull request #3202 from bluewave-labs/fix/dockerfile
remove console.log, fix docker file
2026-01-27 13:01:52 -08:00
Alex Holliday c1d0cb568d remove console.log, fix docker file 2026-01-27 21:00:28 +00:00
Alexander Holliday 81e73e5adf Merge pull request #3200 from bluewave-labs/fix/checks
fix/checks
2026-01-27 12:52:20 -08:00
Alex Holliday 7a84d06a79 query tweak 2026-01-27 20:49:44 +00:00
Alex Holliday 63d2bb97ee add dateRange to checks query 2026-01-27 20:46:28 +00:00
Alex Holliday 0af7cfe714 generate checks 2026-01-27 20:04:47 +00:00
Alex Holliday d190f5ab02 types 2026-01-27 19:55:52 +00:00
Alexander Holliday 8cfe1d3c1a Merge pull request #3199 from bluewave-labs/feat/recent-checks
feat: recent checks
2026-01-27 11:54:39 -08:00
Alex Holliday 1bf4c75418 format 2026-01-27 19:52:44 +00:00
Alex Holliday 7771a0ce35 resolve errors 2026-01-27 19:50:32 +00:00
Alex Holliday fcd9e78367 infra migration 2026-01-27 19:39:17 +00:00
Alex Holliday 6f9fefddd2 recent checks 2026-01-27 19:24:02 +00:00
Alex Holliday 5731283c46 recent checks 2026-01-27 19:23:34 +00:00
Alex Holliday 420ff292d9 toEntity 2026-01-27 18:58:08 +00:00
Alex Holliday 290ee7d2df proper parsing 2026-01-27 18:50:08 +00:00
Alex Holliday 50c644231e initial commit 2026-01-27 18:26:31 +00:00
Alexander Holliday 5729b0a263 Merge pull request #3197 from bluewave-labs/fix/checks
date limit
2026-01-26 20:07:32 -08:00
Alex Holliday 4f3f5408ef date limit 2026-01-27 04:06:52 +00:00
Alexander Holliday de7038d6c3 Merge pull request #3195 from bluewave-labs/fix/checks
format
2026-01-26 19:40:26 -08:00
Alex Holliday 4119189c71 format 2026-01-27 03:39:55 +00:00
Alexander Holliday fd66affea8 Merge pull request #3193 from ajhollid/fix/dockerfile
fix: dist dir
2026-01-26 15:58:07 -08:00
Alex Holliday 839dc201df dist 2026-01-26 15:57:23 -08:00
Alexander Holliday bbb0716251 Merge pull request #3191 from bluewave-labs/fix/pagespeed-tooltip
fix tooltip styling
2026-01-26 15:46:50 -08:00
Alex Holliday bbdfc00eb9 format 2026-01-26 23:45:28 +00:00
Alexander Holliday ba498534e6 Merge pull request #3190 from bluewave-labs/fix/migration
fix: migration
2026-01-26 15:44:50 -08:00
Alex Holliday f51bf36ddd fix tooltip styling 2026-01-26 23:44:40 +00:00
Alex Holliday 716479c5bb format 2026-01-26 23:21:49 +00:00
Alex Holliday f9c85e2d76 indexes 2026-01-26 23:20:18 +00:00
Alex Holliday e26f3facf1 use creatdAt 2026-01-26 23:15:59 +00:00
Alex Holliday fd5f493345 improve migration 2026-01-26 23:02:30 +00:00
Alexander Holliday e5bb736530 Merge pull request #3189 from bluewave-labs/feat/pagespeed-details
feat: v2 pagespeed details
2026-01-26 14:34:56 -08:00
Alex Holliday d71d16ecd0 refresh interval 2026-01-26 22:32:08 +00:00
Alex Holliday 389716fa77 pretty-ms 2026-01-26 22:29:28 +00:00
Alex Holliday 1bcb2ddf91 pagespeed v2 refactor 2026-01-26 22:28:02 +00:00
Alex Holliday b61077bd3f remove unsued strings, initial commit 2026-01-26 21:14:04 +00:00
Alexander Holliday 1cffd51bd3 Merge pull request #3188 from bluewave-labs/feat/v2-pagespeed
feat/v2 pagespeed
2026-01-26 12:50:30 -08:00
Alex Holliday 451e352458 types 2026-01-26 20:47:29 +00:00
Alex Holliday a07e41d8f1 format 2026-01-26 20:47:24 +00:00
Alex Holliday 90e9b41973 pagespeed key alert 2026-01-26 20:47:10 +00:00
Alex Holliday 1819df5117 priority fallback 2026-01-26 20:27:11 +00:00
Alex Holliday 6380dcc6ba summary 2026-01-26 20:05:11 +00:00
Alex Holliday 6cb45ea6de delete pagepseed 2026-01-26 19:36:29 +00:00
Alex Holliday 67f74dd7de fix: prevent horizontal scrollbar in table by adding overflowX hidden and making histogram responsive 2026-01-26 18:28:55 +00:00
Alex Holliday 8f58ceafab fix: add overflowX hidden to table cells to prevent horizontal scrollbar 2026-01-26 18:27:12 +00:00
Alex Holliday c7aa45cd77 initial commit 2026-01-26 18:24:58 +00:00
Alexander Holliday d0727f91dd Merge pull request #3186 from bluewave-labs/feat/v2-uptime-details
feat/v2 uptime details
2026-01-26 09:16:12 -08:00
Alex Holliday 967b2b200b build errors 2026-01-26 17:15:08 +00:00
Alex Holliday d71c7c6ee1 format 2026-01-26 17:11:09 +00:00
Alexander Holliday 6daa79f0ed Merge pull request #3187 from bluewave-labs/maintenance-canlendar-dark-theme
fix maintenance datepicker theme color
2026-01-26 09:07:50 -08:00
karenvicent 93a5eff562 fix maintenance datepicker theme color 2026-01-26 11:40:44 -05:00
Alex Holliday 11c95f885d bucket date 2026-01-26 03:19:33 +00:00
Alex Holliday 174665e70a remove unused 2026-01-26 02:55:52 +00:00
Alex Holliday 64de01437e remove toast styling 2026-01-26 02:48:56 +00:00
Alex Holliday a64885e609 checks table 2026-01-26 02:37:59 +00:00
Alex Holliday e354fa7ebf response time chart 2026-01-26 02:06:37 +00:00
Alex Holliday 3208cc9e2c strings and icons 2026-01-25 16:48:56 +00:00
Alex Holliday 79dfa886f1 upchart 2026-01-25 16:31:54 +00:00
Alex Holliday 8af8efbe3b toggle-button-group 2026-01-25 16:09:48 +00:00
Alex Holliday 71bd1fa28f time range header 2026-01-25 15:56:15 +00:00
Alex Holliday a2569c50c7 stat boxes 2026-01-25 04:09:40 +00:00
Alex Holliday f7ae11997d monitor controls header 2026-01-25 03:20:58 +00:00
Alexander Holliday 0b7a41dc78 Merge pull request #3184 from bluewave-labs/fix/password-tooltip-theme-contrast
Fix password tooltip theme contrast issues
2026-01-24 18:12:24 -08:00
Alex Holliday d42d5f2325 initial commit 2026-01-25 02:09:33 +00:00
gorkem-bwl 369d16c7c3 fix: resolve password tooltip theme contrast and translation issues
- Fix tooltip background using non-existent tertiary.background
  property by switching to tertiary.main
- Fix Check component text color inheritance for noHighlightText
  by adding color: inherit to inner Typography
- Add translation wrapper for validation error messages in PasswordPanel
- Add translation wrapper for validation error messages in ProfilePanel
2026-01-24 19:52:06 -05:00
Alexander Holliday 6e8172c126 Merge pull request #3183 from bluewave-labs/feat/v2/uptime
feat: v2-uptime
2026-01-24 12:43:09 -08:00
Alex Holliday 794018a6ab simplify breadcrumbs 2026-01-24 20:41:21 +00:00
Alex Holliday 8d6bc7bc0b udpate API client 2026-01-24 20:26:05 +00:00
Alex Holliday 30631d6ff8 fix setRowsPerPage 2026-01-24 16:41:40 +00:00
Alex Holliday 56c5ad419a format settings 2026-01-24 16:20:09 +00:00
Alex Holliday b852293447 header create 2026-01-24 16:19:43 +00:00
Alex Holliday d877ecf061 Merge branch 'feat/v2/uptime' of https://github.com/bluewave-labs/Checkmate into feat/v2/uptime 2026-01-24 16:19:15 +00:00
Alexander Holliday 73e90bfab6 Merge pull request #3182 from bluewave-labs/fix/monitor-utils-naming-conflict
fix: resolve monitorUtils naming conflict on case-insensitive filesystems
2026-01-24 08:10:20 -08:00
gorkem-bwl a55163de56 fix: resolve monitorUtils naming conflict on case-insensitive filesystems
## Changes
- Rename monitorUtils.js to monitorUtilsLegacy.js to avoid conflict with MonitorUtils.ts
- Update imports in IncidentActionsMenu, JobTable, FailedJobTable, and PageSpeed/Create
2026-01-24 11:03:47 -05:00
Alex Holliday f5c6abad1b header create 2026-01-24 16:01:32 +00:00
Alex Holliday 3dbbe035a8 stacking fields 2026-01-24 01:08:44 +00:00
Alex Holliday 9904626526 chart type option 2026-01-24 00:51:13 +00:00
Alex Holliday 61f3aa1d1c reenable sorting 2026-01-24 00:29:19 +00:00
Alex Holliday a0a8ceb0db uptime 2026-01-23 23:54:58 +00:00
Alex Holliday c2dece862c controls 2026-01-23 21:30:34 +00:00
Alex Holliday 69018a4944 components 2026-01-23 12:33:40 -08:00
Alex Holliday 363d89e4f4 initial commit 2026-01-23 19:58:37 +00:00
Alexander Holliday f8bb01190e Merge pull request #3179 from bluewave-labs/feat/swr-hook
add SWR hook and API client
2026-01-23 10:18:09 -08:00
Alex Holliday 00cf0b6521 fix get return type 2026-01-23 18:14:16 +00:00
Alex Holliday a11d551a99 add SWR hook and API client 2026-01-23 17:38:30 +00:00
Alexander Holliday b35be1ecfc Merge pull request #3178 from bluewave-labs/fix/incident-details
remove extra network request
2026-01-22 14:08:09 -08:00
Alexander Holliday 9d64de57b3 Merge pull request #3177 from bluewave-labs/fix/datepicker-dark-mode-contrast
fix: DatePicker calendar popup contrast in dark mode
2026-01-22 14:06:26 -08:00
Alex Holliday de6c4d9c3e remove extra network request 2026-01-22 22:05:18 +00:00
gorkem-bwl e0e79fb0da fix: DatePicker calendar popup contrast in dark mode
## Changes
- Add global theme overrides for MUI DatePicker components (MuiPickersPopper,
  MuiDateCalendar, MuiPickersCalendarHeader, MuiDayCalendar, MuiPickersDay)
- Remove inline popper styling from CreateMaintenance component as it's now
  handled globally
- Calendar popup now properly adapts to both light and dark themes using
  theme palette tokens
2026-01-22 17:05:06 -05:00
Alexander Holliday 3339b6b67f Merge pull request #3176 from bluewave-labs/fix/purge-js
remove final js
2026-01-22 13:51:59 -08:00
Alex Holliday b511cde849 remove final js 2026-01-22 21:49:49 +00:00
Alexander Holliday ce322a8e28 Merge pull request #3175 from bluewave-labs/fix/incident-details
fix: purge incident module
2026-01-22 13:30:42 -08:00
Alex Holliday d25ca473de purge incident module 2026-01-22 21:28:28 +00:00
Alexander Holliday 5292ca8988 Merge pull request #3174 from bluewave-labs/feat/status-page-service-and-repo
feat/status page service and repo
2026-01-22 13:04:30 -08:00
Alex Holliday c98cba2eef shutdown -> ts 2026-01-22 21:02:34 +00:00
Alex Holliday 6b89db131d remove statuspagemodule 2026-01-22 21:01:45 +00:00
Alex Holliday 995ae5252d format 2026-01-22 20:57:45 +00:00
Alex Holliday e6c268f14e status page service/repo refactor 2026-01-22 20:57:20 +00:00
Alexander Holliday 5aef405414 Merge pull request #3173 from bluewave-labs/fix-uptime-monitor-redirection-incident
Fix uptime monitor redirection incident
2026-01-22 12:30:05 -08:00
karenvicent a5112a7955 Merge branch 'develop' into fix-uptime-monitor-redirection-incident 2026-01-22 15:09:29 -05:00
karenvicent 5404b65ce2 Fixed the navigation from an uptime monitor into Incidents 2026-01-22 15:08:24 -05:00
Alexander Holliday 34dfe33c29 Merge pull request #3170 from bluewave-labs/fix/light-dark-mode-theme
fix: sidebar and body background not adapting to light mode
2026-01-22 11:58:13 -08:00
Alexander Holliday 1e24c8841e Merge pull request #3172 from bluewave-labs/fix/fe/incident-detail-resolved
Fix: Incident Detail UI Layout
2026-01-22 11:52:46 -08:00
mannilakash 0c04c06781 fix: sidebar border color changes based on theme (primary->lowContrast color) 2026-01-22 14:48:11 -05:00
Br0wnHammer 7441169a0e Fix: Incident Detail UI Layout 2026-01-23 00:47:57 +05:30
Alexander Holliday 3520e5adf4 Merge pull request #3171 from bluewave-labs/fix/fe/incident-page-ui
Fix: Incidents Page Detail
2026-01-22 11:04:42 -08:00
Br0wnHammer 227e0128f9 Fix: Incidents Page Detail 2026-01-23 00:31:24 +05:30
Alexander Holliday 2359255ef7 Merge pull request #3169 from bluewave-labs/feat/maintenance-window-repository
feat/maintenance window repository
2026-01-22 10:14:22 -08:00
Alex Holliday b074f2401f format 2026-01-22 18:12:56 +00:00
Alex Holliday eee3caf021 remove old db module 2026-01-22 18:11:54 +00:00
Alex Holliday f9848e4e1e update fe 2026-01-22 17:53:54 +00:00
Alex Holliday 8bd608bee0 implement remaining methods 2026-01-22 17:44:03 +00:00
gorkem-bwl ea589d6be4 fix: sidebar and body background not adapting to light mode
## Changes

### Sidebar
- Remove hardcoded `backgroundColor: "#000000"`
- Use `theme.palette.background.main` for dynamic theming
- Update border color to use `theme.palette.border.light`

### Body Background
- Remove hardcoded `background-color: #000000` from index.css
- Add GlobalStyles in App.jsx to apply theme background to body element

## Benefits
- Sidebar now properly displays in light mode
- Body background adapts correctly to theme changes
- Consistent theming across light and dark modes
2026-01-22 12:27:18 -05:00
Alexander Holliday 5a0078cf92 Merge pull request #3167 from bluewave-labs/incidents-feat-modal
Incidents action button and detail modal
2026-01-22 09:06:53 -08:00
karenvicent f4a5bd65ad Merge branch 'develop' into incidents-feat-modal 2026-01-22 11:11:02 -05:00
karenvicent 4766594413 fix function duplicated and log 2026-01-22 10:58:47 -05:00
Alex Holliday 9962866ba5 refactor 2026-01-21 23:26:13 +00:00
Alex Holliday 82cc289f53 mw type 2026-01-21 14:26:17 -08:00
Alex Holliday 117c22fae4 initial commit 2026-01-21 14:22:53 -08:00
Alexander Holliday e0cabdb4fc Merge pull request #3168 from bluewave-labs/fix/notifications-service
fix: refactor notifications service
2026-01-21 13:56:43 -08:00
Alex Holliday 155a2577d0 remove unused dep 2026-01-21 13:55:22 -08:00
Alex Holliday d627a25b40 refactor notifications service 2026-01-21 13:51:04 -08:00
karenvicent b8a5af9c10 delete detail incident page 2026-01-21 16:12:42 -05:00
Alexander Holliday 5b43d9d173 Merge pull request #3166 from ajhollid/fix/db-modules
fix/db modules
2026-01-21 13:03:37 -08:00
Alex Holliday 801b716a03 parse settings 2026-01-21 13:01:43 -08:00
Alex Holliday fdc3983328 remove settingsModule 2026-01-21 13:01:31 -08:00
karenvicent 0cf85f8b3b add traslation 2026-01-21 15:50:46 -05:00
karenvicent 0ea4a5024a Merge branch 'develop' into incidents-feat-modal 2026-01-21 15:35:22 -05:00
karenvicent 09513696ee detail modal fixes and filters options 2026-01-21 15:33:00 -05:00
Alexander Holliday b0da1f4259 Merge pull request #3165 from ajhollid/fix/team-ts
fix/team ts
2026-01-21 11:57:21 -08:00
Alex Holliday 5b96075c03 build errs 2026-01-21 11:56:05 -08:00
Alex Holliday 36e02e0a9a team -> ts 2026-01-21 11:46:49 -08:00
Alex Holliday 9fa0113f2b remove database.js 2026-01-21 11:21:00 -08:00
Alex Holliday 371e8cc154 purge translationService 2026-01-21 11:20:41 -08:00
Alexander Holliday 7b4286646b Merge pull request #3163 from ajhollid/fix/app
fix: app -> ts
2026-01-21 11:20:03 -08:00
Alex Holliday 2a8d2cb06a app -> ts 2026-01-21 11:18:13 -08:00
Alexander Holliday 147eff3ede Merge pull request #3162 from ajhollid/fix/routes
fix/routes
2026-01-21 10:37:21 -08:00
Alex Holliday 81c7c992e2 routes -> ts 2026-01-21 10:36:03 -08:00
Alex Holliday 35488fed3e Merge branch 'develop' into fix/routes 2026-01-21 10:18:55 -08:00
Alexander Holliday 760abe69ed Merge pull request #3161 from bluewave-labs/fix/cleanup
Fix/cleanup
2026-01-21 10:18:44 -08:00
Alex Holliday d242382abf format 2026-01-21 10:16:17 -08:00
Alex Holliday ef61a63da0 parse query 2026-01-21 10:15:13 -08:00
Alex Holliday 3fa370765e purge papaparse 2026-01-21 09:52:31 -08:00
Alexander Holliday 3b5e793f8f Merge pull request #3156 from bluewave-labs/fix/monitor/pause-button
Fix: Configure Pause Button
2026-01-21 09:20:53 -08:00
Br0wnHammer b34dbec3cf Fix: updateTrigger val 2026-01-21 18:47:38 +05:30
karenvicent 90eee474d2 Merge branch 'develop' into incidents-feat-modal 2026-01-20 21:03:07 -05:00
karenvicent 63416ceb4b fix import routes 2026-01-20 20:58:35 -05:00
karenvicent eb8c9bea76 Merge branch 'develop' into incidents-feat-modal 2026-01-20 20:46:02 -05:00
Alex Holliday 63b4779961 monitorSerivce return types 2026-01-21 00:01:38 +00:00
Alexander Holliday d2040495d6 Merge pull request #3160 from bluewave-labs/fix/remove-service-registry
fix/remove service registry
2026-01-20 15:10:20 -08:00
Alex Holliday f436e15569 fix dep 2026-01-20 23:10:08 +00:00
Alex Holliday 952c70fca4 remove v1 dir 2026-01-20 23:07:31 +00:00
Alex Holliday 4957209307 purge string service 2026-01-20 23:06:23 +00:00
Alex Holliday cc9850c975 logger->ts 2026-01-20 22:56:14 +00:00
Alex Holliday c6ac48b4ff rateLimiter -> ts, remove resonse handler 2026-01-20 22:50:34 +00:00
karenvicent 82584d85c7 detail modal 2026-01-20 17:49:54 -05:00
Alex Holliday ffd67150eb sanitization ts 2026-01-20 22:47:06 +00:00
Alex Holliday 44079f2eb8 remove service regsitry, remove unused middleware 2026-01-20 22:40:06 +00:00
Alexander Holliday f03ded4109 Merge pull request #3159 from bluewave-labs/feat/email-service-ts
feat: email-service js->ts
2026-01-20 14:08:32 -08:00
Alex Holliday fa520e5f88 js->ts 2026-01-20 22:06:55 +00:00
Alexander Holliday e9fbb472f4 Merge pull request #3158 from bluewave-labs/feat/diagnostic-service-ts
feat: diagnostic-service js->ts
2026-01-20 13:55:13 -08:00
Alex Holliday de236f03eb js->ts 2026-01-20 21:53:19 +00:00
Alexander Holliday 4f043fe1d4 Merge pull request #3157 from bluewave-labs/feat-checks-repository
Feat checks repository
2026-01-20 13:36:09 -08:00
Alex Holliday 102bd0a5f8 format 2026-01-20 21:34:40 +00:00
Alex Holliday 118372630e return types 2026-01-20 21:31:53 +00:00
Alex Holliday 27dea6a08e implement remaining check methods 2026-01-20 21:18:26 +00:00
Br0wnHammer dd259fea89 Fix: One source of truth 2026-01-21 02:02:16 +05:30
Alex Holliday 9af25a19e5 findByMonitorId 2026-01-20 20:14:23 +00:00
Br0wnHammer 6101fe1bfa Fix: Configure Pause Button 2026-01-21 01:42:00 +05:30
Alexander Holliday 795146c79b Merge pull request #3155 from bluewave-labs/fix/notifications
fix: replaced monitor with updatedMonitor
2026-01-20 11:51:29 -08:00
Alex Holliday a69091bb28 replaced monitor with updatedMonitor 2026-01-20 19:49:40 +00:00
Alexander Holliday 6160806963 Merge pull request #3154 from bluewave-labs/fix/checks
fix/checks
2026-01-20 11:47:45 -08:00
Alex Holliday c1b2bda623 _id -> id 2026-01-20 19:41:57 +00:00
Alex Holliday c0e4893f96 remove ack service and db methods 2026-01-20 19:26:46 +00:00
Alex Holliday 476ff39a17 remove check ack routes and controller functions 2026-01-20 19:25:00 +00:00
Alexander Holliday f467ef1411 Merge pull request #3153 from bluewave-labs/fix/incidents
fix/incidents
2026-01-20 11:24:21 -08:00
Alex Holliday 36b02e5e66 Merge branch 'fix/incidents' into fix/checks 2026-01-20 19:22:18 +00:00
Alex Holliday f6fd8729c3 remove dependency 2026-01-20 19:22:04 +00:00
Alex Holliday cdfc87da09 remove ack 2026-01-20 19:21:03 +00:00
Alex Holliday 427e5a18ea incidents -> checks 2026-01-20 19:12:05 +00:00
Alex Holliday f696b4ef9b Merge branch 'develop' into fix/incidents 2026-01-20 19:08:03 +00:00
Alex Holliday 69d050ff61 remove incidents from buffer 2026-01-20 19:04:30 +00:00
Alex Holliday 7afdcb8ebc fix query param 2026-01-20 19:01:50 +00:00
Alex Holliday 3cf7243c93 summary 2026-01-20 18:56:06 +00:00
Alexander Holliday 4ed0033a41 Merge pull request #3136 from bluewave-labs/fix/various-ui-ux-fixes
fix: various UI/UX improvements for infrastructure pages
2026-01-20 09:18:30 -08:00
Alexander Holliday bafd82a691 Merge pull request #3147 from bluewave-labs/fix/sidebar-scroll-gap
Fix sidebar gap when scrolling on pages with tall content
2026-01-20 09:17:24 -08:00
Alex Holliday adb2a383cf refactor 2026-01-20 00:02:27 +00:00
Alex Holliday e91a54b75c initial commit 2026-01-19 23:41:24 +00:00
Alexander Holliday 33dfedb4d3 Merge pull request #3152 from bluewave-labs/feat/notifications-refactor
feat/notifications refactor
2026-01-19 14:20:36 -08:00
Alex Holliday dde54a4e45 remove old service 2026-01-19 22:18:17 +00:00
Alex Holliday 5f17520ba0 logging 2026-01-19 22:16:33 +00:00
Alex Holliday bebb5d6942 inject logger 2026-01-19 21:58:48 +00:00
Alex Holliday 7c9410f9d7 test alerts 2026-01-19 21:39:02 +00:00
Alexander Holliday 348ca0dd02 Merge pull request #3151 from bluewave-labs/docs/update-openapi-specs
docs: update OpenAPI specification to match current API
2026-01-19 12:19:16 -08:00
Alex Holliday 1d36f40caf slack 2026-01-19 20:12:54 +00:00
Alex Holliday 9006707e57 discord 2026-01-19 20:03:20 +00:00
Alex Holliday be82a7ee2d discord 2026-01-19 19:58:15 +00:00
Alex Holliday fd33b903ed email provider 2026-01-19 19:30:46 +00:00
gorkem-bwl ab25531758 docs: fix OpenAPI spec accuracy and warnings
## Changes

### Route Corrections
- Remove non-existent `/auth/refresh` endpoint
- Add missing `/auth/users/{userId}/password` PUT endpoint
- Fix `/notifications` GET -> `/notifications/team` GET (correct path)

### OpenAPI 3.1.0 Compliance
- Fix `nullable` warnings by using `type: ["string", "null"]` syntax
- Updated Incident schema nullable fields (endTime, message, statusCode, etc.)
- Updated QueueJob schema nullable fields (processedOn, finishedOn)
- Updated Check schema nullable field (ackAt)

### Verified Against Codebase
- Audited all 76 API endpoints across 12 route files
- Confirmed DELETE /checks/team and DELETE /checks/{monitorId} exist
- All routes now match actual codebase implementation

## Validation
- Passes `swagger-cli validate`
- Only remaining warnings are cosmetic `example` deprecations
2026-01-19 14:13:18 -05:00
Alex Holliday f7f764fd68 providers 2026-01-19 18:58:17 +00:00
gorkem-bwl 3b0326457f docs: update OpenAPI specification to match current API
## Changes

### New Endpoints Added
- `/incidents/team` - Get incidents by team with filtering
- `/incidents/team/summary` - Get incident summary statistics
- `/incidents/{incidentId}` - Get incident by ID
- `/incidents/{incidentId}/resolve` - Manually resolve incident
- `/queue/jobs` (GET) - List queue jobs (admin only)
- `/queue/jobs` (POST) - Add job to queue (admin only)
- `/queue/metrics` - Get queue metrics (admin only)
- `/monitors/team/groups` - Get monitor groups by team
- `/monitors/pagespeed/details/{monitorId}` - Get PageSpeed details
- `/checks/{action}` - Acknowledge all checks for all monitors
- `/checks/{action}/{monitorId}` - Acknowledge checks for specific monitor

### Endpoints Fixed
- `/monitors/export` changed to `/monitors/export/json` (matches actual route)
- Removed non-existent `/monitors/stats/{monitorId}` endpoint
- Removed non-existent `/monitors/team/summary` endpoint
- Split optional path parameter into two valid endpoints for checks

### New Schemas Added
- `Incident` - Complete incident model
- `IncidentSummary` - Incident statistics summary
- `QueueJob` - Queue job information
- `QueueMetrics` - Queue metrics data
- `Check` - Complete check result model
- `CheckTimings` - HTTP timing information
- `CheckCpuInfo` - CPU metrics for infrastructure checks
- `CheckMemoryInfo` - Memory metrics for infrastructure checks
- `CheckDiskInfo` - Disk metrics for infrastructure checks
- `CheckHostInfo` - Host information for infrastructure checks
- `CheckErrorInfo` - Error information from checks
- `CheckCaptureInfo` - Capture agent information
- `CheckNetworkInfo` - Network interface metrics
- `CheckAudits` - PageSpeed audit results
- `LighthouseAudit` - Individual Lighthouse audit result

### Responses Added
- `BadRequest` - 400 error response

### Other Fixes
- Simplified server variables to fix OpenAPI 3.1.0 validation
- Added request body to `/checks/check/{checkId}` endpoint
- Added "incidents" tag to tags array

## Benefits
- OpenAPI specification now accurately reflects the actual API
- All endpoints have proper request/response schemas
- Validation passes with swagger-cli
- Better API documentation for consumers
2026-01-19 13:49:08 -05:00
Alex Holliday 7d6bc4b2dc notificaitons provider 2026-01-19 00:48:20 +00:00
Alex Holliday c2487f0134 alerts 2026-01-18 23:54:42 +00:00
Alex Holliday a86588f080 refactoring notifications 2026-01-18 23:38:40 +00:00
Alex Holliday 4e9839e2e5 notificationsService 2026-01-18 22:52:59 +00:00
gorkem-bwl 487f6b222d refactor: extract sidebar width logic into useSidebar hook
Create shared useSidebar hook to centralize sidebar width and transition
logic, addressing code review feedback about duplicated logic between
Sidebar and HomeLayout components.

Changes:
- Add useSidebar hook with collapsed state, width, and transition values
- Update Sidebar to use the hook instead of inline selectors and values
- Update HomeLayout to use the hook for spacer element styling

This ensures sidebar width logic is defined in one place and stays in sync.
2026-01-18 14:11:05 -05:00
Alex Holliday 391f42ee53 add webhook provider and utils 2026-01-18 16:59:38 +00:00
Alex Holliday cff5508614 notification provider 2026-01-18 16:34:20 +00:00
Alex Holliday a0ebf7d224 initial commit 2026-01-18 06:02:07 +00:00
Alexander Holliday ec9c79fc94 Merge pull request #3150 from bluewave-labs/feat/invite-repo
feat: invite-service to TS
2026-01-17 21:43:11 -08:00
Alex Holliday 3e8d4cb49b convert to ts 2026-01-18 05:38:50 +00:00
Alexander Holliday 288ac08cd6 Merge pull request #3149 from bluewave-labs/feat/user-service-ts
feat/user service ts
2026-01-17 11:09:29 -08:00
Alex Holliday ba35230919 remove db references from userService 2026-01-17 19:08:12 +00:00
Alex Holliday 048e53ea15 setting service 2026-01-17 18:29:42 +00:00
Alex Holliday e498bd60b1 team panel fix 2026-01-17 05:41:52 +00:00
gorkem-bwl a4cf53ee15 Set html/body background to black and disable overscroll bounce
- Added background-color: #000000 to prevent white flash on overscroll
- Added overscroll-behavior: none to disable elastic bounce effect
2026-01-16 22:59:25 -05:00
gorkem-bwl 3a77c6d7fa Fix sidebar gap when scrolling on pages with tall content
The sidebar was showing a visible gap at the bottom when scrolling on
pages with tall tables (e.g., /checks). This occurred because the
parent container's overflow-x: hidden was breaking sticky positioning.

Changes:
- Changed sidebar from position: sticky to position: fixed
- Added a spacer Box element to maintain layout spacing
- Moved border and background styles to the sidebar component
- Cleaned up unused CSS selectors
2026-01-16 22:26:43 -05:00
Alex Holliday b47f3710fe refactor user-service 2026-01-16 22:41:12 +00:00
Alex Holliday 9c92542653 recovery 2026-01-16 22:03:35 +00:00
Alexander Holliday 173a4e17d2 Merge pull request #3138 from bluewave-labs/fix/password-special-chars
fix: remove <> from allowed password characters and display allowed special chars
2026-01-16 13:17:12 -08:00
Alexander Holliday 70b197675e Merge pull request #3146 from bluewave-labs/fix/pagespeed-snapshot
remove pagespeed from snapshot array, full check list required
2026-01-16 13:16:37 -08:00
Alex Holliday 11dcccc085 remove pagespeed from snapshot array, full check list required 2026-01-16 21:16:05 +00:00
Alexander Holliday e3e08da259 Merge pull request #3145 from bluewave-labs/fix/pagespeed-stats
fix: stats fixes
2026-01-16 13:06:49 -08:00
Alex Holliday 6447c315ce stats fixes 2026-01-16 21:04:41 +00:00
Alex Holliday cb5b8e0673 update user refactor 2026-01-16 20:29:54 +00:00
Alex Holliday 88bb8ef2d3 Merge branch 'fix/responsive-layout-overflow' into feat/user-service-ts 2026-01-16 19:15:24 +00:00
Alex Holliday 64dc381e0f separate user from token 2026-01-16 19:14:03 +00:00
Alex Holliday af82dec2ad separate user from token 2026-01-16 19:12:21 +00:00
gorkem-bwl 7436c2b5df fix: remove unnecessary dateRange param now that backend defaults it 2026-01-16 13:12:00 -05:00
gorkem-bwl 2055943876 fix: make dateRange optional for hardware details endpoint
The Joi validation marks dateRange as optional, but the controller was using
requireString() which throws a 400 error when dateRange is missing.

Changed to optionalString() with "recent" as the default value, matching
the behavior of other similar endpoints and the Joi validation schema.
2026-01-16 13:11:41 -05:00
gorkem-bwl d91e87f484 fix: remove <> from allowed password characters and display allowed special chars
## Changes
- Remove < and > from password regex pattern in joi.js (these chars are
  stripped by DOMPurify sanitization which caused confusing validation errors)
- Update locale strings to display the list of allowed special characters
  in password tooltip, error messages, and PasswordPanel requirements

## Benefits
- Users now see exactly which special characters are allowed when setting passwords
- Eliminates confusing "password cannot be empty" errors when using < or >
- Regex now accurately reflects the characters that actually work

Fixes #3010
2026-01-15 22:50:29 -05:00
gorkem-bwl bf15018173 fix: various UI/UX improvements for infrastructure pages
## Changes
- Fix infrastructure monitor configure page loading error by adding required dateRange parameter
- Fix URL validation error when editing existing infrastructure monitors
- Fix monitor update using correct 'id' property instead of '_id'
- Fix gauge box labels: "Device" (capitalized) and "Mounted on" instead of "mountpoint"
- Show full device path instead of truncated ".../" prefix
- Implement CSS Grid layout for gauge boxes ensuring consistent widths across all rows
- Gauge boxes expand to fill space only when 4+ boxes exist

## Files Modified
- client/src/Pages/Infrastructure/Create/index.jsx
- client/src/Pages/Infrastructure/Create/hooks/useInfrastructureSubmit.jsx
- client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/index.jsx
- client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/Gauge.jsx
- client/src/Pages/Infrastructure/Details/Components/BaseContainer/index.jsx
- client/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx
- client/src/locales/en.json
2026-01-15 22:04:56 -05:00
karenvicent 86a2171988 Merge branch 'develop' into incidents-feat-modal 2026-01-15 11:39:32 -05:00
karenvicent 1c01dcaf31 create incidents action menu and detail page 2026-01-14 22:12:26 -05:00
Alexander Holliday feb061240b Merge pull request #3042 from bluewave-labs/develop
develp->master
2025-10-27 12:09:00 -07:00
Alexander Holliday a924227805 Merge pull request #2964 from bluewave-labs/develop
develop -> master
2025-09-18 13:40:45 -07:00
Alexander Holliday 2144d3f8c0 Merge pull request #2962 from bluewave-labs/develop
develop -> master
2025-09-18 12:04:51 -07:00
Alexander Holliday fcd6786149 Merge pull request #2936 from bluewave-labs/develop
develop -> master
2025-09-08 20:43:21 -07:00
Alexander Holliday ea968ebf3c Merge pull request #2933 from bluewave-labs/develop
develop -> master
2025-09-08 20:33:35 -07:00
Alexander Holliday 8a7b852974 Merge pull request #2929 from bluewave-labs/develop
develop -> master
2025-09-08 07:24:16 -07:00
Alexander Holliday c8d3e3b70c Merge pull request #2921 from bluewave-labs/develop
develop -> master
2025-09-04 15:46:30 -07:00
Alexander Holliday 7a90a6f9f4 Merge pull request #2915 from bluewave-labs/develop
develop->master
2025-09-01 10:33:09 -07:00
341 changed files with 14864 additions and 9421 deletions

No files matched your search

+2 -2
View File
@@ -26,7 +26,7 @@
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"mui-color-input": "^6.0.0",
"pretty-ms": "9.3.0",
"pretty-ms": "^9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
"react-hook-form": "7.63.0",
@@ -38,7 +38,7 @@
"react-toastify": "^10.0.5",
"recharts": "2.15.2",
"redux-persist": "6.0.0",
"swr": "2.3.6",
"swr": "^2.3.6",
"vite-plugin-svgr": "^4.2.0",
"zod": "4.1.11"
},
+2 -2
View File
@@ -31,7 +31,7 @@
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"mui-color-input": "^6.0.0",
"pretty-ms": "9.3.0",
"pretty-ms": "^9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
"react-hook-form": "7.63.0",
@@ -43,7 +43,7 @@
"react-toastify": "^10.0.5",
"recharts": "2.15.2",
"redux-persist": "6.0.0",
"swr": "2.3.6",
"swr": "^2.3.6",
"vite-plugin-svgr": "^4.2.0",
"zod": "4.1.11"
},
+18 -2
View File
@@ -22,13 +22,29 @@ function App() {
};
}, []);
const theme = mode === "light" ? lightTheme : darkTheme;
return (
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
<ThemeProvider theme={theme}>
<CssBaseline />
<GlobalStyles
styles={{
body: {
backgroundColor: theme.palette.background.main,
},
}}
/>
<AppLayout>
<Routes />
</AppLayout>
<ToastContainer />
<ToastContainer
newestOnTop={true}
theme={mode}
style={{
"--toastify-color-progress-light": "#7C8BA1",
"--toastify-color-progress-dark": "#7C8BA1",
}}
/>
</ThemeProvider>
);
}
@@ -32,7 +32,6 @@ const ActionsMenu = ({
event.preventDefault();
event.stopPropagation();
let monitor = { id: actions.id };
console.log(monitor);
await deleteMonitor({ monitor });
updateRowCallback();
};
@@ -1,6 +1,6 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Tooltip, Typography } from "@mui/material";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { useEffect, useState } from "react";
import "./index.css";
import { useSelector } from "react-redux";
@@ -5,7 +5,7 @@ import { Stack, Box, Tooltip, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { useSelector } from "react-redux";
const PlaceholderCheck = ({ daysToShow }) => {
@@ -12,7 +12,7 @@ import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { useMemo, useState } from "react";
import { useSelector } from "react-redux";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import {
tooltipDateFormatLookup,
tickDateFormatLookup,
@@ -1,6 +1,6 @@
import { useTheme } from "@emotion/react";
import { Box, Stack, Tooltip, Typography } from "@mui/material";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { useEffect, useState, forwardRef } from "react";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
@@ -148,7 +148,7 @@ const StatusPageBarChart = ({ checks = [] }) => {
</>
}
placement="top"
key={`check-${check?._id}`}
key={`check-${check?.id}`}
slotProps={{
popper: {
className: "bar-tooltip",
@@ -2,7 +2,7 @@ import PropTypes from "prop-types";
import { useSelector } from "react-redux";
import { useTheme } from "@mui/material";
import { Text } from "recharts";
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { Box, Stack, Typography } from "@mui/material";
import { tickDateFormatLookup, tooltipDateFormatLookup } from "./chartUtilFunctions.js";
/**
+8 -1
View File
@@ -56,7 +56,14 @@ const Check = ({ text, noHighlightText, variant = "info", outlined = false }) =>
fontWeight: 450,
}}
>
{noHighlightText && <Typography component="span">{noHighlightText}</Typography>}{" "}
{noHighlightText && (
<Typography
component="span"
sx={{ color: "inherit" }}
>
{noHighlightText}
</Typography>
)}{" "}
{text}
</Typography>
</Stack>
+2
View File
@@ -12,6 +12,7 @@ import {
Calendar,
Check,
CheckCircle,
CheckCircle2,
CheckSquare,
ChevronDown,
ChevronLeft,
@@ -82,6 +83,7 @@ const iconComponents = {
Calendar,
Check,
CheckCircle,
CheckCircle2,
CheckSquare,
ChevronDown,
ChevronLeft,
@@ -14,15 +14,7 @@
}
} */
/* .home-layout aside {
position: sticky;
top: 0;
left: 0;
height: 100vh;
max-width: var(--env-var-side-bar-width);
} */
.home-layout > div {
.home-layout > .home-content-wrapper {
min-height: calc(100vh - var(--env-var-spacing-2) * 2);
flex: 1;
}
@@ -1,17 +1,27 @@
import Sidebar from "../../Sidebar/index.jsx";
import { Outlet } from "react-router";
import { Stack } from "@mui/material";
import { Box, Stack } from "@mui/material";
import { useSidebar } from "@/Hooks/useSidebar.js";
import "./index.css";
const HomeLayout = () => {
const { width, transition } = useSidebar();
return (
<Stack
className="home-layout"
flexDirection="row"
gap={14}
>
<Sidebar />
{/* Spacer for fixed sidebar */}
<Box
sx={{
width,
flexShrink: 0,
transition,
}}
/>
<Stack className="home-content-wrapper">
<Outlet />
</Stack>
@@ -124,7 +124,7 @@ const MonitorDetailsControlHeader = ({
});
}}
>
{monitor?.isActive ? "Pause" : "Resume"}
{monitor?.isActive ? t("pause") : t("resume")}
</Button>
)}
{isAdmin && (
@@ -4,7 +4,7 @@ import PulseDot from "../Animated/PulseDot.jsx";
import Typography from "@mui/material/Typography";
import Dot from "../Dot/index.jsx";
// Utils
import { formatDurationRounded } from "../../../Utils/timeUtils.js";
import { formatDurationRounded } from "../../../Utils/timeUtilsLegacy.js";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js";
@@ -31,7 +31,7 @@ const NotificationConfig = ({
setMonitor((prev) => {
return {
...prev,
notifications: value.map((notification) => notification._id),
notifications: value.map((notification) => notification.id),
};
});
};
@@ -39,14 +39,14 @@ const NotificationConfig = ({
// Handlers
const handleDelete = (id) => {
const updatedNotifications = selectedNotifications.filter(
(notification) => notification._id !== id
(notification) => notification.id !== id
);
setSelectedNotifications(updatedNotifications);
setMonitor((prev) => {
return {
...prev,
notifications: updatedNotifications.map((notification) => notification._id),
notifications: updatedNotifications.map((notification) => notification.id),
};
});
};
@@ -57,7 +57,7 @@ const NotificationConfig = ({
useEffect(() => {
if (setNotifications) {
const toSet = setNotifications.map((notification) => {
return notifications.find((n) => n._id === notification);
return notifications.find((n) => n.id === notification);
});
setSelectedNotifications(toSet);
}
@@ -94,7 +94,7 @@ const NotificationConfig = ({
<Stack
direction="row"
alignItems="center"
key={notification._id}
key={notification.id}
width="100%"
>
<Typography
@@ -106,7 +106,7 @@ const NotificationConfig = ({
name="Trash2"
size={20}
onClick={() => {
handleDelete(notification._id);
handleDelete(notification.id);
}}
style={{ cursor: "pointer" }}
/>
+9 -11
View File
@@ -12,9 +12,9 @@ import Icon from "../Icon";
// Utils
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useSidebar } from "@/Hooks/useSidebar.js";
const URL_MAP = {
support: "https://discord.com/invite/NAb6H3UTjK",
@@ -65,8 +65,7 @@ const Sidebar = () => {
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
// Redux state
const collapsed = useSelector((state) => state.ui.sidebar?.collapsed ?? false);
const { collapsed, width, transition } = useSidebar();
const menu = getMenu(t);
const otherMenuItems = getOtherMenuItems(t);
@@ -75,20 +74,19 @@ const Sidebar = () => {
return (
<Stack
height="100vh"
width={
collapsed
? "var(--env-var-side-bar-collapsed-width)"
: "var(--env-var-side-bar-width)"
}
width={width}
component="aside"
position="sticky"
position="fixed"
top={0}
borderRight={`1px solid ${theme.palette.primary.lowContrast}`}
left={0}
paddingTop={theme.spacing(6)}
paddingBottom={theme.spacing(6)}
gap={theme.spacing(6)}
sx={{
transition: "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)",
transition,
backgroundColor: theme.palette.background.main,
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
zIndex: 1000,
}}
>
<CollapseButton collapsed={collapsed} />
@@ -0,0 +1,56 @@
import React, { useState } from "react";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton";
import { Settings } from "lucide-react";
export type ActionMenuItem = {
id: number | string;
label: React.ReactNode;
action: Function;
closeMenu?: boolean;
};
export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => {
const [anchorEl, setAnchorEl] = useState<null | any>(null);
const open = Boolean(anchorEl);
const handleClick = (e: React.MouseEvent<any>) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
};
const handleClose = (e: React.MouseEvent<any>) => {
e.stopPropagation();
setAnchorEl(null);
};
return (
<div>
<IconButton onClick={handleClick}>
<Settings
size={20}
strokeWidth={1.5}
/>
</IconButton>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
>
{items.map((item) => (
<MenuItem
key={item.id}
onClick={(e: React.MouseEvent<HTMLLIElement>) => {
e.stopPropagation();
if (item.closeMenu) handleClose(e);
item.action();
}}
>
{item.label}
</MenuItem>
))}
</Menu>
</div>
);
};
@@ -0,0 +1,140 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { CheckSnapshot } from "@/Types/Check";
import { getResponseColor } from "@/Utils/DataUtils";
import { HeatmapResponseTimeTooltip } from "@/Components/v2/common/charts/HeatmapResponseTimeTooltip";
import type { SxProps } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
interface PlaceholderCheck {
status: "placeholder";
responseTime: 0;
createdAt: "";
}
interface HeatmapResponseTimeProps {
checks: CheckSnapshot[];
gap?: ResponsiveStyleValue<number | string>;
availabilityCellSx?: SxProps;
responseCellSx?: SxProps;
}
export const HeatmapResponseTime = ({
checks,
gap,
availabilityCellSx,
responseCellSx,
}: HeatmapResponseTimeProps) => {
const theme = useTheme();
if (!gap) {
gap = theme.spacing(0.5);
}
if (!checks || checks.length === 0) return null;
const latestChecks = checks.slice(-25).reverse();
let data: Array<CheckSnapshot | PlaceholderCheck>;
if (latestChecks.length !== 25) {
const placeholders = Array(25 - latestChecks.length).fill({
status: "placeholder" as const,
});
data = [...latestChecks, ...placeholders];
} else {
data = latestChecks;
}
return (
<Box sx={{ width: "100%" }}>
<Box
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gap,
alignItems: "stretch",
}}
>
{data.map((check, index) => {
const statusBg =
check.status === "placeholder"
? theme.palette.action.hover
: check.status === true
? theme.palette.success.light
: theme.palette.error.light;
const respBg =
check.status === "placeholder"
? theme.palette.action.hover
: getResponseColor(check.responseTime || 0, {
start: theme.palette.success.main,
mid: theme.palette.warning.main,
end: theme.palette.error.main,
});
const statusBorder =
check.status === "placeholder"
? theme.palette.divider
: check.status === true
? theme.palette.success.main
: theme.palette.error.main;
return (
<HeatmapResponseTimeTooltip
key={`${check}-${index}`}
check={check}
>
<Box
sx={{
display: "grid",
gridTemplateRows: "auto auto",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: theme.spacing(2),
}}
>
<Box
sx={{
position: "relative",
width: "100%",
aspectRatio: "10",
}}
>
<Box
sx={{
position: "absolute",
inset: 0,
bgcolor: statusBg,
borderRadius: theme.spacing(0.5),
...availabilityCellSx,
}}
/>
<Box
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
borderRadius: theme.spacing(0.5),
boxShadow: `inset 0 0 0 2px ${statusBorder}`,
}}
/>
</Box>
<Box
sx={{
width: "100%",
aspectRatio: "1 / 1",
bgcolor: respBg,
borderRadius: theme.spacing(0.5),
...responseCellSx,
}}
/>
</Box>
</Box>
</HeatmapResponseTimeTooltip>
);
})}
</Box>
</Box>
);
};
@@ -0,0 +1,78 @@
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { CheckSnapshot } from "@/Types/Check";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { useTranslation } from "react-i18next";
type HeatmapCheck =
| CheckSnapshot
| { status: "placeholder"; responseTime: 0; createdAt: "" };
export const HeatmapResponseTimeTooltip = ({
children,
check,
}: {
children: React.ReactElement;
check: HeatmapCheck;
}) => {
const { t } = useTranslation();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const theme = useTheme();
const getColor = (status: boolean) => {
if (status === true) return theme.palette.success.light;
if (status === false) return theme.palette.error.light;
};
if (check.status === "placeholder") {
return children;
}
return (
<Tooltip
slotProps={{
tooltip: {
sx: {
backgroundColor: "transparent",
color: "inherit",
boxShadow: "none",
},
},
}}
title={
<Stack
sx={{
backgroundColor: theme.palette.secondary.main,
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
p: theme.spacing(4),
}}
>
<Typography>
{formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography>
{check?.responseTime && (
<Typography>
{t("common.labels.responseTime")}: {check.responseTime.toFixed()} ms
</Typography>
)}
<Typography textTransform={"capitalize"}>
Status:{" "}
<span style={{ color: getColor(check?.status) }}>
{check?.status === true
? t("pages.common.monitors.status.up")
: t("pages.common.monitors.status.down")}
</span>
</Typography>
</Stack>
}
>
{children}
</Tooltip>
);
};
@@ -0,0 +1,102 @@
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import type { ResponsiveStyleValue } from "@mui/system";
import type { CheckSnapshot } from "@/Types/Check";
import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip";
interface HistogramResponseTimeProps {
checks: CheckSnapshot[];
height?: ResponsiveStyleValue<number | string>;
gap?: ResponsiveStyleValue<number | string>;
}
const DEFAULT_HEIGHT = 50;
export const HistogramResponseTime = ({
checks,
height = DEFAULT_HEIGHT,
gap,
}: HistogramResponseTimeProps) => {
const theme = useTheme();
if (!Array.isArray(checks) || checks.length === 0) return null;
let data = Array<any>();
if (!checks || checks.length === 0) {
data = [];
}
if (checks.length !== 25) {
const placeholders = Array(25 - checks.length).fill({
status: "placeholder",
});
data = [...checks, ...placeholders];
} else {
data = checks;
}
const chartHeight = typeof height === "number" ? `${height}px` : height;
const gridGap = gap ?? theme.spacing(0.5);
return (
<Box sx={{ width: "100%" }}>
<Box
sx={{
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(25, 1fr)",
gap: gridGap,
alignItems: "end",
height: chartHeight,
}}
>
{data.map((check, index) => {
const isPlaceholder = (check as any).status === "placeholder";
const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`;
const barColor =
check.status === true
? theme.palette.success.light
: theme.palette.error.light;
const bar = (
<Box
sx={{
position: "relative",
width: "100%",
height: "100%",
borderRadius: theme.spacing(1),
bgcolor: theme.palette.action.hover,
overflow: "hidden",
}}
>
<Box
sx={{
position: "absolute",
bottom: 0,
left: 0,
width: "100%",
height: isPlaceholder ? 0 : heightPct,
bgcolor: barColor,
transition: "height 500ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
</Box>
);
return isPlaceholder ? (
<Box
key={index}
sx={{ height: "100%" }}
>
{bar}
</Box>
) : (
<HeatmapResponseTimeTooltip
key={index}
check={check}
>
{bar}
</HeatmapResponseTimeTooltip>
);
})}
</Box>
</Box>
);
};
@@ -0,0 +1,47 @@
import { Stack } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { ButtonInput } from "../../inputs/Button";
import { useTranslation } from "react-i18next";
interface HeaderCreateProps {
label?: string;
path: string;
isLoading?: boolean;
isAdmin?: boolean;
}
export const HeaderCreate = ({
label,
path,
isLoading = false,
isAdmin = false,
}: HeaderCreateProps) => {
const navigate = useNavigate();
const { t } = useTranslation();
if (!isAdmin) {
return null;
}
const handleClick = () => {
navigate(path);
};
return (
<Stack
direction="row"
justifyContent="flex-end"
alignItems="center"
width="100%"
>
<ButtonInput
variant="contained"
color="primary"
onClick={handleClick}
loading={isLoading}
>
{label || t("common.buttons.create")}
</ButtonInput>
</Stack>
);
};
@@ -0,0 +1,102 @@
import Stack from "@mui/material/Stack";
import { Icon, MonitorStatus } from "@/Components/v2/design-elements";
import { Button } from "@/Components/v2/inputs";
import { Settings, Pause, Play, Mail, Bug } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor.js";
interface HeaderMonitorControlsProps {
path: string;
monitor?: Monitor;
isAdmin: boolean;
refetch: Function;
}
export const HeaderMonitorControls = ({
path,
monitor,
isAdmin,
refetch,
}: HeaderMonitorControlsProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme();
const {
post,
loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
if (!monitor) {
return null;
}
return (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"space-between"}
>
<MonitorStatus monitor={monitor} />
<Stack
width={{ xs: "100%", md: "auto" }}
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={<Icon icon={Mail} />}
onClick={async () => {
await post(`/notifications/test/all`, { monitorId: monitor.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("common.buttons.testNotifications")}
</Button>
<Button
variant="contained"
color="secondary"
startIcon={<Icon icon={Bug} />}
onClick={() => {
navigate(`/incidents/${monitor?.id}`);
}}
>
{t("common.buttons.incidents")}
</Button>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPosting}
startIcon={monitor?.isActive ? <Icon icon={Pause} /> : <Icon icon={Play} />}
onClick={async () => {
await post(`/monitors/pause/${monitor.id}`, {});
await refetch();
}}
>
{monitor?.isActive ? t("pause") : t("resume")}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="secondary"
startIcon={<Icon icon={Settings} />}
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
>
Configure
</Button>
)}
</Stack>
</Stack>
);
};
@@ -0,0 +1,89 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { ToggleButtonGroup, ToggleButton } from "@/Components/v2/inputs";
import { useTheme } from "@mui/material/styles";
interface MonitorTimeFrameHeaderProps {
isLoading?: boolean;
hasDateRange?: boolean;
dateRange: string;
setDateRange: (dateRange: string) => void;
}
export const HeaderTimeRange = ({
isLoading = false,
hasDateRange = true,
dateRange,
setDateRange,
}: MonitorTimeFrameHeaderProps) => {
const theme = useTheme();
const handleChange = (
_event: React.MouseEvent<HTMLElement>,
newValue: string | null
) => {
if (newValue !== null) {
setDateRange(newValue);
}
};
let timeFramePicker = null;
if (hasDateRange) {
timeFramePicker = (
<ToggleButtonGroup
value={dateRange}
exclusive
onChange={handleChange}
size="small"
>
<ToggleButton
disabled={isLoading}
value="recent"
>
Recent
</ToggleButton>
<ToggleButton
disabled={isLoading}
value="day"
>
Day
</ToggleButton>
<ToggleButton
disabled={isLoading}
value="week"
>
Week
</ToggleButton>
<ToggleButton
disabled={isLoading}
value="month"
>
Month
</ToggleButton>
</ToggleButtonGroup>
);
}
return (
<Stack
direction="row"
justifyContent="flex-end"
alignItems="center"
gap={theme.spacing(4)}
>
<Typography variant="body2">
Showing statistics for past{" "}
{dateRange === "recent"
? "2 hours"
: dateRange === "day"
? "24 hours"
: dateRange === "week"
? "7 days"
: "30 days"}
.
</Typography>
{timeFramePicker}
</Stack>
);
};
+6
View File
@@ -0,0 +1,6 @@
export * from "../common/charts/HistogramResponseTime";
export * from "../common/charts/HeatmapResponseTime";
export * from "../common/charts/HeatmapResponseTimeTooltip";
export * from "../common/controls/HeaderCreate";
export * from "../common/controls/HeaderMonitorControls";
export * from "../common/controls/HeaderTimeRange";
@@ -0,0 +1,19 @@
import Box, { type BoxProps } from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
export const BaseBox = ({ sx, ...rest }: BoxProps) => {
const theme = useTheme();
return (
<Box
{...rest}
sx={{
backgroundColor: theme.palette.background.paper,
border: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
...sx,
}}
/>
);
};
@@ -0,0 +1,72 @@
import { BaseBox } from "../design-elements";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import type { ResponsiveStyleValue } from "@mui/system";
import { useTheme } from "@mui/material/styles";
type BaseChartProps = React.PropsWithChildren<{
icon: React.ReactNode;
title: string;
width?: number | string;
maxWidth?: number | string;
padding?: number | string | ResponsiveStyleValue<number | string>;
}>;
export const BaseChart = ({
children,
icon,
title,
width = "100%",
maxWidth = "100%",
padding,
}: BaseChartProps) => {
const theme = useTheme();
return (
<BaseBox
sx={{
padding: padding ?? theme.spacing(8),
display: "flex",
flex: 1,
width: width,
maxWidth: maxWidth,
}}
>
<Stack
gap={theme.spacing(8)}
flex={1}
>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
{icon && (
<BaseBox
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
backgroundColor: theme.palette.action.hover,
"& svg": {
width: 20,
height: 20,
"& path": {
stroke: theme.palette.text.secondary,
},
},
}}
>
{icon}
</BaseBox>
)}
<Typography variant="h2">{title}</Typography>
</Stack>
<Box flex={1}>{children}</Box>
</Stack>
</BaseBox>
);
};
@@ -0,0 +1,209 @@
import Stack from "@mui/material/Stack";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import {
ErrorFallback,
EmptyFallback,
EmptyMonitorFallback,
BaseFallback,
} from "@/Components/v2/design-elements/Fallback";
import { Breadcrumb } from "@/Components/v2/design-elements/Breadcrumb";
import CircularProgress from "@mui/material/CircularProgress";
import type { StackProps } from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { Trans, useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { Typography } from "@mui/material";
export const PageSpeedKeyPriorityFallback = () => {
return (
<BaseFallback>
<Alert
severity="warning"
sx={{
width: "100%",
maxWidth: 600,
}}
>
<Typography>
<Trans
i18nKey="common.alerts.pageSpeedApiKey.content"
components={{
settingsLink: (
<Link
component={RouterLink}
to="/settings"
color="inherit"
fontWeight="inherit"
/>
),
}}
/>
</Typography>
</Alert>
</BaseFallback>
);
};
interface BasePageProps extends StackProps {
loading?: boolean;
error?: boolean;
children: React.ReactNode;
breadcrumbOverride?: string[];
}
export const BasePage = ({
loading,
error,
children,
breadcrumbOverride,
...props
}: BasePageProps) => {
const theme = useTheme();
if (loading) {
return (
<Stack
alignItems="center"
justifyContent="center"
sx={{ height: "100%" }}
>
<CircularProgress
color="primary"
size={28}
/>
</Stack>
);
}
if (error) {
return (
<ErrorFallback
title="Something went wrong..."
subtitle="Please try again later"
/>
);
}
return (
<Stack
spacing={theme.spacing(10)}
{...props}
>
<Breadcrumb />
{children}
</Stack>
);
};
interface BasePageWithStatesProps extends StackProps {
loading: boolean;
error: any;
items: any[];
bullets: string[] | unknown;
page: string;
actionButtonText: string;
actionLink: string;
children: React.ReactNode;
}
export const BasePageWithStates = ({
loading,
error,
items,
page,
bullets,
actionButtonText,
actionLink,
children,
...props
}: BasePageWithStatesProps) => {
const showLoading = loading && (!items || items.length === 0);
if (isEmpty(items)) {
return (
<EmptyFallback
bullets={bullets}
title={page}
actionButtonText={actionButtonText}
actionLink={actionLink}
/>
);
}
return (
<BasePage
loading={showLoading}
error={error}
{...props}
>
{children}
</BasePage>
);
};
interface MonitorBasePageWithStatesProps extends StackProps {
loading: boolean;
error: any;
items: any[];
page: string;
actionLink?: string;
children: React.ReactNode;
priorityFallback?: React.ReactNode;
}
const isEmpty = (items: any[]) => {
if (!items) return true;
if (Array.isArray(items) && items.length === 0) return true;
return false;
};
export const MonitorBasePageWithStates = ({
loading,
error,
items,
page,
actionLink,
children,
priorityFallback,
...props
}: MonitorBasePageWithStatesProps) => {
const { t } = useTranslation();
const showLoading = loading && (!items || items.length === 0);
if (priorityFallback) {
return (
<BasePage
loading={loading}
error={error}
{...props}
>
{priorityFallback}
</BasePage>
);
}
if (!loading && isEmpty(items)) {
return (
<EmptyMonitorFallback
page={page}
title={t(`pages.${page}.fallback.title`)}
bullets={t(`pages.${page}.fallback.checks`, { returnObjects: true })}
actionButtonText={t(`pages.${page}.fallback.actionButton`)}
actionLink={actionLink || ""}
/>
);
}
return (
<BasePage
loading={showLoading}
error={error}
{...props}
>
{children}
</BasePage>
);
};
@@ -0,0 +1,78 @@
import MuiBreadcrumbs from "@mui/material/Breadcrumbs";
import Typography from "@mui/material/Typography";
import { Link, useLocation } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
import { ChevronRight } from "lucide-react";
const isId = (segment: string): boolean => {
return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment);
};
export const Breadcrumb = () => {
const theme = useTheme();
const location = useLocation();
const segments = location.pathname.split("/").filter((x) => x);
// Build simplified breadcrumb: "uptime" or "uptime / details"
const basePage = segments[0] || "home";
const hasDetailsPage = segments.length > 1 && isId(segments[1]);
return (
<MuiBreadcrumbs
separator={
<ChevronRight
size={16}
strokeWidth={1.5}
/>
}
sx={{
fontSize: "14px",
marginBottom: theme.spacing(6),
"& .MuiBreadcrumbs-separator": {
color: theme.palette.text.secondary,
},
}}
>
{hasDetailsPage ? (
<Link
to={`/${basePage}`}
style={{ textDecoration: "none" }}
>
<Typography
sx={{
fontSize: "14px",
color: theme.palette.text.secondary,
"&:hover": {
color: theme.palette.primary.main,
},
}}
>
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
</Typography>
</Link>
) : (
<Typography
sx={{
fontSize: "14px",
fontWeight: 600,
color: theme.palette.primary.main,
}}
>
{basePage.charAt(0).toUpperCase() + basePage.slice(1)}
</Typography>
)}
{hasDetailsPage && (
<Typography
sx={{
fontSize: "14px",
fontWeight: 600,
color: theme.palette.primary.main,
}}
>
Details
</Typography>
)}
</MuiBreadcrumbs>
);
};
@@ -0,0 +1,46 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Check } from "lucide-react";
import { useTheme } from "@mui/material/styles";
export const BulletPointCheck = ({
text,
variant = "info",
}: {
text: string;
noHighlightText?: string;
variant?: "success" | "error" | "info";
}) => {
const theme = useTheme();
const colors: Record<string, string | undefined> = {
success: theme.palette.success.main,
error: theme.palette.error.main,
info: theme.palette.text.secondary,
};
return (
<Stack
direction="row"
gap={theme.spacing(6)}
alignItems="center"
>
<Check
size={16}
strokeWidth={1.5}
style={{
flexShrink: 0,
}}
/>
<Typography
component="span"
color={variant === "info" ? theme.palette.text.secondary : colors[variant]}
fontWeight={450}
sx={{
opacity: 0.9,
}}
>
{text}
</Typography>
</Stack>
);
};
@@ -0,0 +1,23 @@
import type { CSSProperties } from "react";
interface DotProps {
color?: string;
size?: string;
style?: CSSProperties;
}
export const Dot = ({ color = "gray", size = "4px", style }: DotProps) => {
return (
<span
style={{
content: '""',
width: size,
height: size,
borderRadius: "50%",
backgroundColor: color,
opacity: 0.8,
...style,
}}
/>
);
};
@@ -0,0 +1,180 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BulletPointCheck, SkeletonCard } from "@/Components/v2/design-elements";
import { Button } from "@/Components/v2/inputs";
import { useNavigate } from "react-router";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import type { StackProps } from "@mui/material/Stack";
interface BaseFallbackProps extends StackProps {
children: React.ReactNode;
}
export const BaseFallback = ({ children, ...props }: BaseFallbackProps) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Stack
alignItems={"center"}
margin={isSmall ? "inherit" : "auto"}
marginTop={isSmall ? "33%" : "10%"}
width={{
sm: "90%",
md: "70%",
lg: "50%",
xl: "40%",
}}
padding={{ xs: theme.spacing(8), md: theme.spacing(16) }}
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={theme.shape.borderRadius}
sx={{
borderStyle: "dashed",
}}
{...props}
>
<SkeletonCard showHalo={true} />
{children}
</Stack>
);
};
export const ErrorFallback = ({
title,
subtitle,
}: {
title: string;
subtitle: string;
}) => {
const theme = useTheme();
return (
<BaseFallback>
<Typography
variant="h1"
marginY={theme.spacing(4)}
color={theme.palette.text.secondary}
>
{title}
</Typography>
<Typography>{subtitle}</Typography>
</BaseFallback>
);
};
export const EmptyFallback = ({
title,
bullets,
actionButtonText,
actionLink,
}: {
title: string;
bullets: any;
actionButtonText: string;
actionLink: string;
}) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<BaseFallback>
<Stack
gap={theme.spacing(10)}
zIndex={1}
alignItems="center"
>
<Typography
component="h1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(2),
maxWidth: "1100px",
width: "100%",
}}
>
{Array.isArray(bullets) &&
bullets?.map((bullet: string) => (
<BulletPointCheck
text={bullet}
key={`${bullet}-${Math.random()}`}
/>
))}
</Stack>
<Stack>
<Button
variant="contained"
color="primary"
onClick={() => navigate(actionLink)}
>
{actionButtonText}
</Button>
</Stack>
</Stack>
</BaseFallback>
);
};
export const EmptyMonitorFallback = ({
page,
title,
bullets,
actionButtonText,
actionLink,
}: {
page: string;
title: string;
bullets: any;
actionButtonText: string;
actionLink: string;
}) => {
const theme = useTheme();
const navigate = useNavigate();
return (
<BaseFallback>
<Stack
gap={theme.spacing(10)}
zIndex={1}
alignItems="center"
>
<Typography
component="h1"
color={theme.palette.primary.contrastText}
>
{title}
</Typography>
<Stack
sx={{
flexWrap: "wrap",
gap: theme.spacing(2),
maxWidth: "1100px",
width: "100%",
}}
>
{Array.isArray(bullets) &&
bullets?.map((bullet: string, index: number) => (
<BulletPointCheck
text={bullet}
key={`${(page + "Monitors").trim().split(" ")[0]}-${index}`}
/>
))}
</Stack>
<Stack>
<Button
variant="contained"
color="primary"
onClick={() => navigate(actionLink)}
>
{actionButtonText}
</Button>
</Stack>
</Stack>
</BaseFallback>
);
};
@@ -0,0 +1,18 @@
import type { LucideIcon } from "lucide-react";
interface IconProps {
icon: LucideIcon;
size?: number;
strokeWidth?: number;
}
const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => {
return (
<Icon
size={size}
strokeWidth={strokeWidth}
/>
);
};
export default Icon;
@@ -0,0 +1,58 @@
import type { Monitor } from "@/Types/Monitor";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { PulseDot, Dot } from "@/Components/v2/design-elements";
import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import prettyMilliseconds from "pretty-ms";
import { typographyLevels } from "@/Utils/Theme/v2Palette";
import useMediaQuery from "@mui/material/useMediaQuery";
export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (!monitor) {
return null;
}
return (
<Stack>
<Typography
fontSize={typographyLevels.xl}
fontWeight={500}
color={theme.palette.primary.contrastText}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
>
{monitor.name}
</Typography>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
<PulseDot color={getStatusColor(monitor.status, theme)} />
<Typography
fontSize={typographyLevels.l}
fontWeight={"bolder"}
fontFamily={"monospace"}
overflow={"hidden"}
textOverflow={"ellipsis"}
whiteSpace={"nowrap"}
maxWidth={isSmall ? "100%" : "calc((100vw - var(--env-var-width-2)) / 2)"}
>
{formatUrl(monitor?.url)}
</Typography>
{!isSmall && (
<>
<Dot />
<Typography>
Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })}
</Typography>
</>
)}
</Stack>
</Stack>
);
};
@@ -0,0 +1,48 @@
import { Box, Stack, useTheme } from "@mui/material";
interface PulseDotProps {
color: string;
}
export const PulseDot = ({ color }: PulseDotProps) => {
const theme = useTheme();
return (
<Stack
width="26px"
height="24px"
alignItems="center"
justifyContent="center"
>
<Box
minWidth="18px"
minHeight="18px"
sx={{
position: "relative",
backgroundColor: color,
borderRadius: "50%",
"&::before": {
content: `""`,
position: "absolute",
width: "100%",
height: "100%",
backgroundColor: "inherit",
borderRadius: "50%",
animation: "ripple 1.8s ease-out infinite",
},
"&::after": {
content: `""`,
position: "absolute",
width: "7px",
height: "7px",
borderRadius: "50%",
backgroundColor: theme.palette.background.paper,
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
},
}}
/>
</Stack>
);
};
@@ -0,0 +1,147 @@
import { Box, Stack, useTheme } from "@mui/material";
interface SkeletonCardProps {
width?: number | string | { xs?: number | string; md?: number | string };
showHalo?: boolean;
}
export const SkeletonCard = ({
width = { xs: 130, md: 216 },
showHalo = true,
}: SkeletonCardProps) => {
const theme = useTheme();
const pulseAnimation = {
"@keyframes pulse": {
"0%, 100%": { opacity: 1 },
"50%": { opacity: 0.72 },
},
};
const floatAnimation = {
"@keyframes float": {
"0%, 100%": { transform: "rotate(5deg) translate(12px, -20px)" },
"50%": { transform: "rotate(5deg) translate(12px, -24px)" },
},
};
const cardStyle = {
width: width,
height: 57,
borderRadius: theme.shape.borderRadius,
background:
theme.palette.mode === "dark"
? theme.palette.secondary.dark
: theme.palette.background.paper,
boxShadow:
theme.palette.mode === "dark"
? "0 8px 30px rgba(0,0,0,.3), 0 0 0 1px rgba(255,255,255,.05) inset"
: "0 8px 30px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.05) inset",
display: "flex",
gap: theme.spacing(5),
alignItems: "center",
padding: theme.spacing(5),
};
const blockStyle = {
width: 55,
height: 34,
borderRadius: theme.shape.borderRadius,
background: theme.palette.secondary.main,
border: `1px solid ${theme.palette.divider}`,
animation: "pulse 1.6s ease-in-out infinite",
...pulseAnimation,
};
const lineStyle = {
height: 7,
borderRadius: theme.shape.borderRadius,
background: theme.palette.secondary.main,
border: `1px solid ${theme.palette.divider}`,
animation: "pulse 1.6s ease-in-out infinite",
...pulseAnimation,
};
return (
<Box
position={"relative"}
mt={20}
mb={20}
>
{showHalo && (
<Box
sx={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
pointerEvents: "none",
"&::before": {
content: '""',
width: "260px",
height: "260px",
borderRadius: "999px",
background:
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.05)" : "#fff",
filter: "blur(60px)",
opacity: 0.9,
},
}}
aria-hidden="true"
/>
)}
{/* Card stack */}
<Box sx={{ position: "relative", zIndex: 1 }}>
{/* Front card (now at bottom) */}
<Box sx={cardStyle}>
<Box sx={blockStyle} />
<Stack sx={{ flex: 1, gap: "12px" }}>
{/* Empty space to maintain card height */}
</Stack>
</Box>
{/* Back card A */}
<Box
sx={{
...cardStyle,
transform: "rotate(-4deg) translate(-12px, -12px)",
filter: "blur(.2px)",
position: "absolute",
left: 0,
top: 0,
}}
aria-hidden="true"
>
<Box sx={blockStyle} />
<Stack sx={{ flex: 1, gap: "12px" }}>
<Box sx={{ ...lineStyle, width: "60%" }} />
<Box sx={{ ...lineStyle, width: "80%" }} />
<Box sx={{ ...lineStyle, width: "40%" }} />
</Stack>
</Box>
{/* Back card B */}
<Box
sx={{
...cardStyle,
transform: "rotate(5deg) translate(12px, -20px)",
filter: "blur(.3px)",
position: "absolute",
left: 0,
top: 0,
animation: "float 3s ease-in-out 5",
...floatAnimation,
}}
aria-hidden="true"
>
<Box sx={blockStyle} />
<Stack sx={{ flex: 1, gap: "12px" }}>
<Box sx={{ ...lineStyle, width: "60%" }} />
<Box sx={{ ...lineStyle, width: "80%" }} />
<Box sx={{ ...lineStyle, width: "40%" }} />
</Stack>
</Box>
</Box>
</Box>
);
};
@@ -0,0 +1,86 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { useTheme } from "@mui/material/styles";
import { lighten } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { PaletteKey } from "@/Utils/Theme/v2Theme";
import { BaseBox, TooltipWithInfo } from "@/Components/v2/design-elements";
import type { SxProps } from "@mui/material";
type GradientBox = React.PropsWithChildren<{
palette?: PaletteKey;
sx?: SxProps;
}>;
export const GradientBox = ({ children, palette, sx }: GradientBox) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isLight = theme.palette.mode === "light";
const paper = theme.palette.background.paper;
const paperStart = lighten(paper, isLight ? 0.06 : 0.08);
const paperEnd = paper;
const bg = palette
? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)`
: `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`;
return (
<BaseBox
sx={{
padding: `${theme.spacing(4)} ${theme.spacing(8)}`,
width: isSmall ? `100%` : `calc(25% - (3 * ${theme.spacing(8)} / 4))`,
background: bg,
...sx,
}}
>
{children}
</BaseBox>
);
};
type StatBoxProps = React.PropsWithChildren<{
title: string;
subtitle: string;
palette?: PaletteKey;
sx?: SxProps;
tooltip?: string;
onClick?: () => void;
}>;
export const StatBox = ({
title,
subtitle,
palette,
children,
sx,
tooltip,
onClick,
}: StatBoxProps) => {
const theme = useTheme();
const textColor = palette ? theme.palette[palette].contrastText : "inherit";
return (
<GradientBox
palette={palette}
sx={{
...(sx as object),
...(onClick ? { cursor: "pointer", "&:hover": { opacity: 0.95 } } : {}),
}}
>
<Stack onClick={onClick}>
<Box sx={{ display: "flex", alignItems: "center", gap: theme.spacing(2) }}>
<Typography color={textColor}>{title}</Typography>
{tooltip && (
<TooltipWithInfo
title={tooltip}
iconColor={textColor as string}
iconSize={14}
/>
)}
</Box>
<Typography color={textColor}>{subtitle}</Typography>
{children}
</Stack>
</GradientBox>
);
};
@@ -0,0 +1,112 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/v2/design-elements";
import Background from "@/assets/Images/background-grid.svg?react";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode }>;
export const BGBox = ({ children }: StatusBoxProps) => {
const theme = useTheme();
return (
<BaseBox
sx={{
overflow: "hidden",
position: "relative",
flex: 1,
padding: theme.spacing(4),
}}
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
{children}
</BaseBox>
);
};
const StatusBox = ({
label,
n,
color,
}: {
label: string;
n: number;
color: string | undefined;
}) => {
const theme = useTheme();
return (
<BGBox>
<Stack spacing={theme.spacing(4)}>
<Typography
variant={"h2"}
textTransform="uppercase"
color={theme.palette.text.secondary}
>
{label}
</Typography>
<Typography
variant="h1"
color={color}
>
{n}
</Typography>
</Stack>
</BGBox>
);
};
export const UpStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.up")}
n={n}
color={theme.palette.success.light}
/>
);
};
export const DownStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.down")}
n={n}
color={theme.palette.error.light}
/>
);
};
export const PausedStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.paused")}
n={n}
color={theme.palette.warning.light}
/>
);
};
export const InitializingStatusBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.initializing")}
n={n}
color={theme.palette.warning.light}
/>
);
};
@@ -0,0 +1,104 @@
import Box from "@mui/material/Box";
import { BaseBox } from "@/Components/v2/design-elements";
import Typography from "@mui/material/Typography";
import type { MonitorStatus } from "@/Types/Monitor";
import type { SxProps } from "@mui/material/styles";
import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
export const ValueTypes = ["positive", "negative", "neutral"] as const;
export type ValueType = (typeof ValueTypes)[number];
export const StatusLabel = ({
status,
isActive,
sx,
}: {
status: MonitorStatus;
isActive?: boolean;
sx?: SxProps;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const palette = getStatusPalette(status);
const determineStatus = (
isActive: boolean | undefined,
status: MonitorStatus
): string => {
if (isActive === false) {
return t("pages.common.monitors.status.paused");
}
if (status === true) {
return t("pages.common.monitors.status.up");
}
if (status === false) {
return t("pages.common.monitors.status.down");
}
return t("pages.common.monitors.status.initializing");
};
return (
<BaseBox
sx={{
display: "inline-flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
padding: theme.spacing(3, 5),
color: theme.palette[palette].main,
borderColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.08)"
: "rgba(0, 0, 0, 0.08)",
...sx,
}}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].light}
borderRadius="50%"
marginRight="5px"
/>
<Typography textTransform={"capitalize"}>
{determineStatus(isActive, status)}
</Typography>
</BaseBox>
);
};
export const ValueLabel = ({ value, text }: { value: ValueType; text: string }) => {
const theme = useTheme();
const palette = getValuePalette(value);
const transformedText = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
return (
<BaseBox
sx={{
display: "inline-flex",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
padding: theme.spacing(3, 5),
color: theme.palette[palette].main,
borderColor:
theme.palette.mode === "dark"
? "rgba(255, 255, 255, 0.08)"
: "rgba(0, 0, 0, 0.08)",
}}
>
<Box
width={7}
height={7}
bgcolor={theme.palette[palette].light}
borderRadius="50%"
marginRight="5px"
/>
{transformedText}
</BaseBox>
);
};
@@ -0,0 +1,577 @@
import Stack from "@mui/material/Stack";
import Grid2 from "@mui/material/Grid2";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Paper from "@mui/material/Paper";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Collapse from "@mui/material/Collapse";
import Tooltip from "@mui/material/Tooltip";
import IconButton from "@mui/material/IconButton";
import {
ChevronsLeft,
ChevronsRight,
ChevronLeft,
ChevronRight,
Coffee,
Ellipsis,
PartyPopper,
} from "lucide-react";
import TablePagination from "@mui/material/TablePagination";
import type { TablePaginationOwnProps } from "@mui/material/TablePagination";
import { useTranslation } from "react-i18next";
import { useState, Fragment } from "react";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
export type Header<T> = {
id: number | string;
content: React.ReactNode;
onClick?: (event: React.MouseEvent<HTMLTableCellElement | null>, row: T) => void;
render: (row: T) => React.ReactNode;
};
type DataTableProps<T extends { id?: string | number; _id?: string | number }> = {
headers: Header<T>[];
data: T[];
onRowClick?: (row: T) => void;
cardsOnSmallScreens?: boolean;
expandableRows?: boolean;
renderExpandedContent?: (row: T) => React.ReactNode;
emptyViewText?: string;
emptyViewPositive?: boolean;
};
export function DataTable<
T extends {
id?: string | number;
_id?: string | number;
onRowClick?: (row: T) => void;
},
>({
headers,
data,
onRowClick,
cardsOnSmallScreens = true,
expandableRows = false,
renderExpandedContent,
emptyViewText,
emptyViewPositive,
}: DataTableProps<T>) {
const theme = useTheme();
const [expanded, setExpanded] = useState<(string | number) | null>(null);
const handleExpand = (row: T) => {
const key = row.id || row._id || null;
setExpanded(expanded === key ? null : key);
};
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
if (data.length === 0 || headers.length === 0) {
return (
<EmptyView
text={emptyViewText}
positive={emptyViewPositive}
/>
);
}
const keys = [];
// Return stack of cards for small screens
if (isSmall && cardsOnSmallScreens) {
return (
<Stack spacing={theme.spacing(4)}>
{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)}
sx={{
borderStyle: "solid",
borderWidth: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(4),
cursor: onRowClick ? "pointer" : "default",
}}
key={key}
>
{headers.map((header) => {
return (
<Grid2
container
key={header.id}
>
<Grid2
size={5}
display={"flex"}
alignItems={"center"}
>
<Typography
component="div"
color={theme.palette.text.primary}
>
{header.content}
</Typography>
</Grid2>
<Grid2
size={7}
display="flex"
alignItems={"center"}
>
{header.render(row)}{" "}
</Grid2>
</Grid2>
);
})}
{expandableRows && renderExpandedContent && renderExpandedContent(row) && (
<Grid2 size={12}>{renderExpandedContent(row)}</Grid2>
)}
</Stack>
);
})}
</Stack>
);
}
return (
<TableContainer
component={Paper}
elevation={0}
sx={{ boxShadow: "none", overflowX: "hidden" }}
>
<Table
sx={{
"&.MuiTable-root :is(.MuiTableHead-root, .MuiTableBody-root) :is(th, td)": {
paddingLeft: theme.spacing(8),
},
"& .MuiTableCell-root": {
borderBottom: `1px solid ${theme.palette.divider}`,
},
"& .MuiTableHead-root .MuiTableRow-root": {
height: "28px",
},
"& :is(th)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
fontWeight: 500,
textTransform: "uppercase",
padding: `${theme.spacing(2)} ${theme.spacing(8)}`,
fontSize: theme.typography.fontSize,
},
"& :is(td)": {
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.secondary,
padding: `${theme.spacing(7)} ${theme.spacing(8)}`,
fontSize: theme.typography.fontSize,
overflowX: "hidden",
},
"& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root": {
borderBottom: "none",
},
}}
>
<TableHead>
<TableRow>
{headers.map((header, idx) => {
return (
<TableCell
align={idx === 0 ? "left" : "center"}
key={header.id}
>
{header.content}
</TableCell>
);
})}
</TableRow>
</TableHead>
<TableBody>
{data.map((row) => {
const key = row.id || row._id || Math.random();
const isExpanded = expanded === key;
return (
<Fragment key={key}>
<TableRow
sx={{
cursor: onRowClick ? "pointer" : "default",
}}
onClick={() => {
if (expandableRows) handleExpand(row);
else if (onRowClick) onRowClick(row);
}}
>
{headers.map((header, index) => {
return (
<TableCell
align={index === 0 ? "left" : "center"}
key={header.id}
onClick={
header.onClick ? (e) => header.onClick!(e, row) : undefined
}
>
{header.render(row)}
</TableCell>
);
})}
</TableRow>
{expandableRows &&
renderExpandedContent &&
renderExpandedContent(row) && (
<TableRow>
<TableCell
colSpan={headers.length}
style={{
borderBottom: isExpanded ? undefined : "none",
paddingTop: 0,
paddingBottom: 0,
}}
>
<Collapse
in={isExpanded}
timeout="auto"
unmountOnExit
>
<Box sx={{ pt: 4, pb: 4 }}>{renderExpandedContent(row)}</Box>
</Collapse>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
interface TablePaginationActionsProps {
count: number;
page: number;
rowsPerPage: number;
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
function TablePaginationActions(props: TablePaginationActionsProps) {
const theme = useTheme();
const { count, page, rowsPerPage, onPageChange } = props;
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page + 1);
};
const handleLastPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<Box
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
className="table-pagination-actions"
>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? (
<ChevronsRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronsLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
{theme.direction === "rtl" ? (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
{theme.direction === "rtl" ? (
<ChevronsLeft
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronsRight
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
</Box>
);
}
interface HasMoreTablePaginationActionsProps {
hasMore?: boolean;
page: number;
onPageChange: (event: React.MouseEvent<HTMLButtonElement>, newPage: number) => void;
}
function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps) {
const theme = useTheme();
const { hasMore, page, onPageChange } = props;
const handleFirstPageButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, 0);
};
const handleBackButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page - 1);
};
const handleNextButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onPageChange(event, page + 1);
};
return (
<Box
sx={{ flexShrink: 0, ml: { xs: 0, md: 2.5 } }}
className="table-pagination-actions"
>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? (
<ChevronsRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronsLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={hasMore === false}
aria-label="next page"
>
{theme.direction === "rtl" ? (
<ChevronLeft
size={20}
strokeWidth={1.5}
/>
) : (
<ChevronRight
size={20}
strokeWidth={1.5}
/>
)}
</IconButton>
<Tooltip title={hasMore === true ? "More pages available" : "No more pages"}>
<IconButton
disabled={hasMore === false}
aria-label="next page"
>
<Ellipsis
size={20}
strokeWidth={1.5}
/>
</IconButton>
</Tooltip>
</Box>
);
}
interface PaginationProps extends TablePaginationOwnProps {
component?: React.ElementType;
hasMore?: boolean;
}
export const Pagination = ({ ...props }: PaginationProps) => {
const { hasMore, ...rest } = props;
const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm"));
const theme = useTheme();
const labelDisplayedRows = ({
from,
to,
count,
}: {
from: number;
to: number;
count: number;
}) => {
if (hasMore) {
return to === 0 ? "" : `${from}–${to}`;
}
return `${from}–${to} of ${count}`;
};
return (
<TablePagination
ActionsComponent={(props) =>
typeof hasMore === "boolean" ? (
<HasMoreTablePaginationActions
{...props}
hasMore={hasMore}
/>
) : (
<TablePaginationActions {...props} />
)
}
rowsPerPageOptions={[5, 10, 25]}
labelDisplayedRows={labelDisplayedRows}
{...rest}
sx={{
"& .MuiTablePagination-toolbar": isSmall
? {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridAutoRows: "auto",
rowGap: theme.spacing(1),
alignItems: "center",
justifyItems: "center",
paddingLeft: 0,
paddingRight: 0,
}
: { display: "flex", alignItems: "center" },
"& .MuiTablePagination-selectLabel": {
gridColumn: "1",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-select": {
gridColumn: "2",
gridRow: "1",
justifySelf: "center",
},
"& .MuiTablePagination-displayedRows": isSmall
? { gridColumn: "1 / span 2", gridRow: "2", justifySelf: "center" }
: {},
"& .table-pagination-actions": isSmall
? { gridColumn: "1 / span 2", gridRow: "3", justifySelf: "center" }
: {},
"& .MuiSelect-select": {
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
},
}}
/>
);
};
const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) => {
const theme = useTheme();
const { t } = useTranslation();
const Icon = positive ? PartyPopper : Coffee;
return (
<Stack
alignItems={"center"}
justifyContent={"center"}
sx={{
py: theme.spacing(12),
px: theme.spacing(4),
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
textAlign: "center",
}}
>
<Box
sx={{
width: 64,
height: 64,
borderRadius: "50%",
backgroundColor: theme.palette.action.hover,
display: "flex",
alignItems: "center",
justifyContent: "center",
mb: theme.spacing(2),
}}
>
<Icon
size={28}
strokeWidth={1}
color={theme.palette.text.secondary}
/>
</Box>
<Typography
variant="subtitle1"
color={theme.palette.text.primary}
sx={{ fontWeight: 500 }}
>
{text ?? t("common.table.empty")}
</Typography>
</Stack>
);
};
@@ -0,0 +1,79 @@
import MuiTooltip from "@mui/material/Tooltip";
import { useTheme } from "@mui/material/styles";
import { Info } from "lucide-react";
import type { TooltipProps } from "@mui/material/Tooltip";
type StyledTooltipProps = TooltipProps;
export const Tooltip = ({ placement = "top", ...props }: StyledTooltipProps) => {
const theme = useTheme();
return (
<MuiTooltip
title={props.title}
placement={placement}
slotProps={{
tooltip: {
sx: {
background:
theme.palette.mode === "dark"
? "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)"
: "linear-gradient(135deg, #2c3e50 0%, #1a252f 100%)",
backgroundColor: "transparent",
color: "#ffffff",
fontSize: "13px",
padding: `${theme.spacing(4)} ${theme.spacing(5)}`,
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
},
},
}}
>
{props.children}
</MuiTooltip>
);
};
export interface TooltipWithInfoProps extends Omit<StyledTooltipProps, "children"> {
iconSize?: number;
iconColor?: string;
}
export const TooltipWithInfo = ({
iconSize = 14,
iconColor,
...props
}: TooltipWithInfoProps) => {
const theme = useTheme();
const defaultColor = theme.palette.text.secondary;
return (
<Tooltip {...props}>
<span
style={{
display: "inline-flex",
alignItems: "center",
cursor: "help",
}}
>
<Info
size={iconSize}
strokeWidth={1.5}
style={{
opacity: 0.7,
color: iconColor || defaultColor,
transition: "opacity 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = "1";
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = "0.7";
}}
/>
</span>
</Tooltip>
);
};
@@ -0,0 +1,17 @@
export * from "./Breadcrumb";
export * from "./BasePage";
export * from "./Fallback";
export * from "./SkeletonCard";
export * from "./BulletPointCheck";
export * from "./StatusBox";
export * from "./BaseBox";
export * from "./Table";
export { DataTable as Table } from "./Table";
export * from "./StatusLabel";
export * from "./PulseDot";
export * from "./Dot";
export * from "./MonitorStatus";
export { default as Icon } from "./Icon";
export * from "./Tooltip";
export * from "./StatBox";
export * from "./BaseChart";
@@ -0,0 +1,88 @@
import Autocomplete from "@mui/material/Autocomplete";
import type { AutocompleteProps } from "@mui/material/Autocomplete";
import { TextField, Checkbox } from "@/Components/v2/inputs";
import ListItem from "@mui/material/ListItem";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
type AutoCompleteInputProps = Omit<
AutocompleteProps<any, boolean, boolean, boolean>,
"renderInput"
> & {
renderInput?: AutocompleteProps<any, boolean, boolean, boolean>["renderInput"];
fieldLabel?: string;
required?: boolean;
};
export const AutoCompleteInput = ({
fieldLabel,
required,
renderInput,
...props
}: AutoCompleteInputProps) => {
const theme = useTheme();
const multiple = props.multiple;
const defaultRenderInput = (params: any) => (
<TextField
{...params}
placeholder="Type to search"
/>
);
const autocomplete = (
<Autocomplete
{...props}
disableCloseOnSelect={!!multiple}
popupIcon={
<ChevronDown
size={18}
strokeWidth={1.5}
style={{ marginRight: theme.spacing(3) }}
/>
}
renderInput={renderInput || defaultRenderInput}
getOptionKey={(option) => option.id}
renderTags={() => null}
renderOption={(props, option, { selected }) => {
const { key, ...optionProps } = props;
return (
<ListItem
key={key}
{...optionProps}
>
<Stack
direction={"row"}
alignItems={"center"}
gap={theme.spacing(2)}
>
{multiple && <Checkbox checked={selected} />}
{option.name}
</Stack>
</ListItem>
);
}}
sx={{
"&.MuiAutocomplete-root .MuiAutocomplete-input": {
padding: `0 ${theme.spacing(5)}`,
},
"& .MuiInputBase-root .MuiAutocomplete-endAdornment": {
right: theme.spacing(3),
},
}}
/>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{autocomplete}
</Stack>
);
}
return autocomplete;
};
@@ -0,0 +1,37 @@
import Button from "@mui/material/Button";
import type { ButtonProps } from "@mui/material/Button";
import { useTheme } from "@mui/material/styles";
export const ButtonInput = ({ sx, ...props }: ButtonProps) => {
const theme = useTheme();
const outlinedSx =
props.variant === "outlined"
? {
color: theme.palette.text.primary,
borderColor: theme.palette.divider,
"&:hover": {
borderColor: theme.palette.text.secondary,
},
}
: {};
return (
<Button
{...props}
sx={{
textTransform: "none",
height: 34,
fontWeight: 400,
borderRadius: 2,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
boxShadow: "none",
"&:hover": {
boxShadow: "none",
},
...outlinedSx,
...sx,
}}
/>
);
};
@@ -0,0 +1,56 @@
import { forwardRef } from "react";
import Checkbox from "@mui/material/Checkbox";
import type { CheckboxProps } from "@mui/material/Checkbox";
import { Square, SquareCheck } from "lucide-react";
import { useTheme } from "@mui/material/styles";
type CheckboxInputProps = CheckboxProps & {
label?: string;
};
export const CheckboxInput = forwardRef<HTMLInputElement, CheckboxInputProps>(
function CheckboxInput(
{ sx, ...props }: CheckboxInputProps,
ref: React.Ref<HTMLInputElement>
) {
const theme = useTheme();
return (
<Checkbox
{...props}
aria-label={props.label}
slotProps={{
input: {
ref: ref,
},
}}
icon={
<Square
size={16}
strokeWidth={1.5}
/>
}
checkedIcon={
<SquareCheck
size={16}
strokeWidth={1.5}
/>
}
sx={{
color: theme.palette.text.secondary,
"&.Mui-checked": {
color: theme.palette.primary.main,
},
"&:hover": { backgroundColor: "transparent" },
"& svg": {
stroke: "currentColor",
},
"& svg path, & svg line, & svg polyline, & svg rect": {
stroke: "currentColor",
fill: "none",
},
...sx,
}}
/>
);
}
);
@@ -0,0 +1,56 @@
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import { Button } from "@/Components/v2/inputs";
import { typographyLevels } from "@/Utils/Theme/v2Palette";
import { useTranslation } from "react-i18next";
export const DialogInput = ({
open,
title,
content,
onConfirm,
onCancel,
confirmColor = "primary",
cancelColor = "error",
loading = false,
}: {
open: boolean;
title?: string;
content?: string;
onConfirm?(item: any): any;
onCancel?(item: any): any;
confirmColor?: "error" | "primary";
cancelColor?: "error" | "primary";
loading?: boolean;
}) => {
const { t } = useTranslation();
return (
<Dialog open={open}>
<DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>
<DialogContent>
<DialogContentText>{content}</DialogContentText>
</DialogContent>
<DialogActions>
<Button
loading={loading}
variant="contained"
color={cancelColor}
onClick={onCancel}
>
{t("common.buttons.cancel")}
</Button>
<Button
loading={loading}
variant="contained"
color={confirmColor}
onClick={onConfirm}
>
{t("common.buttons.confirm")}
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,38 @@
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
interface FieldLabelProps {
children: React.ReactNode;
required?: boolean;
htmlFor?: string;
}
export const FieldLabel = ({ children, required = false, htmlFor }: FieldLabelProps) => {
const theme = useTheme();
return (
<Typography
component="label"
htmlFor={htmlFor}
sx={{
fontSize: "14px",
fontWeight: 500,
color: theme.palette.text.secondary,
marginBottom: theme.spacing(2),
display: "block",
}}
>
{children}
{required && (
<span
style={{
color: theme.palette.error.main,
marginLeft: theme.spacing(2),
}}
>
*
</span>
)}
</Typography>
);
};
+102
View File
@@ -0,0 +1,102 @@
import Select from "@mui/material/Select";
import React from "react";
import { useTheme } from "@mui/material/styles";
import type { SelectProps } from "@mui/material/Select";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import { ChevronDown } from "lucide-react";
import Typography from "@mui/material/Typography";
interface SelectInputProps<T> extends Omit<SelectProps<T>, "label"> {
fieldLabel?: string;
required?: boolean;
placeholder?: string;
placeholderColor?: string;
}
export const SelectInput = <T,>({
fieldLabel,
required,
placeholder,
placeholderColor,
...props
}: SelectInputProps<T>) => {
const theme = useTheme();
const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled;
const renderValue = (selected: any) => {
const isMultiple = Boolean((props as any).multiple);
const isEmpty = isMultiple
? !Array.isArray(selected) || selected.length === 0
: selected === undefined || selected === null || selected === "";
if (isEmpty && placeholder) {
return <Typography sx={{ color: emptyPlaceholderColor }}>{placeholder}</Typography>;
}
if (isMultiple) {
const items: string[] = Array.isArray(selected) ? selected : [];
const capitalized = items.map(
(item) => item.charAt(0).toUpperCase() + item.slice(1)
);
return (<Typography>{capitalized.join(" | ")}</Typography>) as any;
}
const nodes = React.Children.toArray(props.children as React.ReactNode);
for (const node of nodes) {
if (!React.isValidElement(node)) continue;
const el = node as React.ReactElement<{
value?: unknown;
children?: React.ReactNode;
}>;
if (el.props?.value === selected) {
return (el.props.children ?? selected) as React.ReactNode;
}
}
return selected as React.ReactNode;
};
const select = (
<Select<T>
{...props}
displayEmpty
renderValue={renderValue}
inputProps={{
...(props.inputProps || {}),
"aria-placeholder": placeholder,
}}
IconComponent={() => (
<ChevronDown
size={18}
strokeWidth={1.5}
style={{ marginRight: theme.spacing(3) }}
/>
)}
sx={{
height: "34px",
"& .MuiSelect-icon": {
right: theme.spacing(3),
},
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
...props.sx,
}}
/>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{select}
</Stack>
);
}
return select;
};
@@ -0,0 +1,56 @@
import { forwardRef } from "react";
import TextField from "@mui/material/TextField";
import type { TextFieldProps } from "@mui/material";
import { typographyLevels } from "@/Utils/Theme/v2Palette";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
interface TextInputProps extends Omit<TextFieldProps, "label"> {
fieldLabel?: string;
required?: boolean;
}
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{ fieldLabel, required, ...props },
ref
) {
const theme = useTheme();
const input = (
<TextField
{...props}
inputRef={ref}
sx={{
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
fontSize: typographyLevels.base,
},
"& .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.divider,
},
"& .MuiFormHelperText-root": {
marginLeft: 0,
marginRight: 0,
marginTop: theme.spacing(1),
},
}}
/>
);
if (fieldLabel) {
return (
<Stack spacing={theme.spacing(2)}>
<FieldLabel required={required}>{fieldLabel}</FieldLabel>
{input}
</Stack>
);
}
return input;
});
TextInput.displayName = "TextInput";
@@ -0,0 +1,52 @@
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import type { ToggleButtonProps } from "@mui/material/ToggleButton";
import type { ToggleButtonGroupProps } from "@mui/material/ToggleButtonGroup";
import { useTheme } from "@mui/material/styles";
export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => {
return (
<ToggleButton
{...props}
sx={{
px: 8,
textTransform: "none",
height: 34,
fontWeight: 400,
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
boxShadow: "none",
"&:hover": {
boxShadow: "none",
},
...sx,
}}
/>
);
};
export const ToggleButtonGroupInput = ({ sx, ...props }: ToggleButtonGroupProps) => {
const theme = useTheme();
return (
<ToggleButtonGroup
{...props}
sx={{
"& .MuiToggleButtonGroup-grouped": {
borderColor: theme.palette.divider,
borderRadius: 0,
"&:first-of-type": {
borderTopLeftRadius: 2,
borderBottomLeftRadius: 2,
},
"&:last-of-type": {
borderTopRightRadius: 2,
borderBottomRightRadius: 2,
},
},
...sx,
}}
/>
);
};
+11
View File
@@ -0,0 +1,11 @@
export { ButtonInput as Button } from "./Button";
export * from "./FieldLabel";
export { SelectInput as Select } from "./Select";
export { CheckboxInput as Checkbox } from "./Checkbox";
export { AutoCompleteInput as Autocomplete } from "./AutoComplete";
export { TextInput as TextField } from "./TextInput";
export {
ToggleButtonInput as ToggleButton,
ToggleButtonGroupInput as ToggleButtonGroup,
} from "./ToggleButton";
export { DialogInput as Dialog } from "./Dialog";
@@ -0,0 +1,92 @@
import Stack from "@mui/material/Stack";
import { Select, Button } from "@/Components/v2/inputs";
import MenuItem from "@mui/material/MenuItem";
import useMediaQuery from "@mui/material/useMediaQuery";
import type { MonitorType } from "@/Types/Monitor";
import { Typography, useTheme } from "@mui/material";
const types = ["http", "ping", "port", "docker"];
const statuses = ["up", "down"];
const states = ["active", "paused"];
export const ControlsFilter = ({
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
onClearFilters,
}: {
selectedTypes: MonitorType[];
setSelectedTypes: React.Dispatch<React.SetStateAction<MonitorType[]>>;
selectedStatus: string;
setSelectedStatus: React.Dispatch<React.SetStateAction<string>>;
selectedState: string;
setSelectedState: React.Dispatch<React.SetStateAction<string>>;
onClearFilters: () => void;
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isFilterActive =
selectedTypes.length > 0 || selectedStatus !== "" || selectedState !== "";
return (
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(2)}
>
<Select
multiple
placeholder="Type"
value={selectedTypes}
onChange={(e) => setSelectedTypes(e.target.value as MonitorType[])}
>
{types.map((type) => (
<MenuItem
key={type}
value={type}
>
<Typography textTransform={"capitalize"}>{type}</Typography>
</MenuItem>
))}
</Select>
<Select
placeholder="Status"
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
>
{statuses.map((status) => (
<MenuItem
key={status}
value={status}
>
<Typography textTransform={"capitalize"}>{status}</Typography>
</MenuItem>
))}
</Select>
<Select
placeholder="State"
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
>
{states.map((state) => (
<MenuItem
key={state}
value={state}
>
<Typography textTransform={"capitalize"}>{state}</Typography>
</MenuItem>
))}
</Select>
{isFilterActive && (
<Button
variant="contained"
onClick={onClearFilters}
>
Clear Filters
</Button>
)}
</Stack>
);
};
@@ -0,0 +1,69 @@
import Stack from "@mui/material/Stack";
import { StatBox } from "@/Components/v2/design-elements";
import { useTheme } from "@mui/material/styles";
import type { MonitorStats, Monitor } from "@/Types/Monitor";
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
import { getStatusPalette } from "@/Utils/MonitorUtils";
import { useTranslation } from "react-i18next";
interface MonitorStatBoxesProps {
monitor?: Monitor;
monitorStats: MonitorStats | null;
certificateExpiry?: string;
}
export const MonitorStatBoxes = ({
monitor,
monitorStats,
certificateExpiry,
}: MonitorStatBoxesProps) => {
const theme = useTheme();
const { t } = useTranslation();
if (!monitorStats || !monitor) {
return null;
}
const timeOfLastFailure = monitorStats?.timeOfLastFailure || 0;
const timeSinceLastFailure =
timeOfLastFailure > 0
? Date.now() - timeOfLastFailure
: Date.now() - new Date(monitorStats?.createdAt || 0).getTime();
// Determine time since last check
const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0;
const timeSinceLastCheck = Date.now() - timeOfLastCheck;
const streakTime = getHumanReadableDuration(timeSinceLastFailure);
const lastCheckTime = getHumanReadableDuration(timeSinceLastCheck);
const palette = getStatusPalette(monitor?.status);
return (
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(8)}
>
<StatBox
palette={palette}
title={t("pages.common.monitors.statBoxes.activeFor")}
subtitle={streakTime}
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastCheck")}
subtitle={lastCheckTime}
/>
<StatBox
title={t("pages.common.monitors.statBoxes.lastResponseTime")}
subtitle={monitorStats?.lastResponseTime + " ms"}
/>
{monitor?.type === "http" && (
<StatBox
title={t("pages.common.monitors.statBoxes.certificateExpiry")}
subtitle={certificateExpiry || "N/A"}
/>
)}
</Stack>
);
};
@@ -0,0 +1,163 @@
import { BaseChart, BaseBox } from "@/Components/v2/design-elements";
import { Clock } from "lucide-react";
import {
AreaChart,
Area,
XAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
Text,
} from "recharts";
import Typography from "@mui/material/Typography";
import { useSelector } from "react-redux";
import {
formatDateWithTz,
tickDateFormatLookup,
tooltipDateFormatLookup,
} from "@/Utils/TimeUtils";
import { useTheme } from "@mui/material/styles";
import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
type XTickProps = {
x: number;
y: number;
payload: { value: any };
range: string;
};
export const XTick = ({ x, y, payload, range }: XTickProps) => {
const format = tickDateFormatLookup(range);
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<Text
x={x}
y={y + 10}
textAnchor="middle"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight={400}
>
{formatDateWithTz(payload?.value, format, uiTimezone)}
</Text>
);
};
type ResponseTimeToolTipProps = {
active?: boolean | undefined;
payload?: any[];
label?: string | number;
range: string;
theme: any;
uiTimezone: string;
};
const ResponseTimeToolTip = ({
active,
payload,
label,
range,
theme,
uiTimezone,
}: ResponseTimeToolTipProps) => {
if (!label) return null;
if (!payload) return null;
if (!active) return null;
const format = tooltipDateFormatLookup(range);
const responseTime = Math.floor(payload?.[0]?.payload?.avgResponseTime || 0);
return (
<BaseBox sx={{ py: theme.spacing(2), px: theme.spacing(4) }}>
<Typography>{formatDateWithTz(String(label), format, uiTimezone)}</Typography>
<Typography>Response time: {responseTime} ms</Typography>
</BaseBox>
);
};
export const HistogramDetails = ({
checks,
range,
}: {
checks: GroupedCheck[];
range: string;
}) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
return (
<BaseChart
icon={
<Clock
size={20}
strokeWidth={1.5}
/>
}
title="Response times"
>
<ResponsiveContainer
width="100%"
height={300}
>
<AreaChart data={checks?.slice()}>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<defs>
<linearGradient
id="colorUv"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={theme.palette.primary.main}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={theme.palette.primary.light}
stopOpacity={0}
/>
</linearGradient>
</defs>
<XAxis
axisLine={false}
tickLine={false}
dataKey="bucketDate"
tick={(props) => (
<XTick
{...props}
range={range}
/>
)}
/>
<Tooltip
content={(props) => (
<ResponseTimeToolTip
{...props}
range={range}
theme={theme}
uiTimezone={uiTimezone}
/>
)}
/>
<Area
type="monotone"
dataKey="avgResponseTime"
stroke={theme.palette.primary.main}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -0,0 +1,106 @@
import Stack from "@mui/material/Stack";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts";
import { HistogramPageSpeedTooltip } from "@/Components/v2/monitors/charts/HistogramPageSpeedTooltip";
import { useTheme } from "@mui/material/styles";
import type { CheckSnapshot } from "@/Types/Check";
import type { MonitorStatus } from "@/Types/Monitor";
import { getStatusColor } from "@/Utils/MonitorUtils";
const processData = (checks: CheckSnapshot[]) => {
if (checks.length === 0) return [];
const formattedData: { score: number; date: string }[] = [];
const calculateScore = (entry: CheckSnapshot) => {
const accessibility = entry?.accessibility || 0;
const bestPractices = entry?.bestPractices || 0;
const performance = entry?.performance || 0;
const seo = entry?.seo || 0;
return Math.floor((accessibility + bestPractices + performance + seo) / 4);
};
checks.forEach((entry) => {
const score = calculateScore(entry);
const date = entry.createdAt;
formattedData.push({ score, date });
});
return formattedData;
};
export const HistogramPageSpeed = ({
checks,
status,
}: {
checks: CheckSnapshot[];
status: MonitorStatus;
}) => {
const theme = useTheme();
const scores = processData(checks ?? []);
const color = getStatusColor(status, theme);
return (
<Stack
direction="row"
flexWrap="nowrap"
gap={theme.spacing(1.5)}
height="100px"
maxWidth="400px"
width="100%"
onClick={(event) => event.stopPropagation()}
overflow={"hidden"}
sx={{
cursor: "default",
position: "relative",
}}
>
<ResponsiveContainer
width="100%"
height={"100%"}
>
<AreaChart
data={scores}
margin={{ top: 10, bottom: -5 }}
style={{ cursor: "pointer" }}
>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Tooltip
cursor={{ stroke: theme.palette.divider }}
content={<HistogramPageSpeedTooltip />}
/>
<defs>
<linearGradient
id={`pagespeed-chart-${status}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={color}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={color}
stopOpacity={0}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="score"
stroke={color}
fill={`url(#pagespeed-chart-${status})`}
/>
</AreaChart>
</ResponsiveContainer>
</Stack>
);
};
@@ -0,0 +1,144 @@
import { BaseChart } from "@/Components/v2/design-elements";
import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/v2/monitors/";
import {
XAxis,
AreaChart,
Area,
Tooltip,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { HistogramPageSpeedScoresTooltip } from "@/Components/v2/monitors";
import { useTheme } from "@mui/material/styles";
import type { CheckSnapshot } from "@/Types/Check";
import type { Palette } from "@mui/material/styles";
type PaletteColorKey = Extract<
keyof Palette,
"primary" | "error" | "success" | "warning"
>;
export interface ConfigItem {
id: string;
text: string;
palette: PaletteColorKey;
}
const config: Record<string, ConfigItem> = {
seo: {
id: "seo",
text: "SEO",
palette: "primary",
},
performance: {
id: "performance",
text: "performance",
palette: "success",
},
bestPractices: {
id: "bestPractices",
text: "best practices",
palette: "warning",
},
accessibility: {
id: "accessibility",
text: "accessibility",
palette: "error",
},
};
export const HistogramPageSpeedDetails = ({
checks,
range,
}: {
checks: CheckSnapshot[];
range: string;
}) => {
const theme = useTheme();
return (
<BaseChart
icon={
<TrendingUp
size={20}
strokeWidth={1.5}
/>
}
title="Score history"
>
<ResponsiveContainer
width="100%"
minWidth={25}
height={215}
>
<AreaChart data={checks}>
<XAxis
dataKey={"createdAt"}
tick={(props) => (
<XTick
{...props}
range={range}
/>
)}
/>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Tooltip
cursor={{ stroke: theme.palette.divider }}
content={<HistogramPageSpeedScoresTooltip config={config} />}
/>
<defs>
{Object.values(config).map(({ id, palette }) => {
const startColor = theme.palette[palette].main;
const endColor = theme.palette[palette].light;
return (
<linearGradient
id={id}
x1="0"
y1="0"
x2="0"
y2="1"
key={id}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={0}
/>
</linearGradient>
);
})}
</defs>
{Object.keys(config).map((key) => {
const { palette } = config[key];
const strokeColor = theme.palette[palette].main;
const bgColor = theme.palette.primary.main;
return (
<Area
connectNulls
key={key}
dataKey={key}
stackId={1}
stroke={strokeColor}
fill={`url(#${config[key].id})`}
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -0,0 +1,78 @@
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 { formatDateWithTz } from "@/Utils/TimeUtils";
import type { ConfigItem } from "@/Components/v2/monitors";
import type { TooltipProps } from "recharts";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
interface HistogramPageSpeedScoresTooltipProps
extends Partial<TooltipProps<number, string>> {
config: Record<string, ConfigItem>;
}
export const HistogramPageSpeedScoresTooltip = ({
active,
payload,
label,
config,
}: HistogramPageSpeedScoresTooltipProps) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
if (active && payload && payload.length) {
return (
<Box
sx={{
backgroundColor: theme.palette.background.paper,
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.text.secondary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
</Typography>
{Object.keys(config)
.reverse()
.map((key) => {
const { palette } = config[key];
const dotColor = theme.palette[palette].main;
return (
<Stack
key={`${key}-tooltip`}
direction="row"
alignItems="center"
gap={theme.spacing(3)}
mt={theme.spacing(1)}
>
<Box
width={theme.spacing(4)}
height={theme.spacing(4)}
sx={{ borderRadius: "50%", backgroundColor: dotColor }}
/>
<Typography
textTransform="capitalize"
sx={{ opacity: 0.8 }}
>
{config[key].text}
</Typography>
<Typography>{Math.floor(payload[0].payload[key])}</Typography>
</Stack>
);
})}
</Box>
);
}
return null;
};
@@ -0,0 +1,39 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { TooltipProps } from "recharts";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
export const HistogramPageSpeedTooltip = ({
active,
payload,
}: TooltipProps<number, string>) => {
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const theme = useTheme();
if (!active || !payload || payload.length === 0) {
return null;
}
const data = payload[0]?.payload;
return (
<Stack
alignItems="flex-start"
sx={{
backgroundColor: theme.palette.secondary.main,
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
p: theme.spacing(4),
}}
>
<Typography>
{formatDateWithTz(data?.date, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)}
</Typography>
<Typography>Score: {data?.score}</Typography>
</Stack>
);
};
@@ -0,0 +1,166 @@
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { BaseChart } from "@/Components/v2/design-elements";
import { useTheme } from "@mui/material/styles";
import { useSelector } from "react-redux";
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy";
import { useTranslation } from "react-i18next";
import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import type { GroupedCheck } from "@/Types/Check";
import type { RootState } from "@/Types/state";
interface XLabelProps {
p1: GroupedCheck;
p2: GroupedCheck;
range: string;
}
const XLabel = ({ p1, p2, range }: XLabelProps) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const dateFormat = range === "day" ? "MMM D, h:mm A" : "MMM D";
return (
<>
<text
x={0}
y="100%"
dy={-3}
textAnchor="start"
fontSize={11}
fill={theme.palette.text.secondary}
>
{formatDateWithTz(p1.bucketDate, dateFormat, uiTimezone)}
</text>
<text
x="100%"
y="100%"
dy={-3}
textAnchor="end"
fontSize={11}
fill={theme.palette.text.secondary}
>
{formatDateWithTz(p2.bucketDate, dateFormat, uiTimezone)}
</text>
</>
);
};
interface HistogramStatusProps {
title: string;
icon: React.ReactNode;
checks?: GroupedCheck[];
range: string;
}
export const HistogramStatus = ({
title,
icon,
checks = [],
range,
}: HistogramStatusProps) => {
const { t } = useTranslation();
const theme = useTheme();
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: GroupedCheck }>;
}) => {
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
if (!active || !payload?.length) return null;
const d = payload[0]?.payload;
const avg = d?.avgResponseTime ?? 0;
const titleText = t("common.charts.labels.averageResponseTime");
const fmt = range === "30d" ? "MMM D, YYYY" : "ddd, MMM D, YYYY, h:mm A";
let dateLabel = "";
if (d?.bucketDate) {
const base = new Date(d.bucketDate);
const midBucket =
range === "30d" ? new Date(base.getTime() + 12 * 60 * 60 * 1000) : base;
dateLabel = formatDateWithTz(midBucket.toISOString(), fmt, uiTimezone);
}
return (
<Stack
sx={{
p: 1,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
{dateLabel ? (
<Typography
sx={{ color: "text.secondary" }}
variant="caption"
>
{dateLabel}
</Typography>
) : null}
<Typography variant="caption">{titleText}</Typography>
<Typography variant="body2">{Math.floor(avg)} ms</Typography>
</Stack>
);
};
const totalChecks = checks.reduce((acc, check) => acc + (check.totalChecks || 0), 0);
return (
<BaseChart
icon={icon}
title={title}
>
<Stack gap={theme.spacing(8)}>
<Stack
position="relative"
direction="row"
justifyContent="space-between"
>
<Stack>
<Typography>Total checks: {totalChecks}</Typography>
</Stack>
</Stack>
<ResponsiveContainer
width="100%"
height={155}
>
<BarChart data={checks}>
<XAxis
stroke={theme.palette.divider}
height={15}
tick={false}
label={
<XLabel
p1={checks[0]}
p2={checks[checks.length - 1]}
range={range}
/>
}
/>
<Tooltip
cursor={false}
content={<CustomTooltip />}
/>
<Bar
dataKey="avgResponseTime"
maxBarSize={7}
background={{ fill: "transparent" }}
>
{checks?.map((groupedCheck, index) => {
const fillColor = getResponseTimeColor(groupedCheck.avgResponseTime);
return (
<Cell
key={groupedCheck.bucketDate || `check-${index}`}
fill={theme.palette[fillColor].main}
/>
);
})}
</Bar>
</BarChart>
</ResponsiveContainer>
</Stack>
</BaseChart>
);
};
@@ -0,0 +1,161 @@
import { BaseChart } from "@/Components/v2/design-elements";
import { FileText } from "lucide-react";
import type { CheckAudits, CheckSnapshot } from "@/Types/Check";
import { Pie, PieChart, ResponsiveContainer, Label } from "recharts";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { alpha } from "@mui/material/styles";
import { useState } from "react";
import Tooltip from "@mui/material/Tooltip";
import { useTranslation } from "react-i18next";
const CenterLabel = ({ viewBox, value }: any) => {
const { cx, cy } = viewBox;
return (
<foreignObject
x={cx - 25}
y={cy - 10}
width={50}
height={50}
>
<Typography
variant="h1"
align="center"
>
{value}
</Typography>
</foreignObject>
);
};
export const PiePageSpeed = ({ latestCheck }: { latestCheck?: CheckSnapshot }) => {
const { t } = useTranslation();
const theme = useTheme();
const [hoverTitle, setHoverTitle] = useState<string | null>(null);
if (!latestCheck) return null;
const LABELS: Record<string, string> = {
FCP: t("pages.pageSpeed.charts.common.fcp"),
SI: t("pages.pageSpeed.charts.common.si"),
LCP: t("pages.pageSpeed.charts.common.lcp"),
TBT: t("pages.pageSpeed.charts.common.tbt"),
CLS: t("pages.pageSpeed.charts.common.cls"),
};
const metrics: { key: keyof CheckAudits; color: string; weight: number }[] = [
{ key: "fcp", color: alpha("#1DE9B6", 0.6), weight: 0.1 },
{ key: "si", color: alpha("#7C4DFF", 0.6), weight: 0.1 },
{ key: "lcp", color: alpha("#FFB200", 0.6), weight: 0.25 },
{ key: "tbt", color: alpha("#00AFFE", 0.6), weight: 0.3 },
{ key: "cls", color: alpha("#FF4181", 0.6), weight: 0.25 },
];
const scores = metrics.flatMap(({ key, color, weight }) => {
const audit = latestCheck?.audits?.[key];
const val = Math.floor((audit?.score ?? 0) * 100);
const inverse = 100 - val;
return [
{
name: `${key.toUpperCase()} Inverse`,
value: inverse * weight,
fill: "transparent",
stroke: color,
weight,
},
{
name: key.toUpperCase(),
value: val * weight,
fill: color,
stroke: color,
weight,
},
];
});
const totalScore =
(latestCheck.audits?.fcp?.score || 0) * 0.1 +
(latestCheck.audits?.si?.score || 0) * 0.1 +
(latestCheck.audits?.lcp?.score || 0) * 0.25 +
(latestCheck.audits?.tbt?.score || 0) * 0.3 +
(latestCheck.audits?.cls?.score || 0) * 0.25;
const pageSpeedPalette = getPageSpeedPalette(Math.floor(totalScore * 100));
const score = [
{
value: 100,
fill: alpha(theme.palette[pageSpeedPalette].light || "#ffffff", 0.6),
stroke: "none",
},
];
return (
<BaseChart
icon={
<FileText
size={20}
strokeWidth={1.5}
/>
}
title={t("pages.pageSpeed.charts.pie.title")}
>
<Tooltip
open={Boolean(hoverTitle)}
title={hoverTitle || ""}
arrow
followCursor
placement="top"
>
<Box
sx={{
"& .recharts-wrapper:focus, & .recharts-surface:focus": {
outline: "none",
},
"& .recharts-wrapper *:focus": { outline: "none" },
"& svg:focus, & g:focus, & path:focus": { outline: "none" },
position: "relative",
}}
>
<ResponsiveContainer
width="100%"
height={250}
>
<PieChart>
<Pie
data={score}
dataKey="value"
cx="50%"
cy="50%"
outerRadius="65%"
>
<Label
position={"center"}
content={<CenterLabel value={Math.floor(totalScore * 100)} />}
/>
</Pie>
<Pie
data={scores}
innerRadius="70%"
outerRadius="80%"
label={({ name, value }) => `${name}: ${Math.round(value)}`}
dataKey="value"
stroke="none"
onMouseEnter={(_, index: number) => {
const d = scores[index];
if (!d) return;
const isInverse = String(d.name).includes("Inverse");
const pair = isInverse && scores[index + 1] ? scores[index + 1] : d;
const base = String(pair.name).replace(" Inverse", "");
const full = LABELS[base] ?? base;
const displayVal = Math.round(Number(pair.value) || 0);
setHoverTitle(`${full}: ${displayVal}`);
}}
onMouseLeave={() => setHoverTitle(null)}
/>
</PieChart>
</ResponsiveContainer>
</Box>
</Tooltip>
</BaseChart>
);
};
@@ -0,0 +1,105 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { BarChart3 } from "lucide-react";
import { BaseChart } from "@/Components/v2/design-elements";
import Typography from "@mui/material/Typography";
import type { CheckSnapshot } from "@/Types/Check";
import { useTranslation } from "react-i18next";
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
const MetricBox = ({
label,
value,
weight,
}: {
label: string;
value: number;
weight: number;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const palette = getPageSpeedPalette(value);
return (
<Stack
direction={"row"}
sx={{
border: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
}}
>
<Stack
flex={1}
p={theme.spacing(4)}
>
<Typography textTransform={"uppercase"}>{label}</Typography>
<Stack
direction="row"
justifyContent={"space-between"}
>
<Typography>{`${value}%`}</Typography>
<Typography>{`${t("pages.pageSpeed.charts.legend.weight")}: ${weight}%`}</Typography>
</Stack>
</Stack>
<Box
width={4}
bgcolor={theme.palette[palette].light}
sx={{
borderTopRightRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
}}
/>
</Stack>
);
};
export const PiePageSpeedLegend = ({ latestCheck }: { latestCheck?: CheckSnapshot }) => {
const theme = useTheme();
const { t } = useTranslation();
if (!latestCheck) {
return null;
}
return (
<BaseChart
icon={
<BarChart3
size={20}
strokeWidth={1.5}
/>
}
title={t("pages.pageSpeed.charts.legend.title")}
>
<Stack gap={theme.spacing(4)}>
<MetricBox
label={t("pages.pageSpeed.charts.common.si")}
value={Math.floor((latestCheck.audits?.si?.score || 0) * 100)}
weight={10}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.fcp")}
value={Math.floor((latestCheck.audits?.fcp?.score || 0) * 100)}
weight={10}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.cls")}
value={Math.floor((latestCheck.audits?.cls?.score || 0) * 100)}
weight={25}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.tbt")}
value={Math.floor((latestCheck.audits?.tbt?.score || 0) * 100)}
weight={30}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.lcp")}
value={Math.floor((latestCheck.audits?.lcp?.score || 0) * 100)}
weight={25}
/>
</Stack>
</BaseChart>
);
};
@@ -0,0 +1,91 @@
import { BaseChart } from "@/Components/v2/design-elements";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { Gauge } from "lucide-react";
import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts";
import { useTranslation } from "react-i18next";
import { getResponseTimeColor } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {
const { t } = useTranslation();
const theme = useTheme();
const chartData = [
{ name: "max", value: max - avg, color: "transparent" },
{ name: "avg", value: avg, color: "red" },
];
const palette = getResponseTimeColor(avg);
const msg: Record<string, string> = {
success: "Excellent",
warning: "Average",
danger: "Poor",
};
return (
<BaseChart
icon={
<Gauge
size={20}
strokeWidth={1.5}
/>
}
title={t("common.charts.labels.averageResponseTime")}
>
<Stack
height="100%"
position={"relative"}
justifyContent={"flex-end"}
>
<ResponsiveContainer
width="100%"
height={155}
>
<RadialBarChart
cy="100%"
data={chartData}
startAngle={180}
endAngle={0}
innerRadius={"120%"}
outerRadius={"200%"}
>
<RadialBar
dataKey="value"
background={{ fill: theme.palette[palette].light }}
>
<Cell visibility={"hidden"} />
<Cell fill={theme.palette[palette].main} />
</RadialBar>
</RadialBarChart>
</ResponsiveContainer>
<Stack
direction={"row"}
justifyContent={"space-between"}
>
<Typography variant="body2">{t("common.charts.labels.low")}</Typography>
<Typography variant="body2">{t("common.charts.labels.high")}</Typography>
</Stack>
<Stack
position="absolute"
top={"50%"}
right={"50%"}
sx={{
transform: "translate(50%, 0%)",
}}
>
<Typography
variant="h6"
textAlign={"center"}
>
{msg[palette]}
</Typography>
<Typography
variant="h6"
textAlign={"center"}
>{`${avg?.toFixed()}ms`}</Typography>
</Stack>
</Stack>
</BaseChart>
);
};
@@ -0,0 +1,11 @@
export * from "./ControlsFilter";
export * from "./MonitorStatBoxes";
export * from "./charts/HistogramStatus";
export * from "./charts/RadialAvgResponse";
export * from "./charts/HistogramDetails";
export * from "./charts/HistogramPageSpeed";
export * from "./charts/HistogramPageSpeedTooltip";
export * from "./charts/PiePageSpeed";
export * from "./charts/PiePageSpeedLegend";
export * from "./charts/HistogramPageSpeedDetails";
export * from "./charts/HistogramPageSpeedDetailsTooltip";
+16 -13
View File
@@ -10,21 +10,24 @@ const initialState = {
msg: null,
};
export const register = createAsyncThunk("auth/register", async (form, thunkApi) => {
try {
const res = await networkService.registerUser(form);
return res.data;
} catch (error) {
if (error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
export const register = createAsyncThunk(
"auth/register",
async ({ user, token }, thunkApi) => {
try {
const res = await networkService.registerUser(user, token);
return res.data;
} catch (error) {
if (error.response.data) {
return thunkApi.rejectWithValue(error.response.data);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
const payload = {
status: false,
msg: error.message ? error.message : "Unknown error",
};
return thunkApi.rejectWithValue(payload);
}
});
);
export const login = createAsyncThunk("auth/login", async (form, thunkApi) => {
try {
+5
View File
@@ -29,6 +29,7 @@ const initialState = {
distributedUptimeEnabled: false,
language: "en",
starPromptOpen: true,
chartType: "histogram",
};
const uiSlice = createSlice({
@@ -73,6 +74,9 @@ const uiSlice = createSlice({
setStarPromptOpen: (state, action) => {
state.starPromptOpen = action.payload;
},
setChartType: (state, action) => {
state.chartType = action.payload;
},
},
});
@@ -88,4 +92,5 @@ export const {
setDistributedUptimeEnabled,
setLanguage,
setStarPromptOpen,
setChartType,
} = uiSlice.actions;
+144
View File
@@ -0,0 +1,144 @@
import useSWR from "swr";
import type { SWRConfiguration } from "swr";
import type { AxiosRequestConfig } from "axios";
import { get, patch, post, deleteOp } from "@/Utils/ApiClient";
import { useState } from "react";
import { useToast } from "@/Hooks/UseToast";
interface ApiResponse<T> {
success: boolean;
msg: string;
data: T;
}
const fetcher = async <T>(url: string, config?: AxiosRequestConfig) => {
const res = await get<ApiResponse<T>>(url, config);
return res.data;
};
export const useGet = <T>(
url: string | null,
axiosConfig?: AxiosRequestConfig,
swrConfig?: SWRConfiguration
) => {
const { data, error, isLoading, mutate } = useSWR<ApiResponse<T>>(
url,
(url: string) => fetcher<T>(url, axiosConfig),
swrConfig
);
return {
data: data?.data ?? null,
isLoading,
error,
refetch: mutate,
};
};
export const usePost = <B = any, R = any>() => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toastError, toastSuccess } = useToast();
const postFn = async (
endpoint: string,
body: B,
config?: AxiosRequestConfig
): Promise<ApiResponse<R> | null> => {
setLoading(true);
setError(null);
try {
const res = await post<ApiResponse<R>>(endpoint, body, {
...config,
headers: {
...config?.headers,
},
});
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
console.error(err);
toastError(errMsg);
setError(errMsg);
return null;
} finally {
setLoading(false);
}
};
return { post: postFn, loading, error };
};
export const usePatch = <B = any, R = any>() => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toastError, toastSuccess } = useToast();
const patchFn = async (
endpoint: string,
body?: B,
config?: AxiosRequestConfig
): Promise<ApiResponse<R> | null> => {
setLoading(true);
setError(null);
try {
const res = await patch<ApiResponse<R>>(endpoint, body, {
...config,
headers: {
...config?.headers,
},
});
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
const errMsg = err?.response?.data?.msg || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
return null;
} finally {
setLoading(false);
}
};
return { patch: patchFn, loading, error };
};
export const useDelete = <R = any>() => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { toastError, toastSuccess } = useToast();
const deleteFn = async (
endpoint: string,
config?: AxiosRequestConfig
): Promise<ApiResponse<R> | null> => {
setLoading(true);
setError(null);
try {
const res = await deleteOp<ApiResponse<R>>(endpoint, {
...config,
headers: {
...config?.headers,
},
});
toastSuccess(res.data?.msg || "Operation successful");
return res.data;
} catch (err: any) {
console.error(err);
const errMsg = err?.response?.data?.message || err.message || "An error occurred";
toastError(errMsg);
setError(errMsg);
return null;
} finally {
setLoading(false);
}
};
return { deleteFn, loading, error };
};
+24
View File
@@ -0,0 +1,24 @@
import { toast, type ToastOptions } from "react-toastify";
export const useToast = () => {
const showToast = (message: string, options?: ToastOptions) => {
toast.dismiss();
const baseStyle: React.CSSProperties = {
whiteSpace: "pre-line",
wordBreak: "break-word",
};
toast(message, {
...options,
style: { ...baseStyle, ...(options?.style || {}) },
});
};
const toastSuccess = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "success" });
const toastError = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "error" });
const toastInfo = (msg: string, opts?: ToastOptions) =>
showToast(msg, { ...opts, type: "info" });
return { showToast, toastSuccess, toastError, toastInfo };
};
+3 -30
View File
@@ -137,7 +137,7 @@ const useFetchChecksByMonitor = ({
return [checks, checksCount, isLoading, networkError];
};
const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
const useFetchChecksSummaryByTeamId = ({ dateRange, updateTrigger } = {}) => {
const [summary, setSummary] = useState(undefined);
const [isLoading, setIsLoading] = useState(false);
const [networkError, setNetworkError] = useState(false);
@@ -147,7 +147,7 @@ const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
try {
setIsLoading(true);
const res = await networkService.getChecksAndSummaryByTeamId();
const res = await networkService.getChecksAndSummaryByTeamId({ dateRange });
setSummary(res.data.data);
} catch (error) {
setNetworkError(true);
@@ -158,7 +158,7 @@ const useFetchChecksSummaryByTeamId = ({ updateTrigger } = {}) => {
};
fetchSummary();
}, [updateTrigger]);
}, [dateRange, updateTrigger]);
return [summary, isLoading, networkError];
};
@@ -185,36 +185,9 @@ const useResolveIncident = () => {
return [resolveIncident, isLoading];
};
const useAcknowledgeChecks = () => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const acknowledge = async (setUpdateTrigger, monitorId = null) => {
setIsLoading(true);
try {
if (monitorId) {
await networkService.updateMonitorChecksStatus({ monitorId, ack: true });
} else {
await networkService.updateAllChecksStatus({ ack: true });
}
setUpdateTrigger((prev) => !prev);
} catch (error) {
const toastMessage = monitorId
? t("checkHooks.failureResolveMonitor")
: t("checkHooks.failureResolveAll");
createToast({ body: toastMessage });
} finally {
setIsLoading(false);
}
};
return { acknowledge, isLoading };
};
export {
useFetchChecksByMonitor,
useFetchChecksTeam,
useFetchChecksSummaryByTeamId,
useResolveIncident,
useAcknowledgeChecks,
};
+4 -3
View File
@@ -243,6 +243,7 @@ export const useFetchPageSpeedMonitorById = ({ monitorId, dateRange, updateTrigg
const [isLoading, setIsLoading] = useState(true);
const [networkError, setNetworkError] = useState(false);
const [monitor, setMonitor] = useState(undefined);
const [monitorStats, setMonitorStats] = useState(undefined);
useEffect(() => {
const fetchMonitor = async () => {
@@ -254,7 +255,8 @@ export const useFetchPageSpeedMonitorById = ({ monitorId, dateRange, updateTrigg
monitorId: monitorId,
dateRange: dateRange,
});
setMonitor(response.data.data);
setMonitor(response.data.data.monitor);
setMonitorStats(response.data.data.monitorStats);
} catch (error) {
setNetworkError(true);
} finally {
@@ -263,7 +265,7 @@ export const useFetchPageSpeedMonitorById = ({ monitorId, dateRange, updateTrigg
};
fetchMonitor();
}, [monitorId, dateRange, updateTrigger]);
return [monitor, isLoading, networkError];
return [monitor, monitorStats, isLoading, networkError];
};
export const useFetchUptimeMonitorById = ({ monitorId, dateRange, trigger }) => {
@@ -342,7 +344,6 @@ export const useDeleteMonitor = () => {
const deleteMonitor = async ({ monitor, redirect }) => {
try {
setIsLoading(true);
console.log(monitor);
await networkService.deleteMonitorById({ monitorId: monitor.id });
createToast({ body: "Monitor deleted successfully!" });
if (redirect) {
+1 -1
View File
@@ -102,7 +102,7 @@ const useGetNotificationById = (id, setNotification) => {
const notificationData = {
address: notification?.address,
notificationName: notification?.notificationName,
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?._id,
type: NOTIFICATION_TYPES.find((type) => type.value === notification?.type)?.id,
};
setNotification(notificationData);
+29
View File
@@ -0,0 +1,29 @@
import { useSelector } from "react-redux";
// CSS variable names for sidebar widths
const SIDEBAR_WIDTH_VAR = "var(--env-var-side-bar-width)";
const SIDEBAR_COLLAPSED_WIDTH_VAR = "var(--env-var-side-bar-collapsed-width)";
// Transition timing for sidebar width changes
const SIDEBAR_TRANSITION = "width 650ms cubic-bezier(0.36, -0.01, 0, 0.77)";
/**
* Hook to get sidebar state and computed width
* Centralizes sidebar width logic to avoid duplication between Sidebar and HomeLayout
*
* @returns {Object} Sidebar state and styles
* @returns {boolean} collapsed - Whether the sidebar is collapsed
* @returns {string} width - CSS width value based on collapsed state
* @returns {string} transition - CSS transition for width changes
*/
export const useSidebar = () => {
const collapsed = useSelector((state) => state.ui.sidebar?.collapsed ?? false);
return {
collapsed,
width: collapsed ? SIDEBAR_COLLAPSED_WIDTH_VAR : SIDEBAR_WIDTH_VAR,
transition: SIDEBAR_TRANSITION,
};
};
export default useSidebar;
@@ -167,7 +167,11 @@ const PasswordPanel = () => {
value={localData.password}
onChange={handleChange}
error={errors[idToName["edit-current-password"]] ? true : false}
helperText={errors[idToName["edit-current-password"]]}
helperText={
errors[idToName["edit-current-password"]]
? t(errors[idToName["edit-current-password"]])
: ""
}
endAdornment={<PasswordEndAdornment />}
flex={1}
/>
@@ -193,7 +197,11 @@ const PasswordPanel = () => {
value={localData.newPassword}
onChange={handleChange}
error={errors[idToName["edit-new-password"]] ? true : false}
helperText={errors[idToName["edit-new-password"]]}
helperText={
errors[idToName["edit-new-password"]]
? t(errors[idToName["edit-new-password"]])
: ""
}
endAdornment={<PasswordEndAdornment />}
flex={1}
/>
@@ -219,7 +227,11 @@ const PasswordPanel = () => {
value={localData.confirm}
onChange={handleChange}
error={errors[idToName["edit-confirm-password"]] ? true : false}
helperText={errors[idToName["edit-confirm-password"]]}
helperText={
errors[idToName["edit-confirm-password"]]
? t(errors[idToName["edit-confirm-password"]])
: ""
}
endAdornment={<PasswordEndAdornment />}
flex={1}
/>
@@ -210,7 +210,11 @@ const ProfilePanel = () => {
autoComplete="given-name"
onChange={handleChange}
error={errors[idToName["edit-first-name"]] ? true : false}
helperText={errors[idToName["edit-first-name"]]}
helperText={
errors[idToName["edit-first-name"]]
? t(errors[idToName["edit-first-name"]])
: ""
}
flex={1}
/>
</Stack>
@@ -228,7 +232,11 @@ const ProfilePanel = () => {
value={localData.lastName}
onChange={handleChange}
error={errors[idToName["edit-last-name"]] ? true : false}
helperText={errors[idToName["edit-last-name"]]}
helperText={
errors[idToName["edit-last-name"]]
? t(errors[idToName["edit-last-name"]])
: ""
}
flex={1}
/>
</Stack>
@@ -104,7 +104,7 @@ const TeamPanel = () => {
team = team.map((member) => ({
...member,
id: member._id,
id: member.id,
role: member.role.map((role) => ROLE_MAP[role]).join(","),
}));
setData(team);
+1 -1
View File
@@ -176,7 +176,7 @@ const Register = ({ superAdminExists }) => {
delete toSubmit.confirm;
const action = await dispatch(register(toSubmit));
const action = await dispatch(register({ user: toSubmit, token }));
if (action.payload.success) {
navigate("/uptime");
createToast({
@@ -75,7 +75,7 @@ const PasswordTooltip = ({ feedback, form, children }) => {
slotProps={{
tooltip: {
sx: {
backgroundColor: theme.palette.tertiary.background,
backgroundColor: theme.palette.tertiary.main,
border: `0.5px solid ${theme.palette.primary.lowContrast}90`,
borderRadius: theme.spacing(4),
color: theme.palette.primary.contrastText,
@@ -88,7 +88,7 @@ const PasswordTooltip = ({ feedback, form, children }) => {
},
arrow: {
sx: {
color: theme.palette.tertiary.background,
color: theme.palette.tertiary.main,
},
},
}}
@@ -0,0 +1,233 @@
//Components
import Stack from "@mui/material/Stack";
import DataTable from "@/Components/v1/Table/index.jsx";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableRow from "@mui/material/TableRow";
import TableCell from "@mui/material/TableCell";
import TableSkeleton from "@/Components/v1/Table/skeleton.jsx";
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
//Utils
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy.js";
import { useSelector } from "react-redux";
import { useState } from "react";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { useFetchChecksTeam, useFetchChecksByMonitor } from "@/Hooks/checkHooks.js";
import { Typography, useTheme } from "@mui/material";
import { lighten } from "@mui/material/styles";
const GetTooltip = (row) => {
const theme = useTheme();
const phases = row?.timings?.phases;
const phaseKeyFormattingMap = {
firstByte: "first byte",
};
return (
<Stack
backgroundColor={lighten(theme.palette.primary.main, 0.1)}
border={`1px solid ${theme.palette.primary.lowContrast}`}
borderRadius={theme.shape.borderRadius}
py={theme.spacing(2)}
px={theme.spacing(4)}
>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Status code: ${row?.statusCode}`}</Typography>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Response time: ${row?.responseTime} ms`}</Typography>
{phases && (
<>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Request timing: `}</Typography>
<Table
size="small"
sx={{ ml: theme.spacing(2), mt: theme.spacing(2) }}
>
<TableBody>
{Object.keys(phases)?.map((phaseKey) => (
<TableRow key={phaseKey}>
<TableCell sx={{ border: "none", p: 0 }}>
<Typography
variant="body2"
color="success"
>
{`${phaseKeyFormattingMap[phaseKey] || phaseKey}:`}
</Typography>
</TableCell>
<TableCell sx={{ border: "none", p: 0 }}>
<Typography
color={theme.palette.primary.contrastText}
variant="body2"
>{`${phases[phaseKey]} ms`}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</Stack>
);
};
const IncidentTable = ({
isLoading,
monitors,
selectedMonitor,
filter,
dateRange,
updateTrigger,
}) => {
//Redux state
const uiTimezone = useSelector((state) => state.ui.timezone);
//Local state
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const selectedMonitorDetails = monitors?.[selectedMonitor];
const selectedMonitorType = selectedMonitorDetails?.type;
//Hooks
const [checksMonitor, checksCountMonitor, isLoadingMonitor, networkErrorMonitor] =
useFetchChecksByMonitor({
monitorId: selectedMonitor === "0" ? undefined : selectedMonitor,
type: selectedMonitorType,
status: false,
sortOrder: "desc",
limit: null,
dateRange,
filter: filter === "resolved" ? "all" : filter,
ack: filter === "resolved" ? true : false,
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor !== "0",
updateTrigger,
});
const [checksTeam, checksCountTeam, isLoadingTeam, networkErrorTeam] =
useFetchChecksTeam({
status: false,
sortOrder: "desc",
limit: null,
dateRange,
filter: filter === "resolved" ? "all" : filter,
ack: filter === "resolved" ? true : false,
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor === "0",
updateTrigger,
});
const checks = selectedMonitor === "0" ? checksTeam : checksMonitor;
const checksCount = selectedMonitor === "0" ? checksCountTeam : checksCountMonitor;
isLoading = isLoadingTeam || isLoadingMonitor;
const networkError = selectedMonitor === "0" ? networkErrorTeam : networkErrorMonitor;
const { t } = useTranslation();
//Handlers
const handleChangePage = (_, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(event.target.value);
};
const headers = [
{
id: "monitorName",
content: t("incidentsTableMonitorName"),
render: (row) => {
return monitors?.[row.metadata?.monitorId]?.name || "N/A";
},
},
{
id: "status",
content: t("incidentsTableStatus"),
render: (row) => {
const status = row.status === true ? "up" : "down";
return (
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
/>
);
},
},
{
id: "dateTime",
content: t("incidentsTableDateTime"),
render: (row) => {
const formattedDate = formatDateWithTz(
row.createdAt,
"YYYY-MM-DD HH:mm:ss A",
uiTimezone
);
return formattedDate;
},
},
{
id: "statusCode",
content: t("incidentsTableStatusCode"),
render: (row) => <HttpStatusLabel status={row.statusCode} />,
},
{ id: "message", content: t("incidentsTableMessage"), render: (row) => row.message },
];
if (isLoading) return <TableSkeleton />;
if (networkError) {
return (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
if (!isLoading && typeof checksCount === "undefined") {
return <GenericFallback>{t("incidentsTableNoIncidents")}</GenericFallback>;
}
return (
<>
<DataTable
headers={headers}
data={checks}
config={{ tooltipContent: GetTooltip }}
/>
<Pagination
paginationLabel={t("incidentsTablePaginationLabel")}
itemCount={checksCount}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</>
);
};
IncidentTable.propTypes = {
isLoading: PropTypes.bool,
monitors: PropTypes.object,
selectedMonitor: PropTypes.string,
filter: PropTypes.string,
dateRange: PropTypes.string,
updateTrigger: PropTypes.bool,
setUpdateTrigger: PropTypes.func,
};
export default IncidentTable;
@@ -5,6 +5,7 @@ import PropTypes from "prop-types";
//Utils
import { useTheme } from "@emotion/react";
import SkeletonLayout from "./skeleton.jsx";
import { useTranslation } from "react-i18next";
const OptionsHeader = ({
@@ -14,27 +15,27 @@ const OptionsHeader = ({
monitors,
filter = "all",
setFilter,
dateRange = "all",
dateRange = "hour",
setDateRange,
}) => {
const theme = useTheme();
const { t } = useTranslation();
const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : [];
const filterOptions = [
{ _id: "all", name: t("incidentsPage.incidentsOptionsHeaderFilterAll") },
{ _id: "active", name: t("incidentsPage.incidentsOptionsHeaderFilterActive") },
{ _id: "resolved", name: t("incidentsPage.incidentsOptionsHeaderFilterResolved") },
{ _id: "manual", name: t("incidentsPage.incidentsOptionsHeaderFilterManual") },
{ id: "all", name: t("incidentsOptionsHeaderFilterAll") },
{ id: "down", name: t("incidentsOptionsHeaderFilterDown") },
{ id: "resolve", name: t("incidentsOptionsHeaderFilterCannotResolve") },
{ id: "resolved", name: t("incidentsOptionsHeaderFilterResolved") },
];
// The stacks below which are three in number have the same style so
const stackStyles = {
direction: "row",
alignItems: "center",
gap: theme.spacing(6),
};
if (!shouldRender) return;
if (!shouldRender) return <SkeletonLayout />;
return (
<Stack
+100
View File
@@ -0,0 +1,100 @@
// Components
import { Stack } from "@mui/material";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import IncidentTable from "./Components/IncidentTable/index.jsx";
import OptionsHeader from "./Components/OptionsHeader/index.jsx";
import StatusBoxes from "./Components/StatusBoxes/index.jsx";
//Utils
import { useTheme } from "@emotion/react";
import { useFetchMonitorsByTeamId } from "@/Hooks/monitorHooks.js";
import { useFetchChecksSummaryByTeamId } from "@/Hooks/checkHooks.js";
import { useState, useEffect } from "react";
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
//Constants
const Checks = () => {
// Redux state
const { t } = useTranslation();
const BREADCRUMBS = [{ name: t("checksPageTitle"), path: "/checks" }];
// Local state
const [selectedMonitor, setSelectedMonitor] = useState("0");
const [filter, setFilter] = useState(undefined);
const [dateRange, setDateRange] = useState("hour");
const [monitorLookup, setMonitorLookup] = useState(undefined);
const [updateTrigger, setUpdateTrigger] = useState(false);
//Hooks
//Utils
const theme = useTheme();
const [monitors, , isLoading, networkError] = useFetchMonitorsByTeamId({});
const [summary, isLoadingSummary, networkErrorSummary] = useFetchChecksSummaryByTeamId({
dateRange,
updateTrigger,
});
const { monitorId } = useParams();
useEffect(() => {
if (monitorId) {
setSelectedMonitor(monitorId);
}
}, [monitorId]);
useEffect(() => {
const monitorLookup = monitors?.reduce((acc, monitor) => {
acc[monitor.id] = {
id: monitor.id,
name: monitor.name,
type: monitor.type,
};
return acc;
}, {});
setMonitorLookup(monitorLookup);
}, [monitors]);
if (networkError || networkErrorSummary) {
return (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<StatusBoxes
isLoading={isLoadingSummary}
summary={summary}
/>
<OptionsHeader
shouldRender={!isLoading}
monitors={monitorLookup}
selectedMonitor={selectedMonitor}
setSelectedMonitor={setSelectedMonitor}
filter={filter}
setFilter={setFilter}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<IncidentTable
isLoading={isLoading}
monitors={monitorLookup ? monitorLookup : {}}
selectedMonitor={selectedMonitor}
filter={filter}
dateRange={dateRange}
updateTrigger={updateTrigger}
setUpdateTrigger={setUpdateTrigger}
/>
</Stack>
);
};
export default Checks;
@@ -0,0 +1,413 @@
// Components
import { Stack, Typography, Grid, Box, Button, Divider } from "@mui/material";
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import SummaryCard from "../SummaryCard/index.jsx";
import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx";
// Utils
import { useEffect, useState } from "react";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { formatDateWithTz } from "@/Utils/timeUtilsLegacy.js";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
// Hooks
import useFetchIncidents from "../../hooks/useFetchIncidents.js";
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration.js";
const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => {
const theme = useTheme();
const { t } = useTranslation();
const { fetchIncidentById, resolveIncident } = useFetchIncidents();
const [incidentData, setIncidentData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false);
const uiTimezone = useSelector((state) => state.ui.timezone);
const isActive = incidentData?.incident?.status === true;
const duration = useGetIncidentsDuration(incidentData?.incident || null, isActive);
const statusColor = isActive ? theme.palette.error.main : theme.palette.success.main;
const toCapitalLetter = (text) =>
typeof text === "string" && text.length > 0
? text.charAt(0).toUpperCase() + text.slice(1)
: text;
const KeyValueRow = ({ label, value, sx = {} }) => (
<Grid
container
spacing={2}
alignItems="flex-start"
sx={{ ...sx }}
>
<Grid
item
xs={4}
>
<Typography
variant="body1"
sx={{ whiteSpace: "nowrap" }}
>
{label}
</Typography>
</Grid>
<Grid
item
xs
sx={{ minWidth: 0 }}
>
<Typography
variant="body1"
textAlign="right"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{value ?? "-"}
</Typography>
</Grid>
</Grid>
);
useEffect(() => {
const loadIncident = async () => {
if (!incidentId || !open) {
setIncidentData(null);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const incidentData = await fetchIncidentById(incidentId);
setIncidentData(incidentData);
} catch (error) {
console.error("Error fetching incident:", error);
setIncidentData(null);
} finally {
setIsLoading(false);
}
};
loadIncident();
}, [incidentId, open, fetchIncidentById]);
const openResolveDialog = () => {
setIsResolveDialogOpen(true);
};
const closeResolveDialog = () => {
setIsResolveDialogOpen(false);
};
const handleAfterResolve = async () => {
if (!incidentId) return;
const updatedIncident = await fetchIncidentById(incidentId);
if (updatedIncident) {
setIncidentData(updatedIncident);
}
};
const renderContent = () => {
if (isLoading) {
return (
<GenericFallback
isLoading={true}
message={t("incidentsPage.loadingIncidentDetails")}
/>
);
}
if (!incidentData) {
return (
<GenericFallback
isLoading={false}
message={t("incidentsPage.incidentNotFound")}
/>
);
}
return (
<Stack gap={theme.spacing(8)}>
<Grid
container
spacing={3}
>
<Grid
item
xs={12}
md={12}
>
<SummaryCard title={t("incidentsPage.overview")}>
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
sx={{ mb: theme.spacing(3) }}
>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: statusColor,
}}
/>
<Typography
variant="body2"
sx={{
color: statusColor,
fontWeight: 700,
letterSpacing: 0.4,
}}
>
{isActive ? t("incidentsPage.active") : t("incidentsPage.resolved")}
</Typography>
</Stack>
<Stack
direction="column"
alignItems="start"
gap={theme.spacing(3)}
>
<Stack
direction="row"
gap={2}
sx={{ alignItems: "baseline" }}
>
<Typography
variant="body1"
fontWeight={500}
sx={{ minWidth: 70 }}
>
{t("incidentsPage.incidentItemMonitor")}
</Typography>
<Typography
variant="body1"
sx={{ flex: 1 }}
>
{incidentData?.monitor?.name || t("incidentsPage.unknownMonitor")}
</Typography>
</Stack>
<Stack
direction="row"
gap={2}
sx={{ alignItems: "baseline" }}
>
<Typography
variant="body1"
fontWeight={500}
sx={{ minWidth: 70 }}
>
{t("incidentsPage.URL")}
</Typography>
<Typography
variant="body1"
sx={{ flex: 1, wordBreak: "break-word", fontFamily: "monospace" }}
>
{incidentData?.monitor?.url || "-"}
</Typography>
</Stack>
</Stack>
</SummaryCard>
</Grid>
</Grid>
<Grid
container
spacing={3}
>
<Grid
item
xs={12}
sm={12}
>
<SummaryCard title={t("incidentsPage.incidentAnalysis")}>
<Grid
container
spacing={3}
alignItems="stretch"
>
<Grid
item
xs={12}
sm={6}
>
<Stack gap={theme.spacing(2)}>
<Typography
variant="subtitle1"
fontWeight={700}
>
{t("incidentsPage.timeline")}
</Typography>
<Divider sx={{ mt: 2 }} />
<KeyValueRow
label={t("incidentsPage.startedAt")}
value={
formatDateWithTz(
incidentData?.incident?.startTime,
"D MMM YYYY, h:mm A",
uiTimezone
) || "-"
}
sx={{ paddingBottom: theme.spacing(3) }}
/>
{!isActive && (
<KeyValueRow
label={t("incidentsPage.endedAt")}
value={
formatDateWithTz(
incidentData?.incident?.endTime,
"D MMM YYYY, h:mm A",
uiTimezone
) || "-"
}
sx={{ paddingBottom: theme.spacing(3) }}
/>
)}
<KeyValueRow
label={t("incidentsPage.downtime")}
value={duration || "-"}
/>
</Stack>
</Grid>
<Grid
item
sm="auto"
sx={{ display: { xs: "none", sm: "flex" } }}
>
<Divider
orientation="vertical"
flexItem
/>
</Grid>
<Grid
item
xs={12}
sm
>
<Stack gap={theme.spacing(2)}>
<Typography
variant="subtitle1"
sx={{ letterSpacing: 0.6, fontWeight: 700 }}
>
{isActive
? t("incidentsPage.errorDetails")
: t("incidentsPage.resolutionDetails")}
</Typography>
<Divider sx={{ mt: 2 }} />
<KeyValueRow
label={t("incidentsPage.statusCode")}
value={incidentData?.incident?.statusCode ?? "-"}
/>
<KeyValueRow
label={t("incidentsPage.message")}
value={incidentData?.incident?.message ?? "-"}
/>
{!isActive && (
<>
<KeyValueRow
label={t("incidentsPage.resolutionMethod")}
value={
toCapitalLetter(incidentData?.incident?.resolutionType) || "-"
}
/>
{incidentData?.incident?.resolutionType === "manual" && (
<KeyValueRow
label={t("incidentsPage.comment")}
value={
incidentData?.incident.comment?.trim()
? incidentData.incident.comment
: t("incidentsPage.noCommentProvided")
}
/>
)}
</>
)}
</Stack>
</Grid>
</Grid>
</SummaryCard>
</Grid>
</Grid>
</Stack>
);
};
return (
<GenericDialog
open={open}
onClose={onClose}
theme={theme}
title={t("incidentsPage.incidentDetails")}
width={{ xs: "95%", md: "45%", lg: "40%", xl: "35%" }}
>
<Stack
gap={theme.spacing(6)}
sx={{ maxHeight: "70vh", overflowY: "auto", pr: 1 }}
>
{renderContent()}
{!isActive &&
incidentData?.incident?.resolutionType === "manual" &&
incidentData?.user?.email && (
<Typography
variant="body2"
sx={{
fontStyle: "italic",
}}
>
{t("incidentsPage.resolvedBy")}: {incidentData.user.email}
</Typography>
)}
<Stack
direction="row"
justifyContent="flex-end"
gap={2}
sx={{ pt: theme.spacing(3) }}
>
{isActive && (
<Button
variant="outlined"
color="error"
onClick={openResolveDialog}
>
{t("incidentsPage.incidentsTableActionResolveManually")}
</Button>
)}
<Button
variant="contained"
color="secondary"
onClick={onClose}
>
{t("close")}
</Button>
</Stack>
<ResolveIncidentDialog
open={isResolveDialogOpen}
incidentId={incidentId}
onClose={closeResolveDialog}
onResolve={resolveIncident}
onAfterResolve={handleAfterResolve}
onResolved={onResolved}
/>
</Stack>
</GenericDialog>
);
};
IncidentDetailsModal.propTypes = {
open: PropTypes.bool.isRequired,
incidentId: PropTypes.string,
onClose: PropTypes.func.isRequired,
onResolved: PropTypes.func,
};
export default IncidentDetailsModal;
@@ -0,0 +1,156 @@
// Components
import IconButton from "@mui/material/IconButton";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Icon from "@/Components/v1/Icon/index.jsx";
import ResolveIncidentDialog from "../ResolveIncidentDialog/index.jsx";
// Utils
import { useState } from "react";
import { useTheme } from "@emotion/react";
import { useTranslation } from "react-i18next";
import { TypeToPathMap } from "@/Utils/monitorUtilsLegacy.js";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
const IncidentActionsMenu = ({ incident, monitor, onResolve, onOpenDetails }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const theme = useTheme();
const { t } = useTranslation();
const navigate = useNavigate();
const openMenu = (event) => {
event.preventDefault();
event.stopPropagation();
setAnchorEl(event.currentTarget);
};
const closeMenu = (e) => {
if (e) {
e.stopPropagation();
}
setAnchorEl(null);
};
const openResolveDialog = (e) => {
closeMenu(e);
setIsDialogOpen(true);
};
const closeDialog = (e) => {
if (e) {
e.stopPropagation();
}
setIsDialogOpen(false);
};
const isActive = incident.status === true;
const handleGoToDetails = (e) => {
e.stopPropagation();
closeMenu(e);
if (onOpenDetails) {
onOpenDetails(incident.id);
}
};
const handleGoToMonitor = async (e) => {
e.stopPropagation();
closeMenu(e);
try {
if (monitor) {
const path = TypeToPathMap[monitor.type];
if (path && monitor.id) {
navigate(`/${path}/${monitor.id}`);
} else {
console.error(`Monitor type not found`);
}
} else {
console.error("Monitor information not found in incident data");
}
} catch (error) {
console.error("Error fetching incident details:", error);
}
};
return (
<>
<IconButton
aria-label="incident actions"
onClick={(event) => {
event.stopPropagation();
openMenu(event);
}}
sx={{
"&:focus": {
outline: "none",
},
"& svg path": {
stroke: theme.palette.primary.contrastTextTertiary,
},
}}
>
<Icon
name="Settings"
size={20}
/>
</IconButton>
<Menu
className="incident-actions-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={closeMenu}
disableScrollLock
slotProps={{
paper: {
sx: {
"& ul": {
p: theme.spacing(2.5),
backgroundColor: theme.palette.primary.main,
},
"& li": { m: 0, color: theme.palette.primary.contrastTextSecondary },
},
},
}}
>
<MenuItem onClick={handleGoToDetails}>
{t("incidentsPage.incidentsTableActionDetails", "Details")}
</MenuItem>
<MenuItem onClick={handleGoToMonitor}>
{t("incidentsPage.incidentsTableActionGoToMonitor", "Go to monitor")}
</MenuItem>
{isActive && (
<MenuItem
onClick={(e) => {
e.stopPropagation();
openResolveDialog(e);
}}
>
{t("incidentsPage.incidentsTableActionResolveManually")}
</MenuItem>
)}
</Menu>
<ResolveIncidentDialog
open={isDialogOpen}
incidentId={incident.id}
onClose={closeDialog}
onResolve={onResolve}
/>
</>
);
};
IncidentActionsMenu.propTypes = {
incident: PropTypes.shape({
id: PropTypes.string.isRequired,
status: PropTypes.bool.isRequired,
}).isRequired,
monitor: PropTypes.shape({
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
}).isRequired,
onResolve: PropTypes.func.isRequired,
onOpenDetails: PropTypes.func,
};
export default IncidentActionsMenu;
@@ -1,225 +1,144 @@
//Components
import Stack from "@mui/material/Stack";
import DataTable from "@/Components/v1/Table/index.jsx";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableRow from "@mui/material/TableRow";
import TableCell from "@mui/material/TableCell";
import TableSkeleton from "@/Components/v1/Table/skeleton.jsx";
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
import IncidentActionsMenu from "./IncidentActionsMenu.jsx";
//Utils
import { formatDateWithTz } from "@/Utils/timeUtils.js";
import { formatDateWithTz } from "../../../../Utils/timeUtilsLegacy.js";
import { useSelector } from "react-redux";
import { useState } from "react";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import {
useFetchChecksTeam,
useFetchChecksByMonitor,
useResolveIncident,
} from "@/Hooks/checkHooks.js";
import { Button, Typography, useTheme } from "@mui/material";
import { lighten } from "@mui/material/styles";
const GetTooltip = (row) => {
const theme = useTheme();
const phases = row?.timings?.phases;
const phaseKeyFormattingMap = {
firstByte: "first byte",
};
return (
<Stack
backgroundColor={lighten(theme.palette.primary.main, 0.1)}
border={`1px solid ${theme.palette.primary.lowContrast}`}
borderRadius={theme.shape.borderRadius}
py={theme.spacing(2)}
px={theme.spacing(4)}
>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Status code: ${row?.statusCode}`}</Typography>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Response time: ${row?.responseTime} ms`}</Typography>
{phases && (
<>
<Typography
variant="body2"
color={theme.palette.primary.contrastText}
>{`Request timing: `}</Typography>
<Table
size="small"
sx={{ ml: theme.spacing(2), mt: theme.spacing(2) }}
>
<TableBody>
{Object.keys(phases)?.map((phaseKey) => (
<TableRow key={phaseKey}>
<TableCell sx={{ border: "none", p: 0 }}>
<Typography
variant="body2"
color="success"
>
{`${phaseKeyFormattingMap[phaseKey] || phaseKey}:`}
</Typography>
</TableCell>
<TableCell sx={{ border: "none", p: 0 }}>
<Typography
color={theme.palette.primary.contrastText}
variant="body2"
>{`${phases[phaseKey]} ms`}</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</Stack>
);
};
import { Typography, useTheme } from "@mui/material";
const IncidentTable = ({
isLoading,
monitors,
selectedMonitor,
filter,
dateRange,
updateTrigger,
setUpdateTrigger,
monitors = [],
incidents = [],
incidentsCount = 0,
isLoading = false,
networkError = false,
page = 0,
rowsPerPage = 10,
handleChangePage,
handleChangeRowsPerPage,
resolveIncident,
handleUpdateTrigger,
onOpenDetails,
}) => {
//Redux state
const uiTimezone = useSelector((state) => state.ui.timezone);
//Local state
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const selectedMonitorDetails = monitors?.[selectedMonitor];
const selectedMonitorType = selectedMonitorDetails?.type;
//Hooks
const [resolveIncident, resolveLoading] = useResolveIncident();
const [checksMonitor, checksCountMonitor, isLoadingMonitor, networkErrorMonitor] =
useFetchChecksByMonitor({
monitorId: selectedMonitor === "0" ? undefined : selectedMonitor,
type: selectedMonitorType,
status: false,
sortOrder: "desc",
limit: null,
dateRange,
filter: filter === "resolved" ? "all" : filter,
ack: filter === "resolved" ? true : false,
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor !== "0",
updateTrigger,
});
const [checksTeam, checksCountTeam, isLoadingTeam, networkErrorTeam] =
useFetchChecksTeam({
status: false,
sortOrder: "desc",
limit: null,
dateRange,
filter: filter === "resolved" ? "all" : filter,
ack: filter === "resolved" ? true : false,
page: page,
rowsPerPage: rowsPerPage,
enabled: selectedMonitor === "0",
updateTrigger,
});
const checks = selectedMonitor === "0" ? checksTeam : checksMonitor;
const checksCount = selectedMonitor === "0" ? checksCountTeam : checksCountMonitor;
isLoading = isLoadingTeam || isLoadingMonitor;
const networkError = selectedMonitor === "0" ? networkErrorTeam : networkErrorMonitor;
const { t } = useTranslation();
const theme = useTheme();
//Handlers
const handleChangePage = (_, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(event.target.value);
};
const handleResolveIncident = (checkId) => {
resolveIncident(checkId, setUpdateTrigger);
const handleResolveIncident = async (incidentId, options = {}) => {
try {
await resolveIncident(incidentId, options);
handleUpdateTrigger();
} catch (error) {
console.error(t("incidentsPage.errorResolvingIncident"), error);
}
};
const headers = [
{
id: "monitorName",
content: t("incidentsTableMonitorName"),
render: (row) => monitors[row.monitorId]?.name ?? "N/A",
render: (row) => {
const monitor = monitors.find((monitor) => monitor.id === row.monitorId);
return monitor ? monitor.name : "N/A";
},
},
{
id: "status",
content: t("incidentsTableStatus"),
render: (row) => {
const status = row.status === true ? "up" : "down";
const status = row.status === true ? "down" : "up";
const statusText =
row.status === true ? t("incidentsPage.active") : t("incidentsPage.resolved");
return (
<StatusLabel
status={status}
text={status}
customStyles={{ textTransform: "capitalize" }}
text={statusText}
/>
);
},
},
{
id: "dateTime",
content: t("incidentsTableDateTime"),
id: "startTime",
content: t("incidentsPage.startTime"),
render: (row) => {
const formattedDate = formatDateWithTz(
row.createdAt,
row.startTime || row.createdAt,
"YYYY-MM-DD HH:mm:ss A",
uiTimezone
);
return formattedDate;
},
},
{
id: "endTime",
content: t("incidentsPage.endTime"),
render: (row) => {
if (row.endTime) {
return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone);
}
return "-";
},
},
{
id: "resolutionType",
content: t("incidentsPage.resolutionType"),
render: (row) => {
if (row.resolutionType) {
return (
<Typography
variant="body2"
sx={{
textTransform: "capitalize",
color:
row.resolutionType === "manual"
? theme.palette.accent.main
: theme.palette.success.main,
}}
>
{row.resolutionType}
</Typography>
);
}
return "-";
},
},
{
id: "statusCode",
content: t("incidentsTableStatusCode"),
render: (row) => <HttpStatusLabel status={row.statusCode} />,
},
{ id: "message", content: t("incidentsTableMessage"), render: (row) => row.message },
{
id: "message",
content: t("incidentsTableMessage"),
render: (row) => row.message || "-",
},
{
id: "action",
content: t("actions"),
render: (row) => {
return row.ack === false ? (
<Button
variant="contained"
color="accent"
onClick={() => {
handleResolveIncident(row._id);
}}
>
{t("incidentsTableActionResolve")}
</Button>
) : (
<Typography>
{t("incidentsTableResolvedAt")}{" "}
{formatDateWithTz(row.ackAt, "YYYY-MM-DD HH:mm:ss A", uiTimezone)}
</Typography>
return (
<IncidentActionsMenu
incident={row}
monitor={monitors.find((monitor) => monitor.id === row.monitorId)}
onResolve={handleResolveIncident}
onOpenDetails={onOpenDetails}
/>
);
},
},
];
if (isLoading || resolveLoading) return <TableSkeleton />;
if (isLoading) return <TableSkeleton />;
if (networkError) {
return (
@@ -229,20 +148,25 @@ const IncidentTable = ({
);
}
if (!isLoading && typeof checksCount === "undefined") {
return <GenericFallback>{t("incidentsTableNoIncidents")}</GenericFallback>;
if (!isLoading && !networkError && incidents?.length === 0) {
return (
<GenericFallback>
{t("incidentsTableNoIncidents", "No incidents found")}
</GenericFallback>
);
}
const incidentsData = Array.isArray(incidents) ? incidents : [];
return (
<>
<DataTable
headers={headers}
data={checks}
config={{ tooltipContent: GetTooltip }}
data={incidentsData}
/>
<Pagination
paginationLabel={t("incidentsTablePaginationLabel")}
itemCount={checksCount}
paginationLabel={t("incidentsTablePaginationLabel", "Incidents")}
itemCount={incidentsCount || 0}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
@@ -253,12 +177,17 @@ const IncidentTable = ({
};
IncidentTable.propTypes = {
isLoading: PropTypes.bool,
monitors: PropTypes.object,
selectedMonitor: PropTypes.string,
filter: PropTypes.string,
dateRange: PropTypes.string,
updateTrigger: PropTypes.bool,
setUpdateTrigger: PropTypes.func,
incidents: PropTypes.array.isRequired, // Array of incident objects
incidentsCount: PropTypes.number.isRequired, // Total count for pagination
isLoading: PropTypes.bool.isRequired, // Loading state
networkError: PropTypes.bool, // Network error object
page: PropTypes.number.isRequired, // Current page number
rowsPerPage: PropTypes.number.isRequired, // Number of rows per page
handleChangePage: PropTypes.func.isRequired, // Handler for page change
handleChangeRowsPerPage: PropTypes.func.isRequired, // Handler for rows per page change
resolveIncident: PropTypes.func.isRequired,
handleUpdateTrigger: PropTypes.func.isRequired,
onOpenDetails: PropTypes.func,
};
export default IncidentTable;
@@ -2,39 +2,16 @@ import PropTypes from "prop-types";
import { Box, Stack, Typography } from "@mui/material";
import { useTheme } from "@emotion/react";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { getHumanReadableDuration } from "@/Utils/timeUtils.js";
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
import Icon from "@/Components/v1/Icon";
import { useTranslation } from "react-i18next";
import useGetIncidentsDuration from "../../hooks/useGetIncidentsDuration";
const IncidentItem = ({ incident }) => {
const { t } = useTranslation();
const theme = useTheme();
const isActive = incident.status === true;
const calculateDuration = () => {
if (!incident.startTime) {
return "-";
}
const startTime = new Date(incident.startTime);
const endTime = isActive
? new Date()
: incident.endTime
? new Date(incident.endTime)
: null;
if (!endTime) {
return "-";
}
const durationMs = endTime.getTime() - startTime.getTime();
if (durationMs < 0) {
return "-";
}
return getHumanReadableDuration(durationMs);
};
const duration = calculateDuration();
const duration = useGetIncidentsDuration(incident, isActive);
const iconWrapperStyle = {
display: "flex",
justifyContent: "center",
@@ -84,7 +84,7 @@ const LatestIncidentsPanel = ({ incidents = [], isLoading = false, error = null
) : (
<Stack gap={theme.spacing(4)}>
{incidents.map((incident, index) => (
<Box key={incident._id}>
<Box key={incident.id}>
<IncidentItem incident={incident} />
{index < incidents.length - 1 && <Divider sx={{ mt: theme.spacing(2) }} />}
</Box>
@@ -5,7 +5,6 @@ import PropTypes from "prop-types";
//Utils
import { useTheme } from "@emotion/react";
import SkeletonLayout from "./skeleton.jsx";
import { useTranslation } from "react-i18next";
const OptionsHeader = ({
@@ -15,27 +14,28 @@ const OptionsHeader = ({
monitors,
filter = "all",
setFilter,
dateRange = "hour",
dateRange = "all",
setDateRange,
}) => {
const theme = useTheme();
const { t } = useTranslation();
const monitorNames = typeof monitors !== "undefined" ? Object.values(monitors) : [];
const filterOptions = [
{ _id: "all", name: t("incidentsOptionsHeaderFilterAll") },
{ _id: "down", name: t("incidentsOptionsHeaderFilterDown") },
{ _id: "resolve", name: t("incidentsOptionsHeaderFilterCannotResolve") },
{ _id: "resolved", name: t("incidentsOptionsHeaderFilterResolved") },
{ _id: "all", name: t("incidentsPage.incidentsOptionsHeaderFilterAll") },
{ _id: "active", name: t("incidentsPage.incidentsOptionsHeaderFilterActive") },
{ _id: "resolved", name: t("incidentsPage.incidentsOptionsHeaderFilterResolved") },
{ _id: "manual", name: t("incidentsPage.incidentsOptionsHeaderFilterManual") },
{ _id: "automatic", name: t("incidentsPage.incidentsOptionsHeaderFilterAutomatic") },
];
// The stacks below which are three in number have the same style so
const stackStyles = {
direction: "row",
alignItems: "center",
gap: theme.spacing(6),
};
if (!shouldRender) return <SkeletonLayout />;
if (!shouldRender) return;
return (
<Stack
@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import PropTypes from "prop-types";
import { Button, Stack } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import { GenericDialog } from "@/Components/v1/Dialog/genericDialog.jsx";
import TextInput from "@/Components/v1/Inputs/TextInput/index.jsx";
const ResolveIncidentDialog = ({
open,
incidentId,
onClose,
onResolve,
onAfterResolve,
onResolved,
isLoading = false,
}) => {
const { t } = useTranslation();
const theme = useTheme();
const [comment, setComment] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (open) {
setComment("");
}
}, [open]);
const handleConfirm = async () => {
if (!incidentId) return;
const trimmed = comment.trim();
try {
setIsSubmitting(true);
await onResolve(incidentId, trimmed ? { comment: trimmed } : {});
if (onAfterResolve) {
await onAfterResolve();
}
onResolved?.();
onClose();
} catch (error) {
console.error("Error resolving incident:", error);
} finally {
setIsSubmitting(false);
}
};
return (
<GenericDialog
open={open}
theme={theme}
title={t("incidentsPage.resolveIncidentDialogTitle")}
description={t("incidentsPage.resolveIncidentDialogDescription")}
onClose={onClose}
>
<Stack gap={theme.spacing(4)}>
<TextInput
label={t("incidentsPage.resolveIncidentDialogCommentLabel")}
placeholder={t("incidentsPage.resolveIncidentDialogCommentPlaceholder")}
value={comment}
onChange={(e) => setComment(e.target.value)}
maxWidth="100%"
/>
<Stack
direction="row"
gap={theme.spacing(4)}
mt={theme.spacing(4)}
justifyContent="flex-end"
>
<Button
variant="contained"
color="secondary"
onClick={onClose}
disabled={isLoading || isSubmitting}
>
{t("cancel", "Cancel")}
</Button>
<Button
variant="contained"
color="error"
onClick={handleConfirm}
disabled={isLoading || isSubmitting}
>
{t("incidentsPage.resolveIncidentDialogConfirm")}
</Button>
</Stack>
</Stack>
</GenericDialog>
);
};
ResolveIncidentDialog.propTypes = {
open: PropTypes.bool.isRequired,
incidentId: PropTypes.string,
onClose: PropTypes.func.isRequired,
onResolve: PropTypes.func.isRequired,
onAfterResolve: PropTypes.func,
isLoading: PropTypes.bool,
onResolved: PropTypes.func,
};
export default ResolveIncidentDialog;
@@ -45,9 +45,8 @@ const useFetchIncidents = () => {
setNetworkError(false);
const res = await networkService.getIncidentsByTeam(config);
setIncidents(res.data?.data?.incidents || []);
setIncidentsCount(res.data?.data?.incidentsCount || 0);
setIncidentsCount(res.data?.data?.count || 0);
} catch (error) {
setNetworkError(true);
console.error(t("incidentsPage.errorFetchingIncidents"), error);
@@ -73,6 +72,16 @@ const useFetchIncidents = () => {
[fetchIncidentsByTeam]
);
const fetchIncidentsByResolutionType = useCallback(
async (resolutionType, config = {}) => {
await fetchIncidentsByTeam({
resolutionType,
sortOrder: "desc",
...config,
});
},
[fetchIncidentsByTeam]
);
/**
* Fetch resolved incidents
*
@@ -199,6 +208,7 @@ const useFetchIncidents = () => {
fetchIncidentById,
resolveIncident,
fetchIncidentSummary,
fetchIncidentsByResolutionType,
};
};
@@ -0,0 +1,25 @@
import { getHumanReadableDuration } from "@/Utils/timeUtilsLegacy.js";
const useGetIncidentsDuration = (incident, isActive) => {
if (!incident?.startTime) {
return "-";
}
const startTime = new Date(incident?.startTime);
const endTime = isActive
? new Date()
: incident?.endTime
? new Date(incident.endTime)
: null;
if (!endTime) {
return "-";
}
const durationMs = endTime.getTime() - startTime.getTime();
if (durationMs < 0) {
return "-";
}
return getHumanReadableDuration(durationMs);
};
export default useGetIncidentsDuration;
+120 -62
View File
@@ -4,70 +4,110 @@ import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import IncidentTable from "./Components/IncidentTable/index.jsx";
import OptionsHeader from "./Components/OptionsHeader/index.jsx";
import StatusBoxes from "./Components/StatusBoxes/index.jsx";
import { Box, Button } from "@mui/material";
import IncidentsSummaryPanel from "./Components/IncidentsSummaryPanel/index.jsx";
import IncidentDetailsModal from "./Components/IncidentDetailsModal/index.jsx";
//Utils
import { useTheme } from "@emotion/react";
import { useFetchMonitorsByTeamId } from "@/Hooks/monitorHooks.js";
import {
useFetchChecksSummaryByTeamId,
useAcknowledgeChecks,
} from "@/Hooks/checkHooks.js";
import { useState, useEffect } from "react";
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
//Constants
const Checks = () => {
// Redux state
// Hooks
import useFetchIncidents from "./hooks/useFetchIncidents.js";
import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks.js";
const Incidents2 = () => {
const { monitorId } = useParams();
const { t } = useTranslation();
const BREADCRUMBS = [{ name: t("checksPageTitle"), path: "/checks" }];
const BREADCRUMBS = [
{ name: t("incidentsPageTitle", "Incidents"), path: "/incidents" },
];
// Local state
const [selectedMonitor, setSelectedMonitor] = useState("0");
const [filter, setFilter] = useState(undefined);
const [dateRange, setDateRange] = useState(undefined);
const [selectedMonitor, setSelectedMonitor] = useState(monitorId || "0");
const [filter, setFilter] = useState("all");
const [dateRange, setDateRange] = useState("all");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [monitorLookup, setMonitorLookup] = useState(undefined);
const [updateTrigger, setUpdateTrigger] = useState(false);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [selectedIncidentId, setSelectedIncidentId] = useState(null);
const handleUpdateTrigger = () => {
setUpdateTrigger((prev) => !prev);
};
//Hooks
const { acknowledge, isLoadingAcknowledge } = useAcknowledgeChecks();
const [monitors, isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId(
{}
);
const {
incidents,
incidentsCount,
isLoading: isLoadingIncidents,
networkError: incidentsNetworkError,
fetchIncidents,
fetchActiveIncidents,
fetchResolvedIncidents,
resolveIncident,
fetchIncidentsByResolutionType,
} = useFetchIncidents();
const networkError = monitorsNetworkError || incidentsNetworkError;
//Utils
const theme = useTheme();
const [monitors, , isLoading, networkError] = useFetchMonitorsByTeamId({});
const [summary, isLoadingSummary, networkErrorSummary] = useFetchChecksSummaryByTeamId({
updateTrigger,
});
const { monitorId } = useParams();
useEffect(() => {
if (monitorId) {
setSelectedMonitor(monitorId);
setPage(0);
}, [selectedMonitor, filter, dateRange]);
useEffect(() => {
const config = {
monitorId: selectedMonitor !== "0" ? selectedMonitor : undefined,
sortOrder: "desc",
dateRange,
page,
rowsPerPage,
};
if (filter === "active") {
fetchActiveIncidents(config);
} else if (filter === "resolved") {
fetchResolvedIncidents(config);
} else if (filter === "manual") {
fetchIncidentsByResolutionType("manual", config);
} else if (filter === "automatic") {
fetchIncidentsByResolutionType("automatic", config);
} else {
fetchIncidents(config);
}
}, [monitorId]);
}, [
selectedMonitor,
filter,
dateRange,
page,
rowsPerPage,
updateTrigger,
fetchActiveIncidents,
fetchResolvedIncidents,
fetchIncidents,
fetchIncidentsByResolutionType,
]);
useEffect(() => {
const monitorLookup = monitors?.reduce((acc, monitor) => {
acc[monitor._id] = {
_id: monitor._id,
const lookup = monitors?.reduce((acc, monitor) => {
acc[monitor.id] = {
id: monitor.id,
name: monitor.name,
type: monitor.type,
};
return acc;
}, {});
setMonitorLookup(monitorLookup);
setMonitorLookup(lookup);
}, [monitors]);
const handleAcknowledge = () => {
const monitorId = selectedMonitor === "0" ? null : selectedMonitor;
acknowledge(setUpdateTrigger, monitorId);
};
if (networkError || networkErrorSummary) {
if (networkError) {
return (
<GenericFallback>
<NetworkError />
@@ -75,27 +115,33 @@ const Checks = () => {
);
}
const handleChangePage = (_, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const handleOpenDetails = (id) => {
if (!id) return;
setSelectedIncidentId(id);
setIsDetailsOpen(true);
};
const handleCloseDetails = () => {
setIsDetailsOpen(false);
setSelectedIncidentId(null);
};
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<Box alignSelf="flex-end">
<Button
variant="contained"
color="accent"
onClick={handleAcknowledge}
disabled={isLoadingAcknowledge}
>
{selectedMonitor === "0"
? t("checksPageActionResolveAllMonitor")
: t("checksPageActionResolveMonitor")}
</Button>
</Box>
<StatusBoxes
isLoading={isLoadingSummary}
summary={summary}
/>
<IncidentsSummaryPanel updateTrigger={updateTrigger} />
<OptionsHeader
shouldRender={!isLoading}
shouldRender={!isLoadingMonitors}
monitors={monitorLookup}
selectedMonitor={selectedMonitor}
setSelectedMonitor={setSelectedMonitor}
@@ -104,17 +150,29 @@ const Checks = () => {
dateRange={dateRange}
setDateRange={setDateRange}
/>
<IncidentTable
isLoading={isLoading}
monitors={monitorLookup ? monitorLookup : {}}
selectedMonitor={selectedMonitor}
filter={filter}
dateRange={dateRange}
updateTrigger={updateTrigger}
setUpdateTrigger={setUpdateTrigger}
monitors={monitors || []}
incidents={incidents || []}
incidentsCount={incidentsCount || 0}
isLoading={isLoadingIncidents}
networkError={incidentsNetworkError}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
resolveIncident={resolveIncident}
handleUpdateTrigger={handleUpdateTrigger}
onOpenDetails={handleOpenDetails}
/>
<IncidentDetailsModal
open={isDetailsOpen}
incidentId={selectedIncidentId}
onClose={handleCloseDetails}
onResolved={handleUpdateTrigger}
/>
</Stack>
);
};
export default Checks;
export default Incidents2;
@@ -1,204 +0,0 @@
//Components
import DataTable from "@/Components/v1/Table/index.jsx";
import TableSkeleton from "@/Components/v1/Table/skeleton.jsx";
import Pagination from "@/Components/v1/Table/TablePagination/index.jsx";
import { StatusLabel } from "@/Components/v1/Label/index.jsx";
import { HttpStatusLabel } from "@/Components/v1/HttpStatusLabel/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
//Utils
import { formatDateWithTz } from "../../../../Utils/timeUtils.js";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { Button, Typography, useTheme } from "@mui/material";
const IncidentTable = ({
incidents = [],
incidentsCount = 0,
isLoading = false,
networkError = false,
page = 0,
rowsPerPage = 10,
handleChangePage,
handleChangeRowsPerPage,
resolveIncident,
handleUpdateTrigger,
}) => {
const uiTimezone = useSelector((state) => state.ui.timezone);
const { t } = useTranslation();
const theme = useTheme();
const handleResolveIncident = async (incidentId) => {
try {
await resolveIncident(incidentId);
handleUpdateTrigger();
} catch (error) {
console.error(t("incidentsPage.errorResolvingIncident"), error);
}
};
const headers = [
{
id: "monitorName",
content: t("incidentsTableMonitorName"),
render: (row) => row.monitorName ?? "N/A",
},
{
id: "status",
content: t("incidentsTableStatus"),
render: (row) => {
const status = row.status === true ? "down" : "up";
const statusText =
row.status === true ? t("incidentsPage.active") : t("incidentsPage.resolved");
return (
<StatusLabel
status={status}
text={statusText}
/>
);
},
},
{
id: "startTime",
content: t("incidentsPage.startTime"),
render: (row) => {
const formattedDate = formatDateWithTz(
row.startTime || row.createdAt,
"YYYY-MM-DD HH:mm:ss A",
uiTimezone
);
return formattedDate;
},
},
{
id: "endTime",
content: t("incidentsPage.endTime"),
render: (row) => {
if (row.endTime) {
return formatDateWithTz(row.endTime, "YYYY-MM-DD HH:mm:ss A", uiTimezone);
}
return "-";
},
},
{
id: "resolutionType",
content: t("incidentsPage.resolutionType"),
render: (row) => {
if (row.resolutionType) {
return (
<Typography
variant="body2"
sx={{
textTransform: "capitalize",
color:
row.resolutionType === "manual"
? theme.palette.accent.main
: theme.palette.success.main,
}}
>
{row.resolutionType}
</Typography>
);
}
return "-";
},
},
{
id: "statusCode",
content: t("incidentsTableStatusCode"),
render: (row) => <HttpStatusLabel status={row.statusCode} />,
},
{
id: "message",
content: t("incidentsTableMessage"),
render: (row) => row.message || "-",
},
{
id: "action",
content: t("actions"),
render: (row) => {
if (row.status === true) {
return (
<Button
variant="contained"
color="accent"
sx={{
minHeight: "max-content",
lineHeight: 1.2,
}}
onClick={() => {
handleResolveIncident(row._id);
}}
>
{t("incidentsPage.incidentsTableActionResolveManually")}
</Button>
);
} else {
return (
<Typography
variant="body2"
color={theme.palette.primary.contrastTextSecondary}
>
{t("incidentsPage.incidentsTableResolved")}
</Typography>
);
}
},
},
];
if (isLoading) return <TableSkeleton />;
if (networkError) {
return (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
if (!isLoading && !networkError && incidents?.length === 0) {
return (
<GenericFallback>
{t("incidentsTableNoIncidents", "No incidents found")}
</GenericFallback>
);
}
const incidentsData = Array.isArray(incidents) ? incidents : [];
return (
<>
<DataTable
headers={headers}
data={incidentsData}
/>
<Pagination
paginationLabel={t("incidentsTablePaginationLabel", "Incidents")}
itemCount={incidentsCount || 0}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
/>
</>
);
};
IncidentTable.propTypes = {
incidents: PropTypes.array.isRequired, // Array of incident objects
incidentsCount: PropTypes.number.isRequired, // Total count for pagination
isLoading: PropTypes.bool.isRequired, // Loading state
networkError: PropTypes.bool, // Network error object
page: PropTypes.number.isRequired, // Current page number
rowsPerPage: PropTypes.number.isRequired, // Number of rows per page
handleChangePage: PropTypes.func.isRequired, // Handler for page change
handleChangeRowsPerPage: PropTypes.func.isRequired, // Handler for rows per page change
resolveIncident: PropTypes.func.isRequired,
handleUpdateTrigger: PropTypes.func.isRequired,
};
export default IncidentTable;
-150
View File
@@ -1,150 +0,0 @@
// Components
import { Stack } from "@mui/material";
import Breadcrumbs from "@/Components/v1/Breadcrumbs/index.jsx";
import GenericFallback from "@/Components/v1/GenericFallback/index.jsx";
import IncidentTable from "./Components/IncidentTable/index.jsx";
import OptionsHeader from "./Components/OptionsHeader/index.jsx";
import IncidentsSummaryPanel from "./Components/IncidentsSummaryPanel/index.jsx";
//Utils
import { useTheme } from "@emotion/react";
import { useState, useEffect } from "react";
import NetworkError from "@/Components/v1/GenericFallback/NetworkError.jsx";
import { useTranslation } from "react-i18next";
// Hooks
import useFetchIncidents from "./hooks/useFetchIncidents.js";
import { useFetchMonitorsByTeamId } from "../../Hooks/monitorHooks.js";
const Incidents2 = () => {
const { t } = useTranslation();
const BREADCRUMBS = [
{ name: t("incidentsPageTitle", "Incidents"), path: "/incidents" },
];
const [selectedMonitor, setSelectedMonitor] = useState("0");
const [filter, setFilter] = useState("all");
const [dateRange, setDateRange] = useState("all");
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [monitorLookup, setMonitorLookup] = useState(undefined);
const [updateTrigger, setUpdateTrigger] = useState(false);
const handleUpdateTrigger = () => {
setUpdateTrigger((prev) => !prev);
};
const [monitors, isLoadingMonitors, monitorsNetworkError] = useFetchMonitorsByTeamId(
{}
);
const {
incidents,
incidentsCount,
isLoading: isLoadingIncidents,
networkError: incidentsNetworkError,
fetchIncidents,
fetchActiveIncidents,
fetchResolvedIncidents,
resolveIncident,
} = useFetchIncidents();
const networkError = monitorsNetworkError || incidentsNetworkError;
const theme = useTheme();
useEffect(() => {
setPage(0);
}, [selectedMonitor, filter, dateRange]);
useEffect(() => {
const config = {
monitorId: selectedMonitor !== "0" ? selectedMonitor : undefined,
sortOrder: "desc",
dateRange,
page,
rowsPerPage,
};
if (filter === "active") {
fetchActiveIncidents(config);
} else if (filter === "resolved") {
fetchResolvedIncidents(config);
} else {
fetchIncidents(config);
}
}, [
selectedMonitor,
filter,
dateRange,
page,
rowsPerPage,
updateTrigger,
fetchActiveIncidents,
fetchResolvedIncidents,
fetchIncidents,
]);
useEffect(() => {
const lookup = monitors?.reduce((acc, monitor) => {
acc[monitor.id] = {
_id: monitor.id,
name: monitor.name,
type: monitor.type,
};
return acc;
}, {});
setMonitorLookup(lookup);
}, [monitors]);
if (networkError) {
return (
<GenericFallback>
<NetworkError />
</GenericFallback>
);
}
const handleChangePage = (_, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
return (
<Stack gap={theme.spacing(10)}>
<Breadcrumbs list={BREADCRUMBS} />
<IncidentsSummaryPanel updateTrigger={updateTrigger} />
<OptionsHeader
shouldRender={!isLoadingMonitors}
monitors={monitorLookup}
selectedMonitor={selectedMonitor}
setSelectedMonitor={setSelectedMonitor}
filter={filter}
setFilter={setFilter}
dateRange={dateRange}
setDateRange={setDateRange}
/>
<IncidentTable
incidents={incidents || []}
incidentsCount={incidentsCount || 0}
isLoading={isLoadingIncidents}
networkError={incidentsNetworkError}
page={page}
rowsPerPage={rowsPerPage}
handleChangePage={handleChangePage}
handleChangeRowsPerPage={handleChangeRowsPerPage}
resolveIncident={resolveIncident}
handleUpdateTrigger={handleUpdateTrigger}
/>
</Stack>
);
};
export default Incidents2;
Loaded 100 of 341 files, more files were not shown because too many files have changed in this diff. Show more