160 Commits

Author SHA1 Message Date
Phearun UM bda862e4d3 menu collaged full view and font size adjustment (#162)
* menu sticky and add font adjustment

* menu sticky and add font adjustment

* ui menu sticky and fontsize adjustment

---------

Co-authored-by: phearun <phearun@whitesand.online>
2025-09-15 13:43:22 +07:00
Tola Leng 6887dc291b Merge pull request #172 from EchtkPvL/develop
Fix YAML syntax error
2025-09-14 21:01:03 +07:00
Jonas Berner 29daddfc3b Fix YAML syntax error 2025-09-14 12:32:54 +02:00
Tola Leng 9c44ba6d7f Fix: Improve Uptime History Bar Display Speed Immediately
- Optimize the UptimeBar component to display uptime data immediately and reduce loading delays and background updates
2025-09-14 02:52:55 +07:00
Tola Leng c61a740725 feat: Add schema database update for release v1.6.0
- Applied new database schema updates.
- Ensured compatibility with data retention service and notification features.
- Keeps database in sync with new features introduced in v1.6.0.
2025-09-13 22:24:37 +07:00
Tola Leng a9d3d86394 chore(project): Update development status and progress 2025-09-13 22:22:08 +07:00
Tola Leng 9b0b2663ec refactor: Remove manual cleanup buttons from Data Retention Settings
- The three manual cleanup buttons have been removed from the dashboard.
- The data retention service now automatically runs once per day, cleaning up older data based on the configured retention settings.
2025-09-13 02:28:52 +07:00
Tola Leng 0b04034141 Merge pull request #159 from sqkkyzx/develop
feat(i18n): Refactored i18n function to support multiple parameter combinations
2025-09-12 15:57:24 +07:00
Tola Leng 0a635ab3aa feat: Integrate data retention service
- Implemented the data retention service in Go (service-operation) that manages cleanup of old records based on configured retention periods. The service runs once per day to ensure outdated data is removed automatically.
2025-09-11 22:43:07 +07:00
YiZixuan 175d2b6329 docs(README_zhcn): Update documentation content and sponsorship policy
- Update image links in the documentation
- Revise the sponsorship policy to no longer accept individual sponsorships, only enterprise-level collaborations
- Update the display method for supporters and partners
- Add Cloudflare and DigitalOcean as new partners
2025-09-10 00:19:14 +08:00
YiZixuan 3d6c4151b8 Merge branch 'operacle:develop' into develop 2025-09-09 22:40:37 +08:00
YiZixuan 5f893e3b20 refactor(i18n): Optimize service pagination internationalization copy
- Added service pagination related copy in English and Chinese translation files
- Updated type definitions to include new translation fields
- Modified the ServicesPagination component to use the newly added translation fields
2025-09-09 22:39:17 +08:00
YiZixuan 1296cf6a32 feat(i18n): Enhanced internationalization support with variable interpolation
- Refactored the `t` function in LanguageContext to support multiple parameter combinations
- Added variable interpolation feature, allowing variables in translated texts
- Optimized translation logic to improve accuracy and performance
- Enhanced type safety by using TypeScript generics to ensure correct typing
2025-09-09 22:38:43 +08:00
Tola Leng 31e5335aac docs(readme): deactivate donations, accept only infra support (cloud, domain, hosting) 2025-09-09 20:50:33 +07:00
YiZixuan b6d4ca5269 refactor(i18n): Optimize internationalization configuration and add new translation items
- Add internationalization support to the LoadingState component
- Implement internationalization translations in the Profile page
- Use internationalized text in the TestEmailDialog component
- Update English and Chinese translation files by adding new translation entries
2025-09-09 21:48:35 +08:00
Tola Leng 7e7522608a chore: deactivate GitHub donation 2025-09-09 20:39:09 +07:00
YiZixuan 43957f65d2 refactor(i18n): Added Chinese translations for Create Event and Maintenance
- Supplemented missing Chinese translations
- Added translation entries related to Create Event and Maintenance in both English and Chinese translation files
2025-09-09 21:21:34 +08:00
Tola Leng 09ece0a77e feat(auth): add token support for ntfy
- Updated the NTFY service to support API token authentication by adding Authorization Bearer header when api_token is provided.

Closes: #89
2025-09-09 20:21:20 +07:00
Tola Leng 06ba38aa15 feat: Add NTFY API token field for Token-based authentication to ntfy server
- Add the api_token field for NTFY notifications in both the dialog and service to save it to PB. NTFY schema to include api_token field.
2025-09-09 17:48:25 +07:00
Tola Leng abd878fe6c Merge pull request #158 from sqkkyzx/develop
refactory(i18n): Improve internationalization configuration and add new translations
2025-09-09 02:31:34 +07:00
YiZixuan f4d47bbd45 refactory(i18n): Add internationalization support for the service statistics card 2025-09-08 21:35:48 +08:00
YiZixuan b675fddc92 refactory(i18n): Added architecture import function translation for the "About" page
- Added translation items related to schema import in English, Chinese, and type definition files
- Replaced hard-coded text with new translation items in the AboutSystem component
2025-09-08 20:54:56 +08:00
YiZixuan 0a70ed2143 refactory(i18n): Added architecture import function translation for the "About" page
- Added translation items related to schema import in English, Chinese, and type definition files
- Replaced hard-coded text with new translation items in the AboutSystem component
2025-09-08 19:35:41 +08:00
YiZixuan 4e07279a54 refactory(i18n): Improve internationalization configuration and add new translations
- Added multiple new translation items
- Updated the two language files: en and zhcn
- Added type definitions for new translation items in types/services.ts
- Modified some components to use the new translation items
2025-09-08 14:18:05 +08:00
Tola Leng 020cdec912 chore(project): update development status and progress
- feat: Add server_url field for Gotify
- feat: Implement Gotify notification service
2025-09-06 20:44:28 +07:00
Tola Leng 4771b2ead0 feat: Implement Gotify notification service
- The Gotify notification functionality has been implemented. The alert configuration channel will now use the `api_token` and `server_url` field for send to the Gotify API endpoint.
2025-09-06 20:42:22 +07:00
Tola Leng 87c829e100 feat: Add Gotify to notification channels
- Add Gotify  as a new channel type in the notification settings. The Gotify channel will use the `api_token` and `server_url` field for API key storage in PB.
2025-09-06 19:33:14 +07:00
Tola Leng e6093f9fe2 Fix: Remove duplicate close button
- Removed the duplicate close button from the test email dialog.
2025-09-04 22:05:18 +07:00
Tola Leng 956786a859 feat: add SSL history and Notifiarr notification channel schema 2025-09-03 22:31:20 +07:00
Tola Leng 6a0169e4d8 chore(project): update development status and progress
- feat: Add channel_id field for Notifiarr
- feat: Implement Notifiarr notification service

Closes: #133
2025-09-03 22:05:11 +07:00
Tola Leng 92959945f3 feat: Implement Notifiarr notification service
- The Notifiarr notification functionality has been implemented. The alert configuration channel will now use the `api_token` and `channel_id` field for send to the Notifiarr API endpoint.
2025-09-03 21:58:40 +07:00
Tola Leng b2269187d8 feat: Add channel_id field for Notifiarr
- Add channel_id field for Notifiarr to store record of the discord channel_id into the PB.
2025-09-03 21:48:59 +07:00
Tola Leng 05f46969c4 feat: Add Notifiarr to notification channels (UI)
- Add Notifiarr as a new channel type in the notification settings. The Notifiarr channel will use the `api_token` field for API key storage in PB.
2025-09-02 21:37:54 +07:00
Tola Leng ab168b440e chore(project): update development status and progress
- fix(ssl): ensure edit form saves notification_id and template_id in DB
- feat(notifications): add Notifiarr notification channel (planned)
2025-08-29 19:58:24 +07:00
Tola Leng 2e866d7a95 chore(partners): add Cloudflare to Ecosystem & Community Partner section
- Cloudflare has been added to the Ecosystem & Community Partner list to acknowledge their support and contribution.
2025-08-29 19:53:39 +07:00
Tola Leng b372131978 fix(ssl): Ensure edit form saves notification_id and template_id in DB.
- The SSL edit form was not saving the `notification_id` and `template_id` fields to the PB database when re-assigning the Notification Channel and Alert Template.
2025-08-29 19:50:36 +07:00
Tola Leng 5f7c451fc0 feat: add Pushover notification channel schema 2025-08-27 17:24:02 +07:00
Tola Leng 3d7172d9aa Update Project Development Status and Progress
- feat: Implement Pushover notification
- fix(services-form): max retry attempts
2025-08-24 16:58:52 +07:00
Tola Leng 22d4597f88 Merge pull request #147 from ghotso/develop-fix_max_retries_issue
fix(services-form): make retry attempts select controlled and synced with form state
2025-08-24 13:49:31 +07:00
Tola Leng d4423e328b feat: Implement Pushover notification
- Implement Pushover notification functionality to send messages similar to Telegram. This includes adding `api_token` and `user_key` fields to the alert configuration for Pushover. Closes #135 #95
2025-08-23 20:47:46 +07:00
Tola Leng b3b64806f6 feat: Add Pushover notification channel
- Adds Pushover as a new notification channel option. Includes fields for `api_token` and `user_key` in the `alert_configurations` schema and integrates it into the channel type dropdown and form handling.
2025-08-23 20:10:03 +07:00
ghotso 001f6f8783 fix: update ServiceConfigFields to set default value for retry attempts and enhance delete migration scripts for server processes and notifications with error handling for deleting non existing collections 2025-08-22 12:10:01 +02:00
Tola Leng d01f682a83 Update Project Development Status and Progress 2025-08-22 16:44:27 +07:00
Tola Leng bce523bdf1 Merge pull request #142 from ghotso/develop-fix-incident_assigned_user
fix(incident): unify assigned user handling with fallback and UI update 🎯
2025-08-22 02:47:15 +07:00
Tola Leng 623e3d5ebf Add schema JSON file locally to allow direct updates from the UI. This schema is for CheckCle version v1.5.0. 2025-08-22 02:39:01 +07:00
Tola Leng e3a7865e40 feat: Allow user to update the schema directly from the dashboard
Includes:
- Dashboard "Update Schema" action
- Multiple notification/alert fixes and enhancements
- This allow to update the schema for the latest update of environment, this update include with multiple fixes

Closes The notification function is not available #138 Still can't enable and create notifications, And A new problems #136 Can not turn on notification (Telegram , Mail....) #120 Notification type 'slack' not supported #26 REQUEST: webhook notification #43 Notification can't be enabled (Discord) #77
2025-08-22 02:37:11 +07:00
ghotso 3c93354c7d Merge branch 'operacle:develop' into develop-fix-incident_assigned_user 2025-08-21 17:15:02 +02:00
ghotso 323ac36b65 chore: add server/pb_data/ to .gitignore 2025-08-21 16:53:56 +02:00
ghotso c437704007 chore: remove pb_data runtime files and ignore directory 2025-08-21 16:49:04 +02:00
ghotso 1953d54dc5 fix(types): refine user type definitions for assigned users in incident handling 2025-08-21 16:48:57 +02:00
ghotso b574073294 fix(incidents): update detail dialog and PDF generation to prefer assigned_users over assigned_to for assignee 2025-08-21 16:48:57 +02:00
ghotso b164602ee5 fix(pdf): render assignee using assigned_users with fallback to assigned_to 2025-08-21 16:48:57 +02:00
ghotso 4bd08efc9a fix(incidents): display assignee in BasicInfoSection via assigned_users fallback 2025-08-21 16:48:57 +02:00
ghotso 58fa09bea7 fix(incidents): show assignee in AssignmentSection via assigned_users with fallback to assigned_to 2025-08-21 16:48:57 +02:00
ghotso 7ff180bea4 fix(incidents): fetch and display assignee in detail dialog via assigned_users with fallback 2025-08-21 16:48:57 +02:00
ghotso 7b18806066 fix(incidents): show assignee in table using assigned_users with fallback to assigned_to 2025-08-21 16:48:56 +02:00
ghotso d4434a64e1 fix(types): add assigned_users to incident types for server persistence 2025-08-21 16:48:56 +02:00
ghotso 08580794da fix(incidents): ensure assignee is prefilled and persisted on edit (assigned_to → assigned_users) 2025-08-21 16:48:56 +02:00
ghotso 70fdcc9ea6 fix(incidents): include assigned_users in form submit so assignee is persisted 2025-08-21 16:48:56 +02:00
ghotso aba3120235 fix(incidents): persist assignee correctly via assigned_users
- map form field `assigned_to` to backend field `assigned_users`
- ensure assignee is stored when creating or updating incidents
- keep `assigned_to` for backward compatibility
2025-08-21 16:48:56 +02:00
Tola Leng b038d87848 Database Schema Update
- Database schema up to date with the latest features and improvements
2025-08-21 14:53:52 +07:00
Tola Leng 87588a9127 Added more notification channel (GoogleChat, ntfy, webhook) into the collections configuration 2025-08-21 02:19:44 +07:00
Tola Leng 4090fa5aef Merge pull request #143 from taking/develop
feat(i18n): Add Korean translations and update types
2025-08-21 01:46:43 +07:00
taking c04561dd99 Merge branch 'operacle:develop' into develop 2025-08-20 22:56:41 +09:00
Ubuntu 7f1dfcdd42 feat(i18n): Add translation fields for de, en, ja, km, zhcn
- Update translation index to support Korean
  `application/src/translation/index.ts`
- Update translation indexes
  `application/src/translation/{de,en,ja,km,zhcn}/index.ts`
- Update dropdown menu item in dashboard header
  `application/src/components/dashboard/Header.tsx`
2025-08-20 22:54:31 +09:00
Tola Leng 6f753f2901 Merge pull request #141 from ghotso/develop
feat(i18n): 🌐 sync German translations across incident, maintenance, services & settings
2025-08-20 20:25:44 +07:00
taking c39296cee6 feat(i18n): Add Korean translations and update types
- Added Korean (ko) translations for all modules (incident, login, maintenance, menu, service, settings, ssl)
- Added previously empty strings in `application/src/translations/types`
- Followed existing style and naming conventions
2025-08-19 14:54:51 +09:00
ghotso a1a8a8443e feat(i18n): 🌐 add missing German translations for settings module
Added missing German translation keys to align with the English settings module:
- addUser
- permissionNotice
- permissionNoticeAddUser
- loadingSettings
- loadingSettingsError

Ensured all existing German translations remain unchanged. 🚀
2025-08-18 21:26:11 +02:00
ghotso f866d36db4 feat(i18n): 🌐 add missing German translations for services module
Added missing German translation keys to align with the English services module.
Also imported section comments from the English file for better structure.

Now the German translation file has full feature parity with English 🚀
2025-08-18 21:20:47 +02:00
ghotso 98ec911281 feat(i18n): 🌐 add missing German translations for incident module
Added the missing German translation keys to align with the English incident module:
- enterIncidentTitle
- enterIncidentDescription
- enterServiceId
- selectAssignedUser
- noAssignedUser
- affectedSystems
- enterAffectedSystems
- separateSystemsWithComma

This ensures feature parity between the English and German translation files.
2025-08-18 21:15:04 +02:00
ghotso 046db61326 feat(i18n): 🌐 add missing German translations for maintenance module
Added the missing German translation keys to align with the English maintenance module.

Now the German translation file is complete and feature-parity with English 🌟
2025-08-18 21:14:22 +02:00
Tola Leng 3c00d0933e Merge pull request #140 from ghotso/develop
Added missing English translations to incidents.ts and maintenance.ts
2025-08-19 01:08:54 +07:00
ghotso 8e013f0b0b Added missing EN translations to maintenance.ts
Added missing translations
2025-08-18 19:04:20 +02:00
ghotso f686134525 Added missing EN translations in incident.ts
Added missing translations
2025-08-18 18:56:17 +02:00
ghotso 98d33a609e Update translations in maintenance.ts
Added missing trranslations
2025-08-18 18:49:21 +02:00
Tola Leng d33244956e Merge pull request #137 from sqkkyzx/develop
improve i18n and add new translations
2025-08-18 21:06:03 +07:00
YiZixuan 572c572c14 refactor(i18n): improvements in Chinese translation 2025-08-17 23:22:08 +08:00
YiZixuan f9743407a4 refactor(i18n): Add internationalization support for the dashboard service status card
- Add new translation items to multiple language files, including Chinese and English
- Implement internationalization support in the StatusCards component
2025-08-17 23:21:39 +08:00
YiZixuan fa3ad9b2e1 refactor(add-service): Refactor ServiceForm.tsx to support internationalization
- Extract ServiceForm.tsx validation logic into the custom hook useServiceSchema
- Add internationalization support and move error messages to translation files
2025-08-17 23:13:25 +08:00
YiZixuan 6bf75b2bf7 refactor(i18n): Extracted multiple fields in the Service component for translation.
- Extracted multiple fields in the Service component for translation.
- Optimized the structure of the translation file, categorizing it by functional modules.
2025-08-17 15:19:01 +08:00
YiZixuan b068c0cd51 refactor(i18n): improve translation handling and add English fallback
- Add English as the default fallback language; automatically use English when a translation for the current language is missing.
2025-08-16 16:21:28 +08:00
YiZixuan 92617038f0 refactor(i18n): Improve multilingual support 2025-08-16 15:59:00 +08:00
YiZixuan 40488a5a53 feat(i18n): improve i18n copy and add new translations
- Add new translation entries
- Fix several language files
2025-08-16 15:25:35 +08:00
Tola Leng 35fe0b9071 feat: Implement NTFY notification functionality
- Implement NTFY push notification functionality to send messages to ntfy toptics.
2025-08-15 17:17:29 +07:00
Tola Leng 3bc1fd7c02 feat: Add NTFY notification channel
- Adds NTFY as a new option to the notification channel type dropdown
- This includes updating the form schema and related components to support the ntfy_endpoint field for NTFY configurations.
2025-08-15 16:47:26 +07:00
Tola Leng ee4ce50193 📜 Correct the DigitalOcean logo URL 2025-08-15 14:07:57 +07:00
Tola Leng 67a0d1d7f4 📜 Adds some default styling to table cells. 2025-08-15 14:00:01 +07:00
Tola Leng f006df3b02 📜 Use a simple HTML table for image layout fo sponsorship logo 2025-08-15 13:56:16 +07:00
Tola Leng 5234303565 📌 Update on Project Development Status and Progress 2025-08-15 13:48:51 +07:00
Tola Leng 4ff0457966 📜 Wrap display logo in a flex container 2025-08-15 13:45:31 +07:00
Tola Leng 7a00d8828e 💼 Update on Project Development Status and Progress 2025-08-14 22:56:52 +07:00
Tola Leng 6193ad8b4f 📧 Added Powerful Notification System Image and Sponsorship 2025-08-14 22:54:02 +07:00
Tola Leng 41c387a301 Removed the insert Webhook Payload Template in the Notification Channel Dialog. 2025-08-14 21:00:15 +07:00
Tola Leng 933e70a3f6 feat: Implement comprehensive multi-channel notification system with templating and SSL monitoring
- Add robust notification system supporting 7+ communication channels with intelligent message templating, resource monitoring, and SSL certificate alerts. Notification Channels: (Telegram, Discord, Slack, Signal, Email, Google Chat, Webhooks).

- This notification system provides enterprise-grade alerting capabilities with extensive customization options and multi-channel redundancy for critical service monitoring.
2025-08-14 20:50:31 +07:00
Tola Leng 58242c33f8 Refactor Webhook to use alert_configurations and Add Signal API endpoint field
- This field will be used to specify the API endpoint for Signal notifications.
- This change aligns the frontend with the backend's data structure for webhooks.
2025-08-14 04:54:15 +07:00
Tola Leng 47a38d3c64 Refactor Webhook fields
- Condense webhook fields in the notification channel form to display only essential information, mirroring the approach in webhook service.
2025-08-14 03:39:47 +07:00
Tola Leng 07c101617c feat: Add SMTP password field for email
- Add an input field for SMTP password in the "Add Notification Channel" dialog. This field will be used for SMTP authentication.
2025-08-13 23:08:10 +07:00
Tola Leng 37bb7e25b6 Fix: Display existing SSL notification and template data
- This change ensures that users can see and manage their existing SSL configurations.
2025-08-13 21:42:48 +07:00
Tola Leng a4e819d220 Fix: Preserve notification IDs when disabling notifications
- Allowing notifications to be re-enabled correctly without losing previously configured
2025-08-13 17:20:24 +07:00
Tola Leng 7b01cffb64 Merge pull request #128 from sqkkyzx/develop
feat(translations): Add Simplified Chinese translation
2025-08-13 14:42:18 +07:00
YiZixuan 6479ea50a5 docs: Add Chinese documentation and update the language selection list
- Add a README_zhcn.md file in the docs directory, which contains the Chinese documentation content.
- Add a link to the Chinese language option in README.md and READMEs in other languages
2025-08-13 00:48:37 +08:00
YiZixuan 9596f53742 feat(translations): Add German and Japanese translation fields
- Added the "german" and "japanese" fields to the common.ts file
2025-08-13 00:22:44 +08:00
YiZixuan f15a00772e feat(translations): Add Simplified Chinese translation
- Add simplified Chinese translation files
- Add the Simplified Chinese option in the language selection
- Update the translation index to support Simplified Chinese
2025-08-13 00:05:16 +08:00
Tola Leng d4636d5436 feat: Add error_message placeholder for uptime notifications
- Add support for the `${error_message}` placeholder in uptime service notifications
2025-08-12 22:26:51 +07:00
Tola Leng 37bc86e9d8 feat: Enhance uptime notification templates
- Improve Telegram notification messages for uptime services by adding support for new placeholders.
2025-08-12 22:23:31 +07:00
Tola Leng 34d3e81154 Removed entrypoint script with hidden pocketbase 2025-08-12 15:12:35 +07:00
Tola Leng e70b23cbe5 Add placeholders to Service Uptime templates
- Adds support for new placeholders in the Service Uptime Monitoring alert templates. The following placeholders are now available:
-- ${ip_host}: The IP address or hostname of the service.
-- ${service_type}: The type of the service (e.g., HTTP, PING, TCP, DNS).
-- ${port}: The port the service is running on.
-- ${domain}: The domain name of the service.
-- ${region_agent}: The regional agent monitoring the service.
2025-08-12 14:49:13 +07:00
Tola Leng 36dfd8a52c Add restore messages to server notification templates
- Add new fields for restore messages to the Server Template Dialog to match the DB schema.
2025-08-11 23:09:43 +07:00
Tola Leng 86503b7160 Fix: Prevent clearing notification fields
- This fix ensures that when the notification status is toggled off, notification_channels, threshold_id, and template_id are no longer cleared or emptied.
2025-08-11 17:43:55 +07:00
Tola Leng aecf16ba40 Fix: Correctly update notification status
- This fix ensures that when the notification status is enabled. Resolves the issue where the status was incorrectly shown as false in PB.
2025-08-11 17:33:08 +07:00
Tola Leng 2ca7c35c69 Update Translate SSL Certificate Details
- Translate untranslated text in the SSL Certificate Details dialog. The following keys have been translated:
- certificateDetails
- subjectAlternativeNames
- monitoringConfiguration
- technicalInformation
2025-08-10 16:38:15 +07:00
Tola Leng 81604cb736 Merge pull request #119 from gnsworks/develop
Update Japanese translation
2025-08-10 16:21:29 +07:00
Tola Leng 9f9d773256 Fix: Wrap Subject Alternative Names
- Wrap the Subject Alternative Names (SANs) in the SSL detail dialog to ensure they break onto new lines when there are multiple entries, improving readability.
2025-08-10 16:07:04 +07:00
Tola Leng 92e5f3f074 feat: Add Server Threshold Tab
- Add a new tab for "Server Threshold" to the Alert Templates dashboard, to allow users to manage server threshold configurations.
2025-08-09 20:06:40 +07:00
gnsworks 2f12f8f805 Add docs/DEVELOPMENT_ROADMAP_ja.md 2025-08-09 13:16:22 +09:00
gnsworks c6e7e243e6 Update : japanese documents translation 2025-08-09 11:57:14 +09:00
gnsworks 074fc18ee1 Merge branch 'operacle:develop' into develop 2025-08-09 08:54:50 +09:00
Tola Leng 34dccb763d 📝 Added sponsoring the OSS documentation site 2025-08-08 21:24:39 +07:00
Tola Leng cc6abf58ac Remove Quick Actions button and dialog
- Remove the QuickActionsDialog component and its associated file from the dashboard.
2025-08-08 15:39:57 +07:00
Tola Leng 269240c6c9 Add icons to notification channels
- Display icons for each notification channel type in the dialog and dropdown.
2025-08-05 22:27:37 +07:00
Tola Leng 7856aff98e Updated Server Installation Tips 2025-08-05 22:01:20 +07:00
Tola Leng 99d67deb10 Add Google Chat webhook to notification channels
- Added Google Chat  as a new channel type option in the Notification Channel
- Add Google Chat tab to notifications
- Fix: Ensure Google Chat and Email save record to PB
2025-08-05 21:35:56 +07:00
Tola Leng 96bf456338 feat: Add & Enhance Notification Channel and Alert Template to Add/Edit Domain Form
- Add multi-select for notification channels and an alert template field to the Add/Edit SSL Certificate dialog
2025-08-04 21:59:04 +07:00
Tola Leng f4cc96733c Refactor threshold update logic
- Remove the separate "Update Thresholds" button and integrate its functionality into the main "Update Server" button.
2025-08-04 21:26:19 +07:00
Tola Leng da4678570f Fixed: Remove [Default] alert template option
- The Alert Template field in the Service dialog will now correctly fetch data from service_notification_templates
2025-08-04 21:14:52 +07:00
gnsworks 881d745776 Merge branch 'operacle:develop' into develop 2025-08-03 16:03:21 +09:00
Tola Leng b4dc510611 Add webhook payload template
- Add webhook notification channel configuration. Allow users to define custom payload structures for webhook notifications.
2025-08-02 20:37:51 +07:00
Tola Leng 914f1aba60 feat: Support multiple notification channels
- Update the Notification Settings dashboard to allow users to select multiple notification channels for alerts.
- Add webhook to notification channels: Add webhook as a selectable option in the notification channel type dropdown.
- Implement template type selection in the Add Template dialog. Based on the selected type.
2025-08-02 16:53:15 +07:00
Tola Leng f7cf2fcc20 Fix: Set SSL initialize check with the current time. Closes: #100
- When adding a new SSL certificate, initialize with the current time. This ensures that the SSL check is triggered shortly after the certificate is added to the monitoring system.
2025-07-31 17:28:41 +07:00
Tola Leng 50b4d1dfb0 Refactor: Reduce verbose logging output in Regional Script
- Reduce verbose logging in install-regional-agent.sh to provide a more minimal output while retaining all core functionality.
2025-07-31 16:19:16 +07:00
Tola Leng 8bb78e68ea Disable verbose logging
- Disable log console output in file to prevent logs from being printed to the console.
2025-07-31 14:57:16 +07:00
Tola Leng 205bedfba5 Updated project development status 2025-07-31 02:58:02 +07:00
Tola Leng 1fac88dcc9 feat: add docker compose for server docker agent 2025-07-31 02:52:57 +07:00
Tola Leng 44bc2bc1e5 feat: Create server-docker-agent.sh script
- This script will automate the process of running the monitoring agent Docker container. It will take environment variables as input and construct the `docker run` command with the provided values, including volumes and ports, to ensure a seamless one-click installation experience.
2025-07-31 02:46:27 +07:00
Tola Leng bc06a24adb feat: Add Docker One-Click Install Tab
- Adds a new "Docker One-Click" tab to the server dashboard and create new server dialog. This tab provides a one-click installation script for the Docker monitoring agent, dynamically populating it with server-specific details.
2025-07-31 02:44:51 +07:00
Tola Leng 952eb3d8d7 Update script URL to deploy from CDN 2025-07-29 21:19:38 +07:00
Tola Leng 1d8b5172c5 Added the OS logo icon for Arch Linux 2025-07-29 20:54:54 +07:00
Tola Leng ff4938533c Fix: Add tar.gz fallback for unsupported systems
- The script has been updated to include a fallback mechanism for systems that do not have `dpkg` or `rpm` package managers. This ensures that the agent can be installed on a wider range of Linux distributions, such as Arch Linux, by automatically using the `tar.gz` package when native package managers are not detected.
2025-07-29 20:54:20 +07:00
gnsworks 384920e952 Merge branch 'operacle:develop' into develop 2025-07-29 09:03:10 +09:00
Tola Leng cad092bc6b 📖 Add sections for Project Status and Sponsorship to the README. 2025-07-28 02:12:49 +07:00
Tola Leng be57688a5e 📖 Update X (twitter) Account 2025-07-28 01:27:29 +07:00
Tola Leng e2f41fdef1 feat: Add pagination to SSL table
- Implement pagination for the SSL dashboard table, includes adding a dropdown for selecting the number of records per page (10, 30, 50).
2025-07-27 20:07:59 +07:00
Tola Leng c329425ac1 Refactor the project developement roadmap 2025-07-27 19:22:03 +07:00
Tola Leng fd3b959966 📖 Updated README - Refactor the project developement roadmap 2025-07-27 19:21:32 +07:00
gnsworks 4157ee11cc Merge branch 'operacle:develop' into develop 2025-07-25 21:20:34 +09:00
Tola Leng 86e707803e Fix: Improve dropdown theme support
- Update dropdown themes by ensuring its background color adapts correctly.
2025-07-25 17:42:25 +07:00
Tola Leng 8d279aa0da Fix: Resolve invalid DOM nesting in OperationalPageCard
- Resolved the `validateDOMNesting` warning. The `Badge` will be rendered directly within a `<div>` or other appropriate block-level element.
2025-07-25 17:37:29 +07:00
Tola Leng 07dc10a422 Remove Quick Add Template
- This section provided pre-defined component types for quick addition, but is no longer needed.
2025-07-25 17:34:37 +07:00
Tola Leng e9cedef443 Disabled console log for production 2025-07-25 16:59:49 +07:00
Tola Leng c827151521 Merge pull request #91 from michaelortnerit/develop
[IMP] Added view details (for the names) on the Server Instances and …
2025-07-25 01:31:20 +07:00
Michael Ortner 9d5fb72708 [IMP] Added view details (for the names) on the Server Instances and SSL Views 2025-07-24 07:59:50 +02:00
Tola Leng 2bc508a230 Updated GitHub issue templates for Bug Report, Help Request, and Translation Request
- Bug Report template with detailed reproduction steps, logs, and checklist
- Ask for Help template to guide users in submitting clear questions
- Translation Request template to facilitate multilingual contributions
2025-07-23 22:55:04 +07:00
Tola Leng fda91aedb9 Fix: Display new service immediately
- Ensure that newly created services are immediately visible in the services table list on the Uptime Monitoring dashboard, without requiring a manual refresh or a delay.
2025-07-23 22:13:20 +07:00
Tola Leng 4ff92b4c4d Updated server metric field 2025-07-23 21:55:23 +07:00
Tola Leng fc568891d5 📖 Updated README - For project status and url image 2025-07-23 21:53:31 +07:00
Tola Leng 57f815e29e Updated server agent automate bash script 2025-07-23 21:52:06 +07:00
Tola Leng 9f704bb1ad Added Reddit Community 2025-07-22 20:11:11 +07:00
Tola Leng 0cba09f53d Updated Credentail for Demo [Docs]: Demo Login #74 2025-07-22 15:46:44 +07:00
gnsworks 88b4803f52 Merge branch 'operacle:develop' into develop 2025-07-15 21:23:16 +09:00
gnsworks c6ed816c04 Update Japanese docs 2025-07-14 20:32:08 +09:00
264 changed files with 31550 additions and 30028 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# These are supported funding model platforms
github: [tolaleng]
github: #[tolaleng]
patreon: # Replace with a single Patreon username
open_collective: #checkcle
ko_fi: # Replace with a single Ko-fi username
+15 -15
View File
@@ -9,16 +9,16 @@ body:
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.
Please complete the form below to help us reproduce and fix the issue more effectively.
- 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"
Describe the environment where the issue occurred.
(e.g., OS, browser, Node.js version, Docker version, etc.)
placeholder: "Example: Ubuntu 22.04, Chrome 125, Node.js 20.3.0"
validations:
required: true
@@ -27,7 +27,7 @@ body:
attributes:
label: Bug Description
description: |
A clear and concise description of what the bug is.
A clear and concise description of the issue you encountered.
placeholder: "When I do X, Y happens instead of Z..."
validations:
required: true
@@ -37,13 +37,12 @@ body:
attributes:
label: Steps to Reproduce
description: |
How can we reproduce the behavior?
Please list the steps in order.
Provide step-by-step instructions to reproduce the issue.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
4. See the error
validations:
required: true
@@ -61,8 +60,8 @@ body:
attributes:
label: Screenshots or Videos
description: |
If applicable, add screenshots or screen recordings to help explain your problem.
placeholder: "Attach screenshots here."
If applicable, add screenshots or screen recordings to help illustrate the issue.
placeholder: "Drag and drop or paste screenshots here."
validations:
required: false
@@ -71,8 +70,8 @@ body:
attributes:
label: Logs
description: |
If applicable, paste logs or error messages here.
Please remove any sensitive information.
If applicable, paste any relevant logs or error messages.
Please remove sensitive information before submitting.
render: shell
placeholder: |
```
@@ -86,9 +85,10 @@ body:
id: terms
attributes:
label: Checklist
description: Before submitting, please confirm:
description: |
Please confirm the following before submitting:
options:
- label: I have searched existing issues to avoid duplicates.
- label: I have searched existing issues to avoid creating duplicates.
required: true
- label: I have provided enough information for reproduction.
- label: I have provided sufficient information to reproduce the issue.
required: true
+1 -1
View File
@@ -1 +1 @@
blank_issues_enabled: false
blank_issues_enabled: true
+6 -9
View File
@@ -15,8 +15,7 @@ body:
id: description
attributes:
label: Feature Description
description: |
A clear and concise description of the feature youd like to see.
description: A clear and concise description of the feature youd like to see.
placeholder: "I would like to have..."
validations:
required: true
@@ -25,8 +24,7 @@ body:
id: motivation
attributes:
label: Motivation
description: |
Please explain why this feature would be useful.
description: Please explain why this feature would be useful.
placeholder: "This feature would help because..."
validations:
required: true
@@ -35,8 +33,7 @@ body:
id: alternatives
attributes:
label: Alternatives Considered
description: |
Have you considered any alternative solutions or workarounds?
description: Have you considered any alternative solutions or workarounds?
placeholder: "I have tried..."
validations:
required: false
@@ -45,8 +42,7 @@ body:
id: additional
attributes:
label: Additional Context
description: |
Add any other context or screenshots about the feature request here.
description: Add any other context or screenshots about the feature request here.
placeholder: "Links to related issues or references..."
validations:
required: false
@@ -55,7 +51,8 @@ body:
id: checklist
attributes:
label: Checklist
description: Before submitting, please confirm:
description: |
Before submitting, please confirm:
options:
- label: I have searched existing issues to make sure this feature hasnt been requested yet.
required: true
@@ -1,5 +1,5 @@
name: ❓ Ask for Help
description: Ask a question or request guidance about this project.
description: Ask a question or request guidance related to this project.
title: "[Question]: "
labels: [question]
assignees: []
@@ -8,8 +8,8 @@ body:
- type: markdown
attributes:
value: |
**Thank you for reaching out!**
Please fill out this form to help us understand your question.
**Thanks for reaching out!**
Please provide as much detail as possible so we can better assist you.
- type: textarea
id: question
@@ -17,17 +17,17 @@ body:
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...
I am trying to do X, but I'm not sure how to...
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
label: Additional Context
description: |
Provide any additional context, related issues, or links that help explain your question.
placeholder: "Related issues, pull requests, or documentation..."
Provide any relevant context, related issues, pull requests, or documentation links that could help us understand your question better.
placeholder: "Mention related issues, links to docs, or previous discussions..."
validations:
required: false
@@ -36,8 +36,8 @@ body:
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"
If relevant, provide details about your environment (e.g., OS, browser, Node.js version).
placeholder: "Example: Windows 11, Node.js v20.3.0, Firefox 125"
validations:
required: false
@@ -45,9 +45,10 @@ body:
id: checklist
attributes:
label: Checklist
description: Before submitting, please confirm:
description: |
Before submitting, please confirm the following:
options:
- label: I have searched existing issues and discussions.
- label: I have searched existing issues and discussions for a solution.
required: true
- label: I have read the documentation.
- label: I have read the relevant documentation.
required: true
+2
View File
@@ -22,6 +22,8 @@ dist-ssr
*.njsproj
*.sln
*.sw?
pocketbase.exe
application/src/lib/pocketbase.ts
server/pb_data/
Dockerfile
application/src/lib/pocketbase-prod.ts
+68 -62
View File
@@ -20,6 +20,12 @@
<br/><strong>Japanese</strong>
</a>
</td>
<td align="center">
<a href="docs/README_zhcn.md">
<img src="https://flagcdn.com/24x18/cn.png" alt="Chinese" />
<br/><strong>Chinese</strong>
</a>
</td>
</tr>
</table>
@@ -39,10 +45,12 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
## 🎯 Live Demo
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
User: admin@example.com | Passwd: Admin123456
## 🌟 Core Features
### 📝 Roadmap : [DEVELOPMENT_ROADMAP](docs/DEVELOPMENT_ROADMAP.md)
### Uptime Services & Infrastructure Server Monitoring
### Uptime Services & Infrastructure Server Monitoring
- Monitor HTTP, DNS, and Ping protocols
- Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP)
- Track detail uptime, response times, and performance issues
@@ -62,26 +70,9 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
* ✅ 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.
### Install CheckCle using one of the following methods:
1. CheckCle One-Click Installation - Just copy and run on terminal
```bash
curl -fsSL https://checkcle.io/install.sh | bash
```
2. Install with docker run. Just copy ready docker run command below
```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.
1. Install with Docker Compose Configuration (Recommended)
```bash
version: '3.9'
@@ -101,6 +92,18 @@ services:
hard: 8192
```
2. Install with docker run. Just copy ready docker run command below
```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. Admin Web Management
Default URL: http://0.0.0.0:8090
@@ -110,33 +113,12 @@ services:
4. Follow the Quick Start Guide at https://docs.checkcle.io
###
![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-monitoring.png)
![Service Detail Page](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-service-detail.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)
![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-1.4.png)
![Service Detail Page](https://cdn.checkcle.io/images/uptime/uptime-regional-detail.png)
![checkcle-server-instance](https://cdn.checkcle.io/images/server/server-list.png)
![SSL Monitoring](https://cdn.checkcle.io/images/ssl-domain/ssl-list.png)
![Notification System](https://cdn.checkcle.io/general/powerfull_notification.png)
## 📝 Development Roadmap
- ✅ Health check & uptime monitoring (HTTP)
- ✅ Dashboard UI with live stats
- ✅ Auth with Multi-users system (admin)
- ✅ Notifications (Telegram)
- ✅ Docker containerization
- ✅ CheckCle Website
- ✅ CheckCle Demo Server
- ✅ SSL & Domain Monitoring
- ✅ Schedule Maintenance
- ✅ Incident Management
- [ ] Infrastructure Server Monitoring
- ✅ Operational Status / Public Status Pages
- ✅ Uptime monitoring (HTTP, TCP, PING, DNS) Full functionality
- ✅ Distributed Regional Monitoring Agent [Support Operation](https://github.com/operacle/Distributed-Regional-Monitoring)
- ✅ System Setting Panel and Mail Settings
- ✅ User Permission Roles
- [ ] Notifications (Email/Slack/Discord/Signal)
- ✅ Data Retention & Automate Strink (Muti Options to Shrink Data & Database )
- ✅ Open-source release with full documentation
## 🌟 CheckCle for Communities?
- **Built with Passion**: Created by an open-source enthusiast for the community
@@ -145,6 +127,42 @@ services:
---
## Sponsors
Sponsorships will no longer be accepted. From now on, support will only be accepted in the form of ecosystem and community partnerships that provide infrastructure such as cloud servers, domains, or hosting credits.
If youre a tech company interested in supporting CheckCle, please contact the author directly at tolaleng@checkcle.io
### 🤝 Ecosystem & Community Partner
<div style="display: flex; align-items: center; gap: 10px;">
<a href="https://github.com/gitbookio">
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
width="75" height="75"
style="border-radius: 50%;"
alt="GitBook Logo" />
</a>
<a href="https://www.cloudflare.com">
<img src="https://cdn.checkcle.io/images/sponsor/cloudflare-checkcle_logo.png"
height="60"
alt="Cloudflare Logo" />
</a>
<a href="https://m.do.co/c/0c27ef82475f">
<img src="https://cdn.checkcle.io/images/sponsor/digitalocean_checkcle.png"
height="50"
alt="DigitalOcean Logo" />
</a>
</div>
---
## 👥 Contributors
Thank you for contributing and continuously making CheckCle better, you're awesome 🫶
[![](https://contrib.rocks/image?repo=operacle/checkcle)](https://github.com/operacle/checkcle/graphs/contributors)
---
## 🤝 Ways to Contribute
Here are some ways you can help improve CheckCle:
@@ -153,30 +171,18 @@ Here are some ways you can help improve CheckCle:
- 🌟 **Suggest Features** Have an idea? Start a [Discussion](https://github.com/operacle/checkcle/discussions) or open a Feature Request issue.
- 🛠 **Submit Pull Requests** Improve the code, fix bugs, add features, or enhance the docs.
- 📝 **Improve Documentation** Even a typo fix helps!
- 🌍 **Spread the Word** Star ⭐ the repo, share it on socials, and invite others to contribute!
- 🌍 **Spread the Word** Star ⭐ the [CheckCle](https://github.com/operacle/checkcle.git) repo, share it on socials, and invite others to contribute!
---
## 🌍 Stay Connected
- Website: [checkcle.io](https://checkcle.io)
- Documentation: [docs.checkcle.io](https://docs.checkcle.io)
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git)
- Community Channels: Engage via discussions and issues!
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
- X: [@tlengoss](https://x.com/tlengoss)
- Documentation: [docs.checkcle.io](https://docs.checkcle.io) | Big thanks to [GitBook](https://github.com/gitbookio) for sponsoring the OSS site plan for CheckCle!
- Chat on Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
- Follow us on X: [@checkcle_oss](https://x.com/checkcle_oss)
## 📜 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! 🌐
+24 -20
View File
@@ -1,25 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CheckCle is an open-source monitoring stack</title>
<meta name="description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
<meta name="author" content="Tola Leng" />
<meta property="og:title" content="checkcle-an-open-source" />
<meta property="og:description" content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CheckCle - Dev</title>
<meta name="description"
content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
<meta name="author" content="Tola Leng" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@tlengoss" />
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
</head>
<meta property="og:title" content="checkcle-an-open-source" />
<meta property="og:description"
content="An open-source monitoring platform offering real-time insights into server and service health, incident management, and operational transparency." />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@checkcle_oss" />
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+469 -15
View File
@@ -8,6 +8,7 @@
"name": "vite_react_shadcn_ts",
"version": "0.0.0",
"dependencies": {
"@headlessui/react": "2.2.7",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
@@ -27,7 +28,7 @@
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@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-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
@@ -43,6 +44,8 @@
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"input-otp": "^1.2.4",
"jspdf": "^3.0.1",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"pocketbase": "^0.19.0",
@@ -91,13 +94,10 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.9.tgz",
"integrity": "sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==",
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz",
"integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
@@ -645,6 +645,21 @@
"@floating-ui/utils": "^0.2.8"
}
},
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
@@ -664,6 +679,26 @@
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==",
"license": "MIT"
},
"node_modules/@headlessui/react": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.7.tgz",
"integrity": "sha512-WKdTymY8Y49H8/gUc/lIyYK1M+/6dq0Iywh4zTZVAaiTDprRfioxSgD0wnXTQTBpjpGJuTL1NO/mqEvc//5SSg==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.20.2",
"@react-aria/interactions": "^3.25.0",
"@tanstack/react-virtual": "^3.13.9",
"use-sync-external-store": "^1.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@hookform/resolvers": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz",
@@ -1811,12 +1846,12 @@
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz",
"integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==",
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0"
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -1833,6 +1868,62 @@
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slider": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.1.tgz",
@@ -2225,6 +2316,103 @@
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==",
"license": "MIT"
},
"node_modules/@react-aria/focus": {
"version": "3.21.1",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.1.tgz",
"integrity": "sha512-hmH1IhHlcQ2lSIxmki1biWzMbGgnhdxJUM0MFfzc71Rv6YAzhlx4kX3GYn4VNcjCeb6cdPv4RZ5vunV4kgMZYQ==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/interactions": "^3.25.5",
"@react-aria/utils": "^3.30.1",
"@react-types/shared": "^3.32.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.25.5",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.5.tgz",
"integrity": "sha512-EweYHOEvMwef/wsiEqV73KurX/OqnmbzKQa2fLxdULbec5+yDj6wVGaRHIzM4NiijIDe+bldEl5DG05CAKOAHA==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-aria/utils": "^3.30.1",
"@react-stately/flags": "^3.1.2",
"@react-types/shared": "^3.32.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/utils": {
"version": "3.30.1",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.30.1.tgz",
"integrity": "sha512-zETcbDd6Vf9GbLndO6RiWJadIZsBU2MMm23rBACXLmpRztkrIqPEb2RVdlLaq1+GklDx0Ii6PfveVjx+8S5U6A==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-stately/flags": "^3.1.2",
"@react-stately/utils": "^3.10.8",
"@react-types/shared": "^3.32.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-stately/flags": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@react-stately/utils": {
"version": "3.10.8",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
"integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.0.tgz",
"integrity": "sha512-t+cligIJsZYFMSPFMvsJMjzlzde06tZMOIOFa1OV5Z0BcMowrb2g4mB57j/9nP28iJIRYn10xCniQts+qadrqQ==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@remix-run/router": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz",
@@ -2674,6 +2862,15 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@swc/types": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz",
@@ -2738,6 +2935,33 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
@@ -2832,6 +3056,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/react": {
"version": "18.3.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz",
@@ -2853,6 +3084,13 @@
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz",
@@ -3206,6 +3444,18 @@
"node": ">=10"
}
},
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/autoprefixer": {
"version": "10.4.20",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
@@ -3250,6 +3500,16 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -3318,6 +3578,18 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"license": "(MIT OR Apache-2.0)",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3358,6 +3630,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -3843,6 +4135,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.0.tgz",
"integrity": "sha512-c2KZL9lP4DjkN3hk/an4pWn5b5ZefhRJnAc42n6LJ19kSnbeRbdQZE5dSeE2LBol1OwJD3X1BQvFTAsa8ReeDA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3856,6 +4160,16 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -4064,6 +4378,16 @@
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -4424,6 +4748,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4647,6 +4977,20 @@
"node": ">= 0.4"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4848,6 +5192,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/jspdf": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.1.tgz",
"integrity": "sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.7",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.2.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf-autotable": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.2.tgz",
"integrity": "sha512-YNKeB7qmx3pxOLcNeoqAv3qTS7KuvVwkFe5AduCawpop3NOkBUtqDToxNc225MlNecxT4kP2Zy3z/y/yvGdXUQ==",
"license": "MIT",
"peerDependencies": {
"jspdf": "^2 || ^3"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5208,6 +5579,13 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5450,6 +5828,16 @@
],
"license": "MIT"
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -5708,10 +6096,11 @@
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/resolve": {
"version": "1.22.8",
@@ -5750,6 +6139,16 @@
"node": ">=0.10.0"
}
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz",
@@ -5883,6 +6282,16 @@
"node": ">=0.10.0"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -6039,6 +6448,22 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz",
@@ -6095,6 +6520,16 @@
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -6308,12 +6743,31 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vaul": {
"version": "0.9.9",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz",
+2
View File
@@ -11,6 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "2.2.7",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
@@ -45,6 +46,7 @@
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.3.0",
"framer-motion": "12.23.12",
"input-otp": "^1.2.4",
"jspdf": "^3.0.1",
"jspdf-autotable": "^5.0.2",
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+139 -45
View File
@@ -1,37 +1,40 @@
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 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 { ThemeProvider } from './contexts/ThemeContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { SidebarProvider } from './contexts/SidebarContext';
import { ThemeProvider } from "./contexts/ThemeContext";
import { LanguageProvider } from "./contexts/LanguageContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import Index from './pages/Index';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import InstanceMonitoring from './pages/InstanceMonitoring';
import ContainerMonitoring from './pages/ContainerMonitoring';
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';
import Index from "./pages/Index";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import InstanceMonitoring from "./pages/InstanceMonitoring";
import ContainerMonitoring from "./pages/ContainerMonitoring";
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";
function App() {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000
}
}
}));
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
);
return (
<BrowserRouter>
@@ -43,22 +46,113 @@ function App() {
<Routes>
{/* Public routes */}
<Route path="/public/:slug" element={<PublicStatusPage />} />
{/* Protected routes */}
<Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Index /></ProtectedRoute>} />
<Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/instance-monitoring" element={<ProtectedRoute><InstanceMonitoring /></ProtectedRoute>} />
<Route path="/server-detail/:serverId" element={<ProtectedRoute><ServerDetail /></ProtectedRoute>} />
<Route path="/container-monitoring" element={<ProtectedRoute><ContainerMonitoring /></ProtectedRoute>} />
<Route path="/container-monitoring/:serverId" element={<ProtectedRoute><ContainerMonitoring /></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 path="/operational-page" element={<ProtectedRoute><OperationalPage /></ProtectedRoute>} />
<Route path="/regional-monitoring" element={<ProtectedRoute><RegionalMonitoring /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
<Route
path="/"
element={
<ProtectedRoute>
<Index />
</ProtectedRoute>
}
/>
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/instance-monitoring"
element={
<ProtectedRoute>
<InstanceMonitoring />
</ProtectedRoute>
}
/>
<Route
path="/server-detail/:serverId"
element={
<ProtectedRoute>
<ServerDetail />
</ProtectedRoute>
}
/>
<Route
path="/container-monitoring"
element={
<ProtectedRoute>
<ContainerMonitoring />
</ProtectedRoute>
}
/>
<Route
path="/container-monitoring/:serverId"
element={
<ProtectedRoute>
<ContainerMonitoring />
</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
path="/operational-page"
element={
<ProtectedRoute>
<OperationalPage />
</ProtectedRoute>
}
/>
<Route
path="/regional-monitoring"
element={
<ProtectedRoute>
<RegionalMonitoring />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</QueryClientProvider>
@@ -69,4 +163,4 @@ function App() {
);
}
export default App;
export default App;
@@ -1,4 +1,3 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
@@ -7,6 +6,7 @@ import { StatusCards } from "./StatusCards";
import { ServiceFilters } from "./ServiceFilters";
import { ServicesTable } from "./ServicesTable";
import { AddServiceDialog } from "@/components/services/AddServiceDialog";
import { useLanguage } from "@/contexts/LanguageContext";
interface DashboardContentProps {
services: Service[];
@@ -14,16 +14,24 @@ interface DashboardContentProps {
error: Error | null;
}
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
export const DashboardContent = ({
services,
isLoading,
error,
}: DashboardContentProps) => {
const { t } = useLanguage();
const [filter, setFilter] = useState<string>("all");
const [searchTerm, setSearchTerm] = useState<string>("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
// Filter services based on search term and type filter
const filteredServices = services.filter(service => {
const matchesSearch = service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(service.url && service.url.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesFilter = filter === 'all' || service.type.toLowerCase() === filter.toLowerCase();
const filteredServices = services.filter((service) => {
const matchesSearch =
service.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(service.url &&
service.url.toLowerCase().includes(searchTerm.toLowerCase()));
const matchesFilter =
filter === "all" || service.type.toLowerCase() === filter.toLowerCase();
return matchesSearch && matchesFilter;
});
@@ -31,40 +39,42 @@ export const DashboardContent = ({ services, isLoading, error }: DashboardConten
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
<p>Error loading service data.</p>
<Button onClick={() => window.location.reload()}>Retry</Button>
<Button onClick={() => window.location.reload()}>{t("retry")}</Button>
</div>
);
}
return (
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
<div className="flex flex-col flex-1">
<main className="flex-1 flex flex-col overflow-auto dark:bg-black p-2 shadow pb-0 ">
<div className="flex flex-col flex-1 bg-white dark:bg-black p-4 rounded-xl ">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-foreground">Overview</h2>
<Button
<h2 className="text-2xl font-bold text-foreground">
{t("overview")}
</h2>
<Button
className="text-primary-foreground"
onClick={() => setIsAddDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-2" /> New Service
<Plus className="w-4 h-4 mr-2" /> {t("newService")}
</Button>
</div>
<StatusCards services={services} />
<ServiceFilters
<ServiceFilters
filter={filter}
setFilter={setFilter}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
servicesCount={filteredServices.length}
/>
<div className="flex-1 flex flex-col pb-6">
<ServicesTable services={filteredServices} />
</div>
</div>
<AddServiceDialog
<AddServiceDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
/>
+214 -73
View File
@@ -1,15 +1,39 @@
import { Button } from "@/components/ui/button";
import { AuthUser } from "@/services/authService";
import { useTheme } from "@/contexts/ThemeContext";
import { Moon, PanelLeft, PanelLeftClose, Sun, Globe, FileText, Github, Twitter, MessageSquare, Bell, User, Settings, LogOut, Grid3x3 } from "lucide-react";
import {
Moon,
PanelLeft,
PanelLeftClose,
Sun,
Globe,
FileText,
Github,
Twitter,
MessageSquare,
Bell,
User,
Settings,
LogOut,
CaseUpper,
BookA,
CaseSensitive,
CaseLower,
ALargeSmall,
} from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useNavigate } from "react-router-dom";
import { useSystemSettings } from "@/hooks/useSystemSettings";
import QuickActionsDialog from "./QuickActionsDialog";
import useDynamicFontSize from "@/contexts/FontSizeContext";
interface HeaderProps {
currentUser: AuthUser | null;
@@ -22,14 +46,13 @@ export const Header = ({
currentUser,
onLogout,
sidebarCollapsed,
toggleSidebar
toggleSidebar,
}: HeaderProps) => {
const { theme, toggleTheme } = useTheme();
const { language, setLanguage, t } = useLanguage();
const [greeting, setGreeting] = useState<string>("");
const { systemName } = useSystemSettings();
const navigate = useNavigate();
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
// Set greeting based on time of day
useEffect(() => {
@@ -53,157 +76,275 @@ export const Header = ({
// Log avatar data for debugging
useEffect(() => {
if (currentUser) {
// console.log("Avatar URL in Header:", currentUser.avatar);
//console.log("Avatar URL in Header:", currentUser.avatar);
}
}, [currentUser]);
// Prepare avatar URL - ensure it displays correctly if it's a local profile image
let avatarUrl = '';
let avatarUrl = "";
if (currentUser?.avatar) {
// If it's a relative path from the public folder, make sure it's resolved properly
if (currentUser.avatar.startsWith('/upload/profile/')) {
if (currentUser.avatar.startsWith("/upload/profile/")) {
avatarUrl = currentUser.avatar;
} else {
avatarUrl = currentUser.avatar;
}
// console.log("Final avatar URL:", avatarUrl);
console.log("Final avatar URL:", avatarUrl);
}
const [fontSize, setFontSize] = useState(() => {
const savedFontSize = localStorage.getItem("fontSize");
return savedFontSize ? parseInt(savedFontSize, 10) : 14;
});
useDynamicFontSize(fontSize);
return (
<header className="relative bg-background border-b border-border px-6 flex justify-between items-center py-[12px] overflow-hidden">
{/* Grid Pattern Overlay - Similar to StatusCards */}
<div className="absolute inset-0 z-0">
<div
className="w-full h-full"
style={{
backgroundImage: `linear-gradient(${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px),
linear-gradient(90deg, ${theme === 'dark' ? '#ffffff10' : '#00000010'} 1px, transparent 1px)`,
backgroundSize: '20px 20px'
<div
className="w-full h-full dark:bg-black"
style={{
backgroundImage: `linear-gradient(${
theme === "dark" ? "#ffffff10" : "#00000010"
} 1px, transparent 1px),
linear-gradient(90deg, ${
theme === "dark" ? "#ffffff10" : "#00000010"
} 1px, transparent 1px)`,
backgroundSize: "20px 20px",
}}
>
<div className="w-full h-full backdrop-blur-[1px]"></div>
</div>
</div>
{/* Header Content */}
<div className="flex items-center gap-4 z-10">
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2">
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</Button>
{/* Quick Actions Button */}
<Button
variant="ghost"
size="icon"
onClick={() => setQuickActionsOpen(true)}
<div
className={` p-1 h-8 w-8 -ml-2 rounded bg-gray-600 ${
!sidebarCollapsed ? "hidden" : ""
}`}
>
<img
src="/favicon_sidebar.ico"
alt="CheckCle"
className={`h-6 w-6 `}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="mr-2"
>
<Grid3x3 className="h-5 w-5 text-green-500" />
{sidebarCollapsed ? (
<PanelLeft className="h-5 w-5" />
) : (
<PanelLeftClose className="h-5 w-5" />
)}
</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="font-medium text-xl">
{greeting},{" "}
{currentUser?.name || currentUser?.email?.split("@")[0] || "User"}{" "}
👋
</h1>
</div>
</div>
<div className="flex items-center space-x-4 z-10">
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border" onClick={toggleTheme}>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={toggleTheme}
>
<span className="sr-only">Toggle theme</span>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{theme === "dark" ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<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"
>
<span className="sr-only">{t("language")}</span>
<Globe className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
<DropdownMenuItem
onClick={() => setLanguage("en")}
className={language === "en" ? "bg-accent" : ""}
>
{t("english")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("km")} className={language === "km" ? "bg-accent" : ""}>
<DropdownMenuItem
onClick={() => setLanguage("km")}
className={language === "km" ? "bg-accent" : ""}
>
{t("khmer")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
<DropdownMenuItem
onClick={() => setLanguage("de")}
className={language === "de" ? "bg-accent" : ""}
>
{t("Deutsch")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}>
<DropdownMenuItem
onClick={() => setLanguage("ko")}
className={language === "ko" ? "bg-accent" : ""}
>
{t("korean")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage("ja")}
className={language === "ja" ? "bg-accent" : ""}
>
{t("japanese")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage("zhcn")}
className={language === "zhcn" ? "bg-accent" : ""}
>
{t("simplifiedChinese")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Documentation */}
<Button
variant="outline"
size="icon"
<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>
<FileText className="w-4 h-4" />
</Button>
{/* GitHub */}
<Button
variant="outline"
size="icon"
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://github.com/operacle/checkcle", "_blank")}
onClick={() =>
window.open("https://github.com/operacle/checkcle", "_blank")
}
>
<span className="sr-only">GitHub</span>
<Github className="w-4 h-4" />
</Button>
{/* X (Twitter) */}
<Button
variant="outline"
size="icon"
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://x.com/tlengoss", "_blank")}
onClick={() => window.open("https://x.com/checkcle_oss)", "_blank")}
>
<span className="sr-only">X (Twitter)</span>
<Twitter className="w-4 h-4" />
</Button>
{/* Discord */}
<Button
variant="outline"
size="icon"
<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>
<MessageSquare className="w-4 h-4" />
</Button>
{/* Notifications */}
<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"
>
<span className="sr-only">{t("notifications")}</span>
<Bell className="w-4 h-4" />
</Button>
{/* Fond Size */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
>
<span className="sr-only">{t("language")}</span>
<BookA className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setFontSize((prev) => prev + 1)}
className={`cursor-pointer`}
>
<span className={`text-[15px] font-semibold`}> Aa +</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setFontSize((prev) => prev - 1)}
className={`cursor-pointer`}
>
<span className={`text-[12px] font-semibold`}> Aa -</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* User Profile Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="h-8 w-8 cursor-pointer ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
</AvatarFallback>}
{avatarUrl ? (
<AvatarImage
src={avatarUrl}
alt={
currentUser?.name ||
currentUser?.email?.split("@")[0] ||
"User"
}
/>
) : (
<AvatarFallback className="bg-primary/20 text-primary">
{currentUser?.name?.[0] || currentUser?.email?.[0] || "U"}
</AvatarFallback>
)}
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-3 p-2">
<Avatar className="h-10 w-10">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary">
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
</AvatarFallback>}
{avatarUrl ? (
<AvatarImage
src={avatarUrl}
alt={
currentUser?.name ||
currentUser?.email?.split("@")[0] ||
"User"
}
/>
) : (
<AvatarFallback className="bg-primary/20 text-primary">
{currentUser?.name?.[0] || currentUser?.email?.[0] || "U"}
</AvatarFallback>
)}
</Avatar>
<div className="flex flex-col space-y-1">
<span className="font-medium">{currentUser?.name || 'User'}</span>
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
<span className="font-medium">
{currentUser?.name || "User"}
</span>
<span className="text-xs text-muted-foreground truncate">
{currentUser?.email}
</span>
</div>
</div>
<DropdownMenuSeparator />
@@ -216,16 +357,16 @@ export const Header = ({
<span>{t("settings")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onLogout} className="text-red-500 focus:text-red-500">
<DropdownMenuItem
onClick={onLogout}
className="text-red-500 focus:text-red-500"
>
<LogOut className="mr-2 h-4 w-4" />
<span>{t("logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Quick Actions Dialog */}
<QuickActionsDialog isOpen={quickActionsOpen} setIsOpen={setQuickActionsOpen} />
</header>
);
};
};
@@ -1,145 +0,0 @@
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;
@@ -1,8 +1,14 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Plus } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServiceFiltersProps {
filter: string;
@@ -12,28 +18,31 @@ interface ServiceFiltersProps {
servicesCount: number;
}
export const ServiceFilters = ({
filter,
setFilter,
searchTerm,
export const ServiceFilters = ({
filter,
setFilter,
searchTerm,
setSearchTerm,
servicesCount
servicesCount,
}: ServiceFiltersProps) => {
const { t } = useLanguage();
return (
<div className="mb-6 flex justify-between items-center">
<div className="mb-6 flex justify-between items-center ">
<div className="flex items-center">
<h3 className="text-xl font-semibold mr-2 text-foreground">Currently Monitoring</h3>
<h3 className="text-xl font-semibold mr-2 text-foreground">
{t("currentlyMonitoring")}
</h3>
<span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
{servicesCount}
</span>
</div>
<div className="flex space-x-4">
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-40 bg-card border-border">
<SelectValue placeholder="All Types" />
<SelectTrigger className="w-40 bg-card border-border dark:bg-gray-800">
<SelectValue placeholder={t("allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="all">{t("allTypes")}</SelectItem>
<SelectItem value="HTTP">HTTP</SelectItem>
<SelectItem value="PING">PING</SelectItem>
<SelectItem value="TCP">TCP</SelectItem>
@@ -41,9 +50,9 @@ export const ServiceFilters = ({
</SelectContent>
</Select>
<div className="relative">
<Input
className="w-72 bg-card border-border"
placeholder="Search"
<Input
className="w-72 bg-card border-border"
placeholder={t("search")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
@@ -1,4 +1,3 @@
import React from "react";
import { useTheme } from "@/contexts/ThemeContext";
import { SidebarHeader } from "./sidebar/SidebarHeader";
@@ -11,18 +10,25 @@ interface SidebarProps {
export const Sidebar = ({ collapsed }: SidebarProps) => {
const { theme } = useTheme();
return (
<div
<div
className={`
${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'}
border-r flex flex-col h-full
${collapsed ? 'w-16' : 'w-64'}
`}
${
theme === "dark"
? "bg-black border-[#1e1e1e]"
: "bg-sidebars border-sidebar-border"
}
border-r flex flex-col h-full
${collapsed ? "w-16" : "w-64"}
${collapsed ? "hidden" : ""}`}
>
<SidebarHeader collapsed={collapsed} />
<MainNavigation collapsed={collapsed} />
<SettingsPanel collapsed={collapsed} />
<div className="items-center ml-2 p-2 h-30 border-t ">
Version: latest
</div>
</div>
);
};
};
@@ -3,12 +3,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react";
import { Service } from "@/services/serviceService";
import { useTheme } from "@/contexts/ThemeContext";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface StatusCardsProps {
services: Service[];
}
export const StatusCards = ({ services }: StatusCardsProps) => {
const { t } = useLanguage();
// Count services by status
const upServices = services.filter(s => s.status === "up").length;
const downServices = services.filter(s => s.status === "down").length;
@@ -42,7 +45,7 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">UP SERVICES</CardTitle>
<CardTitle className="text-sm font-medium text-white">{t("upServices")}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{upServices}</span>
@@ -74,7 +77,7 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">DOWN SERVICES</CardTitle>
<CardTitle className="text-sm font-medium text-white">{t("downServices")}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{downServices}</span>
@@ -106,7 +109,7 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">PAUSED SERVICES</CardTitle>
<CardTitle className="text-sm font-medium text-white">{t("pausedServices")}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{pausedServices}</span>
@@ -138,7 +141,7 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
></div>
</div>
<CardHeader className="pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-white">WARNING SERVICES</CardTitle>
<CardTitle className="text-sm font-medium text-white">{t("warningServices")}</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{warningServices}</span>
@@ -1,4 +1,3 @@
import React from "react";
import { MenuItem } from "./MenuItem";
import { mainMenuItems } from "./navigationData";
@@ -7,9 +6,15 @@ interface MainNavigationProps {
collapsed: boolean;
}
export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) => {
export const MainNavigation: React.FC<MainNavigationProps> = ({
collapsed,
}) => {
return (
<nav className="my-2 mx-1 py-1 px-1">
<nav
className={`my-2 mx-1 py-1 px-1 dark:bg-black ${
collapsed ? "hidden" : ""
}`}
>
{mainMenuItems.map((item) => (
<MenuItem
key={item.id}
@@ -24,4 +29,4 @@ export const MainNavigation: React.FC<MainNavigationProps> = ({ collapsed }) =>
))}
</nav>
);
};
};
@@ -1,4 +1,3 @@
import React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext";
@@ -22,7 +21,7 @@ export const MenuItem: React.FC<MenuItemProps> = ({
translationKey,
color,
hasNavigation,
collapsed
collapsed,
}) => {
const { theme } = useTheme();
const { t } = useLanguage();
@@ -32,7 +31,7 @@ export const MenuItem: React.FC<MenuItemProps> = ({
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (hasNavigation && path) {
navigate(path, { replace: false });
}
@@ -42,23 +41,29 @@ export const MenuItem: React.FC<MenuItemProps> = ({
const mainIconSize = "h-6 w-6";
return (
<div
<div
className={`
${collapsed ? 'p-3' : 'p-2 pl-3'}
${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'}`}
${
isActive
? theme === "dark"
? "bg-drak"
: "bg-sidebar-accent "
: `hover:${theme === "dark" ? "bg-gray-800" : "bg-sidebar-accent "}`
}
flex items-center
${collapsed ? 'justify-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]">
<span className="ml-2.5 font-medium text-foreground tracking-wide ">
{t(translationKey)}
</span>
)}
</div>
);
};
};
@@ -1,10 +1,13 @@
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 {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { settingsMenuItems } from "./navigationData";
@@ -17,19 +20,27 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
const { t } = useLanguage();
const location = useLocation();
const navigate = useNavigate();
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>("general");
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
const [activeSettingsItem, setActiveSettingsItem] = useState<string | null>(
"general"
);
//const [settingsPanelOpen, setSettingsPanelOpen] = useState(false);
const [settingsPanelOpen, setSettingsPanelOpen] = useState(() => {
const savedValue = localStorage.getItem("settingsPanelOpen");
// Check if the saved value exists and is "true"
return savedValue === "true";
});
// Update active settings item based on URL
useEffect(() => {
if (location.pathname === '/settings') {
if (location.pathname === "/settings") {
const params = new URLSearchParams(location.search);
const panel = params.get('panel');
const panel = params.get("panel");
if (panel) {
setActiveSettingsItem(panel);
}
}
}, [location]);
localStorage.setItem("settingsPanelOpen", settingsPanelOpen.toString());
}, [location, settingsPanelOpen]);
const handleSettingsItemClick = (item: string) => {
setActiveSettingsItem(item);
@@ -43,15 +54,27 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
};
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`;
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)}
<div
className={`border-t ${
theme === "dark"
? "border-[#1e1e1e] bg-[#121212]"
: "border-sidebar-border bg-sidebar"
} p-4 flex justify-center ${collapsed ? "hidden" : ""}`}
>
<div
onClick={(e) => handleMenuItemClick("/settings", e)}
className="cursor-pointer"
>
<Settings className={`${mainIconSize} text-purple-400`} />
@@ -61,33 +84,59 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
}
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-1 flex flex-col border-t ${
theme === "dark"
? "border-[#1e1e1e] bg-black"
: "border-sidebar-border bg-sidebars"
} p-4 ${collapsed ? "hidden" : ""}`}
>
<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>
<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' : ''}`} />
<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`}>
<CollapsibleContent
className={`${
theme === "dark" ? "bg-black" : "bg-sidebars"
} 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">
<div className="space-y-2 pr-4 cursor-pointer">
{settingsMenuItems.map((item) => (
<div
<div
key={item.id}
className={getMenuItemClasses(activeSettingsItem === 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>
<span className="text-base">{t(item.translationKey)}</span>
</div>
))}
</div>
@@ -97,4 +146,4 @@ export const SettingsPanel: React.FC<SettingsPanelProps> = ({ collapsed }) => {
</Collapsible>
</div>
);
};
};
@@ -1,4 +1,3 @@
import React from "react";
import { useTheme } from "@/contexts/ThemeContext";
@@ -10,15 +9,17 @@ 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
className={`p-[15.7px] ${
theme === "dark"
? "border-[#1e1e1e] bg-black"
: "border-sidebar-border bg-white"
} border-b flex items-center ${collapsed ? "justify-center" : ""}`}
>
<div className="h-8 w-8 rounded flex items-center justify-center mr-2 dark:bg-drak">
<img src="/favicon_sidebar.ico" alt="CheckCle" className="h-7 w-6" />
</div>
{!collapsed && <h1 className="text-xl font-semibold">CheckCle App</h1>}
</div>
);
};
};
@@ -1,3 +1,4 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -6,7 +7,7 @@ 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 { Plus, X, Server } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useQuery } from '@tanstack/react-query';
import { serviceService } from '@/services/serviceService';
@@ -17,12 +18,6 @@ interface ComponentsSelectorProps {
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({
@@ -169,7 +164,7 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<SelectTrigger>
<SelectValue placeholder="Select an uptime service" />
</SelectTrigger>
<SelectContent className="z-50 bg-white border shadow-lg">
<SelectContent>
{services.map((service) => (
<SelectItem key={service.id} value={service.id}>
<div className="flex items-center gap-2">
@@ -206,37 +201,6 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
</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>
);
@@ -67,13 +67,13 @@ export const CreateOperationalPageDialog = () => {
page_style: data.page_style || '',
};
console.log('Creating operational page with payload:', payload);
// console.log('Creating operational page with payload:', payload);
const createdPage = await createMutation.mutateAsync(payload);
console.log('Created page:', createdPage);
// console.log('Created page:', createdPage);
// Create components after page is created
if (selectedComponents.length > 0) {
console.log('Creating components for page:', createdPage.id);
// console.log('Creating components for page:', createdPage.id);
for (const component of selectedComponents) {
const componentPayload = {
operational_status_id: createdPage.id,
@@ -84,7 +84,7 @@ export const CreateOperationalPageDialog = () => {
display_order: component.display_order || 1,
};
console.log('Creating component with payload:', componentPayload);
// console.log('Creating component with payload:', componentPayload);
await createComponentMutation.mutateAsync(componentPayload);
}
}
@@ -93,7 +93,7 @@ export const CreateOperationalPageDialog = () => {
form.reset();
setSelectedComponents([]);
} catch (error) {
console.error('Error creating operational page:', error);
// console.error('Error creating operational page:', error);
}
};
@@ -41,11 +41,11 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
</div>
<div>
<span className="font-medium text-muted-foreground">Public:</span>
<p className="mt-1">
<div className="mt-1">
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
{page.is_public === 'true' ? 'Yes' : 'No'}
</Badge>
</p>
</div>
</div>
<div>
<span className="font-medium text-muted-foreground">Updated:</span>
@@ -1,14 +1,17 @@
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 { 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,
@@ -18,17 +21,26 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
} from "@/components/ui/alert-dialog";
export const OperationalPageContent = () => {
const navigate = useNavigate();
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
const {
data: pages,
isLoading,
error,
refetch,
isRefetching,
} = useOperationalPages();
const deleteMutation = useDeleteOperationalPage();
const [editingPage, setEditingPage] = useState<OperationalPageRecord | null>(null);
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 [pageToDelete, setPageToDelete] =
useState<OperationalPageRecord | null>(null);
const handleEdit = (page: OperationalPageRecord) => {
setEditingPage(page);
@@ -37,10 +49,10 @@ export const OperationalPageContent = () => {
const handleView = (page: OperationalPageRecord) => {
if (page.custom_domain) {
window.open(`https://${page.custom_domain}`, '_blank');
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');
window.open(`/public/${page.slug}`, "_blank");
}
};
@@ -56,7 +68,7 @@ export const OperationalPageContent = () => {
setDeleteDialogOpen(false);
setPageToDelete(null);
} catch (error) {
console.error('Error deleting page:', error);
console.error("Error deleting page:", error);
}
}
};
@@ -68,7 +80,9 @@ export const OperationalPageContent = () => {
<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>
<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>
@@ -82,16 +96,18 @@ export const OperationalPageContent = () => {
}
return (
<div className="container mx-auto px-4 py-8">
<div className="w-full 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 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>
<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"
@@ -99,7 +115,9 @@ export const OperationalPageContent = () => {
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefetching ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-4 w-4 mr-2 ${isRefetching ? "animate-spin" : ""}`}
/>
Refresh
</Button>
<CreateOperationalPageDialog />
@@ -135,9 +153,12 @@ export const OperationalPageContent = () => {
<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>
<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.
Create your first operational page to start monitoring your
services and communicate status to your users.
</p>
<CreateOperationalPageDialog />
</CardContent>
@@ -167,12 +188,12 @@ export const OperationalPageContent = () => {
<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}
<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}
<span className="font-medium">Operational:</span>{" "}
{pages.filter((p) => p.status === "operational").length}
</div>
</div>
</div>
@@ -191,7 +212,9 @@ export const OperationalPageContent = () => {
<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.
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>
@@ -201,11 +224,11 @@ export const OperationalPageContent = () => {
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
};
@@ -12,7 +12,7 @@ import { PublicStatusPageFooter } from './PublicStatusPageFooter';
export const PublicStatusPage = () => {
const { slug } = useParams<{ slug: string }>();
console.log('PublicStatusPage - slug from params:', slug);
// console.log('PublicStatusPage - slug from params:', slug);
const { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
const [lastUpdated, setLastUpdated] = useState(new Date());
@@ -51,7 +51,7 @@ export const PublicStatusPage = () => {
};
}, [page?.theme]);
console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
// console.log('PublicStatusPage state:', { loading, error, page: !!page, components: components.length, services: services.length });
if (loading) {
return (
@@ -19,61 +19,61 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
useEffect(() => {
const fetchPublicPage = async () => {
if (!slug) {
console.log('No slug provided');
// console.log('No slug provided');
setError('No status page slug provided');
setLoading(false);
return;
}
try {
console.log('Fetching public status page for slug:', slug);
// console.log('Fetching public status page for slug:', slug);
setLoading(true);
setError(null);
// Fetch operational page
console.log('Fetching operational pages...');
// console.log('Fetching operational pages...');
const pages = await operationalPageService.getOperationalPages();
console.log('All pages:', pages);
// console.log('All pages:', pages);
const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
console.log('Found page:', foundPage);
// console.log('Found page:', foundPage);
if (!foundPage) {
console.log('Page not found or not public');
// 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');
// console.log('Page set successfully');
// Fetch components for this page
console.log('Fetching components for page:', foundPage.id);
// console.log('Fetching components for page:', foundPage.id);
const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
console.log('Components found:', pageComponents);
// console.log('Components found:', pageComponents);
setComponents(pageComponents);
// Fetch all services
console.log('Fetching all services...');
// console.log('Fetching all services...');
const allServices = await serviceService.getServices();
console.log('Services found:', allServices);
// console.log('Services found:', allServices);
setServices(allServices);
// Fetch uptime data for each component that has a service
console.log('Fetching uptime data...');
// 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);
// 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');
// 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);
// console.error(`Error fetching uptime for service ${component.service_id}:`, error);
return { serviceId: component.service_id, history: [] };
}
});
@@ -84,12 +84,12 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
uptimeMap[result.serviceId] = result.history;
});
setUptimeData(uptimeMap);
console.log('Uptime data set successfully');
// console.log('Uptime data set successfully');
console.log('All data fetched successfully');
// console.log('All data fetched successfully');
} catch (err) {
console.error('Error fetching public page:', err);
// console.error('Error fetching public page:', err);
setError(`Failed to load status page: ${err}`);
} finally {
setLoading(false);
@@ -114,7 +114,7 @@ export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
textarea.setSelectionRange(0, 99999); // For mobile devices
}
} catch (selectError) {
console.error('Failed to select text:', selectError);
// console.error('Failed to select text:', selectError);
}
}
};
@@ -1,8 +1,13 @@
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 {
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";
@@ -15,24 +20,29 @@ export const RegionalMonitoringContent = () => {
const { toast } = useToast();
const queryClient = useQueryClient();
const { data: regionalServices = [], isLoading, error } = useQuery({
queryKey: ['regional-services'],
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'] });
queryClient.invalidateQueries({ queryKey: ["regional-services"] });
toast({
title: "Regional Agent Added",
description: "The regional monitoring agent has been successfully configured.",
description:
"The regional monitoring agent has been successfully configured.",
});
};
const handleDeleteAgent = async (id: string) => {
try {
await regionalService.deleteRegionalService(id);
queryClient.invalidateQueries({ queryKey: ['regional-services'] });
queryClient.invalidateQueries({ queryKey: ["regional-services"] });
toast({
title: "Agent Removed",
description: "The regional monitoring agent has been removed.",
@@ -46,14 +56,18 @@ export const RegionalMonitoringContent = () => {
}
};
const onlineAgents = regionalServices.filter(agent => agent.connection === 'online').length;
const onlineAgents = regionalServices.filter(
(agent) => agent.connection === "online"
).length;
const totalAgents = regionalServices.length;
return (
<div className="p-6 space-y-6">
<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>
<h1 className="text-3xl font-bold tracking-tight">
Regional Monitoring
</h1>
<p className="text-muted-foreground">
Manage distributed monitoring agents across different regions
</p>
@@ -85,23 +99,25 @@ export const RegionalMonitoringContent = () => {
<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>
<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>
<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>
<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>
@@ -109,7 +125,7 @@ export const RegionalMonitoringContent = () => {
{/* 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) => (
@@ -133,7 +149,8 @@ export const RegionalMonitoringContent = () => {
<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.
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" />
@@ -144,9 +161,9 @@ export const RegionalMonitoringContent = () => {
) : (
<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}
<RegionalAgentCard
key={agent.id}
agent={agent}
onDelete={() => handleDeleteAgent(agent.id)}
/>
))}
@@ -161,4 +178,4 @@ export const RegionalMonitoringContent = () => {
/>
</div>
);
};
};
@@ -1,33 +1,37 @@
import React, { useState, useEffect } from 'react';
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';
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 [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");
// console.log("Initializing maintenance notifications");
initMaintenanceNotifications();
// Clean up when the component unmounts
return () => {
// console.log("Cleaning up maintenance notifications");
// console.log("Cleaning up maintenance notifications");
stopMaintenanceNotifications();
};
}, []);
@@ -43,65 +47,67 @@ export const ScheduleIncidentContent = () => {
const handleMaintenanceCreated = () => {
// Refresh data by incrementing the refresh trigger
const newTriggerValue = refreshTrigger + 1;
// console.log("Maintenance created, refreshing data with new trigger value:", newTriggerValue);
// console.log("Maintenance created, refreshing data with new trigger value:", newTriggerValue);
setRefreshTrigger(newTriggerValue);
// Show success toast
toast({
title: t('success'),
description: t('maintenanceCreatedSuccess'),
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);
// console.log("Incident created, refreshing data with new trigger value:", newTriggerValue);
setIncidentRefreshTrigger(newTriggerValue);
// Show success toast
toast({
title: t('success'),
description: t('incidentCreatedSuccess'),
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">
<main className="flex-1 flex flex-col overflow-auto bg-background dark:bg-black p-2 ">
<div className="flex flex-col gap-6 bg-white dark:bg-black p-3 h-full rounded-xl">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold text-foreground">
{t('scheduleIncidentManagement')}
{t("scheduleIncidentManagement")}
</h2>
<Button
<Button
className="text-primary-foreground"
onClick={handleCreateButtonClick}
>
<Plus className="w-4 h-4 mr-2" />
{activeTab === "maintenance" ? t('createMaintenanceWindow') : t('createIncident')}
<Plus className="w-4 h-4 mr-2" />
{activeTab === "maintenance"
? t("createMaintenanceWindow")
: t("createIncident")}
</Button>
</div>
<Tabs
defaultValue="maintenance"
<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')}
{t("scheduledMaintenance")}
</TabsTrigger>
<TabsTrigger value="incidents">
<AlertCircle className="w-4 h-4 mr-2" />
{t('incidentManagement')}
{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>
@@ -109,14 +115,14 @@ export const ScheduleIncidentContent = () => {
</div>
{/* Maintenance creation dialog */}
<CreateMaintenanceDialog
<CreateMaintenanceDialog
open={createMaintenanceDialogOpen}
onOpenChange={setCreateMaintenanceDialogOpen}
onMaintenanceCreated={handleMaintenanceCreated}
/>
{/* Incident creation dialog */}
<CreateIncidentDialog
<CreateIncidentDialog
open={createIncidentDialogOpen}
onOpenChange={setCreateIncidentDialogOpen}
onIncidentCreated={handleIncidentCreated}
@@ -25,19 +25,20 @@ export const IncidentDetailContent = ({
onClose,
assignedUser
}: IncidentDetailContentProps) => {
// Fetch assigned user details if one wasn't provided and there's an assigned_to field
// Fetch assigned user details if none was provided; prefer server field
const assigneeId = incident?.assigned_users || incident?.assigned_to;
const { data: fetchedUser } = useQuery({
queryKey: ['user', incident?.assigned_to],
queryKey: ['user', assigneeId],
queryFn: async () => {
if (!incident?.assigned_to) return null;
if (!assigneeId) return null;
try {
return await userService.getUser(incident.assigned_to);
return await userService.getUser(assigneeId);
} catch (error) {
console.error("Failed to fetch assigned user:", error);
return null;
}
},
enabled: !!incident?.assigned_to && !assignedUser,
enabled: !!assigneeId && !assignedUser,
staleTime: 300000 // Cache for 5 minutes
});
@@ -17,7 +17,7 @@ export const IncidentDetailDialog = ({
onOpenChange,
incident
}: IncidentDetailDialogProps) => {
// Fetch user data for assigned_to field
// Fetch user data for assigned field (prefer assigned_users, fallback to assigned_to)
const { data: users = [] } = useQuery({
queryKey: ['users'],
queryFn: async () => {
@@ -25,12 +25,12 @@ export const IncidentDetailDialog = ({
return Array.isArray(usersList) ? usersList : [];
},
staleTime: 300000, // Cache for 5 minutes
enabled: !!incident?.assigned_to && open // Only run query if there's an assigned_to value and dialog is open
enabled: !!(incident?.assigned_users || incident?.assigned_to) && open // Only run query if there's an assigned value and dialog is open
});
// Find the assigned user
const assignedUser = incident?.assigned_to
? users.find(user => user.id === incident.assigned_to)
// Find the assigned user (prefer assigned_users, fallback to assigned_to)
const assignedUser = (incident?.assigned_users || incident?.assigned_to)
? users.find(user => user.id === (incident?.assigned_users || incident?.assigned_to))
: null;
if (!incident) return null;
@@ -23,19 +23,19 @@ export {
// Legacy component - keeping this for backward compatibility with other imports
export const IncidentDetailSections = ({ incident }: { incident: IncidentItem | null }) => {
// Fetch assigned user details if there's an assigned_to field
// Fetch assigned user details if there's an assigned field (prefer assigned_users, fallback to assigned_to)
const { data: assignedUser } = useQuery({
queryKey: ['user', incident?.assigned_to],
queryKey: ['user', incident?.assigned_users || incident?.assigned_to],
queryFn: async () => {
if (!incident?.assigned_to) return null;
if (!(incident?.assigned_users || incident?.assigned_to)) return null;
try {
return await userService.getUser(incident.assigned_to);
return await userService.getUser(incident?.assigned_users || incident?.assigned_to);
} catch (error) {
console.error("Failed to fetch assigned user:", error);
return null;
}
},
enabled: !!incident?.assigned_to,
enabled: !!(incident?.assigned_users || incident?.assigned_to),
staleTime: 300000 // Cache for 5 minutes
});
@@ -31,8 +31,8 @@ export const AssignmentSection: React.FC<AssignmentSectionProps> = ({ incident,
</Avatar>
<span>{assignedUser.full_name || assignedUser.username}</span>
</div>
) : incident.assigned_to ? (
<span>{incident.assigned_to}</span>
) : (incident.assigned_users || incident.assigned_to) ? (
<span>{incident.assigned_users || incident.assigned_to}</span>
) : (
<span className="text-muted-foreground italic">{t('unassigned')}</span>
)}
@@ -50,8 +50,8 @@ export const BasicInfoSection: React.FC<BasicInfoSectionProps> = ({ incident, as
</Avatar>
<span>{assignedUser.full_name || assignedUser.username}</span>
</div>
) : incident.assigned_to ? (
<span>{incident.assigned_to}</span>
) : (incident.assigned_users || incident.assigned_to) ? (
<span>{incident.assigned_users || incident.assigned_to}</span>
) : (
<span className="text-muted-foreground italic">{t('unassigned')}</span>
)}
@@ -68,7 +68,7 @@ export const IncidentBasicFields: React.FC = () => {
const selectedUser = users.find(user => user.id === form.getValues('assigned_to'));
// Function to get user initials from name
const getUserInitials = (user: any): string => {
const getUserInitials = (user: { full_name?: string; username: string }): string => {
if (user.full_name) {
const nameParts = user.full_name.split(' ');
if (nameParts.length > 1) {
@@ -25,7 +25,7 @@ export const useIncidentEditForm = (
impact: (incident.impact?.toLowerCase() || 'minor') as any,
priority: (incident.priority?.toLowerCase() || 'medium') as any,
service_id: incident.service_id || '',
assigned_to: incident.assigned_to || '',
assigned_to: incident.assigned_users || incident.assigned_to || '',
root_cause: incident.root_cause || '',
resolution_steps: incident.resolution_steps || '',
lessons_learned: incident.lessons_learned || '',
@@ -45,11 +45,14 @@ export const useIncidentEditForm = (
impact: data.impact,
priority: data.priority,
service_id: data.service_id,
assigned_to: data.assigned_to, // This is the user ID from the form
...(data.assigned_to
? { assigned_to: data.assigned_to, assigned_users: data.assigned_to }
: {}),
root_cause: data.root_cause,
resolution_steps: data.resolution_steps,
lessons_learned: data.lessons_learned,
});
toast({
title: t('incidentUpdated'),
@@ -57,6 +57,7 @@ export const useIncidentForm = (onSuccess: () => void, onClose: () => void) => {
priority: data.priority,
service_id: data.service_id,
assigned_to: data.assigned_to,
assigned_users: data.assigned_to, // map to server field
root_cause: data.root_cause,
resolution_steps: data.resolution_steps,
lessons_learned: data.lessons_learned,
@@ -86,7 +86,7 @@ export const IncidentTableRow = memo(({
</Badge>
</TableCell>
<TableCell>
<AssignedUserCell userId={localItem.assigned_to} />
<AssignedUserCell userId={localItem.assigned_users || localItem.assigned_to} />
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end items-center space-x-2">
@@ -14,6 +14,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getCurrentEndpoint } from "@/lib/pocketbase";
import { ServerAgentConfigForm } from "./ServerAgentConfigForm";
import { OneClickInstallTab } from "./OneClickInstallTab";
import { DockerOneClickTab } from "./DockerOneClickTab";
import { ManualInstallTab } from "./ManualInstallTab";
interface AddServerAgentDialogProps {
@@ -123,9 +124,10 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="configure">Configure Agent</TabsTrigger>
<TabsTrigger value="one-click">One-Click Install</TabsTrigger>
<TabsTrigger value="docker-one-click">Docker One-Click</TabsTrigger>
<TabsTrigger value="manual">Manual Installation</TabsTrigger>
</TabsList>
@@ -150,6 +152,16 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
onDialogClose={handleDialogClose}
/>
</TabsContent>
<TabsContent value="docker-one-click" className="space-y-6">
<DockerOneClickTab
serverToken={serverToken}
currentPocketBaseUrl={currentPocketBaseUrl}
formData={formData}
serverId={serverId}
onDialogClose={handleDialogClose}
/>
</TabsContent>
<TabsContent value="manual" className="space-y-6">
<ManualInstallTab
@@ -0,0 +1,176 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Copy, Download, Container } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils";
interface DockerOneClickTabProps {
serverToken: string;
currentPocketBaseUrl: string;
formData: {
serverName: string;
osType: string;
checkInterval: string;
retryAttempt: string;
};
serverId: string;
onDialogClose: () => void;
}
export const DockerOneClickTab: React.FC<DockerOneClickTabProps> = ({
serverToken,
currentPocketBaseUrl,
formData,
serverId,
onDialogClose,
}) => {
const getDockerOneClickCommand = () => {
const scriptUrl = "https://cdn.checkcle.io/scripts/server-docker-agent.sh";
return `curl -L -o server-docker-agent.sh "${scriptUrl}"
chmod +x server-docker-agent.sh
SERVER_TOKEN="${serverToken}" \\
POCKETBASE_URL="${currentPocketBaseUrl}" \\
SERVER_NAME="${formData.serverName}" \\
AGENT_ID="${serverId}" \\
sudo -E bash ./server-docker-agent.sh`;
};
const getDirectDockerCommand = () => {
return `docker run -d \\
--name monitoring-agent \\
--restart unless-stopped \\
-p 8081:8081 \\
--group-add 999 \\
-e AGENT_ID="${serverId}" \\
-e SERVER_NAME="${formData.serverName}" \\
-e SERVER_TOKEN="${serverToken}" \\
-e POCKETBASE_URL="${currentPocketBaseUrl}" \\
-e POCKETBASE_ENABLED=true \\
-v /proc:/host/proc:ro \\
-v /etc:/host/etc:ro \\
-v /sys:/host/sys:ro \\
-v /:/host/root:ro \\
-v /var/run:/host/var/run:ro \\
-v /dev:/host/dev:ro \\
-v /var/run/docker.sock:/var/run/docker.sock:ro \\
-v monitoring_data:/var/lib/monitoring-agent \\
-v monitoring_logs:/var/log/monitoring-agent \\
operacle/checkcle-server-agent:latest`;
};
const handleCopyOneClickCommand = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// console.log('Copy one-click command button clicked');
const command = getDockerOneClickCommand();
// console.log('Copying one-click command:', command);
await copyToClipboard(command);
};
const handleCopyDockerCommand = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// console.log('Copy docker command button clicked');
const command = getDirectDockerCommand();
// console.log('Copying docker command:', command);
await copyToClipboard(command);
};
return (
<div className="space-y-6">
{/* One-Click Docker Installation */}
<Card className="border-blue-500/20 bg-blue-50/50 dark:bg-blue-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-700 dark:text-blue-400">
<Container className="h-5 w-5" />
Docker One-Click Install
</CardTitle>
<CardDescription className="text-blue-600 dark:text-blue-300">
Automated Docker container installation with system monitoring capabilities
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-blue-700 dark:text-blue-400">Docker One-Click Command</Label>
<div className="relative">
<pre className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-blue-800 dark:text-blue-200">
<code>{getDockerOneClickCommand()}</code>
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="absolute top-2 right-2 bg-blue-50 dark:bg-blue-950/50 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-400"
onClick={handleCopyOneClickCommand}
>
<Copy className="h-4 w-4 mr-1" />
Copy
</Button>
</div>
</div>
<div className="text-sm text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 p-3 rounded-md">
<p className="font-medium mb-1">This script will automatically:</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>Download and setup the Docker monitoring agent</li>
<li>Configure all required environment variables</li>
<li>Start the container with proper system access</li>
<li>Setup monitoring data persistence</li>
</ol>
</div>
</CardContent>
</Card>
{/* Direct Docker Run Command */}
<Card className="border-gray-500/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Container className="h-5 w-5" />
Direct Docker Run Command
</CardTitle>
<CardDescription>
If you prefer to run the Docker container directly without the script
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Docker Run Command</Label>
<div className="relative">
<pre className="bg-muted border p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all">
<code>{getDirectDockerCommand()}</code>
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="absolute top-2 right-2"
onClick={handleCopyDockerCommand}
>
<Copy className="h-4 w-4 mr-1" />
Copy
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground bg-muted/50 border p-3 rounded-md">
<p className="font-medium mb-1">Prerequisites for direct Docker run:</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>Docker must be installed and running</li>
<li>The operacle/checkcle-server-agent image must be available</li>
<li>Run as root or with sudo privileges</li>
</ol>
</div>
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button onClick={onDialogClose}>
Done
</Button>
</div>
</div>
);
};
@@ -12,7 +12,7 @@ import { pb } from "@/lib/pocketbase";
import { Server } from "@/types/server.types";
import { RefreshCw, X } from "lucide-react";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { templateService, NotificationTemplate } from "@/services/templateService";
import { templateService, ServerNotificationTemplate } from "@/services/templateService";
import { serverThresholdService, ServerThreshold } from "@/services/serverThresholdService";
interface EditServerDialogProps {
@@ -25,7 +25,7 @@ interface EditServerDialogProps {
interface ServerFormData {
name: string;
check_interval: number;
retry_attempts: number;
max_retries: number;
docker_monitoring: boolean;
notification_enabled: boolean;
notification_channels: string[]; // Changed to array for multiple selections
@@ -49,7 +49,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
const [formData, setFormData] = useState<ServerFormData>({
name: "",
check_interval: 60,
retry_attempts: 3,
max_retries: 3,
docker_monitoring: false,
notification_enabled: false,
notification_channels: [], // Changed to array
@@ -66,9 +66,9 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
const [templates, setTemplates] = useState<ServerNotificationTemplate[]>([]);
const [thresholds, setThresholds] = useState<ServerThreshold[]>([]);
const [selectedTemplate, setSelectedTemplate] = useState<NotificationTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<ServerNotificationTemplate | null>(null);
const [selectedThreshold, setSelectedThreshold] = useState<ServerThreshold | null>(null);
const [loadingAlertConfigs, setLoadingAlertConfigs] = useState(false);
const [loadingTemplates, setLoadingTemplates] = useState(false);
@@ -78,7 +78,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
// Initialize form data when server changes
useEffect(() => {
if (server) {
// console.log("Setting form data for server:", server);
// Parse comma-separated notification_id into array
const notificationChannels = server.notification_id
? server.notification_id.split(',').map(id => id.trim()).filter(id => id)
@@ -87,9 +86,9 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
setFormData({
name: server.name || "",
check_interval: server.check_interval || 60,
retry_attempts: 3,
max_retries: server.max_retries || 3,
docker_monitoring: server.docker === "true",
notification_enabled: notificationChannels.length > 0,
notification_enabled: server.notification_status === true,
notification_channels: notificationChannels,
threshold_id: server.threshold_id || "none",
template_id: server.template_id || "none",
@@ -109,10 +108,8 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
// Load existing threshold data when thresholds are loaded and we have a server with threshold_id
useEffect(() => {
if (server && server.threshold_id && thresholds.length > 0) {
// console.log("Loading existing threshold data for server:", server.threshold_id);
const existingThreshold = thresholds.find(t => t.id === server.threshold_id);
if (existingThreshold) {
// console.log("Found existing threshold:", existingThreshold);
setSelectedThreshold(existingThreshold);
// Handle the API response format with proper field names and type conversion
setThresholdFormData({
@@ -166,7 +163,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
const configs = await alertConfigService.getAlertConfigurations();
setAlertConfigs(configs);
} catch (error) {
// console.error('Error loading alert configurations:', error);
toast({
variant: "destructive",
title: "Error",
@@ -180,10 +176,10 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
const loadTemplates = async () => {
try {
setLoadingTemplates(true);
const templateList = await templateService.getTemplates();
setTemplates(templateList);
const templateList = await templateService.getTemplates('server');
// Cast to ServerNotificationTemplate[] since we know we're getting server templates
setTemplates(templateList as ServerNotificationTemplate[]);
} catch (error) {
// console.error('Error loading templates:', error);
toast({
variant: "destructive",
title: "Error",
@@ -200,7 +196,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
const thresholdList = await serverThresholdService.getServerThresholds();
setThresholds(thresholdList);
} catch (error) {
// console.error('Error loading server thresholds:', error);
toast({
variant: "destructive",
title: "Error",
@@ -211,47 +206,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
}
};
const handleThresholdUpdate = async () => {
if (!selectedThreshold) return;
try {
// Use the correct field name for RAM threshold
const updateData = {
cpu_threshold: thresholdFormData.cpu_threshold,
ram_threshold_message: thresholdFormData.ram_threshold, // Use the correct field name
disk_threshold: thresholdFormData.disk_threshold,
network_threshold: thresholdFormData.network_threshold,
};
await serverThresholdService.updateServerThreshold(selectedThreshold.id, updateData);
// Update local state
setSelectedThreshold({
...selectedThreshold,
...thresholdFormData,
});
// Update thresholds list
setThresholds(prev => prev.map(t =>
t.id === selectedThreshold.id
? { ...t, ...thresholdFormData }
: t
));
toast({
title: "Threshold updated",
description: "Server threshold values have been updated successfully.",
});
} catch (error) {
// console.error('Error updating threshold:', error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to update threshold values.",
});
}
};
const handleNotificationChannelToggle = (channelId: string, checked: boolean) => {
setFormData(prev => ({
...prev,
@@ -276,22 +230,56 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
setIsSubmitting(true);
// Convert notification channels array to comma-separated string
const notificationChannelsString = formData.notification_enabled
? formData.notification_channels.join(',')
: "";
const notificationChannelsString = formData.notification_channels.join(',');
const updateData = {
name: formData.name,
check_interval: formData.check_interval,
max_retries: formData.max_retries,
docker: formData.docker_monitoring ? "true" : "false",
notification_status: formData.notification_enabled,
notification_id: notificationChannelsString,
threshold_id: formData.notification_enabled && formData.threshold_id !== "none" ? formData.threshold_id : "",
template_id: formData.notification_enabled && formData.template_id !== "none" ? formData.template_id : "",
threshold_id: formData.threshold_id !== "none" ? formData.threshold_id : "",
template_id: formData.template_id !== "none" ? formData.template_id : "",
updated: new Date().toISOString(),
};
await pb.collection('servers').update(server.id, updateData);
// Update threshold if a threshold is selected and values have been modified
if (selectedThreshold && formData.threshold_id !== "none") {
try {
const updateThresholdData = {
cpu_threshold: thresholdFormData.cpu_threshold,
ram_threshold_message: thresholdFormData.ram_threshold,
disk_threshold: thresholdFormData.disk_threshold,
network_threshold: thresholdFormData.network_threshold,
};
await serverThresholdService.updateServerThreshold(selectedThreshold.id, updateThresholdData);
// Update local state
setSelectedThreshold({
...selectedThreshold,
...thresholdFormData,
});
// Update thresholds list
setThresholds(prev => prev.map(t =>
t.id === selectedThreshold.id
? { ...t, ...thresholdFormData }
: t
));
} catch (error) {
toast({
variant: "destructive",
title: "Warning",
description: "Server updated but failed to update threshold values.",
});
}
}
toast({
title: "Server updated",
description: `${formData.name} has been updated successfully.`,
@@ -301,7 +289,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
onOpenChange(false);
} catch (error) {
// console.error('Error updating server:', error);
toast({
variant: "destructive",
title: "Error",
@@ -321,9 +308,9 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
setFormData({
name: server.name || "",
check_interval: server.check_interval || 60,
retry_attempts: 3,
max_retries: server.max_retries || 3,
docker_monitoring: server.docker === "true",
notification_enabled: notificationChannels.length > 0,
notification_enabled: server.notification_status === true,
notification_channels: notificationChannels,
threshold_id: server.threshold_id || "none",
template_id: server.template_id || "none",
@@ -372,20 +359,20 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
</div>
<div className="space-y-2">
<Label htmlFor="retryAttempts">Retry Attempts</Label>
<Label htmlFor="maxRetries">Max Retries</Label>
<Select
value={formData.retry_attempts.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, retry_attempts: parseInt(value) }))}
value={formData.max_retries.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, max_retries: parseInt(value) }))}
>
<SelectTrigger>
<SelectValue placeholder="Select retry attempts" />
<SelectValue placeholder="Select max retries" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 attempt</SelectItem>
<SelectItem value="2">2 attempts</SelectItem>
<SelectItem value="3">3 attempts</SelectItem>
<SelectItem value="5">5 attempts</SelectItem>
<SelectItem value="10">10 attempts</SelectItem>
<SelectItem value="1">1 retry</SelectItem>
<SelectItem value="2">2 retries</SelectItem>
<SelectItem value="3">3 retries</SelectItem>
<SelectItem value="5">5 retries</SelectItem>
<SelectItem value="10">10 retries</SelectItem>
</SelectContent>
</Select>
</div>
@@ -416,10 +403,8 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
checked={formData.notification_enabled}
onCheckedChange={(checked) => setFormData(prev => ({
...prev,
notification_enabled: checked,
notification_channels: checked ? prev.notification_channels : [],
threshold_id: checked ? prev.threshold_id : "none",
template_id: checked ? prev.template_id : "none"
notification_enabled: checked
// Remove the automatic clearing of notification_channels, threshold_id, and template_id
}))}
/>
<Label htmlFor="notificationEnabled">Enable Notifications</Label>
@@ -516,16 +501,8 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
{/* Editable Threshold Details */}
{selectedThreshold && (
<Card className="bg-muted/50">
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader>
<CardTitle className="text-base">Threshold Details: {selectedThreshold.name}</CardTitle>
<Button
type="button"
onClick={handleThresholdUpdate}
size="sm"
variant="outline"
>
Update Thresholds
</Button>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
@@ -621,27 +598,39 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
<CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 text-sm">
<div>
<Label className="text-xs font-medium text-muted-foreground">RAM Threshold Message</Label>
<Label className="text-xs font-medium text-muted-foreground">RAM Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.up_message || "No RAM threshold message defined"}
{selectedTemplate.ram_message || "No RAM message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">CPU Threshold Message</Label>
<Label className="text-xs font-medium text-muted-foreground">CPU Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.down_message || "No CPU threshold message defined"}
{selectedTemplate.cpu_message || "No CPU message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Disk Threshold Message</Label>
<Label className="text-xs font-medium text-muted-foreground">Disk Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.incident_message || "No disk threshold message defined"}
{selectedTemplate.disk_message || "No disk message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Network Threshold Message</Label>
<Label className="text-xs font-medium text-muted-foreground">Network Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.maintenance_message || "No network threshold message defined"}
{selectedTemplate.network_message || "No network message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Up Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.up_message || "No up message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Down Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.down_message || "No down message defined"}
</p>
</div>
</div>
@@ -677,4 +666,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
</DialogContent>
</Dialog>
);
};
};
export default EditServerDialog;
@@ -25,7 +25,7 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
onDialogClose,
}) => {
const getManualInstallSteps = () => {
const scriptUrl = "https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/server-agent.sh";
const scriptUrl = "https://cdn.checkcle.io/scripts/server-agent.sh";
return [
{
@@ -27,7 +27,7 @@ export const OneClickInstallTab: React.FC<OneClickInstallTabProps> = ({
onDialogClose,
}) => {
const getOneClickInstallCommand = () => {
const scriptUrl = "https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/server-agent.sh";
const scriptUrl = "https://cdn.checkcle.io/scripts/server-agent.sh";
return `curl -L -o server-agent.sh "${scriptUrl}"
chmod +x server-agent.sh
@@ -55,7 +55,7 @@ sudo -E bash ./server-agent.sh`;
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400">
<Download className="h-5 w-5" />
One-Click Install
One-Click Install (Recommended)
</CardTitle>
<CardDescription className="text-green-600 dark:text-green-300">
Copy and paste this single command to install the monitoring agent instantly
+269 -105
View File
@@ -1,13 +1,45 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { RefreshCw, Search, Eye, Activity, MoreHorizontal, Pause, Play, Edit, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
RefreshCw,
Search,
Eye,
Activity,
MoreHorizontal,
Pause,
Play,
Edit,
Trash2,
} from "lucide-react";
import { Server } from "@/types/server.types";
import { ServerStatusBadge } from "./ServerStatusBadge";
import { OSTypeIcon } from "./OSTypeIcon";
@@ -23,7 +55,11 @@ interface ServerTableProps {
onRefresh: () => void;
}
export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => {
export const ServerTable = ({
servers,
isLoading,
onRefresh,
}: ServerTableProps) => {
const { theme } = useTheme();
const [searchTerm, setSearchTerm] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -34,10 +70,11 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
const navigate = useNavigate();
const { toast } = useToast();
const filteredServers = servers.filter(server =>
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
server.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
server.ip_address.toLowerCase().includes(searchTerm.toLowerCase())
const filteredServers = servers.filter(
(server) =>
server.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
server.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
server.ip_address.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleViewDetails = (serverId: string) => {
@@ -51,41 +88,44 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
const handlePauseResume = async (server: Server) => {
const serverId = server.id;
const isPaused = server.status === "paused";
if (pausingServers.has(serverId)) {
return; // Already processing this server
}
try {
setPausingServers(prev => new Set(prev).add(serverId));
setPausingServers((prev) => new Set(prev).add(serverId));
// Only update the status field, preserving all other server configuration
const updateData = {
status: isPaused ? "up" : "paused",
last_checked: new Date().toISOString()
last_checked: new Date().toISOString(),
};
await pb.collection('servers').update(serverId, updateData);
await pb.collection("servers").update(serverId, updateData);
toast({
title: isPaused ? "Server resumed" : "Server paused",
description: `Monitoring ${isPaused ? 'resumed' : 'paused'} for ${server.name}`,
description: `Monitoring ${isPaused ? "resumed" : "paused"} for ${
server.name
}`,
});
// console.log(`${isPaused ? 'Resume' : 'Pause'} server monitoring: ${serverId}`);
// console.log(`${isPaused ? 'Resume' : 'Pause'} server monitoring: ${serverId}`);
// Refresh the server list to show updated status
onRefresh();
} catch (error) {
// console.error('Error updating server status:', error);
// console.error('Error updating server status:', error);
toast({
variant: "destructive",
title: "Error",
description: `Failed to ${isPaused ? 'resume' : 'pause'} server monitoring. Please try again.`,
description: `Failed to ${
isPaused ? "resume" : "pause"
} server monitoring. Please try again.`,
});
} finally {
setPausingServers(prev => {
setPausingServers((prev) => {
const newSet = new Set(prev);
newSet.delete(serverId);
return newSet;
@@ -108,24 +148,23 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
try {
setIsDeleting(true);
// Delete the server from the database
await pb.collection('servers').delete(selectedServer.id);
await pb.collection("servers").delete(selectedServer.id);
toast({
title: "Server deleted",
description: `${selectedServer.name} has been deleted successfully.`,
});
// Refresh the server list
onRefresh();
// Close the dialog
setDeleteDialogOpen(false);
setSelectedServer(null);
} catch (error) {
// console.error('Error deleting server:', error);
// console.error('Error deleting server:', error);
toast({
variant: "destructive",
title: "Error",
@@ -136,65 +175,65 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
}
};
const CustomProgressBar = ({
value,
label,
subtitle,
type
}: {
value: number;
label: string;
subtitle: string;
type: 'cpu' | 'memory' | 'disk'
const CustomProgressBar = ({
value,
label,
subtitle,
type,
}: {
value: number;
label: string;
subtitle: string;
type: "cpu" | "memory" | "disk";
}) => {
const getGradientColors = (type: string, value: number) => {
if (type === 'cpu') {
if (value > 90) return 'from-red-500 to-red-600';
if (value > 75) return 'from-orange-500 to-orange-600';
if (value > 60) return 'from-yellow-500 to-yellow-600';
return 'from-green-500 to-green-600';
if (type === "cpu") {
if (value > 90) return "from-red-500 to-red-600";
if (value > 75) return "from-orange-500 to-orange-600";
if (value > 60) return "from-yellow-500 to-yellow-600";
return "from-green-500 to-green-600";
}
if (type === 'memory') {
if (value > 90) return 'from-red-500 to-red-600';
if (value > 75) return 'from-yellow-500 to-yellow-600';
return 'from-blue-500 to-blue-600';
if (type === "memory") {
if (value > 90) return "from-red-500 to-red-600";
if (value > 75) return "from-yellow-500 to-yellow-600";
return "from-blue-500 to-blue-600";
}
if (type === 'disk') {
if (value > 95) return 'from-red-500 to-red-600';
if (value > 85) return 'from-yellow-500 to-yellow-600';
return 'from-orange-500 to-orange-600';
if (type === "disk") {
if (value > 95) return "from-red-500 to-red-600";
if (value > 85) return "from-yellow-500 to-yellow-600";
return "from-orange-500 to-orange-600";
}
return 'from-gray-500 to-gray-600';
return "from-gray-500 to-gray-600";
};
const getTextColor = (value: number) => {
if (value > 90) return 'text-red-600 dark:text-red-400';
if (value > 75) return 'text-orange-600 dark:text-orange-400';
if (value > 60) return 'text-yellow-600 dark:text-yellow-400';
return 'text-green-600 dark:text-green-400';
if (value > 90) return "text-red-600 dark:text-red-400";
if (value > 75) return "text-orange-600 dark:text-orange-400";
if (value > 60) return "text-yellow-600 dark:text-yellow-400";
return "text-green-600 dark:text-green-400";
};
return (
<div className="space-y-2 min-w-[120px]">
<div className="space-y-2 min-w-[120px] -mt-20">
<div className="flex justify-between items-center">
<span className={`text-sm font-semibold ${getTextColor(value)}`}>
{label}
</span>
<span className="text-xs text-muted-foreground">
{subtitle}
</span>
<span className="text-xs text-muted-foreground">{subtitle} </span>
</div>
<div className="relative">
<div className="w-full h-3 bg-muted/30 rounded-full overflow-hidden shadow-inner">
<div
className={`h-full bg-gradient-to-r ${getGradientColors(type, value)} rounded-full transition-all duration-700 ease-out relative overflow-hidden`}
<div
className={`h-full bg-gradient-to-r ${getGradientColors(
type,
value
)} rounded-full transition-all duration-700 ease-out relative overflow-hidden`}
style={{ width: `${Math.min(value, 100)}%` }}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse opacity-60" />
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-white/10" />
</div>
</div>
</div>
</div>
);
@@ -202,7 +241,7 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
if (isLoading) {
return (
<Card className="flex-1 flex flex-col">
<Card className="flex-1 flex flex-col ">
<CardHeader className="flex-shrink-0">
<CardTitle>Servers</CardTitle>
</CardHeader>
@@ -244,32 +283,116 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<p className="text-muted-foreground">No servers found</p>
</div>
) : (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
<div
className={`${
theme === "dark" ? "bg-gray-900" : "bg-white"
} rounded-lg border border-border shadow-sm`}
>
<Table>
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Name</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Status</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>OS</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>IP Address</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>CPU</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Memory</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Disk</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Uptime</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Last Checked</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right`}>Actions</TableHead>
<TableHeader
className={`${
theme === "dark" ? "bg-gray-800" : "bg-gray-50"
}`}
>
<TableRow
className={`${
theme === "dark"
? "border-gray-700 hover:bg-gray-800"
: "border-gray-200 hover:bg-gray-100"
}`}
>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4`}
>
Name
</TableHead>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4`}
>
Status
</TableHead>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4`}
>
OS
</TableHead>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4`}
>
IP Address
</TableHead>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4`}
>
CPU
</TableHead>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4`}
>
Memory
</TableHead>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4`}
>
Disk
</TableHead>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4`}
>
Uptime
</TableHead>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4`}
>
Last Checked
</TableHead>
<TableHead
className={`${
theme === "dark" ? "text-gray-300" : "text-gray-700"
} font-medium text-base py-4 text-right`}
>
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredServers.map((server) => {
const cpuUsage = server.cpu_usage || 0;
const memoryUsage = server.ram_total > 0 ? (server.ram_used / server.ram_total) * 100 : 0;
const diskUsage = server.disk_total > 0 ? (server.disk_used / server.disk_total) * 100 : 0;
const memoryUsage =
server.ram_total > 0
? (server.ram_used / server.ram_total) * 100
: 0;
const diskUsage =
server.disk_total > 0
? (server.disk_used / server.disk_total) * 100
: 0;
const isPaused = server.status === "paused";
const isProcessing = pausingServers.has(server.id);
return (
<TableRow key={server.id} className="hover:bg-muted/50">
<TableRow
key={server.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleViewDetails(server.id)}
>
<TableCell className="font-medium">
<div className="truncate" title={server.name}>
{server.name}
@@ -281,7 +404,10 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<TableCell>
<div className="flex items-center gap-2">
<OSTypeIcon osType={server.os_type} />
<span className="text-sm truncate" title={server.os_type}>
<span
className="text-sm truncate"
title={server.os_type}
>
{server.os_type}
</span>
</div>
@@ -303,7 +429,9 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<CustomProgressBar
value={memoryUsage}
label={`${memoryUsage.toFixed(1)}%`}
subtitle={serverService.formatBytes(server.ram_total)}
subtitle={serverService.formatBytes(
server.ram_total
)}
type="memory"
/>
</TableCell>
@@ -311,12 +439,17 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<CustomProgressBar
value={diskUsage}
label={`${diskUsage.toFixed(1)}%`}
subtitle={serverService.formatBytes(server.disk_total)}
subtitle={serverService.formatBytes(
server.disk_total
)}
type="disk"
/>
</TableCell>
<TableCell>
<div className="text-sm truncate" title={server.uptime}>
<div
className="text-sm truncate"
title={server.uptime}
>
{server.uptime}
</div>
</TableCell>
@@ -325,10 +458,17 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
{new Date(server.last_checked).toLocaleString()}
</div>
</TableCell>
<TableCell className="text-right">
<TableCell
className="text-right"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0" disabled={isProcessing}>
<Button
variant="ghost"
className="h-8 w-8 p-0"
disabled={isProcessing}
>
<span className="sr-only">Open menu</span>
{isProcessing ? (
<RefreshCw className="h-4 w-4 animate-spin" />
@@ -337,20 +477,36 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem onClick={() => handleViewDetails(server.id)}>
<DropdownMenuContent
align="end"
className="w-[200px]"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleViewDetails(server.id);
}}
>
<Eye className="mr-2 h-4 w-4" />
View Server Detail
</DropdownMenuItem>
{server.docker === 'true' && (
<DropdownMenuItem onClick={() => handleViewContainers(server.id)}>
{server.docker === "true" && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleViewContainers(server.id);
}}
>
<Activity className="mr-2 h-4 w-4" />
Container Monitoring
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handlePauseResume(server)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlePauseResume(server);
}}
disabled={isProcessing}
>
{isPaused ? (
@@ -366,12 +522,20 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleEdit(server)}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleEdit(server);
}}
>
<Edit className="mr-2 h-4 w-4" />
Edit Server
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(server)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleDelete(server);
}}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
@@ -402,19 +566,19 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this server?</AlertDialogTitle>
<AlertDialogTitle>
Are you sure you want to delete this server?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete{' '}
This action cannot be undone. This will permanently delete{" "}
<span className="font-semibold text-foreground">
{selectedServer?.name}
</span>{' '}
</span>{" "}
and all of its monitoring data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
@@ -427,4 +591,4 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
</AlertDialog>
</>
);
};
};
@@ -8,6 +8,8 @@ import {
} from "@/components/ui/dialog";
import { ServiceForm } from "./ServiceForm";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useQueryClient } from "@tanstack/react-query";
import { useLanguage } from "@/contexts/LanguageContext";
interface AddServiceDialogProps {
open: boolean;
@@ -15,7 +17,12 @@ interface AddServiceDialogProps {
}
export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) {
const handleSuccess = () => {
const { t } = useLanguage();
const queryClient = useQueryClient();
const handleSuccess = async () => {
// Immediately invalidate and refetch services data
await queryClient.invalidateQueries({ queryKey: ["services"] });
await queryClient.refetchQueries({ queryKey: ["services"] });
onOpenChange(false);
};
@@ -27,9 +34,9 @@ export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps)
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl">Create New Service</DialogTitle>
<DialogTitle className="text-xl">{t("createNewService")}</DialogTitle>
<DialogDescription>
Fill in the details to create a new service to monitor.
{t("createNewServiceDesc")}
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
@@ -2,6 +2,7 @@
import React from "react";
import { Clock, TimerOff } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useLanguage } from "@/contexts/LanguageContext";
interface LastCheckedTimeProps {
lastCheckedTime: string;
@@ -10,6 +11,7 @@ interface LastCheckedTimeProps {
}
export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => {
const { t } = useLanguage();
// Format the time without seconds to display a static time
const formatTimeWithoutSeconds = (timeString: string) => {
try {
@@ -55,7 +57,7 @@ export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheck
<Clock className="h-4 w-4" />
)}
<span>
{isPaused ? "Paused at " : ""}
{isPaused ? t("pausedAt") : ""}
{formattedTime}
</span>
</div>
@@ -66,14 +68,14 @@ export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheck
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">
{isPaused ? "Monitoring Paused" : "Last Check Details"}
{isPaused ? t("monitoringPaused") : t("lastCheckDetails")}
</div>
<div>
{isPaused ? "No automatic checks" : `Checked at ${formattedTime}`}
{isPaused ? t("noAutomaticChecks") : t("checkedAt") + `${formattedTime}`}
</div>
{interval && !isPaused && (
<div>
Check interval: {formatInterval(interval)}
{t("checkInterval")}: {formatInterval(interval)}
</div>
)}
<div className="text-gray-400 text-[10px]">
@@ -1,5 +1,7 @@
import {useLanguage} from "@/contexts/LanguageContext.tsx";
export function LoadingState() {
const { t } = useLanguage();
return (
<div className="flex items-center justify-center h-screen bg-background text-foreground">
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
@@ -7,8 +9,8 @@ export function LoadingState() {
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
<h3 className="text-xl font-medium mb-1">Loading server data</h3>
<p className="text-muted-foreground">Please wait while we retrieve your information...</p>
<h3 className="text-xl font-medium mb-1">{t("loadingServerData")}</h3>
<p className="text-muted-foreground">{t("retrievingYourInformation")}</p>
</div>
</div>
);
@@ -125,7 +125,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
} else {
// Fetch regional agent specific data
const [regionName, agentId] = currentAgent.split("|");
console.log(`Fetching regional agent data for region: ${regionName}, agent: ${agentId} from ${service.type} collection`);
// console.log(`Fetching regional agent data for region: ${regionName}, agent: ${agentId} from ${service.type} collection`);
history = await uptimeService.getUptimeHistoryByRegionalAgent(serviceId, limit, start, end, service.type, regionName, agentId);
// console.log(`Retrieved ${history.length} regional monitoring records`);
}
@@ -11,6 +11,7 @@ import { Service } from "@/types/service.types";
import { useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServiceEditDialogProps {
open: boolean;
@@ -19,6 +20,7 @@ interface ServiceEditDialogProps {
}
export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) {
const { t } = useLanguage();
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -53,9 +55,9 @@ export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDi
}}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl">Edit Service</DialogTitle>
<DialogTitle className="text-xl">{t("editService")}</DialogTitle>
<DialogDescription>
Update the details of your monitored service.
{t("editServiceDesc")}
</DialogDescription>
</DialogHeader>
{open && service && (
@@ -5,7 +5,6 @@ import { Play, Pause } from "lucide-react";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast";
import { notificationService } from "@/services/notificationService";
interface ServiceMonitoringButtonProps {
service: Service;
@@ -34,16 +33,8 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
if (onStatusChange) onStatusChange("paused");
// Send notification for paused status (only here, not in pauseMonitoring.ts)
if (service.alerts !== "muted") {
// console.log("Sending pause notification from UI component");
// IMPORTANT: Direct call to the notification service to ensure a message is sent
await notificationService.sendNotification({
service: service,
status: "paused",
timestamp: new Date().toISOString(),
});
}
// Notification handling removed - will be handled by backend
// console.log("Service paused - notifications will be handled by backend");
toast({
title: "Monitoring paused",
@@ -51,7 +42,7 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
});
} else {
// Start/resume monitoring
// console.log(`Starting monitoring for service ${service.id} (${service.name})`);
// console.log(`Starting monitoring for service ${service.id} (${service.name})`);
// First ensure we update the status in the database to not be paused anymore
await serviceService.resumeMonitoring(service.id);
@@ -100,4 +91,4 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
)}
</Button>
);
}
}
@@ -4,6 +4,7 @@ import { Service, UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useMemo } from "react";
import { formatDistanceStrict } from "date-fns";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceStatsCardsProps {
service: Service;
@@ -11,6 +12,8 @@ interface ServiceStatsCardsProps {
}
export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) {
const { t } = useLanguage();
// Calculate uptime percentage
const calculateUptimePercentage = () => {
if (uptimeData.length === 0) return 100;
@@ -109,41 +112,46 @@ export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProp
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card className="border-blue-400 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">Response Time</CardTitle>
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">{t('responseTime')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-700 dark:text-white">
{service.responseTime}ms
</div>
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Last checked at {service.lastChecked}</p>
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">{t('lastCheckedAt').replace('{datetime}', service.lastChecked)}</p>
{avgResponseTime > 0 && avgResponseTime !== service.responseTime && (
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Avg: {avgResponseTime}ms (last {upChecks.length} up checks)</p>
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">{t('avg')}: {avgResponseTime}ms ({t('lastUpChecksCount').replace('{count}', String(upChecks.length))})</p>
)}
</CardContent>
</Card>
<Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">Uptime</CardTitle>
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">{t('uptime')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-700 dark:text-white">
{uptimePercentage}%
</div>
<p className="text-xs text-green-600 dark:text-green-200 mt-1">Based on last {uptimeData.length} checks</p>
<p className="text-xs text-green-600 dark:text-green-200 mt-1">{t('basedOnlastChecksCount').replace('{count}', String(uptimeData.length))}</p>
<div className="flex flex-col space-y-1 mt-2">
<div className="flex items-center text-xs text-green-600 dark:text-green-200">
<ArrowUp className="h-3 w-3 mr-1 text-green-600 dark:text-green-400" />
<span>Total uptime: {uptimeStats.totalUptimeFormatted}</span>
<span>{t("totalUptime")}: {uptimeStats.totalUptimeFormatted}</span>
</div>
<div className="flex items-center text-xs text-red-600 dark:text-red-200">
<ArrowDown className="h-3 w-3 mr-1 text-red-600 dark:text-red-400" />
<span>Total downtime: {uptimeStats.totalDowntimeFormatted}</span>
<span>{t("totalDowntime")}: {uptimeStats.totalDowntimeFormatted}</span>
</div>
{service.status !== "paused" && (
<div className="flex items-center text-xs font-medium mt-1">
<span className={service.status === "up" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{service.status === "up" ? "Up" : "Down"} for {uptimeStats.currentStatusDuration}
{
service.status === "up" ?
t('upStatusDuration').replace("{duration}", uptimeStats.currentStatusDuration)
:
t('downStatusDuration').replace("{duration}", uptimeStats.currentStatusDuration)
}
</span>
</div>
)}
@@ -153,16 +161,16 @@ export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProp
<Card className="border-purple-400 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">Monitoring Settings</CardTitle>
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">{t('monitoringSettings')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200">
<Clock className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>Checked every {service.interval} seconds</span>
<span>{t('monitoringSettingsInterval').replace('{interval}', String(service.interval))}</span>
</div>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200 mt-1">
<Server className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>{service.type} monitoring</span>
<span>{service.type} {t('monitoringSettingsType')}</span>
</div>
</CardContent>
</Card>
@@ -80,9 +80,9 @@ export function ServicesPagination({
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{totalItems > 0
? `${startItem}-${endItem} of ${totalItems} ${t("services") || "services"}`
: `0 ${t("services") || "services"}`
{totalItems > 0
? t("servicesPagination", {"startItem": startItem, "endItem": endItem, "totalItems": totalItems})
: t("servicesPaginationNoService")
}
</span>
</div>
@@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
import { UptimeStatusItem } from './uptime/UptimeStatusItem';
import { uptimeService } from '@/services/uptimeService';
import { UptimeData } from '@/types/service.types';
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface UptimeBarProps {
uptime: number;
@@ -15,6 +16,7 @@ interface UptimeBarProps {
}
const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType = "HTTP" }: UptimeBarProps) => {
const { t } = useLanguage();
// Calculate date range for last 20 checks with much more aggressive caching
const endDate = useMemo(() => new Date(), []);
const startDate = useMemo(() => {
@@ -23,19 +25,21 @@ const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType =
return start;
}, [endDate, interval]);
// Fetch uptime data with very aggressive caching to reduce API calls
// Fetch uptime data with immediate display and background updates
const { data: uptimeData = [] } = useQuery({
queryKey: ['uptime-bar', serviceId, serviceType],
queryFn: () => uptimeService.getUptimeHistory(serviceId, 20, startDate, endDate, serviceType),
enabled: !!serviceId,
staleTime: 30000, // Data is fresh for 30 seconds
gcTime: 600000, // Keep in cache for 10 minutes
refetchInterval: 60000, // 1 minute polling
staleTime: 300000, // Data stays fresh for 5 minutes - display immediately from cache
gcTime: 900000, // Keep in cache for 15 minutes
refetchInterval: 300000, // Reduced to 5 minute polling
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
retry: 1, // Reduce retries for faster failure
retryDelay: 1000, // Faster retry delay
refetchIntervalInBackground: true, // Allow background updates
placeholderData: (previousData) => previousData, // Keep showing previous data while refetching
});
// Memoize the uptime calculation to prevent unnecessary recalculations
@@ -84,7 +88,7 @@ const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType =
</div>
<span className="text-xs text-muted-foreground">
Last 20 checks
{t('last20Checks')}
</span>
</TooltipProvider>
);
@@ -3,22 +3,24 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/comp
import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceBasicFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) {
const { t } = useLanguage();
return (
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Service Name</FormLabel>
<FormLabel>{t('serviceName')}</FormLabel>
<FormControl>
<Input
placeholder="Enter a descriptive name for your service"
placeholder={t('serviceNameDesc')}
{...field}
/>
</FormControl>
@@ -6,12 +6,14 @@ import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
import { ServiceUrlField } from "./ServiceUrlField";
import { useState } from "react";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceConfigFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
const { t } = useLanguage();
const [isCustomInterval, setIsCustomInterval] = useState(false);
const intervalValue = form.watch("interval");
@@ -35,7 +37,7 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
name="interval"
render={({ field }) => (
<FormItem>
<FormLabel>Check Interval</FormLabel>
<FormLabel>{t("checkInterval")}</FormLabel>
{!isCustomInterval ? (
<FormControl>
<Select onValueChange={handleIntervalChange} value={isCustomInterval ? "custom" : field.value}>
@@ -43,13 +45,13 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="30">30 seconds</SelectItem>
<SelectItem value="60">1 minute</SelectItem>
<SelectItem value="300">5 minutes</SelectItem>
<SelectItem value="900">15 minutes</SelectItem>
<SelectItem value="1800">30 minutes</SelectItem>
<SelectItem value="3600">1 hour</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="30">30 {t("seconds")}</SelectItem>
<SelectItem value="60">1 {t("minute")}</SelectItem>
<SelectItem value="300">5 {t("minutes")}</SelectItem>
<SelectItem value="900">15 {t("minutes")}</SelectItem>
<SelectItem value="1800">30 {t("minutes")}</SelectItem>
<SelectItem value="3600">1 {t("hour")}</SelectItem>
<SelectItem value="custom">{t("custom")}</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -58,7 +60,7 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
<FormControl>
<Input
type="number"
placeholder="Enter interval in seconds"
placeholder={t("checkIntervalPlaceholder")}
value={field.value}
onChange={field.onChange}
min="10"
@@ -72,14 +74,14 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
}}
className="text-sm text-blue-600 hover:text-blue-800"
>
Back to presets
{t("backToPresets")}
</button>
</div>
)}
<FormDescription className="text-xs">
{isCustomInterval
? "Enter custom interval in seconds (minimum 10 seconds)"
: "How often to check the service status"
? t("checkIntervalDescCustom")
: t("checkIntervalDesc")
}
</FormDescription>
<FormMessage />
@@ -92,22 +94,22 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
name="retries"
render={({ field }) => (
<FormItem>
<FormLabel>Retry Attempts</FormLabel>
<FormLabel>{t("retryAttempts")}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select onValueChange={field.onChange} value={field.value ?? "3"}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 attempt</SelectItem>
<SelectItem value="2">2 attempts</SelectItem>
<SelectItem value="3">3 attempts</SelectItem>
<SelectItem value="5">5 attempts</SelectItem>
<SelectItem value="1">1 {t("attempt")}</SelectItem>
<SelectItem value="2">2 {t("attempts")}</SelectItem>
<SelectItem value="3">3 {t("attempts")}</SelectItem>
<SelectItem value="5">5 {t("attempts")}</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription className="text-xs">
Number of retry attempts before marking as down
{t("retryAttemptsDesc")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { serviceSchema, ServiceFormData } from "./types";
import { useServiceSchema, ServiceFormData } from "./types";
import { ServiceBasicFields } from "./ServiceBasicFields";
import { ServiceTypeField } from "./ServiceTypeField";
import { ServiceConfigFields } from "./ServiceConfigFields";
@@ -13,6 +13,8 @@ import { serviceService } from "@/services/serviceService";
import { Service } from "@/types/service.types";
import { ServiceRegionalFields } from "./ServiceRegionalFields";
import { getServiceFormDefaults, mapServiceToFormData, mapFormDataToServiceData } from "./serviceFormUtils";
import { useQueryClient } from "@tanstack/react-query";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceFormProps {
onSuccess: () => void;
@@ -29,8 +31,12 @@ export function ServiceForm({
isEdit = false,
onSubmitStart
}: ServiceFormProps) {
const { t } = useLanguage();
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const queryClient = useQueryClient();
const serviceSchema = useServiceSchema();
// Initialize form with default values
const form = useForm<ServiceFormData>({
@@ -68,10 +74,10 @@ export function ServiceForm({
if (onSubmitStart) onSubmitStart();
try {
console.log("Form data being submitted:", data);
// console.log("Form data being submitted:", data);
const serviceData = mapFormDataToServiceData(data);
console.log("Service data being sent:", serviceData);
// console.log("Service data being sent:", serviceData);
if (isEdit && initialData) {
// Update existing service
@@ -91,6 +97,16 @@ export function ServiceForm({
});
}
// Force immediate refresh of services data
queryClient.setQueryData(["services"], (oldData: any) => {
// Invalidate the cache to force a fresh fetch
return undefined;
});
// Invalidate and refetch services query
await queryClient.invalidateQueries({ queryKey: ["services"] });
await queryClient.refetchQueries({ queryKey: ["services"] });
onSuccess();
if (!isEdit) {
form.reset();
@@ -112,23 +128,23 @@ export function ServiceForm({
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6">
<div className="space-y-6">
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Basic Information</h3>
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">{t('basicInformation')}</h3>
<ServiceBasicFields form={form} />
<ServiceTypeField form={form} />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Configuration</h3>
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">{t('configuration')}</h3>
<ServiceConfigFields form={form} />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Regional Monitoring</h3>
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">{t('regionalMonitoring')}</h3>
<ServiceRegionalFields form={form} />
</div>
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Notifications</h3>
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">{t('notifications')}</h3>
<ServiceNotificationFields form={form} />
</div>
</div>
@@ -136,7 +152,7 @@ export function ServiceForm({
<ServiceFormActions
isSubmitting={isSubmitting}
onCancel={onCancel}
submitLabel={isEdit ? "Update Service" : "Create Service"}
submitLabel={isEdit ? t("updateService") : t("createService")}
/>
</form>
</Form>
@@ -2,6 +2,7 @@
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { MouseEvent } from "react";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceFormActionsProps {
isSubmitting: boolean;
@@ -14,6 +15,8 @@ export function ServiceFormActions({
onCancel,
submitLabel = "Create Service"
}: ServiceFormActionsProps) {
const {t} = useLanguage();
const handleCancel = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!isSubmitting) {
@@ -29,7 +32,7 @@ export function ServiceFormActions({
variant="outline"
disabled={isSubmitting}
>
Cancel
{t("cancel")}
</Button>
<Button
type="submit"
@@ -38,7 +41,7 @@ export function ServiceFormActions({
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
{t("processing")}...
</>
) : (
submitLabel
@@ -1,4 +1,3 @@
import { FormControl, FormField, FormItem, FormLabel, FormDescription } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
@@ -8,31 +7,34 @@ import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
import { useQuery } from "@tanstack/react-query";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { serviceNotificationTemplateService, ServiceNotificationTemplate } from "@/services/serviceNotificationTemplateService";
import { useState, useEffect } from "react";
import { useLanguage } from "@/contexts/LanguageContext.tsx";
interface ServiceNotificationFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceNotificationFields({ form }: ServiceNotificationFieldsProps) {
const { t } = useLanguage();
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
// Get the current form values for debugging
const notificationStatus = form.watch("notificationStatus");
const notificationChannels = form.watch("notificationChannels") || [];
const alertTemplate = form.watch("alertTemplate");
// console.log("Current notification values:", {
// notificationStatus,
// notificationChannels,
// alertTemplate
// });
// Fetch alert configurations for notification channels
const { data: alertConfigsData } = useQuery({
queryKey: ['alertConfigs'],
queryFn: () => alertConfigService.getAlertConfigurations(),
});
// Fetch service notification templates
const { data: serviceTemplates, isLoading: isLoadingTemplates } = useQuery({
queryKey: ['serviceNotificationTemplates'],
queryFn: () => serviceNotificationTemplateService.getTemplates(),
});
// Update alert configs when data is loaded
useEffect(() => {
@@ -42,13 +44,19 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
setAlertConfigs(enabledChannels);
// Debug log to check what alert configs are loaded
// console.log("Loaded alert configurations:", enabledChannels);
}
}, [alertConfigsData]);
// Debug log for service templates
useEffect(() => {
if (serviceTemplates) {
// console.log("Loaded service notification templates:", serviceTemplates);
}
}, [serviceTemplates]);
// Log when form values change to debug
useEffect(() => {
// console.log("Notification values changed:", {
// console.log("Notification values changed:", {
// notificationStatus: form.getValues("notificationStatus"),
// notificationChannels: form.getValues("notificationChannels")
// });
@@ -82,10 +90,10 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Enable Notifications
{t("enableNotifications")}
</FormLabel>
<FormDescription>
Enable or disable notifications for this service
{t("enableNotificationsDesc")}
</FormDescription>
</div>
<FormControl>
@@ -109,11 +117,11 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
name="notificationChannels"
render={({ field }) => (
<FormItem>
<FormLabel>Notification Channels</FormLabel>
<FormLabel>{t("notificationChannels")}</FormLabel>
<FormDescription>
{notificationStatus === "enabled"
? "Select notification channels for this service"
: "Enable notifications first to select channels"}
? t("notificationChannelsEnabledDesc")
: t("notificationChannelsDesc")}
</FormDescription>
{/* Display selected channels as badges */}
@@ -138,7 +146,7 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
value="" // Always reset to empty after selection
>
<SelectTrigger className={notificationStatus !== "enabled" ? 'opacity-50' : ''}>
<SelectValue placeholder="Add a notification channel" />
<SelectValue placeholder={t("notificationChannelsPlaceholder")} />
</SelectTrigger>
<SelectContent>
{alertConfigs
@@ -159,35 +167,35 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
control={form.control}
name="alertTemplate"
render={({ field }) => {
// Don't convert existing values to "default"
const displayValue = field.value || "default";
// console.log("Rendering alert template field with value:", displayValue);
// console.log("Rendering alert template field with value:", field.value);
return (
<FormItem>
<FormLabel>Alert Template</FormLabel>
<FormLabel>{t("alertTemplate")}</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
console.log("Alert template changed to:", value);
field.onChange(value === "default" ? "" : value);
field.onChange(value);
}}
value={displayValue}
disabled={notificationStatus !== "enabled"}
value={field.value || ""}
disabled={notificationStatus !== "enabled" || isLoadingTemplates}
>
<SelectTrigger className={notificationStatus !== "enabled" ? 'opacity-50' : ''}>
<SelectValue placeholder="Select an alert template" />
<SelectValue placeholder={isLoadingTemplates ? t("alertTemplateLoading") : t("alertTemplatePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
{/* Add templates here when available */}
{serviceTemplates?.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{notificationStatus === "enabled"
? "Choose a template for alert messages"
: "Enable notifications first to select template"}
? t("alertTemplateEnabledDesc")
: t("alertTemplateDesc")}
</FormDescription>
</FormItem>
);
@@ -195,4 +203,4 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
/>
</>
);
}
}
@@ -9,12 +9,14 @@ import { regionalService } from "@/services/regionalService";
import { MapPin, Loader2, X, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useLanguage } from "@/contexts/LanguageContext.tsx";
interface ServiceRegionalFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
const { t } = useLanguage();
const regionalMonitoringEnabled = form.watch("regionalMonitoringEnabled");
const currentRegionalAgents = form.watch("regionalAgents") || [];
@@ -79,10 +81,10 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
<div className="space-y-0.5">
<FormLabel className="text-base font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" />
Regional Monitoring
{t("regionalMonitoring")}
</FormLabel>
<div className="text-sm text-muted-foreground">
Assign this service to regional monitoring agents for distributed monitoring
{t("regionalMonitoringDesc")}
</div>
</div>
<FormControl>
@@ -107,7 +109,7 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
name="regionalAgents"
render={({ field }) => (
<FormItem>
<FormLabel>Regional Agents</FormLabel>
<FormLabel>{t("regionalAgents")}</FormLabel>
{/* Display selected agents */}
{currentRegionalAgents.length > 0 && (
@@ -140,12 +142,12 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
<SelectTrigger>
<SelectValue placeholder={
isLoading
? "Loading agents..."
? t("regionalAgentsLoading")
: availableAgents.length > 0
? "Select additional regional agents..."
? t("regionalAgentsAvailablePlaceholder")
: currentRegionalAgents.length > 0
? "All available agents selected"
: "No regional agents available"
? t("regionalAgentsAllSelected")
: t("regionalAgentsNoAvailable")
} />
</SelectTrigger>
</FormControl>
@@ -154,14 +156,14 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
<SelectItem value="loading" disabled>
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading agents...
t("regionalAgentsLoading")
</div>
</SelectItem>
) : availableAgents.length === 0 ? (
<SelectItem value="no-agents" disabled>
{currentRegionalAgents.length > 0
? "All available agents selected"
: "No online regional agents available"
? t("regionalAgentsAllSelected")
: t("regionalAgentsNoOnlineAvailable")
}
</SelectItem>
) : (
@@ -190,13 +192,13 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
{regionalMonitoringEnabled && onlineAgents.length === 0 && !isLoading && (
<p className="text-sm text-amber-600">
No online regional agents found. Services will use default monitoring.
{t("regionalAgentsNotFoundMessage")}
</p>
)}
{currentRegionalAgents.length === 0 && regionalMonitoringEnabled && (
<p className="text-sm text-orange-600">
No regional agents selected. Service will use default monitoring.
{t("regionalAgentsNotSelectedMessage")}
</p>
)}
@@ -4,12 +4,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Globe, Wifi, Server, Globe2 } from "lucide-react";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
import { useLanguage } from "@/contexts/LanguageContext.tsx";
interface ServiceTypeFieldProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
const { t } = useLanguage();
const getServiceIcon = (type: string) => {
switch (type) {
case "http":
@@ -31,7 +33,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Service Type</FormLabel>
<FormLabel>{t("serviceType")}</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
@@ -56,7 +58,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
<span>HTTP/S</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Monitor websites and REST APIs with HTTP/HTTPS Protocol
{t("serviceTypeHTTPDesc")}
</p>
</div>
</SelectItem>
@@ -67,7 +69,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
<span>PING</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Monitor host availability with PING Protocol
{t("serviceTypePINGDesc")}
</p>
</div>
</SelectItem>
@@ -78,7 +80,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
<span>TCP</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Monitor TCP port connectivity with TCP Protocol
{t("serviceTypeTCPDesc")}
</p>
</div>
</SelectItem>
@@ -89,7 +91,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
<span>DNS</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Monitor DNS resolution
{t("serviceTypeDNSDesc")}
</p>
</div>
</SelectItem>
@@ -3,12 +3,14 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescripti
import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
import { useLanguage } from "@/contexts/LanguageContext.tsx";
interface ServiceUrlFieldProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
const { t } = useLanguage();
const serviceType = form.watch("type");
const getPlaceholder = () => {
@@ -22,35 +24,35 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
case "dns":
return "example.com";
default:
return "Enter URL or hostname";
return t("targetDefaultPlaceholder");
}
};
const getDescription = () => {
switch (serviceType) {
case "http":
return "Enter the full URL including protocol (http:// or https://)";
return t("targetHTTPDesc");
case "ping":
return "Enter hostname or IP address to ping";
return t("targetPINGDesc");
case "tcp":
return "Enter hostname or IP address for TCP connection test";
return t("targetTCPDesc");
case "dns":
return "Enter domain name for DNS record monitoring (A, AAAA, MX, etc.)";
return t("targetDNSDesc");
default:
return "Enter the target URL or hostname for monitoring";
return t("targetDefaultDesc");
}
};
const getFieldLabel = () => {
switch (serviceType) {
case "dns":
return "Domain Name";
return t("targetDNS");
case "ping":
return "Hostname/IP";
case "tcp":
return "Hostname/IP";
default:
return "Target URL/Host";
return t("targetDefault");
}
};
@@ -67,7 +69,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
placeholder={getPlaceholder()}
{...field}
onChange={(e) => {
console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value);
// console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value);
field.onChange(e);
}}
/>
@@ -93,13 +95,13 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
type="number"
{...field}
onChange={(e) => {
console.log("Port field changed:", e.target.value);
// console.log("Port field changed:", e.target.value);
field.onChange(e);
}}
/>
</FormControl>
<FormDescription className="text-xs">
Enter the port number for TCP connection test
{t("targetTCPPortDesc")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -74,7 +74,6 @@ export const mapServiceToFormData = (service: Service): ServiceFormData => {
notificationChannels.push(...parsedChannels);
}
} catch (error) {
// console.warn("Failed to parse notification_channel JSON:", error);
// If parsing fails, treat as single channel ID
notificationChannels.push(service.notification_channel);
}
@@ -91,16 +90,13 @@ export const mapServiceToFormData = (service: Service): ServiceFormData => {
}
}
// console.log("Mapping service to form data:", {
// serviceName: service.name,
// notification_status: service.notification_status,
// notification_channel: service.notification_channel,
// notificationChannel: service.notificationChannel,
// mappedChannels: notificationChannels,
// regionalAgents: regionalAgents,
// region_name: service.region_name,
/// agent_id: service.agent_id
// });
// Handle notification_status - it can be boolean or string
let notificationStatus: "enabled" | "disabled" = "disabled";
if (typeof service.notification_status === "boolean") {
notificationStatus = service.notification_status ? "enabled" : "disabled";
} else if (typeof service.notification_status === "string") {
notificationStatus = service.notification_status === "enabled" ? "enabled" : "disabled";
}
return {
name: service.name || "",
@@ -109,7 +105,7 @@ export const mapServiceToFormData = (service: Service): ServiceFormData => {
port: portValue,
interval: String(service.interval || 60),
retries: String(service.retries || 3),
notificationStatus: service.notification_status || "disabled",
notificationStatus: notificationStatus,
notificationChannels: notificationChannels,
alertTemplate: service.alertTemplate === "default" ? "" : service.alertTemplate || "",
regionalMonitoringEnabled: isRegionalEnabled,
@@ -152,7 +148,8 @@ export const mapFormDataToServiceData = (data: ServiceFormData) => {
type: data.type,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationStatus: data.notificationStatus || "disabled",
// Convert string status to boolean for notification_status field
notificationStatus: data.notificationStatus === "enabled",
notificationChannels: data.notificationChannels || [],
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
// Use regional_status field and store multiple agents as comma-separated values
@@ -1,22 +1,42 @@
import { z } from "zod";
import type { ZodSchema } from "zod";
import { useLanguage } from "@/contexts/LanguageContext";
export const serviceSchema = z.object({
name: z.string().min(1, "Service name is required"),
type: z.enum(["http", "ping", "tcp", "dns"]),
url: z.string().min(1, "URL/Domain/Host is required").refine((value) => {
// Basic validation - more specific validation can be added per type
return value.trim().length > 0;
}, "Please enter a valid URL, hostname, or domain"),
port: z.string().optional(),
interval: z.string(),
retries: z.string(),
notificationStatus: z.enum(["enabled", "disabled"]).optional(),
notificationChannels: z.array(z.string()).optional(),
alertTemplate: z.string().optional(),
// Regional monitoring fields - now supports multiple agents
regionalMonitoringEnabled: z.boolean().optional(),
regionalAgents: z.array(z.string()).optional(),
});
export type ServiceFormData = z.infer<typeof serviceSchema>;
export type ServiceFormData = {
name: string;
type: "http" | "ping" | "tcp" | "dns";
url: string;
port?: string;
interval: string;
retries: string;
notificationStatus?: "enabled" | "disabled";
notificationChannels?: string[];
alertTemplate?: string;
regionalMonitoringEnabled?: boolean;
regionalAgents?: string[];
};
// Hook to use the schema with translations
export const useServiceSchema = () => {
const { t } = useLanguage();
return z.object({
name: z.string().min(1, t("serviceNameRequired")),
type: z.enum(["http", "ping", "tcp", "dns"]),
url: z.string()
.min(1, t("urlDomainHostRequired"))
.refine(
(value) => value.trim().length > 0,
t("enterValidUrlHostnameDomain")
),
port: z.string().optional(),
interval: z.string(),
retries: z.string(),
notificationStatus: z.enum(["enabled", "disabled"]).optional(),
notificationChannels: z.array(z.string()).optional(),
alertTemplate: z.string().optional(),
// Regional monitoring fields - now supports multiple agents
regionalMonitoringEnabled: z.boolean().optional(),
regionalAgents: z.array(z.string()).optional(),
});
};
@@ -25,8 +25,8 @@ export function IncidentTable({ incidents }: IncidentTableProps) {
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Error Message</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Details</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("errorMessage")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("details")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -9,10 +9,12 @@ import { IncidentTable } from "./IncidentTable";
import { StatusFilter, PageSize } from "./types";
import { getStatusChangeEvents } from "./utils";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] }) {
// Get current theme
const { theme } = useTheme();
const { t } = useLanguage();
// Filter state
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
@@ -65,7 +67,7 @@ export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] })
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<CardTitle className="text-card-foreground">
<span>Incident History</span>
<span>{t("incidentHistory")}</span>
</CardTitle>
<StatusFilterTabs statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
</div>
@@ -15,6 +15,7 @@ import {
SelectValue
} from "@/components/ui/select";
import { PageSize } from "./types";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface TablePaginationProps {
currentPage: number;
@@ -35,6 +36,7 @@ export function TablePagination({
onPageChange,
onPageSizeChange,
}: TablePaginationProps) {
const {t} = useLanguage()
// Generate page numbers
const getPageNumbers = () => {
const pages = [];
@@ -58,7 +60,7 @@ export function TablePagination({
return (
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<span className="text-sm text-muted-foreground">{t("rowsPerPage")}:</span>
<Select
value={pageSize}
onValueChange={(value) => onPageSizeChange(value as PageSize)}
@@ -12,6 +12,7 @@ import {
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServiceRowActionsProps {
service: Service;
@@ -31,6 +32,7 @@ export const ServiceRowActions = ({
onMuteAlerts
}: ServiceRowActionsProps) => {
const { toast } = useToast();
const { t } = useLanguage();
// Handle pause/resume directly from dropdown
const handlePauseResume = async (e: React.MouseEvent) => {
@@ -122,7 +124,7 @@ export const ServiceRowActions = ({
}}
>
<Eye className="h-4 w-4" />
<span>View Detail</span>
<span>{t("viewDetail")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer text-base py-2.5"
@@ -131,12 +133,12 @@ export const ServiceRowActions = ({
{service.status === "paused" ? (
<>
<Play className="h-4 w-4" />
<span>Resume Monitoring</span>
<span>{t("resumeMonitoring")}</span>
</>
) : (
<>
<Pause className="h-4 w-4" />
<span>Pause Monitoring</span>
<span>{t("pauseMonitoring")}</span>
</>
)}
</DropdownMenuItem>
@@ -148,7 +150,7 @@ export const ServiceRowActions = ({
}}
>
<Edit className="h-4 w-4" />
<span>Edit</span>
<span>{t("edit")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer text-base py-2.5"
@@ -157,12 +159,12 @@ export const ServiceRowActions = ({
{alertsMuted ? (
<>
<Bell className="h-4 w-4" />
<span>Unmute Alerts</span>
<span>{t("unmuteAlerts")}</span>
</>
) : (
<>
<BellOff className="h-4 w-4" />
<span>Mute Alerts</span>
<span>{t("muteAlerts")}</span>
</>
)}
</DropdownMenuItem>
@@ -175,7 +177,7 @@ export const ServiceRowActions = ({
}}
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
<span>{t("delete")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1,5 +1,6 @@
import React from 'react';
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface UptimeSummaryProps {
uptime: number;
@@ -7,13 +8,14 @@ interface UptimeSummaryProps {
}
export const UptimeSummary = ({ uptime, interval }: UptimeSummaryProps) => {
const { t } = useLanguage();
return (
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-muted-foreground">
{Math.round(uptime)}% uptime
</span>
<span className="text-xs text-muted-foreground">
Last 20 checks
{t('last20Checks')}
</span>
</div>
);
@@ -1,50 +1,293 @@
import React, { useEffect, useState } from 'react';
import { format } from 'date-fns';
import {
Card, CardContent, CardDescription, CardHeader, CardTitle,
} from "@/components/ui/card";
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import {
Github, FileText, Twitter, MessageCircle, Code2, ServerIcon,
} from "lucide-react";
import { Github, FileText, Twitter, MessageCircle, Code2, ServerIcon, FolderOpen, Database, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { useLanguage } from "@/contexts/LanguageContext";
import { useTheme } from "@/contexts/ThemeContext";
import { useSystemSettings } from "@/hooks/useSystemSettings";
import { pb } from "@/lib/pocketbase";
import { toast } from "@/components/ui/use-toast";
export const AboutSystem: React.FC = () => {
const { t } = useLanguage();
const { theme } = useTheme();
const { systemName } = useSystemSettings();
const [isImporting, setIsImporting] = useState(false);
const [mergeFields, setMergeFields] = useState(true);
const [importResult, setImportResult] = useState<{
success: boolean;
created: number;
updated: number;
skipped: number;
} | null>(null);
const [version, setVersion] = useState<string>('...');
const [releaseDate, setReleaseDate] = useState<string>('...');
// Helper function to check if a collection is a system collection
const isSystemCollection = (name: string): boolean => {
return name.startsWith('_') || ['users'].includes(name);
};
useEffect(() => {
const fetchLatestRelease = async () => {
try {
const res = await fetch('https://api.github.com/repos/operacle/checkcle/releases/latest');
const data = await res.json();
setVersion(data.tag_name || 'v1.x.x');
setReleaseDate(data.published_at ? format(new Date(data.published_at), 'MMMM d, yyyy') : t('unknown'));
} catch (err) {
setVersion('v1.x.x');
setReleaseDate(t('unknown'));
// Helper function to validate collection schema
const validateCollectionSchema = (collection: any): string | null => {
if (!collection.name) return "Collection name is required";
if (typeof collection.name !== 'string') return "Collection name must be a string";
if (!/^[a-z][a-z0-9_]*$/.test(collection.name)) {
return "Collection name must start with a letter and contain only lowercase letters, numbers, and underscores";
}
if (collection.name.length < 3 || collection.name.length > 32) {
return "Collection name must be between 3 and 32 characters";
}
// Validate schema array if present
if (collection.schema && !Array.isArray(collection.schema)) {
return "Collection schema must be an array";
}
// Validate each field in the schema
if (collection.schema) {
for (const field of collection.schema) {
if (!field.name || typeof field.name !== 'string') {
return `Invalid field name in collection '${collection.name}'`;
}
if (!field.type || typeof field.type !== 'string') {
return `Invalid field type for '${field.name}' in collection '${collection.name}'`;
}
}
};
fetchLatestRelease();
}, [t]);
}
return null;
};
const handleSchemaImport = async (schemaData: string) => {
if (!schemaData.trim()) {
toast({
title: "Error",
description: "No schema data provided",
variant: "destructive",
});
return;
}
try {
setIsImporting(true);
setImportResult(null);
// Parse the JSON to validate it
const collections = JSON.parse(schemaData);
if (!Array.isArray(collections)) {
throw new Error("Schema must be an array of collections");
}
// Check if user is authenticated as admin
if (!pb.authStore.isValid) {
throw new Error("Authentication required. Please log in as an admin.");
}
// Create/update collections one by one using PocketBase client
let successCount = 0;
let errorCount = 0;
let updatedCount = 0;
let skippedCount = 0;
const errors = [];
for (const collection of collections) {
try {
// Skip system collections
if (isSystemCollection(collection.name)) {
skippedCount++;
continue;
}
// Validate collection schema
const validationError = validateCollectionSchema(collection);
if (validationError) {
throw new Error(validationError);
}
// Check if collection already exists
const existingCollections = await pb.collections.getFullList();
const existingCollection = existingCollections.find(c => c.name === collection.name);
if (existingCollection) {
if (mergeFields) {
try {
// Get existing schema
const existingSchema = existingCollection.schema || [];
const newSchema = collection.schema || [];
// Merge schemas - add new fields, keep existing ones
const mergedSchema = [...existingSchema];
let fieldsAdded = 0;
for (const newField of newSchema) {
const existingFieldIndex = mergedSchema.findIndex(f => f.name === newField.name);
if (existingFieldIndex >= 0) {
} else {
// Add new field
mergedSchema.push(newField);
fieldsAdded++;
}
}
// Update the collection with merged schema
const updateData = {
...collection,
schema: mergedSchema
};
const updatedCollection = await pb.collections.update(existingCollection.id, updateData);
updatedCount++;
} catch (mergeError) {
throw new Error(`Failed to merge fields: ${mergeError.message || mergeError}`);
}
} else {
skippedCount++;
continue;
}
} else {
// Create new collection
try {
const newCollection = await pb.collections.create(collection);
successCount++;
} catch (createError) {
// Extract more detailed error information
let errorMessage = createError.message || createError;
if (createError.data) {
// Try to extract field-specific errors
if (createError.data.data) {
const fieldErrors = Object.entries(createError.data.data).map(([field, msgs]) =>
`${field}: ${Array.isArray(msgs) ? msgs.join(', ') : msgs}`
).join('; ');
if (fieldErrors) {
errorMessage += ` - Field errors: ${fieldErrors}`;
}
}
}
throw new Error(`Failed to create collection: ${errorMessage}`);
}
}
} catch (error) {
errorCount++;
const errorMsg = `Failed to process collection '${collection.name}': ${error.message || error}`;
errors.push(errorMsg);
}
}
// Set import result for display
setImportResult({
success: successCount + updatedCount > 0,
created: successCount,
updated: updatedCount,
skipped: skippedCount
});
// Show results
const totalProcessed = successCount + updatedCount;
if (totalProcessed > 0 || skippedCount > 0) {
let description = '';
if (totalProcessed > 0) {
description = `Successfully processed ${totalProcessed} collection(s)`;
if (successCount > 0) description += ` (${successCount} created)`;
if (updatedCount > 0) description += ` (${updatedCount} updated)`;
}
if (skippedCount > 0) {
if (description) description += `, `;
description += `${skippedCount} skipped (system collections or existing)`;
}
if (errorCount > 0) description += `. ${errorCount} failed.`;
toast({
title: "Import Complete",
description,
variant: totalProcessed > errorCount ? "default" : "destructive",
});
} else if (errorCount > 0) {
toast({
title: "Import Failed",
description: "No collections were processed successfully.",
variant: "destructive",
});
} else {
toast({
title: "No Changes",
description: "All collections already exist and no changes were made.",
variant: "default",
});
}
if (errors.length > 0) {
// Show detailed errors for debugging
if (errors.length <= 3) {
toast({
title: "Detailed Errors",
description: errors.join('; '),
variant: "destructive",
});
}
}
} catch (error) {
toast({
title: "Import Failed",
description: error instanceof Error ? error.message : "Failed to import schema",
variant: "destructive",
});
setImportResult({
success: false,
created: 0,
updated: 0,
skipped: 0
});
} finally {
setIsImporting(false);
}
};
const handleLoadLocalSchema = async () => {
try {
setIsImporting(true);
setImportResult(null);
const response = await fetch('/upload/data/pb_schema_latest.json');
if (!response.ok) {
throw new Error(`Failed to load local schema: ${response.status} ${response.statusText}`);
}
const schemaText = await response.text();
// Validate that it's valid JSON
JSON.parse(schemaText);
// Directly import the schema
await handleSchemaImport(schemaText);
} catch (error) {
toast({
title: "Import Failed",
description: error instanceof Error ? error.message : "Failed to load and import local schema file",
variant: "destructive",
});
setImportResult({
success: false,
created: 0,
updated: 0,
skipped: 0
});
setIsImporting(false);
}
};
return (
<div className="space-y-6 animate-fade-in">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t('aboutSystem')}</h1>
<p className="text-muted-foreground text-base leading-relaxed mt-2">
</p>
</div>
<Separator />
<div className="grid gap-8 md:grid-cols-2">
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
<CardHeader className="bg-muted/50 pb-4">
@@ -58,7 +301,7 @@ export const AboutSystem: React.FC = () => {
<div className="flex flex-col space-y-3 pt-2">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('systemVersion')}</span>
<span className="text-foreground font-medium">{version}</span>
<span className="text-foreground font-medium">{t('version')} 1.5.1</span>
</div>
<Separator className="my-1" />
<div className="flex justify-between items-center">
@@ -68,22 +311,20 @@ export const AboutSystem: React.FC = () => {
<Separator className="my-1" />
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t('releasedOn')}</span>
<span className="text-foreground font-medium">{releaseDate}</span>
<span className="text-foreground font-medium">Auguest 21, 2025</span>
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
<CardHeader className="bg-muted/50 pb-4">
<CardTitle className="flex items-center gap-2">
<Code2 className={`h-5 w-5 ${theme === 'dark' ? 'text-emerald-400' : 'text-emerald-600'}`} />
<span>{t('links')}</span>
</CardTitle>
<CardDescription className="font-medium text-base">
{systemName || 'CheckCle'} {t('resources').toLowerCase()}
</CardDescription>
<CardDescription className="font-medium text-base">{systemName || 'CheckCle'} {t('resources').toLowerCase()}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="grid grid-cols-1 gap-3">
@@ -95,7 +336,7 @@ export const AboutSystem: React.FC = () => {
<FileText className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('viewDocumentation')}</span>
</Button>
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://x.com/tlengoss", "_blank")}>
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://x.com/checkcle_oss", "_blank")}>
<Twitter className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('followOnX')}</span>
</Button>
@@ -107,8 +348,90 @@ export const AboutSystem: React.FC = () => {
</CardContent>
</Card>
</div>
<Separator />
<Card className="overflow-hidden border border-border transition-all duration-300 hover:shadow-md">
<CardHeader className="bg-muted/50 pb-4">
<CardTitle className="flex items-center gap-2">
<Database className={`h-5 w-5 ${theme === 'dark' ? 'text-blue-400' : 'text-blue-600'}`} />
<span>{t('updateSchema')}</span>
</CardTitle>
<CardDescription className="font-medium text-base">{t('updateSchemaDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="merge-fields"
checked={mergeFields}
onCheckedChange={(checked) => setMergeFields(checked === true)}
/>
<Label htmlFor="merge-fields" className="text-sm font-medium">
{t('mergeFieldsLabel')}
</Label>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button
onClick={handleLoadLocalSchema}
disabled={isImporting}
className={`flex items-center gap-2 transition-all duration-200 ${
isImporting ? 'animate-pulse' : ''
}`}
>
<Database className={`h-4 w-4 ${isImporting ? 'animate-spin' : ''}`} />
{isImporting ? t('importing') : t('clickToUpdateSchema')}
</Button>
</div>
{importResult && (
<div className={`mt-4 p-4 rounded-lg border ${
importResult.success
? 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800'
: 'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800'
} animate-in fade-in-0 slide-in-from-bottom-2 duration-300`}>
<div className="flex items-center gap-2">
<CheckCircle className={`h-5 w-5 ${
importResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`} />
<span className={`font-medium ${
importResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}>
{importResult.success ? t('importSuccessful') : t('importFailed')}
</span>
</div>
<div className={`mt-2 text-sm ${
importResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}>
{importResult.created > 0 && t('collectionsCreatedCount').replace('{count}', String(importResult.created))}
{importResult.updated > 0 && (importResult.created > 0 ? ', ' : '') + t('collectionsUpdatedCount').replace('{count}', String(importResult.updated))}
{importResult.skipped > 0 && ((importResult.created > 0 || importResult.updated > 0) ? ', ' : '') + t('collectionsSkippedCount').replace('{count}', String(importResult.skipped))}
</div>
</div>
)}
<div className="text-sm text-muted-foreground">
<p className="mb-2">
<strong>{t('instructions')}:</strong>
</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li><strong>{t('mergeFields')}:</strong> {t('instructionsMergeFields')}</li>
<li>{t('instructionsCollections')}</li>
<li>{t('instructionsImportAuth')}</li>
</ul>
</div>
</CardContent>
</Card>
</div>
);
};
export default AboutSystem;
export default AboutSystem;
@@ -1,9 +1,10 @@
import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { templateService } from "@/services/templateService";
import { templateService, TemplateType } from "@/services/templateService";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, RefreshCcw } from "lucide-react";
import { TemplateList } from "./TemplateList";
import { TemplateDialog } from "./TemplateDialog";
@@ -11,8 +12,10 @@ import { useToast } from "@/hooks/use-toast";
export const AlertsTemplates = () => {
const { toast } = useToast();
const [activeTab, setActiveTab] = useState<TemplateType>('service');
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<string | null>(null);
const [editingTemplateType, setEditingTemplateType] = useState<TemplateType | null>(null);
const {
data: templates = [],
@@ -20,17 +23,19 @@ export const AlertsTemplates = () => {
error,
refetch
} = useQuery({
queryKey: ['notification_templates'],
queryFn: templateService.getTemplates,
queryKey: ['notification_templates', activeTab],
queryFn: () => templateService.getTemplates(activeTab),
});
const handleAddTemplate = () => {
const handleAddTemplate = (templateType: TemplateType) => {
setEditingTemplate(null);
setEditingTemplateType(templateType);
setIsDialogOpen(true);
};
const handleEditTemplate = (id: string) => {
const handleEditTemplate = (id: string, templateType: TemplateType) => {
setEditingTemplate(id);
setEditingTemplateType(templateType);
setIsDialogOpen(true);
};
@@ -51,33 +56,103 @@ export const AlertsTemplates = () => {
<RefreshCcw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button onClick={handleAddTemplate}>
<Button onClick={() => handleAddTemplate(activeTab)}>
<Plus className="h-4 w-4 mr-2" />
Add Template
</Button>
</div>
</CardHeader>
<CardContent>
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={handleEditTemplate}
refetchTemplates={refetch}
/>
)}
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TemplateType)}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="service">Service Uptime</TabsTrigger>
<TabsTrigger value="server">Server Monitoring</TabsTrigger>
<TabsTrigger value="ssl">SSL Certificate</TabsTrigger>
<TabsTrigger value="server_threshold">Server Threshold</TabsTrigger>
</TabsList>
<TabsContent value="service" className="mt-4">
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading service templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={(id) => handleEditTemplate(id, 'service')}
refetchTemplates={refetch}
templateType="service"
/>
)}
</TabsContent>
<TabsContent value="server" className="mt-4">
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading server templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={(id) => handleEditTemplate(id, 'server')}
refetchTemplates={refetch}
templateType="server"
/>
)}
</TabsContent>
<TabsContent value="ssl" className="mt-4">
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading SSL templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={(id) => handleEditTemplate(id, 'ssl')}
refetchTemplates={refetch}
templateType="ssl"
/>
)}
</TabsContent>
<TabsContent value="server_threshold" className="mt-4">
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading server threshold templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={(id) => handleEditTemplate(id, 'server_threshold')}
refetchTemplates={refetch}
templateType="server_threshold"
/>
)}
</TabsContent>
</Tabs>
</CardContent>
<TemplateDialog
open={isDialogOpen}
templateId={editingTemplate}
templateType={editingTemplateType}
onOpenChange={setIsDialogOpen}
onSuccess={() => {
refetch();
@@ -86,4 +161,4 @@ export const AlertsTemplates = () => {
/>
</Card>
);
};
};
@@ -1,19 +1,27 @@
import React, { useEffect } from "react";
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTemplateForm } from "./hooks/useTemplateForm";
import { BasicTemplateFields } from "./form/BasicTemplateFields";
import { MessagesTabContent } from "./form/MessagesTabContent";
import { PlaceholdersTabContent } from "./form/PlaceholdersTabContent";
import { ServerTemplateFields } from "./form/ServerTemplateFields";
import { ServiceTemplateFields } from "./form/ServiceTemplateFields";
import { SslTemplateFields } from "./form/SslTemplateFields";
import { ServerThresholdFields } from "./form/ServerThresholdFields";
import { Loader2, ChevronDown } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { TemplateType, templateTypeConfigs } from "@/services/templateService";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import {z} from "zod";
interface TemplateDialogProps {
open: boolean;
templateId: string | null;
templateType: TemplateType | null;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
@@ -21,9 +29,12 @@ interface TemplateDialogProps {
export const TemplateDialog: React.FC<TemplateDialogProps> = ({
open,
templateId,
templateType: initialTemplateType,
onOpenChange,
onSuccess,
}) => {
const [selectedTemplateType, setSelectedTemplateType] = useState<TemplateType>(initialTemplateType || 'service');
const {
form,
isEditMode,
@@ -32,28 +43,73 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
onSubmit
} = useTemplateForm({
templateId,
templateType: selectedTemplateType,
open,
onOpenChange,
onSuccess
});
// For debugging purposes
// Update template type when prop changes or dialog opens
useEffect(() => {
if (open) {
// console.log("Template dialog opened. Edit mode:", isEditMode, "Template ID:", templateId);
// Log form values when they change
const subscription = form.watch((value) => {
// console.log("Current form values:", value);
});
return () => subscription.unsubscribe();
if (initialTemplateType) {
setSelectedTemplateType(initialTemplateType);
} else if (open && !isEditMode) {
setSelectedTemplateType('service');
}
}, [open, isEditMode, templateId, form]);
}, [initialTemplateType, open, isEditMode]);
// Handle template type change
const handleTemplateTypeChange = (newType: TemplateType) => {
if (!isEditMode) {
setSelectedTemplateType(newType);
form.setValue('templateType', newType);
}
};
const renderTemplateFields = () => {
switch (selectedTemplateType) {
case 'server':
return <ServerTemplateFields control={form.control} />;
case 'service':
return <ServiceTemplateFields control={form.control} />;
case 'ssl':
return <SslTemplateFields control={form.control} />;
case 'server_threshold':
return <ServerThresholdFields control={form.control} />;
default:
return null;
}
};
const renderPlaceholderGuide = () => {
const config = templateTypeConfigs[selectedTemplateType];
if (!config) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Available Placeholders</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
{config.description}. Use these placeholders in your messages:
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-sm">
{config.placeholders.map((placeholder) => (
<div key={placeholder} className="bg-muted/30 p-2 rounded">
<code className="text-xs">{placeholder}</code>
</div>
))}
</div>
</CardContent>
</Card>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{isEditMode ? "Edit Template" : "Add Template"}</DialogTitle>
</DialogHeader>
@@ -69,20 +125,85 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
<div className="relative flex-1">
<ScrollArea className="pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="space-y-6 pb-6 pr-4">
<BasicTemplateFields control={form.control} />
{/* Basic Fields */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Template Name</FormLabel>
<FormControl>
<Input
placeholder="Enter template name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="templateType"
render={({ field }) => (
<FormItem>
<FormLabel>Template Type</FormLabel>
<FormControl>
<Select
onValueChange={handleTemplateTypeChange}
value={selectedTemplateType}
disabled={isEditMode}
>
<SelectTrigger>
<SelectValue placeholder="Select template type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="server">Server Monitoring</SelectItem>
<SelectItem value="service">Service Uptime</SelectItem>
<SelectItem value="ssl">SSL Certificate</SelectItem>
<SelectItem value="server_threshold">Server Threshold</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{selectedTemplateType !== 'server_threshold' && (
<FormField
control={form.control}
name="placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Placeholder</FormLabel>
<FormControl>
<Input
placeholder="Optional custom placeholder"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<Tabs defaultValue="messages">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="messages">Messages</TabsTrigger>
<TabsTrigger value="messages">{selectedTemplateType === 'server_threshold' ? 'Thresholds' : 'Messages'}</TabsTrigger>
<TabsTrigger value="placeholders">Placeholders</TabsTrigger>
</TabsList>
<TabsContent value="messages" className="pt-4">
<MessagesTabContent control={form.control} />
{renderTemplateFields()}
</TabsContent>
<TabsContent value="placeholders" className="pt-4">
<PlaceholdersTabContent control={form.control} />
{renderPlaceholderGuide()}
</TabsContent>
</Tabs>
</div>
@@ -120,4 +241,4 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
</DialogContent>
</Dialog>
);
};
};
@@ -1,17 +1,15 @@
import React, { useState } from "react";
import { NotificationTemplate, templateService } from "@/services/templateService";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Edit, Trash2 } from "lucide-react";
import {
import { Badge } from "@/components/ui/badge";
import { Trash2, Edit, MoreVertical } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
@@ -22,63 +20,75 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { templateService, AnyTemplate, TemplateType } from "@/services/templateService";
interface TemplateListProps {
templates: NotificationTemplate[];
templates: AnyTemplate[];
isLoading: boolean;
onEdit: (id: string) => void;
refetchTemplates: () => void;
templateType: TemplateType;
}
export const TemplateList: React.FC<TemplateListProps> = ({
templates,
isLoading,
onEdit,
export const TemplateList: React.FC<TemplateListProps> = ({
templates,
isLoading,
onEdit,
refetchTemplates,
templateType
}) => {
const { toast } = useToast();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<NotificationTemplate | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const queryClient = useQueryClient();
const [deleteTemplateId, setDeleteTemplateId] = useState<string | null>(null);
const handleDeletePrompt = (template: NotificationTemplate) => {
setTemplateToDelete(template);
setDeleteDialogOpen(true);
};
const handleDeleteTemplate = async () => {
if (!templateToDelete) return;
setIsDeleting(true);
try {
await templateService.deleteTemplate(templateToDelete.id);
// Delete mutation
const deleteMutation = useMutation({
mutationFn: (id: string) => templateService.deleteTemplate(id, templateType),
onSuccess: () => {
toast({
title: "Template deleted",
description: `Template "${templateToDelete.name}" has been removed.`,
description: "The template has been deleted successfully.",
});
queryClient.invalidateQueries({ queryKey: ['notification_templates', templateType] });
refetchTemplates();
} catch (error) {
console.error("Error deleting template:", error);
},
onError: (error) => {
// console.error("Error deleting template:", error);
toast({
title: "Error",
description: "Failed to delete template. Please try again.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
setDeleteDialogOpen(false);
setTemplateToDelete(null);
},
});
const handleDelete = (id: string) => {
setDeleteTemplateId(id);
};
const confirmDelete = () => {
if (deleteTemplateId) {
deleteMutation.mutate(deleteTemplateId);
setDeleteTemplateId(null);
}
};
if (isLoading) {
return (
<div className="py-8 flex justify-center">
<div className="animate-pulse flex flex-col items-center">
<div className="h-4 bg-gray-200 rounded w-32 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-64"></div>
</div>
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="flex items-center justify-between p-4 border border-border rounded-lg">
<div className="space-y-2">
<div className="h-4 bg-muted animate-pulse rounded w-32"></div>
<div className="h-3 bg-muted animate-pulse rounded w-48"></div>
</div>
<div className="flex space-x-2">
<div className="h-8 w-8 bg-muted animate-pulse rounded"></div>
<div className="h-8 w-8 bg-muted animate-pulse rounded"></div>
</div>
</div>
))}
</div>
);
}
@@ -86,83 +96,86 @@ export const TemplateList: React.FC<TemplateListProps> = ({
if (templates.length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground mb-2">No templates found</p>
<p className="text-sm text-muted-foreground">
Create your first notification template to get started.
</p>
<p className="text-muted-foreground">No templates found. Create your first template to get started.</p>
</div>
);
}
const getTemplateTypeLabel = (type: TemplateType) => {
switch (type) {
case 'server': return 'Server';
case 'service': return 'Service';
case 'ssl': return 'SSL';
default: return 'Unknown';
}
};
return (
<>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium">{template.name}</TableCell>
<TableCell>
<Badge variant="outline">{template.type}</Badge>
</TableCell>
<TableCell>
{new Date(template.created).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex space-x-2">
<Button
size="sm"
variant="ghost"
onClick={() => onEdit(template.id)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeletePrompt(template)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="space-y-4">
{templates.map((template) => (
<div key={template.id} className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-medium">{template.name}</h3>
<Badge variant="outline">{getTemplateTypeLabel(templateType)}</Badge>
</div>
<p className="text-sm text-muted-foreground">
Created: {new Date(template.created).toLocaleDateString()}
{template.updated !== template.created &&
` • Updated: ${new Date(template.updated).toLocaleDateString()}`
}
</p>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => onEdit(template.id)}
>
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDelete(template.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialog open={!!deleteTemplateId} onOpenChange={() => setDeleteTemplateId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Template</AlertDialogTitle>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the template "{templateToDelete?.name}"? This action cannot be undone.
This action cannot be undone. This will permanently delete the template.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDeleteTemplate();
}}
disabled={isDeleting}
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Deleting..." : "Delete"}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
};
@@ -71,15 +71,168 @@ export const PlaceholdersTabContent: React.FC<PlaceholdersTabContentProps> = ({
<FormField
control={control}
name="threshold_placeholder"
name="url_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Threshold Placeholder</FormLabel>
<FormLabel>URL Placeholder</FormLabel>
<FormControl>
<Input placeholder="${threshold}" {...field} />
<Input placeholder="${url}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for threshold values in alerts
Used for service URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="host_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Host/IP Placeholder</FormLabel>
<FormControl>
<Input placeholder="${host}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service host or IP address
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="service_type_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Service Type Placeholder</FormLabel>
<FormControl>
<Input placeholder="${service_type}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service type (HTTP, PING, TCP, DNS)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="port_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Port Placeholder</FormLabel>
<FormControl>
<Input placeholder="${port}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service port number
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="domain_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Domain Placeholder</FormLabel>
<FormControl>
<Input placeholder="${domain}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for domain name (DNS services)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="region_name_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Region Name Placeholder</FormLabel>
<FormControl>
<Input placeholder="${region_name}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for regional agent name
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="agent_id_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Agent ID Placeholder</FormLabel>
<FormControl>
<Input placeholder="${agent_id}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for regional agent ID
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="uptime_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Uptime Placeholder</FormLabel>
<FormControl>
<Input placeholder="${uptime}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service uptime percentage
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="time_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Time Placeholder</FormLabel>
<FormControl>
<Input placeholder="${time}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for current date and time
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="error_message_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Error Message Placeholder</FormLabel>
<FormControl>
<Input placeholder="${error_message}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for error details when service is down
</FormDescription>
<FormMessage />
</FormItem>
@@ -111,22 +264,50 @@ export const PlaceholdersTabContent: React.FC<PlaceholdersTabContentProps> = ({
<code className="text-xs">${"{status}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service status (UP, DOWN)</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{threshold}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service threshold value</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{url}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service URL</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{host}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service host or IP address</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{service_type}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service type (HTTP, PING, TCP, DNS)</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{port}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service port number</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{domain}"}</code>
<p className="text-xs text-muted-foreground mt-1">Domain name (DNS services)</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{region_name}"}</code>
<p className="text-xs text-muted-foreground mt-1">Regional agent name</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{agent_id}"}</code>
<p className="text-xs text-muted-foreground mt-1">Regional agent ID</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{uptime}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service uptime percentage</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{time}"}</code>
<p className="text-xs text-muted-foreground mt-1">Current date and time</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{error_message}"}</code>
<p className="text-xs text-muted-foreground mt-1">Error details when service is down</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
};
};
@@ -0,0 +1,328 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ServerTemplateFieldsProps {
control: Control<any>;
}
export const ServerTemplateFields: React.FC<ServerTemplateFieldsProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">System Resource Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="cpu_message"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Alert Message</FormLabel>
<FormControl>
<Textarea
placeholder="CPU usage on ${server_name} is ${cpu_usage}% (threshold: ${threshold}%)"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="ram_message"
render={({ field }) => (
<FormItem>
<FormLabel>RAM Alert Message</FormLabel>
<FormControl>
<Textarea
placeholder="Memory usage on ${server_name} is ${ram_usage}% (threshold: ${threshold}%)"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="disk_message"
render={({ field }) => (
<FormItem>
<FormLabel>Disk Alert Message</FormLabel>
<FormControl>
<Textarea
placeholder="Disk usage on ${server_name} is ${disk_usage}% (threshold: ${threshold}%)"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="network_message"
render={({ field }) => (
<FormItem>
<FormLabel>Network Alert Message</FormLabel>
<FormControl>
<Textarea
placeholder="Network usage on ${server_name} is ${network_usage}% (threshold: ${threshold}%)"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">System Resource Restore Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="restore_cpu_message"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="CPU usage on ${server_name} has returned to normal: ${cpu_usage}%"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_ram_message"
render={({ field }) => (
<FormItem>
<FormLabel>RAM Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="Memory usage on ${server_name} has returned to normal: ${ram_usage}%"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_disk_message"
render={({ field }) => (
<FormItem>
<FormLabel>Disk Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="Disk usage on ${server_name} has returned to normal: ${disk_usage}%"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_network_message"
render={({ field }) => (
<FormItem>
<FormLabel>Network Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="Network usage on ${server_name} has returned to normal: ${network_usage}%"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="up_message"
render={({ field }) => (
<FormItem>
<FormLabel>Server Up Message</FormLabel>
<FormControl>
<Textarea
placeholder="Server ${server_name} is UP and responding"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="down_message"
render={({ field }) => (
<FormItem>
<FormLabel>Server Down Message</FormLabel>
<FormControl>
<Textarea
placeholder="Server ${server_name} is DOWN"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="warning_message"
render={({ field }) => (
<FormItem>
<FormLabel>Warning Message</FormLabel>
<FormControl>
<Textarea
placeholder="Warning: Server ${server_name} requires attention"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="paused_message"
render={({ field }) => (
<FormItem>
<FormLabel>Paused Message</FormLabel>
<FormControl>
<Textarea
placeholder="Monitoring for server ${server_name} is paused"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="cpu_temp_message"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Temperature Message</FormLabel>
<FormControl>
<Textarea
placeholder="CPU temperature on ${server_name} is ${cpu_temp}°C"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="disk_io_message"
render={({ field }) => (
<FormItem>
<FormLabel>Disk I/O Message</FormLabel>
<FormControl>
<Textarea
placeholder="Disk I/O on ${server_name} is ${disk_io} MB/s"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_cpu_temp_message"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Temperature Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="CPU temperature on ${server_name} has returned to normal: ${cpu_temp}°C"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_disk_io_message"
render={({ field }) => (
<FormItem>
<FormLabel>Disk I/O Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="Disk I/O on ${server_name} has returned to normal: ${disk_io} MB/s"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,121 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ServerThresholdFieldsProps {
control: Control<any>;
}
export const ServerThresholdFields: React.FC<ServerThresholdFieldsProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Server Resource Thresholds</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="cpu_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Threshold (%)</FormLabel>
<FormControl>
<Input
type="number"
min="0"
max="100"
placeholder="85"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
CPU usage percentage that triggers an alert
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="ram_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>RAM Threshold (%)</FormLabel>
<FormControl>
<Input
type="number"
min="0"
max="100"
placeholder="80"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Memory usage percentage that triggers an alert
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="disk_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Disk Threshold (%)</FormLabel>
<FormControl>
<Input
type="number"
min="0"
max="100"
placeholder="90"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Disk usage percentage that triggers an alert
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="network_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Network Threshold (%)</FormLabel>
<FormControl>
<Input
type="number"
min="0"
max="100"
placeholder="75"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Network usage percentage that triggers an alert
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,133 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ServiceTemplateFieldsProps {
control: Control<any>;
}
export const ServiceTemplateFields: React.FC<ServiceTemplateFieldsProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Service Status Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="up_message"
render={({ field }) => (
<FormItem>
<FormLabel>Service Up Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is UP. Response time: ${response_time}ms"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="down_message"
render={({ field }) => (
<FormItem>
<FormLabel>Service Down Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is DOWN. Status: ${status}"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="maintenance_message"
render={({ field }) => (
<FormItem>
<FormLabel>Maintenance Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is under maintenance"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="incident_message"
render={({ field }) => (
<FormItem>
<FormLabel>Incident Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} has an incident"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="resolved_message"
render={({ field }) => (
<FormItem>
<FormLabel>Resolved Message</FormLabel>
<FormControl>
<Textarea
placeholder="Issue with service ${service_name} has been resolved"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="warning_message"
render={({ field }) => (
<FormItem>
<FormLabel>Warning Message</FormLabel>
<FormControl>
<Textarea
placeholder="Warning: Service ${service_name} response time is high"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,79 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface SslTemplateFieldsProps {
control: Control<any>;
}
export const SslTemplateFields: React.FC<SslTemplateFieldsProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">SSL Certificate Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<FormField
control={control}
name="expired"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Expired Message</FormLabel>
<FormControl>
<Textarea
placeholder="SSL certificate for ${domain} has EXPIRED on ${expiry_date}"
className="min-h-24"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="exiring_soon"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Expiring Soon Message</FormLabel>
<FormControl>
<Textarea
placeholder="SSL certificate for ${domain} will expire in ${days_left} days on ${expiry_date}"
className="min-h-24"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="warning"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Warning Message</FormLabel>
<FormControl>
<Textarea
placeholder="Warning: SSL certificate for ${domain} requires attention"
className="min-h-24"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -1,2 +1,2 @@
export * from './useTemplateForm';
export * from './useTemplateForm';
@@ -1,72 +1,180 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useToast } from "@/hooks/use-toast";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { templateService, CreateUpdateTemplateData } from "@/services/templateService";
import { templateService, TemplateType, AnyTemplateData } from "@/services/templateService";
import { useEffect } from "react";
// Template form schema
export const templateFormSchema = z.object({
// Base schema
const baseSchema = {
name: z.string().min(2, "Name is required and must be at least 2 characters"),
type: z.string().min(1, "Type is required"),
templateType: z.enum(['server', 'service', 'ssl', 'server_threshold'] as const),
placeholder: z.string().optional(),
};
// Server template schema
const serverTemplateSchema = z.object({
...baseSchema,
templateType: z.literal('server'),
ram_message: z.string().min(1, "RAM message is required"),
cpu_message: z.string().min(1, "CPU message is required"),
disk_message: z.string().min(1, "Disk message is required"),
network_message: z.string().min(1, "Network message is required"),
up_message: z.string().min(1, "Up message is required"),
down_message: z.string().min(1, "Down message is required"),
notification_id: z.string().optional(),
warning_message: z.string().min(1, "Warning message is required"),
paused_message: z.string().min(1, "Paused message is required"),
cpu_temp_message: z.string().min(1, "CPU temperature message is required"),
disk_io_message: z.string().min(1, "Disk I/O message is required"),
restore_ram_message: z.string().optional(),
restore_cpu_message: z.string().optional(),
restore_disk_message: z.string().optional(),
restore_network_message: z.string().optional(),
restore_cpu_temp_message: z.string().optional(),
restore_disk_io_message: z.string().optional(),
});
// Service template schema
const serviceTemplateSchema = z.object({
...baseSchema,
templateType: z.literal('service'),
up_message: z.string().min(1, "Up message is required"),
down_message: z.string().min(1, "Down message is required"),
maintenance_message: z.string().min(1, "Maintenance message is required"),
incident_message: z.string().min(1, "Incident message is required"),
resolved_message: z.string().min(1, "Resolved message is required"),
service_name_placeholder: z.string().min(1, "Service name placeholder is required"),
response_time_placeholder: z.string().min(1, "Response time placeholder is required"),
status_placeholder: z.string().min(1, "Status placeholder is required"),
threshold_placeholder: z.string().min(1, "Threshold placeholder is required"),
warning_message: z.string().min(1, "Warning message is required"),
});
// Define the form data type from the schema
// SSL template schema
const sslTemplateSchema = z.object({
...baseSchema,
templateType: z.literal('ssl'),
expired: z.string().min(1, "Expired message is required"),
exiring_soon: z.string().min(1, "Expiring soon message is required"),
warning: z.string().min(1, "Warning message is required"),
});
// Server threshold schema
const serverThresholdSchema = z.object({
...baseSchema,
templateType: z.literal('server_threshold'),
cpu_threshold: z.number().min(0).max(100, "CPU threshold must be between 0-100"),
ram_threshold: z.number().min(0).max(100, "RAM threshold must be between 0-100"),
disk_threshold: z.number().min(0).max(100, "Disk threshold must be between 0-100"),
network_threshold: z.number().min(0).max(100, "Network threshold must be between 0-100"),
notification_id: z.string().optional(),
server_template_id: z.string().optional(),
});
// Combined schema
export const templateFormSchema = z.discriminatedUnion("templateType", [
serverTemplateSchema,
serviceTemplateSchema,
sslTemplateSchema,
serverThresholdSchema,
]);
export type TemplateFormData = z.infer<typeof templateFormSchema>;
// Default form values
const defaultFormValues: TemplateFormData = {
name: "",
type: "default",
up_message: "Service ${service_name} is UP. Response time: ${response_time}ms",
down_message: "Service ${service_name} is DOWN. Status: ${status}",
maintenance_message: "Service ${service_name} is under maintenance",
incident_message: "Service ${service_name} has an incident",
resolved_message: "Service ${service_name} issue has been resolved",
service_name_placeholder: "${service_name}",
response_time_placeholder: "${response_time}",
status_placeholder: "${status}",
threshold_placeholder: "${threshold}",
// Default form values for each template type
const getDefaultValues = (templateType: TemplateType): TemplateFormData => {
const base = {
name: "",
templateType,
placeholder: "",
};
switch (templateType) {
case 'server':
return {
...base,
templateType: 'server' as const,
ram_message: "Memory usage on ${server_name} is ${ram_usage}% (threshold: ${threshold}%)",
cpu_message: "CPU usage on ${server_name} is ${cpu_usage}% (threshold: ${threshold}%)",
disk_message: "Disk usage on ${server_name} is ${disk_usage}% (threshold: ${threshold}%)",
network_message: "Network usage on ${server_name} is ${network_usage}% (threshold: ${threshold}%)",
up_message: "Server ${server_name} is UP and responding",
down_message: "Server ${server_name} is DOWN",
notification_id: "",
warning_message: "Warning: Server ${server_name} requires attention",
paused_message: "Monitoring for server ${server_name} is paused",
cpu_temp_message: "CPU temperature on ${server_name} is ${cpu_temp}°C",
disk_io_message: "Disk I/O on ${server_name} is ${disk_io} MB/s",
restore_ram_message: "Memory usage on ${server_name} has returned to normal: ${ram_usage}%",
restore_cpu_message: "CPU usage on ${server_name} has returned to normal: ${cpu_usage}%",
restore_disk_message: "Disk usage on ${server_name} has returned to normal: ${disk_usage}%",
restore_network_message: "Network usage on ${server_name} has returned to normal: ${network_usage}%",
restore_cpu_temp_message: "CPU temperature on ${server_name} has returned to normal: ${cpu_temp}°C",
restore_disk_io_message: "Disk I/O on ${server_name} has returned to normal: ${disk_io} MB/s",
};
case 'service':
return {
...base,
templateType: 'service' as const,
up_message: "Service ${service_name} is UP. Response time: ${response_time}ms",
down_message: "Service ${service_name} is DOWN. Status: ${status}",
maintenance_message: "Service ${service_name} is under maintenance",
incident_message: "Service ${service_name} has an incident",
resolved_message: "Issue with service ${service_name} has been resolved",
warning_message: "Warning: Service ${service_name} response time is high",
};
case 'ssl':
return {
...base,
templateType: 'ssl' as const,
expired: "SSL certificate for ${domain} has EXPIRED on ${expiry_date}",
exiring_soon: "SSL certificate for ${domain} will expire in ${days_left} days on ${expiry_date}",
warning: "Warning: SSL certificate for ${domain} requires attention",
};
case 'server_threshold':
return {
...base,
templateType: 'server_threshold' as const,
cpu_threshold: 85,
ram_threshold: 80,
disk_threshold: 90,
network_threshold: 75,
notification_id: "",
server_template_id: "",
};
default:
throw new Error(`Unknown template type: ${templateType}`);
}
};
export interface UseTemplateFormProps {
templateId: string | null;
templateType: TemplateType;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: UseTemplateFormProps) => {
export const useTemplateForm = ({ templateId, templateType, open, onOpenChange, onSuccess }: UseTemplateFormProps) => {
const { toast } = useToast();
const queryClient = useQueryClient();
const isEditMode = !!templateId;
// console.log("Template form initialized with templateId:", templateId, "isEditMode:", isEditMode);
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateFormSchema),
defaultValues: defaultFormValues,
defaultValues: getDefaultValues(templateType),
mode: "onChange"
});
// Query to fetch template data for editing
const { isLoading: isLoadingTemplate, data: templateData } = useQuery({
queryKey: ['template', templateId],
queryKey: ['template', templateId, templateType],
queryFn: () => {
if (!templateId) return null;
console.log("Fetching template data for ID:", templateId);
return templateService.getTemplate(templateId);
return templateService.getTemplate(templateId, templateType);
},
enabled: !!templateId && open,
});
@@ -74,53 +182,71 @@ export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: U
// Set form values when template data is loaded
useEffect(() => {
if (templateData && open) {
// console.log("Setting form values with template data:", templateData);
form.reset({
const formData: any = {
name: templateData.name || "",
type: templateData.type || "default",
up_message: templateData.up_message || "",
down_message: templateData.down_message || "",
maintenance_message: templateData.maintenance_message || "",
incident_message: templateData.incident_message || "",
resolved_message: templateData.resolved_message || "",
service_name_placeholder: templateData.service_name_placeholder || "${service_name}",
response_time_placeholder: templateData.response_time_placeholder || "${response_time}",
status_placeholder: templateData.status_placeholder || "${status}",
threshold_placeholder: templateData.threshold_placeholder || "${threshold}",
});
}
}, [templateData, open, form]);
templateType: templateType,
placeholder: (templateData as any).placeholder || "",
};
// Handle form errors
useEffect(() => {
const subscription = form.formState.errors.root?.message &&
toast({
title: "Error",
description: form.formState.errors.root.message,
variant: "destructive",
// Define the expected fields for each template type
const expectedFields = {
server: [
'ram_message', 'cpu_message', 'disk_message', 'network_message',
'up_message', 'down_message', 'notification_id', 'warning_message',
'paused_message', 'cpu_temp_message', 'disk_io_message',
'restore_ram_message', 'restore_cpu_message', 'restore_disk_message',
'restore_network_message', 'restore_cpu_temp_message', 'restore_disk_io_message'
],
service: [
'up_message', 'down_message', 'maintenance_message', 'incident_message',
'resolved_message', 'warning_message'
],
ssl: ['expired', 'exiring_soon', 'warning'],
server_threshold: [
'cpu_threshold', 'ram_threshold', 'disk_threshold', 'network_threshold',
'notification_id', 'server_template_id'
]
};
// Add template-specific fields
const fieldsToProcess = expectedFields[templateType] || [];
fieldsToProcess.forEach(key => {
let value = (templateData as any)[key];
// Convert string values to numbers for server threshold fields
if (templateType === 'server_threshold' &&
['cpu_threshold', 'ram_threshold', 'disk_threshold', 'network_threshold'].includes(key)) {
value = typeof value === 'string' ? Number(value) : value;
// Ensure valid number, fallback to default if invalid
if (isNaN(value)) {
const defaults = getDefaultValues(templateType) as any;
value = defaults[key] || 0;
}
}
// Set the value, using empty string as fallback for string fields
formData[key] = value !== undefined && value !== null ? value : "";
});
return () => {
if (subscription) {
// Clean up if needed
}
};
}, [form.formState.errors.root, toast]);
console.log("Final form data being set:", formData);
form.reset(formData);
}
}, [templateData, open, form, templateType]);
// Create mutation
const createMutation = useMutation({
mutationFn: (data: CreateUpdateTemplateData) => templateService.createTemplate(data),
mutationFn: (data: AnyTemplateData) => templateService.createTemplate(data, templateType),
onSuccess: () => {
toast({
title: "Template created",
description: "Your notification template has been created successfully.",
});
queryClient.invalidateQueries({ queryKey: ['notification_templates'] });
queryClient.invalidateQueries({ queryKey: ['notification_templates', templateType] });
onSuccess();
},
onError: (error) => {
// console.error("Error creating template:", error);
toast({
title: "Error",
description: "Failed to create template. Please check your inputs and try again.",
@@ -131,18 +257,18 @@ export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: U
// Update mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: CreateUpdateTemplateData }) =>
templateService.updateTemplate(id, data),
mutationFn: ({ id, data }: { id: string; data: AnyTemplateData }) =>
templateService.updateTemplate(id, data, templateType),
onSuccess: () => {
toast({
title: "Template updated",
description: "Your notification template has been updated successfully.",
});
queryClient.invalidateQueries({ queryKey: ['notification_templates'] });
queryClient.invalidateQueries({ queryKey: ['notification_templates', templateType] });
onSuccess();
},
onError: (error) => {
// console.error("Error updating template:", error);
toast({
title: "Error",
description: "Failed to update template. Please check your inputs and try again.",
@@ -155,39 +281,28 @@ export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: U
// Handle form submission
const onSubmit = (formData: TemplateFormData) => {
// console.log("Submitting form data:", formData);
// Ensure all required fields are present
const completeData: CreateUpdateTemplateData = {
name: formData.name,
type: formData.type,
up_message: formData.up_message,
down_message: formData.down_message,
maintenance_message: formData.maintenance_message,
incident_message: formData.incident_message,
resolved_message: formData.resolved_message,
service_name_placeholder: formData.service_name_placeholder,
response_time_placeholder: formData.response_time_placeholder,
status_placeholder: formData.status_placeholder,
threshold_placeholder: formData.threshold_placeholder,
};
// Remove templateType from the data before sending to API
const { templateType: _, ...templateDataWithoutType } = formData;
const completeData = templateDataWithoutType as AnyTemplateData;
if (isEditMode && templateId) {
// console.log("Updating template with ID:", templateId);
updateMutation.mutate({ id: templateId, data: completeData });
} else {
// console.log("Creating new template");
createMutation.mutate(completeData);
}
};
// Reset form when dialog closes
// Reset form when dialog closes or template type changes
useEffect(() => {
if (!open) {
// console.log("Dialog closed, resetting form");
form.reset(defaultFormValues);
console.log("Dialog closed, resetting form");
form.reset(getDefaultValues(templateType));
}
}, [open, form]);
}, [open, form, templateType]);
return {
form,
@@ -196,4 +311,4 @@ export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: U
isSubmitting,
onSubmit
};
};
};
@@ -52,7 +52,6 @@ const DataRetentionSettings = () => {
setLastCleanup(result.lastCleanup);
}
} catch (error) {
console.error("Error loading retention settings:", error);
toast({
title: "Error",
description: "Failed to load retention settings",
@@ -72,7 +71,6 @@ const DataRetentionSettings = () => {
description: "Data retention settings have been updated",
});
} catch (error) {
console.error("Error saving retention settings:", error);
toast({
title: "Error",
description: "Failed to save retention settings",
@@ -96,7 +94,6 @@ const DataRetentionSettings = () => {
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during uptime cleanup:", error);
toast({
title: "Error",
description: "Failed to perform uptime data cleanup",
@@ -120,7 +117,6 @@ const DataRetentionSettings = () => {
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during server cleanup:", error);
toast({
title: "Error",
description: "Failed to perform server data cleanup",
@@ -144,7 +140,6 @@ const DataRetentionSettings = () => {
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during manual cleanup:", error);
toast({
title: "Error",
description: "Failed to perform database cleanup",
@@ -210,10 +205,16 @@ const DataRetentionSettings = () => {
min="1"
max="365"
value={settings.uptimeRetentionDays}
onChange={(e) => setSettings(prev => ({
...prev,
uptimeRetentionDays: parseInt(e.target.value) || 30
}))}
onChange={(e) => {
const value = e.target.value;
if (value === '' || isNaN(Number(value))) {
return;
}
setSettings(prev => ({
...prev,
uptimeRetentionDays: Number(value)
}));
}}
className="mt-1"
/>
<p className="text-sm text-muted-foreground mt-1">
@@ -229,10 +230,16 @@ const DataRetentionSettings = () => {
min="1"
max="365"
value={settings.serverRetentionDays}
onChange={(e) => setSettings(prev => ({
...prev,
serverRetentionDays: parseInt(e.target.value) || 30
}))}
onChange={(e) => {
const value = e.target.value;
if (value === '' || isNaN(Number(value))) {
return;
}
setSettings(prev => ({
...prev,
serverRetentionDays: Number(value)
}));
}}
className="mt-1"
/>
<p className="text-sm text-muted-foreground mt-1">
@@ -250,63 +257,17 @@ const DataRetentionSettings = () => {
</Alert>
)}
</CardContent>
<CardFooter className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2 w-full">
<Button
variant="outline"
onClick={handleUptimeShrink}
disabled={isUptimeShrinking}
className="flex items-center gap-2"
>
{isUptimeShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
Shrink Uptime Data
</Button>
<Button
variant="outline"
onClick={handleServerShrink}
disabled={isServerShrinking}
className="flex items-center gap-2"
>
{isServerShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Server className="h-4 w-4" />
)}
Shrink Server Data
</Button>
<Button
variant="outline"
onClick={handleFullShrink}
disabled={isFullShrinking}
className="flex items-center gap-2"
>
{isFullShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Full Database Shrink
</Button>
</div>
<div className="flex justify-end w-full">
<Button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save Changes
</Button>
</div>
<CardFooter className="flex justify-end">
<Button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save Changes
</Button>
</CardFooter>
</Card>
</div>
@@ -143,7 +143,7 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = () => {
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800">
<ShieldAlert className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<AlertDescription className="text-blue-700 dark:text-blue-300">
<span className="font-medium">Permission Notice:</span> As an admin user, you do not have access to view or modify system and mail settings. These settings can only be accessed and modified by Super Admins. Contact your Super Admin if you need to make changes to system configuration or mail settings.
<span className="font-medium">{t("permissionNotice")}</span> {t("permissionNoticeAddUser")}
</AlertDescription>
</Alert>
</CardContent>
@@ -153,11 +153,11 @@ const GeneralSettingsPanel: React.FC<GeneralSettingsPanelProps> = () => {
}
if (isLoading) {
return <div className="p-4">Loading settings...</div>;
return <div className="p-4">{t("loadingSettings")}</div>;
}
if (error) {
return <div className="p-4 text-red-500">Error loading settings</div>;
return <div className="p-4 text-red-500">{t("loadingSettingsError")}</div>;
}
return (
@@ -126,15 +126,6 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
<Mail className="h-5 w-5" />
{t("sendTestEmail", "settings")}
</DialogTitle>
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4"
onClick={handleClose}
disabled={isLoading}
>
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<div className="space-y-4 py-4">
@@ -203,9 +194,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
{/* Info message */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This will send a test email using your configured SMTP settings. Make sure SMTP is properly configured first.
</AlertDescription>
<AlertDescription>{t("testEmailAlert", "settings")}</AlertDescription>
</Alert>
</div>
@@ -223,7 +212,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
) : (
<Mail className="h-4 w-4" />
)}
{isLoading ? t("sending", "settings") : t("send", "common")}
{isLoading ? t("sending", "settings") : t("send", "settings")}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,4 +1,3 @@
import React, { useEffect } from "react";
import {
Dialog,
@@ -8,12 +7,13 @@ import {
DialogFooter
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Form,
FormControl,
@@ -24,7 +24,10 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { Loader2 } from "lucide-react";
import { Loader2, Copy } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
interface NotificationChannelDialogProps {
open: boolean;
@@ -32,11 +35,12 @@ interface NotificationChannelDialogProps {
editingConfig: AlertConfiguration | null;
}
const baseSchema = z.object({
notify_name: z.string().min(1, "Name is required"),
notification_type: z.enum(["telegram", "discord", "slack", "signal", "email"]),
notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "notifiarr", "gotify", "webhook"]),
enabled: z.boolean().default(true),
service_id: z.string().default("global"), // Assuming global for now, could be linked to specific services
service_id: z.string().default("global"),
template_id: z.string().optional(),
});
@@ -59,11 +63,51 @@ const slackSchema = baseSchema.extend({
const signalSchema = baseSchema.extend({
notification_type: z.literal("signal"),
signal_number: z.string().min(1, "Signal number is required"),
signal_api_endpoint: z.string().url("Must be a valid API endpoint URL"),
});
const googleChatSchema = baseSchema.extend({
notification_type: z.literal("google_chat"),
google_chat_webhook_url: z.string().url("Must be a valid URL"),
});
const emailSchema = baseSchema.extend({
notification_type: z.literal("email"),
// Email specific fields could be added here
email_address: z.string().email("Valid email is required"),
email_sender_name: z.string().min(1, "Sender name is required"),
smtp_server: z.string().min(1, "SMTP server is required"),
smtp_port: z.string().min(1, "SMTP port is required"),
smtp_password: z.string().min(1, "SMTP password is required"),
});
const ntfySchema = baseSchema.extend({
notification_type: z.literal("ntfy"),
ntfy_endpoint: z.string().url("Must be a valid NTFY endpoint URL"),
api_token: z.string().optional(),
});
const pushoverSchema = baseSchema.extend({
notification_type: z.literal("pushover"),
api_token: z.string().min(1, "API token is required"),
user_key: z.string().min(1, "User key is required"),
});
const notifiarrSchema = baseSchema.extend({
notification_type: z.literal("notifiarr"),
api_token: z.string().min(1, "API token is required"),
channel_id: z.string().min(1, "Channel ID is required"),
});
const webhookSchema = baseSchema.extend({
notification_type: z.literal("webhook"),
webhook_url: z.string().url("Must be a valid URL"),
webhook_payload_template: z.string().optional(),
});
const gotifySchema = baseSchema.extend({
notification_type: z.literal("gotify"),
api_token: z.string().min(1, "API token is required"),
server_url: z.string().url("Must be a valid server URL"),
});
const formSchema = z.discriminatedUnion("notification_type", [
@@ -71,11 +115,133 @@ const formSchema = z.discriminatedUnion("notification_type", [
discordSchema,
slackSchema,
signalSchema,
emailSchema
googleChatSchema,
emailSchema,
ntfySchema,
pushoverSchema,
notifiarrSchema,
gotifySchema,
webhookSchema,
]);
type FormValues = z.infer<typeof formSchema>;
const notificationTypeOptions = [
{
value: "telegram",
label: "Telegram",
description: "Send notifications via Telegram bot",
icon: "/upload/notification/telegram.png"
},
{
value: "discord",
label: "Discord",
description: "Send notifications to Discord webhook",
icon: "/upload/notification/discord.png"
},
{
value: "slack",
label: "Slack",
description: "Send notifications to Slack webhook",
icon: "/upload/notification/slack.png"
},
{
value: "signal",
label: "Signal",
description: "Send notifications via Signal",
icon: "/upload/notification/signal.png"
},
{
value: "google_chat",
label: "Google Chat",
description: "Send notifications to Google Chat webhook",
icon: "/upload/notification/google.png"
},
{
value: "email",
label: "Email",
description: "Send notifications via email",
icon: "/upload/notification/email.png"
},
{
value: "ntfy",
label: "NTFY",
description: "Send notifications via NTFY push service",
icon: "/upload/notification/ntfy.png"
},
{
value: "pushover",
label: "Pushover",
description: "Send push notifications via Pushover",
icon: "/upload/notification/pushover.png"
},
{
value: "notifiarr",
label: "Notifiarr",
description: "Send notifications via Notifiarr",
icon: "/upload/notification/notifiarr.png"
},
{
value: "gotify",
label: "Gotify",
description: "Send push notifications via Gotify",
icon: "/upload/notification/gotify.png"
},
{
value: "webhook",
label: "Webhook",
description: "Send notifications to custom webhook",
icon: "/upload/notification/webhook.png"
},
];
const webhookPayloadTemplates = {
server: `{
"alert_type": "server",
"server_name": "\${server_name}",
"status": "\${status}",
"cpu_usage": "\${cpu_usage}",
"ram_usage": "\${ram_usage}",
"disk_usage": "\${disk_usage}",
"network_usage": "\${network_usage}",
"cpu_temp": "\${cpu_temp}",
"disk_io": "\${disk_io}",
"threshold": "\${time}",
"message": "Server \${server_name} alert: \${status}"
}`,
service: `{
"alert_type": "service",
"service_name": "\${service_name}",
"status": "\${status}",
"response_time": "\${response_time}",
"url": "\${url}",
"uptime": "\${uptime}",
"downtime": "\${downtime}",
"timestamp": "\${time}",
"message": "Service \${service_name} is \${status}"
}`,
ssl: `{
"alert_type": "ssl",
"domain": "\${domain}",
"certificate_name": "\${certificate_name}",
"expiry_date": "\${expiry_date}",
"days_left": "\${days_left}",
"issuer": "\${issuer}",
"serial_number": "\${serial_number}",
"timestamp": "\${time}",
"message": "SSL certificate for \${domain} expires in \${days_left} days"
}`
};
const defaultPayloadTemplate = `{
"alert_type": "general",
"service_name": "\${service_name}",
"status": "\${status}",
"message": "\${message}",
"timestamp": "\${time}"
}`;
export const NotificationChannelDialog = ({
open,
onClose,
@@ -93,7 +259,7 @@ export const NotificationChannelDialog = ({
},
});
const { watch, reset } = form;
const { watch, reset, setValue } = form;
const notificationType = watch("notification_type");
const [isSubmitting, setIsSubmitting] = React.useState(false);
@@ -123,10 +289,14 @@ export const NotificationChannelDialog = ({
onClose(false);
};
const insertTemplate = (template: string) => {
setValue("webhook_payload_template", template);
};
const onSubmit = async (values: FormValues) => {
setIsSubmitting(true);
try {
// Ensure service_id is always present
// Handle all notification types including webhook through alert_configurations
const configData = {
...values,
service_id: values.service_id || "global",
@@ -137,7 +307,14 @@ export const NotificationChannelDialog = ({
} else {
await alertConfigService.createAlertConfiguration(configData as any);
}
onClose(true); // Close with refresh
} catch (error) {
toast({
title: "Error",
description: "Failed to save notification channel",
variant: "destructive"
});
} finally {
setIsSubmitting(false);
}
@@ -145,7 +322,7 @@ export const NotificationChannelDialog = ({
return (
<Dialog open={open} onOpenChange={() => handleClose()}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEditing ? "Edit Notification Channel" : "Add Notification Channel"}
@@ -159,7 +336,7 @@ export const NotificationChannelDialog = ({
name="notify_name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormLabel>Channel Name</FormLabel>
<FormControl>
<Input placeholder="My Notification Channel" {...field} />
</FormControl>
@@ -175,63 +352,37 @@ export const NotificationChannelDialog = ({
control={form.control}
name="notification_type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormItem>
<FormLabel>Channel Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="telegram" />
</FormControl>
<FormLabel className="font-normal">
Telegram
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="discord" />
</FormControl>
<FormLabel className="font-normal">
Discord
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="slack" />
</FormControl>
<FormLabel className="font-normal">
Slack
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="signal" />
</FormControl>
<FormLabel className="font-normal">
Signal
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="email" />
</FormControl>
<FormLabel className="font-normal">
Email
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select notification type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{notificationTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center space-x-3">
<img
src={option.icon}
alt={`${option.label} icon`}
className="w-5 h-5 object-contain"
/>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-xs text-muted-foreground">{option.description}</span>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Show different fields based on notification type */}
{notificationType === "telegram" && (
<>
<FormField
@@ -243,6 +394,9 @@ export const NotificationChannelDialog = ({
<FormControl>
<Input placeholder="Telegram Chat ID" {...field} />
</FormControl>
<FormDescription>
The Telegram chat ID to send notifications to
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -256,6 +410,9 @@ export const NotificationChannelDialog = ({
<FormControl>
<Input placeholder="Telegram Bot Token" {...field} type="password" />
</FormControl>
<FormDescription>
Your Telegram bot token from @BotFather
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -271,8 +428,11 @@ export const NotificationChannelDialog = ({
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input placeholder="Discord Webhook URL" {...field} />
<Input placeholder="https://discord.com/api/webhooks/..." {...field} />
</FormControl>
<FormDescription>
Discord webhook URL from your server settings
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -287,8 +447,11 @@ export const NotificationChannelDialog = ({
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input placeholder="Slack Webhook URL" {...field} />
<Input placeholder="https://hooks.slack.com/services/..." {...field} />
</FormControl>
<FormDescription>
Slack incoming webhook URL
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -296,20 +459,378 @@ export const NotificationChannelDialog = ({
)}
{notificationType === "signal" && (
<>
<FormField
control={form.control}
name="signal_number"
render={({ field }) => (
<FormItem>
<FormLabel>Signal Number</FormLabel>
<FormControl>
<Input placeholder="+1234567890" {...field} />
</FormControl>
<FormDescription>
Signal phone number to send notifications to
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signal_api_endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Signal API Endpoint</FormLabel>
<FormControl>
<Input placeholder="https://your-signal-api.com/v2/send" {...field} />
</FormControl>
<FormDescription>
The Rest API endpoint for your Signal service
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "google_chat" && (
<FormField
control={form.control}
name="signal_number"
name="google_chat_webhook_url"
render={({ field }) => (
<FormItem>
<FormLabel>Signal Number</FormLabel>
<FormLabel>Google Chat Webhook URL</FormLabel>
<FormControl>
<Input placeholder="+1234567890" {...field} />
<Input placeholder="https://chat.googleapis.com/v1/spaces/..." {...field} />
</FormControl>
<FormDescription>
Google Chat webhook URL from your Google Chat space
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{notificationType === "email" && (
<>
<FormField
control={form.control}
name="email_address"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input placeholder="notifications@example.com" {...field} type="email" />
</FormControl>
<FormDescription>
Email address to send notifications to
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email_sender_name"
render={({ field }) => (
<FormItem>
<FormLabel>Sender Name</FormLabel>
<FormControl>
<Input placeholder="Alert System" {...field} />
</FormControl>
<FormDescription>
Display name for outgoing emails
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="smtp_server"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Server</FormLabel>
<FormControl>
<Input placeholder="smtp.gmail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtp_port"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input placeholder="587" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="smtp_password"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Password</FormLabel>
<FormControl>
<Input placeholder="Enter your SMTP password" {...field} type="password" />
</FormControl>
<FormDescription>
Password for authenticating with the SMTP server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "ntfy" && (
<>
<FormField
control={form.control}
name="ntfy_endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>NTFY Endpoint</FormLabel>
<FormControl>
<Input placeholder="https://ntfy.sh/your-topic" {...field} />
</FormControl>
<FormDescription>
The NTFY endpoint URL including your topic (e.g., https://ntfy.sh/checkcle)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token (Optional)</FormLabel>
<FormControl>
<Input placeholder="Enter NTFY API token" {...field} type="password" />
</FormControl>
<FormDescription>
Optional API token for authentication with NTFY server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "pushover" && (
<>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="Your Pushover API token" {...field} type="password" />
</FormControl>
<FormDescription>
Your Pushover application API token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="user_key"
render={({ field }) => (
<FormItem>
<FormLabel>User Key</FormLabel>
<FormControl>
<Input placeholder="Your Pushover user key" {...field} />
</FormControl>
<FormDescription>
Your Pushover user key (or group key)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "notifiarr" && (
<>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="Your Notifiarr API token" {...field} type="password" />
</FormControl>
<FormDescription>
Your Notifiarr API token for sending notifications
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel_id"
render={({ field }) => (
<FormItem>
<FormLabel>Channel ID</FormLabel>
<FormControl>
<Input placeholder="Discord Channel ID" {...field} />
</FormControl>
<FormDescription>
The Discord channel ID where notifications will be sent
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "gotify" && (
<>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="Your Gotify API token" {...field} type="password" />
</FormControl>
<FormDescription>
Your Gotify application API token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="server_url"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input placeholder="https://your-gotify-server.com" {...field} />
</FormControl>
<FormDescription>
The URL of your Gotify server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "webhook" && (
<>
<FormField
control={form.control}
name="webhook_url"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input placeholder="https://api.example.com/webhook" {...field} />
</FormControl>
<FormDescription>
The URL where webhook notifications will be sent
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<FormField
control={form.control}
name="webhook_payload_template"
render={({ field }) => (
<FormItem>
<FormLabel>Payload Template (Optional)</FormLabel>
<FormDescription>
JSON template for the webhook payload. Leave empty to use default template.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Payload Templates</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="mt-4">
<h4 className="text-sm font-medium mb-2">Available Placeholders:</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div className="space-y-1">
<p className="font-medium text-muted-foreground">Server:</p>
<div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{server_name}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{cpu_usage}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{ram_usage}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{disk_usage}'}</code>
</div>
</div>
<div className="space-y-1">
<p className="font-medium text-muted-foreground">Service:</p>
<div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{service_name}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{response_time}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{url}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{uptime}'}</code>
</div>
</div>
<div className="space-y-1">
<p className="font-medium text-muted-foreground">SSL:</p>
<div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{domain}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{expiry_date}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{days_left}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{issuer}'}</code>
</div>
</div>
<div className="space-y-1">
<p className="font-medium text-muted-foreground">Common:</p>
<div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{status}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{time}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{message}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{threshold}'}</code>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</>
)}
<FormField
control={form.control}
@@ -319,7 +840,7 @@ export const NotificationChannelDialog = ({
<div className="space-y-0.5">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Turn notifications on or off
Enable or disable this notification channel
</FormDescription>
</div>
<FormControl>
@@ -338,7 +859,7 @@ export const NotificationChannelDialog = ({
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? "Update" : "Create"}
{isEditing ? "Update Channel" : "Create Channel"}
</Button>
</DialogFooter>
</form>
@@ -346,4 +867,4 @@ export const NotificationChannelDialog = ({
</DialogContent>
</Dialog>
);
};
};
@@ -13,9 +13,17 @@ import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { alertConfigService } from "@/services/alertConfigService";
import { pb } from "@/lib/pocketbase";
interface CombinedChannel extends Partial<AlertConfiguration> {
isWebhook?: boolean;
url?: string;
method?: string;
description?: string;
}
interface NotificationChannelListProps {
channels: AlertConfiguration[];
channels: CombinedChannel[];
onEdit: (config: AlertConfiguration) => void;
onDelete: (id: string) => void;
}
@@ -25,25 +33,65 @@ export const NotificationChannelList = ({
onEdit,
onDelete
}: NotificationChannelListProps) => {
const toggleEnabled = async (config: AlertConfiguration) => {
const toggleEnabled = async (config: CombinedChannel) => {
if (!config.id) return;
await alertConfigService.updateAlertConfiguration(config.id, {
enabled: !config.enabled
});
// The parent component will refresh the list
onEdit(config);
if (config.isWebhook) {
// Handle webhook toggle
try {
const newEnabled = config.enabled ? "off" : "on";
await pb.collection('webhook').update(config.id, {
enabled: newEnabled
});
// Trigger refresh by calling onEdit with empty config
onEdit({} as AlertConfiguration);
} catch (error) {
console.error("Error updating webhook:", error);
}
} else {
// Handle alert config toggle
await alertConfigService.updateAlertConfiguration(config.id, {
enabled: !config.enabled
});
// The parent component will refresh the list
onEdit(config as AlertConfiguration);
}
};
const getChannelTypeLabel = (type: string) => {
const getChannelTypeLabel = (type: string | undefined) => {
switch(type) {
case "telegram": return "Telegram";
case "discord": return "Discord";
case "slack": return "Slack";
case "signal": return "Signal";
case "google_chat": return "Google Chat";
case "email": return "Email";
default: return type;
case "pushover": return "Pushover";
case "notifiarr": return "Notifiarr";
case "webhook": return "Webhook";
default: return type || "Unknown";
}
};
const getChannelDetails = (config: CombinedChannel) => {
if (config.isWebhook) {
return `${config.method || 'POST'} ${config.url || ''}`;
}
switch(config.notification_type) {
case "telegram":
return config.telegram_chat_id || '';
case "discord":
case "slack":
case "google_chat":
return config.discord_webhook_url || config.slack_webhook_url || config.google_chat_webhook_url || '';
case "signal":
return config.signal_number || '';
case "email":
return config.email_address || '';
default:
return '';
}
};
@@ -66,6 +114,7 @@ export const NotificationChannelList = ({
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Details</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
@@ -78,11 +127,14 @@ export const NotificationChannelList = ({
<TableCell>
<Badge variant="outline">{getChannelTypeLabel(channel.notification_type)}</Badge>
</TableCell>
<TableCell className="max-w-xs truncate text-sm text-muted-foreground">
{getChannelDetails(channel)}
</TableCell>
<TableCell>
<Switch
checked={
typeof channel.enabled === 'string'
? channel.enabled === "true"
? channel.enabled === "true" || channel.enabled === "on"
: !!channel.enabled
}
onCheckedChange={() => toggleEnabled(channel)}
@@ -96,7 +148,8 @@ export const NotificationChannelList = ({
<Button
variant="outline"
size="sm"
onClick={() => onEdit(channel)}
onClick={() => onEdit(channel as AlertConfiguration)}
disabled={channel.isWebhook} // Disable edit for webhooks for now
>
<Edit className="h-4 w-4" />
</Button>
@@ -119,4 +172,4 @@ export const NotificationChannelList = ({
</Table>
</div>
);
};
};
@@ -5,28 +5,47 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Plus, Loader2 } from "lucide-react";
import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService";
import { WebhookConfiguration, webhookService } from "@/services/webhookService";
import { NotificationChannelDialog } from "./NotificationChannelDialog";
import { NotificationChannelList } from "./NotificationChannelList";
import { pb } from "@/lib/pocketbase";
interface CombinedChannel extends Partial<AlertConfiguration> {
isWebhook?: boolean;
url?: string;
method?: string;
description?: string;
}
const NotificationSettings = () => {
const [isLoading, setIsLoading] = useState(true);
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [webhookConfigs, setWebhookConfigs] = useState<WebhookConfiguration[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [currentTab, setCurrentTab] = useState<string>("all");
const [editingConfig, setEditingConfig] = useState<AlertConfiguration | null>(null);
const fetchAlertConfigurations = async () => {
const fetchNotificationChannels = async () => {
setIsLoading(true);
try {
// Fetch alert configurations
const configs = await alertConfigService.getAlertConfigurations();
setAlertConfigs(configs);
// Fetch webhooks
try {
const webhookResponse = await pb.collection('webhook').getList(1, 50);
setWebhookConfigs(webhookResponse.items as WebhookConfiguration[]);
} catch (webhookError) {
setWebhookConfigs([]);
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchAlertConfigurations();
fetchNotificationChannels();
}, []);
const handleAddNew = () => {
@@ -40,22 +59,66 @@ const NotificationSettings = () => {
};
const handleDelete = async (id: string) => {
const success = await alertConfigService.deleteAlertConfiguration(id);
if (success) {
fetchAlertConfigurations();
// Check if it's a webhook first
const isWebhook = webhookConfigs.find(w => w.id === id);
if (isWebhook) {
// Handle webhook deletion
if (confirm("Are you sure you want to delete this webhook?")) {
try {
await pb.collection('webhook').delete(id);
fetchNotificationChannels();
} catch (error) {
console.error("Error deleting webhook:", error);
}
}
} else {
// Handle alert config deletion
const success = await alertConfigService.deleteAlertConfiguration(id);
if (success) {
fetchNotificationChannels();
}
}
};
const handleDialogClose = (refreshList: boolean) => {
setDialogOpen(false);
if (refreshList) {
fetchAlertConfigurations();
fetchNotificationChannels();
}
};
const getCombinedChannels = (): CombinedChannel[] => {
const combined: CombinedChannel[] = [];
// Add alert configurations
alertConfigs.forEach(config => {
combined.push(config);
});
// Add webhooks as notification channels
webhookConfigs.forEach(webhook => {
combined.push({
id: webhook.id,
notify_name: webhook.name,
notification_type: "webhook" as const,
enabled: webhook.enabled === "on",
created: webhook.created,
updated: webhook.updated,
isWebhook: true,
url: webhook.url,
method: webhook.method,
description: webhook.description
});
});
return combined;
};
const getFilteredConfigs = () => {
if (currentTab === "all") return alertConfigs;
return alertConfigs.filter(config => config.notification_type === currentTab);
const combined = getCombinedChannels();
if (currentTab === "all") return combined;
return combined.filter(config => config.notification_type === currentTab);
};
return (
@@ -86,7 +149,9 @@ const NotificationSettings = () => {
<TabsTrigger value="discord">Discord</TabsTrigger>
<TabsTrigger value="slack">Slack</TabsTrigger>
<TabsTrigger value="signal">Signal</TabsTrigger>
<TabsTrigger value="google_chat">Google Chat</TabsTrigger>
<TabsTrigger value="email">Email</TabsTrigger>
<TabsTrigger value="webhook">Webhook</TabsTrigger>
</TabsList>
<TabsContent value={currentTab} className="mt-0">
@@ -114,4 +179,4 @@ const NotificationSettings = () => {
);
};
export default NotificationSettings;
export default NotificationSettings;
@@ -10,7 +10,10 @@ import { UserCog, Loader2, AlertCircle, Info, Users, ShieldAlert } from "lucide-
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { authService } from "@/services/authService";
import { useLanguage } from "@/contexts/LanguageContext.tsx";
const UserManagement = () => {
const { t } = useLanguage();
const {
users,
loading,
@@ -44,21 +47,21 @@ const UserManagement = () => {
<AccordionTrigger className="py-4 px-5 bg-card hover:bg-card/90 hover:no-underline rounded-lg text-lg font-medium flex items-center w-full">
<div className="flex items-center">
<UserCog className="h-5 w-5 mr-2 text-green-500" />
<span>User Management</span>
<span>{t("userManagement")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="p-4 pt-6 bg-background rounded-b-lg">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">User Management</h2>
<h2 className="text-2xl font-bold">{t("userManagement")}</h2>
{isSuperAdmin && <button onClick={() => setIsAddUserDialogOpen(true)} className="bg-green-500 hover:bg-green-600 text-white py-2 px-4 rounded-md flex items-center">
<span className="mr-1">+</span> Add User
<span className="mr-1">+</span> {t("addUser")}
</button>}
</div>
{!isSuperAdmin && <Alert className="mb-6 border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800">
<ShieldAlert className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<AlertDescription className="text-blue-700 dark:text-blue-300">
<span className="font-medium">Permission Notice:</span> As an admin user, you have access to view and modify existing user details. However, only Super Admins have permission to create new user accounts. Contact your Super Admin if you need to add a new user.
<span className="font-medium">{t("permissionNotice")}</span> As an admin user, you have access to view and modify existing user details. However, only Super Admins have permission to create new user accounts. Contact your Super Admin if you need to add a new user.
</AlertDescription>
</Alert>}

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