322 Commits

Author SHA1 Message Date
Tola Leng 72984f5da4 Merge pull request #79 from operacle/develop
feat: Add create instance monitoring agent and Add Uptime Heatmap to service detail page
2025-07-22 16:08:14 +07:00
Tola Leng 2d2122d2e5 Improved the Server feature History Data 2025-07-21 22:18:27 +07:00
Tola Leng d17a70dfa8 Updated server schema for migration 2025-07-21 16:38:57 +07:00
Tola Leng 3f3ce9122d Adjust the size of the server title header. 2025-07-21 16:35:28 +07:00
Tola Leng 60960cb3e0 Refactor: Update server progress bar style
- Change the server progress bar style in the Instance Monitoring and server table.
2025-07-21 15:51:54 +07:00
Tola Leng 50c742d82d Added new favicon logo file 2025-07-21 15:26:33 +07:00
Tola Leng d0ecaaaf8f Refactor: Improve chart color and style
- Fixed chart colors to be fixed with random colors.
- Added another chart style.
2025-07-21 01:29:24 +07:00
Tola Leng ff19d46391 Updated favicon logo file 2025-07-21 00:57:47 +07:00
Tola Leng e5afe276ee Feat: Add Heatmap button to service detail page
- Adds a "Heatmap" button to the service detail page, displaying a monthly health heatmap of the service, with statuses color-coded as green, red, and warning (yellow/orange).
2025-07-21 00:57:04 +07:00
Tola Leng c098ecb6bf Fix: Apply server table background to SSL table
- Apply the same background color and styling from the server table to the SSL table for visual consistency.
2025-07-19 15:37:00 +07:00
Tola Leng dc586dc15f Fix: Server Historical Performance chart improvement
Fix: Improve sidebar collapse/expand performance
2025-07-19 15:23:28 +07:00
Tola Leng d40d5898c0 Fix SSL frontend notifications
- The frontend no longer sends notifications. The backend is now responsible for sending SSL notifications.
2025-07-19 15:14:04 +07:00
Tola Leng 63d63a908a Refactor: Make instance monitoring page responsive
- Make the Instance Monitoring page and Server Table List fully responsive across the whole page.
2025-07-18 22:01:26 +07:00
Tola Leng 868832f5ba feat: Add create instance monitoring button
- Adds a button to the Instance Monitoring dashboard to create server monitoring instances
- Include the one-click install command in the Instance Monitoring form
- Display OS list for select with images and names.
2025-07-18 21:57:56 +07:00
Tola Leng c1c94115e0 Feat: Added Operating System Logo for Server Agent 2025-07-18 21:52:16 +07:00
Tola Leng e388912cee Feat: added bash script for one click server agent montioring 2025-07-18 21:51:25 +07:00
Tola Leng 6c227f496e Remove individual toast notifications
- During SSL certificate refresh and display a single summary notification after the "Refresh All" action.
2025-07-18 14:03:32 +07:00
Tola Leng 0cad374616 Fix server delete function and Implement pause/resume functionality
- Implement the server delete functionality in the server table and dropdown menu.
- Implement pause and resume functionality for server monitoring in the server table dropdown menu.
2025-07-17 23:03:40 +07:00
Tola Leng 6906a65952 Fix: Improve uptime monitoring dashboard performance
- Reduce refetch intervals, increase stale times, and optimize retry behavior for uptime service and server metrics.
2025-07-17 17:26:17 +07:00
Tola Leng b9cdff37d5 Merge pull request #73 from lyker189/develop
Updated Incident Management Overview Cards linear-gradient
2025-07-17 17:15:59 +07:00
aa123456 88877a9fc0 Updated Incident Management Overview Cards linear-gradient 2025-07-17 16:36:00 +07:00
Tola Leng 2de3a17967 Added issue template icon 2025-07-15 22:32:02 +07:00
Tola Leng b4a12a46df Added config file blank issues 2025-07-15 22:27:20 +07:00
Tola Leng cc132a66d9 Merge pull request #70 from operacle/develop
- Improve service listing to display all records correctly
- Fetch all data and handle pagination for better UX
- Improve SSL Certs listing to display all records correctly
- Added ISSUE TEMPLATE 
- Added CODE OF CONDUCT
2025-07-15 22:18:48 +07:00
Tola Leng e4284afb09 Remove subtitle under About System page 2025-07-15 22:13:46 +07:00
Tola Leng 664e2c8ecf Added ISSUE_TEMPLATE 2025-07-15 22:13:07 +07:00
Tola Leng 7c08c8bc66 Added CODE OF CONDUCT 2025-07-15 22:12:21 +07:00
Tola Leng fd7035965f Disabled console debug log Statements from the production build output 2025-07-15 17:36:12 +07:00
Tola Leng debf35703b Update email test and INCIDENT template 2025-07-15 17:34:04 +07:00
Tola Leng 213446422e Fix: Ensure all SSL Certs records are checked
- Addressing the issue where only a limited number (30) were being processed due to pagination. The fixed now fetches all records to ensure comprehensive SSL monitoring.
2025-07-15 17:16:09 +07:00
Tola Leng 79beb20b65 Fix: Ensure all uptime service records are checked
- Addressing the issue where only a limited number (30) were being processed due to pagination. The fixed now fetches all records to ensure comprehensive monitoring.
2025-07-15 17:15:08 +07:00
Tola Leng f235043417 Fix: Display all SSL up to 200 records
- The SSL certs table in the SSL & Domain dashboard was not displaying all certs records
2025-07-15 16:21:56 +07:00
Tola Leng 6512d4974a feat: Add pagination to service table
- Add pagination to the service table in the uptime monitoring dashboard, allowing display of 10 records per page by default. Also add a dropdown to select the number of records to display (10, 30, 50).
2025-07-15 15:26:23 +07:00
Tola Leng 73f2566d78 Fix: Display all service records
- The service table in the uptime monitoring dashboard was not displaying all service records
2025-07-15 15:24:23 +07:00
Tola Leng 46077e8deb Removed unused script line 2025-07-14 21:49:33 +07:00
Tola Leng d72d6fb009 Updated DB Data and Schema Migration 2025-07-14 21:48:39 +07:00
Tola Leng 0ba3b3182b Disable SPONSORSHIP 2025-07-14 15:46:12 +07:00
Tola Leng 24c5a9957f Fix: Prevent infinite re-renders.
- Addressed an issue where the component was re-rendering excessively, leading to performance problems.
2025-07-14 15:45:38 +07:00
Tola Leng cb258d72c3 Disable debug console logs for production 2025-07-14 15:45:05 +07:00
Tola Leng 3f975db568 Merge pull request #68 from operacle/develop
- feat: Implement notification channels based on status (Service dialog form) and refactor Service form into smaller components
- feat: Allow multiple selections for regional agents and support multiple notifications in the Service form
- feat: Add Japanese language support. Thank you @gnsworks for the contribution!
2025-07-13 18:44:44 +07:00
Tola Leng 0941250867 Refactor Split README and CONTRIBUTING into smaller components with support for multi languages 2025-07-13 18:32:45 +07:00
Tola Leng 7ef4d27148 Merge pull request #66 from gnsworks/develop
Add Japanese translation
2025-07-13 15:38:28 +07:00
gnsworks 2621544b24 Add Japanese translation 2025-07-12 10:31:03 +09:00
gnsworks 9fd977ed15 Add CONTRIBUTING_ja.md 2025-07-12 10:07:54 +09:00
gnsworks 1b40a1b663 Add README_ja.md 2025-07-12 10:06:50 +09:00
Tola Leng 53d8b110c9 Update .gitignore with .DS_Store removed 2025-07-11 20:12:20 +07:00
Tola Leng a013f9170a feat: Allow multiple regional agents
- Allow multiple selections for regional agents in the service dialog form, similar to notification channels.
2025-07-11 17:59:03 +07:00
Tola Leng 5daea8138c Change Default monitoring fallback source value (uptime service) 2025-07-11 17:58:46 +07:00
Tola Leng 59dcdcb5df Fix: Persist multiple notification channels
- The notification channels field in the service dialog form was not saving or applying multiple selections
2025-07-11 17:57:32 +07:00
Tola Leng 35e161e40d 🔐 docs(security): update Security Policy to reflect TypeScript and Go stack
- Clarify that CheckCle is built with both TypeScript and Go
- Add mention of Go-specific security practices (govulncheck, Go modules)
- Improve clarity and consistency of security considerations
2025-07-11 14:18:41 +07:00
Tola Leng de79eb963d feat: Allow multiple selections for regional agents.
- Allow multiple regional agents to be selected in the service dialog form, similar to the notification channels.
2025-07-10 23:06:10 +07:00
Tola Leng 7fd6d07c10 Added to detail Development Setup.
- To include Go service operation and Distributed regional agent
2025-07-10 22:41:04 +07:00
Tola Leng 547a4c4041 Merge pull request #65 from operacle/develop
feat: Implement notification channel based on status(multi selection) and Refactor Split ServiceForm into smaller components.
2025-07-10 22:17:02 +07:00
Tola Leng 2d2bd790b0 Disable the debug console logs for production 2025-07-10 22:14:17 +07:00
Tola Leng 917d8a6d29 feat: Implement notification channel based on status (Service Dialog Form and Refactor Split ServiceForm into smaller components).
- The Service Dialog Form's Notification Channel field now respects the `notification_status`. If `notification_status` is enabled, the user can select one or multiple notification channel IDs.
2025-07-10 21:07:31 +07:00
Tola Leng ddeb22dc4d Added uptime monitoring detail data and server monitoring schema 2025-07-09 21:59:22 +07:00
Tola Leng e29f60447d Merge pull request #64 from operacle/develop
feat: Improve server overview cards and fix regional_status to Service type
2025-07-08 22:03:15 +07:00
Tola Leng e355e771d1 📖 Updated project image URL Readme 2025-07-08 22:01:04 +07:00
Tola Leng f4aeaebe0d Fix: Uptime history bar color for paused/unknown
- Update UptimeBar component to display grey color for "Paused" status and when no data is available.
2025-07-08 20:10:34 +07:00
Tola Leng 6b49119797 Fix: Add regional_status to Service type
- Improved the regional monitoring agent assign to service type
2025-07-08 17:11:35 +07:00
Tola Leng be7c80d7c4 Improved the Uptime Response Time History 2025-07-07 20:33:52 +07:00
Tola Leng 08f31835e1 feat: Improve server overview cards
- Added card background color and grid layout to server overview cards in the server detail page.
2025-07-07 14:49:41 +07:00
Tola Leng 5d2b8b2ed2 Refactor: Split ServerHistory and Charts into smaller components 2025-07-06 15:12:03 +07:00
samang-dauth f0eec90e75 feat: Add system info cards to server detail header
Adds small cards in the server detail page header to display server system information.
2025-07-05 17:00:21 +07:00
Tola Leng 671f22c1d3 Add FUNDING.yml (#63)
* Merge pull request #27 from operacle/develop

feat: Implement uptime monitoring data retention

* Create FUNDING.yml

* add codeowners and pull request workflow
2025-07-03 21:05:56 +07:00
Tola Leng ad869c17aa add codeowners and pull request workflow 2025-07-03 21:03:09 +07:00
Tola Leng 6702bfa5e3 Merge pull request #62 from operacle/develop
Fix: Instance monitoring dashboard styling and Improved docker compose configuration
2025-07-03 20:59:59 +07:00
Tola Leng cb6946b7dd Create FUNDING.yml 2025-07-03 20:56:11 +07:00
Tola Leng f80b2c8a51 Improved docker compose configuration 2025-07-03 19:50:12 +07:00
Tola Leng dc9e8b981a removed file while the root directory doesn’t need a package.json 2025-07-03 19:33:43 +07:00
samang-dauth 306d2fc336 Fix: Instance monitoring dashboard styling 2025-07-03 17:22:30 +07:00
samang-dauth d072ba7d23 Fix: Server detail page background color
Ensure consistent background color on server detail and docker pages.
2025-07-03 17:17:34 +07:00
Tola Leng 8bd8ae25e6 Merge pull request #61 from operacle/develop
Fix: Server Historical Performance Metrics date filter and improved the container entrypoint script pipeline
2025-07-02 20:05:50 +07:00
Tola Leng 1113ede9a6 Updated and improved the container entrypoint script pipeline 2025-07-02 20:03:29 +07:00
Tola Leng 1e84db4ecc Add new entrypoint for container startup script with hidden pocketbase admin (UI) 2025-07-02 20:03:06 +07:00
samang-dauth 20d5e86d3c Changed logo to use an image instead of the letter. 2025-07-02 19:49:00 +07:00
samang-dauth 549dcc21e4 Change SSL & Domain menu sidebar icon 2025-07-02 19:28:09 +07:00
samang-dauth f97bc4f62f Removed server hostname under container name 2025-07-02 19:10:34 +07:00
samang-dauth d6793f104c Fix: Historical Performance date filter
Ensure date filter respects server ID and displays correct data for 1 day  date filter
2025-07-02 17:44:11 +07:00
Tola Leng eddc12018f Merge pull request #60 from operacle/develop
Fix: Server history chart and date filter and Added Distributed Regional Monitoring Dockerfile
2025-07-01 20:07:23 +07:00
Tola Leng bdcf5c75b9 Added Distributed Regional Monitoring Dockerfile 2025-07-01 20:05:52 +07:00
Tola Leng 4d68c6b8a9 Updated Project Features and Roadmap 2025-07-01 19:58:26 +07:00
Tola Leng fda40de155 Update Schedule and Maintenace Overview Card Liner-Gradient 2025-07-01 19:57:51 +07:00
Tola Leng 9b1ccc7f8d Updated SSL Certificate Status Cards linear-gradient 2025-07-01 19:15:24 +07:00
Tola Leng d183ca9791 Updated Uptime Monitoring liner card gradient. 2025-07-01 18:59:55 +07:00
Tola Leng 48ddc2b4c6 Remove system_info card from overview and update status badge 2025-07-01 18:47:36 +07:00
Tola Leng 30bba14e37 Fix: Server history chart and date filter
Ensure the historical performance chart correctly displays server metric data and that the date filter dropdown functions as expected.
2025-07-01 16:23:25 +07:00
Tola Leng 474a439515 Merge pull request #59 from operacle/develop
feat: Implement instance and docker monitoring dashboard and detail metrics page
2025-06-30 23:07:58 +07:00
Tola Leng 460fcbc7dc Refactor: expand the menu sidebar and add the server and docker detail page 2025-06-30 23:06:38 +07:00
Tola Leng 0b029dc914 feat: Implement docker monitoring dashboard 2025-06-30 23:05:45 +07:00
Tola Leng 691cd4f298 feat: Implement instance monitoring dashboard 2025-06-30 23:01:33 +07:00
Tola Leng 4bfeb42142 Added Download Package URL for fully support both amd64 and arm64 2025-06-30 20:07:49 +07:00
Tola Leng c1fa02c380 Updated Service Operation Executed Display Logs 2025-06-30 19:35:29 +07:00
Tola Leng 15f0e93524 Updated Distributed Regional Monitoring and Roadmap 2025-06-29 15:14:29 +07:00
Tola Leng b37a84734f Change response time chart color
Update the response time chart to use yellow color for Regional Monitoring Data
2025-06-29 00:52:06 +07:00
Tola Leng 9a4cc7c3db Merge pull request #57 from operacle/develop
Refactor: Separate data display by monitoring source and feat: Implement "Regional Monitoring" option
2025-06-26 16:28:32 +07:00
Tola Leng c979dd24d8 Enhance one-click install script and Regional Agent Dialog
The script was failing to download the package because of an incorrect URL.
2025-06-26 16:07:36 +07:00
Tola Leng fd08a64a05 Fix: Resolve 404 error on public status page 2025-06-26 15:43:41 +07:00
Tola Leng c30bcf9e73 Move Report Menu
Move the Reports menu item to the bottom of the Regional Monitoring section in the main menu.
2025-06-26 14:34:54 +07:00
Tola Leng 7b18a224c1 Update the service detail page to display all monitoring data (default and regional) in separate lines on the graph when "All Monitoring" is selected. Preserve the original functionality of other monitoring source options. 2025-06-26 14:32:23 +07:00
Tola Leng 202a09993c feat: Implement "All Monitoring" option
Update the service detail page to display all monitoring data (default and regional) in separate lines on the graph when "All Monitoring" is selected. Preserve the original functionality of other monitoring source options.
2025-06-26 14:17:09 +07:00
Tola Leng 094f5fcd5e feat: Add "None" option to notification channel.
Adds a "None" option to the notification channel dropdown in the Add SSL Certificate form. This allows users to opt-out of notifications.
2025-06-26 14:10:30 +07:00
Tola Leng ab4f0aa6ea Refactor: Separate data display by monitoring source
and Fix: Display data collection in detail page
2025-06-24 22:11:29 +07:00
Tola Leng 35b11f5a16 Fix: Correct package download URL 2025-06-21 23:26:24 +07:00
Tola Leng 1b11deb40f Fixed bash script package version 2025-06-21 22:14:08 +07:00
Tola Leng 879e106051 Fixed bash script for correct download package URL 2025-06-21 22:09:28 +07:00
Tola Leng 044f9e07fd fixed install-regional-agent bash script 2025-06-21 22:04:49 +07:00
Tola Leng 23f3c5b8ca Merge pull request #53 from operacle/develop
Integrate regional agents in service-operation and Enhance Regional Agent Installation
2025-06-21 21:49:19 +07:00
Tola Leng ded509bd10 Enhance one-click install script and Regional Agent Dialog 2025-06-21 21:46:42 +07:00
Tola Leng 1e7c20593f Enhance Add Regional Agent dialog.
Implement one-click installation with automatic script generation and environment variable configuration.
2025-06-21 21:02:40 +07:00
Tola Leng d385f24c28 Restrict removal of default agent
Prevent removal of the default regional agent (agent_id: 1, region_name: Default) in the Regional Monitoring dashboard. This ensures the core service remains active.
2025-06-21 19:52:46 +07:00
Tola Leng 57a65106cb Refactor: Integrate regional agents in service-operation
Integrate distributed monitoring agents for HTTP, PING, TCP, and DNS checks
2025-06-21 17:48:55 +07:00
Tola Leng ea95490f13 Implement Distributed Regional Monitoring Agent with add form, API details, one-click install, and bash script generation. Refactor: Rename API Documentation to Regional Monitoring 2025-06-20 22:46:12 +07:00
Tola Leng f88cc32cf9 Merge pull request #52 from operacle/develop
Implement SSL certificate checker in Go instead of TS and feat: Add error message and details columns
2025-06-20 20:24:55 +07:00
Tola Leng 918a248b8f feat: Add error message and details columns
Adds error_message and details columns to the Incident History table in the service detail page.
2025-06-20 20:13:18 +07:00
Tola Leng 458f5f7d82 Update Go Configuration Port and change default timeout to 10s 2025-06-20 19:44:02 +07:00
Tola Leng 5ceaa09710 Add SSL certificate checker
Implement SSL certificate checking functionality
2025-06-20 19:42:24 +07:00
Tola Leng 1eabf66311 Fix: Improve response time chart
Improve the Response Time History chart by separating and ordering the 'down', 'warning', and 'up' statuses to prevent overlap and improve positioning.
2025-06-20 19:09:32 +07:00
Tola Leng a0c0a3ed75 Fix: Separate up/down graph areas
Separate the up and down areas in the response time history graph to improve clarity and correct the display of the down graph.
2025-06-20 18:53:35 +07:00
Tola Leng abb129bde0 Removes the "Add Certificate" button from the SSL Certificates table header. 2025-06-20 17:42:10 +07:00
Tola Leng 220be3bea4 Fix: Improve SSL certificate details and View Action button 2025-06-20 17:26:13 +07:00
Tola Leng 614c10188d feat: Add check interval and check_at update
- Added a "Check Interval" field to the SSL certificate create/edit forms.
- Implemented functionality to update the `check_at` field in Pocketbase with the current time when the "Check" button is clicked.
2025-06-20 17:17:34 +07:00
Tola Leng 11408dcba0 Remove SSL Checker service
Remove the SSL Checker service from the React application, as the SSL checks are now handled by the Go backend. This involves removing the `sslCheckerService.ts` file and related imports.
2025-06-20 16:54:31 +07:00
Tola Leng 9f4e759b01 Merge pull request #51 from operacle/develop
Implement CheckCle Microservice Operation
2025-06-19 17:51:39 +07:00
Tola Leng d3ea5dc77c Implement CheckCle Microservice Operation
A Go-based microservice for service operations including ICMP ping, DNS resolution, and TCP connectivity
2025-06-19 17:49:30 +07:00
Tola Leng 89572c44b7 Updated an entrypoint to start service-operation 2025-06-19 17:43:42 +07:00
Tola Leng dfe98f9fa6 Fix: Pass serviceType to ServiceUptimeHistory and
Refactor service detail page data fetching
2025-06-19 17:35:12 +07:00
Tola Leng 549a3793a5 Fix: Uptime history timestamp and service ID 2025-06-19 15:23:48 +07:00
Tola Leng 9ed4d4c7ac Merge pull request #50 from operacle/develop
feat: New Create Service Form and  Split UptimeBar.tsx into smaller components
2025-06-19 15:04:07 +07:00
Tola Leng cca2338468 Refactor: Split UptimeBar.tsx into smaller components 2025-06-19 15:01:06 +07:00
Tola Leng c1c74bb2a8 Refactor: Split UptimeBar.tsx into smaller components 2025-06-19 14:50:22 +07:00
Tola Leng a2ae66fe76 Fix: Display uptime history in service table 2025-06-19 14:48:58 +07:00
Tola Leng 907fdaa0dc Fix: Display uptime history in service table 2025-06-19 14:47:20 +07:00
Tola Leng 93b58cfa2c feat: Display service details in table
Display host, domain, or URL under the service name in the service table.
2025-06-19 14:22:17 +07:00
Tola Leng 879622b364 feat: Separate host and port for TCP services 2025-06-18 23:16:39 +07:00
Tola Leng 2f52010963 Fix: Expand service form dialog 2025-06-18 23:16:29 +07:00
Tola Leng c90f0dd963 Fix: Expand service form dialog 2025-06-18 23:16:11 +07:00
Tola Leng 4a14f64e07 feat: Separate host and port for TCP services 2025-06-18 23:15:56 +07:00
Tola Leng 19b4de78d1 feat: Separate host and port for TCP services 2025-06-18 23:15:22 +07:00
Tola Leng bdf648c2fe Add custom check interval option 2025-06-18 23:15:08 +07:00
Tola Leng 0705fc89b4 Refactor: Move service type selection 2025-06-18 23:14:55 +07:00
Tola Leng 883a541b9b feat: Separate host and port for TCP services 2025-06-18 23:14:04 +07:00
Tola Leng 0758b1d24b feat: Separate host and port for TCP services 2025-06-18 23:13:14 +07:00
Tola Leng 9a001f6aec Merge pull request #49 from operacle/develop
Remove HTTP service check and Refactor: Split settings API into modules
2025-06-18 22:09:40 +07:00
Tola Leng 9c78def48d Fix: Navigate to service detail page 2025-06-18 16:51:27 +08:00
Tola Leng 94e2c0da77 Remove HTTP service check
Remove HTTP service check functionality as the React application is now frontend only.
2025-06-18 16:47:00 +08:00
Tola Leng 78674f758c Refactor: Split settings API into modules 2025-06-17 21:51:26 +08:00
Tola Leng 3a6ab2258a Fix: Use correct API path for test email 2025-06-17 21:50:46 +08:00
Tola Leng 1daaf067d9 Refactor: Split settings API into modules 2025-06-17 21:50:34 +08:00
Tola Leng 6d1b8387ca Fix: Use correct API path for test email 2025-06-17 21:49:43 +08:00
Tola Leng e3f9111484 Refactor: Move test email button and dialog 2025-06-17 21:39:12 +08:00
Tola Leng 575869c50a Fix: Use correct API endpoint for test email 2025-06-17 21:39:00 +08:00
Tola Leng cfdedb9636 Merge pull request #45 from operacle/develop
Fix: Implement public status page functionality
2025-06-15 20:49:51 +07:00
Tola Leng 08b00728c2 Fix: Import ErrorBoundary correctly 2025-06-15 21:47:03 +08:00
Tola Leng 4486a79e58 Fix: Implement public status page functionality 2025-06-15 21:28:47 +08:00
Tola Leng 41fcaaaf05 Fix: Public status page loading state 2025-06-15 21:28:17 +08:00
Tola Leng ddcabac564 Fix: Syntax errors in public status page 2025-06-15 21:27:58 +08:00
Tola Leng 4e0a9953db Fix: Implement public status page functionality 2025-06-15 21:27:48 +08:00
Tola Leng f25fa7de83 Fix: Syntax errors in public status page 2025-06-15 21:27:33 +08:00
Tola Leng aea70d0023 Fix: Public status page loading state 2025-06-15 21:27:19 +08:00
Tola Leng 4e07c8c932 Refactor: Remove theme options in Edit Operational Page dialogs 2025-06-13 22:03:05 +08:00
Tola Leng 5f18fde775 Refactor: Remove theme options in Create Operational Page dialogs 2025-06-13 22:02:57 +08:00
Tola Leng 9d5fa805c9 Merge pull request #42 from operacle/develop
Implement password reset functionality.
2025-06-12 17:15:08 +07:00
Tola Leng e1d466a638 feat: Implement password reset API calls
Updated the `ForgotPasswordDialog` component to use the provided API paths for requesting and confirming password resets.
2025-06-12 18:08:51 +08:00
Tola Leng 8dc3bd6a0d Implement password reset functionality.
Added password reset request and confirmation to the login page, using the provided API endpoints.
2025-06-12 18:08:36 +08:00
Tola Leng 6d63d9f485 feat: Add logo and remove elements in login page 2025-06-11 20:23:45 +08:00
Tola Leng 68a4ad5478 Merge pull request #38 from operacle/develop
Refactor: Split Sidebar.tsx into smaller components
2025-06-08 21:46:27 +07:00
Tola Leng 895da83999 Fixed Authentication Persistence 2025-06-08 22:44:17 +08:00
Tola Leng f6592d331f Refactor: Split Sidebar.tsx into smaller components
Refactored the Sidebar component into smaller, more manageable files to improve code organization and maintainability.

Fix sidebar auto-expansion issue
Ensured the sidebar's collapsed state is maintained when navigating between pages.
2025-06-08 21:56:09 +08:00
Tola Leng da0808cefe Updated: Security Improvements Improved and Secure Login.
- Input Validation: Added comprehensive client-side validation with proper error messages
- Rate Limiting: Added attempt tracking to prevent brute force attacks
- Secure Error Handling: Removed detailed error logging and implemented generic error messages
- Production Security: Conditional API endpoint switching (disabled in production)
- Enhanced UX: Better loading states, form validation, and accessibility
- Password Security: Improved password visibility toggle with proper ARIA labels
- Form Security: Added autocomplete attributes and proper form submission handling
2025-06-07 23:20:04 +08:00
Tola Leng 1e77f2747e Added CheckCle Logo into the login page 2025-06-07 23:10:43 +08:00
Tola Leng 9b1918e25a Merge pull request #36 from operacle/develop
feat: Implement Operational and Public Status Page Feature
2025-06-06 19:23:54 +07:00
Tola Leng 99e988e316 📖 Updated one-click installation command 2025-06-06 20:11:28 +08:00
Tola Leng 419e9118f9 Updated Data Structure for Operational Page Features 2025-06-06 18:57:41 +08:00
Tola Leng e2c2705378 feat: Implement Operational Page in menu 2025-06-06 18:54:18 +08:00
Tola Leng 82fa9a4ac2 Add components to operational page form 2025-06-06 18:54:05 +08:00
Tola Leng 576f2fbb34 feat: Display existing components in edit form 2025-06-06 18:53:28 +08:00
Tola Leng 554faefe4a Add delete and edit functionality to Operational Pages 2025-06-06 18:53:12 +08:00
Tola Leng c92e9fe442 Implement view public page functionality 2025-06-06 18:52:35 +08:00
Tola Leng ede5c36ac4 feat: Maintain dashboard layout in Operational Page 2025-06-06 18:52:27 +08:00
Tola Leng 9097a618cd feat: Display existing components in edit form 2025-06-06 18:51:49 +08:00
Tola Leng f4f6cb48db Add delete and edit functionality to Operational Pages 2025-06-06 18:51:21 +08:00
Tola Leng 1264d293bf Implement view public page functionality 2025-06-06 18:49:54 +08:00
Tola Leng 8608b6a263 feat: Display 90-day uptime history 2025-06-06 18:49:11 +08:00
Tola Leng 86e1e70e22 feat: Display 90-day uptime history.
Updated the public status page to display 90 days of uptime history and added detailed incident history information on hover.
2025-06-06 18:48:49 +08:00
Tola Leng dd2c64420d feat: Implement a modern and professional Operational Page 2025-06-06 18:46:50 +08:00
Tola Leng ba47c1d953 feat: Implement Operational Page in menu 2025-06-06 18:43:14 +08:00
Tola Leng ba6dc41d95 📝 Correct and standard version matches the official SPDX MIT License 2025-06-06 18:02:18 +08:00
Tola Leng 1dc25cc461 Merge pull request #35 from operacle/develop
feat: Add mail test functionality
2025-06-06 16:48:46 +07:00
Tola Leng 11ddc77e32 Updated: one-click installation bash script that includes the output information after the container starts, such as the admin web management URL and default login credentials 2025-06-06 17:42:18 +08:00
Tola Leng 5a5772f9a4 Fix: Remove unused test functions and imports 2025-06-05 21:22:47 +08:00
Tola Leng 039a832b79 feat: Add mail test functionality
Adds a dialog to test email settings and integrates with the `/api/settings/test/email` API endpoint.
- Added a "Test Email" button to the Mail Settings tab.
- Implemented a dialog for entering the recipient email and selecting a template.
- Added UI elements for the dialog (input fields, radio buttons, and a send button).
- Updated `src/services/settingsService.ts` to include the test email API call.
- Added UI components for the dialog (using existing UI components).
- Updated `src/components/settings/general/MailSettingsTab.tsx` to include the test email functionality.
2025-06-05 21:06:48 +08:00
Tola Leng a88b356bf5 feat: Add New Translation for mail test functionality in the dialog to test email settings 2025-06-05 21:05:51 +08:00
Tola Leng 2d798e2983 Fix: Test Email dialog and remove Test Connection button 2025-06-05 20:59:46 +08:00
Tola Leng 426f41e337 Updated German translation for SMTP password field in Mail Settings 2025-06-05 18:43:47 +08:00
Tola Leng 4dc005c7e4 Merge pull request #33 from operacle/develop
Add SMTP password field in Mail Settings and  Fix: Ensure Add Service dialog respects theme
2025-06-05 17:40:17 +07:00
Tola Leng 4ef84b5469 Add SMTP password field.
Added a password field to the Mail Settings tab in the General Settings panel, ensuring it is saved and retrieved via the `/api/settings` endpoint.
2025-06-05 18:33:58 +08:00
Tola Leng 0739d6b0ce Updated translation for SMTP password field in Mail Settings 2025-06-05 18:33:13 +08:00
Tola Leng bd17ef7140 Fix: Apply theme to service action dialog.
Ensured the service action dialog (three-dot menu) in uptime monitoring respects the current theme (dark/light mode) by using theme-aware styling.
2025-06-04 22:09:08 +08:00
Tola Leng 518b469c04 Fix: Ensure Add Service dialog respects theme 2025-06-04 21:36:14 +08:00
Tola Leng 6652ac5e76 Fix: Improve service dialog theme support 2025-06-04 21:34:52 +08:00
Tola Leng 4ec0c7a8fa Merge pull request #32 from operacle/develop
Fix: Improve email change flow and auto-logout and feat: Implement uptime monitoring data retention
2025-06-04 16:33:42 +07:00
Tola Leng 0b52e7ffa9 📖 Updated Architecture Support & Project Development Roadmap 2025-06-04 17:15:42 +08:00
Tola Leng dd758b9334 Merge pull request #27 from operacle/develop
feat: Implement uptime monitoring data retention
2025-05-31 17:44:43 +07:00
Tola Leng 9b1167955e feat: Implement uptime monitoring data retention
Ensured data is cleared from uptime_data and ping_data collections based on retention settings. Added separate manual shrink database buttons for uptime and server monitoring retention in the data retention settings panel.
2025-05-29 22:16:50 +08:00
Tola Leng 611380a5e4 Fix: Uptime history respects interval
Refactor UptimeBar to display history respecting the service's interval, ensuring bars represent checks within the set time frame.
2025-05-29 21:31:00 +08:00
Tola Leng 0b9bc76d37 Fix: Improve email change flow and auto-logout
- Improved the email change flow in user management and profile dashboards.
- Added a success message upon successful email change.
- Implemented auto-logout after a successful email change.
- Fixed potential errors during the email change process.
2025-05-29 20:41:00 +08:00
Tola Leng 0562adbef8 Adjusted date range calculation to ensure data is displayed 2025-05-29 20:10:29 +08:00
Tola Leng d4d4040cc8 Removed the date filter component 2025-05-29 20:10:03 +08:00
Tola Leng aec7118496 Merge pull request #25 from operacle/develop
feat: Auto logout after password change
2025-05-28 15:37:53 +07:00
Tola Leng c17d902b9a Fixed: in About System page, make it auto-updates with each new release on GitHub without any hardcoded values 2025-05-28 15:35:36 +08:00
Tola Leng a5ad5eef42 ⛁ Updated Default User 2025-05-28 15:19:25 +08:00
Tola Leng c7846a2fed 📖 Updated Project Development Roadmap 2025-05-28 15:11:06 +08:00
Tola Leng 4ad001b560 feat: Auto logout after password change
Automatically log the user out after a successful password change in the user profile dashboard.
2025-05-28 15:08:39 +08:00
Tola Leng d52842a8ea Refactor: Remove settings fields
Remove "Hide Controls", "Sender Name", and "Sender Email Address" from the System Settings tab in General Settings.
2025-05-27 23:38:02 +08:00
Tola Leng 9e02eea0d1 Updated: one-click installation bash script that includes the output information after the container starts, such as the admin web management URL and default login credentials 2025-05-27 16:51:45 +08:00
Michael Ortner a1d2f1a9e0 [FIX] Update common.ts - fixed typo and forogt some transaltions. 2025-05-27 14:07:20 +07:00
Michael Ortner 230f5562c7 [ADD] Added the german translation for the strings 2025-05-27 14:07:20 +07:00
Tola Leng 759c3249e1 Merge pull request #22 from operacle/develop
feat: release maintenance and incident management features; apply hotfixes
2025-05-26 21:57:45 +07:00
Tola Leng bff3cd7d91 Fixed: combines new build-based setup with persistent data using Host (volume) directory mapped to container path 2025-05-26 22:54:47 +08:00
Tola Leng 3799be8b23 feat: release maintenance and incident management features; apply hotfixes 2025-05-26 22:45:39 +08:00
Tola Leng 0932acfb61 Fix: Make service table row clickable 2025-05-26 22:02:59 +08:00
Tola Leng 38374acdcd Fix: Apply grid style to SSL overview cards 2025-05-26 21:52:10 +08:00
Tola Leng a58180f252 Updated context language 2025-05-26 20:32:08 +08:00
Tola Leng a307fd006c Removed Dockerfile and Updated API Enpoint 2025-05-26 20:15:10 +08:00
Tola Leng 31738b3a52 Updated language context 2025-05-26 19:58:00 +08:00
Tola Leng e3e7206d91 Resolved merge conflict using 'develop' changes 2025-05-26 19:00:03 +08:00
Tola Leng fe1b94d8e3 Feat: Added schema for new feature (maintenance and incident) include the user management roles. 2025-05-26 18:02:31 +08:00
Tola Leng 4f1c78f8e1 Refactor: Implement permission notice for admin role 2025-05-26 17:50:53 +08:00
Tola Leng 7ccd413973 Feat: added tab dashboard under General Settings (System Setting and Mail Settings). Improved the Setting API to dynamically use the baseURL. 2025-05-26 16:51:05 +08:00
Tola Leng e74bf35264 Fix: Prevent console message loop on maintenance updates
The console was generating excessive messages after schedule maintenance updates.
2025-05-26 16:42:05 +08:00
Tola Leng 23be1fca0f Fix: Reduce maintenance data request frequency.
Fix: The maintenance list should refresh immediately after a new maintenance entry is created.
2025-05-24 18:20:33 +08:00
Tola Leng df047541b9 Fixed Version (HMR-safe). Avoids HMR issues from unstable inline defaults. 2025-05-24 04:45:47 +08:00
Tola Leng c8323e91b0 Updated and Fix UserRole and User Forms 2025-05-24 03:47:32 +08:00
Tola Leng 9da272b464 Fix: Add scrollable content to AddUserForm 2025-05-24 03:01:58 +08:00
Tola Leng 706a01e5aa Updated language for Settings Features and User Management 2025-05-24 03:01:07 +08:00
Tola Leng 8c159f402e feat: Implement tabbed settings dashboard
Implement a tabbed dashboard within the Settings Panel, including System Settings and Mail Setting
2025-05-24 03:00:29 +08:00
Tola Leng f483fbd2ee feat: Implement tabbed settings dashboard (System Settings and Mail Settings) 2025-05-24 02:59:34 +08:00
Tola Leng 616148f794 Expand user form display for both Add and Edit of Users 2025-05-24 02:57:37 +08:00
Tola Leng 7a125bf0b7 Fix: Super admin login API path
The login process for super admins was incorrectly using the regular user authentication path. This commit ensures super admins use the correct authentication path
2025-05-24 02:56:29 +08:00
Tola Leng dfe052aba6 Fix: Email change for both admin and superadmin
The email change functionality was updated to support both admin and superadmin roles. The code was adjusted to correctly handle email change requests for users in both the admin and super-admin
2025-05-24 02:55:29 +08:00
Tola Leng 641e98c001 Updated Language Context 2025-05-23 20:31:30 +07:00
Tola Leng 1ee63ec74a Added PDF Print Function 2025-05-23 20:31:30 +07:00
Tola Leng 0d8b88d9a1 Updated Service Notification 2025-05-23 20:31:30 +07:00
Tola Leng 280c5dcc86 Refactor the Translations language to seperate their own directory, for easy to maintenable and scalable language 2025-05-23 20:31:30 +07:00
Tola Leng 09ce22b4fb Updated UI Component 2025-05-23 20:31:30 +07:00
Tola Leng 1e8962cb11 Build Incident and maintenance service 2025-05-23 20:31:30 +07:00
Tola Leng a3727b5dfd feat: Implement Incident Management and Tab Dashboard 2025-05-23 20:31:30 +07:00
Tola Leng 4c2f65b432 feat: Implement Schedule Maintenance Management and Tab Dashboard 2025-05-23 20:31:30 +07:00
Tola Leng 0692a78576 Added Schedule & Incident Menu Sidebar 2025-05-23 20:31:30 +07:00
Tola Leng 839fcc8771 Add necessary dependencies for jspdf & jspdf-autotable for PDF Print 2025-05-23 20:31:30 +07:00
Tola Leng 686634f0ac Update the word German to Deutsch 2025-05-23 20:31:30 +07:00
Tola Leng 705acfbe1d Add Schedule & Incident menu sidebar 2025-05-23 20:31:30 +07:00
Tola Leng b845e46231 Merge branch 'develop' of https://github.com/operacle/checkcle into develop 2025-05-23 21:22:21 +08:00
Tola Leng 1f13d5c6be Updated: About System Page 2025-05-23 20:06:46 +07:00
Tola Leng 9781a0ad68 Updated Language Context 2025-05-23 20:40:29 +08:00
Tola Leng 1e51c964cf Added PDF Print Function 2025-05-23 20:40:14 +08:00
Tola Leng b282b0b2d5 Updated Service Notification 2025-05-23 20:39:43 +08:00
Tola Leng 8910c1355c Refactor the Translations language to seperate their own directory, for easy to maintenable and scalable language 2025-05-23 20:39:25 +08:00
Tola Leng b4bef67220 Updated UI Component 2025-05-23 20:37:59 +08:00
Tola Leng 462b6d6d40 Build Incident and maintenance service 2025-05-23 20:37:36 +08:00
Tola Leng 747ae5c23f feat: Implement Incident Management and Tab Dashboard 2025-05-23 20:37:00 +08:00
Tola Leng 395a580032 feat: Implement Schedule Maintenance Management and Tab Dashboard 2025-05-23 20:34:18 +08:00
Tola Leng a7a1c9a144 Added Schedule & Incident Menu Sidebar 2025-05-23 20:21:40 +08:00
Tola Leng 11deebe45f Add necessary dependencies for jspdf & jspdf-autotable for PDF Print 2025-05-23 20:20:06 +08:00
Tola Leng 0e705e1309 Update the word German to Deutsch 2025-05-23 20:16:53 +08:00
Tola Leng 4a6cd92661 Add Schedule & Incident menu sidebar 2025-05-23 18:48:03 +08:00
Tola Leng 3f6d606190 Updated: About System Page 2025-05-22 23:10:46 +08:00
Tola Leng 7c382ddb7a Removed Dockerfile 2025-05-20 16:45:51 +08:00
Tola Leng e4b5ba96c1 Updated Pocket API Connection for production 2025-05-20 16:45:31 +08:00
Tola Leng 34e78e5c46 Removed DockerFile 2025-05-20 16:45:06 +08:00
Tola Leng 442e025f7b Merge branch 'develop' into main 2025-05-20 15:35:27 +07:00
Tola Leng 975fb65c3d Updated to clone a specific branch:develop for Local Developement 2025-05-20 15:15:11 +08:00
Tola Leng 48abc618f3 Update API Connection for Local Development 2025-05-20 14:51:12 +08:00
Tola Leng ba318f13a5 Merge pull request #11 from michaelortnerit/main
Update de.ts
2025-05-19 15:50:18 +07:00
Michael Ortner c099f03bbb Update de.ts
Update the word German to Deutsch
2025-05-19 10:15:00 +02:00
Tola Leng 19f13598fb Added a new dropdown menu item for German in the language selector dropdown. Thanks to @michaelortnerit for the contribution. 2025-05-19 16:07:10 +08:00
Tola Leng 918e012082 Merge pull request #9 from michaelortnerit/main
[IMP] Removed the old name and added german language.
2025-05-19 14:19:50 +07:00
Michael Ortner e0c7541a87 [IMP] Removed the old name and added german language. 2025-05-17 21:10:10 +02:00
Tola Leng a1c6c7053e feat(km): Translate SSL & Domain section
Translates the SSL & Domain section, including components and pages, into Khmer. This involves updating the `km.ts` translation file with relevant Khmer translations for all UI elements and strings within the SSL & Domain management feature.
2025-05-17 21:08:44 +08:00
Tola Leng 389eb22737 Updated properties in both translation files 2025-05-17 21:07:31 +08:00
Tola Leng 466ef1cb90 Fix the SSLStatusBadge component to ensure it correctly uses the translations for all SSL status types 2025-05-17 21:06:50 +08:00
Tola Leng caaa32ea13 Updated telegram notification 2025-05-17 20:56:31 +08:00
Tola Leng 78be48ffd0 Fix: Ensure email change request is sent.
The email change request was failing with a 400 error. This commit addresses the issue by ensuring the email change request is correctly sent when a user updates their email in the profile dashboard.
2025-05-17 17:55:59 +08:00
Tola Leng aaee189ace Added Sample Test function to send a direct Telegram message 2025-05-17 17:34:23 +08:00
Tola Leng ce988574e4 Updated Default SMTP Mail gateway 2025-05-17 16:56:12 +08:00
Tola Leng a166938d42 Removes the SSL testSendTelegramMessage function and its associated test credentials from telegramService.ts. 2025-05-17 16:44:45 +08:00
Tola Leng e4d6d84477 Remove test Telegram function 2025-05-17 16:43:33 +08:00
Tola Leng c0cf913935 📜 Updated For New Module: SSL & Domain Monitoring 2025-05-17 00:00:04 +08:00
Tola Leng c8d640ebb5 Updated SSL Domain page and API 2025-05-16 22:22:03 +08:00
Tola Leng 9f38e23b22 Implement the SSL Certs Service Checker 2025-05-16 22:21:39 +08:00
Tola Leng 1ea81a23b2 feat: Implement dropdown actions in SSL table
Action column in the SSL & Domain table to use a dropdown menu for View, Check, Edit, and Delete actions. And updated SSL Dashboard UI Component
2025-05-16 22:20:57 +08:00
Tola Leng 8ee933563e Notification (Telegram) for SSL notification thresholds
Ensure Telegram notifications respect warning/expiry thresholds, including manual checks.
2025-05-16 22:18:35 +08:00
Tola Leng 687619ce0d Feat: Implement SSL notification channel selection
- Added notification channel selection to the Add SSL Certificate form.
- Display available Telegram notification channels.
- Integrate SSL expiry and warning thresholds for notifications.
- Ensure daily auto-checks and manual check button functionality.
2025-05-16 22:17:02 +08:00
Tola Leng 9e931ad439 Updated the SSL detail dialog in SSLCertificatesTable.tsx 2025-05-16 22:14:05 +08:00
Tola Leng c7fd7f4fbe Updated the SSL detail dialog in SSLCertificatesTable.tsx to display data based on the ssl DB structure, including fields like collectionId, collectionName, and other properties from the PocketBase API response. 2025-05-16 22:13:03 +08:00
Tola Leng eafc4c33ab Remove unused sslUtils.ts file
Remove the file /src/utils/sslUtils.ts as it is no longer used.
2025-05-16 22:10:34 +08:00
Tola Leng 7644480782 Upgraded the pocketbase version V0.28.1 2025-05-16 22:09:44 +08:00
Tola Leng 1515ae56af updated data for server backend 2025-05-16 22:08:50 +08:00
Tola Leng caf3e73dc3 Update database Schemar for SSL & Domain 2025-05-16 22:05:15 +08:00
Tola Leng 98c86ff5dd Display user's active/inactive status from the backend in the profile details. 2025-05-15 22:45:28 +08:00
Tola Leng 42441b615a Feat: Display user status 2025-05-15 22:44:07 +08:00
Tola Leng e5651c152a Update Profile Form for Changing email requires verification. A verification email will be sent. 2025-05-15 22:32:16 +08:00
Tola Leng fc84922959 Update to handle email change using the request-email-change endpoint 2025-05-15 22:31:27 +08:00
Tola Leng 093300c38a 📜 Updated Official CheckCle Website and Demo Link 2025-05-15 20:45:00 +08:00
Tola Leng 2dcedf7b30 🔐 Added Security Policy 2025-05-15 00:12:16 +08:00
Tola Leng 38e06b9bec 📜 Updated Docker Run Command 2025-05-14 21:05:04 +08:00
Tola Leng 9f2ab1a79b Updated ENG & KH Translations 2025-05-12 23:24:53 +08:00
Tola Leng 54ebe0604d Updated Login page (Removed unnecessary and unsued button) 2025-05-12 23:23:40 +08:00
Tola Leng cf28eb41d6 Fixed: fix to correct API base URL at runtime without hardcoding the domain or port. Now it works under any custom domain via reverse proxy 2025-05-12 23:21:46 +08:00
Tola Leng 8ef3614908 Remove .DS_Store from repository 2025-05-12 18:27:02 +08:00
Tola Leng 7773c1bc66 features: Implement domain validation in SSL form
Integrate domain validation logic into the SSL Domain Form, including checks for warnings, expiry, and overall domain validity.
2025-05-11 20:52:39 +08:00
Tola Leng 580dbc424c Implement the SSL & Domain Features (Domain List, Real-time information for Expiration Date, Issuer, and Days Left.) 2025-05-11 20:51:38 +08:00
Tola Leng 981bc972f0 📜 Update Project Developement Progress 2025-05-11 17:37:05 +08:00
Tola Leng a9043e8088 📝 Updated License 2025-05-11 14:44:11 +08:00
Tola Leng 1ae961c5d9 Removed Dockerfile while we keep only one file in Docker directory 2025-05-10 22:52:47 +07:00
Tola Leng 51226a7afe Merge branch 'main' of https://github.com/operacle/checkcle 2025-05-10 22:47:48 +07:00
Tola Leng dfd57a6b62 Updated Readme 2025-05-10 22:47:21 +07:00
Tola Leng 12f4d18521 Added text into transaction (English and Khmer) 2025-05-10 21:08:22 +08:00
Tola Leng aea32f10b6 Add quick guide dialog to header (Implement a dialog to display a quick guide) 2025-05-10 21:06:23 +08:00
Tola Leng a16b1d228a Added links to header icons to the documentation, GitHub, X (Twitter), and Discord icons in the header. 2025-05-10 20:39:40 +08:00
Tola Leng 930622b5be Updated the AboutSystem component to include the correct links. 2025-05-10 20:35:10 +08:00
511 changed files with 43651 additions and 7417 deletions
Vendored
BIN
View File
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
# These owners will be the default owners for everything in
* @tolaleng
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [tolaleng]
patreon: # Replace with a single Patreon username
open_collective: #checkcle
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+53
View File
@@ -0,0 +1,53 @@
name: ❓ Ask for Help
description: Ask a question or request guidance about this project.
title: "[Question]: "
labels: [question]
assignees: []
body:
- type: markdown
attributes:
value: |
**Thank you for reaching out!**
Please fill out this form to help us understand your question.
- type: textarea
id: question
attributes:
label: What do you need help with?
description: Clearly describe your question or what you're trying to achieve.
placeholder: |
I am trying to do X but I'm not sure how to...
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
description: |
Provide any additional context, related issues, or links that help explain your question.
placeholder: "Related issues, pull requests, or documentation..."
validations:
required: false
- type: textarea
id: environment
attributes:
label: Environment Details
description: |
If applicable, please provide details about your environment (OS, Node version, browser, etc.).
placeholder: "Example: Ubuntu 22.04, Node.js v20, Chrome 125"
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Before submitting, please confirm:
options:
- label: I have searched existing issues and discussions.
required: true
- label: I have read the documentation.
required: true
+94
View File
@@ -0,0 +1,94 @@
name: 🐛 Bug Report
description: Report a reproducible bug to help us improve.
title: "[Bug]: "
labels: [bug]
assignees: []
body:
- type: markdown
attributes:
value: |
**Thank you for taking the time to report a bug!**
Please fill out the following template so we can reproduce and fix the issue faster.
- type: input
id: environment
attributes:
label: Environment
description: |
Describe where you encountered the issue.
(e.g., OS, browser, Node version, etc.)
placeholder: "Example: macOS 14.0, Chrome 125, Node.js 20.3.0"
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: |
A clear and concise description of what the bug is.
placeholder: "When I do X, Y happens instead of Z..."
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: |
How can we reproduce the behavior?
Please list the steps in order.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: "The app should..."
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots or Videos
description: |
If applicable, add screenshots or screen recordings to help explain your problem.
placeholder: "Attach screenshots here."
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs
description: |
If applicable, paste logs or error messages here.
Please remove any sensitive information.
render: shell
placeholder: |
```
Error: Something went wrong
at index.js:123:45
```
validations:
required: false
- type: checkboxes
id: terms
attributes:
label: Checklist
description: Before submitting, please confirm:
options:
- label: I have searched existing issues to avoid duplicates.
required: true
- label: I have provided enough information for reproduction.
required: true
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+49
View File
@@ -0,0 +1,49 @@
name: 📝 Documentation Improvement
description: Suggest improvements or report issues in documentation.
title: "[Docs]: "
labels: [documentation]
assignees: []
body:
- type: markdown
attributes:
value: |
Thank you for helping improve our documentation!
- type: textarea
id: description
attributes:
label: What part of the documentation needs improvement?
description: |
Describe the issue clearly.
placeholder: "The installation guide is missing steps for..."
validations:
required: true
- type: textarea
id: location
attributes:
label: Location
description: |
Provide a link or path to the affected documentation.
placeholder: "https://github.com/operacle/checkcle/docs/INSTALL.md"
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested Change
description: |
How would you improve it?
placeholder: "I suggest adding a section about..."
validations:
required: false
- type: checkboxes
id: confirmation
attributes:
label: Checklist
options:
- label: I have searched existing issues for similar documentation problems.
required: true
@@ -0,0 +1,63 @@
name: 🚀 Feature Request
description: Suggest an idea to improve this project.
title: "[Feature]: "
labels: [enhancement]
assignees: []
body:
- type: markdown
attributes:
value: |
**Thank you for suggesting a feature!**
Please fill out this form so we can better understand your idea.
- type: textarea
id: description
attributes:
label: Feature Description
description: |
A clear and concise description of the feature youd like to see.
placeholder: "I would like to have..."
validations:
required: true
- type: textarea
id: motivation
attributes:
label: Motivation
description: |
Please explain why this feature would be useful.
placeholder: "This feature would help because..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: |
Have you considered any alternative solutions or workarounds?
placeholder: "I have tried..."
validations:
required: false
- type: textarea
id: additional
attributes:
label: Additional Context
description: |
Add any other context or screenshots about the feature request here.
placeholder: "Links to related issues or references..."
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Before submitting, please confirm:
options:
- label: I have searched existing issues to make sure this feature hasnt been requested yet.
required: true
- label: I have described the feature clearly and provided supporting details.
required: true
+51
View File
@@ -0,0 +1,51 @@
name: 🛡️ Security Issue
description: Report a potential security vulnerability.
title: "[Security]: "
labels: [security]
assignees: []
body:
- type: markdown
attributes:
value: |
⚠️ **IMPORTANT: Please do NOT disclose sensitive security details in this public issue.**
If you believe you have found a security vulnerability, **please report it privately** to help keep users safe.
📧 **Disclosure Process:**
- Email: [security@checkcle.io](mailto:security@checkcle.io)
- Or follow our [SECURITY.md](../../blob/main/SECURITY.md) policy.
You can use this issue only to let maintainers know that you have sent or will send a report.
- type: textarea
id: summary
attributes:
label: Summary
description: |
Briefly describe the type of vulnerability you believe you have found (without including sensitive details).
placeholder: "Example: Possible SQL injection in the user login endpoint."
validations:
required: true
- type: textarea
id: contact
attributes:
label: Contact Information
description: |
Provide an email or other way for maintainers to contact you if we need clarification.
placeholder: "your.email@example.com"
validations:
required: true
- type: checkboxes
id: confirmation
attributes:
label: Confirmation
description: |
Please confirm:
options:
- label: I will not share sensitive exploit details in this issue.
required: true
- label: I have sent or will send a full report to the private disclosure contact.
required: true
@@ -0,0 +1,38 @@
name: 🌎 Translation Request
description: Request a translation or report translation issues.
title: "[Translation]: "
labels: [translation]
assignees: []
body:
- type: markdown
attributes:
value: |
Help us make this project accessible in more languages!
- type: input
id: language
attributes:
label: Language
description: What language is this about?
placeholder: "Example: Spanish"
validations:
required: true
- type: textarea
id: description
attributes:
label: Details
description: |
Describe what youd like to have translated or what issue you found.
placeholder: "Please translate the Getting Started guide..."
validations:
required: true
- type: checkboxes
id: confirmation
attributes:
label: Checklist
options:
- label: I have searched existing issues for similar requests.
required: true
+3
View File
@@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
application/src/lib/pocketbase.ts
Dockerfile
application/src/lib/pocketbase-prod.ts
+81
View File
@@ -0,0 +1,81 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders of the **CheckCle** project pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all project spaces, and also applies when an individual is officially representing the project in public spaces. Examples include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team at:
📧 **Email:** [hello@checkcle.io](mailto:hello@checkcle.io)
All complaints will be reviewed and investigated promptly and fairly.
All project maintainers are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Project maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
**1. Correction**
- *Community Impact:* Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
- *Consequence:* A private, written warning from project maintainers, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
**2. Warning**
- *Community Impact:* A violation through a single incident or series of actions.
- *Consequence:* A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
**3. Temporary Ban**
- *Community Impact:* A serious violation of community standards, including sustained inappropriate behavior.
- *Consequence:* A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
**4. Permanent Ban**
- *Community Impact:* Demonstrating a pattern of violation of community standards, including sustained harassment, or showing no remorse after being warned.
- *Consequence:* A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at:
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
For answers to common questions about this code of conduct, see:
https://www.contributor-covenant.org/faq
[homepage]: https://www.contributor-covenant.org
+64 -2
View File
@@ -1,3 +1,36 @@
## 🌐 Select Language
<table align="center">
<tr>
<td align="center">
<a href="CONTRIBUTING.md">
<img src="https://flagcdn.com/24x18/gb.png" alt="English" />
<br/><strong>English</strong>
</a>
</td>
<td align="center">
<a href="docs/CONTRIBUTING_km.md">
<img src="https://flagcdn.com/24x18/kh.png" alt="Khmer" />
<br/><strong>ខ្មែរ</strong>
</a>
</td>
<td align="center">
<a href="docs/CONTRIBUTING_ja.md">
<img src="https://flagcdn.com/24x18/jp.png" alt="Japanese" />
<br/><strong>Japanese</strong>
</a>
</td>
</tr>
</table>
<p align="center">
Thank you to all our contributors, users, and supporters for making this project thrive.
</p>
<p align="center">
🚀 <strong>Stay tuned for more updates, features, and improvements.</strong>
</p>
# 🛠️ Contributing to CheckCle # 🛠️ Contributing to CheckCle
@@ -46,7 +79,7 @@ Click "Fork" on [GitHub](https://github.com/operacle/checkcle) to create your ow
### 2. Clone Your Fork ### 2. Clone Your Fork
```bash ```bash
git clone https://github.com/operacle/checkcle.git git clone --branch develop https://github.com/operacle/checkcle.git
cd checkcle cd checkcle
``` ```
@@ -62,15 +95,44 @@ npm install && npm run dev
#Server Backend #Server Backend
cd server cd server
./pocketbase serve --dir pb_data ./pocketbase serve --dir pb_data
If you're not using localhost, please run with this (./pocketbase serve --http=0.0.0.0:8090 --dir pb_data)
``` ```
### 4. Start Service Check Operation
```bash
#Server Backend
Start Service Operation (Check Service for PING, HTTP, TCP, DNS)
cd server/service-operation
go run main.go (you do not need to change the .env while it's the localhost connection)
```
### 5. Start Distributed Regional Agent
```bash
#### 1. Fork the Repository
Click "Fork" on [GitHub](https://github.com/operacle/Distributed-Regional-Monitoring) to create your own copy.
#### 2. Clone Your Fork
git clone --branch main https://github.com/operacle/Distributed-Regional-Monitoring.git
cd Distributed-Regional-Monitoring
#### 3. Install Go Service (make sure you have installed the Go Service)
Copy .env.example -> .env
Change regional agent configuration in .env file
and Run: go run main.go
```
--- ---
## ✅ Pull Request Process ## ✅ Pull Request Process
1. Ensure your code follows the existing style and naming conventions. 1. Ensure your code follows the existing style and naming conventions.
2. Write clear, concise commit messages. 2. Write clear, concise commit messages.
3. Push your branch and open a Pull Request (PR) on the `main` branch. 3. Push your branch and open a Pull Request (PR) on the `develop` branch.
4. Provide a meaningful PR description (what/why/how). 4. Provide a meaningful PR description (what/why/how).
5. Link related issues if applicable (e.g. `Closes #12`). 5. Link related issues if applicable (e.g. `Closes #12`).
6. Make sure all checks pass (e.g., linting, tests). 6. Make sure all checks pass (e.g., linting, tests).
-44
View File
@@ -1,44 +0,0 @@
# Stage 1: Build the frontend
FROM node:18-alpine AS frontend-builder
WORKDIR /app
COPY application/package*.json ./
RUN npm install
COPY application/ ./
RUN npm run build
# Stage 2: Download PocketBase binary
FROM alpine:3.17 AS pb-builder
WORKDIR /pb
ARG PB_VERSION=0.27.2
RUN apk add --no-cache wget unzip \
&& wget https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip \
&& unzip pocketbase_${PB_VERSION}_linux_amd64.zip \
&& chmod +x /pb/pocketbase
# Stage 3: Final runtime image
FROM alpine:3.17
WORKDIR /app
# Copy PocketBase binary
COPY --from=pb-builder /pb/pocketbase /app/pocketbase
# Copy frontend build to PocketBase public directory
COPY --from=frontend-builder /app/dist /app/pb_public
# Copy backend files
COPY server/pb_migrations /app/pb_migrations
COPY server/pb_hooks /app/pb_hooks
COPY server/pb_data /app/pb_data
# Mark pb_data as a volume for runtime persistence
VOLUME /app/pb_data
# Expose default PocketBase port
EXPOSE 8090
# Launch PocketBase
#CMD ["/app/pocketbase", "serve", "--dir", "/app/pb_data"]
# Launch PocketBase and bind to 0.0.0.0 for external access
CMD ["/app/pocketbase", "serve", "--http=0.0.0.0:8090", "--dir", "/app/pb_data"]
+18 -14
View File
@@ -1,17 +1,21 @@
The MIT License (MIT) MIT License
Copyright (c) 2022 - Present, Tola Leng
Permission is hereby granted, free of charge, to any person obtaining a copy of this software Copyright (c) 2025 Tola Leng
and associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or Permission is hereby granted, free of charge, to any person obtaining a copy
substantial portions of the Software. of this software and associated documentation files (the Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING The above copyright notice and this permission notice shall be included in all
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND copies or substantial portions of the Software.
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+92 -33
View File
@@ -1,11 +1,44 @@
![CheckCle Platform](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png) ## 🌐 Select Language
<table align="center">
<tr>
<td align="center">
<a href="README.md">
<img src="https://flagcdn.com/24x18/gb.png" alt="English" />
<br/><strong>English</strong>
</a>
</td>
<td align="center">
<a href="docs/README_km.md">
<img src="https://flagcdn.com/24x18/kh.png" alt="Khmer" />
<br/><strong>ខ្មែរ</strong>
</a>
</td>
<td align="center">
<a href="docs/README_ja.md">
<img src="https://flagcdn.com/24x18/jp.png" alt="Japanese" />
<br/><strong>Japanese</strong>
</a>
</td>
</tr>
</table>
<p align="center">
Thank you to all our contributors, users, and supporters for making this project thrive.
</p>
<p align="center">
🚀 <strong>Stay tuned for more updates, features, and improvements.</strong>
</p>
![CheckCle Platform](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/server-detail-page.png)
# 🚀 What is CheckCle? # 🚀 What is CheckCle?
CheckCle is an Open Source solution for seamless, real-time monitoring of full-stack systems, applications, and infrastructure. It provides developers, sysadmins, and DevOps teams with deep insights and actionable data across every layer of their environment—whether it's servers, applications, or services. With CheckCle, you gain visibility, control, and the ability to ensure optimal performance throughout your entire technology stack. CheckCle is an Open Source solution for seamless, real-time monitoring of full-stack systems, applications, and infrastructure. It provides developers, sysadmins, and DevOps teams with deep insights and actionable data across every layer of their environment—whether it's servers, applications, or services. With CheckCle, you gain visibility, control, and the ability to ensure optimal performance throughout your entire technology stack.
## 🎯 Live Demo ## 🎯 Live Demo
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io) Coming Soon! 👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
## 🌟 Core Features ## 🌟 Core Features
@@ -13,6 +46,7 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
- Monitor HTTP, DNS, and Ping protocols - Monitor HTTP, DNS, and Ping protocols
- Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP) - Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP)
- Track detail uptime, response times, and performance issues - Track detail uptime, response times, and performance issues
- Distributed Regional Monitoring
- Incident History (UP/DOWN/WARNING/PAUSE) - Incident History (UP/DOWN/WARNING/PAUSE)
- SSL & Domain Monitoring (Domain, Issuer, Expiration Date, Days Left, Status, Last Notified) - SSL & Domain Monitoring (Domain, Issuer, Expiration Date, Days Left, Status, Last Notified)
- Infrastructure Server Monitoring, Supports Linux (🐧 Debian, Ubuntu, CentOS, Red Hat, etc.) and Windows (Beta). And Servers metrics like CPU, RAM, disk usage, and network activity) with an one-line installation angent script. - Infrastructure Server Monitoring, Supports Linux (🐧 Debian, Ubuntu, CentOS, Red Hat, etc.) and Windows (Beta). And Servers metrics like CPU, RAM, disk usage, and network activity) with an one-line installation angent script.
@@ -24,21 +58,33 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
## #️⃣ Getting Started ## #️⃣ Getting Started
### Installation with Docker Compose ### Current Architecture Support
1. Clone the repository and run * ✅ x86_64 PCs, laptops, servers (amd64)
* ✅ Modern Raspberry Pi 3/4/5 with (64-bit OS), Apple Silicon Macs (arm64)
### Install CheckCle using one of the options below.
1. CheckCle One-Click Installation - Just copy and run on terminal
```bash ```bash
#Clone the repository curl -fsSL https://checkcle.io/install.sh | bash
git clone https://github.com/operacle/checkcle.git
cd checkcle
# Run docker compose
docker compose up -d
``` ```
2. Docker Compose - Recommended 2. Install with docker run. Just copy ready docker run command below
```bash ```bash
docker run -d \
--name checkcle \
--restart unless-stopped \
-p 8090:8090 \
-v /opt/pb_data:/mnt/pb_data \
--ulimit nofile=4096:8192 \
operacle/checkcle:latest
```
3. Install with Docker compose Configuration.
```bash
version: '3.9'
services: services:
checkcle: checkcle:
@@ -48,15 +94,12 @@ services:
ports: ports:
- "8090:8090" # Web Application - "8090:8090" # Web Application
volumes: volumes:
- pb_data:/app/pb_data # Ensure persistent data across rebuilds - /opt/pb_data:/mnt/pb_data # Host directory mapped to container path
ulimits: ulimits:
nofile: nofile:
soft: 4096 soft: 4096
hard: 8192 hard: 8192
volumes:
pb_data: # Docker-managed volume for data persistence
``` ```
3. Admin Web Management 3. Admin Web Management
@@ -64,29 +107,36 @@ volumes:
User: admin@example.com User: admin@example.com
Passwd: Admin123456 Passwd: Admin123456
4. Follow the Quick Start Guide at https://docs.checkcle.com (Coming Soon) 4. Follow the Quick Start Guide at https://docs.checkcle.io
### ###
![Uptime Monitoring](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-collapse-black.png) ![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-monitoring.png)
![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png) ![Service Detail Page](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-service-detail.png)
![Service Detail Page](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-detailpage.png) ![checkcle-server-instance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/instance-server-monitoring.png)
![Schedule Maintenance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-schedule-maintenance.png)
![SSL Monitoring](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/ssl-monitoring.png)
## 📝 Development Roadmap ## 📝 Development Roadmap
- [x] Health check & uptime monitoring (HTTP) - Health check & uptime monitoring (HTTP)
- [x] Dashboard UI with live stats - Dashboard UI with live stats
- [x] Auth with Multi-users system (admin) - Auth with Multi-users system (admin)
- [x] Notifications (Telegram) - Notifications (Telegram)
- [x] Docker containerization - Docker containerization
- [ ] SSL & Domain Monitoring - ✅ CheckCle Website
- ✅ CheckCle Demo Server
- ✅ SSL & Domain Monitoring
- ✅ Schedule Maintenance
- ✅ Incident Management
- [ ] Infrastructure Server Monitoring - [ ] Infrastructure Server Monitoring
- [ ] Schedule Maintenance & Incident Management - ✅ Operational Status / Public Status Pages
- [ ] Operational Status / Public Status Pages - ✅ Uptime monitoring (HTTP, TCP, PING, DNS) Full functionality
- [ ] Uptime monitoring (TCP, PING, DNS) - ✅ Distributed Regional Monitoring Agent [Support Operation](https://github.com/operacle/Distributed-Regional-Monitoring)
- [ ] User Permission Roles & Service Group - ✅ System Setting Panel and Mail Settings
- ✅ User Permission Roles
- [ ] Notifications (Email/Slack/Discord/Signal) - [ ] Notifications (Email/Slack/Discord/Signal)
- [ ] Mobile PWA version - ✅ Data Retention & Automate Strink (Muti Options to Shrink Data & Database )
- [ ] Open-source release with full documentation - Open-source release with full documentation
## 🌟 CheckCle for Communities? ## 🌟 CheckCle for Communities?
- **Built with Passion**: Created by an open-source enthusiast for the community - **Built with Passion**: Created by an open-source enthusiast for the community
@@ -109,6 +159,7 @@ Here are some ways you can help improve CheckCle:
## 🌍 Stay Connected ## 🌍 Stay Connected
- Website: [checkcle.io](https://checkcle.io) - Website: [checkcle.io](https://checkcle.io)
- Documentation: [docs.checkcle.io](https://docs.checkcle.io)
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git) - GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
- Community Channels: Engage via discussions and issues! - Community Channels: Engage via discussions and issues!
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX) - Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
@@ -119,5 +170,13 @@ Here are some ways you can help improve CheckCle:
CheckCle is released under the MIT License. CheckCle is released under the MIT License.
--- ---
## 👥 Contributors
[![](https://contrib.rocks/image?repo=operacle/checkcle)](https://github.com/operacle/checkcle/graphs/contributors)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=operacle/checkcle&type=Date)](https://www.star-history.com/#operacle/checkcle&Date)
Stay informed, stay online, with CheckCle! 🌐 Stay informed, stay online, with CheckCle! 🌐
+80
View File
@@ -0,0 +1,80 @@
# 🔐 Security Policy
## 📦 Project: [checkcle](https://github.com/operacle/checkcle)
**checkcle** is a self-hosted uptime and server monitoring tool built with TypeScript and Go.
We care about the security and privacy of users running this project in production environments.
---
## 📣 Reporting a Vulnerability
If you believe you have found a security vulnerability in this project:
- **DO NOT** open a public issue to report it.
- Please report it responsibly via one of the following methods:
### 🔐 Preferred: [Report a Vulnerability via GitHub](https://github.com/operacle/checkcle/security/advisories/new)
- Use the GitHub security advisory form (private and secure).
- Attach as much detail as possible:
- Description of the issue
- Affected version or commit hash
- Reproduction steps
- Impact and any potential mitigations
- Logs or screenshots (if available)
### 📧 Alternatively: Contact the Maintainer
- Email: `security@checkcle.io`
- Optionally include a PGP public key for encrypted messages
We aim to respond within **35 business days**.
---
## ✅ Supported Versions
We support the latest stable release of `checkcle`. Security patches may also be applied to recent versions at our discretion.
| Version | Supported |
|---------|-----------|
| `main` (latest) | ✅ Yes |
| Older versions | ⚠️ Best-effort |
| Pre-release or forks | ❌ No |
---
## 🔍 Security Practices
CheckCle follows these practices to improve overall security:
- 🔎 Regular vulnerability scanning (npm audit for JavaScript dependencies, govulncheck for Go modules)
- ⛓️ Dependency pinning (package-lock.json and Go modules)
- ✅ Type-safe code in TypeScript and memory-safe design in Go
- 🧪 Continuous testing and CI pipelines
- 🔐 No data is stored or transmitted unless explicitly configured by the user
- 🧑‍💻 All code contributions are reviewed before merging
---
## ⚠️ Known Security Limitations
- Outbound HTTPS requests: CheckCle agents perform outbound HTTPS connections to send metric data to the backend server. Avoid deploying in untrusted or high-risk environments without appropriate network policies and monitoring.
- The data may be lost upon system restarts or crashes. Always ensure that backup (pb_data) and recovery mechanisms are in place in production environments.
---
## 📄 License
This project is released under the [MIT License](./LICENSE). Use at your own risk. The Creator and contributors are not liable for misuse, data loss, or operational impact resulting from use of the software.
---
## 🙌 Acknowledgements
We appreciate responsible disclosures from the community. Your efforts help us make the open-source ecosystem safer for everyone.
Thanks & Regards,
— [Tola Leng](https://github.com/tolaleng)
-1
View File
@@ -20,7 +20,6 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<!-- IMPORTANT: DO NOT REMOVE THIS SCRIPT TAG OR THIS VERY COMMENT! -->
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -30,7 +30,7 @@
"@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-slider": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-switch": "^1.1.0",
@@ -46,6 +46,8 @@
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"jspdf": "^3.0.1",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"pocketbase": "^0.19.0", "pocketbase": "^0.19.0",
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+55 -90
View File
@@ -1,107 +1,72 @@
import React, { useState } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from '@/components/ui/sonner';
import { Toaster } from "@/components/ui/toaster"; import { ThemeProvider } from './contexts/ThemeContext';
import { Toaster as Sonner } from "@/components/ui/sonner"; import { LanguageProvider } from './contexts/LanguageContext';
import { TooltipProvider } from "@/components/ui/tooltip"; import { SidebarProvider } from './contexts/SidebarContext';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { authService } from "@/services/authService";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { LanguageProvider } from "@/contexts/LanguageContext";
import Login from "./pages/Login"; import Index from './pages/Index';
import Dashboard from "./pages/Dashboard"; import Login from './pages/Login';
import ServiceDetail from "./pages/ServiceDetail"; import Dashboard from './pages/Dashboard';
import Settings from "./pages/Settings"; import InstanceMonitoring from './pages/InstanceMonitoring';
import Profile from "./pages/Profile"; import ContainerMonitoring from './pages/ContainerMonitoring';
import NotFound from "./pages/NotFound"; import ServiceDetail from './pages/ServiceDetail';
import SslDomain from './pages/SslDomain';
import ScheduleIncident from './pages/ScheduleIncident';
import OperationalPage from './pages/OperationalPage';
import RegionalMonitoring from './pages/RegionalMonitoring';
import Settings from './pages/Settings';
import Profile from './pages/Profile';
import NotFound from './pages/NotFound';
import PublicStatusPage from './pages/PublicStatusPage';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import ServerDetail from './pages/ServerDetail';
// Create a Protected route component function App() {
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const [queryClient] = useState(() => new QueryClient({
const isAuthenticated = authService.isAuthenticated(); defaultOptions: {
queries: {
if (!isAuthenticated) { staleTime: 60 * 1000
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
const queryClient = new QueryClient();
const App = () => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check authentication status when the app loads
const checkAuth = async () => {
try {
// Just check the auth state
authService.isAuthenticated();
} finally {
setIsLoading(false);
} }
}; }
}));
checkAuth();
}, []);
if (isLoading) {
return <div className="flex items-center justify-center h-screen bg-background text-foreground">Loading...</div>;
}
return ( return (
<QueryClientProvider client={queryClient}> <BrowserRouter>
<ThemeProvider> <ThemeProvider>
<LanguageProvider> <LanguageProvider>
<TooltipProvider> <SidebarProvider>
<Toaster /> <QueryClientProvider client={queryClient}>
<Sonner /> <Toaster />
<BrowserRouter>
<Routes> <Routes>
{/* Public routes */}
<Route path="/public/:slug" element={<PublicStatusPage />} />
{/* Protected routes */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<ProtectedRoute><Index /></ProtectedRoute>} />
<Route <Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
path="/dashboard" <Route path="/instance-monitoring" element={<ProtectedRoute><InstanceMonitoring /></ProtectedRoute>} />
element={ <Route path="/server-detail/:serverId" element={<ProtectedRoute><ServerDetail /></ProtectedRoute>} />
<ProtectedRoute> <Route path="/container-monitoring" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
<Dashboard /> <Route path="/container-monitoring/:serverId" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
</ProtectedRoute> <Route path="/service/:id" element={<ProtectedRoute><ServiceDetail /></ProtectedRoute>} />
} <Route path="/ssl-domain" element={<ProtectedRoute><SslDomain /></ProtectedRoute>} />
/> <Route path="/schedule-incident" element={<ProtectedRoute><ScheduleIncident /></ProtectedRoute>} />
<Route <Route path="/operational-page" element={<ProtectedRoute><OperationalPage /></ProtectedRoute>} />
path="/service/:id" <Route path="/regional-monitoring" element={<ProtectedRoute><RegionalMonitoring /></ProtectedRoute>} />
element={ <Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<ProtectedRoute> <Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<ServiceDetail />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</BrowserRouter> </QueryClientProvider>
</TooltipProvider> </SidebarProvider>
</LanguageProvider> </LanguageProvider>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </BrowserRouter>
); );
}; }
export default App; export default App;
+55 -3
View File
@@ -1,6 +1,7 @@
// API routes handler // API routes handler
import realtime from './realtime'; import realtime from './realtime';
import settingsApi from './settings';
/** /**
* Simple API router for client-side application * Simple API router for client-side application
@@ -10,16 +11,19 @@ const api = {
* Handle API requests * Handle API requests
*/ */
async handleRequest(path, method, body) { async handleRequest(path, method, body) {
console.log(`API request: ${method} ${path}`, body); // console.log(`API request: ${method} ${path}`, body);
// Route to the appropriate handler // Route to the appropriate handler
if (path === '/api/realtime') { if (path === '/api/realtime') {
console.log("Routing to realtime handler"); // console.log("Routing to realtime handler");
return await realtime(body); return await realtime(body);
} else if (path === '/api/settings' || path.startsWith('/api/settings/')) {
// console.log("Routing to settings handler");
return await settingsApi(body, path);
} }
// Return 404 for unknown routes // Return 404 for unknown routes
console.error(`Endpoint not found: ${path}`); // console.error(`Endpoint not found: ${path}`);
return { return {
status: 404, status: 404,
json: { json: {
@@ -31,4 +35,52 @@ const api = {
} }
}; };
// Mock fetch override for development
const originalFetch = window.fetch;
window.fetch = async (url, options = {}) => {
// Check if this is an API request to our mock endpoints
if (typeof url === 'string' && url.startsWith('/api/')) {
// console.log('Intercepting API request:', url, options);
try {
let body = {};
// Properly handle different body types
if (options.body) {
if (typeof options.body === 'string') {
body = JSON.parse(options.body);
} else {
// Handle ReadableStream or other BodyInit types
const bodyText = await new Response(options.body).text();
body = bodyText ? JSON.parse(bodyText) : {};
}
}
const result = await api.handleRequest(url, options.method || 'GET', body);
// Create a proper Response object
return new Response(JSON.stringify(result.json), {
status: result.status,
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error('Error in API handler:', error);
return new Response(JSON.stringify({
success: false,
message: 'Internal server error'
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
// For all other requests, use the original fetch
return originalFetch(url, options);
};
export default api; export default api;
+1 -1
View File
@@ -2,7 +2,7 @@
// This file handles realtime notifications in a client-side environment // This file handles realtime notifications in a client-side environment
// In a production app, this would be a server-side endpoint // In a production app, this would be a server-side endpoint
console.log("API Realtime endpoint loaded"); //console.log("API Realtime endpoint loaded");
// Simple implementation that simulates sending notifications // Simple implementation that simulates sending notifications
export default async function handler(req) { export default async function handler(req) {
@@ -0,0 +1,26 @@
import { getAuthHeaders, getBaseUrl } from '../utils';
import { SettingsApiResponse } from '../types';
export const getSettings = async (): Promise<SettingsApiResponse> => {
try {
const response = await fetch(`${getBaseUrl()}/api/settings`, {
method: 'GET',
headers: getAuthHeaders(),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const settings = await response.json();
return {
status: 200,
json: { success: true, data: settings },
};
} catch (error) {
console.error('Error fetching settings:', error);
return {
status: 500,
json: { success: false, message: 'Failed to fetch settings' },
};
}
};
@@ -0,0 +1,204 @@
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
import { SettingsApiResponse } from '../types';
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
let subject = 'Test Email from CheckCle';
let htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">Test Email</h2>
<p>This is a test email from your monitoring system.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">
Sent from CheckCle Monitoring System<br>
Template: ${template}<br>
${data.collection ? `Collection: ${data.collection}` : ''}
</p>
</div>
</body>
</html>
`;
switch (template) {
case 'verification':
subject = 'Email Verification Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #10b981;">Email Verification Test</h2>
<p>This is a test of the email verification template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Verification Email</p>
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
case 'password-reset':
subject = 'Password Reset Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #f59e0b;">Password Reset Test</h2>
<p>This is a test of the password reset template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Password Reset Email</p>
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
case 'email-change':
subject = 'Email Change Confirmation Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
<p>This is a test of the email change confirmation template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Email Change Confirmation</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
}
return { subject, htmlBody };
};
export const sendTestEmail = async (data: any): Promise<SettingsApiResponse> => {
console.log('sendTestEmail function called with data:', data);
try {
// Validate required fields
if (!data || typeof data !== 'object') {
console.log('Invalid request data - not object');
return {
status: 200,
json: { success: false, message: 'Invalid request data' },
};
}
if (!data.email || typeof data.email !== 'string') {
console.log('Email address missing or invalid type');
return {
status: 200,
json: { success: false, message: 'Email address is required and must be a string' },
};
}
if (!validateEmail(data.email)) {
console.log('Invalid email format:', data.email);
return {
status: 200,
json: { success: false, message: 'Invalid email address format' },
};
}
console.log('Email validation passed for:', data.email);
const headers = getAuthHeaders();
const baseUrl = getBaseUrl();
// Get current SMTP settings first
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
method: 'GET',
headers,
});
if (!settingsResponse.ok) {
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
return {
status: 200,
json: { success: false, message: 'Failed to get SMTP settings' },
};
}
const settingsData = await settingsResponse.json();
console.log('Retrieved settings data:', settingsData);
const smtpSettings = settingsData?.smtp;
if (!smtpSettings || !smtpSettings.enabled) {
console.log('SMTP not enabled or missing');
return {
status: 200,
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
};
}
if (!smtpSettings.host || !smtpSettings.username) {
console.log('SMTP configuration incomplete - missing host or username');
return {
status: 200,
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
};
}
if (!smtpSettings.password) {
console.log('SMTP password missing');
return {
status: 200,
json: { success: false, message: 'SMTP password is required for authentication. Please configure the SMTP password.' },
};
}
// Create test email content based on template
const template = data.template || 'basic';
const { subject, htmlBody } = createEmailTemplate(template, data);
console.log('Test email prepared successfully:', {
to: data.email,
subject: subject,
template: template,
smtpHost: smtpSettings.host,
smtpPort: smtpSettings.port || 587
});
// Simulate processing time
console.log('Simulating email send...');
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Email send simulation completed');
return {
status: 200,
json: {
success: true,
message: `Test email sent successfully to ${data.email}`,
},
};
} catch (error) {
console.error('Error in sendTestEmail function:', error);
return {
status: 200,
json: {
success: false,
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
},
};
}
};
@@ -0,0 +1,238 @@
import { getAuthHeaders, getBaseUrl, validateEmail } from '../utils';
import { SettingsApiResponse } from '../types';
const createEmailTemplate = (template: string, data: any): { subject: string; htmlBody: string } => {
let subject = 'Test Email from CheckCle';
let htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">Test Email</h2>
<p>This is a test email from your monitoring system.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">
Sent from CheckCle Monitoring System<br>
Template: ${template}<br>
${data.collection ? `Collection: ${data.collection}` : ''}
</p>
</div>
</body>
</html>
`;
switch (template) {
case 'verification':
subject = 'Email Verification Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #10b981;">Email Verification Test</h2>
<p>This is a test of the email verification template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Verification Email</p>
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
case 'password-reset':
subject = 'Password Reset Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #f59e0b;">Password Reset Test</h2>
<p>This is a test of the password reset template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Password Reset Email</p>
<p><strong>Collection:</strong> ${data.collection || '_superusers'}</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
case 'email-change':
subject = 'Email Change Confirmation Test - CheckCle';
htmlBody = `
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #8b5cf6;">Email Change Confirmation Test</h2>
<p>This is a test of the email change confirmation template.</p>
<p>If you received this email, your SMTP configuration is working correctly.</p>
<div style="background: #f3f4f6; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p><strong>Template:</strong> Email Change Confirmation</p>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">Sent from CheckCle Monitoring System</p>
</div>
</body>
</html>
`;
break;
}
return { subject, htmlBody };
};
export const testEmail = async (data: any): Promise<SettingsApiResponse> => {
console.log('testEmail function called with data:', data);
try {
// Validate required fields
if (!data || typeof data !== 'object') {
console.log('Invalid request data - not object');
return {
status: 200,
json: { success: false, message: 'Invalid request data' },
};
}
if (!data.email || typeof data.email !== 'string') {
console.log('Email address missing or invalid type');
return {
status: 200,
json: { success: false, message: 'Email address is required and must be a string' },
};
}
if (!validateEmail(data.email)) {
console.log('Invalid email format:', data.email);
return {
status: 200,
json: { success: false, message: 'Invalid email address format' },
};
}
console.log('Email validation passed for:', data.email);
const headers = getAuthHeaders();
const baseUrl = getBaseUrl();
// Get current SMTP settings first
console.log('Fetching SMTP settings from:', `${baseUrl}/api/settings`);
const settingsResponse = await fetch(`${baseUrl}/api/settings`, {
method: 'GET',
headers,
});
if (!settingsResponse.ok) {
console.error('Failed to get SMTP settings, status:', settingsResponse.status);
return {
status: 200,
json: { success: false, message: 'Failed to get SMTP settings' },
};
}
const settingsData = await settingsResponse.json();
console.log('Retrieved settings data:', settingsData);
const smtpSettings = settingsData?.smtp;
if (!smtpSettings || !smtpSettings.enabled) {
console.log('SMTP not enabled or missing');
return {
status: 200,
json: { success: false, message: 'SMTP is not enabled. Please enable and configure SMTP settings first.' },
};
}
if (!smtpSettings.host || !smtpSettings.username) {
console.log('SMTP configuration incomplete - missing host or username');
return {
status: 200,
json: { success: false, message: 'SMTP configuration is incomplete. Please check host and username.' },
};
}
// Create test email content based on template
const template = data.template || 'basic';
const { subject, htmlBody } = createEmailTemplate(template, data);
console.log('Test email prepared successfully:', {
to: data.email,
subject: subject,
template: template,
smtpHost: smtpSettings.host,
smtpPort: smtpSettings.port || 587
});
// Send actual email using the correct PocketBase API endpoint
console.log('Sending actual email via PocketBase...');
// Fix the payload structure to match PocketBase API expectations
const emailPayload = {
email: data.email, // Use 'email' instead of 'to'
template: template, // Add the template field
subject: subject,
html: htmlBody,
};
console.log('Email payload:', emailPayload);
const emailResponse = await fetch(`${baseUrl}/api/settings/test/email`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(emailPayload),
});
if (!emailResponse.ok) {
console.error('Failed to send email, status:', emailResponse.status);
const errorText = await emailResponse.text();
console.error('Email send error response:', errorText);
return {
status: 200,
json: { success: false, message: 'Failed to send email. Please check your SMTP configuration.' },
};
}
// Handle 204 No Content response (successful but no body)
if (emailResponse.status === 204) {
console.log('Email sent successfully (204 No Content)');
return {
status: 200,
json: {
success: true,
message: `Test email sent successfully to ${data.email}`,
},
};
}
// For other successful responses, try to parse JSON
const emailResult = await emailResponse.json();
console.log('Email sent successfully:', emailResult);
return {
status: 200,
json: {
success: true,
message: `Test email sent successfully to ${data.email}`,
},
};
} catch (error) {
console.error('Error in testEmail function:', error);
return {
status: 200,
json: {
success: false,
message: error instanceof Error ? error.message : 'Failed to send test email. Please check your SMTP configuration.'
},
};
}
};
@@ -0,0 +1,31 @@
import { getAuthHeaders, getBaseUrl } from '../utils';
import { SettingsApiResponse } from '../types';
export const testEmailConnection = async (data: any): Promise<SettingsApiResponse> => {
try {
const response = await fetch(`${getBaseUrl()}/api/settings/test-email`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const result = await response.json();
return {
status: 200,
json: {
success: result.success || false,
message:
result.message || (result.success ? 'Connection successful' : 'Connection failed'),
},
};
} catch (error) {
console.error('Error testing email connection:', error);
return {
status: 500,
json: { success: false, message: 'Failed to test email connection' },
};
}
};
@@ -0,0 +1,38 @@
import { getAuthHeaders, getBaseUrl } from '../utils';
import { SettingsApiResponse } from '../types';
export const updateSettings = async (data: any): Promise<SettingsApiResponse> => {
try {
const headers = getAuthHeaders();
const baseUrl = getBaseUrl();
let response = await fetch(`${baseUrl}/api/settings`, {
method: 'PATCH',
headers,
body: JSON.stringify(data),
});
if (!response.ok && (response.status === 404 || response.status === 405)) {
response = await fetch(`${baseUrl}/api/settings`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
}
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const updatedSettings = await response.json();
return {
status: 200,
json: { success: true, data: updatedSettings },
};
} catch (error) {
console.error('Error updating settings:', error);
return {
status: 500,
json: { success: false, message: 'Failed to update settings' },
};
}
};
+42
View File
@@ -0,0 +1,42 @@
import { getSettings } from './actions/getSettings';
import { updateSettings } from './actions/updateSettings';
import { testEmailConnection } from './actions/testEmailConnection';
import { testEmail } from './actions/testEmail';
/**
* Settings API handler
*/
const settingsApi = async (body: any, path?: string) => {
// console.log('Settings API called with path:', path, 'body:', body);
// Handle test email endpoint specifically
if (path === '/api/settings/test/email') {
console.log('Handling test email request');
return await testEmail(body);
}
// Handle regular settings API with action-based routing
const action = body?.action;
// console.log('Settings API called with action:', action, 'data:', body?.data);
switch (action) {
case 'getSettings':
return await getSettings();
case 'updateSettings':
return await updateSettings(body.data);
case 'testEmailConnection':
return await testEmailConnection(body.data);
default:
console.error('Unknown action:', action);
return {
status: 400,
json: { success: false, message: 'Unknown action' },
};
}
};
export default settingsApi;
+25
View File
@@ -0,0 +1,25 @@
export interface SettingsApiRequest {
action: string;
data?: any;
}
export interface SettingsApiResponse {
status: number;
json: {
success: boolean;
data?: any;
message?: string;
};
}
export interface SmtpSettings {
enabled?: boolean;
host?: string;
port?: number;
username?: string;
password?: string;
authMethod?: string;
tls?: boolean;
localName?: string;
}
+24
View File
@@ -0,0 +1,24 @@
import { pb, getCurrentEndpoint } from '@/lib/pocketbase';
export const getAuthHeaders = (): Record<string, string> => {
const authToken = pb.authStore.token;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
return headers;
};
export const getBaseUrl = (): string => {
return getCurrentEndpoint();
};
export const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
@@ -0,0 +1,49 @@
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center p-6">
<h2 className="text-2xl font-bold text-destructive mb-4">Something went wrong</h2>
<p className="text-muted-foreground mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,259 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from '@/components/ui/use-toast';
import { Mail, ArrowLeft } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
import { getCurrentEndpoint } from '@/lib/pocketbase';
interface ForgotPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ForgotPasswordDialog({ open, onOpenChange }: ForgotPasswordDialogProps) {
const [step, setStep] = useState<'request' | 'confirm'>('request');
const [email, setEmail] = useState('');
const [token, setToken] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [loading, setLoading] = useState(false);
const { t } = useLanguage();
const handleRequestReset = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const apiUrl = getCurrentEndpoint();
const response = await fetch(`${apiUrl}/api/collections/_superusers/request-password-reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to send reset email');
}
toast({
title: "Reset Email Sent",
description: "Please check your email for password reset instructions.",
});
setStep('confirm');
} catch (error) {
console.error('Password reset request error:', error);
toast({
variant: "destructive",
title: "Reset Failed",
description: error instanceof Error ? error.message : "Failed to send reset email. Please try again.",
});
} finally {
setLoading(false);
}
};
const handleConfirmReset = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== passwordConfirm) {
toast({
variant: "destructive",
title: "Password Mismatch",
description: "Passwords do not match. Please try again.",
});
return;
}
if (password.length < 6) {
toast({
variant: "destructive",
title: "Password Too Short",
description: "Password must be at least 6 characters long.",
});
return;
}
setLoading(true);
try {
const apiUrl = getCurrentEndpoint();
const response = await fetch(`${apiUrl}/api/collections/_superusers/confirm-password-reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
password,
passwordConfirm
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to reset password');
}
toast({
title: "Password Reset Successful",
description: "Your password has been reset successfully. You can now log in with your new password.",
});
onOpenChange(false);
// Reset form state
setStep('request');
setEmail('');
setToken('');
setPassword('');
setPasswordConfirm('');
} catch (error) {
console.error('Password reset confirmation error:', error);
toast({
variant: "destructive",
title: "Reset Failed",
description: error instanceof Error ? error.message : "Failed to reset password. Please try again.",
});
} finally {
setLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset form state when closing
setStep('request');
setEmail('');
setToken('');
setPassword('');
setPasswordConfirm('');
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{step === 'request' ? 'Reset Password' : 'Confirm Password Reset'}
</DialogTitle>
<DialogDescription>
{step === 'request'
? 'Enter your email address and we\'ll send you a reset link.'
: 'Enter the reset token from your email and your new password.'
}
</DialogDescription>
</DialogHeader>
{step === 'request' ? (
<form onSubmit={handleRequestReset} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="reset-email">
Email Address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Mail className="h-4 w-4 text-muted-foreground" />
</div>
<Input
id="reset-email"
placeholder="your.email@provider.com"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10"
/>
</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={handleClose}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || !email}
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
>
{loading ? 'Sending...' : 'Send Reset Email'}
</Button>
</div>
</form>
) : (
<form onSubmit={handleConfirmReset} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="reset-token">
Reset Token
</label>
<Input
id="reset-token"
placeholder="Enter token from email"
type="text"
value={token}
onChange={(e) => setToken(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="new-password">
New Password
</label>
<Input
id="new-password"
placeholder="••••••••••••"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-foreground" htmlFor="confirm-password">
Confirm Password
</label>
<Input
id="confirm-password"
placeholder="••••••••••••"
type="password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required
minLength={6}
/>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setStep('request')}
className="flex-1"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Button
type="submit"
disabled={loading || !token || !password || !passwordConfirm}
className="flex-1 bg-emerald-500 hover:bg-emerald-600"
>
{loading ? 'Resetting...' : 'Reset Password'}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,16 @@
import { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { authService } from '@/services/authService';
interface ProtectedRouteProps {
children: ReactNode;
}
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
if (!authService.isAuthenticated()) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
@@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useSystemSettings } from "@/hooks/useSystemSettings"; import { useSystemSettings } from "@/hooks/useSystemSettings";
import QuickActionsDialog from "./QuickActionsDialog";
interface HeaderProps { interface HeaderProps {
currentUser: AuthUser | null; currentUser: AuthUser | null;
@@ -28,6 +29,7 @@ export const Header = ({
const [greeting, setGreeting] = useState<string>(""); const [greeting, setGreeting] = useState<string>("");
const { systemName } = useSystemSettings(); const { systemName } = useSystemSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
// Set greeting based on time of day // Set greeting based on time of day
useEffect(() => { useEffect(() => {
@@ -51,7 +53,7 @@ export const Header = ({
// Log avatar data for debugging // Log avatar data for debugging
useEffect(() => { useEffect(() => {
if (currentUser) { if (currentUser) {
console.log("Avatar URL in Header:", currentUser.avatar); // console.log("Avatar URL in Header:", currentUser.avatar);
} }
}, [currentUser]); }, [currentUser]);
@@ -64,7 +66,7 @@ export const Header = ({
} else { } else {
avatarUrl = currentUser.avatar; avatarUrl = currentUser.avatar;
} }
console.log("Final avatar URL:", avatarUrl); // console.log("Final avatar URL:", avatarUrl);
} }
return ( return (
@@ -88,8 +90,18 @@ export const Header = ({
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2"> <Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />} {sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</Button> </Button>
<div className="flex items-center space-x-2">
{/* Quick Actions Button */}
<Button
variant="ghost"
size="icon"
onClick={() => setQuickActionsOpen(true)}
className="mr-2"
>
<Grid3x3 className="h-5 w-5 text-green-500" /> <Grid3x3 className="h-5 w-5 text-green-500" />
</Button>
<div className="flex items-center space-x-2">
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 </h1> <h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 </h1>
</div> </div>
</div> </div>
@@ -114,29 +126,55 @@ export const Header = ({
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}> <DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
{t("khmer")} {t("khmer")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
{t("Deutsch")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}>
{t("japanese")}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Documentation */} {/* Documentation */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border"> <Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://docs.checkcle.io", "_blank")}
>
<span className="sr-only">{t("documentation")}</span> <span className="sr-only">{t("documentation")}</span>
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
</Button> </Button>
{/* GitHub */} {/* GitHub */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border"> <Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://github.com/operacle/checkcle", "_blank")}
>
<span className="sr-only">GitHub</span> <span className="sr-only">GitHub</span>
<Github className="w-4 h-4" /> <Github className="w-4 h-4" />
</Button> </Button>
{/* X (Twitter) */} {/* X (Twitter) */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border"> <Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://x.com/tlengoss", "_blank")}
>
<span className="sr-only">X (Twitter)</span> <span className="sr-only">X (Twitter)</span>
<Twitter className="w-4 h-4" /> <Twitter className="w-4 h-4" />
</Button> </Button>
{/* Discord (replaced with MessageSquare) */} {/* Discord */}
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border"> <Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://discord.gg/xs9gbubGwX", "_blank")}
>
<span className="sr-only">Discord</span> <span className="sr-only">Discord</span>
<MessageSquare className="w-4 h-4" /> <MessageSquare className="w-4 h-4" />
</Button> </Button>
@@ -185,6 +223,9 @@ export const Header = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Quick Actions Dialog */}
<QuickActionsDialog isOpen={quickActionsOpen} setIsOpen={setQuickActionsOpen} />
</header> </header>
); );
}; };
@@ -0,0 +1,145 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogClose
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
interface QuickActionsDialogProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
export const QuickActionsDialog: React.FC<QuickActionsDialogProps> = ({ isOpen, setIsOpen }) => {
const { t } = useLanguage();
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-[800px] bg-background max-h-[90vh] overflow-y-auto">
<DialogClose className="absolute right-4 top-4">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
<DialogHeader>
<DialogTitle className="text-xl font-bold">{t('quickActions')}</DialogTitle>
<DialogDescription>
{t('quickActionsDescription')}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
{/* Website Monitoring Card */}
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
<h3 className="font-semibold text-lg mb-2">{t('monitorWebsite')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('monitorWebsiteDesc')}
</p>
<div className="space-y-2">
<p className="text-sm font-medium">{t('quickTips')}:</p>
<ul className="list-disc pl-5 text-sm space-y-1">
<li>{t('monitorMultipleEndpoints')}</li>
<li>{t('trackResponseTimes')}</li>
<li>{t('setCustomAlerts')}</li>
</ul>
</div>
</div>
{/* Server Monitoring Card */}
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
<h3 className="font-semibold text-lg mb-2">{t('monitorServer')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('monitorServerDesc')}
</p>
<div className="space-y-2">
<p className="text-sm font-medium">{t('quickTips')}:</p>
<ul className="list-disc pl-5 text-sm space-y-1">
<li>{t('monitorCPURam')}</li>
<li>{t('trackNetworkMetrics')}</li>
<li>{t('viewProcesses')}</li>
</ul>
</div>
</div>
{/* SSL Certificate Card */}
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
<h3 className="font-semibold text-lg mb-2">{t('sslCertificate')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('sslCertificateDesc')}
</p>
<div className="space-y-2">
<p className="text-sm font-medium">{t('quickTips')}:</p>
<ul className="list-disc pl-5 text-sm space-y-1">
<li>{t('trackSSLExpiration')}</li>
<li>{t('getEarlyRenewal')}</li>
<li>{t('monitorMultipleDomains')}</li>
</ul>
</div>
</div>
{/* Incidents Management Card */}
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
<h3 className="font-semibold text-lg mb-2">{t('incidentsManagement')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('incidentsManagementDesc')}
</p>
<div className="space-y-2">
<p className="text-sm font-medium">{t('quickTips')}:</p>
<ul className="list-disc pl-5 text-sm space-y-1">
<li>{t('createTrackIncidents')}</li>
<li>{t('assignIncidents')}</li>
<li>{t('monitorResolution')}</li>
</ul>
</div>
</div>
{/* Operational Status Card */}
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
<h3 className="font-semibold text-lg mb-2">{t('operationalStatus')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('operationalStatusDesc')}
</p>
<div className="space-y-2">
<p className="text-sm font-medium">{t('quickTips')}:</p>
<ul className="list-disc pl-5 text-sm space-y-1">
<li>{t('createCustomStatus')}</li>
<li>{t('displayRealTime')}</li>
<li>{t('shareIncidentUpdates')}</li>
</ul>
</div>
</div>
{/* Reports & Analytics Card */}
<div className="border rounded-lg p-4 bg-card hover:shadow-md transition-shadow">
<h3 className="font-semibold text-lg mb-2">{t('reportsAnalytics')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('reportsAnalyticsDesc')}
</p>
<div className="space-y-2">
<p className="text-sm font-medium">{t('quickTips')}:</p>
<ul className="list-disc pl-5 text-sm space-y-1">
<li>{t('generateUptimeReports')}</li>
<li>{t('analyzePerformance')}</li>
<li>{t('exportMonitoringData')}</li>
</ul>
</div>
</div>
</div>
<div className="flex justify-end mt-6">
<Button variant="outline" onClick={() => setIsOpen(false)}>
{t('close')}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default QuickActionsDialog;
+19 -132
View File
@@ -1,141 +1,28 @@
import { Globe, Boxes, Radar, Calendar, BarChart2, LineChart, FileText, Settings, User, UserCog, Bell, FileClock, Database, RefreshCw, Info, ChevronDown, BookOpen } from "lucide-react"; import React from "react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { Link, useLocation } from "react-router-dom"; import { SidebarHeader } from "./sidebar/SidebarHeader";
import { useState, useEffect } from "react"; import { MainNavigation } from "./sidebar/MainNavigation";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { SettingsPanel } from "./sidebar/SettingsPanel";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useLanguage } from "@/contexts/LanguageContext";
interface SidebarProps { interface SidebarProps {
collapsed: boolean; collapsed: boolean;
} }
export const Sidebar = ({ export const Sidebar = ({ collapsed }: SidebarProps) => {
collapsed const { theme } = useTheme();
}: SidebarProps) => {
const {
theme
} = useTheme();
const {
t
} = useLanguage();
const location = useLocation();
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
// Update active settings item based on URL return (
useEffect(() => { <div
if (location.pathname === '/settings') { className={`
const params = new URLSearchParams(location.search); ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'}
const panel = params.get('panel'); border-r flex flex-col h-full
if (panel) { ${collapsed ? 'w-16' : 'w-64'}
setActiveSettingsItem(panel); `}
} >
} <SidebarHeader collapsed={collapsed} />
}, [location]); <MainNavigation collapsed={collapsed} />
<SettingsPanel collapsed={collapsed} />
const handleSettingsItemClick = (item: string) => { </div>
setActiveSettingsItem(item); );
};
const getMenuItemClasses = (isActive: boolean) => {
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
};
// New larger icon size for the main menu
const mainIconSize = "h-6 w-6";
return <div className={`${collapsed ? 'w-16' : 'w-64'} ${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} border-r flex flex-col transition-all duration-300 h-full`}>
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
<div className="h-8 w-8 bg-green-500 rounded flex items-center justify-center mr-2">
<span className="text-white font-bold">C</span>
</div>
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
</div>
<nav className="my-2 mx-1 py-1 px-1">
<Link to="/dashboard" className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg ${location.pathname === '/dashboard' ? theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Globe className={`${mainIconSize} text-purple-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("uptimeMonitoring")}</span>}
</Link>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Boxes className={`${mainIconSize} text-blue-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("instanceMonitoring")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Radar className={`${mainIconSize} text-cyan-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("sslDomain")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<Calendar className={`${mainIconSize} text-emerald-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("scheduleIncident")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<BarChart2 className={`${mainIconSize} text-amber-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("operationalPage")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<LineChart className={`${mainIconSize} text-rose-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("reports")}</span>}
</div>
<div className={`${collapsed ? 'p-3' : 'p-2 pl-3'} mb-1 rounded-lg hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'} flex items-center ${collapsed ? 'justify-center' : ''} transition-colors duration-200`}>
<FileText className={`${mainIconSize} text-indigo-400`} />
{!collapsed && <span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">{t("apiDocumentation")}</span>}
</div>
</nav>
{!collapsed && <div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
<div className="flex items-center">
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
</div>
<div className="flex items-center">
<Settings className="h-4 w-4 mr-1" />
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
<ScrollArea className="h-full">
<div className="space-y-2 pr-4">
<Link to={`/settings?panel=general`} className={getMenuItemClasses(activeSettingsItem === 'general')} onClick={() => handleSettingsItemClick('general')}>
<Settings className="h-4 w-4 mr-2" />
<span className="text-sm">{t("generalSettings")}</span>
</Link>
<Link to={`/settings?panel=users`} className={getMenuItemClasses(activeSettingsItem === 'users')} onClick={() => handleSettingsItemClick('users')}>
<User className="h-4 w-4 mr-2" />
<span className="text-sm">{t("userManagement")}</span>
</Link>
<Link to={`/settings?panel=notifications`} className={getMenuItemClasses(activeSettingsItem === 'notifications')} onClick={() => handleSettingsItemClick('notifications')}>
<Bell className="h-4 w-4 mr-2" />
<span className="text-sm">{t("notificationSettings")}</span>
</Link>
<Link to={`/settings?panel=templates`} className={getMenuItemClasses(activeSettingsItem === 'templates')} onClick={() => handleSettingsItemClick('templates')}>
<BookOpen className="h-4 w-4 mr-2" />
<span className="text-sm">{t("alertsTemplates")}</span>
</Link>
<div className={getMenuItemClasses(false)}>
<Database className="h-4 w-4 mr-2" />
<span className="text-sm">{t("dataRetention")}</span>
</div>
<Link to={`/settings?panel=about`} className={getMenuItemClasses(activeSettingsItem === 'about')} onClick={() => handleSettingsItemClick('about')}>
<Info className="h-4 w-4 mr-2" />
<span className="text-sm">{t("aboutSystem")}</span>
</Link>
</div>
</ScrollArea>
</div>
</CollapsibleContent>
</Collapsible>
</div>}
{collapsed && <div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
<Link to="/settings">
<Settings className={`${mainIconSize} text-purple-400`} />
</Link>
</div>}
</div>;
}; };
@@ -27,15 +27,15 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
} relative z-10`} } relative z-10`}
style={{ style={{
background: theme === 'dark' background: theme === 'dark'
? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)" ? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
: "linear-gradient(135deg, #43a047 0%, #66bb6a 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #66bb6a 100%)"
}} }}
> >
{/* Grid Pattern Overlay */} {/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10"> <div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full" <div className="w-full h-full"
style={{ style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px), backgroundImage: `linear-gradient(#000 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`, linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px' backgroundSize: '20px 20px'
}} }}
@@ -59,15 +59,15 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
} relative z-10`} } relative z-10`}
style={{ style={{
background: theme === 'dark' background: theme === 'dark'
? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)" ? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
: "linear-gradient(135deg, #e53935 0%, #ef5350 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ef5350 100%)"
}} }}
> >
{/* Grid Pattern Overlay */} {/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10"> <div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full" <div className="w-full h-full"
style={{ style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px), backgroundImage: `linear-gradient(#000 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`, linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px' backgroundSize: '20px 20px'
}} }}
@@ -91,15 +91,15 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
} relative z-10`} } relative z-10`}
style={{ style={{
background: theme === 'dark' background: theme === 'dark'
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)" ? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
: "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #42a5f5 100%)"
}} }}
> >
{/* Grid Pattern Overlay */} {/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10"> <div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full" <div className="w-full h-full"
style={{ style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px), backgroundImage: `linear-gradient(#000 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`, linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px' backgroundSize: '20px 20px'
}} }}
@@ -123,15 +123,15 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
} relative z-10`} } relative z-10`}
style={{ style={{
background: theme === 'dark' background: theme === 'dark'
? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)" ? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
: "linear-gradient(135deg, #ff9800 0%, #ffb74d 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ffb74d 100%)"
}} }}
> >
{/* Grid Pattern Overlay */} {/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10"> <div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full" <div className="w-full h-full"
style={{ style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px), backgroundImage: `linear-gradient(#000 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`, linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px' backgroundSize: '20px 20px'
}} }}
@@ -0,0 +1,27 @@
import React from "react";
import { MenuItem } from "./MenuItem";
import { mainMenuItems } from "./navigationData";
interface MainNavigationProps {
collapsed: boolean;
}
export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) => {
return (
<nav className="my-2 mx-1 py-1 px-1">
{mainMenuItems.map((item) => (
<MenuItem
key={item.id}
id={item.id}
path={item.path}
icon={item.icon}
translationKey={item.translationKey}
color={item.color}
hasNavigation={item.hasNavigation}
collapsed={collapsed}
/>
))}
</nav>
);
};
@@ -0,0 +1,64 @@
import React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { LucideIcon } from "lucide-react";
interface MenuItemProps {
id: string;
path: string | null;
icon: LucideIcon;
translationKey: string;
color: string;
hasNavigation: boolean;
collapsed: boolean;
}
export const MenuItem: React.FC<MenuItemProps> = ({
id,
path,
icon: Icon,
translationKey,
color,
hasNavigation,
collapsed
}) => {
const { theme } = useTheme();
const { t } = useLanguage();
const location = useLocation();
const navigate = useNavigate();
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (hasNavigation && path) {
navigate(path, { replace: false });
}
};
const isActive = path && location.pathname === path;
const mainIconSize = "h-6 w-6";
return (
<div
className={`
${collapsed ? 'p-3' : 'p-2 pl-3'}
mb-1 rounded-lg
${isActive ? (theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent') : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`}
flex items-center
${collapsed ? 'justify-center' : ''}
cursor-pointer
`}
onClick={handleClick}
>
<Icon className={`${mainIconSize} ${color}`} />
{!collapsed && (
<span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]">
{t(translationKey)}
</span>
)}
</div>
);
};
@@ -0,0 +1,100 @@
import React, { useState, useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
import { Settings, ChevronDown } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { settingsMenuItems } from "./navigationData";
interface SettingsPanelProps {
collapsed: boolean;
}
export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
const { theme } = useTheme();
const { t } = useLanguage();
const location = useLocation();
const navigate = useNavigate();
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
// Update active settings item based on URL
useEffect(() => {
if (location.pathname === '/settings') {
const params = new URLSearchParams(location.search);
const panel = params.get('panel');
if (panel) {
setActiveSettingsItem(panel);
}
}
}, [location]);
const handleSettingsItemClick = (item: string) => {
setActiveSettingsItem(item);
};
const handleMenuItemClick = (path: string, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
// Use navigate instead of window.location to prevent full page reload
navigate(path, { replace: false });
};
const getMenuItemClasses = (isActive: boolean) => {
return `p-2 ${isActive ? theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent' : `hover:${theme === 'dark' ? 'bg-[#1a1a1a]' : 'bg-sidebar-accent'}`} rounded-lg flex items-center`;
};
if (collapsed) {
const mainIconSize = "h-6 w-6";
return (
<div className={`border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4 flex justify-center`}>
<div
onClick={(e) => handleMenuItemClick('/settings', e)}
className="cursor-pointer"
>
<Settings className={`${mainIconSize} text-purple-400`} />
</div>
</div>
);
}
return (
<div className={`flex-1 flex flex-col border-t ${theme === 'dark' ? 'border-[#1e1e1e] bg-[#121212]' : 'border-sidebar-border bg-sidebar'} p-4`}>
<Collapsible open={settingsPanelOpen} onOpenChange={setSettingsPanelOpen} className="w-full flex flex-col flex-1">
<CollapsibleTrigger className={`flex items-center justify-between w-full mb-4 px-2 py-2 rounded-lg ${theme === 'dark' ? 'hover:bg-[#1a1a1a]' : 'hover:bg-sidebar-accent'}`}>
<div className="flex items-center">
<span className="font-medium tracking-wide">{t("settingPanel")}</span>
</div>
<div className="flex items-center">
<Settings className="h-4 w-4 mr-1" />
<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${settingsPanelOpen ? 'rotate-180' : ''}`} />
</div>
</CollapsibleTrigger>
<CollapsibleContent className={`${theme === 'dark' ? 'bg-[#121212]' : 'bg-sidebar'} flex-1 flex flex-col`}>
<div className="max-h-[300px] overflow-y-auto custom-scrollbar relative pr-1">
<ScrollArea className="h-full">
<div className="space-y-2 pr-4">
{settingsMenuItems.map((item) => (
<div
key={item.id}
className={getMenuItemClasses(activeSettingsItem === item.id)}
onClick={(e) => {
handleMenuItemClick(`/settings?panel=${item.id}`, e);
handleSettingsItemClick(item.id);
}}
>
<item.icon className="h-4 w-4 mr-2" />
<span className="text-sm">{t(item.translationKey)}</span>
</div>
))}
</div>
</ScrollArea>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
@@ -0,0 +1,24 @@
import React from "react";
import { useTheme } from "@/contexts/ThemeContext";
interface SidebarHeaderProps {
collapsed: boolean;
}
export const SidebarHeader: React.FC<SidebarHeaderProps> = ({ collapsed }) => {
const { theme } = useTheme();
return (
<div className={`p-4 ${theme === 'dark' ? 'border-[#1e1e1e]' : 'border-sidebar-border'} border-b flex items-center ${collapsed ? 'justify-center' : ''}`}>
<div className="h-8 w-8 bg-gray-600 rounded flex items-center justify-center mr-2">
<img
src="/favicon_sidebar.ico"
alt="CheckCle"
className="h-6 w-6"
/>
</div>
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
</div>
);
};
@@ -0,0 +1,6 @@
export { SidebarHeader } from './SidebarHeader';
export { MainNavigation } from './MainNavigation';
export { MenuItem } from './MenuItem';
export { SettingsPanel } from './SettingsPanel';
export { mainMenuItems, settingsMenuItems } from './navigationData';
@@ -0,0 +1,94 @@
import { Globe, Boxes, Layers, Calendar, BarChart2, LineChart, MapPin, Settings, User, Bell, Database, Info, BookOpen } from "lucide-react";
export const mainMenuItems = [
{
id: 'uptime-monitoring',
path: '/dashboard',
icon: Globe,
translationKey: 'uptimeMonitoring',
color: 'text-purple-400',
hasNavigation: true
},
{
id: 'instance-monitoring',
path: '/instance-monitoring',
icon: Boxes,
translationKey: 'instanceMonitoring',
color: 'text-blue-400',
hasNavigation: true
},
{
id: 'ssl-domain',
path: '/ssl-domain',
icon: Layers,
translationKey: 'sslDomain',
color: 'text-cyan-400',
hasNavigation: true
},
{
id: 'schedule-incident',
path: '/schedule-incident',
icon: Calendar,
translationKey: 'scheduleIncident',
color: 'text-emerald-400',
hasNavigation: true
},
{
id: 'operational-page',
path: '/operational-page',
icon: BarChart2,
translationKey: 'operationalPage',
color: 'text-amber-400',
hasNavigation: true
},
{
id: 'regional-monitoring',
path: '/regional-monitoring',
icon: MapPin,
translationKey: 'regionalMonitoring',
color: 'text-indigo-400',
hasNavigation: true
},
{
id: 'reports',
path: null,
icon: LineChart,
translationKey: 'reports',
color: 'text-rose-400',
hasNavigation: false
}
];
export const settingsMenuItems = [
{
id: 'general',
icon: Settings,
translationKey: 'generalSettings'
},
{
id: 'users',
icon: User,
translationKey: 'userManagement'
},
{
id: 'notifications',
icon: Bell,
translationKey: 'notificationSettings'
},
{
id: 'templates',
icon: BookOpen,
translationKey: 'alertsTemplates'
},
{
id: 'data-retention',
icon: Database,
translationKey: 'dataRetention'
},
{
id: 'about',
icon: Info,
translationKey: 'aboutSystem'
}
];
@@ -0,0 +1,97 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody } from "@/components/ui/table";
import { DockerContainer } from "@/types/docker.types";
import { DockerMetricsDialog } from "./DockerMetricsDialog";
import {
DockerTableSearch,
DockerTableHeader,
DockerTableRow,
DockerEmptyState
} from "./table";
interface DockerContainersTableProps {
containers: DockerContainer[];
isLoading: boolean;
onRefresh: () => void;
}
export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => {
const [searchTerm, setSearchTerm] = useState("");
const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null);
const [metricsDialogOpen, setMetricsDialogOpen] = useState(false);
const filteredContainers = containers.filter(container =>
container.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
container.docker_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
container.hostname.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleContainerAction = (action: string, containerId: string, containerName: string) => {
console.log(`${action} action for container ${containerName} (${containerId})`);
// TODO: Implement container actions
};
const handleRowClick = (container: DockerContainer) => {
setSelectedContainer(container);
setMetricsDialogOpen(true);
};
const handleViewMetrics = (container: DockerContainer) => {
setSelectedContainer(container);
setMetricsDialogOpen(true);
};
return (
<>
<Card className="w-full bg-transparent border-0 shadow-none">
<CardHeader className="pb-4 px-0">
<div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle className="text-lg sm:text-xl font-semibold">Docker Containers</CardTitle>
<DockerTableSearch
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onRefresh={onRefresh}
isLoading={isLoading}
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<div className="min-w-full inline-block align-middle">
<div className="overflow-hidden border border-border rounded-lg shadow-sm">
<Table>
<DockerTableHeader />
<TableBody>
{filteredContainers.length === 0 ? (
<DockerEmptyState searchTerm={searchTerm} />
) : (
filteredContainers.map((container) => (
<DockerTableRow
key={container.id}
container={container}
onRowClick={handleRowClick}
onContainerAction={handleContainerAction}
onViewMetrics={handleViewMetrics}
/>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
</CardContent>
</Card>
<DockerMetricsDialog
container={selectedContainer}
open={metricsDialogOpen}
onOpenChange={setMetricsDialogOpen}
/>
</>
);
};
@@ -0,0 +1,695 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, AreaChart, Area } from "recharts";
import { DockerContainer, DockerMetrics } from "@/types/docker.types";
import { dockerService } from "@/services/dockerService";
import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
interface DockerMetricsDialogProps {
container: DockerContainer | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
const timeRangeOptions = [
{ value: '60m' as TimeRange, label: '60 minutes', hours: 1 },
{ value: '1d' as TimeRange, label: '1 day', hours: 24 },
{ value: '7d' as TimeRange, label: '7 days', hours: 24 * 7 },
{ value: '1m' as TimeRange, label: '1 month', hours: 24 * 30 },
{ value: '3m' as TimeRange, label: '3 months', hours: 24 * 90 },
];
export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => {
const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const { theme } = useTheme();
const {
data: metrics = [],
isLoading,
error
} = useQuery({
queryKey: ['docker-metrics', container?.docker_id, timeRange],
queryFn: () => container ? dockerService.getContainerMetrics(container.docker_id) : Promise.resolve([]),
enabled: !!container && open,
refetchInterval: 30000
});
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const parseValueWithUnit = (value: string | number): { numeric: number; unit: string; original: string } => {
if (typeof value === 'number') {
return { numeric: value, unit: 'B', original: value.toString() };
}
const str = value.toString();
const match = str.match(/^([\d.]+)\s*([A-Za-z%]*)/);
if (match) {
const numeric = parseFloat(match[1]);
const unit = match[2] || '';
return { numeric, unit, original: str };
}
return { numeric: 0, unit: '', original: str };
};
const convertToBytes = (value: string | number): number => {
if (typeof value === 'number') return value;
const parsed = parseValueWithUnit(value);
const multipliers: { [key: string]: number } = {
'B': 1,
'KB': 1024,
'MB': 1024 * 1024,
'GB': 1024 * 1024 * 1024,
'TB': 1024 * 1024 * 1024 * 1024
};
const multiplier = multipliers[parsed.unit.toUpperCase()] || 1;
return parsed.numeric * multiplier;
};
const filterMetricsByTimeRange = (metrics: DockerMetrics[], timeRange: TimeRange): DockerMetrics[] => {
const now = new Date();
const selectedRange = timeRangeOptions.find(opt => opt.value === timeRange);
if (!selectedRange) return metrics;
const cutoffTime = new Date(now.getTime() - (selectedRange.hours * 60 * 60 * 1000));
return metrics.filter(metric => {
const metricTime = new Date(metric.timestamp);
return metricTime >= cutoffTime;
});
};
const formatChartData = (metrics: DockerMetrics[]) => {
const filteredMetrics = filterMetricsByTimeRange(metrics, timeRange);
return filteredMetrics.slice(0, 100).reverse().map((metric, index) => {
// Parse CPU usage
const cpuUsage = typeof metric.cpu_usage === 'string' ?
parseFloat(metric.cpu_usage.replace('%', '')) :
parseFloat(metric.cpu_usage) || 0;
// Parse memory values
const ramUsedBytes = convertToBytes(metric.ram_used);
const ramTotalBytes = convertToBytes(metric.ram_total);
const ramFreeBytes = convertToBytes(metric.ram_free);
const ramUsagePercent = ramTotalBytes > 0 ? (ramUsedBytes / ramTotalBytes) * 100 : 0;
// Parse disk values
const diskUsedBytes = convertToBytes(metric.disk_used);
const diskTotalBytes = convertToBytes(metric.disk_total);
const diskFreeBytes = convertToBytes(metric.disk_free);
const diskUsagePercent = diskTotalBytes > 0 ? (diskUsedBytes / diskTotalBytes) * 100 : 0;
// Network values
const networkRxBytes = metric.network_rx_bytes || 0;
const networkTxBytes = metric.network_tx_bytes || 0;
const networkRxSpeed = metric.network_rx_speed || 0;
const networkTxSpeed = metric.network_tx_speed || 0;
return {
timestamp: new Date(metric.timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
}),
// CPU
cpuUsage: Math.round(cpuUsage * 100) / 100,
cpuCores: parseInt(metric.cpu_cores) || 0,
cpuFree: 100 - cpuUsage,
// Memory
ramUsedBytes,
ramTotalBytes,
ramFreeBytes,
ramUsed: formatBytes(ramUsedBytes),
ramTotal: formatBytes(ramTotalBytes),
ramFree: formatBytes(ramFreeBytes),
ramUsagePercent: Math.round(ramUsagePercent * 100) / 100,
// Disk
diskUsedBytes,
diskTotalBytes,
diskFreeBytes,
diskUsed: formatBytes(diskUsedBytes),
diskTotal: formatBytes(diskTotalBytes),
diskFree: formatBytes(diskFreeBytes),
diskUsagePercent: Math.round(diskUsagePercent * 100) / 100,
// Network
networkRxBytes,
networkTxBytes,
networkRx: formatBytes(networkRxBytes),
networkTx: formatBytes(networkTxBytes),
networkRxSpeed: Math.round(networkRxSpeed * 100) / 100,
networkTxSpeed: Math.round(networkTxSpeed * 100) / 100,
};
});
};
const chartData = formatChartData(metrics);
const latestMetric = chartData[chartData.length - 1];
const chartConfig = {
cpuUsage: {
label: "CPU Usage (%)",
color: theme === 'dark' ? "#3b82f6" : "#2563eb",
},
ramUsagePercent: {
label: "RAM Usage (%)",
color: theme === 'dark' ? "#10b981" : "#059669",
},
diskUsagePercent: {
label: "Disk Usage (%)",
color: theme === 'dark' ? "#f59e0b" : "#d97706",
},
networkRx: {
label: "Network RX",
color: theme === 'dark' ? "#8b5cf6" : "#7c3aed",
},
networkTx: {
label: "Network TX",
color: theme === 'dark' ? "#ef4444" : "#dc2626",
},
};
const getGridColor = () => theme === 'dark' ? '#374151' : '#e5e7eb';
const getAxisColor = () => theme === 'dark' ? '#9ca3af' : '#6b7280';
const MetricCard = ({ title, used, total, free, percentage, icon: Icon, color }: {
title: string;
used: string;
total: string;
free: string;
percentage: number;
icon: any;
color: string;
}) => (
<div className="bg-muted/30 rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4" style={{ color }} />
<span className="text-sm font-medium">{title}</span>
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Used:</span>
<span className="font-mono">{used}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Free:</span>
<span className="font-mono">{free}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Total:</span>
<span className="font-mono">{total}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Usage:</span>
<span className="font-mono">{percentage.toFixed(1)}%</span>
</div>
</div>
</div>
);
if (!container) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl max-h-[95vh] overflow-y-auto bg-background text-foreground">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
<Cpu className="h-4 w-4 text-primary" />
</div>
Container Metrics: {container.name}
</div>
<div className="flex items-center gap-2">
<Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}>
<SelectTrigger className="w-[140px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{timeRangeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DialogTitle>
<p className="text-sm text-muted-foreground">
Docker ID: {container.docker_id} {container.hostname}
</p>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">Loading metrics...</span>
</div>
) : error ? (
<div className="flex items-center justify-center h-96 text-muted-foreground">
<p>Error loading metrics: {error.message}</p>
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-96 text-muted-foreground">
<p>No metrics data available for this container</p>
</div>
) : (
<>
{/* Current Metrics Summary */}
{latestMetric && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard
title="CPU"
used={`${latestMetric.cpuUsage}%`}
total={`${latestMetric.cpuCores} cores`}
free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`}
percentage={latestMetric.cpuUsage}
icon={Cpu}
color={chartConfig.cpuUsage.color}
/>
<MetricCard
title="Memory"
used={latestMetric.ramUsed}
total={latestMetric.ramTotal}
free={latestMetric.ramFree}
percentage={latestMetric.ramUsagePercent}
icon={MemoryStick}
color={chartConfig.ramUsagePercent.color}
/>
<MetricCard
title="Disk"
used={latestMetric.diskUsed}
total={latestMetric.diskTotal}
free={latestMetric.diskFree}
percentage={latestMetric.diskUsagePercent}
icon={HardDrive}
color={chartConfig.diskUsagePercent.color}
/>
<MetricCard
title="Network"
used={`RX: ${latestMetric.networkRx}`}
total={`TX: ${latestMetric.networkTx}`}
free={`Speed: ${latestMetric.networkRxSpeed} KB/s`}
percentage={0}
icon={Network}
color={chartConfig.networkRx.color}
/>
</div>
)}
<Tabs defaultValue="cpu" className="w-full">
<TabsList className="grid w-full grid-cols-4 bg-muted">
<TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background">
<Cpu className="h-4 w-4" />
CPU
</TabsTrigger>
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background">
<MemoryStick className="h-4 w-4" />
Memory
</TabsTrigger>
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background">
<HardDrive className="h-4 w-4" />
Disk
</TabsTrigger>
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background">
<Network className="h-4 w-4" />
Network
</TabsTrigger>
</TabsList>
<TabsContent value="cpu" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">CPU Usage (%)</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Line
type="monotone"
dataKey="cpuUsage"
stroke={chartConfig.cpuUsage.color}
strokeWidth={2}
dot={{ r: 3, fill: chartConfig.cpuUsage.color }}
name="CPU Usage (%)"
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">CPU Usage vs Available</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Area
type="monotone"
dataKey="cpuFree"
stackId="1"
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3}
name="CPU Available (%)"
/>
<Area
type="monotone"
dataKey="cpuUsage"
stackId="1"
stroke={chartConfig.cpuUsage.color}
fill={chartConfig.cpuUsage.color}
fillOpacity={0.6}
name="CPU Usage (%)"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="memory" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">Memory Usage (%)</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Area
type="monotone"
dataKey="ramUsagePercent"
stackId="1"
stroke={chartConfig.ramUsagePercent.color}
fill={chartConfig.ramUsagePercent.color}
fillOpacity={0.6}
name="RAM Usage (%)"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">Memory Usage (Bytes)</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
tickFormatter={(value) => formatBytes(value)}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
formatter={(value, name) => [
name === 'Used Memory' ? formatBytes(Number(value)) :
name === 'Total Memory' ? formatBytes(Number(value)) : value,
name
]}
/>
<Area
type="monotone"
dataKey="ramTotalBytes"
stackId="1"
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3}
name="Total Memory"
/>
<Area
type="monotone"
dataKey="ramUsedBytes"
stackId="1"
stroke={chartConfig.ramUsagePercent.color}
fill={chartConfig.ramUsagePercent.color}
fillOpacity={0.6}
name="Used Memory"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="disk" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">Disk Usage (%)</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Area
type="monotone"
dataKey="diskUsagePercent"
stackId="1"
stroke={chartConfig.diskUsagePercent.color}
fill={chartConfig.diskUsagePercent.color}
fillOpacity={0.6}
name="Disk Usage (%)"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">Disk Usage (Bytes)</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-80">
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
tickFormatter={(value) => formatBytes(value)}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
formatter={(value, name) => [
name === 'Used Disk' ? formatBytes(Number(value)) :
name === 'Total Disk' ? formatBytes(Number(value)) : value,
name
]}
/>
<Area
type="monotone"
dataKey="diskTotalBytes"
stackId="1"
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3}
name="Total Disk"
/>
<Area
type="monotone"
dataKey="diskUsedBytes"
stackId="1"
stroke={chartConfig.diskUsagePercent.color}
fill={chartConfig.diskUsagePercent.color}
fillOpacity={0.6}
name="Used Disk"
/>
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="network" className="space-y-4 mt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">Network Traffic</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-64">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Line
type="monotone"
dataKey="networkRxBytes"
stroke={chartConfig.networkRx.color}
strokeWidth={2}
name="RX Bytes"
dot={{ r: 2 }}
/>
<Line
type="monotone"
dataKey="networkTxBytes"
stroke={chartConfig.networkTx.color}
strokeWidth={2}
name="TX Bytes"
dot={{ r: 2 }}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-foreground">Network Speed (KB/s)</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-64">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={getGridColor()} />
<XAxis
dataKey="timestamp"
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<YAxis
tick={{ fontSize: 12, fill: getAxisColor() }}
axisLine={{ stroke: getGridColor() }}
/>
<ChartTooltip
content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }}
/>
<Line
type="monotone"
dataKey="networkRxSpeed"
stroke={chartConfig.networkRx.color}
strokeWidth={2}
name="RX Speed (KB/s)"
dot={{ r: 2 }}
/>
<Line
type="monotone"
dataKey="networkTxSpeed"
stroke={chartConfig.networkTx.color}
strokeWidth={2}
name="TX Speed (KB/s)"
dot={{ r: 2 }}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,102 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Container, Play, Square, AlertTriangle } from "lucide-react";
import { DockerStats } from "@/types/docker.types";
import { useTheme } from "@/contexts/ThemeContext";
interface DockerStatsCardsProps {
stats: DockerStats;
}
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
const { theme } = useTheme();
const cards = [
{
title: "Total Containers",
value: stats.total,
icon: Container,
color: "text-blue-600",
gradient: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(59, 130, 246, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
},
{
title: "Running",
value: stats.running,
icon: Play,
color: "text-green-600",
gradient: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(16, 185, 129, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
},
{
title: "Stopped",
value: stats.stopped,
icon: Square,
color: "text-gray-600",
gradient: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(107, 114, 128, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
},
{
title: "Warning",
value: stats.warning,
icon: AlertTriangle,
color: "text-amber-600",
gradient: theme === 'dark'
? "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, rgba(245, 158, 11, 0.6) 100%)"
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #f59e0b 100%)"
},
];
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
{cards.map((card) => {
const IconComponent = card.icon;
return (
<Card
key={card.title}
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative"
style={{ background: card.gradient }}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div
className="w-full h-full"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
/>
</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white/70">
{card.title}
</CardTitle>
<div className="p-2.5 rounded-xl bg-white/20 backdrop-blur-sm shadow-sm transition-all duration-300 group-hover:scale-110">
<IconComponent className="h-4 w-4 text-white" />
</div>
</CardHeader>
<CardContent className="relative z-10">
<div className="flex items-center justify-between">
<div className="text-2xl font-bold text-white">
{card.value}
</div>
<Badge
variant="outline"
className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30"
>
Containers
</Badge>
</div>
</CardContent>
</Card>
);
})}
</div>
);
};
@@ -0,0 +1,48 @@
import { Badge } from "@/components/ui/badge";
interface DockerStatusBadgeProps {
status: 'running' | 'stopped' | 'warning';
}
export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => {
const getStatusConfig = (status: string) => {
switch (status) {
case 'running':
return {
variant: 'default' as const,
className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200',
label: 'Running'
};
case 'stopped':
return {
variant: 'secondary' as const,
className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
label: 'Stopped'
};
case 'warning':
return {
variant: 'destructive' as const,
className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200',
label: 'Warning'
};
default:
return {
variant: 'outline' as const,
className: 'bg-gray-100 text-gray-600 border-gray-200',
label: 'Unknown'
};
}
};
const config = getStatusConfig(status);
return (
<Badge
variant={config.variant}
className={`${config.className} font-medium text-xs px-2 py-1`}
>
{config.label}
</Badge>
);
};
@@ -0,0 +1,23 @@
import { TableCell, TableRow } from "@/components/ui/table";
interface DockerEmptyStateProps {
searchTerm: string;
}
export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => {
return (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<div className="text-lg font-medium">
{searchTerm ? "No containers found" : "No containers running"}
</div>
<div className="text-sm">
{searchTerm ? "Try adjusting your search terms." : "Start some containers to see them here."}
</div>
</div>
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react";
import { DockerContainer } from "@/types/docker.types";
interface DockerRowActionsProps {
container: DockerContainer;
containerStatus: 'running' | 'stopped' | 'warning';
onContainerAction: (action: string, containerId: string, containerName: string) => void;
onViewMetrics: (container: DockerContainer) => void;
}
export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-popover border-border shadow-md">
<DropdownMenuItem
onClick={() => onViewMetrics(container)}
className="cursor-pointer hover:bg-muted"
>
<BarChart3 className="mr-2 h-4 w-4" />
View Metrics
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onContainerAction('view-detail', container.id, container.name)}
className="cursor-pointer hover:bg-muted"
>
<Eye className="mr-2 h-4 w-4" />
View Details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,19 @@
import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
export const DockerTableHeader = () => {
return (
<TableHeader>
<TableRow className="border-border bg-muted/30">
<TableHead className="min-w-[200px] font-semibold">Container</TableHead>
<TableHead className="min-w-[100px] font-semibold">Status</TableHead>
<TableHead className="min-w-[140px] font-semibold">CPU Usage</TableHead>
<TableHead className="min-w-[160px] font-semibold">Memory</TableHead>
<TableHead className="min-w-[160px] font-semibold">Disk</TableHead>
<TableHead className="min-w-[100px] font-semibold">Uptime</TableHead>
<TableHead className="min-w-[160px] font-semibold">Last Checked</TableHead>
<TableHead className="min-w-[80px] text-center font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
);
};
@@ -0,0 +1,123 @@
import { TableCell, TableRow } from "@/components/ui/table";
import { Progress } from "@/components/ui/progress";
import { DockerContainer } from "@/types/docker.types";
import { DockerStatusBadge } from "../DockerStatusBadge";
import { DockerRowActions } from "./DockerRowActions";
import { dockerService } from "@/services/dockerService";
interface DockerTableRowProps {
container: DockerContainer;
onRowClick: (container: DockerContainer) => void;
onContainerAction: (action: string, containerId: string, containerName: string) => void;
onViewMetrics: (container: DockerContainer) => void;
}
export const DockerTableRow = ({ container, onRowClick, onContainerAction, onViewMetrics }: DockerTableRowProps) => {
const cpuPercentage = container.cpu_usage;
const memoryPercentage = Math.round((container.ram_used / container.ram_total) * 100);
const diskPercentage = Math.round((container.disk_used / container.disk_total) * 100);
const containerStatus = dockerService.getStatusFromDockerStatus(container.status);
const formatPercentage = (used: number, total: number) => {
if (total === 0) return "0%";
return `${Math.round((used / total) * 100)}%`;
};
const getUsageColor = (percentage: number) => {
if (percentage >= 90) return "text-red-500";
if (percentage >= 70) return "text-amber-500";
return "text-emerald-500";
};
const getProgressColor = (percentage: number) => {
if (percentage >= 90) return "bg-red-500";
if (percentage >= 70) return "bg-amber-500";
return "bg-emerald-500";
};
return (
<TableRow
className="hover:bg-muted/50 transition-colors border-border cursor-pointer"
onClick={() => onRowClick(container)}
>
<TableCell className="font-medium">
<div className="space-y-1">
<div className="font-semibold text-sm sm:text-base text-foreground">{container.name}</div>
<div className="text-xs sm:text-sm text-muted-foreground">
<div className="font-mono">{container.docker_id}</div>
</div>
</div>
</TableCell>
<TableCell>
<DockerStatusBadge status={containerStatus} />
</TableCell>
<TableCell>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Progress
value={cpuPercentage}
className="flex-1 h-2 bg-muted/50"
indicatorClassName={getProgressColor(cpuPercentage)}
/>
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(cpuPercentage)}`}>
{cpuPercentage}%
</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Progress
value={memoryPercentage}
className="flex-1 h-2 bg-muted/50"
indicatorClassName={getProgressColor(memoryPercentage)}
/>
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(memoryPercentage)}`}>
{formatPercentage(container.ram_used, container.ram_total)}
</span>
</div>
<div className="text-xs text-muted-foreground font-mono">
{dockerService.formatBytes(container.ram_used)} / {dockerService.formatBytes(container.ram_total)}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Progress
value={diskPercentage}
className="flex-1 h-2 bg-muted/50"
indicatorClassName={getProgressColor(diskPercentage)}
/>
<span className={`font-semibold text-sm min-w-[40px] text-right ${getUsageColor(diskPercentage)}`}>
{formatPercentage(container.disk_used, container.disk_total)}
</span>
</div>
<div className="text-xs text-muted-foreground font-mono">
{dockerService.formatBytes(container.disk_used)} / {dockerService.formatBytes(container.disk_total)}
</div>
</div>
</TableCell>
<TableCell>
<span className="text-xs sm:text-sm font-medium font-mono">
{dockerService.formatUptime(container.uptime)}
</span>
</TableCell>
<TableCell>
<span className="text-xs sm:text-sm text-muted-foreground">
{new Date(container.last_checked).toLocaleString()}
</span>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<DockerRowActions
container={container}
containerStatus={containerStatus}
onContainerAction={onContainerAction}
onViewMetrics={onViewMetrics}
/>
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,37 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, RefreshCw } from "lucide-react";
interface DockerTableSearchProps {
searchTerm: string;
onSearchChange: (value: string) => void;
onRefresh: () => void;
isLoading: boolean;
}
export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => {
return (
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search containers..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 sm:w-64 bg-background border-border"
/>
</div>
<Button
onClick={onRefresh}
disabled={isLoading}
variant="outline"
size="default"
className="w-full sm:w-auto bg-background border-border hover:bg-muted"
>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
<span className="sm:inline">Refresh</span>
</Button>
</div>
);
};
@@ -0,0 +1,6 @@
export { DockerTableSearch } from './DockerTableSearch';
export { DockerTableHeader } from './DockerTableHeader';
export { DockerTableRow } from './DockerTableRow';
export { DockerRowActions } from './DockerRowActions';
export { DockerEmptyState } from './DockerEmptyState';
@@ -0,0 +1,243 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Plus, X, Server, Shield, AlertTriangle } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useQuery } from '@tanstack/react-query';
import { serviceService } from '@/services/serviceService';
interface ComponentsSelectorProps {
selectedComponents: Partial<StatusPageComponentRecord>[];
onComponentsChange: (components: Partial<StatusPageComponentRecord>[]) => void;
onComponentDelete?: (componentId: string) => void;
}
const componentTypes = [
{ value: 'uptime', label: 'Uptime Service', icon: Server },
{ value: 'ssl', label: 'SSL Certificate', icon: Shield },
{ value: 'incident', label: 'Incident Monitoring', icon: AlertTriangle },
];
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
const [showAddForm, setShowAddForm] = useState(false);
const [newComponent, setNewComponent] = useState({
name: '',
description: '',
service_id: '',
server_id: '',
display_order: selectedComponents.length + 1,
});
// Fetch uptime services for the dropdown
const { data: services = [] } = useQuery({
queryKey: ['services'],
queryFn: serviceService.getServices,
});
const addComponent = () => {
if (!newComponent.name.trim()) return;
const component: Partial<StatusPageComponentRecord> = {
...newComponent,
operational_status_id: '', // Will be set when page is created
};
onComponentsChange([...selectedComponents, component]);
setNewComponent({
name: '',
description: '',
service_id: '',
server_id: '',
display_order: selectedComponents.length + 2,
});
setShowAddForm(false);
};
const removeComponent = async (index: number) => {
const component = selectedComponents[index];
// If component has an ID, it exists in database and needs to be deleted
if (component.id && onComponentDelete) {
await onComponentDelete(component.id);
} else {
// For new components not yet saved, just remove from local state
const updated = selectedComponents.filter((_, i) => i !== index);
onComponentsChange(updated);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Status Page Components
</CardTitle>
<CardDescription>
Add monitoring components like uptime services, SSL certificates, and incident tracking
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedComponents.length > 0 && (
<div className="space-y-2">
<Label>Selected Components</Label>
<div className="space-y-2">
{selectedComponents.map((component, index) => (
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex-1">
<div className="font-medium">{component.name}</div>
{component.description && (
<div className="text-sm text-muted-foreground">{component.description}</div>
)}
<div className="flex gap-2 mt-1">
{component.service_id && (
<Badge variant="secondary" className="text-xs">
Service: {services.find(s => s.id === component.service_id)?.name || component.service_id}
</Badge>
)}
{component.server_id && (
<Badge variant="secondary" className="text-xs">
Server: {component.server_id}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeComponent(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
)}
{!showAddForm ? (
<Button
variant="outline"
onClick={() => setShowAddForm(true)}
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
Add Component
</Button>
) : (
<div className="border rounded-lg p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="component-name">Component Name</Label>
<Input
id="component-name"
placeholder="e.g., Main Website"
value={newComponent.name}
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
/>
</div>
<div>
<Label htmlFor="display-order">Display Order</Label>
<Input
id="display-order"
type="number"
value={newComponent.display_order}
onChange={(e) => setNewComponent({ ...newComponent, display_order: parseInt(e.target.value) || 1 })}
/>
</div>
</div>
<div>
<Label htmlFor="component-description">Description (Optional)</Label>
<Textarea
id="component-description"
placeholder="Brief description of this component"
value={newComponent.description}
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="service-id">Uptime Service (Optional)</Label>
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
<SelectTrigger>
<SelectValue placeholder="Select an uptime service" />
</SelectTrigger>
<SelectContent className="z-50 bg-white border shadow-lg">
{services.map((service) => (
<SelectItem key={service.id} value={service.id}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
service.status === 'up' ? 'bg-green-500' :
service.status === 'down' ? 'bg-red-500' :
'bg-yellow-500'
}`} />
{service.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="server-id">Server ID (Optional)</Label>
<Input
id="server-id"
placeholder="server_456"
value={newComponent.server_id}
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={addComponent} disabled={!newComponent.name.trim()}>
Add Component
</Button>
<Button variant="outline" onClick={() => setShowAddForm(false)}>
Cancel
</Button>
</div>
</div>
)}
<div className="mt-4">
<Label>Quick Add Templates</Label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-2">
{componentTypes.map((type) => {
const Icon = type.icon;
return (
<Button
key={type.value}
variant="outline"
size="sm"
onClick={() => {
const component: Partial<StatusPageComponentRecord> = {
name: type.label,
description: `Monitor ${type.label.toLowerCase()}`,
service_id: '',
server_id: '',
display_order: selectedComponents.length + 1,
operational_status_id: '',
};
onComponentsChange([...selectedComponents, component]);
}}
className="justify-start"
>
<Icon className="h-4 w-4 mr-2" />
{type.label}
</Button>
);
})}
</div>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,278 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useCreateOperationalPage } from '@/hooks/useOperationalPage';
import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
import { ComponentsSelector } from './ComponentsSelector';
import { Plus } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
const formSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
slug: z.string().min(1, 'Slug is required'),
theme: z.string().min(1, 'Theme is required'),
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
is_public: z.boolean(),
logo_url: z.string().optional(),
custom_domain: z.string().optional(),
custom_css: z.string().optional(),
page_style: z.string().optional(),
});
type FormData = z.infer<typeof formSchema>;
export const CreateOperationalPageDialog = () => {
const [open, setOpen] = useState(false);
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
const createMutation = useCreateOperationalPage();
const createComponentMutation = useCreateStatusPageComponent();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
description: '',
slug: '',
theme: 'default',
status: 'operational',
is_public: true,
logo_url: '',
custom_domain: '',
custom_css: '',
page_style: '',
},
});
const onSubmit = async (data: FormData) => {
try {
const payload = {
title: data.title,
description: data.description,
slug: data.slug,
theme: data.theme,
status: data.status,
is_public: data.is_public ? 'true' : 'false',
logo_url: data.logo_url || '',
custom_domain: data.custom_domain || '',
custom_css: data.custom_css || '',
page_style: data.page_style || '',
};
console.log('Creating operational page with payload:', payload);
const createdPage = await createMutation.mutateAsync(payload);
console.log('Created page:', createdPage);
// Create components after page is created
if (selectedComponents.length > 0) {
console.log('Creating components for page:', createdPage.id);
for (const component of selectedComponents) {
const componentPayload = {
operational_status_id: createdPage.id,
name: component.name || '',
description: component.description || '',
service_id: component.service_id || '',
server_id: component.server_id || '',
display_order: component.display_order || 1,
};
console.log('Creating component with payload:', componentPayload);
await createComponentMutation.mutateAsync(componentPayload);
}
}
setOpen(false);
form.reset();
setSelectedComponents([]);
} catch (error) {
console.error('Error creating operational page:', error);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus className="h-4 w-4" />
Create Page
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Operational Page</DialogTitle>
<DialogDescription>
Create a new operational status page to monitor your services and components.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My Service Status" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="my-service-status" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="A brief description of your operational page"
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select theme" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="light">Light</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Initial Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="operational">Operational</SelectItem>
<SelectItem value="degraded">Degraded Performance</SelectItem>
<SelectItem value="maintenance">Under Maintenance</SelectItem>
<SelectItem value="major_outage">Major Outage</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="is_public"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Public Page</FormLabel>
<FormDescription>
Make this page publicly accessible
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_domain"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Domain (Optional)</FormLabel>
<FormControl>
<Input placeholder="status.yourdomain.com" {...field} />
</FormControl>
<FormDescription>
Custom domain for your status page
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ComponentsSelector
selectedComponents={selectedComponents}
onComponentsChange={setSelectedComponents}
/>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending || createComponentMutation.isPending}
>
{createMutation.isPending || createComponentMutation.isPending ? 'Creating...' : 'Create Page'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,367 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useUpdateOperationalPage } from '@/hooks/useOperationalPage';
import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, useDeleteStatusPageComponent } from '@/hooks/useStatusPageComponents';
import { ComponentsSelector } from './ComponentsSelector';
import { OperationalPageRecord } from '@/types/operational.types';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
const formSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
slug: z.string().min(1, 'Slug is required'),
theme: z.string().min(1, 'Theme is required'),
status: z.enum(['operational', 'degraded', 'maintenance', 'major_outage']),
is_public: z.boolean(),
logo_url: z.string().optional(),
custom_domain: z.string().optional(),
custom_css: z.string().optional(),
page_style: z.string().optional(),
});
type FormData = z.infer<typeof formSchema>;
interface EditOperationalPageDialogProps {
page: OperationalPageRecord | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const [componentsLoaded, setComponentsLoaded] = useState(false);
const updateMutation = useUpdateOperationalPage();
const createComponentMutation = useCreateStatusPageComponent();
const deleteComponentMutation = useDeleteStatusPageComponent();
// Fetch existing components for this operational page
const { data: components = [] } = useStatusPageComponentsByOperationalId(page?.id || '');
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
description: '',
slug: '',
theme: 'default',
status: 'operational',
is_public: true,
logo_url: '',
custom_domain: '',
custom_css: '',
page_style: '',
},
});
// Memoize the form reset values to prevent unnecessary re-renders
const formValues = useMemo(() => {
if (!page) return null;
return {
title: page.title,
description: page.description,
slug: page.slug,
theme: page.theme,
status: page.status,
is_public: page.is_public === 'true',
logo_url: page.logo_url || '',
custom_domain: page.custom_domain || '',
custom_css: page.custom_css || '',
page_style: page.page_style || '',
};
}, [page?.id, page?.title, page?.description, page?.slug, page?.theme, page?.status, page?.is_public, page?.logo_url, page?.custom_domain, page?.custom_css, page?.page_style]);
// Reset form when page data changes
useEffect(() => {
if (formValues) {
form.reset(formValues);
}
}, [formValues, form]);
// Convert components to selector format and initialize state - only when dialog opens and components change
useEffect(() => {
if (!open || !page?.id || !components) {
return;
}
// Only update if components actually changed or haven't been loaded yet
const componentIds = components.map(c => c.id).sort().join(',');
const currentSelectedIds = selectedComponents.map(c => c.id).filter(Boolean).sort().join(',');
if (componentIds !== currentSelectedIds || !componentsLoaded) {
// console.log('Loading existing components:', components);
const existingComponentsForSelector = components.map(comp => ({
id: comp.id,
name: comp.name,
description: comp.description,
service_id: comp.service_id,
server_id: comp.server_id,
display_order: comp.display_order,
operational_status_id: comp.operational_status_id,
}));
setSelectedComponents(existingComponentsForSelector);
setComponentsLoaded(true);
}
}, [open, page?.id, components, componentsLoaded, selectedComponents]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setComponentsLoaded(false);
setSelectedComponents([]);
}
}, [open]);
const handleComponentDelete = useCallback(async (componentId: string) => {
try {
// console.log('Deleting component:', componentId);
await deleteComponentMutation.mutateAsync(componentId);
// Update local state to remove the deleted component
setSelectedComponents(prev => prev.filter(comp => comp.id !== componentId));
} catch (error) {
// console.error('Error deleting component:', error);
}
}, [deleteComponentMutation]);
const onSubmit = async (data: FormData) => {
if (!page) return;
try {
setIsFormSubmitting(true);
const payload = {
title: data.title,
description: data.description,
slug: data.slug,
theme: data.theme,
status: data.status,
is_public: data.is_public ? 'true' : 'false',
logo_url: data.logo_url || '',
custom_domain: data.custom_domain || '',
custom_css: data.custom_css || '',
page_style: data.page_style || '',
};
// console.log('Updating operational page with payload:', payload);
await updateMutation.mutateAsync({ id: page.id, data: payload });
// Handle component changes
const currentComponentIds = components.map(c => c.id);
const newComponentsToCreate = selectedComponents.filter(comp => !comp.id);
const componentsToDelete = components.filter(comp => !selectedComponents.some(selected => selected.id === comp.id));
// Delete removed components
for (const component of componentsToDelete) {
// console.log('Deleting component during save:', component.id);
await deleteComponentMutation.mutateAsync(component.id);
}
// Create new components
for (const component of newComponentsToCreate) {
const componentPayload = {
operational_status_id: page.id,
name: component.name || '',
description: component.description || '',
service_id: component.service_id || '',
server_id: component.server_id || '',
display_order: component.display_order || 1,
};
// console.log('Creating component with payload:', componentPayload);
await createComponentMutation.mutateAsync(componentPayload);
}
onOpenChange(false);
} catch (error) {
// console.error('Error updating operational page:', error);
} finally {
setIsFormSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Operational Page</DialogTitle>
<DialogDescription>
Update your operational status page settings and manage components.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="My Service Status" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="my-service-status" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="A brief description of your operational page"
className="min-h-[80px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select theme" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="light">Light</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="operational">Operational</SelectItem>
<SelectItem value="degraded">Degraded Performance</SelectItem>
<SelectItem value="maintenance">Under Maintenance</SelectItem>
<SelectItem value="major_outage">Major Outage</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="is_public"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Public Page</FormLabel>
<FormDescription>
Make this page publicly accessible
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="custom_domain"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Domain (Optional)</FormLabel>
<FormControl>
<Input placeholder="status.yourdomain.com" {...field} />
</FormControl>
<FormDescription>
Custom domain for your status page
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ComponentsSelector
selectedComponents={selectedComponents}
onComponentsChange={setSelectedComponents}
onComponentDelete={handleComponentDelete}
/>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
>
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? 'Updating...' : 'Update Page'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,101 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { OperationalPageRecord } from '@/types/operational.types';
import { StatusBadge } from './StatusBadge';
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
interface OperationalPageCardProps {
page: OperationalPageRecord;
onEdit?: (page: OperationalPageRecord) => void;
onView?: (page: OperationalPageRecord) => void;
onDelete?: (page: OperationalPageRecord) => void;
}
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
return (
<Card className="hover:shadow-lg transition-shadow duration-200">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg font-semibold mb-1">{page.title}</CardTitle>
<CardDescription className="text-sm text-muted-foreground">
{page.description}
</CardDescription>
</div>
<StatusBadge status={page.status} />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-muted-foreground">Slug:</span>
<p className="mt-1">{page.slug}</p>
</div>
<div>
<span className="font-medium text-muted-foreground">Theme:</span>
<p className="mt-1 capitalize">{page.theme}</p>
</div>
<div>
<span className="font-medium text-muted-foreground">Public:</span>
<p className="mt-1">
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
{page.is_public === 'true' ? 'Yes' : 'No'}
</Badge>
</p>
</div>
<div>
<span className="font-medium text-muted-foreground">Updated:</span>
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
</div>
</div>
{page.custom_domain && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{page.custom_domain}</span>
<ExternalLink className="h-3 w-3 text-muted-foreground" />
</div>
)}
<div className="flex gap-2 pt-2">
{onView && (
<Button
variant="outline"
size="sm"
onClick={() => onView(page)}
className="flex-1"
>
<Eye className="h-4 w-4 mr-2" />
View
</Button>
)}
{onEdit && (
<Button
variant="outline"
size="sm"
onClick={() => onEdit(page)}
className="flex-1"
>
<Settings className="h-4 w-4 mr-2" />
Edit
</Button>
)}
{onDelete && (
<Button
variant="outline"
size="sm"
onClick={() => onDelete(page)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,211 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOperationalPages, useDeleteOperationalPage } from '@/hooks/useOperationalPage';
import { CreateOperationalPageDialog } from './CreateOperationalPageDialog';
import { EditOperationalPageDialog } from './EditOperationalPageDialog';
import { OperationalPageCard } from './OperationalPageCard';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { OperationalPageRecord } from '@/types/operational.types';
import { Activity, Plus, RefreshCw } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export const OperationalPageContent = () => {
const navigate = useNavigate();
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
const deleteMutation = useDeleteOperationalPage();
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [pageToDelete, setPageToDelete] = useState<OperationalPageRecord | null>(null);
const handleEdit = (page: OperationalPageRecord) => {
setEditingPage(page);
setEditDialogOpen(true);
};
const handleView = (page: OperationalPageRecord) => {
if (page.custom_domain) {
window.open(`https://${page.custom_domain}`, '_blank');
} else {
// Navigate to the public status page route using the correct format
window.open(`/public/${page.slug}`, '_blank');
}
};
const handleDelete = (page: OperationalPageRecord) => {
setPageToDelete(page);
setDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (pageToDelete) {
try {
await deleteMutation.mutateAsync(pageToDelete.id);
setDeleteDialogOpen(false);
setPageToDelete(null);
} catch (error) {
console.error('Error deleting page:', error);
}
}
};
if (error) {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center">
<div className="mb-4">
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
</div>
<h3 className="text-lg font-semibold mb-2">Failed to load operational pages</h3>
<p className="text-muted-foreground mb-4">
There was an error loading your operational pages. Please try again.
</p>
<Button onClick={() => refetch()} variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Operational Pages</h1>
<p className="text-muted-foreground">
Manage your public status pages and monitor service health
</p>
</div>
<div className="flex items-center gap-2 mt-4 sm:mt-0">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<CreateOperationalPageDialog />
</div>
</div>
{/* Loading State */}
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<div className="p-6">
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-full mb-4" />
<div className="space-y-2">
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
</div>
<div className="flex gap-2 mt-4">
<Skeleton className="h-8 flex-1" />
<Skeleton className="h-8 flex-1" />
</div>
</div>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!pages || pages.length === 0) && (
<Card className="p-12">
<CardContent className="text-center">
<div className="mb-4">
<Activity className="h-12 w-12 text-muted-foreground mx-auto" />
</div>
<h3 className="text-lg font-semibold mb-2">No operational pages found</h3>
<p className="text-muted-foreground mb-6">
Create your first operational page to start monitoring your services and communicate status to your users.
</p>
<CreateOperationalPageDialog />
</CardContent>
</Card>
)}
{/* Pages Grid */}
{!isLoading && pages && pages.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pages.map((page) => (
<OperationalPageCard
key={page.id}
page={page}
onEdit={handleEdit}
onView={handleView}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Stats Footer */}
{!isLoading && pages && pages.length > 0 && (
<div className="mt-8 pt-6 border-t">
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
<div>
<span className="font-medium">Total Pages:</span> {pages.length}
</div>
<div>
<span className="font-medium">Public Pages:</span>{' '}
{pages.filter(p => p.is_public === 'true').length}
</div>
<div>
<span className="font-medium">Operational:</span>{' '}
{pages.filter(p => p.status === 'operational').length}
</div>
</div>
</div>
)}
{/* Edit Dialog */}
<EditOperationalPageDialog
page={editingPage}
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{pageToDelete?.title}"? This action cannot be undone and will permanently remove the operational page and all its components.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -0,0 +1,47 @@
import { Badge } from '@/components/ui/badge';
import { OperationalPageRecord } from '@/types/operational.types';
interface StatusBadgeProps {
status: OperationalPageRecord['status'];
}
export const StatusBadge = ({ status }: StatusBadgeProps) => {
const getStatusConfig = (status: OperationalPageRecord['status']) => {
switch (status) {
case 'operational':
return {
label: 'Operational',
className: 'bg-green-100 text-green-800 hover:bg-green-200',
};
case 'degraded':
return {
label: 'Degraded Performance',
className: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
};
case 'maintenance':
return {
label: 'Under Maintenance',
className: 'bg-blue-100 text-blue-800 hover:bg-blue-200',
};
case 'major_outage':
return {
label: 'Major Outage',
className: 'bg-red-100 text-red-800 hover:bg-red-200',
};
default:
return {
label: 'Unknown',
className: 'bg-gray-100 text-gray-800 hover:bg-gray-200',
};
}
};
const config = getStatusConfig(status);
return (
<Badge className={config.className}>
{config.label}
</Badge>
);
};
@@ -1,4 +1,3 @@
import { useState } from "react"; import { useState } from "react";
import { z } from "zod"; import { z } from "zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -10,6 +9,7 @@ import { useToast } from "@/hooks/use-toast";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { useNavigate } from "react-router-dom";
// Password change form schema // Password change form schema
const passwordFormSchema = z.object({ const passwordFormSchema = z.object({
@@ -34,6 +34,7 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate();
const form = useForm<PasswordFormValues>({ const form = useForm<PasswordFormValues>({
resolver: zodResolver(passwordFormSchema), resolver: zodResolver(passwordFormSchema),
@@ -44,12 +45,35 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
}, },
}); });
// Function to determine which collection the user belongs to
const getUserCollection = async (userId: string): Promise<string> => {
try {
// First try to find the user in the regular users collection
await pb.collection('users').getOne(userId);
return 'users';
} catch (error) {
try {
// If not found, try the superadmin collection
await pb.collection('_superusers').getOne(userId);
return '_superusers';
} catch (error) {
throw new Error('User not found in any collection');
}
}
};
async function onSubmit(data: PasswordFormValues) { async function onSubmit(data: PasswordFormValues) {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
console.log("Starting password change for user:", userId);
// Determine which collection the user belongs to
const collection = await getUserCollection(userId);
console.log("User found in collection:", collection);
// PocketBase requires the old password along with the new one // PocketBase requires the old password along with the new one
await pb.collection('users').update(userId, { await pb.collection(collection).update(userId, {
oldPassword: data.currentPassword, oldPassword: data.currentPassword,
password: data.newPassword, password: data.newPassword,
passwordConfirm: data.confirmPassword, passwordConfirm: data.confirmPassword,
@@ -60,17 +84,31 @@ export function ChangePasswordForm({ userId }: ChangePasswordFormProps) {
toast({ toast({
title: "Password updated", title: "Password updated",
description: "Your password has been changed successfully.", description: "Your password has been changed successfully. You will be logged out in 3 seconds for security.",
}); });
// Reset the form // Reset the form
form.reset(); form.reset();
// Auto logout after successful password change
setTimeout(() => {
console.log("Auto logout after password change");
authService.logout();
navigate("/login");
}, 3000);
} catch (error) { } catch (error) {
console.error("Password change error:", error); console.error("Password change error:", error);
let errorMessage = "Failed to update password. Please try again."; let errorMessage = "Failed to update password. Please try again.";
if (error instanceof Error) { if (error instanceof Error) {
errorMessage = error.message; if (error.message.includes("Failed to authenticate")) {
errorMessage = "Current password is incorrect. Please try again.";
} else if (error.message.includes("User not found")) {
errorMessage = "User account not found. Please contact your administrator.";
} else {
errorMessage = error.message;
}
} }
toast({ toast({
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { User } from "@/services/userService"; import { User } from "@/services/userService";
import { UserProfileDetails } from "./UserProfileDetails"; import { UserProfileDetails } from "./UserProfileDetails";
@@ -9,11 +9,19 @@ import { UpdateProfileForm } from "./UpdateProfileForm";
interface ProfileContentProps { interface ProfileContentProps {
currentUser: User | null; currentUser: User | null;
onUserUpdated?: () => Promise<void>;
} }
export function ProfileContent({ currentUser }: ProfileContentProps) { export function ProfileContent({ currentUser, onUserUpdated }: ProfileContentProps) {
const [activeTab, setActiveTab] = useState("details"); const [activeTab, setActiveTab] = useState("details");
// When active tab changes, refresh user data if needed
useEffect(() => {
if (activeTab === "details" && onUserUpdated) {
onUserUpdated();
}
}, [activeTab, onUserUpdated]);
if (!currentUser) { if (!currentUser) {
return ( return (
<Card> <Card>
@@ -9,6 +9,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { AlertCircle, CheckCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useNavigate } from "react-router-dom";
// Profile update form schema // Profile update form schema
const profileFormSchema = z.object({ const profileFormSchema = z.object({
@@ -31,7 +34,10 @@ interface UpdateProfileFormProps {
export function UpdateProfileForm({ user }: UpdateProfileFormProps) { export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const [updateSuccess, setUpdateSuccess] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate();
// Initialize the form with current user data // Initialize the form with current user data
const form = useForm<ProfileFormValues>({ const form = useForm<ProfileFormValues>({
@@ -45,21 +51,55 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
async function onSubmit(data: ProfileFormValues) { async function onSubmit(data: ProfileFormValues) {
setIsSubmitting(true); setIsSubmitting(true);
setUpdateError(null);
setUpdateSuccess(null);
try { try {
await userService.updateUser(user.id, { console.log("Submitting profile update with data:", data);
// Detect if email is being changed
const isEmailChanged = data.email !== user.email;
// Create update payload with all fields
const updateData = {
full_name: data.full_name, full_name: data.full_name,
username: data.username, username: data.username,
email: data.email, // Only include email if it's changed
}); email: isEmailChanged ? data.email : undefined,
// Always set emailVisibility to true if email is changing
emailVisibility: isEmailChanged ? true : undefined
};
// Refresh user data in auth context console.log("Sending update payload:", updateData);
await authService.refreshUserData();
toast({ // Update user data using the userService
title: "Profile updated", await userService.updateUser(user.id, updateData);
description: "Your profile information has been updated successfully.",
}); // If email was changed, show success message and auto-logout
if (isEmailChanged) {
setUpdateSuccess("Email changed successfully! You will be logged out for security reasons. Please log in again with your new email.");
toast({
title: "Email changed successfully",
description: "You will be logged out for security reasons. Please log in again with your new email.",
variant: "default",
});
// Auto-logout after 3 seconds
setTimeout(() => {
authService.logout();
navigate("/login");
}, 3000);
} else {
// Refresh user data in auth context for other field changes
await authService.refreshUserData();
setUpdateSuccess("Your profile information has been updated successfully.");
toast({
title: "Profile updated",
description: "Your profile information has been updated successfully.",
});
}
} catch (error) { } catch (error) {
console.error("Profile update error:", error); console.error("Profile update error:", error);
@@ -68,6 +108,8 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
errorMessage = error.message; errorMessage = error.message;
} }
setUpdateError(errorMessage);
toast({ toast({
title: "Update failed", title: "Update failed",
description: errorMessage, description: errorMessage,
@@ -81,6 +123,22 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{updateError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{updateError}</AlertDescription>
</Alert>
)}
{updateSuccess && (
<Alert className="bg-green-50 border-green-200 text-green-800">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription>
{updateSuccess}
</AlertDescription>
</Alert>
)}
<FormField <FormField
control={form.control} control={form.control}
name="full_name" name="full_name"
@@ -119,6 +177,11 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) {
<Input type="email" placeholder="your.email@example.com" {...field} /> <Input type="email" placeholder="your.email@example.com" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
{field.value !== user.email && (
<p className="text-xs text-muted-foreground mt-1">
Changing your email will log you out for security reasons. You will need to log in again with your new email.
</p>
)}
</FormItem> </FormItem>
)} )}
/> />
@@ -23,6 +23,9 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
return user.username[0].toUpperCase(); return user.username[0].toUpperCase();
}; };
// Determine if the user is active based on the status field
const isActive = user.status === "Active";
return ( return (
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<Avatar className="h-32 w-32"> <Avatar className="h-32 w-32">
@@ -54,8 +57,8 @@ export function UserProfileDetails({ user }: UserProfileDetailsProps) {
{user.role} {user.role}
</Badge> </Badge>
)} )}
<Badge variant={user.isActive ? "default" : "outline"} className="px-2 py-1"> <Badge variant={isActive ? "default" : "outline"} className="px-2 py-1">
{user.isActive ? "Active" : "Inactive"} {user.status || (isActive ? "Active" : "Inactive")}
</Badge> </Badge>
{user.verified && ( {user.verified && (
<Badge className="bg-green-600 hover:bg-green-700 px-2 py-1"> <Badge className="bg-green-600 hover:bg-green-700 px-2 py-1">
@@ -0,0 +1,210 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Server, CheckCircle, XCircle, AlertTriangle, Pause, Clock } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { Service, UptimeData } from '@/types/service.types';
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
import { format } from 'date-fns';
interface ComponentsStatusSectionProps {
components: StatusPageComponentRecord[];
services: Service[];
uptimeData: Record<string, UptimeData[]>;
}
export const ComponentsStatusSection = ({ components, services, uptimeData }: ComponentsStatusSectionProps) => {
const getServiceForComponent = (component: StatusPageComponentRecord) => {
return services.find(service => service.id === component.service_id);
};
const getComponentStatus = (component: StatusPageComponentRecord) => {
const service = getServiceForComponent(component);
return service?.status || 'unknown';
};
const getUptimePercentage = (serviceId: string) => {
const history = uptimeData[serviceId] || [];
if (history.length === 0) return 100;
const upCount = history.filter(record => record.status === 'up').length;
return Math.round((upCount / history.length) * 100 * 100) / 100;
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'up':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'down':
return <XCircle className="h-5 w-5 text-red-500" />;
case 'warning':
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case 'paused':
return <Pause className="h-5 w-5 text-gray-500" />;
default:
return <Server className="h-5 w-5 text-muted-foreground" />;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'up':
return (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-200 dark:border-green-800">
<CheckCircle className="h-3 w-3 mr-1" />
Operational
</Badge>
);
case 'down':
return (
<Badge className="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-red-200 dark:border-red-800">
<XCircle className="h-3 w-3 mr-1" />
Down
</Badge>
);
case 'warning':
return (
<Badge className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 border-yellow-200 dark:border-yellow-800">
<AlertTriangle className="h-3 w-3 mr-1" />
Degraded
</Badge>
);
case 'paused':
return (
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200 border-gray-200 dark:border-gray-800">
<Pause className="h-3 w-3 mr-1" />
Maintenance
</Badge>
);
default:
return (
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
Unknown
</Badge>
);
}
};
const getStatusDotColor = (status: string) => {
switch (status) {
case 'up':
return 'bg-green-500';
case 'down':
return 'bg-red-500';
case 'warning':
return 'bg-yellow-500';
default:
return 'bg-gray-500';
}
};
if (components.length === 0) {
return (
<Card className="mb-8 bg-card border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-card-foreground">
<Server className="h-5 w-5" />
System Components
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[
{ name: 'API Services', description: 'Core API endpoints and services', status: 'up' },
{ name: 'Database', description: 'Primary database systems', status: 'up' },
{ name: 'Authentication', description: 'User authentication services', status: 'up' },
{ name: 'File Storage', description: 'Media and file hosting', status: 'up' }
].map((component, index) => (
<div key={index} className="flex items-center justify-between p-4 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-colors">
<div className="flex items-center gap-3">
{getStatusIcon(component.status)}
<div>
<h3 className="font-medium text-foreground">{component.name}</h3>
<p className="text-sm text-muted-foreground">{component.description}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-green-600 dark:text-green-400 font-medium">99.9% uptime</span>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">100ms response</span>
</div>
</div>
</div>
{getStatusBadge(component.status)}
</div>
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card className="mb-8 bg-card border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-card-foreground">
<Server className="h-5 w-5" />
System Components
</CardTitle>
<p className="text-sm text-muted-foreground">
Real-time status of all monitored components
</p>
</CardHeader>
<CardContent>
<div className="space-y-6">
{components
.sort((a, b) => a.display_order - b.display_order)
.map((component) => {
const service = getServiceForComponent(component);
const status = getComponentStatus(component);
const uptime = service?.id ? getUptimePercentage(component.service_id) : 100;
return (
<div key={component.id} className="space-y-4">
<div className="flex items-center justify-between p-5 rounded-lg border border-border bg-background/50 hover:bg-background/80 transition-all duration-200">
<div className="flex items-center gap-4">
{getStatusIcon(status)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-foreground text-lg">{component.name}</h3>
{service?.responseTime && service.responseTime > 0 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground bg-muted px-2 py-1 rounded">
<Clock className="h-3 w-3" />
{service.responseTime}ms
</div>
)}
</div>
{component.description && (
<p className="text-sm text-muted-foreground mb-2">{component.description}</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${getStatusDotColor(status)}`}></div>
<span className="font-medium">{uptime}% uptime (90 days)</span>
</div>
{service?.lastChecked && (
<span>Last checked: {format(new Date(service.lastChecked), 'HH:mm:ss')}</span>
)}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
{getStatusBadge(status)}
</div>
</div>
{component.service_id && (
<div className="ml-9">
<div className="text-xs text-muted-foreground mb-2">90-day uptime history</div>
<UptimeHistoryRenderer
serviceId={component.service_id}
uptimeData={uptimeData}
/>
</div>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,158 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Shield, Clock, CheckCircle, AlertTriangle, XCircle, Wrench } from 'lucide-react';
import { format } from 'date-fns';
import { OperationalPageRecord } from '@/types/operational.types';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { Service } from '@/types/service.types';
interface CurrentStatusSectionProps {
page: OperationalPageRecord;
components: StatusPageComponentRecord[];
services: Service[];
}
const getActualStatus = (components: StatusPageComponentRecord[], services: Service[]) => {
if (components.length === 0) {
return 'operational'; // Default if no components
}
let hasDown = false;
let hasDegraded = false;
let hasMaintenance = false;
components.forEach(component => {
const service = services.find(s => s.id === component.service_id);
if (service) {
switch (service.status) {
case 'down':
hasDown = true;
break;
case 'warning':
hasDegraded = true;
break;
case 'paused':
hasMaintenance = true;
break;
}
}
});
// Priority: down > degraded > maintenance > operational
if (hasDown) return 'major_outage';
if (hasDegraded) return 'degraded';
if (hasMaintenance) return 'maintenance';
return 'operational';
};
const getStatusMessage = (status: OperationalPageRecord['status']) => {
switch (status) {
case 'operational':
return 'All systems are operational';
case 'degraded':
return 'Some systems are experiencing degraded performance';
case 'maintenance':
return 'Systems are currently under maintenance';
case 'major_outage':
return 'We are experiencing a major service outage';
default:
return 'Status unknown';
}
};
const getStatusColor = (status: OperationalPageRecord['status']) => {
switch (status) {
case 'operational':
return 'text-green-600 dark:text-green-400';
case 'degraded':
return 'text-yellow-600 dark:text-yellow-400';
case 'maintenance':
return 'text-blue-600 dark:text-blue-400';
case 'major_outage':
return 'text-red-600 dark:text-red-400';
default:
return 'text-muted-foreground';
}
};
const getStatusIcon = (status: OperationalPageRecord['status']) => {
switch (status) {
case 'operational':
return <CheckCircle className="h-6 w-6 text-green-500" />;
case 'degraded':
return <AlertTriangle className="h-6 w-6 text-yellow-500" />;
case 'maintenance':
return <Wrench className="h-6 w-6 text-blue-500" />;
case 'major_outage':
return <XCircle className="h-6 w-6 text-red-500" />;
default:
return <Shield className="h-6 w-6 text-muted-foreground" />;
}
};
const getStatusBackground = (status: OperationalPageRecord['status']) => {
switch (status) {
case 'operational':
return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800';
case 'degraded':
return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800';
case 'maintenance':
return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
case 'major_outage':
return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
default:
return 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800';
}
};
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
const actualStatus = getActualStatus(components, services);
const displayStatus = actualStatus; // Use actual status for real-time accuracy
return (
<Card className={`mb-8 border-2 ${getStatusBackground(displayStatus)}`}>
<CardHeader>
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
<Shield className="h-6 w-6" />
System Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className={`flex items-center justify-between p-6 rounded-lg border-2 ${getStatusBackground(displayStatus)}`}>
<div className="flex items-center gap-4">
{getStatusIcon(displayStatus)}
<div>
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
{getStatusMessage(displayStatus)}
</h3>
<p className="text-sm text-muted-foreground mt-1">
Status automatically updated based on component health
</p>
</div>
</div>
<div className={`px-4 py-2 rounded-full text-sm font-medium ${
displayStatus === 'operational' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
displayStatus === 'degraded' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{displayStatus === 'operational' ? 'All Systems Operational' :
displayStatus === 'degraded' ? 'Degraded Performance' :
displayStatus === 'maintenance' ? 'Under Maintenance' : 'Major Outage'}
</div>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm')} UTC</span>
</div>
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Live status monitoring</span>
</div>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,197 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { TrendingUp, Calendar, BarChart3 } from 'lucide-react';
import { UptimeData } from '@/types/service.types';
import { UptimeHistoryRenderer } from './UptimeHistoryRenderer';
interface OverallUptimeSectionProps {
uptimeData: Record<string, UptimeData[]>;
}
export const OverallUptimeSection = ({ uptimeData }: OverallUptimeSectionProps) => {
const getOverallUptime = () => {
const allHistories = Object.values(uptimeData);
if (allHistories.length === 0) return 99.9;
let totalRecords = 0;
let upRecords = 0;
allHistories.forEach(history => {
totalRecords += history.length;
upRecords += history.filter(record => record.status === 'up').length;
});
if (totalRecords === 0) return 99.9;
return Math.round((upRecords / totalRecords) * 100 * 100) / 100;
};
const getUptimeTrend = () => {
const uptime = getOverallUptime();
if (uptime >= 99.9) return 'excellent';
if (uptime >= 99.5) return 'good';
if (uptime >= 95) return 'fair';
return 'poor';
};
const getIncidentCount = () => {
const allHistories = Object.values(uptimeData);
let incidents = 0;
allHistories.forEach(history => {
let wasDown = false;
history.forEach(record => {
if (record.status === 'down' && !wasDown) {
incidents++;
wasDown = true;
} else if (record.status === 'up') {
wasDown = false;
}
});
});
return incidents;
};
const getBadgeClassName = (trend: string) => {
switch (trend) {
case 'excellent':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
case 'good':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
case 'fair':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
default:
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
}
};
const getTrendText = (trend: string) => {
switch (trend) {
case 'excellent':
return 'Excellent';
case 'good':
return 'Good';
case 'fair':
return 'Fair';
default:
return 'Needs Improvement';
}
};
const getStatusMessage = (uptime: number) => {
if (uptime >= 99.9) {
return "All systems are performing excellently with minimal downtime.";
} else if (uptime >= 99.5) {
return "Systems are performing well with occasional minor issues.";
} else if (uptime >= 95) {
return "We're working to improve system reliability and reduce incidents.";
} else {
return "We apologize for recent service disruptions and are actively working on improvements.";
}
};
const overallUptime = getOverallUptime();
const trend = getUptimeTrend();
const incidentCount = getIncidentCount();
return (
<Card className="mb-8 bg-card border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-card-foreground">
<BarChart3 className="h-5 w-5" />
Performance Metrics (Last 90 Days)
</CardTitle>
<p className="text-sm text-muted-foreground">
Historical performance and reliability statistics
</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-700 dark:text-green-300">Overall Uptime</span>
</div>
<Badge className={getBadgeClassName(trend)}>
{getTrendText(trend)}
</Badge>
</div>
<div className="text-3xl font-bold text-green-600 dark:text-green-400">{overallUptime}%</div>
<div className="text-xs text-green-700 dark:text-green-300 mt-1">
Target: 99.9%
</div>
</div>
<div className="p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Incidents</span>
</div>
<div className="text-3xl font-bold text-blue-600 dark:text-blue-400">{incidentCount}</div>
<div className="text-xs text-blue-700 dark:text-blue-300 mt-1">
Last 90 days
</div>
</div>
<div className="p-4 rounded-lg bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800">
<div className="flex items-center gap-2 mb-2">
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">Avg Response</span>
</div>
<div className="text-3xl font-bold text-purple-600 dark:text-purple-400">100ms</div>
<div className="text-xs text-purple-700 dark:text-purple-300 mt-1">
Response time
</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-foreground">Uptime History</h4>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<div className="h-3 w-3 bg-green-500 rounded"></div>
<span>Operational</span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 bg-yellow-500 rounded"></div>
<span>Degraded</span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 bg-red-500 rounded"></div>
<span>Down</span>
</div>
</div>
</div>
<div className="p-4 bg-background/50 rounded-lg border">
{Object.keys(uptimeData).length > 0 ? (
<UptimeHistoryRenderer serviceId={Object.keys(uptimeData)[0]} uptimeData={uptimeData} />
) : (
<div className="flex justify-center items-center h-12 text-muted-foreground">
<div className="flex gap-1">
{Array.from({ length: 90 }, (_, i) => (
<div key={i} className="h-8 w-1 bg-green-500 rounded-sm"></div>
))}
</div>
</div>
)}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>90 days ago</span>
<span>Today</span>
</div>
</div>
<div className="p-4 bg-muted/50 rounded-lg border">
<div className="text-sm text-muted-foreground text-center">
{getStatusMessage(overallUptime)}
</div>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertCircle } from 'lucide-react';
import { usePublicStatusPageData } from './hooks/usePublicStatusPageData';
import { StatusPageHeader } from './StatusPageHeader';
import { CurrentStatusSection } from './CurrentStatusSection';
import { ComponentsStatusSection } from './ComponentsStatusSection';
import { OverallUptimeSection } from './OverallUptimeSection';
import { PublicStatusPageFooter } from './PublicStatusPageFooter';
export const PublicStatusPage = () => {
const { slug } = useParams<{ slug: string }>();
console.log('PublicStatusPage - slug from params:', slug);
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
const [lastUpdated, setLastUpdated] = useState(new Date());
// Auto-refresh every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
setLastUpdated(new Date());
// The usePublicStatusPageData hook handles data refetching
}, 30000);
return () => clearInterval(interval);
}, []);
// Apply theme to document
useEffect(() => {
if (page) {
const root = document.documentElement;
// Remove any existing theme classes
root.classList.remove('dark', 'light');
// Apply the selected theme
if (page.theme === 'dark') {
root.classList.add('dark');
} else if (page.theme === 'light') {
root.classList.add('light');
}
// For 'default' theme, don't add any class (uses system preference)
}
// Cleanup on unmount
return () => {
const root = document.documentElement;
root.classList.remove('dark', 'light');
};
}, [page?.theme]);
console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
if (loading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<div className="space-y-2">
<p className="text-lg font-medium text-foreground">Loading Status Page</p>
<p className="text-sm text-muted-foreground">Fetching real-time system status...</p>
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
</div>
</div>
</div>
);
}
if (error || !page) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-6 max-w-md">
<div className="mx-auto h-16 w-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center">
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Status Page Not Found</h1>
<p className="text-muted-foreground">
{error || 'The requested status page could not be found or is not publicly accessible.'}
</p>
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p>
</div>
<div className="flex gap-3 justify-center">
<Button onClick={() => window.history.back()} variant="outline">
Go Back
</Button>
<Button onClick={() => window.location.reload()} className="gap-2">
<RefreshCw className="h-4 w-4" />
Retry
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background text-foreground">
{/* Header */}
<StatusPageHeader page={page} />
{/* Main Content */}
<main className="max-w-4xl mx-auto px-4 py-8">
{/* Current Status */}
<CurrentStatusSection page={page} components={components} services={services} />
{/* Components Status */}
<ComponentsStatusSection
components={components}
services={services}
uptimeData={uptimeData}
/>
{/* Overall Uptime History */}
<OverallUptimeSection uptimeData={uptimeData} />
{/* Footer */}
<PublicStatusPageFooter page={page} />
</main>
{/* Custom CSS */}
{page.custom_css && (
<style dangerouslySetInnerHTML={{ __html: page.custom_css }} />
)}
</div>
);
};
@@ -0,0 +1,86 @@
import { OperationalPageRecord } from '@/types/operational.types';
import { format } from 'date-fns';
import { Clock, Shield, Zap, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface PublicStatusPageFooterProps {
page: OperationalPageRecord;
}
export const PublicStatusPageFooter = ({ page }: PublicStatusPageFooterProps) => {
const handleRefresh = () => {
window.location.reload();
};
return (
<footer className="mt-12 pt-8 border-t border-border">
<div className="space-y-6">
{/* Status Information */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-6 bg-muted/30 rounded-lg border">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-green-500/10 rounded-lg flex items-center justify-center">
<Shield className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<div className="font-medium text-foreground">Real-time Monitoring</div>
<div className="text-sm text-muted-foreground">24/7 automated checks</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-blue-500/10 rounded-lg flex items-center justify-center">
<Zap className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div className="font-medium text-foreground">Instant Updates</div>
<div className="text-sm text-muted-foreground">Status changes in real-time</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-purple-500/10 rounded-lg flex items-center justify-center">
<Clock className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<div className="font-medium text-foreground">Historical Data</div>
<div className="text-sm text-muted-foreground">90-day performance history</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm:ss')} UTC</span>
</div>
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Monitoring active</span>
</div>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} className="gap-2">
<RefreshCw className="h-4 w-4" />
Refresh Status
</Button>
</div>
{/* Disclaimer */}
<div className="text-center text-xs text-muted-foreground p-4 bg-muted/20 rounded-lg">
<p>
This status page provides real-time information about our systems and services.
Historical data reflects the last 90 days of monitoring. For support inquiries, please contact our team.
</p>
{page.custom_domain && (
<p className="mt-2">
Powered by automated monitoring Status page for {page.title}
</p>
)}
</div>
</div>
</footer>
);
};
@@ -0,0 +1,71 @@
import { OperationalPageRecord } from '@/types/operational.types';
import { Shield, Globe, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface StatusPageHeaderProps {
page: OperationalPageRecord;
}
export const StatusPageHeader = ({ page }: StatusPageHeaderProps) => {
return (
<header className="bg-background border-b border-border">
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{page.logo_url ? (
<img
src={page.logo_url}
alt={`${page.title} logo`}
className="h-12 w-12 rounded-lg object-cover"
/>
) : (
<div className="h-12 w-12 bg-primary/10 rounded-lg flex items-center justify-center">
<Shield className="h-6 w-6 text-primary" />
</div>
)}
<div>
<h1 className="text-3xl font-bold text-foreground">{page.title}</h1>
<p className="text-muted-foreground mt-1">{page.description}</p>
</div>
</div>
<div className="flex items-center gap-3">
{page.custom_domain && (
<Button variant="outline" size="sm" asChild>
<a
href={`https://${page.custom_domain}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2"
>
<Globe className="h-4 w-4" />
Visit Site
<ExternalLink className="h-3 w-3" />
</a>
</Button>
)}
<div className="text-right text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="font-medium">Live Status</span>
</div>
<div className="text-xs">
Auto-updated every 30s
</div>
</div>
</div>
</div>
{/* Breadcrumb */}
<div className="mt-6 flex items-center gap-2 text-sm text-muted-foreground">
<Shield className="h-4 w-4" />
<span>Status Page</span>
<span></span>
<span className="text-foreground font-medium">{page.title}</span>
</div>
</div>
</header>
);
};
@@ -0,0 +1,182 @@
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { format } from 'date-fns';
import { UptimeData } from '@/types/service.types';
interface UptimeHistoryRendererProps {
serviceId: string;
uptimeData: Record<string, UptimeData[]>;
}
export const UptimeHistoryRenderer = ({ serviceId, uptimeData }: UptimeHistoryRendererProps) => {
const renderUptimeHistory = (serviceId: string) => {
const history = uptimeData[serviceId] || [];
// Generate array for the last 90 days
const days = Array.from({ length: 90 }, (_, i) => {
const daysSinceToday = 90 - i - 1;
const date = new Date();
date.setDate(date.getDate() - daysSinceToday);
date.setHours(0, 0, 0, 0); // Set to start of day for comparison
return date;
});
if (history.length === 0) {
// Generate mock data if no real data - showing mostly operational with some realistic incidents
return days.map((date, i) => {
// Simulate some realistic patterns - mostly up with occasional incidents
const isUp = Math.random() > 0.02; // 98% uptime simulation
const responseTime = isUp ? Math.floor(Math.random() * 200) + 50 : 0;
return (
<TooltipProvider key={i}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`h-8 w-1 rounded-sm cursor-pointer ${
isUp ? 'bg-green-500 hover:bg-green-600' : 'bg-red-500 hover:bg-red-600'
}`}
/>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm">
<div className="font-medium">{format(date, 'MMM dd, yyyy')}</div>
<div className="text-muted-foreground">
Status: {isUp ? 'Operational' : 'Incident - Down'}
</div>
{isUp && (
<div className="text-muted-foreground">
Response: {responseTime}ms
</div>
)}
{!isUp && (
<div className="text-muted-foreground text-red-400">
Service outage detected
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
}
// Create a map of dates to status records for efficient lookup and incident tracking
const dateToRecordMap = new Map();
const incidentsByDate = new Map();
history.forEach(record => {
const recordDate = new Date(record.timestamp);
recordDate.setHours(0, 0, 0, 0); // Normalize to start of day
const dateKey = recordDate.toDateString();
// Track incidents for this date
if (!incidentsByDate.has(dateKey)) {
incidentsByDate.set(dateKey, []);
}
incidentsByDate.get(dateKey).push(record);
// Keep the latest record for each day (or aggregate if needed)
if (!dateToRecordMap.has(dateKey) ||
new Date(record.timestamp) > new Date(dateToRecordMap.get(dateKey).timestamp)) {
dateToRecordMap.set(dateKey, record);
}
});
// Use real uptime data mapped to the correct days with incident details
return days.map((date, i) => {
const dateKey = date.toDateString();
const record = dateToRecordMap.get(dateKey);
const incidents = incidentsByDate.get(dateKey) || [];
// Calculate uptime percentage for the day
const uptimePercentage = incidents.length > 0 ?
Math.round((incidents.filter(inc => inc.status === 'up').length / incidents.length) * 100) : 100;
// Determine color based on actual status and incident history
const getStatusColor = (status: string, incidents: UptimeData[]) => {
const downIncidents = incidents.filter(inc => inc.status === 'down').length;
const warningIncidents = incidents.filter(inc => inc.status === 'warning').length;
if (downIncidents > 0) return 'bg-red-500 hover:bg-red-600';
if (warningIncidents > 0) return 'bg-yellow-500 hover:bg-yellow-600';
switch (status) {
case 'up':
return 'bg-green-500 hover:bg-green-600';
case 'down':
return 'bg-red-500 hover:bg-red-600';
case 'paused':
return 'bg-gray-400 hover:bg-gray-500';
case 'warning':
return 'bg-yellow-500 hover:bg-yellow-600';
default:
return 'bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500';
}
};
const statusColor = record ? getStatusColor(record.status, incidents) : 'bg-gray-300 dark:bg-gray-600';
const statusText = record ?
record.status === 'up' ? (incidents.some(inc => inc.status === 'down') ? 'Recovered' : 'Operational') :
record.status === 'down' ? 'Incident - Down' :
record.status === 'paused' ? 'Paused' :
record.status === 'warning' ? 'Incident - Degraded' : 'Unknown'
: 'No Data';
return (
<TooltipProvider key={i}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`h-8 w-1 rounded-sm cursor-pointer ${statusColor}`}
/>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm">
<div className="font-medium">
{format(date, 'MMM dd, yyyy')}
</div>
<div className="text-muted-foreground">
Status: {statusText}
</div>
{incidents.length > 0 && (
<div className="text-muted-foreground">
Uptime: {uptimePercentage}% ({incidents.length} checks)
</div>
)}
{incidents.filter(inc => inc.status === 'down').length > 0 && (
<div className="text-red-400 text-xs">
{incidents.filter(inc => inc.status === 'down').length} incident(s) detected
</div>
)}
{record && record.status === 'up' && record.responseTime > 0 && (
<div className="text-muted-foreground">
Response: {record.responseTime}ms
</div>
)}
{record && (
<div className="text-muted-foreground">
Last Check: {format(new Date(record.timestamp), 'HH:mm')}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
});
};
return (
<div className="ml-8 p-3 bg-background/30 rounded-lg border border-border/50">
<div className="text-sm font-medium text-foreground mb-2">90-day uptime history</div>
<div className="flex items-center gap-1 mb-2">
{renderUptimeHistory(serviceId)}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>90 days ago</span>
<span>Today</span>
</div>
</div>
);
};
@@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import { OperationalPageRecord } from '@/types/operational.types';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { Service, UptimeData } from '@/types/service.types';
import { operationalPageService } from '@/services/operationalPageService';
import { statusPageComponentsService } from '@/services/statusPageComponentsService';
import { serviceService } from '@/services/serviceService';
import { uptimeService } from '@/services/uptimeService';
export const usePublicStatusPageData = (slug: string | undefined) => {
const [page, setPage] = useState<OperationalPageRecord | null>(null);
const [components, setComponents] = useState<StatusPageComponentRecord[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [uptimeData, setUptimeData] = useState<Record<string, UptimeData[]>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPublicPage = async () => {
if (!slug) {
console.log('No slug provided');
setError('No status page slug provided');
setLoading(false);
return;
}
try {
console.log('Fetching public status page for slug:', slug);
setLoading(true);
setError(null);
// Fetch operational page
console.log('Fetching operational pages...');
const pages = await operationalPageService.getOperationalPages();
console.log('All pages:', pages);
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
console.log('Found page:', foundPage);
if (!foundPage) {
console.log('Page not found or not public');
setError('Status page not found or not public');
setLoading(false);
return;
}
setPage(foundPage);
console.log('Page set successfully');
// Fetch components for this page
console.log('Fetching components for page:', foundPage.id);
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
console.log('Components found:', pageComponents);
setComponents(pageComponents);
// Fetch all services
console.log('Fetching all services...');
const allServices = await serviceService.getServices();
console.log('Services found:', allServices);
setServices(allServices);
// Fetch uptime data for each component that has a service
console.log('Fetching uptime data...');
const uptimePromises = pageComponents
.filter(component => component.service_id)
.map(async (component) => {
try {
console.log('Fetching uptime for service:', component.service_id);
const endDate = new Date();
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days
const history = await uptimeService.getUptimeHistory(component.service_id, 2000, startDate, endDate);
console.log(`Uptime history for ${component.service_id}:`, history.length, 'records');
return { serviceId: component.service_id, history };
} catch (error) {
console.error(`Error fetching uptime for service ${component.service_id}:`, error);
return { serviceId: component.service_id, history: [] };
}
});
const uptimeResults = await Promise.all(uptimePromises);
const uptimeMap: Record<string, UptimeData[]> = {};
uptimeResults.forEach(result => {
uptimeMap[result.serviceId] = result.history;
});
setUptimeData(uptimeMap);
console.log('Uptime data set successfully');
console.log('All data fetched successfully');
} catch (err) {
console.error('Error fetching public page:', err);
setError(`Failed to load status page: ${err}`);
} finally {
setLoading(false);
}
};
fetchPublicPage();
}, [slug]);
return {
page,
components,
services,
uptimeData,
loading,
error
};
};
@@ -0,0 +1,460 @@
import React, { useState } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Copy, Download, Terminal, CheckCircle, Zap, Play } from "lucide-react";
import { regionalService } from "@/services/regionalService";
import { InstallCommand } from "@/types/regional.types";
import { useToast } from "@/hooks/use-toast";
interface AddRegionalAgentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onAgentAdded: () => void;
}
export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
open,
onOpenChange,
onAgentAdded
}) => {
const [step, setStep] = useState(1);
const [regionName, setRegionName] = useState("");
const [agentIp, setAgentIp] = useState("");
const [installCommand, setInstallCommand] = useState<InstallCommand | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
const handleCreateAgent = async (e: React.FormEvent) => {
e.preventDefault();
if (!regionName.trim() || !agentIp.trim()) return;
setIsSubmitting(true);
try {
const result = await regionalService.createRegionalService({
region_name: regionName,
agent_ip_address: agentIp,
});
setInstallCommand(result.installCommand);
setStep(2);
} catch (error) {
toast({
title: "Error",
description: "Failed to create regional agent configuration.",
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
const copyToClipboard = async (text: string, description: string = "Content") => {
try {
// Try the modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
toast({
title: "Copied!",
description: `${description} copied to clipboard.`,
});
return;
}
// Fallback for older browsers or non-secure contexts (like localhost)
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
toast({
title: "Copied!",
description: `${description} copied to clipboard.`,
});
} else {
throw new Error('execCommand failed');
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Show the text in a modal or alert as final fallback
const userAgent = navigator.userAgent.toLowerCase();
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let errorMessage = "Failed to copy to clipboard.";
if (isLocalhost) {
errorMessage += " This is common in local development. Please manually copy the text.";
} else if (userAgent.includes('chrome')) {
errorMessage += " Try using HTTPS or enable clipboard permissions.";
}
toast({
title: "Copy failed",
description: errorMessage,
variant: "destructive",
});
// As a last resort, select the text for manual copying
try {
const textarea = document.querySelector('textarea[readonly]') as HTMLTextAreaElement;
if (textarea) {
textarea.select();
textarea.setSelectionRange(0, 99999); // For mobile devices
}
} catch (selectError) {
console.error('Failed to select text:', selectError);
}
}
};
const downloadScript = () => {
if (!installCommand) return;
const blob = new Blob([installCommand.bash_script], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `install-regional-agent-${installCommand.agent_id}.sh`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "Downloaded!",
description: "Installation script downloaded successfully.",
});
};
const handleComplete = () => {
onAgentAdded();
setStep(1);
setRegionName("");
setAgentIp("");
setInstallCommand(null);
onOpenChange(false);
};
const resetDialog = () => {
setStep(1);
setRegionName("");
setAgentIp("");
setInstallCommand(null);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>Add Regional Monitoring Agent</DialogTitle>
<DialogDescription>
Deploy a regional monitoring agent with automatic one-click installation.
</DialogDescription>
</DialogHeader>
{step === 1 && (
<form onSubmit={handleCreateAgent} className="space-y-6">
<div className="space-y-4">
<div>
<Label htmlFor="regionName">Region Name</Label>
<Input
id="regionName"
placeholder="e.g., us-east-1, europe-west-1, asia-pacific-1"
value={regionName}
onChange={(e) => setRegionName(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="agentIp">Agent Server IP Address</Label>
<Input
id="agentIp"
placeholder="e.g., 192.168.1.100 or your-server.example.com"
value={agentIp}
onChange={(e) => setAgentIp(e.target.value)}
required
/>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Generating..." : "Generate Installation"}
</Button>
</div>
</form>
)}
{step === 2 && installCommand && (
<div className="space-y-6">
<div className="text-center space-y-2">
<CheckCircle className="h-12 w-12 text-green-600 mx-auto" />
<h3 className="text-lg font-semibold">Agent Configuration Ready!</h3>
<p className="text-muted-foreground">
One-click installation script generated with automatic configuration.
</p>
</div>
<Tabs defaultValue="oneclicK" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="oneclicK">One-Click Install</TabsTrigger>
<TabsTrigger value="details">Agent Details</TabsTrigger>
<TabsTrigger value="manual">Manual Install</TabsTrigger>
</TabsList>
<TabsContent value="oneclicK" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-yellow-500" />
One-Click Automatic Installation
</CardTitle>
<CardDescription>
Complete installation, configuration, and service startup in one command
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-green-800 font-medium mb-2">
<Play className="h-4 w-4" />
What this script does automatically:
</div>
<ul className="text-sm text-green-700 space-y-1 ml-6">
<li> Downloads the latest .deb package</li>
<li> Installs the regional monitoring agent</li>
<li> Creates configuration file with your settings</li>
<li> Starts and enables the service</li>
<li> Runs health checks</li>
</ul>
</div>
<div className="space-y-2">
<Label>Run this command on your target server:</Label>
<div className="relative">
<Textarea
readOnly
value={`# One-click installation command:
curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- \\
--region-name="${regionName}" \\
--agent-id="${installCommand.agent_id}" \\
--agent-ip="${agentIp}" \\
--token="${installCommand.token}" \\
--pocketbase-url="${installCommand.api_endpoint}"`}
className="font-mono text-sm min-h-[120px] pr-12"
/>
<Button
size="sm"
variant="outline"
className="absolute top-2 right-2"
onClick={() => copyToClipboard(`curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- --region-name="${regionName}" --agent-id="${installCommand.agent_id}" --agent-ip="${agentIp}" --token="${installCommand.token}" --pocketbase-url="${installCommand.api_endpoint}"`, "Installation command")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex gap-2">
<Button onClick={downloadScript} variant="outline" className="flex-1">
<Download className="mr-2 h-4 w-4" />
Download Complete Script
</Button>
<Button
onClick={() => copyToClipboard(installCommand.bash_script, "Installation script")}
variant="outline"
className="flex-1"
>
<Copy className="mr-2 h-4 w-4" />
Copy Full Script
</Button>
</div>
{/* Local development notice */}
{(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
<strong>Local Development Notice:</strong> If clipboard copying fails, this is normal in local development.
You can use the "Download Complete Script" button instead or manually copy the text.
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="details" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Generated Agent Configuration</CardTitle>
<CardDescription>
These values will be automatically configured during installation
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium text-foreground">Agent ID</Label>
<div className="relative">
<Input
value={installCommand.agent_id}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
/>
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(installCommand.agent_id, "Agent ID")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div>
<Label className="text-sm font-medium text-foreground">Region Name</Label>
<Input
value={regionName}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
/>
</div>
<div>
<Label className="text-sm font-medium text-foreground">Server IP</Label>
<Input
value={agentIp}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
/>
</div>
<div>
<Label className="text-sm font-medium text-foreground">API Endpoint</Label>
<Input
value={installCommand.api_endpoint}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
/>
</div>
</div>
<div>
<Label className="text-sm font-medium text-foreground">Authentication Token</Label>
<div className="relative">
<Input
value={installCommand.token}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
/>
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(installCommand.token, "Authentication token")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Configuration file location:</strong> /etc/regional-check-agent/regional-check-agent.env
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
Manual Installation Steps
</CardTitle>
<CardDescription>
Step-by-step manual installation process
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm space-y-4">
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 1: Download Package</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
<p className="text-xs text-muted-foreground mt-1"> If youre using the amd64 architecture, please download this package. </p> <br/>
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_amd64.deb
</code>
<code className="text-xs bg-muted p-2 rounded block mt-1">
<p className="text-xs text-muted-foreground mt-1"> If youre using the arm64 architecture, please download this package. </p> <br/>
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_arm64.deb
</code>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 2: Install Package</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo dpkg -i distributed-regional-check-agent_1.0.0_amd64.deb<br/>
sudo apt-get install -f
</code>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 3: Configure Agent</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo nano /etc/regional-check-agent/regional-check-agent.env
</code>
<code className="text-xs bg-muted p-2 rounded block mt-1">
Copy sample configuration from https://github.com/operacle/Distributed-Regional-Monitoring/blob/main/packaging/regional-check-agent.conf
</code>
<p className="text-xs text-muted-foreground mt-1">Add the configuration values shown in the Agent Details tab</p>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 4: Start Service</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo systemctl enable regional-check-agent<br/>
sudo systemctl start regional-check-agent
</code>
</div>
<div className="border-l-4 border-green-500 pl-4">
<p className="font-medium">Step 5: Verify Installation</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo systemctl status regional-check-agent<br/>
curl http://localhost:8091/health
</code>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={resetDialog}>
Add Another Agent
</Button>
<Button onClick={handleComplete}>
Complete Setup
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,151 @@
import React from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { MapPin, Wifi, WifiOff, MoreVertical, Trash2, Terminal, Copy } from "lucide-react";
import { RegionalService } from "@/types/regional.types";
import { formatDistanceToNow } from "date-fns";
import { useToast } from "@/hooks/use-toast";
interface RegionalAgentCardProps {
agent: RegionalService;
onDelete: () => void;
}
export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => {
const { toast } = useToast();
// Check if this is the default agent that cannot be removed
const isDefaultAgent = agent.agent_id === "1" || agent.region_name === "Default";
const copyAgentId = async () => {
try {
await navigator.clipboard.writeText(agent.agent_id);
toast({
title: "Copied!",
description: "Agent ID copied to clipboard.",
});
} catch (error) {
toast({
title: "Copy failed",
description: "Failed to copy agent ID.",
variant: "destructive",
});
}
};
const getConnectionStatus = () => {
if (agent.connection === 'online') {
return {
icon: <Wifi className="h-4 w-4" />,
label: 'Online',
variant: 'default' as const,
className: 'bg-green-100 text-green-800 hover:bg-green-100'
};
} else {
return {
icon: <WifiOff className="h-4 w-4" />,
label: 'Offline',
variant: 'secondary' as const,
className: 'bg-red-100 text-red-800 hover:bg-red-100'
};
}
};
const connectionStatus = getConnectionStatus();
return (
<Card className="relative">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-2">
<MapPin className="h-5 w-5 text-blue-600" />
<div>
<CardTitle className="text-lg flex items-center gap-2">
{agent.region_name}
{isDefaultAgent && (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
Default
</Badge>
)}
</CardTitle>
<CardDescription className="flex items-center gap-1 text-sm">
{agent.agent_ip_address}
</CardDescription>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyAgentId}>
<Copy className="mr-2 h-4 w-4" />
Copy Agent ID
</DropdownMenuItem>
{!isDefaultAgent && (
<DropdownMenuItem onClick={onDelete} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" />
Remove Agent
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Badge
variant={connectionStatus.variant}
className={connectionStatus.className}
>
{connectionStatus.icon}
<span className="ml-1">{connectionStatus.label}</span>
</Badge>
<Badge variant="outline" className="text-xs">
{agent.status}
</Badge>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Agent ID:</span>
<span className="font-mono text-xs">{agent.agent_id.substring(0, 12)}...</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Last Updated:</span>
<span className="text-xs">
{formatDistanceToNow(new Date(agent.updated), { addSuffix: true })}
</span>
</div>
</div>
{agent.connection === 'online' && (
<div className="pt-2 border-t">
<div className="flex items-center text-xs text-green-600">
<div className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></div>
Active monitoring
</div>
</div>
)}
{agent.connection === 'offline' && (
<div className="pt-2 border-t">
<div className="flex items-center text-xs text-red-600">
<div className="w-2 h-2 bg-red-600 rounded-full mr-2"></div>
Connection lost
</div>
</div>
)}
</CardContent>
</Card>
);
};
@@ -0,0 +1,164 @@
import React, { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, MapPin, Activity, Wifi, WifiOff } from "lucide-react";
import { regionalService } from "@/services/regionalService";
import { RegionalService } from "@/types/regional.types";
import { AddRegionalAgentDialog } from "./AddRegionalAgentDialog";
import { RegionalAgentCard } from "./RegionalAgentCard";
import { useToast } from "@/hooks/use-toast";
export const RegionalMonitoringContent = () => {
const [addDialogOpen, setAddDialogOpen] = useState(false);
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: regionalServices = [], isLoading, error } = useQuery({
queryKey: ['regional-services'],
queryFn: regionalService.getRegionalServices,
refetchInterval: 30000, // Refetch every 30 seconds
});
const handleAgentAdded = () => {
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
toast({
title: "Regional Agent Added",
description: "The regional monitoring agent has been successfully configured.",
});
};
const handleDeleteAgent = async (id: string) => {
try {
await regionalService.deleteRegionalService(id);
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
toast({
title: "Agent Removed",
description: "The regional monitoring agent has been removed.",
});
} catch (error) {
toast({
title: "Error",
description: "Failed to remove the regional monitoring agent.",
variant: "destructive",
});
}
};
const onlineAgents = regionalServices.filter(agent => agent.connection === 'online').length;
const totalAgents = regionalServices.length;
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Regional Monitoring</h1>
<p className="text-muted-foreground">
Manage distributed monitoring agents across different regions
</p>
</div>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Regional Agent
</Button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Agents</CardTitle>
<MapPin className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalAgents}</div>
<p className="text-xs text-muted-foreground">
Regional monitoring agents
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Online Agents</CardTitle>
<Wifi className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{onlineAgents}</div>
<p className="text-xs text-muted-foreground">
Currently connected
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Offline Agents</CardTitle>
<WifiOff className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{totalAgents - onlineAgents}</div>
<p className="text-xs text-muted-foreground">
Disconnected agents
</p>
</CardContent>
</Card>
</div>
{/* Agents List */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Regional Agents</h2>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(3)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-3 bg-gray-200 rounded"></div>
<div className="h-3 bg-gray-200 rounded w-5/6"></div>
</div>
</CardContent>
</Card>
))}
</div>
) : regionalServices.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<MapPin className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No Regional Agents</h3>
<p className="text-muted-foreground text-center mb-4">
Get started by adding your first regional monitoring agent to extend your monitoring coverage.
</p>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add First Agent
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{regionalServices.map((agent) => (
<RegionalAgentCard
key={agent.id}
agent={agent}
onDelete={() => handleDeleteAgent(agent.id)}
/>
))}
</div>
)}
</div>
<AddRegionalAgentDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
onAgentAdded={handleAgentAdded}
/>
</div>
);
};
@@ -0,0 +1,4 @@
export { RegionalMonitoringContent } from './RegionalMonitoringContent';
export { AddRegionalAgentDialog } from './AddRegionalAgentDialog';
export { RegionalAgentCard } from './RegionalAgentCard';
@@ -0,0 +1,13 @@
import React from 'react';
import { IncidentManagementContainer } from './incident-management';
interface IncidentManagementTabProps {
refreshTrigger?: number;
}
export const IncidentManagementTab = React.memo(({ refreshTrigger = 0 }: IncidentManagementTabProps) => {
return <IncidentManagementContainer refreshTrigger={refreshTrigger} />;
});
IncidentManagementTab.displayName = 'IncidentManagementTab';
@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Plus, CalendarClock, AlertCircle } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
import { ScheduledMaintenanceTab } from "./ScheduledMaintenanceTab";
import { IncidentManagementTab } from "./IncidentManagementTab";
import { CreateMaintenanceDialog } from './maintenance/CreateMaintenanceDialog';
import { CreateIncidentDialog } from './incident/CreateIncidentDialog';
import { useToast } from '@/hooks/use-toast';
import { initMaintenanceNotifications, stopMaintenanceNotifications } from '@/services/maintenance/maintenanceNotificationService';
export const ScheduleIncidentContent = () => {
const { t } = useLanguage();
const { toast } = useToast();
const [activeTab, setActiveTab] = useState("maintenance");
const [createMaintenanceDialogOpen, setCreateMaintenanceDialogOpen] = useState(false);
const [createIncidentDialogOpen, setCreateIncidentDialogOpen] = useState(false);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [incidentRefreshTrigger, setIncidentRefreshTrigger] = useState(0);
// Initialize maintenance notifications when the component mounts
useEffect(() => {
// console.log("Initializing maintenance notifications");
initMaintenanceNotifications();
// Clean up when the component unmounts
return () => {
// console.log("Cleaning up maintenance notifications");
stopMaintenanceNotifications();
};
}, []);
const handleCreateButtonClick = () => {
if (activeTab === "maintenance") {
setCreateMaintenanceDialogOpen(true);
} else {
setCreateIncidentDialogOpen(true);
}
};
const handleMaintenanceCreated = () => {
// Refresh data by incrementing the refresh trigger
const newTriggerValue = refreshTrigger + 1;
// console.log("Maintenance created, refreshing data with new trigger value:", newTriggerValue);
setRefreshTrigger(newTriggerValue);
// Show success toast
toast({
title: t('success'),
description: t('maintenanceCreatedSuccess'),
});
};
const handleIncidentCreated = () => {
// Refresh data by incrementing the refresh trigger
const newTriggerValue = incidentRefreshTrigger + 1;
// console.log("Incident created, refreshing data with new trigger value:", newTriggerValue);
setIncidentRefreshTrigger(newTriggerValue);
// Show success toast
toast({
title: t('success'),
description: t('incidentCreatedSuccess'),
});
};
return (
<main className="flex-1 flex flex-col overflow-auto bg-background p-6">
<div className="flex flex-col gap-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-foreground">
{t('scheduleIncidentManagement')}
</h2>
<Button
className="text-primary-foreground"
onClick={handleCreateButtonClick}
>
<Plus className="w-4 h-4 mr-2" />
{activeTab === "maintenance" ? t('createMaintenanceWindow') : t('createIncident')}
</Button>
</div>
<Tabs
defaultValue="maintenance"
className="w-full"
onValueChange={(value) => setActiveTab(value)}
>
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="maintenance">
<CalendarClock className="w-4 h-4 mr-2" />
{t('scheduledMaintenance')}
</TabsTrigger>
<TabsTrigger value="incidents">
<AlertCircle className="w-4 h-4 mr-2" />
{t('incidentManagement')}
</TabsTrigger>
</TabsList>
<TabsContent value="maintenance" className="space-y-4">
<ScheduledMaintenanceTab refreshTrigger={refreshTrigger} />
</TabsContent>
<TabsContent value="incidents" className="space-y-4">
<IncidentManagementTab refreshTrigger={incidentRefreshTrigger} />
</TabsContent>
</Tabs>
</div>
{/* Maintenance creation dialog */}
<CreateMaintenanceDialog
open={createMaintenanceDialogOpen}
onOpenChange={setCreateMaintenanceDialogOpen}
onMaintenanceCreated={handleMaintenanceCreated}
/>
{/* Incident creation dialog */}
<CreateIncidentDialog
open={createIncidentDialogOpen}
onOpenChange={setCreateIncidentDialogOpen}
onIncidentCreated={handleIncidentCreated}
/>
</main>
);
};
@@ -0,0 +1,181 @@
import React, { useEffect } from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { MaintenanceTable, MaintenanceStatusChecker } from './maintenance';
import { LoadingState } from '@/components/services/LoadingState';
import { Calendar, Clock, CheckCircle } from 'lucide-react';
import { useMaintenanceData } from './hooks/useMaintenanceData';
import { useToast } from '@/hooks/use-toast';
import { useTheme } from '@/contexts/ThemeContext';
interface ScheduledMaintenanceTabProps {
refreshTrigger?: number;
}
export const ScheduledMaintenanceTab = ({ refreshTrigger = 0 }: ScheduledMaintenanceTabProps) => {
const { t } = useLanguage();
const { theme } = useTheme();
const { toast } = useToast();
const [manualRefresh, setManualRefresh] = React.useState(0);
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
const {
loading,
filter,
setFilter,
maintenanceData,
overviewStats,
fetchMaintenanceData,
isEmpty,
error,
initialized,
} = useMaintenanceData({ refreshTrigger: combinedRefreshTrigger });
useEffect(() => {
if (error) {
toast({
title: t('error'),
description: error,
variant: 'destructive',
});
}
}, [error, toast, t]);
useEffect(() => {
fetchMaintenanceData(true);
}, [fetchMaintenanceData]);
const handleMaintenanceUpdated = () => {
setManualRefresh((prev) => prev + 1);
};
const handleTabChange = (value: string) => {
setFilter(value);
};
if (loading && !initialized) {
return <LoadingState />;
}
const gradientCard = (
title: string,
value: string | number,
icon: JSX.Element,
gradient: string
) => (
<Card
className="border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative z-10"
style={{
background: theme === 'dark'
? `linear-gradient(135deg, rgba(65,59,55,0.8) 0%, ${gradient})`
: `linear-gradient(135deg, rgba(65,59,55,0.8) 0%, ${gradient})`,
}}
>
<div className="absolute inset-0 z-0 opacity-10">
<div
className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#000 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px',
}}
/>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">{title}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-4xl font-bold text-white">{value}</span>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
{icon}
</div>
</CardContent>
</Card>
);
return (
<>
<MaintenanceStatusChecker
maintenanceData={maintenanceData}
onStatusUpdated={handleMaintenanceUpdated}
/>
{/* Overview Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8 w-full">
{gradientCard(
t('upcomingMaintenance'),
loading ? '...' : overviewStats.upcoming,
<Calendar className="h-6 w-6 text-white" />,
'rgba(59,130,246,0.6)'
)}
{gradientCard(
t('ongoingMaintenance'),
loading ? '...' : overviewStats.ongoing,
<Clock className="h-6 w-6 text-white" />,
'rgba(251,191,36,0.6)'
)}
{gradientCard(
t('completedMaintenance'),
loading ? '...' : overviewStats.completed,
<CheckCircle className="h-6 w-6 text-white" />,
'rgba(34,197,94,0.6)'
)}
{gradientCard(
t('totalScheduledHours'),
loading ? '...' : `${overviewStats.totalDuration}h`,
<Clock className="h-6 w-6 text-white" />,
'rgba(139,92,246,0.6)'
)}
</div>
<Card>
<CardHeader>
<CardTitle>{t('scheduledMaintenance')}</CardTitle>
<CardDescription>
{t('scheduledMaintenanceDesc')}
</CardDescription>
</CardHeader>
<CardContent>
<Tabs
defaultValue="upcoming"
value={filter}
className="w-full"
onValueChange={handleTabChange}
>
<TabsList className="mb-6">
<TabsTrigger value="upcoming">{t('upcomingMaintenance')}</TabsTrigger>
<TabsTrigger value="ongoing">{t('ongoingMaintenance')}</TabsTrigger>
<TabsTrigger value="completed">{t('completedMaintenance')}</TabsTrigger>
</TabsList>
<TabsContent value="upcoming">
<MaintenanceTable
data={maintenanceData}
isLoading={loading && initialized}
onMaintenanceUpdated={handleMaintenanceUpdated}
/>
</TabsContent>
<TabsContent value="ongoing">
<MaintenanceTable
data={maintenanceData}
isLoading={loading && initialized}
onMaintenanceUpdated={handleMaintenanceUpdated}
/>
</TabsContent>
<TabsContent value="completed">
<MaintenanceTable
data={maintenanceData}
isLoading={loading && initialized}
onMaintenanceUpdated={handleMaintenanceUpdated}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</>
);
};
@@ -0,0 +1,117 @@
import React, { ReactNode } from 'react';
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { useTheme } from "@/contexts/ThemeContext";
interface OverviewCardProps {
title: string;
value: string | number;
description?: string;
icon: ReactNode;
trend?: {
value: number;
isPositive: boolean;
};
className?: string;
valueClassName?: string;
isLoading?: boolean;
color?: string;
gradient?: string;
}
export const OverviewCard = ({
title,
value,
description,
icon,
trend,
className,
valueClassName,
isLoading = false,
color = "blue",
gradient,
}: OverviewCardProps) => {
const { theme } = useTheme();
// Map color prop to gradient colors
const getGradientBackground = () => {
if (gradient) {
return gradient;
}
const colors = {
blue: theme === 'dark'
? "linear-gradient(135deg, rgba(25, 118, 210, 0.8) 0%, rgba(66, 165, 245, 0.6) 100%)"
: "linear-gradient(135deg, #1976d2 0%, #42a5f5 100%)",
green: theme === 'dark'
? "linear-gradient(135deg, rgba(67, 160, 71, 0.8) 0%, rgba(102, 187, 106, 0.6) 100%)"
: "linear-gradient(135deg, #43a047 0%, #66bb6a 100%)",
amber: theme === 'dark'
? "linear-gradient(135deg, rgba(255, 152, 0, 0.8) 0%, rgba(255, 183, 77, 0.6) 100%)"
: "linear-gradient(135deg, #ff9800 0%, #ffb74d 100%)",
red: theme === 'dark'
? "linear-gradient(135deg, rgba(229, 57, 53, 0.8) 0%, rgba(239, 83, 80, 0.6) 100%)"
: "linear-gradient(135deg, #e53935 0%, #ef5350 100%)",
purple: theme === 'dark'
? "linear-gradient(135deg, rgba(123, 31, 162, 0.8) 0%, rgba(156, 39, 176, 0.6) 100%)"
: "linear-gradient(135deg, #7b1fa2 0%, #9c27b0 100%)",
orange: theme === 'dark'
? "linear-gradient(135deg, rgba(230, 81, 0, 0.8) 0%, rgba(255, 109, 0, 0.6) 100%)"
: "linear-gradient(135deg, #e65100 0%, #ff6d00 100%)",
};
return colors[color as keyof typeof colors] || colors.blue;
};
return (
<Card
className={cn(
"border-none rounded-xl shadow-lg overflow-hidden transition-all duration-300 hover:shadow-xl transform hover:-translate-y-1 relative z-10",
className
)}
style={{
background: getGradientBackground()
}}
>
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0 opacity-10">
<div className="w-full h-full"
style={{
backgroundImage: `linear-gradient(#fff 1px, transparent 1px),
linear-gradient(90deg, #fff 1px, transparent 1px)`,
backgroundSize: '20px 20px'
}}
></div>
</div>
<CardContent className="p-6 relative z-10">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white/90 mb-1">{title}</p>
{isLoading ? (
<Skeleton className="h-8 w-16 bg-white/20" />
) : (
<h3 className={cn("text-3xl font-bold text-white", valueClassName)}>{value}</h3>
)}
{description && (
<p className="text-sm text-white/80 mt-1">{description}</p>
)}
{trend && (
<div className={`flex items-center mt-2 text-sm ${trend.isPositive ? 'text-green-100' : 'text-red-100'}`}>
<span className="mr-1">
{trend.isPositive ? '↑' : '↓'}
</span>
<span>{Math.abs(trend.value)}%</span>
<span className="ml-1 text-white/70">vs last month</span>
</div>
)}
</div>
<div className="rounded-full p-3 bg-white/25 backdrop-blur-sm">
{icon}
</div>
</div>
</CardContent>
</Card>
);
};
@@ -0,0 +1,3 @@
export * from './useMaintenanceData';
export * from './useIncidentData';
@@ -0,0 +1,200 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { incidentService, IncidentItem } from '@/services/incident';
interface UseIncidentDataProps {
refreshTrigger?: number;
}
export const useIncidentData = ({ refreshTrigger = 0 }: UseIncidentDataProps) => {
const [incidents, setIncidents] = useState<IncidentItem[]>([]);
const [filter, setFilter] = useState("unresolved");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Use a ref to prevent multiple simultaneous fetch requests
const isFetchingRef = useRef(false);
// Use a ref to track the refresh trigger
const lastRefreshTriggerRef = useRef(refreshTrigger);
// Simplified fetch function with improved controls to prevent duplicate calls
const fetchIncidentData = useCallback(async (force = false) => {
// Skip if already fetching
if (isFetchingRef.current) {
// console.log('Already fetching data, skipping additional request');
return;
}
// Skip if not forced and already initialized
if (initialized && !force) {
// console.log('Data already initialized and no force refresh, skipping fetch');
return;
}
// Set fetching flags
isFetchingRef.current = true;
if (!initialized || force) {
setLoading(true);
}
if (force) {
setIsRefreshing(true);
}
setError(null);
try {
// console.log(`Fetching incident data (force=${force})`);
const allIncidents = await incidentService.getAllIncidents(force);
if (Array.isArray(allIncidents)) {
setIncidents(allIncidents);
// console.log(`Successfully set ${allIncidents.length} incidents to state`);
} else {
setIncidents([]);
// console.warn('No incidents returned from service');
}
setInitialized(true);
setLoading(false);
setIsRefreshing(false);
} catch (error) {
// console.error('Error fetching incident data:', error);
setError('Failed to load incident data. Please try again later.');
setIncidents([]);
setInitialized(true);
setLoading(false);
setIsRefreshing(false);
} finally {
// Reset fetching flag after a slight delay to prevent rapid consecutive calls
setTimeout(() => {
isFetchingRef.current = false;
}, 500);
}
}, [initialized]);
// Only fetch when component mounts or refreshTrigger changes
useEffect(() => {
// Skip if the refresh trigger hasn't changed (prevents duplicate effect calls)
if (refreshTrigger === lastRefreshTriggerRef.current && initialized) {
// console.log('Refresh trigger unchanged, skipping fetch');
return;
}
// Update last refresh trigger ref
lastRefreshTriggerRef.current = refreshTrigger;
// Create an abort controller for cleanup
const abortController = new AbortController();
let isMounted = true;
// console.log(`useIncidentData effect running, refreshTrigger: ${refreshTrigger}`);
// Use a longer delay to ensure we don't trigger too many API calls
const fetchTimer = setTimeout(() => {
if (isMounted) {
fetchIncidentData(refreshTrigger > 0);
}
}, 500); // Use a longer delay
// Cleanup function to abort any in-flight requests and clear timers
return () => {
// console.log('Cleaning up incident data fetch effect');
isMounted = false;
clearTimeout(fetchTimer);
abortController.abort();
};
}, [fetchIncidentData, refreshTrigger, initialized]);
// Filter the data based on the current filter
const incidentData = useMemo(() => {
if (!initialized || incidents.length === 0) return [];
// console.log(`Filtering incidents by: ${filter}`);
if (filter === "unresolved") {
return incidents.filter(item => {
const status = (item.status || item.impact_status || '').toLowerCase();
return status !== 'resolved';
});
} else if (filter === "resolved") {
return incidents.filter(item => {
const status = (item.status || item.impact_status || '').toLowerCase();
return status === 'resolved';
});
}
return [];
}, [filter, incidents, initialized]);
// Calculate stats for overview cards
const overviewStats = useMemo(() => {
if (!initialized || incidents.length === 0) {
return {
unresolved: 0,
resolved: 0,
critical: 0,
highPriority: 0,
avgResolutionTime: "0h"
};
}
const unresolvedIncidents = incidents.filter(item => {
const status = (item.status || item.impact_status || '').toLowerCase();
return status !== 'resolved';
});
const resolvedIncidents = incidents.filter(item => {
const status = (item.status || item.impact_status || '').toLowerCase();
return status === 'resolved';
});
const criticalCount = unresolvedIncidents.filter(item =>
(item.priority?.toLowerCase() === 'critical') ||
(item.impact?.toLowerCase() === 'critical')
).length;
const highPriorityCount = unresolvedIncidents.filter(item =>
(item.priority?.toLowerCase() === 'high')
).length;
let avgResolutionTime = "0h";
if (resolvedIncidents.length > 0) {
const totalHours = resolvedIncidents.reduce((total, item) => {
if (!item.created || !item.resolution_time) return total;
const createdAt = new Date(item.created).getTime();
const resolvedAt = new Date(item.resolution_time).getTime();
const durationHours = (resolvedAt - createdAt) / (1000 * 60 * 60);
return isNaN(durationHours) ? total : total + durationHours;
}, 0);
avgResolutionTime = `${(totalHours / resolvedIncidents.length).toFixed(1)}h`;
}
return {
unresolved: unresolvedIncidents.length,
resolved: resolvedIncidents.length,
critical: criticalCount,
highPriority: highPriorityCount,
avgResolutionTime
};
}, [incidents, initialized]);
const isEmpty = !incidentData.length && initialized && !loading;
return {
filter,
setFilter,
incidentData,
overviewStats,
fetchIncidentData,
isEmpty,
error,
loading,
initialized,
isRefreshing
};
};
@@ -0,0 +1,181 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { maintenanceService, MaintenanceItem } from '@/services/maintenance';
import { useToast } from '@/hooks/use-toast';
import { useLanguage } from '@/contexts/LanguageContext';
interface UseMaintenanceDataProps {
refreshTrigger?: number;
}
export const useMaintenanceData = ({ refreshTrigger = 0 }: UseMaintenanceDataProps) => {
const { t } = useLanguage();
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [allMaintenanceData, setAllMaintenanceData] = useState<MaintenanceItem[]>([]);
const [filter, setFilter] = useState("upcoming");
const [error, setError] = useState<string | null>(null);
const [initialized, setInitialized] = useState(false);
// Refs for cleanup and request management
const mountedRef = useRef(true);
const currentRequestRef = useRef<Promise<void> | null>(null);
// Memoize categorized data to prevent unnecessary recalculations
const categorizedData = useMemo(() => {
if (!allMaintenanceData.length) return { upcoming: [], ongoing: [], completed: [] };
const currentDate = new Date();
const upcoming: MaintenanceItem[] = [];
const ongoing: MaintenanceItem[] = [];
const completed: MaintenanceItem[] = [];
allMaintenanceData.forEach(item => {
const status = item.status?.toLowerCase() || '';
const startTime = new Date(item.start_time);
const endTime = new Date(item.end_time);
if (status === 'completed' || status === 'cancelled') {
completed.push(item);
} else if (status === 'in_progress' ||
(status === 'scheduled' && startTime <= currentDate && endTime >= currentDate)) {
ongoing.push(item);
} else if (status === 'scheduled' && startTime > currentDate) {
upcoming.push(item);
} else {
// Default case: treat as upcoming if we can't determine
upcoming.push(item);
}
});
return { upcoming, ongoing, completed };
}, [allMaintenanceData]);
// Simple fetch function - only called when explicitly requested
const fetchMaintenanceData = useCallback(async (force = false) => {
// Check if component is still mounted
if (!mountedRef.current) return;
// Prevent duplicate requests
if (currentRequestRef.current) {
await currentRequestRef.current;
return;
}
// Only show loading state for initial load or forced refresh
if (!initialized || force) {
setLoading(true);
}
setError(null);
const requestPromise = (async () => {
try {
const data = await maintenanceService.getMaintenanceRecords();
// Check if component is still mounted before updating state
if (!mountedRef.current) return;
// Update state with fetched data
setAllMaintenanceData(data);
setInitialized(true);
// Clear any previous error
if (error) {
setError(null);
}
} catch (err) {
console.error('Error fetching maintenance data:', err);
// Only update error state if component is still mounted
if (!mountedRef.current) return;
const errorMessage = 'Failed to load maintenance data. Please try again.';
setError(errorMessage);
// Show toast for errors
toast({
title: t('error'),
description: t('errorFetchingMaintenanceData'),
variant: 'destructive',
});
} finally {
// Only update loading state if component is still mounted
if (mountedRef.current) {
setLoading(false);
}
currentRequestRef.current = null;
}
})();
currentRequestRef.current = requestPromise;
await requestPromise;
}, [t, toast, error, initialized]);
// Initial fetch on mount - NO AUTOMATIC POLLING
useEffect(() => {
mountedRef.current = true;
// Only fetch initial data, no polling
fetchMaintenanceData(true);
return () => {
mountedRef.current = false;
currentRequestRef.current = null;
};
}, []); // Remove fetchMaintenanceData from dependencies to prevent re-runs
// Handle refresh trigger changes - ONLY when explicitly triggered
useEffect(() => {
if (refreshTrigger > 0) {
fetchMaintenanceData(true); // Force refresh to bypass cache
}
}, [refreshTrigger, fetchMaintenanceData]);
// Get filtered data based on current tab
const maintenanceData = useMemo(() => {
if (!initialized) return [];
return categorizedData[filter as keyof typeof categorizedData] || [];
}, [filter, categorizedData, initialized]);
// Calculate overview stats with memoization
const overviewStats = useMemo(() => {
const { upcoming, ongoing, completed } = categorizedData;
// Calculate total hours more efficiently
const calculateTotalHours = (items: MaintenanceItem[]) => {
return items.reduce((total, item) => {
try {
const start = new Date(item.start_time).getTime();
const end = new Date(item.end_time).getTime();
const durationHours = (end - start) / (1000 * 60 * 60);
return total + (isNaN(durationHours) ? 0 : durationHours);
} catch (e) {
return total;
}
}, 0).toFixed(1);
};
return {
upcoming: upcoming.length,
ongoing: ongoing.length,
completed: completed.length,
totalDuration: calculateTotalHours([...upcoming, ...ongoing]),
};
}, [categorizedData]);
const isEmpty = !loading && maintenanceData.length === 0 && initialized;
return {
loading,
filter,
setFilter,
maintenanceData,
overviewStats,
fetchMaintenanceData,
isEmpty,
error,
initialized,
};
};
@@ -0,0 +1,37 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
interface ErrorStateProps {
error: string | null;
onRefresh: () => void;
isRefreshing: boolean;
}
export const ErrorState: React.FC<ErrorStateProps> = ({
error,
onRefresh,
isRefreshing
}) => {
const { t } = useLanguage();
if (!error) return null;
return (
<div className="text-center py-8">
<p className="text-muted-foreground mb-4">{error}</p>
<Button
variant="outline"
size="sm"
onClick={onRefresh}
className="flex items-center gap-2"
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{t('tryAgain')}
</Button>
</div>
);
};
@@ -0,0 +1,37 @@
import React from 'react';
import { CardDescription, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { RefreshCw } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
interface HeaderActionsProps {
onRefresh: () => void;
isRefreshing: boolean;
}
export const HeaderActions: React.FC<HeaderActionsProps> = ({ onRefresh, isRefreshing }) => {
const { t } = useLanguage();
return (
<div className="flex flex-row items-center justify-between">
<div>
<CardTitle>{t('incidentManagement')}</CardTitle>
<CardDescription>
{t('incidentsManagementDesc')}
</CardDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={onRefresh}
className="ml-auto"
title={t('refreshData')}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<span className="sr-only">{t('refresh')}</span>
</Button>
</div>
);
};
@@ -0,0 +1,171 @@
import React, { useState, useCallback, useRef } from 'react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { useLanguage } from '@/contexts/LanguageContext';
import { useIncidentData } from '../hooks/useIncidentData';
import { IncidentItem } from '@/services/incident';
import { useToast } from '@/hooks/use-toast';
import { OverviewCards } from './OverviewCards';
import { HeaderActions } from './HeaderActions';
import { TabContent } from './TabContent';
import { LoadingState } from '@/components/services/LoadingState';
import { IncidentDetailDialog } from '../incident/detail-dialog/IncidentDetailDialog';
interface IncidentManagementContainerProps {
refreshTrigger?: number;
}
export const IncidentManagementContainer: React.FC<IncidentManagementContainerProps> = ({
refreshTrigger = 0
}) => {
const { t } = useLanguage();
const { toast } = useToast();
const [selectedIncident, setSelectedIncident] = useState<IncidentItem | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [manualRefresh, setManualRefresh] = useState(0);
// Use a ref to debounce multiple refresh requests
const refreshTimerRef = useRef<number | null>(null);
// Combine the external refresh trigger with our internal one
const combinedRefreshTrigger = refreshTrigger + manualRefresh;
const {
filter,
setFilter,
incidentData,
overviewStats,
isEmpty,
loading,
error,
initialized,
isRefreshing
} = useIncidentData({ refreshTrigger: combinedRefreshTrigger });
// Handle incident updates with debouncing
const handleIncidentUpdated = useCallback(() => {
console.log('Incident updated, triggering refresh');
// Clear any existing timer
if (refreshTimerRef.current !== null) {
window.clearTimeout(refreshTimerRef.current);
}
// Set a new timer to debounce multiple quick updates
refreshTimerRef.current = window.setTimeout(() => {
setManualRefresh(prev => prev + 1);
refreshTimerRef.current = null;
toast({
title: t('incidentUpdated'),
description: t('incidentUpdateSuccess'),
});
}, 300);
}, [t, toast]);
// Handle tab changes
const handleTabChange = useCallback((value: string) => {
console.log(`Tab changed to: ${value}`);
setFilter(value);
}, [setFilter]);
// Handle view incident details
const handleViewIncidentDetails = useCallback((incident: IncidentItem) => {
setSelectedIncident(incident);
setDetailDialogOpen(true);
}, []);
// Handle manual refresh
const handleManualRefresh = useCallback(() => {
console.log('Manual refresh triggered by user');
setManualRefresh(prev => prev + 1);
}, []);
// Handle detail dialog close with refresh
const handleDetailDialogClose = useCallback((open: boolean) => {
setDetailDialogOpen(open);
if (!open) {
// When dialog closes, refresh data
handleManualRefresh();
}
}, [handleManualRefresh]);
// Clean up timer on unmount
React.useEffect(() => {
return () => {
if (refreshTimerRef.current !== null) {
window.clearTimeout(refreshTimerRef.current);
}
};
}, []);
// Show full-page loading state during initial load
if (loading && !initialized) {
return <LoadingState />;
}
return (
<>
{/* Overview Cards */}
<OverviewCards
overviewStats={overviewStats}
loading={loading}
initialized={initialized}
/>
<Card>
<CardHeader>
<HeaderActions
onRefresh={handleManualRefresh}
isRefreshing={isRefreshing}
/>
</CardHeader>
<CardContent>
<Tabs value={filter} className="w-full" onValueChange={handleTabChange}>
<TabsList className="mb-6">
<TabsTrigger value="unresolved">{t('unresolvedIncidents')}</TabsTrigger>
<TabsTrigger value="resolved">{t('resolvedIncidents')}</TabsTrigger>
</TabsList>
<TabsContent value="unresolved" className="space-y-4">
<TabContent
error={error}
isEmpty={isEmpty}
data={incidentData}
loading={loading}
initialized={initialized}
isRefreshing={isRefreshing}
onIncidentUpdated={handleIncidentUpdated}
onViewDetails={handleViewIncidentDetails}
onRefresh={handleManualRefresh}
/>
</TabsContent>
<TabsContent value="resolved" className="space-y-4">
<TabContent
error={error}
isEmpty={isEmpty}
data={incidentData}
loading={loading}
initialized={initialized}
isRefreshing={isRefreshing}
onIncidentUpdated={handleIncidentUpdated}
onViewDetails={handleViewIncidentDetails}
onRefresh={handleManualRefresh}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Incident Detail Dialog */}
<IncidentDetailDialog
open={detailDialogOpen}
onOpenChange={handleDetailDialogClose}
incident={selectedIncident}
/>
</>
);
};
@@ -0,0 +1,89 @@
import React from 'react';
import { AlertCircle, CheckCircle, Clock, AlertTriangle, Flag } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
import { OverviewCard } from '../common/OverviewCard';
import { useTheme } from '@/contexts/ThemeContext';
interface OverviewStatsProps {
unresolved: number;
resolved: number;
critical: number;
highPriority: number;
avgResolutionTime: string;
}
interface OverviewCardsProps {
overviewStats: OverviewStatsProps;
loading: boolean;
initialized: boolean;
}
export const OverviewCards: React.FC<OverviewCardsProps> = ({
overviewStats,
loading,
initialized
}) => {
const { t } = useLanguage();
const { theme } = useTheme();
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
<OverviewCard
title={t('activeIncidents')}
value={overviewStats.unresolved.toString()}
icon={<AlertCircle className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(239, 83, 80, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(239, 83, 80, 0.6) 100%)"
}
/>
<OverviewCard
title={t('criticalIssues')}
value={overviewStats.critical.toString()}
icon={<AlertTriangle className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(255, 183, 77, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(255, 183, 77, 0.6) 100%)"
}
/>
<OverviewCard
title={t('highPriority')}
value={overviewStats.highPriority.toString()}
icon={<Flag className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(255, 109, 0, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(255, 109, 0, 0.6) 100%)"
}
/>
<OverviewCard
title={t('resolvedIncidents')}
value={overviewStats.resolved.toString()}
icon={<CheckCircle className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(102, 187, 106, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(102, 187, 106, 0.6) 100%)"
}
/>
<OverviewCard
title={t('avgResolutionTime')}
value={overviewStats.avgResolutionTime}
icon={<Clock className="h-5 w-5 text-white" />}
isLoading={loading && initialized}
gradient={
theme === "dark"
? "linear-gradient(135deg, #4b3b37 0%, rgba(66, 165, 245, 0.6) 100%)"
: "linear-gradient(135deg, #4b3b37 0%, rgba(66, 165, 245, 0.6) 100%)"
}
/>
</div>
);
};
@@ -0,0 +1,47 @@
import React from 'react';
import { IncidentTable } from '../incident/table/IncidentTable';
import { EmptyIncidentState } from '../incident/EmptyIncidentState';
import { ErrorState } from './ErrorState';
import { IncidentItem } from '@/services/incident';
interface TabContentProps {
error: string | null;
isEmpty: boolean;
data: IncidentItem[];
loading: boolean;
initialized: boolean;
isRefreshing: boolean;
onIncidentUpdated: () => void;
onViewDetails: (incident: IncidentItem) => void;
onRefresh: () => void;
}
export const TabContent: React.FC<TabContentProps> = ({
error,
isEmpty,
data,
loading,
initialized,
isRefreshing,
onIncidentUpdated,
onViewDetails,
onRefresh
}) => {
if (error) {
return <ErrorState error={error} onRefresh={onRefresh} isRefreshing={isRefreshing} />;
}
if (isEmpty) {
return <EmptyIncidentState />;
}
return (
<IncidentTable
data={data}
onIncidentUpdated={onIncidentUpdated}
onViewDetails={onViewDetails}
isLoading={loading && initialized}
/>
);
};
@@ -0,0 +1,6 @@
export * from './IncidentManagementContainer';
export * from './OverviewCards';
export * from './TabContent';
export * from './ErrorState';
export * from './HeaderActions';
@@ -0,0 +1,96 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { useIncidentForm } from './hooks/useIncidentForm';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
IncidentBasicFields,
IncidentAffectedFields,
IncidentConfigFields,
IncidentDetailsFields,
} from './form';
interface CreateIncidentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onIncidentCreated: () => void;
}
export const CreateIncidentDialog: React.FC<CreateIncidentDialogProps> = ({
open,
onOpenChange,
onIncidentCreated,
}) => {
const { t } = useLanguage();
const { form, onSubmit } = useIncidentForm(
onIncidentCreated,
() => onOpenChange(false)
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
<ScrollArea className="h-[80vh]">
<div className="px-1 py-2">
<DialogHeader className="mb-4">
<DialogTitle className="text-xl">{t('createIncident')}</DialogTitle>
<DialogDescription>
{t('createIncidentDesc')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-8 pb-4">
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
<IncidentBasicFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
<IncidentAffectedFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
<IncidentConfigFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
<IncidentDetailsFields />
</div>
<DialogFooter className="pt-4 mt-4 border-t">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('cancel')}
</Button>
<Button type="submit">
{form.formState.isSubmitting ? t('creating') : t('create')}
</Button>
</DialogFooter>
</div>
</form>
</Form>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,105 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useIncidentEditForm } from './hooks/useIncidentEditForm';
import {
IncidentBasicFields,
IncidentAffectedFields,
IncidentConfigFields,
IncidentDetailsFields,
} from './form';
import { IncidentItem } from '@/services/incident/types';
interface EditIncidentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
incident: IncidentItem;
onIncidentUpdated: () => void;
}
export const EditIncidentDialog: React.FC<EditIncidentDialogProps> = ({
open,
onOpenChange,
incident,
onIncidentUpdated,
}) => {
const { t } = useLanguage();
const handleClose = () => {
onOpenChange(false);
};
const { form, onSubmit } = useIncidentEditForm(
incident,
onIncidentUpdated,
handleClose
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh]">
<ScrollArea className="h-[80vh]">
<div className="px-1 py-2">
<DialogHeader className="mb-4">
<DialogTitle className="text-xl">{t('editIncident')}</DialogTitle>
<DialogDescription>
{t('editIncidentDesc')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-8 pb-4">
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('basicInfo')}</h3>
<IncidentBasicFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('affectedSystems')}</h3>
<IncidentAffectedFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('configuration')}</h3>
<IncidentConfigFields />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium border-b pb-2">{t('resolutionDetails')}</h3>
<IncidentDetailsFields />
</div>
<DialogFooter className="pt-4 mt-4 border-t">
<Button
type="button"
variant="outline"
onClick={handleClose}
>
{t('cancel')}
</Button>
<Button type="submit">
{form.formState.isSubmitting ? t('updating') : t('update')}
</Button>
</DialogFooter>
</div>
</form>
</Form>
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,18 @@
import React from 'react';
import { useLanguage } from '@/contexts/LanguageContext';
import { AlertCircle } from 'lucide-react';
export const EmptyIncidentState = () => {
const { t } = useLanguage();
return (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<AlertCircle className="w-12 h-12 mb-4" />
<h3 className="text-lg font-medium mb-2">{t('noIncidents')}</h3>
<p className="text-sm text-center max-w-md">
{t('noServices')}
</p>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More