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
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 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
368 changed files with 14858 additions and 18292 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"
},
+21 -8
View File
@@ -10,7 +10,6 @@ import { logger } from "./Utils/Logger"; // Import the logger
import { networkService } from "./main";
import { Routes } from "./Routes";
import AppLayout from "@/Components/v1/Layouts/AppLayout";
import { UserGuideSidebarProvider } from "@/Components/v1/UserGuide";
function App() {
const mode = useSelector((state) => state.ui.mode);
@@ -23,15 +22,29 @@ function App() {
};
}, []);
const theme = mode === "light" ? lightTheme : darkTheme;
return (
<ThemeProvider theme={mode === "light" ? lightTheme : darkTheme}>
<ThemeProvider theme={theme}>
<CssBaseline />
<UserGuideSidebarProvider>
<AppLayout>
<Routes />
</AppLayout>
</UserGuideSidebarProvider>
<ToastContainer />
<GlobalStyles
styles={{
body: {
backgroundColor: theme.palette.background.main,
},
}}
/>
<AppLayout>
<Routes />
</AppLayout>
<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,
@@ -0,0 +1,29 @@
.home-layout {
position: relative;
min-height: 100vh;
margin: 0 auto;
padding: 0;
overflow-x: hidden;
width: 100%;
}
/* TODO go for this approach for responsiveness. The aside needs to be taken care of */
/* @media (max-width: 1000px) {
.home-layout {
flex-direction: column !important;
}
} */
.home-layout > .home-content-wrapper {
min-height: calc(100vh - var(--env-var-spacing-2) * 2);
flex: 1;
}
.home-content-wrapper {
padding: var(--env-var-spacing-2);
max-width: 1400px;
margin: 0 auto;
flex: 1;
min-width: 0;
overflow-x: hidden;
}
@@ -1,44 +1,30 @@
import Sidebar from "../../Sidebar/index.jsx";
import { Outlet } from "react-router";
import { Stack } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { UserGuideSidebar, TAB_BAR_WIDTH } from "../../UserGuide";
import { Box, Stack } from "@mui/material";
import { useSidebar } from "@/Hooks/useSidebar.js";
import "./index.css";
const HomeLayout = () => {
const theme = useTheme();
const { width, transition } = useSidebar();
return (
<Stack
className="home-layout"
flexDirection="row"
gap={14}
sx={{
position: "relative",
minHeight: "100vh",
margin: "0 auto",
padding: 0,
overflowX: "hidden",
width: "100%",
"& > div": {
minHeight: `calc(100vh - ${theme.spacing(4)} * 2)`,
flex: 1,
},
}}
>
<Sidebar />
<Stack
{/* Spacer for fixed sidebar */}
<Box
sx={{
padding: theme.spacing(4),
paddingRight: `${TAB_BAR_WIDTH + 16}px`,
maxWidth: 1400,
margin: "0 auto",
flex: 1,
minWidth: 0,
overflowX: "hidden",
width,
flexShrink: 0,
transition,
}}
>
/>
<Stack className="home-content-wrapper">
<Outlet />
</Stack>
<UserGuideSidebar />
</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" }}
/>
+8 -12
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,21 +74,18 @@ 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)",
backgroundColor: theme.palette.primaryBackground.main,
transition,
backgroundColor: theme.palette.background.main,
borderRight: `1px solid ${theme.palette.primary.lowContrast}`,
zIndex: 1000,
}}
>
@@ -1,157 +0,0 @@
/* ArticlePage styles - hover states and nested selectors (Dark theme) */
/* Breadcrumb hover */
.user-guide-breadcrumb-link {
transition: color 150ms ease;
}
.user-guide-breadcrumb-link:hover {
color: #1570ef;
}
/* ToC link hover */
.user-guide-toc-link {
transition: all 150ms ease;
}
.user-guide-toc-link:hover {
color: #1570ef;
text-decoration: underline !important;
}
/* Navigation button hover */
.user-guide-nav-button .nav-text {
transition: color 150ms ease;
}
.user-guide-nav-button:hover .nav-text {
color: #1570ef;
}
/* Article body typography */
.user-guide-article-body h2 {
font-size: 18px;
font-weight: 600;
color: #ffffff;
margin-top: 32px;
margin-bottom: 16px;
line-height: 1.4;
}
.user-guide-article-body h3 {
font-size: 14px;
font-weight: 600;
color: #ffffff;
margin-top: 24px;
margin-bottom: 12px;
line-height: 1.4;
}
.user-guide-article-body p {
font-size: 13px;
color: #d0d5dd;
line-height: 1.5;
margin-bottom: 16px;
margin-top: 0;
}
.user-guide-article-body ul,
.user-guide-article-body ol {
padding-left: 24px;
margin-bottom: 16px;
margin-top: 0;
}
.user-guide-article-body li {
font-size: 13px;
color: #d0d5dd;
line-height: 1.5;
margin-bottom: 8px;
}
.user-guide-article-body a {
color: #1570ef;
text-decoration: none;
font-weight: 500;
}
.user-guide-article-body a:hover {
text-decoration: underline;
}
.user-guide-article-body code {
background-color: #2d2d2d;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-family: "Geist Mono", "Fira Code", "Consolas", monospace;
color: #d0d5dd;
}
.user-guide-article-body pre {
background-color: #1c2130;
border-radius: 4px;
padding: 16px;
overflow: auto;
margin-bottom: 16px;
margin-top: 0;
}
.user-guide-article-body pre code {
background-color: transparent;
padding: 0;
color: #e5e7eb;
}
.user-guide-article-body blockquote {
border-left: 3px solid #1570ef;
background-color: rgba(255, 255, 255, 0.05);
padding: 12px 16px;
border-radius: 0 4px 4px 0;
margin-bottom: 16px;
margin-top: 0;
margin-left: 0;
margin-right: 0;
}
.user-guide-article-body blockquote p {
margin-bottom: 0;
color: #d0d5dd;
}
.user-guide-article-body table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.user-guide-article-body th,
.user-guide-article-body td {
border: 1px solid #344054;
padding: 12px;
text-align: left;
font-size: 10px;
}
.user-guide-article-body th {
background-color: rgba(255, 255, 255, 0.05);
font-weight: 600;
color: #ffffff;
}
.user-guide-article-body td {
color: #d0d5dd;
}
.user-guide-article-body img {
max-width: 100%;
border-radius: 4px;
border: 1px solid #344054;
margin-bottom: 16px;
}
.user-guide-article-body hr {
border: none;
border-top: 1px solid #344054;
margin: 32px 0;
}
@@ -1,333 +0,0 @@
import { useState, useEffect } from "react";
import {
ChevronLeft,
ChevronRight,
Rocket,
Globe,
Gauge,
Link,
Bell,
AlertTriangle,
Settings,
FileText,
} from "lucide-react";
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
import "./ArticlePage.css";
// Map icon names to actual Lucide components
const iconMap = {
Rocket,
Globe,
Gauge,
Link,
Bell,
AlertTriangle,
Settings,
FileText,
};
const ArticlePage = ({
collection,
article,
onBack,
onBackToHome,
onNextArticle,
onPrevArticle,
nextArticle,
prevArticle,
tocItems,
children,
mode = "in-app",
}) => {
const { colors, typography, spacing, border, isDark } = useUserGuideTheme();
const IconComponent = iconMap[collection.icon] || Rocket;
const isInApp = mode === "in-app";
const [activeSection, setActiveSection] = useState("");
// Track active section on scroll
useEffect(() => {
if (!tocItems || tocItems.length === 0) return;
const handleScroll = () => {
const sections = tocItems
.map((item) => {
const id = item.href.replace("#", "");
const element = document.getElementById(id);
return { id: item.href, element };
})
.filter((s) => s.element);
if (sections.length === 0) return;
let currentSection = sections[0].id;
for (const section of sections) {
if (section.element) {
const rect = section.element.getBoundingClientRect();
if (rect.top <= 150) {
currentSection = section.id;
}
}
}
setActiveSection(currentSection);
};
window.addEventListener("scroll", handleScroll, { passive: true });
document.addEventListener("scroll", handleScroll, { passive: true, capture: true });
handleScroll();
return () => {
window.removeEventListener("scroll", handleScroll);
document.removeEventListener("scroll", handleScroll, { capture: true });
};
}, [tocItems]);
return (
<div style={{ minHeight: "100%", backgroundColor: colors.background.alt }}>
{/* Main content area */}
<div
style={{
maxWidth: isInApp ? "100%" : 1100,
margin: "0 auto",
padding: isInApp ? spacing.lg : spacing["3xl"],
}}
>
{/* Article Content */}
<div style={{ maxWidth: isInApp ? "100%" : 680, minWidth: 0 }}>
{/* Title */}
<h1
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize["3xl"],
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.lg,
lineHeight: typography.lineHeight.snug,
marginTop: 0,
}}
>
{article.title}
</h1>
{/* Description */}
<p
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.md,
color: colors.text.secondary,
marginBottom: spacing.lg,
lineHeight: typography.lineHeight.normal,
marginTop: 0,
}}
>
{article.description}
</p>
{/* Table of Contents - at top */}
{tocItems && tocItems.length > 0 && (
<div
style={{
marginBottom: spacing["2xl"],
paddingBottom: isInApp ? 0 : spacing.lg,
borderBottom: isInApp ? "none" : border.default,
}}
>
<div
style={{
backgroundColor: isInApp
? isDark
? "rgba(255, 255, 255, 0.05)"
: "rgba(0, 0, 0, 0.02)"
: "transparent",
borderRadius: isInApp ? border.radius : 0,
padding: isInApp ? spacing.md : 0,
border: isInApp ? border.default : "none",
}}
>
<span
style={{
display: "block",
fontSize: 12,
fontWeight: typography.fontWeight.semibold,
color: colors.text.secondary,
marginBottom: spacing.sm,
textTransform: "uppercase",
letterSpacing: "0.5px",
}}
>
In this article
</span>
<div
style={{
display: "flex",
flexDirection: isInApp ? "column" : "row",
flexWrap: isInApp ? "nowrap" : "wrap",
gap: isInApp ? 0 : spacing.xs,
columnGap: isInApp ? 0 : spacing.lg,
}}
>
{tocItems.map((item) => {
const isActive = activeSection === item.href;
return (
<a
key={item.href}
href={item.href}
className="user-guide-toc-link"
onClick={(e) => {
if (isInApp) {
e.preventDefault();
const id = item.href.replace("#", "");
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}
}}
style={{
fontFamily: typography.fontFamily.sans,
fontSize: isInApp ? 13 : 12,
color: isActive ? colors.brand.primary : colors.text.secondary,
fontWeight: isActive
? typography.fontWeight.medium
: typography.fontWeight.normal,
textDecoration: "none",
padding: isInApp ? "2px 0" : `${spacing.xs} 0`,
}}
>
{item.label}
</a>
);
})}
</div>
</div>
</div>
)}
{/* Article Body */}
<div className="user-guide-article-body">
{children || (
<div
style={{
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
padding: spacing.xl,
textAlign: "center",
}}
>
<span
style={{ fontSize: typography.fontSize.sm, color: colors.text.muted }}
>
Article content coming soon.
</span>
</div>
)}
</div>
{/* Navigation */}
{(prevArticle || nextArticle) && (
<div
style={{
display: "flex",
justifyContent: "space-between",
marginTop: spacing["2xl"],
paddingTop: spacing.xl,
borderTop: border.default,
}}
>
{prevArticle ? (
<div
onClick={onPrevArticle}
className="user-guide-nav-button"
style={{
display: "flex",
alignItems: "center",
gap: spacing.sm,
cursor: "pointer",
}}
>
<ChevronLeft
size={16}
strokeWidth={1.5}
color={colors.text.muted}
/>
<div>
<span
style={{
display: "block",
fontSize: typography.fontSize.xs,
color: colors.text.muted,
}}
>
Previous
</span>
<span
className="nav-text"
style={{
display: "block",
fontSize: typography.fontSize.sm,
color: colors.text.primary,
fontWeight: typography.fontWeight.medium,
}}
>
{prevArticle.title}
</span>
</div>
</div>
) : (
<div />
)}
{nextArticle && (
<div
onClick={onNextArticle}
className="user-guide-nav-button"
style={{
display: "flex",
alignItems: "center",
gap: spacing.sm,
textAlign: "right",
cursor: "pointer",
}}
>
<div>
<span
style={{
display: "block",
fontSize: typography.fontSize.xs,
color: colors.text.muted,
}}
>
Next
</span>
<span
className="nav-text"
style={{
display: "block",
fontSize: typography.fontSize.sm,
color: colors.text.primary,
fontWeight: typography.fontWeight.medium,
}}
>
{nextArticle.title}
</span>
</div>
<ChevronRight
size={16}
strokeWidth={1.5}
color={colors.text.muted}
/>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default ArticlePage;
@@ -1,31 +0,0 @@
/* CollectionPage styles - hover states */
/* Back link hover */
.user-guide-back-link {
transition: color 150ms ease;
}
.user-guide-back-link:hover {
color: #1570ef;
}
/* Article row hover */
.user-guide-article-row {
transition: all 150ms ease;
}
.user-guide-article-row:hover {
background-color: rgba(0, 0, 0, 0.04);
}
[data-mui-color-scheme="dark"] .user-guide-article-row:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.user-guide-article-row .arrow-icon {
transition: all 150ms ease;
}
.user-guide-article-row:hover .arrow-icon {
color: #1570ef;
}
@@ -1,171 +0,0 @@
import {
ArrowRight,
FileText,
Rocket,
Globe,
Gauge,
Link,
Bell,
AlertTriangle,
Settings,
} from "lucide-react";
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
import "./CollectionPage.css";
// Map icon names to actual Lucide components
const iconMap = {
Rocket,
Globe,
Gauge,
Link,
Bell,
AlertTriangle,
Settings,
FileText,
};
const CollectionPage = ({ collection, onBack, onArticleClick }) => {
const { colors, typography, spacing, border } = useUserGuideTheme();
const IconComponent = iconMap[collection.icon] || Rocket;
return (
<div style={{ minHeight: "100%", backgroundColor: colors.background.alt }}>
{/* Header */}
<div
style={{
backgroundColor: colors.background.white,
borderBottom: border.default,
padding: `${spacing.lg} ${spacing.lg}`,
}}
>
<div style={{ maxWidth: 900, margin: "0 auto" }}>
{/* Collection Header */}
<div style={{ display: "flex", alignItems: "flex-start", gap: spacing.md }}>
<IconComponent
size={24}
strokeWidth={1.5}
color={colors.brand.primary}
style={{ marginTop: 4 }}
/>
<div>
<h1
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize["2xl"],
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.xs,
marginTop: 0,
}}
>
{collection.title}
</h1>
<p
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
margin: 0,
}}
>
{collection.description}
</p>
<span
style={{
display: "block",
fontSize: typography.fontSize.sm,
color: colors.text.muted,
marginTop: spacing.sm,
}}
>
{collection.articleCount} articles in this collection
</span>
</div>
</div>
</div>
</div>
{/* Articles List */}
<div
style={{
maxWidth: 900,
margin: "0 auto",
padding: `${spacing.lg} ${spacing.lg}`,
}}
>
<div
style={{
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
overflow: "hidden",
}}
>
{collection.articles.map((article, index) => (
<div
key={article.id}
onClick={() => onArticleClick(article.id)}
className="user-guide-article-row"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: spacing.lg,
borderBottom:
index < collection.articles.length - 1 ? border.default : "none",
cursor: "pointer",
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: spacing.md }}>
<FileText
size={16}
strokeWidth={1.5}
color={colors.text.muted}
style={{ marginTop: 2, flexShrink: 0 }}
/>
<div>
<span
style={{
display: "block",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.medium,
color: colors.text.primary,
marginBottom: spacing.xs,
}}
>
{article.title}
</span>
<span
style={{
display: "block",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
}}
>
{article.description}
</span>
</div>
</div>
<ArrowRight
className="arrow-icon"
size={16}
strokeWidth={1.5}
color={colors.text.muted}
style={{
flexShrink: 0,
marginLeft: 16,
}}
/>
</div>
))}
</div>
</div>
</div>
);
};
export default CollectionPage;
@@ -1,45 +0,0 @@
/* ContentRenderer styles - hover states and list styling */
/* List styling - colors handled by inline styles for theme support */
.user-guide-content-list li {
/* Font styles handled inline for theme-aware colors */
}
/* Table row hover */
.user-guide-table-row {
transition: background-color 150ms ease;
}
.user-guide-table-row:hover {
background-color: rgba(0, 0, 0, 0.04) !important;
}
[data-mui-color-scheme="dark"] .user-guide-table-row:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
}
/* Article links hover */
.user-guide-article-link-item {
transition: all 150ms ease;
}
.user-guide-article-link-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
[data-mui-color-scheme="dark"] .user-guide-article-link-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.user-guide-article-link-item:hover .link-title {
color: #1570ef;
}
.user-guide-article-link-item .arrow-icon {
transition: all 150ms ease;
}
.user-guide-article-link-item:hover .arrow-icon {
transform: translateX(4px);
color: #1570ef;
}
@@ -1,925 +0,0 @@
import { useState } from "react";
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
import ImageLightbox from "./ImageLightbox";
import {
CheckCircle,
Clock,
Rocket,
Shield,
Brain,
AlertTriangle,
Server,
Database,
Terminal,
FileText,
FolderKanban,
Gauge,
LayoutDashboard,
Menu,
Settings,
Info,
Lightbulb,
ArrowRight,
Globe,
Bell,
Link,
} from "lucide-react";
import "./ContentRenderer.css";
// Icon mapping
const iconMap = {
CheckCircle,
Clock,
Rocket,
Shield,
Brain,
AlertTriangle,
Server,
Database,
Terminal,
FileText,
FolderKanban,
Gauge,
LayoutDashboard,
Menu,
Settings,
Info,
Lightbulb,
Globe,
Bell,
Link,
};
const getIcon = (name) => iconMap[name] || Info;
const ContentRenderer = ({ content, onNavigate }) => {
const { colors, typography, spacing, border, chipStyles, imageStyles, isDark } =
useUserGuideTheme();
const [lightboxImage, setLightboxImage] = useState(null);
// Parse basic markdown in text (inside component to access onNavigate)
const parseMarkdown = (text) => {
const parts = [];
let key = 0;
// Supports: **bold**, `code`, [[link text]](collection/article)
const combinedRegex = /\*\*([^*]+)\*\*|`([^`]+)`|\[\[([^\]]+)\]\]\(([^)]+)\)/g;
let lastIndex = 0;
let match;
const getChipStyle = (codeContent) => {
const isUrl = /^https?:\/\//.test(codeContent);
return isUrl ? chipStyles.url : chipStyles.code;
};
while ((match = combinedRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[1]) {
// Bold: **text**
parts.push(<strong key={key++}>{match[1]}</strong>);
} else if (match[2]) {
// Code: `text`
const style = getChipStyle(match[2]);
parts.push(
<span
key={key++}
style={style}
>
{match[2]}
</span>
);
} else if (match[3] && match[4]) {
// Article link: [[text]](collection/article)
const linkText = match[3];
const linkPath = match[4];
const [collectionId, articleId] = linkPath.split("/");
parts.push(
<span
key={key++}
onClick={() => onNavigate?.(collectionId, articleId)}
style={{
color: colors.brand.primary,
cursor: "pointer",
textDecoration: "underline",
textDecorationColor: "transparent",
transition: "text-decoration-color 150ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.textDecorationColor = colors.brand.primary;
}}
onMouseLeave={(e) => {
e.currentTarget.style.textDecorationColor = "transparent";
}}
>
{linkText}
</span>
);
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : text;
};
const renderBlock = (block, index) => {
switch (block.type) {
case "heading":
return renderHeading(block, index);
case "paragraph":
return renderParagraph(block, index);
case "callout":
return renderCallout(block, index);
case "bullet-list":
return renderBulletList(block, index);
case "ordered-list":
return renderOrderedList(block, index);
case "checklist":
return renderChecklist(block, index);
case "icon-cards":
return renderIconCards(block, index);
case "grid-cards":
return renderGridCards(block, index);
case "time-estimate":
return renderTimeEstimate(block, index);
case "info-box":
return renderInfoBox(block, index);
case "code":
return renderCode(block, index);
case "requirements":
return renderRequirements(block, index);
case "table":
return renderTable(block, index);
case "image":
return renderImage(block, index);
case "article-links":
return renderArticleLinks(block, index);
default:
return null;
}
};
const renderHeading = (block, index) => {
const Tag = block.level === 2 ? "h2" : "h3";
return (
<Tag
key={index}
id={block.id}
style={{
fontFamily: typography.fontFamily.sans,
fontSize:
block.level === 2 ? typography.fontSize["2xl"] : typography.fontSize.lg,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.lg,
marginTop: index > 0 ? spacing["2xl"] : 0,
lineHeight: typography.lineHeight.normal,
}}
>
{block.text}
</Tag>
);
};
const renderParagraph = (block, index) => (
<p
key={index}
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.relaxed,
marginBottom: spacing.lg,
marginTop: 0,
}}
>
{parseMarkdown(block.text)}
</p>
);
const renderCallout = (block, index) => {
const calloutColors = {
info: colors.brand.primary,
tip: colors.brand.primary,
warning: colors.brand.warning,
success: colors.brand.success,
};
const accentColor = calloutColors[block.variant || "info"];
return (
<div
key={index}
style={{
border: border.default,
borderLeftWidth: "3px",
borderLeftColor: accentColor,
backgroundColor: isDark ? "rgba(255, 255, 255, 0.03)" : "rgba(0, 0, 0, 0.02)",
paddingLeft: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.md,
paddingRight: spacing.lg,
borderRadius: border.radius,
marginBottom: spacing["2xl"],
}}
>
{block.title && (
<span
style={{
display: "block",
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.xs,
}}
>
{block.title}
</span>
)}
<span
style={{
display: "block",
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
}}
>
{parseMarkdown(block.text)}
</span>
</div>
);
};
const renderBulletList = (block, index) => (
<ul
key={index}
className="user-guide-content-list"
style={{
paddingLeft: spacing.xl,
marginBottom: spacing["2xl"],
marginTop: 0,
}}
>
{block.items.map((item, i) => (
<li
key={i}
style={{
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
marginBottom: spacing.sm,
}}
>
{item.bold && (
<strong style={{ color: colors.text.primary }}>{item.bold}</strong>
)}
{item.bold && <span style={{ color: colors.text.secondary }}> — </span>}
<span style={{ color: colors.text.secondary }}>
{item.bold ? parseMarkdown(item.text) : parseMarkdown(item.text)}
</span>
</li>
))}
</ul>
);
const renderOrderedList = (block, index) => (
<ol
key={index}
className="user-guide-content-list"
style={{
paddingLeft: spacing.xl,
marginBottom: spacing.lg,
marginTop: 0,
}}
>
{block.items.map((item, i) => (
<li
key={i}
style={{
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
marginBottom: spacing.sm,
}}
>
{item.bold && (
<strong style={{ color: colors.text.primary }}>{item.bold}</strong>
)}
{item.bold && " "}
<span style={{ color: colors.text.secondary }}>
{item.bold ? parseMarkdown(item.text) : parseMarkdown(item.text)}
</span>
</li>
))}
</ol>
);
const renderChecklist = (block, index) => (
<div
key={index}
style={{ marginBottom: spacing["2xl"], paddingLeft: spacing.sm }}
>
{block.items.map((item, i) => (
<div
key={i}
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
gap: "10px",
marginBottom: spacing.sm,
}}
>
<CheckCircle
size={16}
strokeWidth={1.5}
color={colors.brand.primary}
style={{ flexShrink: 0, marginTop: 3 }}
/>
<span
style={{
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.tight,
}}
>
{parseMarkdown(item)}
</span>
</div>
))}
</div>
);
const renderIconCards = (block, index) => (
<div
key={index}
style={{
display: "flex",
flexDirection: "column",
gap: spacing.md,
marginBottom: spacing["2xl"],
}}
>
{block.items.map((item, i) => {
const IconComponent = getIcon(item.icon);
return (
<div
key={i}
style={{
display: "flex",
gap: spacing.md,
padding: spacing.sm,
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: "20px",
height: "22px",
}}
>
<IconComponent
size={20}
strokeWidth={1.5}
color={colors.brand.primary}
/>
</div>
<div>
<span
style={{
display: "block",
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
lineHeight: "22px",
marginBottom: "2px",
}}
>
{item.title}
</span>
<span
style={{
display: "block",
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
}}
>
{parseMarkdown(item.description)}
</span>
</div>
</div>
);
})}
</div>
);
const renderGridCards = (block, index) => (
<div
key={index}
style={{
display: "grid",
gridTemplateColumns: `repeat(auto-fit, minmax(${block.columns === 2 ? "250px" : "200px"}, 1fr))`,
gap: spacing.md,
marginBottom: spacing["2xl"],
}}
>
{block.items.map((item, i) => {
const IconComponent = item.icon ? getIcon(item.icon) : null;
return (
<div
key={i}
style={{
padding: "14px",
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: spacing.sm,
marginBottom: "6px",
}}
>
{IconComponent && (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: "16px",
height: "18px",
}}
>
<IconComponent
size={16}
strokeWidth={1.5}
color={colors.brand.primary}
/>
</span>
)}
<span
style={{
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
lineHeight: "18px",
}}
>
{item.title}
</span>
</div>
<span
style={{
display: "block",
fontSize: typography.fontSize.xs,
color: colors.text.secondary,
lineHeight: typography.lineHeight.snug,
}}
>
{parseMarkdown(item.description)}
</span>
</div>
);
})}
</div>
);
const renderTimeEstimate = (block, index) => (
<div
key={index}
style={{
display: "inline-flex",
alignItems: "center",
gap: spacing.sm,
padding: `${spacing.md} ${spacing.lg}`,
backgroundColor: isDark ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.03)",
border: border.default,
borderRadius: border.radius,
marginBottom: spacing.xl,
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
height: "20px",
}}
>
<Clock
size={16}
strokeWidth={1.5}
color={colors.brand.primary}
/>
</span>
<span
style={{
fontSize: typography.fontSize.base,
color: colors.text.primary,
lineHeight: "20px",
display: "inline-flex",
alignItems: "center",
}}
>
{parseMarkdown(block.text)}
</span>
</div>
);
const renderInfoBox = (block, index) => {
const IconComponent = block.icon ? getIcon(block.icon) : FolderKanban;
return (
<div
key={index}
style={{
display: "flex",
gap: spacing.md,
padding: spacing.lg,
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
marginBottom: spacing.lg,
}}
>
<IconComponent
size={20}
strokeWidth={1.5}
color={colors.brand.primary}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<div>
<span
style={{
display: "block",
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.sm,
}}
>
{block.title}
</span>
<ul style={{ margin: 0, paddingLeft: "20px" }}>
{block.items.map((item, i) => (
<li
key={i}
style={{
fontSize: typography.fontSize.sm,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
marginBottom: spacing.xs,
}}
>
{parseMarkdown(item)}
</li>
))}
</ul>
</div>
</div>
);
};
const renderCode = (block, index) => (
<div
key={index}
style={{
backgroundColor: colors.background.code,
borderRadius: border.radius,
padding: "14px 16px",
overflow: "auto",
marginBottom: spacing.lg,
}}
>
<pre
style={{
fontSize: typography.fontSize.sm,
fontFamily: typography.fontFamily.mono,
color: "#E5E7EB",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
}}
>
{block.code}
</pre>
</div>
);
const renderRequirements = (block, index) => (
<div
key={index}
style={{
display: "flex",
flexDirection: "column",
gap: spacing.md,
marginBottom: spacing["2xl"],
}}
>
{block.items.map((req, i) => {
const IconComponent = getIcon(req.icon);
return (
<div
key={i}
style={{
padding: spacing.lg,
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: spacing.sm,
marginBottom: spacing.md,
}}
>
<IconComponent
size={16}
strokeWidth={1.5}
color={colors.brand.primary}
style={{ flexShrink: 0 }}
/>
<span
style={{
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
}}
>
{req.title}
</span>
</div>
<ul style={{ margin: 0, paddingLeft: "20px" }}>
{req.items.map((item, j) => (
<li
key={j}
style={{
fontSize: typography.fontSize.sm,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
marginBottom: spacing.xs,
}}
>
{item}
</li>
))}
</ul>
</div>
);
})}
</div>
);
const renderTableCell = (value) => {
if (value === "✓") {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 20,
height: 20,
borderRadius: "50%",
backgroundColor: "rgba(19, 113, 91, 0.2)",
color: colors.brand.primary,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
}}
>
✓
</span>
);
}
if (value === "✗" || value === "—") {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 20,
height: 20,
borderRadius: "50%",
backgroundColor: "rgba(102, 112, 133, 0.2)",
color: colors.text.muted,
fontSize: typography.fontSize.sm,
}}
>
—
</span>
);
}
return parseMarkdown(value);
};
const renderTable = (block, index) => (
<div
key={index}
style={{
border: border.default,
borderRadius: border.radius,
overflow: "hidden",
marginBottom: spacing["2xl"],
}}
>
{/* Header */}
<div
style={{
display: "grid",
gridTemplateColumns: block.columns.map((col) => col.width || "1fr").join(" "),
backgroundColor: isDark ? "rgba(255, 255, 255, 0.05)" : "rgba(0, 0, 0, 0.03)",
borderBottom: border.default,
padding: `${spacing.md} ${spacing.lg}`,
gap: spacing.md,
}}
>
{block.columns.map((col) => (
<span
key={col.key}
style={{
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
lineHeight: typography.lineHeight.tight,
}}
>
{col.label}
</span>
))}
</div>
{/* Rows */}
{block.rows.map((row, i) => (
<div
key={i}
className="user-guide-table-row"
style={{
display: "grid",
gridTemplateColumns: block.columns.map((col) => col.width || "1fr").join(" "),
borderBottom: i < block.rows.length - 1 ? border.default : "none",
backgroundColor:
i % 2 === 1
? isDark
? "rgba(255, 255, 255, 0.02)"
: "rgba(0, 0, 0, 0.02)"
: "transparent",
padding: `10px ${spacing.lg}`,
gap: spacing.md,
alignItems: "center",
}}
>
{block.columns.map((col, j) => (
<div
key={col.key}
style={{
fontSize: typography.fontSize.sm,
fontWeight:
j === 0 ? typography.fontWeight.medium : typography.fontWeight.normal,
color: j === 0 ? colors.text.primary : colors.text.secondary,
lineHeight: typography.lineHeight.normal,
}}
>
{renderTableCell(row[col.key])}
</div>
))}
</div>
))}
</div>
);
const renderImage = (block, index) => (
<div
key={index}
style={{
marginBottom: spacing["2xl"],
}}
>
<img
src={block.src}
alt={block.alt}
onClick={() =>
setLightboxImage({ src: block.src, alt: block.alt, caption: block.caption })
}
style={{
...imageStyles.image,
cursor: "zoom-in",
}}
/>
{block.caption && (
<p
style={{
...imageStyles.caption,
marginTop: spacing.sm,
marginBottom: 0,
}}
>
{block.caption}
</p>
)}
</div>
);
const renderArticleLinks = (block, index) => (
<div
key={index}
style={{ marginBottom: spacing["2xl"] }}
>
<div
style={{
height: "1px",
backgroundColor: colors.border.default,
marginBottom: spacing.xl,
}}
/>
{block.title && (
<span
style={{
display: "block",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.md,
}}
>
{block.title}
</span>
)}
<div style={{ display: "flex", flexDirection: "column" }}>
{block.items.map((item, i) => (
<div
key={i}
onClick={() => onNavigate?.(item.collectionId, item.articleId)}
className="user-guide-article-link-item"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: spacing.sm,
margin: `0 -${spacing.sm}`,
borderRadius: "4px",
cursor: "pointer",
}}
>
<span
className="link-title"
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.medium,
color: colors.text.primary,
}}
>
{item.title}
</span>
<ArrowRight
className="arrow-icon"
size={18}
strokeWidth={1.5}
color={colors.text.muted}
style={{ flexShrink: 0, marginLeft: 16 }}
/>
</div>
))}
</div>
</div>
);
return (
<>
<div>{content.blocks.map((block, index) => renderBlock(block, index))}</div>
{/* Image Lightbox */}
{lightboxImage && (
<ImageLightbox
src={lightboxImage.src}
alt={lightboxImage.alt}
caption={lightboxImage.caption}
onClose={() => setLightboxImage(null)}
/>
)}
</>
);
};
export default ContentRenderer;
@@ -1,14 +0,0 @@
/* HelpSection styles */
.user-guide-help-button {
transition: all 150ms ease;
}
.user-guide-help-button:hover {
border-color: #1570ef;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}
[data-mui-color-scheme="dark"] .user-guide-help-button:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
@@ -1,134 +0,0 @@
import { ExternalLink } from "lucide-react";
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
import "./HelpSection.css";
const HelpSection = () => {
const { colors, typography, spacing, border } = useUserGuideTheme();
return (
<div
style={{
padding: spacing.xl,
minHeight: "100%",
}}
>
{/* Contact us */}
<div style={{ marginBottom: spacing["2xl"] }}>
<h2
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.sm,
marginTop: 0,
}}
>
Contact us
</h2>
<p
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
margin: 0,
marginBottom: spacing.md,
}}
>
Can't find what you need? Our support team is here to help.
</p>
<a
href="https://discord.com/invite/NAb6H3UTjK"
target="_blank"
rel="noopener noreferrer"
className="user-guide-help-button"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
width: "100%",
padding: `${spacing.md} ${spacing.lg}`,
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
textDecoration: "none",
cursor: "pointer",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.medium,
color: colors.text.primary,
}}
>
Join our Discord
<ExternalLink
size={14}
strokeWidth={1.5}
color={colors.text.muted}
/>
</a>
</div>
{/* Ask the community */}
<div>
<h2
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.sm,
marginTop: 0,
}}
>
GitHub discussions
</h2>
<p
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
margin: 0,
marginBottom: spacing.md,
}}
>
Questions about features, how-tos, or use cases? Join the community discussion.
</p>
<a
href="https://github.com/bluewave-labs/checkmate/discussions"
target="_blank"
rel="noopener noreferrer"
className="user-guide-help-button"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
width: "100%",
padding: `${spacing.md} ${spacing.lg}`,
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
textDecoration: "none",
cursor: "pointer",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.medium,
color: colors.text.primary,
}}
>
GitHub discussions
<ExternalLink
size={14}
strokeWidth={1.5}
color={colors.text.muted}
/>
</a>
</div>
</div>
);
};
export default HelpSection;
@@ -1,125 +0,0 @@
import { useEffect, useCallback } from "react";
import { X } from "lucide-react";
import { typography, spacing } from "./styles/theme";
const ImageLightbox = ({ src, alt, caption, onClose }) => {
// Close on escape key
const handleKeyDown = useCallback(
(e) => {
if (e.key === "Escape") {
onClose();
}
},
[onClose]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
};
}, [handleKeyDown]);
// Close when clicking backdrop
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div
onClick={handleBackdropClick}
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
padding: spacing.xl,
cursor: "zoom-out",
}}
>
{/* Close button */}
<button
onClick={onClose}
style={{
position: "absolute",
top: 16,
right: 16,
width: 40,
height: 40,
borderRadius: "50%",
backgroundColor: "rgba(255, 255, 255, 0.1)",
border: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background-color 150ms ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
}}
title="Close (Esc)"
>
<X
size={24}
strokeWidth={1.5}
color="#fff"
/>
</button>
{/* Image container */}
<div
style={{
maxWidth: "90vw",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<img
src={src}
alt={alt}
style={{
maxWidth: "100%",
maxHeight: caption ? "calc(90vh - 40px)" : "90vh",
objectFit: "contain",
borderRadius: "4px",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.3)",
cursor: "default",
}}
onClick={(e) => e.stopPropagation()}
/>
{caption && (
<p
style={{
marginTop: spacing.md,
fontSize: typography.fontSize.sm,
color: "rgba(255, 255, 255, 0.8)",
textAlign: "center",
fontStyle: "italic",
}}
>
{caption}
</p>
)}
</div>
</div>
);
};
export default ImageLightbox;
@@ -1,638 +0,0 @@
# Checkmate User Guide Style Guide
This document defines the writing standards, formatting rules, and content patterns for all Checkmate user documentation.
---
## Voice & Tone
### Core Principles
1. **Second person, active voice** - Address the reader directly
- ✅ "You can configure monitors from the dashboard"
- ❌ "Monitors can be configured from the dashboard"
2. **Professional but approachable** - Clear and helpful, not stiff or overly casual
- ✅ "Select the notification channel you want to use"
- ❌ "Go ahead and pick whatever notification channel works for you!"
- ❌ "The user shall select the appropriate notification channel"
3. **Direct and instructional** - Tell users what to do, not what the system does
- ✅ "Click Save to apply your changes"
- ❌ "The Save button will apply your changes"
4. **User-focused** - Lead with what users can accomplish
- ✅ "You can track server performance with infrastructure monitors"
- ❌ "Infrastructure monitors use the Capture agent to collect metrics"
### Language Patterns
**Opening phrases:**
- "The [Feature] allows you to..."
- "[Feature] is..."
- "You can..."
- "To [action]:"
**Navigation instructions:**
- "Navigate to [location]"
- "Click [element]"
- "Select [option]"
- "From the [page], click..."
**Avoid:**
- Passive voice where active is clearer
- Jargon without explanation
- Assuming prior knowledge
- Marketing language ("powerful", "seamless", "robust")
---
## Content Structure
### Standard Article Pattern
Every article should follow this flow:
```
1. OVERVIEW (H2)
- 1-2 paragraphs explaining what and why
- Set context for the topic
2. VISUAL (Optional)
- Screenshot showing the feature
- Include after intro, not before
3. MAIN CONTENT (H2/H3 sections)
- Procedures with ordered lists
- Features with bullet lists
- Reference info in tables
4. TIPS & WARNINGS (Callouts)
- Best practices
- Common pitfalls
- Important restrictions
5. RELATED ARTICLES (Links)
- 2-4 related topics
- Logical next steps
```
### Heading Hierarchy
Use only two heading levels within articles:
- **H2** - Major sections (Overview, Configuration, Settings)
- **H3** - Subsections within H2s (Creating a monitor, Editing settings)
Never use H4 or deeper. If content requires more nesting, restructure into separate sections.
### Section Length
| Element | Target Length |
| ------------- | -------------- |
| Paragraphs | 2-4 sentences |
| H2 sections | 100-300 words |
| H3 sections | 50-150 words |
| Full articles | 800-1500 words |
---
## Formatting Rules
### Lists
**Bullet lists** - Use for features, options, characteristics (unordered items)
```javascript
{
type: 'bullet-list',
items: [
{ text: 'HTTP monitors check website availability' },
{ text: 'Ping monitors verify server reachability' },
{ text: 'Port monitors test specific service ports' }
]
}
```
**Ordered lists** - Use for step-by-step procedures (sequential actions)
```javascript
{
type: 'ordered-list',
items: [
{ text: 'Navigate to **Uptime** in the sidebar' },
{ text: 'Click **Create monitor**' },
{ text: 'Enter the URL you want to monitor' },
{ text: 'Click **Save** to create the monitor' }
]
}
```
**List guidelines:**
- Keep procedures to 4-6 steps maximum
- If longer, break into multiple procedures
- Start each item with an action verb for procedures
- Use parallel structure (all items same grammatical form)
### Callouts
Use callouts sparingly for important information that shouldn't be missed.
**Types:**
| Variant | Use For | Example |
| --------- | --------------------------------- | -------------------------------------------------------------------------- |
| `info` | Additional context, clarification | "Monitors check at the interval you specify, starting from creation time." |
| `tip` | Best practices, recommendations | "Use descriptive names to easily identify monitors in alerts." |
| `warning` | Restrictions, potential issues | "Deleting a monitor removes all associated check history." |
| `success` | Confirmations, positive outcomes | "Your monitor is now active and checking every 5 minutes." |
```javascript
{
type: 'callout',
variant: 'tip',
title: 'Best practice',
text: 'Group related monitors together for easier management.'
}
```
**Callout guidelines:**
- Maximum 2-3 callouts per article
- Keep callout text concise (1-2 sentences)
- Title is optional but helps scannability
- Don't use callouts for routine information
### Inline Formatting
**Bold** - Use for:
- UI element names: "Click **Save**"
- Key terms on first use: "**Uptime** measures availability over time"
- Emphasis in lists: "**Name** — The display name for your monitor"
**Code/monospace** - Use for:
- URLs: `https://example.com`
- File paths: `/api/v1/monitors`
- Technical values: `200`, `timeout`
- Commands: `npm run dev`
```javascript
// In paragraph text, use backticks in markdown:
{
text: "Enter the URL (e.g., `https://example.com`) in the field";
}
```
### Tables
Use tables for:
- Feature comparisons
- Configuration options
- Reference data with multiple attributes
```javascript
{
type: 'table',
columns: [
{ key: 'type', label: 'Monitor Type', width: '1fr' },
{ key: 'use', label: 'Best For', width: '2fr' }
],
rows: [
{ type: 'HTTP', use: 'Websites and APIs' },
{ type: 'Ping', use: 'Server availability' },
{ type: 'Port', use: 'Specific services (databases, mail servers)' }
]
}
```
---
## Content Types
### Block Types Reference
| Type | Purpose | When to Use |
| --------------- | ----------------- | ------------------------- |
| `heading` | Section titles | Start each major topic |
| `paragraph` | Body text | Explanations, context |
| `bullet-list` | Unordered items | Features, options |
| `ordered-list` | Sequential steps | Procedures |
| `checklist` | Checkable items | Requirements, checklists |
| `callout` | Important notices | Tips, warnings |
| `code` | Code blocks | Commands, config examples |
| `table` | Structured data | Comparisons, reference |
| `image` | Screenshots | UI demonstration |
| `icon-cards` | Feature grids | Capability overviews |
| `article-links` | Related content | End-of-article navigation |
### Icon Cards
Use for presenting 4-8 related features or concepts:
```javascript
{
type: 'icon-cards',
items: [
{
icon: 'Globe',
title: 'HTTP monitoring',
description: 'Check website availability and response times'
},
{
icon: 'Server',
title: 'Infrastructure',
description: 'Monitor CPU, memory, and disk usage'
}
]
}
```
### Article Links
Always end articles with related content:
```javascript
{
type: 'article-links',
title: 'Related articles',
items: [
{
collectionId: 'uptime-monitoring',
articleId: 'http-monitors',
title: 'Creating HTTP monitors',
description: 'Set up your first website monitor'
}
]
}
```
---
## Images & Screenshots
### When to Include Images
- After introducing a feature (not before)
- For complex UI that benefits from visual reference
- For multi-step workflows
- Skip for simple, text-describable actions
### Image Requirements
```javascript
{
type: 'image',
src: '/images/user-guide/monitor-create.png',
alt: 'Monitor creation form showing URL field, name field, and interval selector',
caption: 'The monitor creation form with required fields highlighted'
}
```
**Alt text guidelines:**
- Describe what the image shows (15-30 words)
- Include key UI elements visible
- Make it useful for screen readers
**Caption guidelines:**
- Explain the context or what to notice
- Keep to one sentence
- Optional but recommended
### Screenshot Standards
- Capture at consistent browser width (1200px recommended)
- Use light theme for screenshots
- Highlight relevant areas if needed
- Avoid personal data in screenshots
- Crop to show relevant UI only
---
## Writing Procedures
### Step-by-Step Instructions
**Format:**
```javascript
{
type: 'ordered-list',
items: [
{ text: 'Navigate to **Uptime** in the sidebar' },
{ text: 'Click **Create monitor** in the top right' },
{ text: 'Enter the URL you want to monitor' },
{ text: 'Set the check interval (default: 5 minutes)' },
{ text: 'Click **Save** to create the monitor' }
]
}
```
**Guidelines:**
- Start each step with an action verb (Click, Enter, Select, Navigate)
- One action per step
- Include the location of UI elements
- Bold all clickable elements
- Add context in parentheses when helpful
### Describing Features
**Format:**
```javascript
{
type: 'bullet-list',
items: [
{ bold: 'Name', text: 'A descriptive label for your monitor' },
{ bold: 'URL', text: 'The address to check (must include protocol)' },
{ bold: 'Interval', text: 'How often to perform checks (1-60 minutes)' }
]
}
```
---
## Terminology
### Consistent Terms
Use these terms consistently throughout documentation:
| Term | Use | Don't Use |
| ------------ | ------------------------- | --------------------- |
| Monitor | The configured check | Watcher, probe, check |
| Check | A single monitoring event | Ping, request, poll |
| Incident | A detected problem | Outage, alert, issue |
| Notification | Alert sent to user | Message, email, ping |
| Dashboard | Main overview page | Home, overview |
| Sidebar | Left navigation | Menu, nav |
### Feature Names
Always match UI exactly:
- **Uptime** (not "Uptime Monitoring")
- **PageSpeed** (not "Page Speed" or "Pagespeed")
- **Infrastructure** (not "Server Monitoring")
- **Status pages** (not "Status Pages" - lowercase 'p')
---
## Article Templates
### Getting Started Article
```javascript
{
blocks: [
{
type: "heading",
level: 2,
text: "Overview",
id: "overview",
},
{
type: "paragraph",
text: "[1-2 sentences: What this feature/concept is]",
},
{
type: "paragraph",
text: "[1-2 sentences: Why it matters to users]",
},
{
type: "heading",
level: 2,
text: "[Main topic]",
id: "main-topic",
},
// ... content blocks
{
type: "article-links",
title: "Next steps",
items: [
// 2-3 logical next articles
],
},
];
}
```
### How-To Article
```javascript
{
blocks: [
{
type: "heading",
level: 2,
text: "Overview",
id: "overview",
},
{
type: "paragraph",
text: "[Brief description of what user will accomplish]",
},
{
type: "heading",
level: 2,
text: "[Action: Creating/Configuring/Setting up...]",
id: "action",
},
{
type: "ordered-list",
items: [
// 4-6 steps
],
},
{
type: "callout",
variant: "tip",
text: "[Best practice or helpful hint]",
},
{
type: "article-links",
title: "Related articles",
items: [],
},
];
}
```
### Reference Article
```javascript
{
blocks: [
{
type: 'heading',
level: 2,
text: 'Overview',
id: 'overview'
},
{
type: 'paragraph',
text: '[What this reference covers]'
},
{
type: 'heading',
level: 2,
text: '[Category 1]',
id: 'category-1'
},
{
type: 'table',
columns: [...],
rows: [...]
},
// More categories as needed
{
type: 'article-links',
title: 'Related articles',
items: []
}
]
}
```
---
## Quality Checklist
Before publishing any article, verify:
### Content
- [ ] Overview explains what AND why
- [ ] Procedures are tested and accurate
- [ ] All UI element names match exactly
- [ ] No assumptions about user knowledge
- [ ] Related articles are linked
### Formatting
- [ ] Only H2 and H3 headings used
- [ ] All headings have unique IDs
- [ ] Lists use correct type (bullet vs ordered)
- [ ] Callouts are used sparingly (max 3)
- [ ] Bold used for UI elements
### Style
- [ ] Active voice throughout
- [ ] Second person ("you")
- [ ] Action verbs start procedure steps
- [ ] Consistent terminology
- [ ] No marketing language
### Technical
- [ ] All links work
- [ ] Images have alt text
- [ ] Code examples are correct
- [ ] No placeholder content
---
## Available Icons
For icon-cards and other icon references, use these Lucide icon names:
**Navigation:**
- `Globe` - Uptime/HTTP
- `Gauge` - PageSpeed
- `Server` - Infrastructure
- `Bell` - Notifications
- `AlertTriangle` - Incidents
- `Wifi` - Status pages
- `Wrench` - Maintenance
- `Settings` - Settings
- `Users` - Team
**Actions:**
- `CheckCircle` - Success/complete
- `Clock` - Time/scheduling
- `Info` - Information
- `Lightbulb` - Tips
- `ArrowRight` - Navigation
**Features:**
- `Database` - Data/storage
- `Terminal` - Commands
- `FileText` - Documentation
- `Shield` - Security
- `Rocket` - Getting started
---
## File Structure
Articles are stored in `/content/index.js`:
```javascript
export const articleContents = {
"collection-id": {
"article-id": {
blocks: [
// Content blocks
],
},
},
};
```
Article metadata is in `/content/userGuideConfig.js`:
```javascript
{
id: 'article-id',
title: 'Article Title',
description: 'Brief description for search and previews',
keywords: ['search', 'terms', 'for', 'this', 'article']
}
```
---
## Examples
### Good Paragraph
> "HTTP monitors check your websites and APIs at regular intervals. When a check fails—due to a timeout, error response, or connection issue—Checkmate creates an incident and sends notifications to your configured channels."
### Good Procedure
> 1. Navigate to **Uptime** in the sidebar
> 2. Click **Create monitor** in the top right
> 3. Enter the URL you want to monitor (include `https://`)
> 4. Set your preferred check interval
> 5. Click **Save** to create the monitor
### Good Callout
> **Tip:** Use descriptive monitor names like "Production API" or "Marketing Website" to quickly identify issues in notifications.
### Good Feature List
> - **Name** — A descriptive label for easy identification
> - **URL** — The address to monitor (must include protocol)
> - **Interval** — How often to check (1-60 minutes)
> - **Timeout** — How long to wait before marking as failed
---
_Last updated: January 2025_
@@ -1,12 +0,0 @@
/* SearchResults styles (Dark theme) */
.user-guide-search-result-item {
transition:
background-color 150ms ease,
border-color 150ms ease;
}
.user-guide-search-result-item:hover {
background-color: rgba(255, 255, 255, 0.05) !important;
border-color: #4a5568 !important;
}
@@ -1,173 +0,0 @@
import { FileText, ArrowRight } from "lucide-react";
import { searchArticles } from "./content/userGuideConfig";
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
import "./SearchResults.css";
const SearchResults = ({ query, onNavigate, onClearSearch }) => {
const { colors, typography, spacing, border } = useUserGuideTheme();
const results = searchArticles(query);
const handleResultClick = (result) => {
onNavigate(result.collectionId, result.articleId);
onClearSearch();
};
if (query.trim().length < 2) {
return (
<div style={{ padding: spacing.lg }}>
<p
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
color: colors.text.muted,
textAlign: "center",
margin: 0,
}}
>
Type at least 2 characters to search...
</p>
</div>
);
}
if (results.length === 0) {
return (
<div style={{ padding: spacing.xl }}>
<p
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
color: colors.text.primary,
textAlign: "center",
marginBottom: spacing.sm,
marginTop: 0,
}}
>
No results found for "{query}"
</p>
<p
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
color: colors.text.muted,
textAlign: "center",
margin: 0,
}}
>
Try different keywords or browse by topic
</p>
</div>
);
}
return (
<div style={{ padding: spacing.lg }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.md,
}}
>
<span
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
color: colors.text.muted,
}}
>
{results.length} result{results.length !== 1 ? "s" : ""} for "{query}"
</span>
<button
onClick={onClearSearch}
style={{
background: "none",
border: "none",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
color: colors.brand.primary,
cursor: "pointer",
padding: 0,
}}
>
Clear search
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: spacing.sm }}>
{results.map((result) => (
<div
key={`${result.collectionId}-${result.articleId}`}
onClick={() => handleResultClick(result)}
className="user-guide-search-result-item"
style={{
display: "flex",
alignItems: "flex-start",
gap: spacing.md,
padding: spacing.md,
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
cursor: "pointer",
}}
>
<FileText
size={16}
strokeWidth={1.5}
color={colors.text.muted}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<span
style={{
display: "block",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.medium,
color: colors.text.primary,
marginBottom: 2,
}}
>
{result.articleTitle}
</span>
<span
style={{
display: "block",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{result.articleDescription}
</span>
<span
style={{
display: "block",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.xs,
color: colors.text.muted,
marginTop: spacing.xs,
}}
>
in {result.collectionTitle}
</span>
</div>
<ArrowRight
size={16}
strokeWidth={1.5}
color={colors.text.muted}
style={{ flexShrink: 0, marginTop: 2 }}
/>
</div>
))}
</div>
</div>
);
};
export default SearchResults;
@@ -1,40 +0,0 @@
/* SidebarHeader styles - Dark theme */
.user-guide-header-icon-button {
transition: all 150ms ease;
}
.user-guide-header-icon-button:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
color: #1570ef !important;
}
.user-guide-header-icon-button.active {
background-color: #0d0d0d;
color: #1570ef;
}
.user-guide-breadcrumb-dropdown-trigger {
transition: background-color 150ms ease;
}
.user-guide-breadcrumb-dropdown-trigger:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.user-guide-breadcrumb-item {
transition: background-color 150ms ease;
}
.user-guide-breadcrumb-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.user-guide-search-input:focus {
outline: none;
border-color: #1570ef;
}
.user-guide-search-input::placeholder {
color: #667085;
}
@@ -1,399 +0,0 @@
import { useState, useRef, useEffect } from "react";
import {
Home,
ChevronDown,
ChevronLeft,
ChevronRight,
Search,
ExternalLink,
X,
} from "lucide-react";
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
import "./SidebarHeader.css";
const SidebarHeader = ({
breadcrumbs,
onHomeClick,
onBack,
onForward,
canGoBack,
canGoForward,
onClose,
onOpenInNewTab,
isSearchOpen,
onSearchToggle,
searchQuery,
onSearchChange,
showOpenInNewTab = true,
showNavigation = true,
}) => {
const { colors, typography, spacing, border, isDark } = useUserGuideTheme();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef(null);
const searchInputRef = useRef(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Focus search input when opened
useEffect(() => {
if (isSearchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isSearchOpen]);
// Get current breadcrumb (last item)
const currentBreadcrumb = breadcrumbs[breadcrumbs.length - 1];
return (
<div
style={{
backgroundColor: colors.background.white,
borderBottom: border.default,
padding: `${spacing.sm} ${spacing.md}`,
}}
>
{/* Main header row */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
}}
>
{/* Left side: Navigation + Breadcrumb dropdown */}
<div
style={{
display: "flex",
alignItems: "center",
gap: spacing.xs,
flex: 1,
minWidth: 0,
}}
>
{/* Navigation buttons - only shown when showNavigation is true */}
{showNavigation && (
<>
{/* Back button */}
<button
onClick={onBack}
disabled={!canGoBack}
className="user-guide-header-icon-button"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
backgroundColor: "transparent",
border: "none",
borderRadius: border.radius,
cursor: canGoBack ? "pointer" : "default",
color: canGoBack ? colors.text.secondary : colors.border.default,
flexShrink: 0,
opacity: canGoBack ? 1 : 0.5,
}}
title="Back"
>
<ChevronLeft
size={16}
strokeWidth={1.5}
/>
</button>
{/* Forward button */}
<button
onClick={onForward}
disabled={!canGoForward}
className="user-guide-header-icon-button"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
backgroundColor: "transparent",
border: "none",
borderRadius: border.radius,
cursor: canGoForward ? "pointer" : "default",
color: canGoForward ? colors.text.secondary : colors.border.default,
flexShrink: 0,
opacity: canGoForward ? 1 : 0.5,
}}
title="Forward"
>
<ChevronRight
size={16}
strokeWidth={1.5}
/>
</button>
{/* Home button */}
<button
onClick={breadcrumbs.length > 1 ? onHomeClick : undefined}
disabled={breadcrumbs.length <= 1}
className="user-guide-header-icon-button"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
backgroundColor: "transparent",
border: "none",
borderRadius: border.radius,
cursor: breadcrumbs.length > 1 ? "pointer" : "default",
color:
breadcrumbs.length > 1
? colors.text.secondary
: colors.border.default,
flexShrink: 0,
opacity: breadcrumbs.length > 1 ? 1 : 0.5,
}}
title="Home"
>
<Home
size={16}
strokeWidth={1.5}
/>
</button>
</>
)}
{/* Breadcrumb dropdown */}
<div
ref={dropdownRef}
style={{ position: "relative", flex: 1, minWidth: 0 }}
>
<button
onClick={() => breadcrumbs.length > 1 && setIsDropdownOpen(!isDropdownOpen)}
className="user-guide-breadcrumb-dropdown-trigger"
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "4px 8px",
backgroundColor: "transparent",
border: "none",
borderRadius: border.radius,
cursor: breadcrumbs.length > 1 ? "pointer" : "default",
maxWidth: "100%",
}}
>
<span
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.medium,
color: colors.text.primary,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{currentBreadcrumb?.label || "User guide"}
</span>
{breadcrumbs.length > 1 && (
<ChevronDown
size={14}
strokeWidth={1.5}
color={colors.text.muted}
style={{
flexShrink: 0,
transform: isDropdownOpen ? "rotate(180deg)" : "rotate(0)",
transition: "transform 150ms ease",
}}
/>
)}
</button>
{/* Dropdown menu */}
{isDropdownOpen && breadcrumbs.length > 1 && (
<div
className="user-guide-breadcrumb-dropdown"
style={{
position: "absolute",
top: "100%",
left: 0,
marginTop: 4,
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
boxShadow: isDark
? "0 4px 12px rgba(0, 0, 0, 0.3)"
: "0 4px 12px rgba(0, 0, 0, 0.1)",
minWidth: 180,
maxWidth: 280,
zIndex: 100,
overflow: "hidden",
}}
>
{breadcrumbs.slice(0, -1).map((item, index) => (
<button
key={index}
onClick={() => {
item.onClick();
setIsDropdownOpen(false);
}}
className="user-guide-breadcrumb-item"
style={{
display: "block",
width: "100%",
padding: "10px 14px",
backgroundColor: "transparent",
border: "none",
textAlign: "left",
cursor: "pointer",
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
color: colors.text.primary,
}}
>
{item.label}
</button>
))}
</div>
)}
</div>
</div>
{/* Right side: Actions */}
<div
style={{
display: "flex",
alignItems: "center",
gap: spacing.xs,
flexShrink: 0,
}}
>
{/* Open in new tab - only shown when showOpenInNewTab is true */}
{showOpenInNewTab && (
<button
onClick={onOpenInNewTab}
className="user-guide-header-icon-button"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
backgroundColor: "transparent",
border: "none",
borderRadius: border.radius,
cursor: "pointer",
color: colors.text.secondary,
}}
title="Open in new tab"
>
<ExternalLink
size={16}
strokeWidth={1.5}
/>
</button>
)}
{/* Search toggle */}
<button
onClick={onSearchToggle}
className={`user-guide-header-icon-button ${isSearchOpen ? "active" : ""}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
backgroundColor: isSearchOpen ? colors.background.alt : "transparent",
border: "none",
borderRadius: border.radius,
cursor: "pointer",
color: isSearchOpen ? colors.brand.primary : colors.text.secondary,
}}
title="Search"
>
<Search
size={16}
strokeWidth={1.5}
/>
</button>
{/* Close button */}
<button
onClick={onClose}
className="user-guide-header-icon-button"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
backgroundColor: "transparent",
border: "none",
borderRadius: border.radius,
cursor: "pointer",
color: colors.text.secondary,
}}
title="Close"
>
<X
size={16}
strokeWidth={1.5}
/>
</button>
</div>
</div>
{/* Search input (collapsible) */}
{isSearchOpen && (
<div style={{ marginTop: spacing.sm }}>
<div style={{ position: "relative" }}>
<Search
size={14}
strokeWidth={1.5}
color={colors.text.muted}
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "none",
}}
/>
<input
ref={searchInputRef}
type="text"
placeholder="Search articles..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="user-guide-search-input"
style={{
width: "100%",
height: 32,
fontSize: typography.fontSize.sm,
fontFamily: typography.fontFamily.sans,
backgroundColor: colors.background.alt,
borderRadius: border.radius,
border: border.default,
paddingLeft: 32,
paddingRight: 12,
boxSizing: "border-box",
color: colors.text.primary,
}}
/>
</div>
</div>
)}
</div>
);
};
export default SidebarHeader;
@@ -1,11 +0,0 @@
/* SidebarWrapper styles (Dark theme) */
.user-guide-sidebar-container {
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3);
}
/* Push main content when sidebar is open */
body.user-guide-sidebar-open .home-content-wrapper {
margin-right: var(--help-sidebar-padding, 440px);
transition: margin-right 300ms ease-in-out;
}
@@ -1,485 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
import TabBar from "./TabBar";
import SidebarHeader from "./SidebarHeader";
import UserGuideLanding from "./UserGuideLanding";
import CollectionPage from "./CollectionPage";
import ArticlePage from "./ArticlePage";
import ContentRenderer from "./ContentRenderer";
import HelpSection from "./HelpSection";
import SearchResults from "./SearchResults";
import { getCollection, getArticle } from "./content/userGuideConfig";
import { getArticleContent } from "./content";
import { extractToc } from "./content/contentTypes";
import {
useUserGuideSidebarContext,
DEFAULT_CONTENT_WIDTH,
} from "./UserGuideSidebarContext";
import "./SidebarWrapper.css";
const STORAGE_KEY = "checkmate-sidebar-state";
const MIN_CONTENT_WIDTH = DEFAULT_CONTENT_WIDTH;
const MAX_CONTENT_WIDTH = DEFAULT_CONTENT_WIDTH * 2; // 100% wider
const SidebarWrapper = ({ isOpen, onClose, onOpen, initialPath, onOpenInNewTab }) => {
const { setContentWidth: setContextContentWidth } = useUserGuideSidebarContext();
const { colors, border } = useUserGuideTheme();
const [activeTab, setActiveTab] = useState("user-guide");
const [collectionId, setCollectionId] = useState(undefined);
const [articleId, setArticleId] = useState(undefined);
const [searchQuery, setSearchQuery] = useState("");
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [contentWidth, setContentWidthLocal] = useState(DEFAULT_CONTENT_WIDTH);
const [isResizing, setIsResizing] = useState(false);
const [isHoveringHandle, setIsHoveringHandle] = useState(false);
const [mouseY, setMouseY] = useState(0);
const resizeRef = useRef(null);
const handleRef = useRef(null);
// Sync content width with context whenever it changes
const setContentWidth = useCallback(
(width) => {
setContentWidthLocal(width);
setContextContentWidth(width);
},
[setContextContentWidth]
);
// Initialize context with current width on mount
useEffect(() => {
setContextContentWidth(DEFAULT_CONTENT_WIDTH);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only run once on mount
// Navigation history
const [history, setHistory] = useState([
{ collectionId: undefined, articleId: undefined },
]);
const [historyIndex, setHistoryIndex] = useState(0);
const isNavigatingRef = useRef(false);
// Parse initial path on mount
useEffect(() => {
if (initialPath) {
const parts = initialPath.split("/");
if (parts.length >= 1 && parts[0]) {
setCollectionId(parts[0]);
if (parts.length >= 2 && parts[1]) {
setArticleId(parts[1]);
}
}
}
}, [initialPath]);
// Persist sidebar state to localStorage
useEffect(() => {
if (isOpen) {
const state = { activeTab, collectionId, articleId };
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
}, [isOpen, activeTab, collectionId, articleId]);
// Restore state from localStorage on mount
useEffect(() => {
const savedState = localStorage.getItem(STORAGE_KEY);
if (savedState && !initialPath) {
try {
const {
activeTab: savedTab,
collectionId: savedCollection,
articleId: savedArticle,
} = JSON.parse(savedState);
if (savedTab) setActiveTab(savedTab);
if (savedCollection) setCollectionId(savedCollection);
if (savedArticle) setArticleId(savedArticle);
} catch (e) {
// Ignore parse errors
}
}
}, [initialPath]);
// Track navigation changes in history
useEffect(() => {
if (isNavigatingRef.current) {
isNavigatingRef.current = false;
return;
}
// Only track if this is a new navigation (not from back/forward)
const currentEntry = history[historyIndex];
if (
currentEntry?.collectionId !== collectionId ||
currentEntry?.articleId !== articleId
) {
// Truncate forward history and add new entry
const newHistory = [
...history.slice(0, historyIndex + 1),
{ collectionId, articleId },
];
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collectionId, articleId]);
// Navigation handlers
const handleNavigate = useCallback((newCollectionId, newArticleId) => {
setCollectionId(newCollectionId);
setArticleId(newArticleId);
}, []);
const handleBackToHome = useCallback(() => {
setCollectionId(undefined);
setArticleId(undefined);
}, []);
// Back/Forward navigation
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < history.length - 1;
const handleBack = useCallback(() => {
if (historyIndex > 0) {
isNavigatingRef.current = true;
const prevIndex = historyIndex - 1;
const prevEntry = history[prevIndex];
setHistoryIndex(prevIndex);
setCollectionId(prevEntry.collectionId);
setArticleId(prevEntry.articleId);
}
}, [historyIndex, history]);
const handleForward = useCallback(() => {
if (historyIndex < history.length - 1) {
isNavigatingRef.current = true;
const nextIndex = historyIndex + 1;
const nextEntry = history[nextIndex];
setHistoryIndex(nextIndex);
setCollectionId(nextEntry.collectionId);
setArticleId(nextEntry.articleId);
}
}, [historyIndex, history]);
const handleBackToCollection = useCallback(() => {
setArticleId(undefined);
}, []);
const handleHomeClick = useCallback(() => {
setCollectionId(undefined);
setArticleId(undefined);
}, []);
// Get current collection and article
const collection = collectionId ? getCollection(collectionId) : undefined;
const article =
collectionId && articleId ? getArticle(collectionId, articleId) : undefined;
// Get prev/next articles for navigation
const getAdjacentArticles = () => {
if (!collection || !articleId) return { prev: undefined, next: undefined };
const currentIndex = collection.articles.findIndex((a) => a.id === articleId);
return {
prev: currentIndex > 0 ? collection.articles[currentIndex - 1] : undefined,
next:
currentIndex < collection.articles.length - 1
? collection.articles[currentIndex + 1]
: undefined,
};
};
const { prev: prevArticle, next: nextArticle } = getAdjacentArticles();
// Get article content
const articleContent =
collectionId && articleId ? getArticleContent(collectionId, articleId) : undefined;
// Auto-generate TOC from content headings
const tocItems = articleContent ? extractToc(articleContent.blocks) : undefined;
// Build breadcrumb items for dropdown
const buildBreadcrumbs = () => {
let items = [];
switch (activeTab) {
case "user-guide":
items = [{ label: "User guide", onClick: handleHomeClick }];
if (collection) {
items.push({ label: collection.title, onClick: () => setArticleId(undefined) });
}
if (article) {
items.push({ label: article.title, onClick: () => {} });
}
return items;
default:
return [{ label: "Help", onClick: () => {} }];
}
};
// Handle "Open in new tab"
const handleOpenInNewTab = () => {
let path = "https://bluewavelabs.gitbook.io/checkmate";
if (onOpenInNewTab) {
onOpenInNewTab();
} else {
window.open(path, "_blank", "noopener,noreferrer");
}
};
// Clear search and close search bar
const handleClearSearch = useCallback(() => {
setSearchQuery("");
setIsSearchOpen(false);
}, []);
// Render User Guide content
const renderUserGuideContent = () => {
// Show search results when search is active
if (isSearchOpen && searchQuery.trim().length > 0) {
return (
<SearchResults
query={searchQuery}
onNavigate={(colId, artId) => {
handleNavigate(colId, artId);
handleClearSearch();
}}
onClearSearch={handleClearSearch}
/>
);
}
if (collection && article) {
return (
<ArticlePage
collection={collection}
article={article}
onBack={handleBackToCollection}
onBackToHome={handleBackToHome}
prevArticle={prevArticle}
nextArticle={nextArticle}
onPrevArticle={
prevArticle ? () => handleNavigate(collectionId, prevArticle.id) : undefined
}
onNextArticle={
nextArticle ? () => handleNavigate(collectionId, nextArticle.id) : undefined
}
tocItems={tocItems}
mode="in-app"
>
{articleContent && (
<ContentRenderer
content={articleContent}
onNavigate={handleNavigate}
/>
)}
</ArticlePage>
);
}
if (collection) {
return (
<CollectionPage
collection={collection}
onArticleClick={(id) => handleNavigate(collectionId, id)}
/>
);
}
return (
<UserGuideLanding
onNavigate={handleNavigate}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
mode="in-app"
/>
);
};
const contentArea = (tabValue) => {
switch (tabValue) {
case "user-guide":
return renderUserGuideContent();
default:
return <HelpSection />;
}
};
// Handle tab click - open sidebar if closed, close if clicking active tab, or switch tab
const handleTabClick = (tab) => {
if (!isOpen) {
onOpen();
setActiveTab(tab);
} else if (activeTab === tab) {
// Clicking the already-active tab closes the sidebar
onClose();
} else {
// Clicking a different tab switches to it
setActiveTab(tab);
}
};
// Resize handlers
const handleResizeStart = useCallback(
(e) => {
e.preventDefault();
setIsResizing(true);
resizeRef.current = { startX: e.clientX, startWidth: contentWidth };
},
[contentWidth]
);
const handleResizeMove = useCallback(
(e) => {
if (!isResizing || !resizeRef.current) return;
const deltaX = resizeRef.current.startX - e.clientX;
const newWidth = Math.min(
MAX_CONTENT_WIDTH,
Math.max(MIN_CONTENT_WIDTH, resizeRef.current.startWidth + deltaX)
);
setContentWidth(newWidth);
},
[isResizing, setContentWidth]
);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
setIsHoveringHandle(false);
resizeRef.current = null;
}, []);
// Add/remove resize event listeners
useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing, handleResizeMove, handleResizeEnd]);
return (
<div
className="user-guide-sidebar-container"
style={{
position: "fixed",
top: 0,
right: 0,
height: "100vh",
display: "flex",
zIndex: 1100,
}}
>
{/* Resize Handle - positioned absolutely on the left edge */}
<div
ref={handleRef}
onMouseDown={handleResizeStart}
onMouseEnter={() => setIsHoveringHandle(true)}
onMouseLeave={() => !isResizing && setIsHoveringHandle(false)}
onMouseMove={(e) => {
if (handleRef.current) {
const rect = handleRef.current.getBoundingClientRect();
setMouseY(e.clientY - rect.top);
}
}}
style={{
position: "absolute",
left: 0,
top: 0,
width: 8,
height: "100%",
cursor: "ew-resize",
zIndex: 20,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
}}
>
{/* Visual indicator line */}
<div
style={{
width: isResizing ? 2 : 1,
height: "100%",
backgroundColor:
isResizing || isHoveringHandle ? "#4CAF93" : colors.border.default,
transition: isResizing ? "none" : "all 150ms ease",
position: "relative",
}}
>
{/* Grab indicator follows mouse */}
{(isHoveringHandle || isResizing) && (
<div
style={{
position: "absolute",
left: -1,
top: mouseY - 10,
width: 3,
height: 20,
backgroundColor: "#555",
borderRadius: 1,
}}
/>
)}
</div>
</div>
{/* Tab Bar - Always visible */}
<TabBar
activeTab={isOpen ? activeTab : undefined}
onTabChange={handleTabClick}
/>
{/* Main Sidebar Content - Slides in/out */}
<div
style={{
width: isOpen ? contentWidth : 0,
height: "100%",
backgroundColor: colors.background.white,
borderLeft: isOpen ? border.default : "none",
display: "flex",
flexDirection: "column",
overflow: "hidden",
transition: isResizing ? "none" : "width 300ms ease-in-out",
position: "relative",
}}
>
{isOpen && (
<>
{/* Header */}
<SidebarHeader
showOpenInNewTab={activeTab === "user-guide"}
showNavigation={activeTab === "user-guide"}
breadcrumbs={buildBreadcrumbs()}
onHomeClick={handleHomeClick}
onBack={handleBack}
onForward={handleForward}
canGoBack={canGoBack}
canGoForward={canGoForward}
onClose={onClose}
onOpenInNewTab={handleOpenInNewTab}
isSearchOpen={isSearchOpen}
onSearchToggle={() => setIsSearchOpen(!isSearchOpen)}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
{/* Content Area */}
<div
style={{
flex: 1,
overflowY: "auto",
backgroundColor: colors.background.alt,
}}
>
{contentArea(activeTab)}
</div>
</>
)}
</div>
</div>
);
};
export default SidebarWrapper;
@@ -1,13 +0,0 @@
/* TabBar styles */
.user-guide-tab-item {
transition: all 150ms ease;
}
.user-guide-tab-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.user-guide-tab-item.active:hover {
background-color: #1a1a1a;
}
@@ -1,96 +0,0 @@
import { BookOpen, HelpCircle } from "lucide-react";
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
import "./TabBar.css";
const TabItem = ({ label, icon, isActive, onClick, colors, typography }) => {
return (
<div
onClick={onClick}
className={`user-guide-tab-item ${isActive ? "active" : ""}`}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "16px 8px",
cursor: "pointer",
backgroundColor: isActive ? colors.background.white : "transparent",
borderLeft: isActive
? `2px solid ${colors.brand.primary}`
: "2px solid transparent",
borderBottom: `1px solid ${colors.border.default}`,
}}
>
<div
style={{
color: isActive ? colors.brand.primary : colors.text.muted,
marginBottom: 8,
}}
>
{icon}
</div>
<span
style={{
writingMode: "vertical-rl",
textOrientation: "mixed",
fontFamily: typography.fontFamily.sans,
fontSize: 12,
fontWeight: typography.fontWeight.normal,
color: isActive ? colors.brand.primary : colors.text.muted,
letterSpacing: "0.5px",
}}
>
{label}
</span>
</div>
);
};
const TabBar = ({ activeTab, onTabChange }) => {
const { colors, typography } = useUserGuideTheme();
return (
<div
style={{
width: 40,
height: "100%",
backgroundColor: colors.background.sidebar,
borderLeft: `1px solid ${colors.border.default}`,
display: "flex",
flexDirection: "column",
alignItems: "stretch",
}}
>
<TabItem
id="user-guide"
label="User guide"
icon={
<BookOpen
size={18}
strokeWidth={1.5}
/>
}
isActive={activeTab === "user-guide"}
onClick={() => onTabChange("user-guide")}
colors={colors}
typography={typography}
/>
<TabItem
id="help"
label="Help"
icon={
<HelpCircle
size={18}
strokeWidth={1.5}
/>
}
isActive={activeTab === "help"}
onClick={() => onTabChange("help")}
colors={colors}
typography={typography}
/>
</div>
);
};
export default TabBar;
@@ -1,39 +0,0 @@
/* UserGuideLanding styles - hover states */
/* Collection card hover */
.user-guide-collection-card {
transition: all 150ms ease;
}
.user-guide-collection-card:hover {
border-color: rgba(21, 112, 239, 0.3) !important;
background-color: rgba(21, 112, 239, 0.02) !important;
box-shadow: 0px 2px 8px -2px rgba(0, 0, 0, 0.08);
}
[data-mui-color-scheme="dark"] .user-guide-collection-card:hover {
border-color: rgba(21, 112, 239, 0.5) !important;
background-color: rgba(21, 112, 239, 0.05) !important;
box-shadow: 0px 4px 16px -4px rgba(0, 0, 0, 0.4);
}
/* Fast find item hover */
.user-guide-fast-find-item {
transition: all 150ms ease;
}
.user-guide-fast-find-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
[data-mui-color-scheme="dark"] .user-guide-fast-find-item:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.user-guide-fast-find-item .arrow-icon {
transition: all 150ms ease;
}
.user-guide-fast-find-item:hover .arrow-icon {
color: #1570ef;
}
@@ -1,214 +0,0 @@
import {
ArrowRight,
Rocket,
Globe,
Gauge,
Server,
Bell,
AlertTriangle,
Settings,
Wifi,
Wrench,
Users,
} from "lucide-react";
import { collections, fastFinds, getCollection } from "./content/userGuideConfig";
import { useUserGuideTheme } from "./styles/useUserGuideTheme";
import "./UserGuideLanding.css";
// Map icon names to actual Lucide components
const iconMap = {
Rocket,
Globe,
Gauge,
Server,
Bell,
AlertTriangle,
Settings,
Wifi,
Wrench,
Users,
};
const UserGuideLanding = ({
onNavigate,
searchQuery,
onSearchChange,
mode = "in-app",
}) => {
const { colors, typography, spacing, border } = useUserGuideTheme();
const isInApp = mode === "in-app";
return (
<div style={{ minHeight: "100%", backgroundColor: colors.background.alt }}>
{/* Main Content */}
<div
style={{
maxWidth: isInApp ? "100%" : 1200,
margin: "0 auto",
padding: isInApp ? spacing.lg : `${spacing["3xl"]} ${spacing["3xl"]}`,
}}
>
{/* Collections Grid */}
<h2
style={{
fontFamily: typography.fontFamily.sans,
fontSize: isInApp ? typography.fontSize.lg : typography.fontSize.xl,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: isInApp ? spacing.md : spacing.xl,
marginTop: 0,
}}
>
Browse by topic
</h2>
<div
style={{
display: "grid",
gridTemplateColumns: isInApp
? "1fr"
: "repeat(auto-fill, minmax(340px, 1fr))",
gap: isInApp ? spacing.md : spacing.lg,
marginBottom: isInApp ? spacing.xl : spacing["4xl"],
}}
>
{collections.map((collection) => {
const IconComponent = iconMap[collection.icon] || Rocket;
return (
<div
key={collection.id}
onClick={() => onNavigate(collection.id)}
className="user-guide-collection-card"
style={{
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
padding: spacing.lg,
cursor: "pointer",
}}
>
<div
style={{ display: "flex", alignItems: "flex-start", gap: spacing.md }}
>
<IconComponent
size={20}
strokeWidth={1.5}
color={colors.brand.primary}
/>
<div style={{ flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: spacing.xs,
}}
>
<span
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
}}
>
{collection.title}
</span>
<span
style={{
fontSize: typography.fontSize.xs,
color: colors.text.secondary,
backgroundColor: colors.background.alt,
padding: "2px 6px",
borderRadius: border.radius,
}}
>
{collection.articleCount} articles
</span>
</div>
<span
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
display: "block",
}}
>
{collection.description}
</span>
</div>
</div>
</div>
);
})}
</div>
{/* About our docs - Only shown in in-app mode */}
{isInApp && (
<div
style={{
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
padding: spacing.lg,
}}
>
<h3
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.md,
marginTop: 0,
}}
>
About our docs
</h3>
<p
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
margin: 0,
marginBottom: spacing.md,
}}
>
There are a few ways to explore our docs:
</p>
<div style={{ marginBottom: spacing.md }}>
<h4
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
marginBottom: spacing.xs,
marginTop: 0,
}}
>
In the product
</h4>
<p
style={{
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.sm,
color: colors.text.secondary,
lineHeight: typography.lineHeight.normal,
margin: 0,
}}
>
Look for help icons and tooltips throughout the interface.
</p>
</div>
</div>
)}
</div>
</div>
);
};
export default UserGuideLanding;
@@ -1,102 +0,0 @@
import { createContext, useContext, useState, useCallback, useEffect } from "react";
const SIDEBAR_STATE_KEY = "checkmate-sidebar-open";
// Constants for sidebar dimensions
export const TAB_BAR_WIDTH = 41; // 40px width + 1px border
export const DEFAULT_CONTENT_WIDTH = 400;
// Gap when sidebar is closed (need more space since tab bar is narrow)
export const CLOSED_GAP = 24;
// Gap when sidebar is open (less needed since content edge is clear)
export const OPEN_GAP = 8;
const UserGuideSidebarContext = createContext(null);
export const UserGuideSidebarProvider = ({ children }) => {
const [isOpen, setIsOpen] = useState(() => {
if (typeof window === "undefined") return false;
const saved = localStorage.getItem(SIDEBAR_STATE_KEY);
return saved === "true";
});
const [currentPath, setCurrentPath] = useState(undefined);
const [contentWidth, setContentWidth] = useState(DEFAULT_CONTENT_WIDTH);
// Calculate total sidebar width and required padding
const totalSidebarWidth = isOpen ? TAB_BAR_WIDTH + contentWidth : TAB_BAR_WIDTH;
const gap = isOpen ? OPEN_GAP : CLOSED_GAP;
const requiredPaddingRight = totalSidebarWidth + gap;
// Set CSS custom properties on document root for global access
useEffect(() => {
if (typeof document === "undefined") return;
const root = document.documentElement;
root.style.setProperty("--help-sidebar-width", `${totalSidebarWidth}px`);
root.style.setProperty("--help-sidebar-padding", `${requiredPaddingRight}px`);
}, [totalSidebarWidth, requiredPaddingRight]);
// Update body class for content push effect
useEffect(() => {
if (typeof document === "undefined") return;
if (isOpen) {
document.body.classList.add("user-guide-sidebar-open");
} else {
document.body.classList.remove("user-guide-sidebar-open");
}
return () => {
document.body.classList.remove("user-guide-sidebar-open");
};
}, [isOpen]);
// Persist state to localStorage
useEffect(() => {
if (typeof window === "undefined") return;
localStorage.setItem(SIDEBAR_STATE_KEY, String(isOpen));
}, [isOpen]);
const open = useCallback((path) => {
setCurrentPath(path);
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
}, []);
const toggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
return (
<UserGuideSidebarContext.Provider
value={{
isOpen,
open,
close,
toggle,
currentPath,
contentWidth,
setContentWidth,
totalSidebarWidth,
requiredPaddingRight,
}}
>
{children}
</UserGuideSidebarContext.Provider>
);
};
export const useUserGuideSidebarContext = () => {
const context = useContext(UserGuideSidebarContext);
if (!context) {
throw new Error(
"useUserGuideSidebarContext must be used within a UserGuideSidebarProvider"
);
}
return context;
};
export default UserGuideSidebarContext;
@@ -1,176 +0,0 @@
// Content block types for the templatic article system
/**
* @typedef {'heading' | 'paragraph' | 'callout' | 'bullet-list' | 'ordered-list' | 'checklist' | 'icon-cards' | 'grid-cards' | 'time-estimate' | 'info-box' | 'code' | 'requirements' | 'table' | 'image' | 'article-links'} ContentBlockType
*/
/**
* @typedef {Object} HeadingBlock
* @property {'heading'} type
* @property {string} id - Used for TOC anchors
* @property {2 | 3} level - h2 or h3
* @property {string} text
*/
/**
* @typedef {Object} ParagraphBlock
* @property {'paragraph'} type
* @property {string} text - Supports basic markdown: **bold**, *italic*, `code`
*/
/**
* @typedef {Object} CalloutBlock
* @property {'callout'} type
* @property {'info' | 'tip' | 'warning' | 'success'} [variant]
* @property {string} [title]
* @property {string} text
*/
/**
* @typedef {Object} ListItem
* @property {string} text
* @property {string} [bold] - Optional bold prefix
*/
/**
* @typedef {Object} BulletListBlock
* @property {'bullet-list'} type
* @property {ListItem[]} items
*/
/**
* @typedef {Object} OrderedListBlock
* @property {'ordered-list'} type
* @property {ListItem[]} items
*/
/**
* @typedef {Object} ChecklistBlock
* @property {'checklist'} type
* @property {string[]} items
*/
/**
* @typedef {Object} IconCardItem
* @property {string} icon - Icon name from lucide-react
* @property {string} title
* @property {string} description
*/
/**
* @typedef {Object} IconCardsBlock
* @property {'icon-cards'} type
* @property {IconCardItem[]} items
*/
/**
* @typedef {Object} GridCardItem
* @property {string} title
* @property {string} description
* @property {string} [icon]
*/
/**
* @typedef {Object} GridCardsBlock
* @property {'grid-cards'} type
* @property {GridCardItem[]} items
* @property {number} [columns]
*/
/**
* @typedef {Object} TimeEstimateBlock
* @property {'time-estimate'} type
* @property {string} text
*/
/**
* @typedef {Object} InfoBoxBlock
* @property {'info-box'} type
* @property {string} [icon]
* @property {string} title
* @property {string[]} items
*/
/**
* @typedef {Object} CodeBlock
* @property {'code'} type
* @property {string} code
* @property {string} [language]
*/
/**
* @typedef {Object} RequirementItem
* @property {string} icon
* @property {string} title
* @property {string[]} items
*/
/**
* @typedef {Object} RequirementsBlock
* @property {'requirements'} type
* @property {RequirementItem[]} items
*/
/**
* @typedef {Object} TableColumn
* @property {string} key
* @property {string} label
* @property {string} [width]
*/
/**
* @typedef {Object} TableBlock
* @property {'table'} type
* @property {TableColumn[]} columns
* @property {Object[]} rows
*/
/**
* @typedef {Object} ImageBlock
* @property {'image'} type
* @property {string} src
* @property {string} alt
* @property {string} [caption]
*/
/**
* @typedef {Object} ArticleLink
* @property {string} collectionId
* @property {string} articleId
* @property {string} title
* @property {string} [description]
*/
/**
* @typedef {Object} ArticleLinksBlock
* @property {'article-links'} type
* @property {string} [title] - e.g., "Next steps", "Related articles"
* @property {ArticleLink[]} items
*/
/**
* @typedef {Object} ArticleContent
* @property {ContentBlock[]} blocks
*/
/**
* @typedef {Object} TocItem
* @property {string} label
* @property {string} href
* @property {number} level
*/
/**
* Helper to extract TOC from content blocks
* @param {ContentBlock[]} blocks
* @returns {TocItem[]}
*/
export const extractToc = (blocks) => {
return blocks
.filter((block) => block.type === "heading")
.map((block) => ({
label: block.text,
href: `#${block.id}`,
level: block.level,
}));
};
File diff suppressed because it is too large. Load diff
@@ -1,604 +0,0 @@
// Icon names as strings - resolved to actual components in UserGuideLanding.jsx
// Available icons: Rocket, Globe, Gauge, Server, Bell, AlertTriangle, Settings, Wifi, Wrench, Users
/**
* @typedef {Object} Article
* @property {string} id
* @property {string} title
* @property {string} description
* @property {string[]} keywords
*/
/**
* @typedef {Object} Collection
* @property {string} id
* @property {string} title
* @property {string} description
* @property {string} icon
* @property {number} articleCount
* @property {Article[]} articles
*/
/**
* @typedef {Object} FastFind
* @property {string} id
* @property {string} title
* @property {string} collectionId
* @property {string} articleId
*/
// Collections configuration for Checkmate
export const collections = [
{
id: "getting-started",
title: "Getting started",
description: "Essential first steps for new users.",
icon: "Rocket",
articleCount: 4,
articles: [
{
id: "what-is-checkmate",
title: "What is Checkmate",
description: "Overview of monitoring capabilities and core features.",
keywords: ["introduction", "overview", "platform", "monitoring", "uptime"],
},
{
id: "quick-start",
title: "Quick start guide",
description: "Create your first monitor in 5 minutes.",
keywords: ["quick", "start", "first", "monitor", "setup", "tutorial"],
},
{
id: "dashboard",
title: "Understanding the dashboard",
description: "Navigation and key metrics explained.",
keywords: ["dashboard", "navigation", "interface", "home", "metrics"],
},
{
id: "roles-permissions",
title: "User roles and permissions",
description: "User, Admin, and Superadmin roles explained.",
keywords: ["role", "permission", "admin", "superadmin", "user", "access"],
},
],
},
{
id: "uptime-monitoring",
title: "Uptime monitoring",
description: "Monitor website and service availability.",
icon: "Globe",
articleCount: 9,
articles: [
{
id: "http-monitors",
title: "Creating HTTP monitors",
description: "URL monitoring with response validation.",
keywords: ["http", "https", "url", "website", "monitor", "create"],
},
{
id: "ping-monitors",
title: "Ping monitors",
description: "ICMP-based availability checks.",
keywords: ["ping", "icmp", "availability", "monitor"],
},
{
id: "port-monitors",
title: "Port monitors",
description: "TCP port availability monitoring.",
keywords: ["port", "tcp", "availability", "monitor", "service"],
},
{
id: "docker-monitors",
title: "Docker monitors",
description: "Monitor Docker container status and health.",
keywords: ["docker", "container", "monitoring", "health", "status"],
},
{
id: "bulk-import",
title: "Bulk import monitors",
description: "Import multiple monitors from a CSV file.",
keywords: ["bulk", "import", "csv", "multiple", "batch", "upload"],
},
{
id: "intervals-timing",
title: "Monitor intervals and timing",
description: "Configure how often to check your services.",
keywords: ["interval", "timing", "frequency", "check", "schedule"],
},
{
id: "json-path-matching",
title: "JSON path matching",
description: "Validate API response content.",
keywords: ["json", "api", "response", "validation", "match", "path"],
},
{
id: "ssl-tls-monitoring",
title: "SSL/TLS monitoring",
description: "Certificate tracking and error handling.",
keywords: ["ssl", "tls", "certificate", "https", "security", "expiry"],
},
{
id: "uptime-metrics",
title: "Understanding uptime metrics",
description: "Response time, status codes, and uptime percentage.",
keywords: [
"metrics",
"response",
"time",
"status",
"code",
"uptime",
"percentage",
],
},
],
},
{
id: "pagespeed",
title: "PageSpeed monitoring",
description: "Track website performance and Core Web Vitals.",
icon: "Gauge",
articleCount: 5,
articles: [
{
id: "creating-pagespeed",
title: "Creating PageSpeed monitors",
description: "Setup and configuration for performance monitoring.",
keywords: ["pagespeed", "create", "setup", "performance", "lighthouse"],
},
{
id: "understanding-scores",
title: "Understanding scores",
description: "Performance, Accessibility, SEO, and Best Practices explained.",
keywords: ["score", "performance", "accessibility", "seo", "best", "practices"],
},
{
id: "core-web-vitals",
title: "Core Web Vitals explained",
description: "LCP, FCP, CLS, TBT, and Speed Index.",
keywords: ["core", "web", "vitals", "lcp", "fcp", "cls", "tbt", "speed", "index"],
},
{
id: "pagespeed-api",
title: "PageSpeed API configuration",
description: "Setting up your Google PageSpeed API key.",
keywords: ["api", "key", "google", "pagespeed", "configure", "setup"],
},
{
id: "performance-trends",
title: "Performance trends",
description: "Tracking improvements over time.",
keywords: ["trend", "history", "improvement", "tracking", "chart"],
},
],
},
{
id: "infrastructure",
title: "Infrastructure monitoring",
description: "Monitor server hardware with Capture agent.",
icon: "Server",
articleCount: 6,
articles: [
{
id: "installing-capture",
title: "Installing Capture agent",
description: "Setup the monitoring agent on your servers.",
keywords: ["install", "capture", "agent", "setup", "server"],
},
{
id: "cpu-monitoring",
title: "CPU monitoring",
description: "Usage, temperature, and core metrics.",
keywords: ["cpu", "processor", "usage", "temperature", "cores"],
},
{
id: "memory-monitoring",
title: "Memory monitoring",
description: "RAM usage and availability tracking.",
keywords: ["memory", "ram", "usage", "available", "free"],
},
{
id: "disk-monitoring",
title: "Disk monitoring",
description: "Storage space and read/write speeds.",
keywords: ["disk", "storage", "space", "read", "write", "speed"],
},
{
id: "network-monitoring",
title: "Network monitoring",
description: "Bandwidth and packet statistics.",
keywords: ["network", "bandwidth", "packet", "interface", "traffic"],
},
{
id: "hardware-thresholds",
title: "Setting hardware thresholds",
description: "Alert when resources become critical.",
keywords: ["threshold", "alert", "critical", "warning", "limit"],
},
],
},
{
id: "incidents",
title: "Incidents & alerts",
description: "Respond to downtime and issues.",
icon: "AlertTriangle",
articleCount: 4,
articles: [
{
id: "understanding-incidents",
title: "Understanding incidents",
description: "How incidents are detected and created.",
keywords: ["incident", "detection", "downtime", "outage", "alert"],
},
{
id: "incident-timeline",
title: "Incident timeline",
description: "Tracking start, duration, and resolution.",
keywords: ["timeline", "duration", "start", "end", "resolution"],
},
{
id: "resolving-incidents",
title: "Resolving incidents",
description: "Manual vs automatic resolution.",
keywords: ["resolve", "manual", "automatic", "acknowledge", "fix"],
},
{
id: "incident-history",
title: "Incident history and analysis",
description: "Learn from past outages.",
keywords: ["history", "analysis", "report", "past", "outage"],
},
],
},
{
id: "checks",
title: "Checks",
description: "View monitoring check results and history.",
icon: "FileText",
articleCount: 3,
articles: [
{
id: "understanding-checks",
title: "Understanding checks",
description: "What checks are and how they work.",
keywords: ["check", "result", "monitoring", "status", "response"],
},
{
id: "check-details",
title: "Check details and timing",
description: "Response times, status codes, and timing breakdown.",
keywords: ["response", "time", "timing", "breakdown", "dns", "tls"],
},
{
id: "filtering-checks",
title: "Filtering and date ranges",
description: "Filter checks by date range and status.",
keywords: ["filter", "date", "range", "pagination", "sort"],
},
],
},
{
id: "logs",
title: "Logs & diagnostics",
description: "System logs, queue monitoring, and diagnostics.",
icon: "Database",
articleCount: 3,
articles: [
{
id: "application-logs",
title: "Application logs",
description: "View and filter system logs by level.",
keywords: ["log", "error", "warning", "info", "debug", "filter"],
},
{
id: "queue-monitoring",
title: "Queue monitoring",
description: "Monitor background jobs and queue status.",
keywords: ["queue", "job", "background", "bullmq", "worker"],
},
{
id: "system-diagnostics",
title: "System diagnostics",
description: "CPU, memory, and heap usage metrics.",
keywords: ["diagnostics", "cpu", "memory", "heap", "performance"],
},
],
},
{
id: "notifications",
title: "Notifications",
description: "Get alerted when things go wrong.",
icon: "Bell",
articleCount: 7,
articles: [
{
id: "email-notifications",
title: "Email notifications",
description: "SMTP setup and configuration.",
keywords: ["email", "smtp", "mail", "setup", "configure"],
},
{
id: "slack-integration",
title: "Slack integration",
description: "Webhook setup for Slack channels.",
keywords: ["slack", "webhook", "integration", "channel"],
},
{
id: "discord-integration",
title: "Discord integration",
description: "Channel notifications via webhooks.",
keywords: ["discord", "webhook", "integration", "channel"],
},
{
id: "pagerduty-integration",
title: "PagerDuty integration",
description: "On-call management integration.",
keywords: ["pagerduty", "oncall", "integration", "escalation"],
},
{
id: "webhook-notifications",
title: "Webhook notifications",
description: "Custom HTTP callbacks for alerts.",
keywords: ["webhook", "http", "callback", "custom", "api"],
},
{
id: "matrix-integration",
title: "Matrix integration",
description: "Decentralized messaging notifications.",
keywords: ["matrix", "messaging", "integration", "homeserver"],
},
{
id: "testing-notifications",
title: "Testing notifications",
description: "Verify your notification setup works.",
keywords: ["test", "verify", "notification", "check"],
},
],
},
{
id: "status-pages",
title: "Status pages",
description: "Share uptime with your users.",
icon: "Wifi",
articleCount: 5,
articles: [
{
id: "creating-status-page",
title: "Creating a status page",
description: "Public-facing uptime dashboard setup.",
keywords: ["status", "page", "create", "public", "dashboard"],
},
{
id: "customizing-appearance",
title: "Customizing appearance",
description: "Logo, colors, and branding options.",
keywords: ["customize", "logo", "color", "brand", "style"],
},
{
id: "adding-monitors",
title: "Adding monitors to status page",
description: "Main monitors and sub-components.",
keywords: ["add", "monitor", "component", "group"],
},
{
id: "display-options",
title: "Display options",
description: "Charts, uptime percentage, and login links.",
keywords: ["display", "chart", "uptime", "percentage", "option"],
},
{
id: "sharing",
title: "Sharing your status page",
description: "Share your status page URL with users.",
keywords: ["share", "url", "public", "link", "access"],
},
],
},
{
id: "maintenance",
title: "Maintenance windows",
description: "Schedule planned downtime.",
icon: "Wrench",
articleCount: 3,
articles: [
{
id: "creating-maintenance",
title: "Creating maintenance windows",
description: "Suppress alerts during planned maintenance.",
keywords: ["maintenance", "create", "schedule", "suppress", "alert"],
},
{
id: "recurring-maintenance",
title: "One-time vs recurring",
description: "Different scheduling options.",
keywords: ["recurring", "one-time", "schedule", "repeat", "frequency"],
},
{
id: "managing-maintenance",
title: "Managing active maintenance",
description: "Edit, extend, or cancel maintenance windows.",
keywords: ["manage", "edit", "cancel", "extend", "active"],
},
],
},
{
id: "team-management",
title: "Team management",
description: "Collaborate with your team.",
icon: "Users",
articleCount: 3,
articles: [
{
id: "inviting-members",
title: "Inviting team members",
description: "Send email invitations to your team.",
keywords: ["invite", "team", "member", "email", "add"],
},
{
id: "user-roles",
title: "User roles",
description: "Admin vs User permissions explained.",
keywords: ["role", "admin", "user", "permission", "access"],
},
{
id: "managing-access",
title: "Managing team access",
description: "Add and remove team members.",
keywords: ["manage", "access", "remove", "team", "member"],
},
],
},
{
id: "settings",
title: "Settings & configuration",
description: "Customize your Checkmate instance.",
icon: "Settings",
articleCount: 6,
articles: [
{
id: "account-settings",
title: "Account settings",
description: "Manage your profile, password, and account.",
keywords: ["account", "profile", "password", "avatar", "photo", "delete"],
},
{
id: "email-configuration",
title: "Email configuration",
description: "SMTP server setup for notifications.",
keywords: ["email", "smtp", "configure", "server", "mail"],
},
{
id: "global-thresholds",
title: "Global thresholds",
description: "Default alert levels for all monitors.",
keywords: ["threshold", "global", "default", "alert", "level"],
},
{
id: "appearance",
title: "Appearance settings",
description: "Theme and display preferences.",
keywords: ["appearance", "theme", "dark", "light", "display", "mode"],
},
{
id: "timezone-settings",
title: "Timezone settings",
description: "Display preferences for dates and times.",
keywords: ["timezone", "time", "date", "display", "preference"],
},
{
id: "export-monitors",
title: "Export monitors",
description: "Download your monitors as JSON.",
keywords: ["export", "download", "backup", "json", "monitors"],
},
],
},
];
// Fast Finds - Popular/quick access articles
export const fastFinds = [
{
id: "ff-1",
title: "Creating your first monitor",
collectionId: "getting-started",
articleId: "quick-start",
},
{
id: "ff-2",
title: "Setting up email notifications",
collectionId: "notifications",
articleId: "email-notifications",
},
{
id: "ff-3",
title: "Installing Capture agent",
collectionId: "infrastructure",
articleId: "installing-capture",
},
{
id: "ff-4",
title: "Creating a status page",
collectionId: "status-pages",
articleId: "creating-status-page",
},
{
id: "ff-5",
title: "Understanding Core Web Vitals",
collectionId: "pagespeed",
articleId: "core-web-vitals",
},
];
// Helper functions
export const getCollection = (collectionId) => {
return collections.find((c) => c.id === collectionId);
};
export const getArticle = (collectionId, articleId) => {
const collection = getCollection(collectionId);
return collection?.articles.find((a) => a.id === articleId);
};
export const getTotalArticleCount = () => {
return collections.reduce((sum, collection) => sum + collection.articleCount, 0);
};
/**
* @typedef {Object} SearchResult
* @property {string} collectionId
* @property {string} collectionTitle
* @property {string} articleId
* @property {string} articleTitle
* @property {string} articleDescription
* @property {'title' | 'description' | 'keyword'} matchType
*/
/**
* Search articles by query
* @param {string} query
* @returns {SearchResult[]}
*/
export const searchArticles = (query) => {
if (!query || query.trim().length < 2) return [];
const normalizedQuery = query.toLowerCase().trim();
const results = [];
for (const collection of collections) {
for (const article of collection.articles) {
let matchType = null;
// Check title match (highest priority)
if (article.title.toLowerCase().includes(normalizedQuery)) {
matchType = "title";
}
// Check description match
else if (article.description.toLowerCase().includes(normalizedQuery)) {
matchType = "description";
}
// Check keywords match
else if (
article.keywords.some((kw) => kw.toLowerCase().includes(normalizedQuery))
) {
matchType = "keyword";
}
if (matchType) {
results.push({
collectionId: collection.id,
collectionTitle: collection.title,
articleId: article.id,
articleTitle: article.title,
articleDescription: article.description,
matchType,
});
}
}
}
// Sort by match type priority: title > description > keyword
return results.sort((a, b) => {
const priority = { title: 0, description: 1, keyword: 2 };
return priority[a.matchType] - priority[b.matchType];
});
};
@@ -1,30 +0,0 @@
import SidebarWrapper from "./SidebarWrapper";
import {
UserGuideSidebarProvider,
useUserGuideSidebarContext,
TAB_BAR_WIDTH,
DEFAULT_CONTENT_WIDTH,
} from "./UserGuideSidebarContext";
// Main component that wraps SidebarWrapper with context consumer
const UserGuideSidebar = () => {
const { isOpen, open, close } = useUserGuideSidebarContext();
return (
<SidebarWrapper
isOpen={isOpen}
onOpen={open}
onClose={close}
/>
);
};
export {
UserGuideSidebar,
UserGuideSidebarProvider,
useUserGuideSidebarContext,
TAB_BAR_WIDTH,
DEFAULT_CONTENT_WIDTH,
};
export default UserGuideSidebar;
@@ -1,222 +0,0 @@
/**
* Design Tokens for UserGuide
* Supports both light and dark themes via MUI theme integration
*/
// Typography (theme-independent)
export const typography = {
fontFamily: {
sans: "'Inter', system-ui, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif",
mono: "'Geist Mono', 'Fira Code', 'Consolas', monospace",
},
fontSize: {
xs: 12,
sm: 12,
base: 13,
md: 14,
lg: 15,
xl: 16,
"2xl": 20,
"3xl": 22,
"4xl": 26,
"5xl": 30,
},
lineHeight: {
tight: 1.2,
snug: 1.3,
normal: 1.4,
relaxed: 1.5,
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
},
};
// Spacing (theme-independent)
export const spacing = {
xs: "4px",
sm: "8px",
md: "12px",
lg: "16px",
xl: "24px",
"2xl": "32px",
"3xl": "40px",
"4xl": "48px",
};
// Brand colors (theme-independent) - Checkmate blue
export const brandColors = {
primary: "#1570EF",
primaryDark: "#1054B0",
primaryLight: "#5BA3F5",
success: "#17B26A",
warning: "#F79009",
error: "#F04438",
};
/**
* Get theme-aware colors based on MUI theme mode
* @param {'light' | 'dark'} mode - The current theme mode
* @returns {Object} Theme-aware color tokens
*/
export const getColors = (mode) => {
const isDark = mode === "dark";
return {
text: {
primary: isDark ? "#ffffff" : "#1C2130",
secondary: isDark ? "#d0d5dd" : "#475467",
muted: isDark ? "#98a2b3" : "#667085",
white: "#ffffff",
whiteMuted: "rgba(255, 255, 255, 0.8)",
},
brand: brandColors,
border: {
default: isDark ? "#344054" : "#d0d5dd",
},
background: {
white: isDark ? "#1a1a1a" : "#ffffff",
alt: isDark ? "#0d0d0d" : "#f9fafb",
code: isDark ? "#1C2130" : "#f3f4f6",
sidebar: isDark ? "#101010" : "#ffffff",
hover: isDark ? "#2a2a2a" : "#f3f4f6",
},
};
};
/**
* Get theme-aware border styles
* @param {'light' | 'dark'} mode - The current theme mode
* @returns {Object} Border styles
*/
export const getBorder = (mode) => {
const colors = getColors(mode);
return {
radius: "4px",
default: `1px solid ${colors.border.default}`,
};
};
/**
* Get theme-aware image styles
* @param {'light' | 'dark'} mode - The current theme mode
* @returns {Object} Image styles
*/
export const getImageStyles = (mode) => {
const colors = getColors(mode);
const border = getBorder(mode);
const isDark = mode === "dark";
return {
image: {
width: "100%",
borderRadius: border.radius,
border: border.default,
display: "block",
boxShadow: isDark ? "0 2px 8px rgba(0, 0, 0, 0.3)" : "0 2px 8px rgba(0, 0, 0, 0.1)",
},
caption: {
fontSize: 10,
color: colors.text.muted,
textAlign: "center",
fontStyle: "italic",
},
};
};
/**
* Get theme-aware chip styles
* @param {'light' | 'dark'} mode - The current theme mode
* @returns {Object} Chip styles
*/
export const getChipStyles = (mode) => {
const isDark = mode === "dark";
return {
url: {
backgroundColor: isDark ? "rgba(21, 112, 239, 0.2)" : "rgba(21, 112, 239, 0.1)",
color: isDark ? "#5BA3F5" : "#1570EF",
padding: "2px 8px",
borderRadius: "4px",
fontFamily: typography.fontFamily.mono,
fontSize: "0.9em",
},
code: {
backgroundColor: isDark ? "#2d2d2d" : "#f3f4f6",
color: isDark ? "#d0d5dd" : "#1C2130",
padding: "2px 8px",
borderRadius: "4px",
fontFamily: typography.fontFamily.mono,
fontSize: "0.9em",
},
};
};
/**
* Get theme-aware component styles
* @param {'light' | 'dark'} mode - The current theme mode
* @returns {Object} Component styles
*/
export const getComponentStyles = (mode) => {
const colors = getColors(mode);
const border = getBorder(mode);
return {
card: {
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
},
heading: {
h2: {
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize["2xl"],
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
lineHeight: typography.lineHeight.normal,
},
h3: {
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
lineHeight: typography.lineHeight.normal,
},
},
paragraph: {
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
color: colors.text.primary,
lineHeight: typography.lineHeight.relaxed,
},
listItem: {
fontSize: typography.fontSize.base,
color: colors.text.primary,
lineHeight: typography.lineHeight.snug,
},
};
};
// Legacy exports for backwards compatibility (dark mode defaults)
// These will be replaced by useUserGuideTheme hook usage
export const colors = getColors("dark");
export const border = getBorder("dark");
export const imageStyles = getImageStyles("dark");
export const chipStyles = getChipStyles("dark");
export const componentStyles = getComponentStyles("dark");
// Export everything as a single theme object (dark mode default)
export const theme = {
colors,
typography,
spacing,
border,
imageStyles,
chipStyles,
componentStyles,
};
export default theme;
@@ -1,123 +0,0 @@
/**
* Hook to get theme-aware styles for UserGuide components
* Uses MUI's theme system from Checkmate
*/
import { useTheme } from "@mui/material/styles";
import { typography, spacing, brandColors } from "./theme";
/**
* Returns theme-aware styles for UserGuide components
*/
export const useUserGuideTheme = () => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const colors = {
text: {
primary: theme.palette.primary.contrastText,
secondary: theme.palette.primary.contrastTextSecondary,
muted: theme.palette.primary.contrastTextTertiary,
white: "#ffffff",
},
brand: brandColors,
border: {
default: theme.palette.primary.lowContrast,
},
background: {
white: isDark ? "#1a1a1a" : "#ffffff",
alt: theme.palette.primaryBackground.main,
code: isDark ? "#1C2130" : "#f3f4f6",
sidebar: theme.palette.primaryBackground.main,
hover: isDark ? "#2a2a2a" : "#f3f4f6",
},
};
const border = {
radius: "4px",
default: `1px solid ${colors.border.default}`,
};
const imageStyles = {
image: {
width: "100%",
borderRadius: border.radius,
border: border.default,
display: "block",
boxShadow: isDark ? "0 2px 8px rgba(0, 0, 0, 0.3)" : "0 2px 8px rgba(0, 0, 0, 0.1)",
},
caption: {
fontSize: 10,
color: colors.text.muted,
textAlign: "center",
fontStyle: "italic",
},
};
const chipStyles = {
url: {
backgroundColor: isDark ? "rgba(21, 112, 239, 0.2)" : "rgba(21, 112, 239, 0.1)",
color: isDark ? "#5BA3F5" : "#1570EF",
padding: "2px 8px",
borderRadius: "4px",
fontFamily: typography.fontFamily.mono,
fontSize: "0.9em",
},
code: {
backgroundColor: isDark ? "#2d2d2d" : "#f3f4f6",
color: isDark ? "#d0d5dd" : "#1C2130",
padding: "2px 8px",
borderRadius: "4px",
fontFamily: typography.fontFamily.mono,
fontSize: "0.9em",
},
};
const componentStyles = {
card: {
backgroundColor: colors.background.white,
border: border.default,
borderRadius: border.radius,
},
heading: {
h2: {
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize["2xl"],
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
lineHeight: typography.lineHeight.normal,
},
h3: {
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
lineHeight: typography.lineHeight.normal,
},
},
paragraph: {
fontFamily: typography.fontFamily.sans,
fontSize: typography.fontSize.base,
color: colors.text.primary,
lineHeight: typography.lineHeight.relaxed,
},
listItem: {
fontSize: typography.fontSize.base,
color: colors.text.primary,
lineHeight: typography.lineHeight.snug,
},
};
return {
colors,
typography,
spacing,
border,
imageStyles,
chipStyles,
componentStyles,
isDark,
theme,
};
};
export default useUserGuideTheme;
@@ -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,
};
-1
View File
@@ -344,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>
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff. Show more