Compare commits

..
Author SHA1 Message Date
Alex Holliday 189f309631 format 2026-02-04 18:10:52 +00:00
Alex Holliday 97dd3ec9cb add permissin check to sendInviteEmail 2026-02-04 17:57:00 +00:00
Alex Holliday b4c929963f add permission check to invite service 2026-02-04 17:49:45 +00:00
Alex Holliday 4ecc737438 add helper method to extract user roles 2026-02-04 17:49:19 +00:00
Alex Holliday bc58f6e08e add user role hierearchy lookup and management function 2026-02-04 17:48:58 +00:00
Alexander Holliday 35eba6897a Merge pull request #3238 from bluewave-labs/feat/v2-create-config-status-page
feat: v2 create config status page
2026-02-03 15:31:39 -08:00
Alex Holliday 667dc55fc8 types 2026-02-03 23:30:23 +00:00
Alex Holliday f2d2687d2a responsive 2026-02-03 22:58:09 +00:00
Alex Holliday 4e3e580179 align logo 2026-02-03 22:52:20 +00:00
Alex Holliday 4080ac8c2c format 2026-02-03 22:40:26 +00:00
Alex Holliday 0a408f8cfd sorting 2026-02-03 22:07:59 +00:00
Alex Holliday e3921beac4 hide unused features 2026-02-03 21:53:34 +00:00
Alex Holliday 4c284493ba actin menu 2026-02-03 21:40:17 +00:00
Alex Holliday 0fcceeb7a6 strings 2026-02-03 21:34:16 +00:00
Alex Holliday f799d46025 cleanup, style picler 2026-02-03 21:29:02 +00:00
Alex Holliday a80df60fc1 handle delete 2026-02-03 21:14:16 +00:00
Alexander Holliday cbec0fa065 Merge pull request #3213 from sukritraj02/#3164
#3164
2026-02-03 13:07:14 -08:00
Alex Holliday 40e5550e0f delete header 2026-02-03 20:18:51 +00:00
Alex Holliday 90fd9930db handle remove logo 2026-02-03 20:11:47 +00:00
Alex Holliday f10f7385b0 fix hook 2026-02-03 19:59:42 +00:00
Alex Holliday 2d85abf030 id 2026-02-03 19:50:36 +00:00
Alex Holliday 3c0c470ca3 features config box 2026-02-03 19:36:43 +00:00
Alex Holliday 4c5cae8722 missing key 2026-02-03 19:31:51 +00:00
Alex Holliday 8650f30d12 type 2026-02-03 19:28:55 +00:00
Alexander Holliday 09e37f4356 Merge pull request #3233 from bluewave-labs/fix/fe/account-page-password-ui
Fix: Send Email Invite
2026-02-03 11:25:52 -08:00
Alex Holliday 6bc8a521c9 logo 2026-02-03 19:24:50 +00:00
Alex Holliday a1e6eaa9d0 submit 2026-02-03 19:03:17 +00:00
Alex Holliday bbb61a6f88 format 2026-02-03 10:40:00 -08:00
Alex Holliday c1d2041482 Merge branch 'develop' into feat/v2-create-config-status-page 2026-02-03 18:34:17 +00:00
Alex Holliday 7aa05c7f85 strings 2026-02-03 18:34:15 +00:00
Alexander Holliday 539866e119 Merge pull request #3237 from bluewave-labs/fix/strings
Fix/strings
2026-02-03 10:26:02 -08:00
Alex Holliday 6f0ff1583e rebuild strings 2026-02-03 18:23:19 +00:00
Alex Holliday 12b61d17f2 initial commit 2026-02-03 18:09:51 +00:00
Alex Holliday 7426418629 fix uptime 2026-02-03 18:03:26 +00:00
Alex Holliday 3566da7515 uptime strings 2026-02-03 18:02:24 +00:00
Alex Holliday b451489bd8 Merge branch 'develop' into feat/v2-create-config-status-page 2026-02-03 18:02:13 +00:00
Alexander Holliday d4a0489275 Merge pull request #3236 from bluewave-labs/fix/strings
sort
2026-02-03 09:54:15 -08:00
Alex Holliday 080c91284d sort 2026-02-03 17:53:45 +00:00
Alexander Holliday dadc74373c Merge pull request #3235 from bluewave-labs/fix/strings
Revert "remove unused strings"
2026-02-03 09:53:16 -08:00
Alex Holliday 6fdea04416 Revert "remove unused strings"
This reverts commit a7c5b0c7fb.
2026-02-03 17:51:32 +00:00
Alex Holliday 67963823d9 Merge branch 'develop' into feat/v2-create-config-status-page 2026-02-03 17:43:19 +00:00
Alexander Holliday ab12a9bd3a Merge pull request #3234 from bluewave-labs/fix/validation
Fix validation
2026-02-03 09:42:32 -08:00
Alex Holliday 3b25c50247 Fix validation 2026-02-03 17:41:31 +00:00
Br0wnHammer 63e42b7276 Fix: Send Email Invite 2026-02-03 22:36:19 +05:30
Alex Holliday ae7524f507 monitors details 2026-02-02 23:57:14 +00:00
Alex Holliday 54d71bef61 monitors 2026-02-02 23:46:15 +00:00
Alexander Holliday a77c74b17c Merge pull request #3231 from bluewave-labs/feat/v2-status-page
feat: v2 status page
2026-02-02 15:01:41 -08:00
Alex Holliday f2363a7a09 form initial commit 2026-02-02 23:01:35 +00:00
Alex Holliday 1fefb038f0 cleanup 2026-02-02 22:15:57 +00:00
Alex Holliday 85190992c7 format 2026-02-02 22:02:14 +00:00
Alex Holliday efe87b599f add missing / 2026-02-02 22:01:42 +00:00
Alex Holliday ed1bcf97da monitor name overflow 2026-02-02 21:57:07 +00:00
Alex Holliday 56ead03416 breadcrumbs 2026-02-02 21:51:06 +00:00
Alex Holliday a912a5ebb8 monitors lis tskeleton 2026-02-02 20:42:27 +00:00
Alex Holliday 0fd4173816 status bar 2026-02-02 20:39:16 +00:00
Alex Holliday 75d3644b36 remove unused 2026-02-02 19:25:30 +00:00
Alex Holliday 853e21623e new status pages 2026-02-02 19:20:59 +00:00
Alex Holliday 57c3bf3240 findBYIds only 2026-02-02 19:00:03 +00:00
Alexander Holliday 6be1eb0a3e Merge pull request #3230 from bluewave-labs/feat/v2-incidents
feat: v2 incidents
2026-02-02 10:51:29 -08:00
Alex Holliday 9d70623e0f bump mui color 2026-02-02 18:50:20 +00:00
Alex Holliday 43fe66022b format 2026-02-02 18:45:52 +00:00
Alex Holliday 294fb13005 remove unused 2026-02-02 18:45:29 +00:00
Alex Holliday 8028cab487 no active incidents msg 2026-02-02 18:31:21 +00:00
Alex Holliday 8160ab4a3c fix isFilterACtive logic 2026-02-02 17:56:11 +00:00
Alex Holliday 6063532c79 fitlers 2026-02-02 17:49:36 +00:00
Alex Holliday 83b628c8f9 divider 2026-02-02 17:36:42 +00:00
Alex Holliday 88a91022c3 summary, activ eincident table 2026-02-02 17:28:22 +00:00
Alex Holliday 5822a363a9 resolve strings 2026-02-02 17:16:34 +00:00
Alex Holliday d54c90d511 require team ID 2026-02-02 06:09:57 +00:00
Alex Holliday 3a5783fe91 email 2026-02-02 06:08:51 +00:00
Alex Holliday 479f22ac69 resolution details 2026-02-02 05:41:32 +00:00
Alex Holliday 30202b2249 grid 2026-02-01 01:54:18 +00:00
Alex Holliday 888910abb2 bump mui 2026-02-01 00:49:35 +00:00
Alex Holliday 874d1b277a incidnet details 2026-02-01 00:34:48 +00:00
Alex Holliday f34520cdbf dialog initial commit 2026-02-01 00:03:08 +00:00
Alex Holliday f420044b64 header 2026-01-31 01:32:33 +00:00
Alex Holliday 53d5ae2251 time header 2026-01-31 01:23:54 +00:00
Alex Holliday bcc57c7135 strings 2026-01-31 01:12:05 +00:00
Alex Holliday 6b67f4977f wire up incidents table 2026-01-31 00:41:03 +00:00
Alex Holliday 87255071f4 headers and actions 2026-01-31 00:18:33 +00:00
Alex Holliday cbf1f97591 initial table commit 2026-01-30 23:35:33 +00:00
Alex Holliday 7a881fda03 strings 2026-01-30 23:31:03 +00:00
Alex Holliday 50fd66ef56 center 2026-01-30 23:28:36 +00:00
Alex Holliday f9b677b1ad strings 2026-01-30 23:22:56 +00:00
Alex Holliday b7a6d50c73 cards 2026-01-30 23:16:35 +00:00
Alex Holliday 04bd7f0e2e initial comit 2026-01-30 21:45:14 +00:00
Alexander Holliday 2e7aba788d Merge pull request #3227 from bluewave-labs/fix/infra-link
hardware->infrastructure
2026-01-30 13:40:36 -08:00
Alex Holliday e1665b29af hardware->infrastructure 2026-01-30 21:40:10 +00:00
Alexander Holliday 17450e2537 Merge pull request #3226 from bluewave-labs/fix/strings
fix: remove unused strings
2026-01-30 13:37:22 -08:00
Alex Holliday 270293a2b5 format 2026-01-30 21:35:51 +00:00
Alex Holliday a7c5b0c7fb remove unused strings 2026-01-30 21:35:38 +00:00
Alex Holliday e804c35c3a remove commments 2026-01-30 21:30:10 +00:00
Alexander Holliday 5c4133097d Merge pull request #3225 from bluewave-labs/feat/v2-create-monitors
feat: V2 create monitors
2026-01-30 13:27:52 -08:00
Alex Holliday 85f93cd6d2 format 2026-01-30 21:26:56 +00:00
Alex Holliday d4db32e392 game 2026-01-30 21:26:27 +00:00
Alex Holliday 791b9fc7a2 game 2026-01-30 21:26:17 +00:00
Alex Holliday 206c5577a6 move header 2026-01-30 21:14:11 +00:00
Alex Holliday cbd0530d85 remove unused 2026-01-30 21:10:52 +00:00
Alex Holliday 45ef2438de format 2026-01-30 21:05:32 +00:00
Alex Holliday ec818eaacb delete 2026-01-30 21:04:14 +00:00
Alex Holliday 91bb60f2ff delte header 2026-01-30 20:55:24 +00:00
Alex Holliday 443ad46fa7 update strings 2026-01-30 20:38:05 +00:00
Alex Holliday f685c9cd5d useAdvncedMatching 2026-01-30 20:21:31 +00:00
Alex Holliday 0263c17618 advance dmatching section 2026-01-30 19:54:44 +00:00
Alex Holliday 5a12e36c7e put -> patch 2026-01-30 19:43:34 +00:00
Alex Holliday 67da6c61cf put/create 2026-01-30 19:41:41 +00:00
Alex Holliday f538f61464 slider update 2026-01-30 18:28:30 +00:00
Alex Holliday f6971ede69 slider w label 2026-01-30 00:00:12 +00:00
Alex Holliday e69283e3ed sliders 2026-01-29 23:52:21 +00:00
Alex Holliday 084eed85bf add incidents config box 2026-01-29 23:39:14 +00:00
Alex Holliday 9a42fb031f check frequency 2026-01-29 23:17:08 +00:00
Alex Holliday 943e88c54d TLS/SSL 2026-01-29 23:10:34 +00:00
Alex Holliday 24ce64b439 notifications 2026-01-29 23:04:40 +00:00
Alex Holliday 841d25ce3e general 2026-01-29 22:47:29 +00:00
Alex Holliday 84e4ea7b64 forward ref 2026-01-29 22:20:50 +00:00
Alex Holliday 4b92dbd19b initial commit 2026-01-29 22:17:56 +00:00
Alexander Holliday 5eb1d54d65 Merge pull request #3223 from bluewave-labs/feat/v2-notifications-create
Feat: v2 notifications create
2026-01-29 13:16:28 -08:00
Alex Holliday a0aee182de remove unused 2026-01-29 21:15:14 +00:00
Alex Holliday 2dca5ccb67 format 2026-01-29 21:09:00 +00:00
Alex Holliday 348eea5475 breadcrumb fix 2026-01-29 21:08:36 +00:00
Alex Holliday 2a3683c74a breadcurmbs 2026-01-29 21:08:26 +00:00
Alex Holliday 806d7ae456 test 2026-01-29 20:59:47 +00:00
Alex Holliday cac502573f put -> patch 2026-01-29 20:55:14 +00:00
Alex Holliday 830318edc3 put -> patch 2026-01-29 20:55:02 +00:00
Alex Holliday 86a55e3cfe submit 2026-01-29 20:47:47 +00:00
Alex Holliday 796960aeb2 matrix in one config 2026-01-29 20:26:28 +00:00
Alex Holliday a25ff03b71 remaining fields 2026-01-29 20:12:50 +00:00
Alex Holliday 22ae6b4be6 type 2026-01-29 19:34:49 +00:00
Alex Holliday 0b5e28c296 form 2026-01-29 19:29:36 +00:00
sukritraj02 c5c91e28f8 udated package-lock.json client 2026-01-30 00:23:02 +05:30
Alex Holliday 3ba3e4b865 initial commit 2026-01-29 18:41:46 +00:00
Alexander Holliday 77fa394004 Merge pull request #3222 from bluewave-labs/fix/missing-create-header
fix: missing create header
2026-01-29 10:37:02 -08:00
Alex Holliday c0ba278e47 loading state 2026-01-29 18:34:50 +00:00
sukritraj02 73f41906b0 initial changes , updated package-lock.json file 2026-01-30 00:00:35 +05:30
Alexander Holliday cb5af2741d Merge pull request #3216 from bluewave-labs/fix/latest-incident-overflow
fix: latest incidents panel overflow text on smaller devices
2026-01-29 10:28:32 -08:00
mannilakash af4ef87966 Merge branch 'develop' of github.com:bluewave-labs/Checkmate into fix/latest-incident-overflow 2026-01-29 13:22:20 -05:00
mannilakash a154ad11b5 fix: latest incident panel: Box -> Grid 2026-01-29 13:21:56 -05:00
Alexander Holliday 1bfd1c9119 Merge pull request #3221 from bluewave-labs/feat/v2-notifications
Feat/v2 notifications
2026-01-29 10:21:35 -08:00
Alex Holliday fc98192bcf format 2026-01-29 18:20:05 +00:00
Alex Holliday b1db74d96f remove unused 2026-01-29 18:19:55 +00:00
Alex Holliday c1258d3cdf v2 2026-01-29 18:18:52 +00:00
Alex Holliday 6ffa5ac648 v2-table 2026-01-29 18:14:23 +00:00
Alex Holliday 6395e8a10e iniital commit 2026-01-29 17:52:10 +00:00
Alexander Holliday 2852d2cdce Merge pull request #3220 from bluewave-labs/fix/infra-cleanup
fix: remove unused infra components
2026-01-29 09:45:14 -08:00
Alex Holliday 363baef7b1 remove unused 2026-01-29 17:43:33 +00:00
Alexander Holliday a1973a6d2b Merge pull request #3219 from bluewave-labs/feat/v2-infra-details
Feat/v2 infra details
2026-01-29 09:26:14 -08:00
Gorkem Cetin (BWL) 12f02d6eea Refine questions and ideas section in README
Updated communication options with preferred channels.
2026-01-28 19:47:48 -05:00
Alex Holliday a6c291c8ef build fix 2026-01-29 00:07:11 +00:00
Alex Holliday 227dc9e7e8 y axis formatting 2026-01-29 00:04:51 +00:00
Alex Holliday 38ef9ccad3 network charts 2026-01-29 00:01:22 +00:00
Alex Holliday 353b97e7d3 _id -> bucketDate 2026-01-28 23:34:18 +00:00
Alex Holliday 9b871ffb52 grid fix 2026-01-28 23:29:04 +00:00
Alex Holliday cb392eaaba gauges, charts 2026-01-28 23:26:00 +00:00
Alex Holliday dcf5a77923 stat boxes 2026-01-28 22:38:02 +00:00
Alex Holliday ec03addba6 headers 2026-01-28 22:00:33 +00:00
Alex Holliday eefdb8d253 initial commit 2026-01-28 21:39:38 +00:00
Alexander Holliday e539f40612 Merge pull request #3218 from bluewave-labs/feat/v2-infra
feat: v2 infra
2026-01-28 13:39:08 -08:00
Alex Holliday c18159afd3 v2 infra migration 2026-01-28 21:34:43 +00:00
Alex Holliday 5596e45ff3 initial commit 2026-01-28 20:48:02 +00:00
Alexander Holliday bde13f4fa7 Merge pull request #3217 from bluewave-labs/fix/check-cleanup
fix: cleanup
2026-01-28 12:23:55 -08:00
Alex Holliday 7f1c996632 format 2026-01-28 20:21:41 +00:00
Alex Holliday 396d5b369b remove unused 2026-01-28 20:20:18 +00:00
Alex Holliday bb8cc8b464 cleanup, check fix 2026-01-28 20:00:42 +00:00
mannilakash 66efa25e2b fix: latest incidents panel overflow text on smaller devices 2026-01-28 14:36:51 -05:00
Alexander Holliday 7bef6dc0e6 Merge pull request #3215 from bluewave-labs/feat/v2-checks
feat: v2 checks
2026-01-28 11:28:10 -08:00
Alexander Holliday 1c341a4e3d Merge pull request #3214 from bluewave-labs/fix/bump-mongo
bump mongo for ARM to 6.0
2026-01-28 11:26:39 -08:00
Alex Holliday f41f2322cf remove unused import 2026-01-28 19:26:15 +00:00
Alex Holliday b653f26c96 format 2026-01-28 19:24:17 +00:00
Alex Holliday ece1dadb78 correctly convert to entity 2026-01-28 19:20:16 +00:00
Alex Holliday 1f003c4d48 fix breakpoint 2026-01-28 19:06:21 +00:00
Alex Holliday 5a468d717a Responsive 2026-01-28 19:00:34 +00:00
Alex Holliday 975d134dd8 extract strings 2026-01-28 18:45:11 +00:00
Alex Holliday 7a66b5310b expose isValidating 2026-01-28 18:03:33 +00:00
Alex Holliday fcfad945a0 selects 2026-01-28 17:59:11 +00:00
Alex Holliday 6b4032ecfe bump mongo for ARM to 6.0 2026-01-28 17:08:36 +00:00
sukritraj02 41d6c092d4 eat: improve notification grouping email subject and monitor name formatting 2026-01-28 18:53:51 +05:30
sukritraj02 f9e366e181 fixed notificationsService.ts 2026-01-28 18:23:22 +05:30
Alex Holliday 07705e9823 fix recent 2026-01-27 23:21:04 +00:00
Alex Holliday c48049e0af checks table 2026-01-27 23:17:40 +00:00
Alex Holliday f3ba8c9815 monitor select 2026-01-27 23:05:00 +00:00
Alex Holliday f88e4ee6f4 refactor 2026-01-27 22:24:16 +00: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 300b1b2238 initial commit 2026-01-27 21:57:09 +00:00
Alex Holliday 6efc05879a cast to string 2026-01-27 21:54:20 +00: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 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 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
485 changed files with 21972 additions and 34188 deletions

No files matched your search

+2 -3
View File
@@ -79,9 +79,8 @@ You can see the memory footprint of MongoDB and Redis on the same server (398Mb
If you have any questions, suggestions or comments, you have several options:
- [Discord channel](https://discord.gg/NAb6H3UTjK)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions)
- [Reddit group](https://www.reddit.com/r/CheckmateMonitoring/)
- [Discord channel](https://discord.gg/NAb6H3UTjK) (preferred)
- [GitHub Discussions](https://github.com/bluewave-labs/bluewave-uptime/discussions) (we check here from time to time)
Feel free to ask questions or share your ideas - we'd love to hear from you!
+559 -1724
View File
File diff suppressed because it is too large. Load diff
+9 -9
View File
@@ -17,10 +17,10 @@
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
"@hookform/resolvers": "5.2.2",
"@mui/lab": "6.0.0-dev.240424162023-9968b4889d",
"@mui/material": "6.4.11",
"@mui/x-charts": "^7.5.1",
"@mui/x-date-pickers": "7.29.0",
"@mui/lab": "7.0.0",
"@mui/material": "7.3.7",
"@mui/x-charts": "7.29.1",
"@mui/x-date-pickers": "7.29.4",
"@reduxjs/toolkit": "2.7.0",
"axios": "^1.7.4",
"dayjs": "1.11.13",
@@ -30,11 +30,11 @@
"i18next": "25.4.2",
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"mui-color-input": "^6.0.0",
"pretty-ms": "9.3.0",
"mui-color-input": "^7.0.0",
"pretty-ms": "^9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
"react-hook-form": "7.63.0",
"react-hook-form": "^7.63.0",
"react-i18next": "^15.4.0",
"react-icons": "5.5.0",
"react-redux": "9.2.0",
@@ -43,9 +43,9 @@
"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"
"zod": "^4.1.11"
},
"devDependencies": {
"@types/node": "24.5.2",
+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>
@@ -1,100 +0,0 @@
import { Checkbox, FormControl, ListItemText, MenuItem, Select } from "@mui/material";
import { useTheme } from "@emotion/react";
import PropTypes from "prop-types";
import Icon from "../Icon";
/**
* A reusable filter header component that displays a dropdown menu with selectable options.
*
* @component
* @param {Object} props - The component props.
* @param {string} props.header - The header text to display when no options are selected.
* @param {Array} props.options - An array of options to display in the dropdown menu. Each option should have a `value` and `label`.
* @param {Array} [props.value] - The currently selected values.
* @param {Function} props.onChange - The callback function to handle changes in the selected values.
* @param {boolean} [props.multiple=true] - Whether multiple options can be selected.
* @returns {JSX.Element} The rendered FilterHeader component.
*/
const FilterHeader = ({ header, options, value, onChange, multiple = true }) => {
const theme = useTheme();
const controlledValue = value === undefined ? [] : value; // Ensure value is always treated as an array for controlled component purposes
return (
<FormControl
sx={{ minWidth: "10%" }}
size="small"
>
<Select
multiple={multiple}
IconComponent={(props) => (
<Icon
{...props}
name="PlusCircle"
size={18}
/>
)}
displayEmpty
value={controlledValue}
onChange={onChange}
renderValue={(selected) => {
if (!selected?.length) {
return header;
}
return (
header +
" | " +
selected
.map((value) => options.find((option) => option.value === value)?.label)
.filter(Boolean)
.join(", ")
);
}}
MenuProps={{
anchorOrigin: {
vertical: "bottom",
horizontal: "left",
},
transformOrigin: {
vertical: "top",
horizontal: "left",
},
}}
>
{options.map((option) => (
<MenuItem
key={option.value}
value={option.value}
sx={{
height: theme.spacing(17),
padding: 0,
}}
>
<Checkbox
checked={controlledValue.includes(option.value)}
size="small"
/>
<ListItemText primary={option.label} />
</MenuItem>
))}
</Select>
</FormControl>
);
};
FilterHeader.propTypes = {
header: PropTypes.string.isRequired,
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
})
).isRequired,
value: PropTypes.arrayOf(PropTypes.string),
onChange: PropTypes.func.isRequired,
multiple: PropTypes.bool,
};
export default FilterHeader;
+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>
);
};
@@ -1,119 +0,0 @@
import * as React from "react";
import Button from "@mui/material/Button";
import ButtonGroup from "@mui/material/ButtonGroup";
import Icon from "../Icon";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import Grow from "@mui/material/Grow";
import Paper from "@mui/material/Paper";
import Popper from "@mui/material/Popper";
import MenuItem from "@mui/material/MenuItem";
import MenuList from "@mui/material/MenuList";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { createToast } from "../../../Utils/toastUtils.jsx";
import { useExportMonitors } from "../../../Hooks/monitorHooks.js";
const MonitorActions = ({ isLoading }) => {
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef(null);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const navigate = useNavigate();
const { t } = useTranslation();
const [exportMonitors, isExporting] = useExportMonitors();
const options = [t("monitorActions.import"), t("monitorActions.export")];
const handleClick = async () => {
if (selectedIndex === 0) {
// Import
navigate("/uptime/bulk-import");
} else {
// Export
const [success, error] = await exportMonitors();
if (!success) {
createToast({ body: error || t("export.failed") });
}
}
};
const handleMenuItemClick = (event, index) => {
setSelectedIndex(index);
setOpen(false);
};
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event) => {
if (anchorRef.current && anchorRef.current.contains(event.target)) {
return;
}
setOpen(false);
};
return (
<React.Fragment>
<ButtonGroup
variant="contained"
color="accent"
ref={anchorRef}
aria-label="Monitor actions"
disabled={isLoading || isExporting}
>
<Button onClick={handleClick}>{options[selectedIndex]}</Button>
<Button
size="small"
aria-controls={open ? "split-button-menu" : undefined}
aria-expanded={open ? "true" : undefined}
aria-label="select monitor action"
aria-haspopup="menu"
onClick={handleToggle}
>
<Icon
name="ChevronDown"
size={20}
/>
</Button>
</ButtonGroup>
<Popper
sx={{ zIndex: 1 }}
open={open}
anchorEl={anchorRef.current}
role={undefined}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin: placement === "bottom" ? "center top" : "center bottom",
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList
id="split-button-menu"
autoFocusItem
>
{options.map((option, index) => (
<MenuItem
key={option}
selected={index === selectedIndex}
onClick={(event) => handleMenuItemClick(event, index)}
>
{option}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</React.Fragment>
);
};
export default MonitorActions;
@@ -1,52 +0,0 @@
import { Stack, Typography } from "@mui/material";
import PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import SkeletonLayout from "./skeleton.jsx";
const MonitorCountHeader = ({
isLoading = false,
monitorCount,
heading = "monitors",
sx,
children,
}) => {
const theme = useTheme();
if (isLoading) return <SkeletonLayout />;
if (monitorCount === 1) {
heading = "monitor";
}
return (
<Stack
direction="row"
alignItems="center"
display="flex"
width="fit-content"
height={theme.spacing(18)}
gap={theme.spacing(2)}
mt={theme.spacing(2)}
px={theme.spacing(4)}
pt={theme.spacing(2)}
pb={theme.spacing(3)}
borderRadius={theme.spacing(1)}
sx={{
...sx,
backgroundColor: theme.palette.secondary.main,
}}
>
{monitorCount} <Typography component="h2">{heading}</Typography>
{children}
</Stack>
);
};
MonitorCountHeader.propTypes = {
isLoading: PropTypes.bool,
monitorCount: PropTypes.number,
heading: PropTypes.string,
children: PropTypes.node,
sx: PropTypes.object,
};
export default MonitorCountHeader;
@@ -1,26 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
import { useTheme } from "@emotion/react";
const SkeletonLayout = () => {
const theme = useTheme();
return (
<Stack
direction="row"
alignItems="center"
gap={theme.spacing(2)}
>
<Skeleton
variant="text"
width={100}
height={32}
/>
<Skeleton
variant="circular"
width={40}
height={40}
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import MonitorActions from "../MonitorActions/index.jsx";
const CreateMonitorHeader = ({ isAdmin, label, isLoading = true, path, bulkPath }) => {
const navigate = useNavigate();
@@ -1,158 +0,0 @@
import Stack from "@mui/material/Stack";
import Status from "./status.jsx";
import Skeleton from "./skeleton.jsx";
import Button from "@mui/material/Button";
import { Tooltip } from "@mui/material";
import Icon from "../Icon";
// Utils
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { useTheme } from "@mui/material/styles";
import { usePauseMonitor } from "../../../Hooks/monitorHooks.js";
import { useSendTestEmail } from "../../../Hooks/useSendTestEmail.js";
import { useTranslation } from "react-i18next";
import { useTestAllNotifications } from "../../../Hooks/useNotifications.js";
/**
* MonitorDetailsControlHeader component displays the control header for monitor details.
* It includes status display, pause/resume button, and a configure button for admins.
*
* @component
* @param {Object} props - Component props
* @param {string} props.path - The base path for navigation
* @param {boolean} [props.isLoading=false] - Flag indicating if the data is loading
* @param {boolean} [props.isAdmin=false] - Flag indicating if the user is an admin
* @param {Object} props.monitor - The monitor object containing details
* @param {Function} props.triggerUpdate - Function to trigger an update
* @returns {JSX.Element} The rendered component
*/
const MonitorDetailsControlHeader = ({
path,
isLoading = false,
isAdmin = false,
monitor,
triggerUpdate,
}) => {
const navigate = useNavigate();
const theme = useTheme();
const { t } = useTranslation();
const [pauseMonitor, isPausing, error] = usePauseMonitor();
const isTestNotificationsDisabled = monitor?.notifications?.length === 0;
const tooltipTitle = isTestNotificationsDisabled ? t("testNotificationsDisabled") : "";
// const [isSending, emailError, sendTestEmail] = useSendTestEmail();
const [testAllNotifications, isSending, errorAllNotifications] =
useTestAllNotifications();
if (isLoading) {
return <Skeleton />;
}
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Status monitor={monitor} />
<Stack
direction="row"
gap={theme.spacing(2)}
>
<Tooltip
key={monitor?.id}
placement="bottom"
title={tooltipTitle}
>
<span>
<Button
variant="contained"
color="secondary"
loading={isSending}
startIcon={
<Icon
name="Mail"
size={18}
/>
}
disabled={isTestNotificationsDisabled}
onClick={() => {
testAllNotifications({ monitorId: monitor?.id });
}}
sx={{
whiteSpace: "nowrap",
}}
>
{t("sendTestNotifications")}
</Button>
</span>
</Tooltip>
<Button
variant="contained"
color="secondary"
onClick={(e) => {
navigate(`/incidents/${monitor?.id}`);
}}
>
{t("menu.incidents")}
</Button>
{isAdmin && (
<Button
variant="contained"
color="secondary"
loading={isPausing}
startIcon={
monitor?.isActive ? (
<Icon
name="Pause"
size={18}
/>
) : (
<Icon
name="Play"
size={18}
/>
)
}
onClick={() => {
pauseMonitor({
monitorId: monitor?.id,
triggerUpdate,
});
}}
>
{monitor?.isActive ? "Pause" : "Resume"}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="secondary"
startIcon={
<Icon
name="Settings"
size={18}
/>
}
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
>
Configure
</Button>
)}
</Stack>
</Stack>
);
};
MonitorDetailsControlHeader.propTypes = {
path: PropTypes.string,
isLoading: PropTypes.bool,
isAdmin: PropTypes.bool,
monitor: PropTypes.object,
triggerUpdate: PropTypes.func,
};
export default MonitorDetailsControlHeader;
@@ -1,23 +0,0 @@
import { Stack, Skeleton } from "@mui/material";
const SkeletonLayout = () => {
return (
<Stack
direction="row"
justifyContent="space-between"
>
<Skeleton
height={40}
variant="rounded"
width="15%"
/>
<Skeleton
height={40}
variant="rounded"
width="15%"
/>
</Stack>
);
};
export default SkeletonLayout;
@@ -1,51 +0,0 @@
// Components
import Stack from "@mui/material/Stack";
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 PropTypes from "prop-types";
import { useTheme } from "@emotion/react";
import { useMonitorUtils } from "../../../Hooks/useMonitorUtils.js";
import { formatMonitorUrl } from "../../../Utils/utils.js";
/**
* Status component displays the status information of a monitor.
* It includes the monitor's name, URL, and check interval.
*
* @component
* @param {Object} props - Component props
* @param {Object} props.monitor - The monitor object containing details
* @param {string} props.monitor.name - The name of the monitor
* @param {string} props.monitor.url - The URL of the monitor
* @param {number} props.monitor.interval - The interval at which the monitor checks
* @returns {JSX.Element} The rendered component
*/
const Status = ({ monitor }) => {
const theme = useTheme();
const { statusColor, determineState } = useMonitorUtils();
return (
<Stack>
<Typography variant="monitorName">{monitor?.name}</Typography>
<Stack
direction="row"
alignItems={"center"}
gap={theme.spacing(4)}
>
<PulseDot color={statusColor[determineState(monitor)]} />
<Typography variant="monitorUrl">{formatMonitorUrl(monitor?.url)}</Typography>
<Dot />
<Typography>
Checking every {formatDurationRounded(monitor?.interval)}.
</Typography>
</Stack>
</Stack>
);
};
Status.propTypes = {
monitor: PropTypes.object,
};
export default Status;
@@ -1,127 +0,0 @@
// Components
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Divider from "@mui/material/Divider";
import Icon from "../Icon";
import Search from "../Inputs/Search/index.jsx";
// Utils
import { useState, useEffect } from "react";
import { useTheme } from "@mui/material/styles";
import PropTypes from "prop-types";
const NotificationConfig = ({
notifications,
setMonitor,
setNotifications,
//FieldWrapper's props
gap,
labelMb,
labelFontWeight,
labelVariant,
labelSx = {},
sx = {},
}) => {
// Local state
const [notificationsSearch, setNotificationsSearch] = useState("");
const [selectedNotifications, setSelectedNotifications] = useState([]);
const handleSearch = (value) => {
setSelectedNotifications(value);
setMonitor((prev) => {
return {
...prev,
notifications: value.map((notification) => notification._id),
};
});
};
// Handlers
const handleDelete = (id) => {
const updatedNotifications = selectedNotifications.filter(
(notification) => notification._id !== id
);
setSelectedNotifications(updatedNotifications);
setMonitor((prev) => {
return {
...prev,
notifications: updatedNotifications.map((notification) => notification._id),
};
});
};
// Setup
const theme = useTheme();
useEffect(() => {
if (setNotifications) {
const toSet = setNotifications.map((notification) => {
return notifications.find((n) => n._id === notification);
});
setSelectedNotifications(toSet);
}
}, [setNotifications, notifications]);
return (
<Stack gap={theme.spacing(6)}>
<Search
type="notifications"
label="Notifications"
options={notifications}
filteredBy="notificationName"
multiple={true}
value={selectedNotifications}
inputValue={notificationsSearch}
handleInputChange={setNotificationsSearch}
handleChange={(value) => {
handleSearch(value);
}}
labelMb={labelMb}
labelVariant={labelVariant}
labelFontWeight={labelFontWeight}
labelSx={labelSx}
gap={gap}
sx={{
...sx,
}}
/>
<Stack
flex={1}
width="100%"
>
{selectedNotifications.map((notification, index) => (
<Stack
direction="row"
alignItems="center"
key={notification._id}
width="100%"
>
<Typography
flexGrow={1} // <-- This will take up all available horizontal space
>
{notification.notificationName}
</Typography>
<Icon
name="Trash2"
size={20}
onClick={() => {
handleDelete(notification._id);
}}
style={{ cursor: "pointer" }}
/>
{index < selectedNotifications.length - 1 && <Divider />}
</Stack>
))}
</Stack>
</Stack>
);
};
NotificationConfig.propTypes = {
notifications: PropTypes.array,
setMonitor: PropTypes.func,
setNotifications: PropTypes.array,
};
export default NotificationConfig;
@@ -1,419 +0,0 @@
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import PropTypes from "prop-types";
import {
Dialog,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Tabs,
Tab,
Stack,
} from "@mui/material";
import { useTheme } from "@emotion/react";
import TabPanel from "./TabPanel.jsx";
import TabComponent from "./TabComponent.jsx";
import useNotifications from "../Hooks/useNotification.js";
// Define constants for notification types to avoid magic values
const NOTIFICATION_TYPES = {
SLACK: "slack",
DISCORD: "discord",
TELEGRAM: "telegram",
WEBHOOK: "webhook",
};
// Define constants for field IDs
const FIELD_IDS = {
WEBHOOK: "webhook",
TOKEN: "token",
CHAT_ID: "chatId",
URL: "url",
};
const NotificationIntegrationModal = ({
open,
onClose,
monitor,
setMonitor,
// Optional prop to configure available notification types
notificationTypes = null,
}) => {
const { t } = useTranslation();
const theme = useTheme();
const [tabValue, setTabValue] = useState(0);
const [loading, _, sendTestNotification] = useNotifications();
// Helper to get the field state key with error handling
const getFieldKey = useCallback(
(typeId, fieldId) => {
if (typeof typeId !== "string" || typeId === "") {
throw new Error(t("errorInvalidTypeId"));
}
if (typeof fieldId !== "string" || fieldId === "") {
throw new Error(t("errorInvalidFieldId"));
}
return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`;
},
[t]
);
// Define notification types
const DEFAULT_NOTIFICATION_TYPES = [
{
id: NOTIFICATION_TYPES.SLACK,
label: t("notifications.slack.label"),
description: t("notifications.slack.description"),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t("notifications.slack.webhookLabel"),
placeholder: t("notifications.slack.webhookPlaceholder"),
type: "text",
},
],
},
{
id: NOTIFICATION_TYPES.DISCORD,
label: t("notifications.discord.label"),
description: t("notifications.discord.description"),
fields: [
{
id: FIELD_IDS.WEBHOOK,
label: t("notifications.discord.webhookLabel"),
placeholder: t("notifications.discord.webhookPlaceholder"),
type: "text",
},
],
},
{
id: NOTIFICATION_TYPES.TELEGRAM,
label: t("notifications.telegram.label"),
description: t("notifications.telegram.description"),
fields: [
{
id: FIELD_IDS.TOKEN,
label: t("notifications.telegram.tokenLabel"),
placeholder: t("notifications.telegram.tokenPlaceholder"),
type: "text",
},
{
id: FIELD_IDS.CHAT_ID,
label: t("notifications.telegram.chatIdLabel"),
placeholder: t("notifications.telegram.chatIdPlaceholder"),
type: "text",
},
],
},
{
id: NOTIFICATION_TYPES.WEBHOOK,
label: t("notifications.webhook.label"),
description: t("notifications.webhook.description"),
fields: [
{
id: FIELD_IDS.URL,
label: t("notifications.webhook.urlLabel"),
placeholder: t("notifications.webhook.urlPlaceholder"),
type: "text",
},
],
},
];
// Use provided notification types or default to our translated ones
const activeNotificationTypes = notificationTypes || DEFAULT_NOTIFICATION_TYPES;
// Memoized function to initialize integrations state
const initialIntegrationsState = useMemo(() => {
const state = {};
activeNotificationTypes.forEach((type) => {
// Add enabled flag for each notification type
state[type.id] = false;
// Add state for each field in the notification type
type.fields.forEach((field) => {
const fieldKey = getFieldKey(type.id, field.id);
state[fieldKey] = "";
});
});
return state;
}, [activeNotificationTypes, getFieldKey]); // Only recompute when these dependencies change
const [integrations, setIntegrations] = useState(initialIntegrationsState);
useEffect(() => {
if (open) {
const extractNotificationValues = () => {
const values = {};
if (!monitor?.notifications || !Array.isArray(monitor.notifications)) {
return values;
}
monitor.notifications.forEach((notification) => {
// Handle notification based on its structure
if (notification.type === "webhook" && notification.platform) {
if (typeof notification.config === "undefined") return;
const platform = notification.platform;
values[platform] = true; // Set platform as enabled
// Extract configuration based on platform
switch (platform) {
case NOTIFICATION_TYPES.SLACK:
case NOTIFICATION_TYPES.DISCORD:
if (notification.config.webhookUrl) {
values[getFieldKey(platform, FIELD_IDS.WEBHOOK)] =
notification.config.webhookUrl;
}
break;
case NOTIFICATION_TYPES.TELEGRAM:
if (notification.config.botToken) {
values[getFieldKey(platform, FIELD_IDS.TOKEN)] =
notification.config.botToken;
}
if (notification.config.chatId) {
values[getFieldKey(platform, FIELD_IDS.CHAT_ID)] =
notification.config.chatId;
}
break;
case NOTIFICATION_TYPES.WEBHOOK:
if (notification.config.webhookUrl) {
values[getFieldKey(platform, FIELD_IDS.URL)] =
notification.config.webhookUrl;
}
break;
}
}
});
return values;
};
const extractedValues = extractNotificationValues();
setIntegrations((prev) => ({
...prev,
...extractedValues,
}));
}
}, [open, monitor, getFieldKey]);
const handleChangeTab = (event, newValue) => {
setTabValue(newValue);
};
const handleIntegrationChange = (type, checked) => {
setIntegrations((prev) => ({
...prev,
[type]: checked,
}));
};
const handleInputChange = (type, value) => {
setIntegrations((prev) => ({
...prev,
[type]: value,
}));
};
const handleTestNotification = async (type) => {
// Get the notification type details
const notificationType = activeNotificationTypes.find((t) => t.id === type);
if (typeof notificationType === "undefined") {
return;
}
// Prepare config object based on notification type
const config = {};
// Add each field value to the config object
notificationType.fields.forEach((field) => {
const fieldKey = getFieldKey(type, field.id);
config[field.id] = integrations[fieldKey];
});
await sendTestNotification(type, config);
};
// In NotificationIntegrationModal.jsx, update the handleSave function:
const handleSave = () => {
// Get existing notifications
const notifications = [...(monitor?.notifications || [])];
// Get all notification types IDs
const existingTypes = activeNotificationTypes.map((type) => type.id);
// Filter out notifications that are configurable in this modal
const filteredNotifications = notifications.filter((notification) => {
if (notification.platform) {
return !existingTypes.includes(notification.platform);
}
return !existingTypes.includes(notification.type);
});
// Add each enabled notification with its configured fields
activeNotificationTypes.forEach((type) => {
if (integrations[type.id]) {
let notificationObject = {
type: "webhook",
platform: type.id, // Set platform to identify the specific service
config: {},
};
// Configure based on notification type
switch (type.id) {
case "slack":
case "discord":
notificationObject.config.webhookUrl =
integrations[getFieldKey(type.id, "webhook")];
break;
case "telegram":
notificationObject.config.botToken =
integrations[getFieldKey(type.id, "token")];
notificationObject.config.chatId =
integrations[getFieldKey(type.id, "chatId")];
break;
case "webhook":
notificationObject.config.webhookUrl =
integrations[getFieldKey(type.id, "url")];
break;
}
filteredNotifications.push(notificationObject);
}
});
// Update monitor with new notifications
setMonitor((prev) => ({
...prev,
notifications: filteredNotifications,
}));
onClose();
};
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="md"
sx={{
"& .MuiDialog-paper": {
width: `calc(80% - ${theme.spacing(40)})`,
maxWidth: `${theme.breakpoints.values.md - 70}px`,
},
}}
>
<DialogContent>
<Stack
direction="row"
sx={{
height: `calc(30vh - ${theme.spacing(20)})`,
}}
>
{/* Left sidebar with tabs */}
<Box
sx={{
borderRight: 1,
borderColor: theme.palette.primary.lowContrast,
width: "30%",
maxWidth: theme.spacing(120),
pr: theme.spacing(10),
}}
>
<Typography variant="h2">
{t("notifications.addOrEditNotifications")}
</Typography>
<Tabs
orientation="vertical"
variant="scrollable"
value={tabValue}
onChange={handleChangeTab}
aria-label="Notification tabs"
>
{activeNotificationTypes.map((type) => (
<Tab
key={type.id}
label={type.label}
orientation="vertical"
disableRipple
/>
))}
</Tabs>
</Box>
{/* Right side content */}
<Box
sx={{
flex: 1,
pl: theme.spacing(7.5),
overflowY: "auto",
}}
>
{activeNotificationTypes.map((type, index) => (
<TabPanel
key={type.id}
value={tabValue}
index={index}
>
<TabComponent
type={type}
integrations={integrations}
handleIntegrationChange={handleIntegrationChange}
handleInputChange={handleInputChange}
handleTestNotification={handleTestNotification}
isLoading={loading}
/>
</TabPanel>
))}
</Box>
</Stack>
</DialogContent>
<DialogActions
sx={{
p: theme.spacing(4),
display: "flex",
justifyContent: "flex-end",
mb: theme.spacing(5),
mr: theme.spacing(5),
}}
>
<Button
variant="contained"
color="accent"
onClick={handleSave}
loading={loading}
sx={{
width: "auto",
minWidth: theme.spacing(60),
px: theme.spacing(8),
}}
>
{t("commonSave")}
</Button>
</DialogActions>
</Dialog>
);
};
NotificationIntegrationModal.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
monitor: PropTypes.object.isRequired,
setMonitor: PropTypes.func.isRequired,
notificationTypes: PropTypes.array,
};
export default NotificationIntegrationModal;
@@ -1,115 +0,0 @@
import React from "react";
import { Typography, Box, Button, CircularProgress } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useTheme } from "@emotion/react";
import TextInput from "../../Inputs/TextInput/index.jsx";
import Checkbox from "../../Inputs/Checkbox/index.jsx";
const TabComponent = ({
type,
integrations,
handleIntegrationChange,
handleInputChange,
handleTestNotification,
isLoading,
}) => {
const theme = useTheme();
const { t } = useTranslation();
// Helper to get the field state key (e.g., slackWebhook, telegramToken)
const getFieldKey = (typeId, fieldId) => {
return `${typeId}${fieldId.charAt(0).toUpperCase() + fieldId.slice(1)}`;
};
// Check if all fields have values to enable test button
const areAllFieldsFilled = () => {
return type.fields.every((field) => {
const fieldKey = getFieldKey(type.id, field.id);
return integrations[fieldKey];
});
};
return (
<>
<Typography
variant="subtitle1"
component="h4"
sx={{
fontWeight: "bold",
color: theme.palette.primary.contrastTextSecondary,
}}
>
{type.label}
</Typography>
<Typography
sx={{
mt: theme.spacing(0.5),
mb: theme.spacing(1.5),
color: theme.palette.primary.contrastTextTertiary,
}}
>
{type.description}
</Typography>
<Box sx={{ pl: theme.spacing(1.5) }}>
<Checkbox
id={`enable-${type.id}`}
label={t("notifications.enableNotifications", { platform: type.label })}
isChecked={integrations[type.id]}
onChange={(e) => handleIntegrationChange(type.id, e.target.checked)}
disabled={isLoading}
/>
</Box>
{type.fields.map((field) => {
const fieldKey = getFieldKey(type.id, field.id);
return (
<Box
key={field.id}
sx={{ mt: theme.spacing(1) }}
>
<Typography
sx={{
mb: theme.spacing(2),
fontWeight: "bold",
color: theme.palette.primary.contrastTextSecondary,
}}
>
{field.label}
</Typography>
<TextInput
id={`${type.id}-${field.id}`}
type={field.type}
placeholder={field.placeholder}
value={integrations[fieldKey]}
onChange={(e) => handleInputChange(fieldKey, e.target.value)}
disabled={!integrations[type.id] || isLoading}
/>
</Box>
);
})}
<Box sx={{ mt: theme.spacing(1) }}>
<Button
variant="text"
color="info"
onClick={() => handleTestNotification(type.id)}
disabled={!integrations[type.id] || !areAllFieldsFilled() || isLoading}
>
{isLoading ? (
<CircularProgress
size={theme.spacing(8)}
sx={{ mr: theme.spacing(1), color: theme.palette.accent.main }}
/>
) : null}
{t("notifications.testNotification")}
</Button>
</Box>
</>
);
};
export default TabComponent;
@@ -1,41 +0,0 @@
import React from "react";
import PropTypes from "prop-types";
import { Box } from "@mui/material";
import { useTheme } from "@emotion/react";
/**
* TabPanel component that displays content for the selected tab.
*
* @component
* @param {Object} props - The component props.
* @param {React.ReactNode} props.children - The content to be displayed when this tab panel is selected.
* @param {number} props.value - The currently selected tab value.
* @param {number} props.index - The index of this specific tab panel.
* @param {Object} props.other - Any additional props to be spread to the root element.
* @returns {React.ReactElement|null} The rendered tab panel or null if not selected.
*/
function TabPanel({ children, value, index, ...other }) {
const theme = useTheme();
return (
<div
role="tabpanel"
hidden={value !== index}
id={`notification-tabpanel-${index}`}
aria-labelledby={`notification-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ pt: theme.spacing(3) }}>{children}</Box>}
</div>
);
}
TabPanel.propTypes = {
children: PropTypes.node,
index: PropTypes.number.isRequired,
value: PropTypes.number.isRequired,
};
export default TabPanel;
@@ -1,129 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { networkService } from "../../../../Utils/NetworkService.js";
import { createToast } from "../../../../Utils/toastUtils.jsx";
// Define constants for notification types to avoid magic values
const NOTIFICATION_TYPES = {
SLACK: "slack",
DISCORD: "discord",
TELEGRAM: "telegram",
WEBHOOK: "webhook",
};
// Define constants for field IDs
const FIELD_IDS = {
WEBHOOK: "webhook",
TOKEN: "token",
CHAT_ID: "chatId",
URL: "url",
};
/**
* Custom hook for notification-related operations
*/
const useNotifications = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(undefined);
const { t } = useTranslation();
/**
* Send a test notification
* @param {string} type - The notification type (slack, discord, telegram, webhook)
* @param {object} config - Configuration object with necessary params
*/
const sendTestNotification = async (type, config) => {
setLoading(true);
setError(undefined);
// Validation based on notification type
let payload = { platform: type };
let isValid = true;
let errorMessage = "";
switch (type) {
case NOTIFICATION_TYPES.SLACK:
payload.webhookUrl = config.webhook;
if (typeof payload.webhookUrl === "undefined" || payload.webhookUrl === "") {
isValid = false;
errorMessage = t("notifications.slack.webhookRequired");
}
break;
case NOTIFICATION_TYPES.DISCORD:
payload.webhookUrl = config.webhook;
if (typeof payload.webhookUrl === "undefined" || payload.webhookUrl === "") {
isValid = false;
errorMessage = t("notifications.discord.webhookRequired");
}
break;
case NOTIFICATION_TYPES.TELEGRAM:
payload.botToken = config.token;
payload.chatId = config.chatId;
if (
typeof payload.botToken === "undefined" ||
payload.botToken === "" ||
typeof payload.chatId === "undefined" ||
payload.chatId === ""
) {
isValid = false;
errorMessage = t("notifications.telegram.fieldsRequired");
}
break;
case NOTIFICATION_TYPES.WEBHOOK:
payload.webhookUrl = config.url;
payload.platform = NOTIFICATION_TYPES.SLACK;
if (typeof payload.webhookUrl === "undefined" || payload.webhookUrl === "") {
isValid = false;
errorMessage = t("notifications.webhook.urlRequired");
}
break;
default:
isValid = false;
errorMessage = t("notifications.unsupportedType");
}
// If validation fails, show error and return
if (isValid === false) {
createToast({
body: errorMessage,
variant: "error",
});
setLoading(false);
return;
}
try {
const response = await networkService.testNotification({
platform: type,
payload: payload,
});
if (response.data.success === true) {
createToast({
body: t("notifications.testSuccess"),
variant: "info",
});
} else {
throw new Error(response.data.msg || t("notifications.testFailed"));
}
} catch (error) {
const errorMsg =
error.response?.data?.msg || error.message || t("notifications.networkError");
createToast({
body: `${t("notifications.testFailed")}: ${errorMsg}`,
variant: "error",
});
setError(errorMsg);
} finally {
setLoading(false);
}
};
return [loading, error, sendTestNotification];
};
export default useNotifications;
+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,76 @@
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>
<Typography>
{t("common.labels.responseTime")}: {check.originalResponseTime.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,82 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { ToggleButtonGroup, ToggleButton } from "@/Components/v2/inputs";
import { useTheme } from "@mui/material/styles";
import CircularProgress from "@mui/material/CircularProgress";
import { useTranslation } from "react-i18next";
import { useMediaQuery } from "@mui/material";
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 { t } = useTranslation();
const isSmallScreen = useMediaQuery(theme.breakpoints.down("md"));
const handleChange = (
_event: React.MouseEvent<HTMLElement>,
newValue: string | null
) => {
if (newValue !== null) {
setDateRange(newValue);
}
};
let timeFramePicker = null;
if (hasDateRange) {
timeFramePicker = (
<ToggleButtonGroup
orientation={isSmallScreen ? "vertical" : "horizontal"}
value={dateRange}
exclusive
onChange={handleChange}
size="small"
fullWidth={isSmallScreen}
>
<ToggleButton value="recent">
{t("components.headerTimeRange.labels.recent")}
</ToggleButton>
<ToggleButton value="day">
{t("components.headerTimeRange.labels.day")}
</ToggleButton>
<ToggleButton value="week">
{t("components.headerTimeRange.labels.week")}
</ToggleButton>
<ToggleButton value="month">
{t("components.headerTimeRange.labels.month")}
</ToggleButton>
</ToggleButtonGroup>
);
}
return (
<Stack
direction={{ xs: "column", md: "row" }}
justifyContent="flex-end"
alignItems="center"
gap={theme.spacing(4)}
>
<Box
width={16}
height={16}
>
{isLoading && <CircularProgress size={16} />}
</Box>
<Typography variant="body2">
{t(`components.headerTimeRange.description.${dateRange}`)}
</Typography>
{timeFramePicker}
</Stack>
);
};
+5
View File
@@ -0,0 +1,5 @@
export * from "../common/charts/HistogramResponseTime";
export * from "../common/charts/HeatmapResponseTime";
export * from "../common/charts/HeatmapResponseTimeTooltip";
export * from "../common/controls/HeaderCreate";
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 breadcrumbOverride={breadcrumbOverride} />
{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,140 @@
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";
import { useTranslation } from "react-i18next";
import type { ReactNode } from "react";
const isId = (segment: string): boolean => {
return segment.length === 24 || /^[a-f0-9-]{36}$/.test(segment);
};
const actionSegments = ["create", "configure"];
const BreadcrumbWrapper = ({ children }: { children: ReactNode }) => {
const theme = useTheme();
return (
<MuiBreadcrumbs
separator={
<ChevronRight
size={16}
strokeWidth={1.5}
/>
}
sx={{
fontSize: "14px",
marginBottom: theme.spacing(6),
"& .MuiBreadcrumbs-separator": {
color: theme.palette.text.secondary,
},
}}
>
{children}
</MuiBreadcrumbs>
);
};
export const Breadcrumb = ({
breadcrumbOverride,
}: {
breadcrumbOverride?: string[] | undefined;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const location = useLocation();
// If override is an empty array, hide entirely
if (breadcrumbOverride !== undefined && breadcrumbOverride.length === 0) {
return null;
}
// If override has items, render them directly
if (breadcrumbOverride !== undefined && breadcrumbOverride.length > 0) {
return (
<BreadcrumbWrapper>
{breadcrumbOverride.map((item, index) => {
const isLast = index === breadcrumbOverride.length - 1;
return (
<Typography
key={index}
sx={{
fontSize: "14px",
fontWeight: isLast ? 600 : 400,
color: isLast ? theme.palette.primary.main : theme.palette.text.secondary,
}}
>
{item}
</Typography>
);
})}
</BreadcrumbWrapper>
);
}
// Default behavior: use location pathname
const segments = location.pathname.split("/").filter((x) => x);
if (segments.length === 0) {
return null;
}
// Build simplified breadcrumb: "uptime" or "uptime / details" or "uptime / create"
const basePage = segments[0] || t("common.breadcrumbs.home");
const secondSegment = segments[1];
const isActionPage = secondSegment && actionSegments.includes(secondSegment);
const isDetailsPage = secondSegment && isId(secondSegment);
const hasSubPage = isActionPage || isDetailsPage;
const getSubPageLabel = (): string => {
if (isActionPage) {
return secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
}
return t("common.breadcrumbs.details");
};
return (
<BreadcrumbWrapper>
{hasSubPage ? (
<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>
)}
{hasSubPage && (
<Typography
sx={{
fontSize: "14px",
fontWeight: 600,
color: theme.palette.primary.main,
}}
>
{getSubPageLabel()}
</Typography>
)}
</BreadcrumbWrapper>
);
};
@@ -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,106 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { useMemo, useState, useEffect } from "react";
import { getInfraGaugeColor } from "@/Utils/MonitorUtils";
const MINIMUM_VALUE = 0;
const MAXIMUM_VALUE = 100;
export const Gauge = ({
isLoading = false,
progress = 0,
radius = 70,
strokeWidth = 15,
precision = 1,
unit = "%",
}: {
isLoading?: boolean;
progress?: number;
radius?: number;
strokeWidth?: number;
precision?: number;
unit?: string;
}) => {
const theme = useTheme();
const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE));
// Calculate the length of the stroke for the circle
const { circumference, totalSize, strokeLength } = useMemo(
() => ({
circumference: 2 * Math.PI * radius,
totalSize: radius * 2 + strokeWidth * 2,
strokeLength: (progress / 100) * (2 * Math.PI * radius),
}),
[radius, strokeWidth, progress]
);
const [offset, setOffset] = useState(circumference);
useEffect(() => {
setOffset(circumference);
const timer = setTimeout(() => {
setOffset(circumference - strokeLength);
}, 100);
return () => clearTimeout(timer);
}, [progress, circumference, strokeLength]);
const fillColor = getInfraGaugeColor(progressWithinRange, theme);
if (isLoading) {
return;
}
return (
<Box
display={"inline-block"}
position={"relative"}
width={radius}
height={radius}
bgcolor={theme.palette.background.paper}
borderRadius={"50%"}
>
<svg
viewBox={`0 0 ${totalSize} ${totalSize}`}
width={radius}
height={radius}
>
<circle
stroke={theme.palette.secondary.main}
strokeWidth={strokeWidth}
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
/>
<circle
stroke={fillColor}
strokeWidth={strokeWidth}
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={offset}
fill="none"
cx={totalSize / 2}
cy={totalSize / 2}
r={radius}
style={{
transform: "rotate(-90deg)",
transformOrigin: "center",
transition: "stroke-dashoffset 1.5s ease-in-out",
}}
/>
</svg>
<Typography
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{`${progressWithinRange.toFixed(precision)}${unit}`}
</Typography>
</Box>
);
};
@@ -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,78 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
export const SplitBox = ({
left,
right,
}: {
left: React.ReactNode;
right: React.ReactNode;
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down("md"));
return (
<Stack
direction={isSmall ? "column" : "row"}
bgcolor={theme.palette.background.paper}
border={1}
borderColor={theme.palette.divider}
borderRadius={theme.spacing(2)}
>
<Box
padding={theme.spacing(15)}
borderRight={isSmall ? 0 : 1}
borderBottom={isSmall ? 1 : 0}
borderColor={theme.palette.divider}
flex={0.7}
sx={{
background:
theme.palette.mode === "dark"
? "linear-gradient(135deg, rgba(255, 255, 255, 0.01) 0%, rgba(255, 255, 255, 0.02) 100%)"
: "linear-gradient(135deg, rgba(0, 0, 0, 0.01) 0%, rgba(0, 0, 0, 0.02) 100%)",
}}
>
{left}
</Box>
<Box
flex={1}
padding={theme.spacing(15)}
>
{right}
</Box>
</Stack>
);
};
export const ConfigBox = ({
title,
subtitle,
leftContent,
rightContent,
}: {
title: string;
subtitle: string;
leftContent?: React.ReactNode;
rightContent: React.ReactNode;
}) => {
const theme = useTheme();
return (
<SplitBox
left={
<Stack spacing={theme.spacing(4)}>
<Typography
textTransform={"capitalize"}
component="h2"
variant="h2"
>
{title}
</Typography>
<Typography component="p">{subtitle}</Typography>
{leftContent}
</Stack>
}
right={rightContent}
/>
);
};
@@ -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,152 @@
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 type { SxProps } from "@mui/material";
import { useTheme } from "@mui/material/styles";
type StatusBoxProps = React.PropsWithChildren<{
children: React.ReactNode;
sx?: SxProps;
}>;
export const BGBox = ({ children, sx }: StatusBoxProps) => {
const theme = useTheme();
return (
<BaseBox
sx={{
overflow: "hidden",
position: "relative",
flex: 1,
padding: theme.spacing(4),
...sx,
}}
>
<Box
position="absolute"
top="-10%"
left="5%"
>
<Background />
</Box>
{children}
</BaseBox>
);
};
const StatusBox = ({
label,
n,
color,
sx,
}: {
label: string;
n: number;
color: string | undefined;
sx?: SxProps;
}) => {
const theme = useTheme();
return (
<BGBox sx={sx}>
<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 TotalChecksBox = ({ n }: { n: number }) => {
const theme = useTheme();
const { t } = useTranslation();
return (
<StatusBox
label={t("pages.common.monitors.status.total")}
n={n}
color={theme.palette.primary.light}
/>
);
};
export const DownChecksBox = ({ 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 UpChecksBox = ({ 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 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"
/>
<Typography>{transformedText}</Typography>
</BaseBox>
);
};
@@ -0,0 +1,577 @@
import Stack from "@mui/material/Stack";
import Grid2 from "@mui/material/Grid";
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,64 @@
import MuiTabs from "@mui/material/Tabs";
import type { TabsProps } from "@mui/material/Tabs";
import { useTheme } from "@mui/material/styles";
interface CustomTabsProps extends TabsProps {}
export const Tabs = (props: CustomTabsProps) => {
const theme = useTheme();
return (
<MuiTabs
sx={{
minHeight: 34,
borderBottom: `1px solid ${theme.palette.divider}`,
"& .MuiTabs-indicator": {
backgroundColor: theme.palette.primary.main,
height: 2,
bottom: 0,
},
"& .MuiTabs-flexContainer": {
gap: theme.spacing(16),
},
}}
{...props}
>
{props.children}
</MuiTabs>
);
};
import MuiTab from "@mui/material/Tab";
import type { TabProps } from "@mui/material/Tab";
interface CustomTabProps extends TabProps {}
export const Tab = (props: CustomTabProps) => {
const theme = useTheme();
return (
<MuiTab
disableRipple
iconPosition="start"
sx={{
textTransform: "none",
fontSize: 14,
fontWeight: 500,
minHeight: 34,
padding: theme.spacing(1, 0),
paddingBottom: 0,
minWidth: "auto",
alignItems: "flex-start",
color: theme.palette.text.secondary,
"&.Mui-selected": {
color: theme.palette.primary.main,
fontWeight: 600,
},
"&:hover": {
color: theme.palette.text.secondary,
},
"& .MuiTab-iconWrapper": {
marginRight: theme.spacing(2),
marginBottom: 0,
},
}}
{...props}
/>
);
};
@@ -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,20 @@
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";
export * from "./Gauge";
export * from "./Tabs";
export * from "./SplitBox";
@@ -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,48 @@
import { MuiColorInput } from "mui-color-input";
import type { MuiColorInputProps } from "mui-color-input";
import { typographyLevels } from "@/Utils/Theme/v2Palette";
import { useTheme } from "@mui/material";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "@/Components/v2/inputs/FieldLabel";
interface ColorPickerProps extends MuiColorInputProps {
fieldLabel?: string;
required?: boolean;
}
export const ColorInput = ({ fieldLabel, required, ...props }: ColorPickerProps) => {
const theme = useTheme();
const input = (
<MuiColorInput
{...props}
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;
};
@@ -0,0 +1,73 @@
import Dialog from "@mui/material/Dialog";
import type { DialogProps } 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";
import type { ReactNode } from "react";
export const DialogInput = ({
open,
title,
content,
onConfirm,
onCancel,
confirmColor = "primary",
cancelColor = "error",
loading = false,
cancelText,
confirmText,
children,
maxWidth,
fullWidth = false,
}: {
open: boolean;
title?: string;
content?: string;
onConfirm?(item: any): any;
onCancel?(item: any): any;
confirmColor?: "error" | "primary";
cancelColor?: "error" | "primary";
loading?: boolean;
cancelText?: string;
confirmText?: string;
children?: ReactNode;
maxWidth?: DialogProps["maxWidth"];
fullWidth?: boolean;
}) => {
const { t } = useTranslation();
return (
<Dialog
open={open}
maxWidth={maxWidth}
fullWidth={fullWidth}
>
{title && <DialogTitle sx={{ fontSize: typographyLevels.l }}>{title}</DialogTitle>}
<DialogContent>
{content && <DialogContentText>{content}</DialogContentText>}
{children}
</DialogContent>
<DialogActions>
<Button
loading={loading}
variant="contained"
color={cancelColor}
onClick={onCancel}
>
{cancelText ?? t("common.buttons.cancel")}
</Button>
<Button
loading={loading}
variant="contained"
color={confirmColor}
onClick={onConfirm}
>
{confirmText ?? 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>
);
};
@@ -0,0 +1,164 @@
import { Box, Stack, Typography, IconButton } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { Upload, X } from "lucide-react";
import { useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
interface FileObject {
src: string;
name: string;
file: File;
}
interface ImageUploadProps {
src?: string;
onChange?: (file: FileObject | undefined) => void;
maxSize?: number;
accept?: string[];
error?: string;
}
export const ImageUpload = ({
src,
onChange,
maxSize = 3 * 1024 * 1024,
accept = ["jpg", "jpeg", "png"],
error,
}: ImageUploadProps) => {
const theme = useTheme();
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [preview, setPreview] = useState<FileObject | null>(null);
const handleFile = useCallback(
(file: File | undefined) => {
if (!file) return;
const isValidType = accept.some((type) => file.type.includes(type));
const isValidSize = file.size <= maxSize;
if (!isValidType) {
setLocalError(t("common.errors.invalidFileFormat"));
return;
}
if (!isValidSize) {
setLocalError(t("common.errors.invalidFileSize"));
return;
}
setLocalError(null);
const fileObj: FileObject = {
src: URL.createObjectURL(file),
name: file.name,
file,
};
setPreview(fileObj);
onChange?.(fileObj);
},
[maxSize, accept, onChange, t]
);
const handleClear = () => {
setPreview(null);
setLocalError(null);
onChange?.(undefined);
if (inputRef.current) inputRef.current.value = "";
};
const displaySrc = src || preview?.src;
const displayError = localError || error;
return (
<Stack sx={{ width: "100%", maxWidth: 500 }}>
{displaySrc ? (
<Stack
alignItems="center"
gap={1}
>
<Box
component="img"
src={displaySrc}
alt="Preview"
sx={{ maxWidth: 250, maxHeight: 250, objectFit: "contain", borderRadius: 1 }}
/>
<IconButton
size="small"
onClick={handleClear}
sx={{ color: theme.palette.error.main }}
>
<X size={18} />
</IconButton>
</Stack>
) : (
<Stack
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
handleFile(e.dataTransfer.files?.[0]);
}}
alignItems="center"
justifyContent="center"
gap={1}
sx={{
position: "relative",
width: "100%",
minHeight: 150,
border: "2px dashed",
borderRadius: 1,
borderColor: isDragging ? theme.palette.primary.main : theme.palette.divider,
backgroundColor: isDragging ? theme.palette.action.hover : "transparent",
transition: "0.2s",
cursor: "pointer",
"&:hover": {
borderColor: theme.palette.primary.main,
backgroundColor: theme.palette.action.hover,
},
}}
>
<input
ref={inputRef}
type="file"
accept={accept.map((ext) => `.${ext}`).join(",")}
onChange={(e) => handleFile(e.target.files?.[0])}
style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}
/>
<Upload size={24} />
<Typography
variant="body2"
color="text.secondary"
>
<Typography
component="span"
variant="body2"
color="primary"
fontWeight={500}
>
{t("common.imageUpload.clickToUpload")}
</Typography>{" "}
{t("common.imageUpload.orDragAndDrop")}
</Typography>
<Typography
variant="caption"
color="text.disabled"
>
{accept.join(", ").toUpperCase()} • {t("common.imageUpload.maxSize")}{" "}
{Math.round(maxSize / 1024 / 1024)}MB
</Typography>
</Stack>
)}
{displayError && (
<Typography
variant="caption"
color="error"
sx={{ mt: 1 }}
>
{displayError}
</Typography>
)}
</Stack>
);
};
+88
View File
@@ -0,0 +1,88 @@
import Radio from "@mui/material/Radio";
import type { RadioProps } from "@mui/material/Radio";
import { useTheme } from "@mui/material/styles";
import { Circle, CircleDot } from "lucide-react";
import FormControlLabel from "@mui/material/FormControlLabel";
import Typography from "@mui/material/Typography";
interface RadioInputProps extends RadioProps {}
export const RadioInput = ({ ...props }: RadioInputProps) => {
const theme = useTheme();
return (
<Radio
{...props}
icon={
<Circle
size={16}
strokeWidth={1.5}
/>
}
checkedIcon={
<CircleDot
size={14}
strokeWidth={1.5}
/>
}
sx={{
padding: 0,
mt: theme.spacing(0.5),
color: theme.palette.text.secondary,
"&.Mui-checked": {
color: theme.palette.primary.main,
"& svg circle": {
fill: theme.palette.primary.main,
},
},
"& .MuiSvgIcon-root": {
fontSize: 16,
},
"& svg": {
stroke: "currentColor",
},
"& svg path, & svg line, & svg polyline, & svg rect, & svg circle": {
stroke: "currentColor",
fill: "none",
},
}}
/>
);
};
export const RadioWithDescription = ({
label,
description,
...props
}: RadioInputProps & { label: string; description: string }) => {
const theme = useTheme();
return (
<FormControlLabel
control={<RadioInput {...props} />}
label={
<>
<Typography component="p">{label}</Typography>
<Typography
component="h6"
color={theme.palette.text.secondary}
>
{description}
</Typography>
</>
}
sx={{
alignItems: "flex-start",
p: theme.spacing(2.5),
m: theme.spacing(-2.5),
borderRadius: theme.shape.borderRadius,
"&:hover": {
backgroundColor: theme.palette.background.paper,
},
"& .MuiButtonBase-root": {
p: 0,
mr: theme.spacing(6),
},
}}
/>
);
};
+104
View File
@@ -0,0 +1,104 @@
import Select from "@mui/material/Select";
import type { SelectProps } from "@mui/material/Select";
import React, { forwardRef } from "react";
import { useTheme } from "@mui/material/styles";
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;
}
const SelectInputInner = <T,>(
{ fieldLabel, required, placeholder, placeholderColor, ...props }: SelectInputProps<T>,
ref: React.ForwardedRef<HTMLDivElement>
) => {
const theme = useTheme();
const emptyPlaceholderColor = placeholderColor || theme.palette.text.disabled;
const renderValue = (selected: unknown) => {
const isMultiple = Boolean((props as { multiple?: boolean }).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>;
}
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}
ref={ref}
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;
};
export const SelectInput = forwardRef(SelectInputInner) as <T>(
props: SelectInputProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
) => React.ReactElement;
@@ -0,0 +1,89 @@
import Typography from "@mui/material/Typography";
import { forwardRef } from "react";
import Slider from "@mui/material/Slider";
import type { SliderProps } from "@mui/material/Slider";
import { useTheme } from "@mui/material/styles";
import Stack from "@mui/material/Stack";
import { FieldLabel } from "./FieldLabel";
import Box from "@mui/material/Box";
import type { ResponsiveStyleValue } from "@mui/system";
interface SliderInputProps extends SliderProps {
sx?: SliderProps["sx"];
showValue?: boolean;
}
export const SliderInput = forwardRef<HTMLSpanElement, SliderInputProps>(
({ sx, showValue = false, ...props }, ref) => {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
return (
<Stack
gap={theme.spacing(8)}
direction={"row"}
alignItems={"center"}
>
{showValue && <Typography>{props.value}</Typography>}
<Slider
{...props}
ref={ref}
sx={[
{
"& .MuiSlider-track": {
backgroundColor: theme.palette.primary.main,
border: "none",
},
"& .MuiSlider-rail": {
backgroundColor: theme.palette.grey[300],
opacity: 1,
},
"& .MuiSlider-thumb": {
backgroundColor: "#fff",
"&:hover, &.Mui-focusVisible": {
boxShadow: `0 0 0 8px ${theme.palette.primary.main}20`,
},
},
"& .MuiSlider-valueLabel": {
backgroundColor: theme.palette.primary.main,
},
},
...additionalSx,
]}
/>
</Stack>
);
}
);
interface SliderWithLabelProps extends SliderProps {
fieldLabel?: string;
required?: boolean;
showValue?: boolean;
sliderMaxWidth?: ResponsiveStyleValue<number | string>;
}
export const SliderWithLabel = forwardRef<HTMLSpanElement, SliderWithLabelProps>(
(
{ fieldLabel, required, showValue = true, value, sliderMaxWidth = "100%", ...props },
ref
) => {
const theme = useTheme();
const labelText = fieldLabel;
return (
<Stack spacing={theme.spacing(2)}>
{fieldLabel && <FieldLabel required={required}>{labelText}</FieldLabel>}
<Box maxWidth={sliderMaxWidth}>
<SliderInput
{...props}
showValue={showValue}
value={value}
ref={ref}
/>
</Box>
</Stack>
);
}
);
@@ -0,0 +1,47 @@
import { forwardRef } from "react";
import Switch from "@mui/material/Switch";
import type { SwitchProps } from "@mui/material/Switch";
import { useTheme } from "@mui/material/styles";
interface SwitchComponentProps extends SwitchProps {
dualOption?: boolean;
}
export const SwitchComponent = forwardRef<HTMLInputElement, SwitchComponentProps>(
function SwitchComponent({ sx, dualOption = false, ...props }, ref) {
const theme = useTheme();
const additionalSx = Array.isArray(sx) ? sx : sx ? [sx] : [];
return (
<Switch
{...props}
slotProps={{
input: {
ref: ref,
},
}}
sx={[
{
"& .MuiSwitch-switchBase": {
"&.Mui-checked": {
color: "#E0E0E0",
"& + .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
border: 0,
},
},
},
...(dualOption && {
"& .MuiSwitch-track": {
backgroundColor: theme.palette.primary.main,
opacity: 1,
},
}),
},
...additionalSx,
]}
/>
);
}
);
@@ -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,
}}
/>
);
};
+16
View File
@@ -0,0 +1,16 @@
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";
export * from "./Radio";
export * from "./Switch";
export * from "./Slider";
export * from "./ImageUpload";
export * from "./ColorPicker";
@@ -0,0 +1,96 @@
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", "game"];
const statuses = ["up", "down"];
const states = ["active", "paused"];
export const ControlsFilter = ({
showTypes = true,
selectedTypes,
setSelectedTypes,
selectedStatus,
setSelectedStatus,
selectedState,
setSelectedState,
onClearFilters,
}: {
showTypes?: boolean;
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) > 0 || selectedStatus !== "" || selectedState !== "";
return (
<Stack
direction={isSmall ? "column" : "row"}
gap={theme.spacing(2)}
>
{showTypes && setSelectedTypes && (
<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,176 @@
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, Trash } 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 BaseHeaderProps {
monitor: Monitor;
}
const BaseHeader = ({ monitor, children }: React.PropsWithChildren<BaseHeaderProps>) => {
const theme = useTheme();
return (
<Stack
spacing={{ xs: theme.spacing(8), md: 0 }}
direction={{ xs: "column", md: "row" }}
alignItems={"center"}
justifyContent={"space-between"}
>
<MonitorStatus monitor={monitor} />
{children}
</Stack>
);
};
interface HeaderMonitorControlsProps {
path: string;
monitor?: Monitor | null;
isAdmin: boolean;
refetch: Function;
}
export const HeaderMonitorControls = ({
path,
monitor,
isAdmin,
refetch,
}: HeaderMonitorControlsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate();
const {
post,
loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
if (!monitor) {
return null;
}
return (
<BaseHeader 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("common.buttons.pause") : t("common.buttons.resume")}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="secondary"
startIcon={<Icon icon={Settings} />}
onClick={() => navigate(`/${path}/configure/${monitor.id}`)}
>
{t("common.buttons.configure")}
</Button>
)}
</Stack>
</BaseHeader>
);
};
interface HeaderDeleteControlsProps {
monitor?: Monitor | null;
isAdmin: boolean;
refetch: Function;
onDelete?: () => void;
}
export const HeaderDeleteControls = ({
monitor,
isAdmin,
refetch,
onDelete,
}: HeaderDeleteControlsProps) => {
const { t } = useTranslation();
const theme = useTheme();
const {
post,
loading: isPosting,
// error: postError,
} = usePost<any, Monitor>();
if (!monitor) {
return null;
}
return (
<BaseHeader monitor={monitor}>
<Stack
width={{ xs: "100%", md: "auto" }}
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(2)}
>
{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("common.buttons.pause") : t("common.buttons.resume")}
</Button>
)}
{isAdmin && (
<Button
variant="contained"
color="error"
startIcon={<Icon icon={Trash} />}
onClick={() => {
onDelete?.();
}}
>
{t("common.buttons.delete")}
</Button>
)}
</Stack>
</BaseHeader>
);
};
@@ -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>
);
};
Loaded 100 of 485 files, more files were not shown because too many files have changed in this diff. Show more