200 Commits

Author SHA1 Message Date
Tola Leng f5a985ff1e add new functionality and improve backend server structure
- Enhance server functionality and optimize backend code
- Add multi-database support (PostgreSQL, MySQL, SQLite)
2026-03-09 17:36:42 +07:00
Tola Leng 4b27b85038 Merge pull request #215 from MrSchneemann/feat/matrix-notification
feat: add Matrix chat notification support (#211)
2026-03-08 14:41:01 +07:00
MrSchneemann 463299fa73 feat(matrix): add HTML formatted messages with severity colors
Matrix notifications now send both a plain-text fallback and an
HTML formatted_body (org.matrix.custom.html) so Element and other
Matrix clients render rich, colored alerts:

- Down/failed/error → red (#E74C3C)
- Warning/expiring  → yellow (#F39C12)
- Maintenance/paused → orange (#E67E22)
- Up/resolved       → green (#2ECC71)
- Default/info      → blue (#3498DB)

Detail lines from the plain-text message are rendered as a <ul>
bullet list beneath the colored heading. Plain Body is preserved
for clients without HTML support.

Also includes resource type (CPU/DISK/etc.) in fallback server
alert messages to improve context.
2026-03-01 10:56:56 +01:00
MrSchneemann 91ceddc489 feat: add Matrix chat notification support (#211)
Implements native Matrix notification channel support, allowing users
to receive service alerts in any Matrix room via the Client-Server API.

Changes:
- server/service-operation/notification/matrix.go: new Matrix service
  implementing the NotificationService interface (PUT /_matrix/client/v3/...)
- server/service-operation/notification/types.go: add MatrixHomeserver,
  MatrixRoomID, MatrixAccessToken fields to AlertConfiguration
- server/service-operation/notification/manager.go: register MatrixService
- server/pb_migrations/1772200000_updated_alert_configurations_matrix.js:
  PocketBase migration adding matrix fields and notification_type value
- application: Matrix option in channel dialog with homeserver/room/token
  fields, tab filter, list display, type definitions and translations (en, km)
- application/public/upload/notification/matrix.png: Matrix channel icon
2026-03-01 10:48:01 +01:00
Tola Leng f2c338ced9 fix(security): enforce API authentication rules on collection (#214)
- Add authentication and authorization rules to prevent unauthorized access via REST API endpoints.
2026-02-27 00:17:45 +07:00
Tola Leng 9dbd267a22 docs: update progress and add implement multi-database support (PostgreSQL, MySQL, SQLite) 2026-02-15 16:16:58 +07:00
Tola Leng 2394243cb9 Merge pull request #207 from giriparus/impersonate-user
feat: implement user impersonation
2026-02-09 14:14:25 +07:00
giriparus bc38dba859 feat: implement user impersonation 2026-02-03 11:51:35 +05:30
Tola Leng 8f84fbf562 Update X platform URL 2026-01-31 15:38:44 +07:00
Tola Leng 6b8ee741b3 Fix server agent ID & token refresh when adding multiple agents (closes #165) 2026-01-24 21:16:10 +07:00
Tola Leng 370f613080 Fix hostname/IP validation to prevent whitespace input in the SSL and Create New Service form (closes #114) 2026-01-24 20:58:33 +07:00
Tola Leng 4f367b92da Fix responsive breakpoints and flex layout for sidebar menu 2026-01-13 01:28:57 +07:00
Tola Leng 6ab3be6fe4 Merge pull request #203 from aalhendi/fix/invalid-credentials-translation
fix: add missing invalidCredentials translation key
2026-01-05 03:19:43 +07:00
Abdulrazaq Alhendi 4221c4751a fix: add missing invalidCredentials translation key 2026-01-04 21:27:39 +03:00
Tola Leng a39b326f0e ci: disable frontend image build in build-frontend-image.yml 2025-12-02 00:45:44 +07:00
Tola Leng 84916f705a Refactor Menu Item to fix multi-line wrapping and spacing 2025-12-02 00:39:33 +07:00
Tola Leng e4e87abac5 Remove dockerfile of frontend from root directory 2025-11-28 17:01:41 +07:00
Tola Leng f528f6eaea reactor the Dockerfile.frontend to locate in Docker folder 2025-11-21 22:14:03 +07:00
Tola Leng b8966fe743 Merge pull request #193 from gnsworks/develop
Update : Japanese documents translation
2025-10-26 17:15:22 +07:00
gnsworks 2a4a6cea48 Update : Japanese documents translation 2025-10-25 21:15:22 +09:00
Tola Leng 3a3dc020fd refactor: update the Regional Monitoring Agent dialog structure with tabs and proper form layout. 2025-10-24 21:48:14 +07:00
Tola Leng ed5cff5026 refactor: Add the ENG translation string for the regional agent 2025-10-24 21:47:22 +07:00
Tola Leng bf45f896bd 🔐 docs(security): update version supported in Security terms 2025-10-23 02:23:19 +07:00
Tola Leng 00430b6cb7 refactor: update docker image for frontend development 2025-10-22 21:07:19 +07:00
Tola Leng feb65edee5 refactor: X url link in header 2025-10-22 21:06:32 +07:00
Tola Leng 04f125ac1e Merge pull request #188 from diptamahardhika/chore/dev-compose-persist-pb-data
feat(instance): multi-select & bulk delete; dev compose; persist pocketbase data volume in dev compose
2025-10-22 21:01:16 +07:00
Pradipta Mahardika 435694f694 chore(dev): persist pocketbase data volume in dev compose and docs 2025-10-16 23:43:31 +07:00
Tola Leng c33328f977 chore(project): Update development status and progress
- fix(server): regenerate Server Agent ID and Token after each agent creation without page refresh
2025-10-15 20:07:26 +07:00
Tola Leng 17e0d0a794 fix(server): regenerate Server Agent ID and Token after each agent creation without page refresh
- Previously, the Add Server Monitoring Agent dialog required a manual page refresh to show the new Server Agent ID and Server Token. This update automatically  generates and displays new credentials after each agent creation.
2025-10-13 14:57:51 +07:00
Tola Leng d408658b69 refactor: update database schema to align with UI date field changes 2025-10-11 20:57:09 +07:00
Tola Leng 682ce84ef7 docs: update contribution guidelines in CONTRIBUTING.md 2025-10-11 20:52:16 +07:00
Tola Leng 7392580db8 refactor: update database schema and migration files 2025-10-11 20:45:10 +07:00
Tola Leng ee5e66eb44 chore(project): Update development status and progress
- Fixed incorrect recovery time display in incident history
- Added a configuration option to delay downtime alert notifications
2025-10-11 20:24:47 +07:00
Tola Leng ed220b9a27 Merge pull request #178 from lyker189/develop
feat(i18n): localize frontend components and add missing translations
2025-10-02 22:31:03 +07:00
lyker189 b6ef4ff7a5 feat(i18n): localize frontend components and add missing translations
- Replaced hardcoded UI strings with translation keys across frontend files
- Added and updated English(en) and Khmer(km) translation files for settings, notifications, and server components
- Improved localization and multi-language support throughout the frontend
2025-10-02 05:10:11 +07:00
Tola Leng a4364936e3 style(sidebar): update menu items to use normal font weight 2025-10-01 22:59:21 +07:00
Tola Leng f5a8d989cc fix(ui): make "Rows per page" label display on a single line 2025-10-01 22:46:01 +07:00
Tola Leng 2e2df30b85 docs(readme): add JetBrains as a community supporter 2025-10-01 15:26:50 +07:00
Tola Leng 498b26dbd5 chore(project): Update development status and progress 2025-09-29 15:38:03 +07:00
Tola Leng 79fda8105f refactor: profile picture selection
- Removed profile picture selection UI from create/edit user forms. We will use the randome avatar instead.
2025-09-15 22:08:29 +07:00
Tola Leng 8d8e871b9f Fix: Enable/Disable notifications directly
- Make the status switch functional to toggle notifications directly from the notification channel list.
2025-09-15 21:04:10 +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
891 changed files with 187078 additions and 7769 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
server/
**/node_modules
**/dist
**/.next
**/.turbo
**/.cache
**/coverage
**/*.log
+1 -1
View File
@@ -1,6 +1,6 @@
# These are supported funding model platforms # These are supported funding model platforms
github: [tolaleng] github: #[tolaleng]
patreon: # Replace with a single Patreon username patreon: # Replace with a single Patreon username
open_collective: #checkcle open_collective: #checkcle
ko_fi: # Replace with a single Ko-fi username ko_fi: # Replace with a single Ko-fi username
+15 -15
View File
@@ -9,16 +9,16 @@ body:
attributes: attributes:
value: | value: |
**Thank you for taking the time to report a bug!** **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 - type: input
id: environment id: environment
attributes: attributes:
label: Environment label: Environment
description: | description: |
Describe where you encountered the issue. Describe the environment where the issue occurred.
(e.g., OS, browser, Node version, etc.) (e.g., OS, browser, Node.js version, Docker version, etc.)
placeholder: "Example: macOS 14.0, Chrome 125, Node.js 20.3.0" placeholder: "Example: Ubuntu 22.04, Chrome 125, Node.js 20.3.0"
validations: validations:
required: true required: true
@@ -27,7 +27,7 @@ body:
attributes: attributes:
label: Bug Description label: Bug Description
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..." placeholder: "When I do X, Y happens instead of Z..."
validations: validations:
required: true required: true
@@ -37,13 +37,12 @@ body:
attributes: attributes:
label: Steps to Reproduce label: Steps to Reproduce
description: | description: |
How can we reproduce the behavior? Provide step-by-step instructions to reproduce the issue.
Please list the steps in order.
placeholder: | placeholder: |
1. Go to '...' 1. Go to '...'
2. Click on '...' 2. Click on '...'
3. Scroll down to '...' 3. Scroll down to '...'
4. See error 4. See the error
validations: validations:
required: true required: true
@@ -61,8 +60,8 @@ body:
attributes: attributes:
label: Screenshots or Videos label: Screenshots or Videos
description: | description: |
If applicable, add screenshots or screen recordings to help explain your problem. If applicable, add screenshots or screen recordings to help illustrate the issue.
placeholder: "Attach screenshots here." placeholder: "Drag and drop or paste screenshots here."
validations: validations:
required: false required: false
@@ -71,8 +70,8 @@ body:
attributes: attributes:
label: Logs label: Logs
description: | description: |
If applicable, paste logs or error messages here. If applicable, paste any relevant logs or error messages.
Please remove any sensitive information. Please remove sensitive information before submitting.
render: shell render: shell
placeholder: | placeholder: |
``` ```
@@ -86,9 +85,10 @@ body:
id: terms id: terms
attributes: attributes:
label: Checklist label: Checklist
description: Before submitting, please confirm: description: |
Please confirm the following before submitting:
options: options:
- label: I have searched existing issues to avoid duplicates. - label: I have searched existing issues to avoid creating duplicates.
required: true required: true
- label: I have provided enough information for reproduction. - label: I have provided sufficient information to reproduce the issue.
required: true 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 id: description
attributes: attributes:
label: Feature Description label: Feature Description
description: | description: A clear and concise description of the feature youd like to see.
A clear and concise description of the feature youd like to see.
placeholder: "I would like to have..." placeholder: "I would like to have..."
validations: validations:
required: true required: true
@@ -25,8 +24,7 @@ body:
id: motivation id: motivation
attributes: attributes:
label: Motivation label: Motivation
description: | description: Please explain why this feature would be useful.
Please explain why this feature would be useful.
placeholder: "This feature would help because..." placeholder: "This feature would help because..."
validations: validations:
required: true required: true
@@ -35,8 +33,7 @@ body:
id: alternatives id: alternatives
attributes: attributes:
label: Alternatives Considered label: Alternatives Considered
description: | description: Have you considered any alternative solutions or workarounds?
Have you considered any alternative solutions or workarounds?
placeholder: "I have tried..." placeholder: "I have tried..."
validations: validations:
required: false required: false
@@ -45,8 +42,7 @@ body:
id: additional id: additional
attributes: attributes:
label: Additional Context label: Additional Context
description: | description: Add any other context or screenshots about the feature request here.
Add any other context or screenshots about the feature request here.
placeholder: "Links to related issues or references..." placeholder: "Links to related issues or references..."
validations: validations:
required: false required: false
@@ -55,7 +51,8 @@ body:
id: checklist id: checklist
attributes: attributes:
label: Checklist label: Checklist
description: Before submitting, please confirm: description: |
Before submitting, please confirm:
options: options:
- label: I have searched existing issues to make sure this feature hasnt been requested yet. - label: I have searched existing issues to make sure this feature hasnt been requested yet.
required: true required: true
@@ -1,5 +1,5 @@
name: ❓ Ask for Help 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]: " title: "[Question]: "
labels: [question] labels: [question]
assignees: [] assignees: []
@@ -8,8 +8,8 @@ body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
**Thank you for reaching out!** **Thanks for reaching out!**
Please fill out this form to help us understand your question. Please provide as much detail as possible so we can better assist you.
- type: textarea - type: textarea
id: question id: question
@@ -17,17 +17,17 @@ body:
label: What do you need help with? label: What do you need help with?
description: Clearly describe your question or what you're trying to achieve. description: Clearly describe your question or what you're trying to achieve.
placeholder: | 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: validations:
required: true required: true
- type: textarea - type: textarea
id: context id: context
attributes: attributes:
label: Context label: Additional Context
description: | description: |
Provide any additional context, related issues, or links that help explain your question. Provide any relevant context, related issues, pull requests, or documentation links that could help us understand your question better.
placeholder: "Related issues, pull requests, or documentation..." placeholder: "Mention related issues, links to docs, or previous discussions..."
validations: validations:
required: false required: false
@@ -36,8 +36,8 @@ body:
attributes: attributes:
label: Environment Details label: Environment Details
description: | description: |
If applicable, please provide details about your environment (OS, Node version, browser, etc.). If relevant, provide details about your environment (e.g., OS, browser, Node.js version).
placeholder: "Example: Ubuntu 22.04, Node.js v20, Chrome 125" placeholder: "Example: Windows 11, Node.js v20.3.0, Firefox 125"
validations: validations:
required: false required: false
@@ -45,9 +45,10 @@ body:
id: checklist id: checklist
attributes: attributes:
label: Checklist label: Checklist
description: Before submitting, please confirm: description: |
Before submitting, please confirm the following:
options: options:
- label: I have searched existing issues and discussions. - label: I have searched existing issues and discussions for a solution.
required: true required: true
- label: I have read the documentation. - label: I have read the relevant documentation.
required: true required: true
@@ -0,0 +1,59 @@
name: Build and Publish Frontend Image
on:
push:
branches: [ "develop", "feature/**" ]
paths:
- 'application/**'
- '.github/workflows/build-frontend-image.yml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/checkcle-frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: application
file: application/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
+2
View File
@@ -22,6 +22,8 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
pocketbase.exe
application/src/lib/pocketbase.ts application/src/lib/pocketbase.ts
server/pb_data/
Dockerfile Dockerfile
application/src/lib/pocketbase-prod.ts application/src/lib/pocketbase-prod.ts
+4
View File
@@ -0,0 +1,4 @@
fileignoreconfig:
- filename: application/package-lock.json
checksum: 3253a06ef27a634ec2b8a8fe9c156288b9fcf57024623c7ddc3d321f2568e498
version: "1.0"
+2 -2
View File
@@ -79,7 +79,7 @@ Click "Fork" on [GitHub](https://github.com/operacle/checkcle) to create your ow
### 2. Clone Your Fork ### 2. Clone Your Fork
```bash ```bash
git clone --branch develop https://github.com/operacle/checkcle.git git clone https://github.com/yourusername/checkcle.git
cd checkcle cd checkcle
``` ```
@@ -116,7 +116,7 @@ go run main.go (you do not need to change the .env while it's the localhost conn
Click "Fork" on [GitHub](https://github.com/operacle/Distributed-Regional-Monitoring) to create your own copy. Click "Fork" on [GitHub](https://github.com/operacle/Distributed-Regional-Monitoring) to create your own copy.
#### 2. Clone Your Fork #### 2. Clone Your Fork
git clone --branch main https://github.com/operacle/Distributed-Regional-Monitoring.git git clone https://github.com/yourusername/Distributed-Regional-Monitoring.git
cd Distributed-Regional-Monitoring cd Distributed-Regional-Monitoring
#### 3. Install Go Service (make sure you have installed the Go Service) #### 3. Install Go Service (make sure you have installed the Go Service)
+75 -63
View File
@@ -20,6 +20,12 @@
<br/><strong>Japanese</strong> <br/><strong>Japanese</strong>
</a> </a>
</td> </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> </tr>
</table> </table>
@@ -39,10 +45,12 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
## 🎯 Live Demo ## 🎯 Live Demo
👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io) 👉 **Try it now:** [CheckCle Live Demo](https://demo.checkcle.io)
User: admin@example.com | Passwd: Admin123456
## 🌟 Core Features ## 🌟 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 HTTP, DNS, and Ping protocols
- Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP) - Monitor TCP-based, API services (e.g., FTP, SMTP, HTTP)
- Track detail uptime, response times, and performance issues - Track detail uptime, response times, and performance issues
@@ -52,7 +60,7 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
- Infrastructure Server Monitoring, Supports Linux (🐧 Debian, Ubuntu, CentOS, Red Hat, etc.) and Windows (Beta). And Servers metrics like CPU, RAM, disk usage, and network activity) with an one-line installation angent script. - Infrastructure Server Monitoring, Supports Linux (🐧 Debian, Ubuntu, CentOS, Red Hat, etc.) and Windows (Beta). And Servers metrics like CPU, RAM, disk usage, and network activity) with an one-line installation angent script.
- Schedule Maintenance & Incident Management - Schedule Maintenance & Incident Management
- Operational Status / Public Status Pages - Operational Status / Public Status Pages
- Notifications via email, Telegram, Discord, and Slack - Notifications via email, Telegram, Discord, Slack, Matrix, and more
- Reports & Analytics - Reports & Analytics
- Settings Panel (User Management, Data Retention, Multi-language, Themes (Dark & Light Mode), Notification and channels and alert templates). - Settings Panel (User Management, Data Retention, Multi-language, Themes (Dark & Light Mode), Notification and channels and alert templates).
@@ -62,26 +70,9 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s
* ✅ x86_64 PCs, laptops, servers (amd64) * ✅ x86_64 PCs, laptops, servers (amd64)
* ✅ Modern Raspberry Pi 3/4/5 with (64-bit OS), Apple Silicon Macs (arm64) * ✅ 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. Install with Docker Compose Configuration (Recommended)
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.
```bash ```bash
version: '3.9' version: '3.9'
@@ -101,6 +92,18 @@ services:
hard: 8192 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 3. Admin Web Management
Default URL: http://0.0.0.0:8090 Default URL: http://0.0.0.0:8090
@@ -110,33 +113,12 @@ services:
4. Follow the Quick Start Guide at https://docs.checkcle.io 4. Follow the Quick Start Guide at https://docs.checkcle.io
### ###
![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-monitoring.png) ![checkcle-collapse-black](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-1.4.png)
![Service Detail Page](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/uptime-service-detail.png) ![Service Detail Page](https://cdn.checkcle.io/images/uptime/uptime-regional-detail.png)
![checkcle-server-instance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/instance-server-monitoring.png) ![checkcle-server-instance](https://cdn.checkcle.io/images/server/server-list.png)
![Schedule Maintenance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-schedule-maintenance.png) ![SSL Monitoring](https://cdn.checkcle.io/images/ssl-domain/ssl-list.png)
![SSL Monitoring](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/ssl-monitoring.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? ## 🌟 CheckCle for Communities?
- **Built with Passion**: Created by an open-source enthusiast for the community - **Built with Passion**: Created by an open-source enthusiast for the community
@@ -145,6 +127,48 @@ 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>
</a>
<a href="https://www.jetbrains.com/">
<img src="https://cdn.checkcle.io/images/sponsor/jetbrains.png"
height="50"
alt="Jetbrains 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 ## 🤝 Ways to Contribute
Here are some ways you can help improve CheckCle: Here are some ways you can help improve CheckCle:
@@ -153,30 +177,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. - 🌟 **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. - 🛠 **Submit Pull Requests** Improve the code, fix bugs, add features, or enhance the docs.
- 📝 **Improve Documentation** Even a typo fix helps! - 📝 **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 ## 🌍 Stay Connected
- Website: [checkcle.io](https://checkcle.io) - Website: [checkcle.io](https://checkcle.io)
- Documentation: [docs.checkcle.io](https://docs.checkcle.io) - Documentation: [docs.checkcle.io](https://docs.checkcle.io) | Big thanks to [GitBook](https://github.com/gitbookio) for sponsoring the OSS site plan for CheckCle!
- GitHub Repository: ⭐ [CheckCle](https://github.com/operacle/checkcle.git) - Chat on Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
- Community Channels: Engage via discussions and issues! - Follow us on X: [@asqrm_io](https://x.com/asqrm_io)
- Discord: Join our community [@discord](https://discord.gg/xs9gbubGwX)
- X: [@tlengoss](https://x.com/tlengoss)
## 📜 License ## 📜 License
CheckCle is released under the MIT License. CheckCle is released under the MIT License.
--- ---
## 👥 Contributors
[![](https://contrib.rocks/image?repo=operacle/checkcle)](https://github.com/operacle/checkcle/graphs/contributors)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=operacle/checkcle&type=Date)](https://www.star-history.com/#operacle/checkcle&Date)
Stay informed, stay online, with CheckCle! 🌐
+1
View File
@@ -39,6 +39,7 @@ We support the latest stable release of `checkcle`. Security patches may also be
| Version | Supported | | Version | Supported |
|---------|-----------| |---------|-----------|
| `develop` (latest) | ✅ Yes |
| `main` (latest) | ✅ Yes | | `main` (latest) | ✅ Yes |
| Older versions | ⚠️ Best-effort | | Older versions | ⚠️ Best-effort |
| Pre-release or forks | ❌ No | | Pre-release or forks | ❌ No |
+6
View File
@@ -0,0 +1,6 @@
.git
node_modules
dist
.cache
*.log
+18
View File
@@ -0,0 +1,18 @@
# syntax=docker/dockerfile:1.7
FROM node:20-bullseye-slim AS build
WORKDIR /app
ENV npm_config_fund=false \
npm_config_audit=false \
npm_config_progress=false
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --fund=false
COPY . .
RUN --mount=type=cache,target=/root/.npm npm run build
FROM nginx:1.27-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY --from=build /app/dist /usr/share/nginx/html
# SPA fallback + asset caching
RUN printf 'server {\n listen 80;\n server_name _;\n root /usr/share/nginx/html;\n index index.html;\n location / { try_files $uri $uri/ /index.html; }\n location ~* \\.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { expires 7d; add_header Cache-Control "public, max-age=604800, immutable"; try_files $uri =404; }\n}\n' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx","-g","daemon off;"]
+2 -2
View File
@@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CheckCle is an open-source monitoring stack</title> <title>CheckCle</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="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="author" content="Tola Leng" />
@@ -14,7 +14,7 @@
<meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" /> <meta property="og:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@tlengoss" /> <meta name="twitter:site" content="@checkcle_oss" />
<meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" /> <meta name="twitter:image" content="https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-black.png" />
</head> </head>
+404 -22
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -50,7 +50,7 @@
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"pocketbase": "^0.19.0", "pocketbase": "^0.26.8",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -82,4 +82,4 @@
"typescript-eslint": "^8.0.1", "typescript-eslint": "^8.0.1",
"vite": "^5.4.1" "vite": "^5.4.1"
} }
} }
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: 250 B

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

@@ -7,6 +7,7 @@ import { StatusCards } from "./StatusCards";
import { ServiceFilters } from "./ServiceFilters"; import { ServiceFilters } from "./ServiceFilters";
import { ServicesTable } from "./ServicesTable"; import { ServicesTable } from "./ServicesTable";
import { AddServiceDialog } from "@/components/services/AddServiceDialog"; import { AddServiceDialog } from "@/components/services/AddServiceDialog";
import { useLanguage } from "@/contexts/LanguageContext";
interface DashboardContentProps { interface DashboardContentProps {
services: Service[]; services: Service[];
@@ -15,6 +16,7 @@ interface DashboardContentProps {
} }
export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => { export const DashboardContent = ({ services, isLoading, error }: DashboardContentProps) => {
const { t } = useLanguage();
const [filter, setFilter] = useState<string>("all"); const [filter, setFilter] = useState<string>("all");
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState<boolean>(false);
@@ -31,7 +33,7 @@ export const DashboardContent = ({ services, isLoading, error }: DashboardConten
return ( return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-foreground"> <div className="flex flex-col items-center justify-center h-full gap-4 text-foreground">
<p>Error loading service data.</p> <p>Error loading service data.</p>
<Button onClick={() => window.location.reload()}>Retry</Button> <Button onClick={() => window.location.reload()}>{t('retry')}</Button>
</div> </div>
); );
} }
@@ -40,12 +42,12 @@ export const DashboardContent = ({ services, isLoading, error }: DashboardConten
<main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0"> <main className="flex-1 flex flex-col overflow-auto bg-background p-6 pb-0">
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-foreground">Overview</h2> <h2 className="text-2xl font-bold text-foreground">{t('overview')}</h2>
<Button <Button
className="text-primary-foreground" className="text-primary-foreground"
onClick={() => setIsAddDialogOpen(true)} onClick={() => setIsAddDialogOpen(true)}
> >
<Plus className="w-4 h-4 mr-2" /> New Service <Plus className="w-4 h-4 mr-2" /> {t('newService')}
</Button> </Button>
</div> </div>
+99 -123
View File
@@ -1,78 +1,59 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AuthUser } from "@/services/authService"; import { AuthUser } from "@/services/authService";
import { useTheme } from "@/contexts/ThemeContext"; 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, Menu, X
} from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext"; 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 { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useSystemSettings } from "@/hooks/useSystemSettings"; import { useSystemSettings } from "@/hooks/useSystemSettings";
import QuickActionsDialog from "./QuickActionsDialog"; import { useSidebar } from "@/contexts/SidebarContext";
import { cn } from "@/lib/utils";
interface HeaderProps { interface HeaderProps {
currentUser: AuthUser | null; currentUser: AuthUser | null;
onLogout: () => void; onLogout: () => void;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
} }
export const Header = ({ export const Header = ({
currentUser, currentUser,
onLogout, onLogout,
sidebarCollapsed,
toggleSidebar
}: HeaderProps) => { }: HeaderProps) => {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { language, setLanguage, t } = useLanguage(); const { language, setLanguage, t } = useLanguage();
const { sidebarCollapsed, toggleSidebar, isMobileOpen, toggleMobileMenu } = useSidebar();
const [greeting, setGreeting] = useState<string>(""); const [greeting, setGreeting] = useState<string>("");
const { systemName } = useSystemSettings(); const { systemName } = useSystemSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
// Set greeting based on time of day
useEffect(() => { useEffect(() => {
const updateGreeting = () => { const updateGreeting = () => {
const hour = new Date().getHours(); const hour = new Date().getHours();
if (hour >= 5 && hour < 12) { if (hour >= 5 && hour < 12) setGreeting(t("goodMorning"));
setGreeting(t("goodMorning")); else if (hour >= 12 && hour < 18) setGreeting(t("goodAfternoon"));
} else if (hour >= 12 && hour < 18) { else setGreeting(t("goodEvening"));
setGreeting(t("goodAfternoon"));
} else {
setGreeting(t("goodEvening"));
}
}; };
updateGreeting(); updateGreeting();
// Update greeting if language changes
// You could add a timer to update the greeting throughout the day
// but that's typically unnecessary since most sessions aren't active across time periods
}, [language, t]); }, [language, t]);
// Log avatar data for debugging const avatarUrl = currentUser?.avatar || '';
useEffect(() => {
if (currentUser) {
// console.log("Avatar URL in Header:", currentUser.avatar);
}
}, [currentUser]);
// Prepare avatar URL - ensure it displays correctly if it's a local profile image const openExternalLink = (url: string) => {
let avatarUrl = ''; window.open(url, '_blank');
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/')) {
avatarUrl = currentUser.avatar;
} else {
avatarUrl = currentUser.avatar;
}
// console.log("Final avatar URL:", avatarUrl);
}
return ( return (
<header className="relative bg-background border-b border-border px-6 flex justify-between items-center py-[12px] overflow-hidden"> <header className="relative bg-background border-b border-border px-4 lg:px-6 flex justify-between items-center h-16 shrink-0 z-30 overflow-hidden">
{/* Grid Pattern Overlay - Similar to StatusCards */} {/* Grid Pattern Overlay */}
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0 pointer-events-none opacity-20">
<div <div
className="w-full h-full" className="w-full h-full"
style={{ style={{
@@ -85,29 +66,74 @@ export const Header = ({
</div> </div>
</div> </div>
{/* Header Content */} <div className="flex items-center gap-2 lg:gap-4 z-10">
<div className="flex items-center gap-4 z-10"> {/* Mobile Toggle */}
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="mr-2"> <Button variant="ghost" size="icon" onClick={toggleMobileMenu} className="lg:hidden h-9 w-9">
{isMobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
{/* Desktop Toggle */}
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="hidden lg:flex h-9 w-9">
{sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />} {sidebarCollapsed ? <PanelLeft className="h-5 w-5" /> : <PanelLeftClose className="h-5 w-5" />}
</Button> </Button>
{/* Quick Actions Button */} <div className="flex items-center">
<Button <h1 className="text-sm lg:text-lg font-medium truncate max-w-[150px] lg:max-w-none">
variant="ghost" {greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋
size="icon" </h1>
onClick={() => setQuickActionsOpen(true)}
className="mr-2"
>
<Grid3x3 className="h-5 w-5 text-green-500" />
</Button>
<div className="flex items-center space-x-2">
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 </h1>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4 z-10"> <div className="flex items-center space-x-1 lg:space-x-3 z-10">
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border" onClick={toggleTheme}> {/* External Links - Hidden on small screens */}
<div className="hidden md:flex items-center space-x-1 mr-2">
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://docs.checkcle.io')}
title={t("documentation")}
>
<FileText className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://github.com/operacle/checkcle')}
title="GitHub"
>
<Github className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://x.com/checkcle_oss')}
title="X (Twitter)"
>
<Twitter className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
onClick={() => openExternalLink('https://discord.gg/xs9gbubGwX')}
title="Discord"
>
<MessageSquare className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="rounded-full w-8 h-8 border-border"
title={t("notifications")}
>
<Bell className="w-4 h-4" />
</Button>
</div>
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border hidden sm:flex" onClick={toggleTheme}>
<span className="sr-only">Toggle theme</span> <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> </Button>
@@ -119,7 +145,7 @@ export const Header = ({
<Globe className="w-4 h-4" /> <Globe className="w-4 h-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}> <DropdownMenuItem onClick={() => setLanguage("en")} className={language === "en" ? "bg-accent" : ""}>
{t("english")} {t("english")}
</DropdownMenuItem> </DropdownMenuItem>
@@ -127,82 +153,35 @@ export const Header = ({
{t("khmer")} {t("khmer")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}> <DropdownMenuItem onClick={() => setLanguage("de")} className={language === "de" ? "bg-accent" : ""}>
{t("Deutsch")} {t("german")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("ko")} className={language === "ko" ? "bg-accent" : ""}>
{t("korean")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}> <DropdownMenuItem onClick={() => setLanguage("ja")} className={language === "ja" ? "bg-accent" : ""}>
{t("japanese")} {t("japanese")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => setLanguage("zhcn")} className={language === "zhcn" ? "bg-accent" : ""}>
{t("simplifiedChinese")}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* Documentation */} <div className="h-8 w-px bg-border mx-1 hidden sm:block" />
<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"
className="rounded-full w-8 h-8 border-border"
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"
className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://x.com/tlengoss", "_blank")}
>
<span className="sr-only">X (Twitter)</span>
<Twitter className="w-4 h-4" />
</Button>
{/* Discord */}
<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">
<span className="sr-only">{t("notifications")}</span>
<Bell className="w-4 h-4" />
</Button>
{/* User Profile Dropdown */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Avatar className="h-8 w-8 cursor-pointer ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground"> <Avatar className="h-8 w-8 cursor-pointer border hover:ring-2 hover:ring-primary/20 transition-all">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} /> : <AvatarFallback className="bg-primary/20 text-primary"> {avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
</AvatarFallback>}
</Avatar> </Avatar>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<div className="flex items-center gap-3 p-2"> <div className="flex items-center gap-3 p-2">
<Avatar className="h-10 w-10"> <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"> {avatarUrl ? <AvatarImage src={avatarUrl} alt="User" /> : <AvatarFallback className="bg-primary/20 text-primary">{currentUser?.name?.[0] || 'U'}</AvatarFallback>}
{currentUser?.name?.[0] || currentUser?.email?.[0] || 'U'}
</AvatarFallback>}
</Avatar> </Avatar>
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-0.5">
<span className="font-medium">{currentUser?.name || 'User'}</span> <span className="text-sm font-medium truncate">{currentUser?.name || 'User'}</span>
<span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span> <span className="text-xs text-muted-foreground truncate">{currentUser?.email}</span>
</div> </div>
</div> </div>
@@ -223,9 +202,6 @@ export const Header = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Quick Actions Dialog */}
<QuickActionsDialog isOpen={quickActionsOpen} setIsOpen={setQuickActionsOpen} />
</header> </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;
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { Plus } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServiceFiltersProps { interface ServiceFiltersProps {
filter: string; filter: string;
@@ -19,10 +20,11 @@ export const ServiceFilters = ({
setSearchTerm, setSearchTerm,
servicesCount servicesCount
}: ServiceFiltersProps) => { }: ServiceFiltersProps) => {
const { t } = useLanguage();
return ( return (
<div className="mb-6 flex justify-between items-center"> <div className="mb-6 flex justify-between items-center">
<div className="flex 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"> <span className="bg-secondary text-secondary-foreground px-2 py-0.5 rounded text-sm">
{servicesCount} {servicesCount}
</span> </span>
@@ -30,10 +32,10 @@ export const ServiceFilters = ({
<div className="flex space-x-4"> <div className="flex space-x-4">
<Select value={filter} onValueChange={setFilter}> <Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-40 bg-card border-border"> <SelectTrigger className="w-40 bg-card border-border">
<SelectValue placeholder="All Types" /> <SelectValue placeholder={t('allTypes')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Types</SelectItem> <SelectItem value="all">{t('allTypes')}</SelectItem>
<SelectItem value="HTTP">HTTP</SelectItem> <SelectItem value="HTTP">HTTP</SelectItem>
<SelectItem value="PING">PING</SelectItem> <SelectItem value="PING">PING</SelectItem>
<SelectItem value="TCP">TCP</SelectItem> <SelectItem value="TCP">TCP</SelectItem>
@@ -43,7 +45,7 @@ export const ServiceFilters = ({
<div className="relative"> <div className="relative">
<Input <Input
className="w-72 bg-card border-border" className="w-72 bg-card border-border"
placeholder="Search" placeholder={t('search')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
@@ -1,28 +1,44 @@
import React from "react"; import React from "react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { SidebarHeader } from "./sidebar/SidebarHeader"; import { SidebarHeader } from "./sidebar/SidebarHeader";
import { MainNavigation } from "./sidebar/MainNavigation"; import { MainNavigation } from "./sidebar/MainNavigation";
import { SettingsPanel } from "./sidebar/SettingsPanel"; import { SettingsPanel } from "./sidebar/SettingsPanel";
import { useSidebar } from "@/contexts/SidebarContext";
import { cn } from "@/lib/utils";
interface SidebarProps { export const Sidebar = () => {
collapsed: boolean;
}
export const Sidebar = ({ collapsed }: SidebarProps) => {
const { theme } = useTheme(); const { theme } = useTheme();
const { sidebarCollapsed, isMobileOpen, toggleMobileMenu } = useSidebar();
return ( return (
<div <>
className={` {/* Mobile Overlay */}
${theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border'} {isMobileOpen && (
border-r flex flex-col h-full <div
${collapsed ? 'w-16' : 'w-64'} className="fixed inset-0 bg-black/60 z-40 lg:hidden backdrop-blur-sm transition-opacity animate-in fade-in"
`} onClick={toggleMobileMenu}
> />
<SidebarHeader collapsed={collapsed} /> )}
<MainNavigation collapsed={collapsed} />
<SettingsPanel collapsed={collapsed} /> {/* Sidebar Container */}
</div> <aside
className={cn(
"fixed inset-y-0 left-0 z-50 lg:static lg:block transition-all duration-300 ease-in-out border-r flex flex-col h-full",
theme === 'dark' ? 'bg-[#121212] border-[#1e1e1e]' : 'bg-sidebar border-sidebar-border',
// Mobile state
isMobileOpen ? "translate-x-0 w-64" : "-translate-x-full lg:translate-x-0",
// Desktop state (collapsed/expanded)
!isMobileOpen && sidebarCollapsed ? "lg:w-16" : "lg:w-64"
)}
>
<SidebarHeader collapsed={!isMobileOpen && sidebarCollapsed} />
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar">
<MainNavigation collapsed={!isMobileOpen && sidebarCollapsed} />
</div>
<SettingsPanel collapsed={!isMobileOpen && sidebarCollapsed} />
</aside>
</>
); );
}; };
@@ -3,12 +3,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react"; import { ArrowUp, ArrowDown, Pause, AlertTriangle } from "lucide-react";
import { Service } from "@/services/serviceService"; import { Service } from "@/services/serviceService";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface StatusCardsProps { interface StatusCardsProps {
services: Service[]; services: Service[];
} }
export const StatusCards = ({ services }: StatusCardsProps) => { export const StatusCards = ({ services }: StatusCardsProps) => {
const { t } = useLanguage();
// Count services by status // Count services by status
const upServices = services.filter(s => s.status === "up").length; const upServices = services.filter(s => s.status === "up").length;
const downServices = services.filter(s => s.status === "down").length; const downServices = services.filter(s => s.status === "down").length;
@@ -42,7 +45,7 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
></div> ></div>
</div> </div>
<CardHeader className="pb-2 relative z-10"> <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> </CardHeader>
<CardContent className="flex items-center justify-between relative z-10"> <CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{upServices}</span> <span className="text-5xl font-bold text-white">{upServices}</span>
@@ -74,7 +77,7 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
></div> ></div>
</div> </div>
<CardHeader className="pb-2 relative z-10"> <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> </CardHeader>
<CardContent className="flex items-center justify-between relative z-10"> <CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{downServices}</span> <span className="text-5xl font-bold text-white">{downServices}</span>
@@ -106,7 +109,7 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
></div> ></div>
</div> </div>
<CardHeader className="pb-2 relative z-10"> <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> </CardHeader>
<CardContent className="flex items-center justify-between relative z-10"> <CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{pausedServices}</span> <span className="text-5xl font-bold text-white">{pausedServices}</span>
@@ -138,7 +141,7 @@ export const StatusCards = ({ services }: StatusCardsProps) => {
></div> ></div>
</div> </div>
<CardHeader className="pb-2 relative z-10"> <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> </CardHeader>
<CardContent className="flex items-center justify-between relative z-10"> <CardContent className="flex items-center justify-between relative z-10">
<span className="text-5xl font-bold text-white">{warningServices}</span> <span className="text-5xl font-bold text-white">{warningServices}</span>
@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { LucideIcon } from "lucide-react"; import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface MenuItemProps { interface MenuItemProps {
id: string; id: string;
@@ -16,7 +16,6 @@ interface MenuItemProps {
} }
export const MenuItem: React.FC<MenuItemProps> = ({ export const MenuItem: React.FC<MenuItemProps> = ({
id,
path, path,
icon: Icon, icon: Icon,
translationKey, translationKey,
@@ -29,36 +28,39 @@ export const MenuItem: React.FC<MenuItemProps> = ({
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const isActive = path && location.pathname === path;
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if (hasNavigation && path) { if (hasNavigation && path) {
navigate(path, { replace: false }); navigate(path);
} }
}; };
const isActive = path && location.pathname === path;
const mainIconSize = "h-6 w-6";
return ( return (
<div <div
className={` className={cn(
${collapsed ? 'p-3' : 'p-2 pl-3'} "group relative flex items-center mb-1 rounded-lg cursor-pointer transition-all duration-200",
mb-1 rounded-lg collapsed ? "p-3 justify-center" : "p-2.5 px-4",
${isActive ? (theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent') : `hover:${theme === 'dark' ? 'bg-gray-800' : 'bg-sidebar-accent'}`} isActive
flex items-center ? (theme === 'dark' ? 'bg-zinc-800 text-white' : 'bg-primary/10 text-primary')
${collapsed ? 'justify-center' : ''} : (theme === 'dark' ? 'text-zinc-400 hover:bg-zinc-800/50 hover:text-white' : 'text-zinc-600 hover:bg-zinc-100')
cursor-pointer )}
`}
onClick={handleClick} onClick={handleClick}
title={collapsed ? t(translationKey) : ""}
> >
<Icon className={`${mainIconSize} ${color}`} /> <Icon className={cn("h-5 w-5 shrink-0 transition-colors", color, isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100")} />
{!collapsed && ( {!collapsed && (
<span className="ml-2.5 font-medium text-foreground tracking-wide text-[15px]"> <span className="ml-3 font-medium tracking-wide text-sm truncate">
{t(translationKey)} {t(translationKey)}
</span> </span>
)} )}
{/* Small Indicator for active state */}
{isActive && !collapsed && (
<div className="absolute left-0 w-1 h-6 bg-primary rounded-r-full" />
)}
</div> </div>
); );
}; };
@@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody } from "@/components/ui/table"; import { Table, TableBody } from "@/components/ui/table";
import { DockerContainer } from "@/types/docker.types"; import { DockerContainer } from "@/types/docker.types";
import { DockerMetricsDialog } from "./DockerMetricsDialog"; import { DockerMetricsDialog } from "./DockerMetricsDialog";
import { useLanguage } from "@/contexts/LanguageContext";
import { import {
DockerTableSearch, DockerTableSearch,
DockerTableHeader, DockerTableHeader,
@@ -18,6 +19,7 @@ interface DockerContainersTableProps {
} }
export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => { export const DockerContainersTable = ({ containers, isLoading, onRefresh }: DockerContainersTableProps) => {
const { t } = useLanguage();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null); const [selectedContainer, setSelectedContainer] = useState<DockerContainer | null>(null);
const [metricsDialogOpen, setMetricsDialogOpen] = useState(false); const [metricsDialogOpen, setMetricsDialogOpen] = useState(false);
@@ -49,7 +51,7 @@ export const DockerContainersTable = ({ containers, isLoading, onRefresh }: Dock
<CardHeader className="pb-4 px-0"> <CardHeader className="pb-4 px-0">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle className="text-lg sm:text-xl font-semibold">Docker Containers</CardTitle> <CardTitle className="text-lg sm:text-xl font-semibold">{t('dockerContainers', 'docker')}</CardTitle>
<DockerTableSearch <DockerTableSearch
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
@@ -10,6 +10,7 @@ import { DockerContainer, DockerMetrics } from "@/types/docker.types";
import { dockerService } from "@/services/dockerService"; import { dockerService } from "@/services/dockerService";
import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react"; import { Loader2, Cpu, HardDrive, Network, MemoryStick } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerMetricsDialogProps { interface DockerMetricsDialogProps {
container: DockerContainer | null; container: DockerContainer | null;
@@ -20,16 +21,17 @@ interface DockerMetricsDialogProps {
type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m'; type TimeRange = '60m' | '1d' | '7d' | '1m' | '3m';
const timeRangeOptions = [ const timeRangeOptions = [
{ value: '60m' as TimeRange, label: '60 minutes', hours: 1 }, { value: '60m' as TimeRange, label: 'minutes60', hours: 1 },
{ value: '1d' as TimeRange, label: '1 day', hours: 24 }, { value: '1d' as TimeRange, label: 'day1', hours: 24 },
{ value: '7d' as TimeRange, label: '7 days', hours: 24 * 7 }, { value: '7d' as TimeRange, label: 'days7', hours: 24 * 7 },
{ value: '1m' as TimeRange, label: '1 month', hours: 24 * 30 }, { value: '1m' as TimeRange, label: 'month1', hours: 24 * 30 },
{ value: '3m' as TimeRange, label: '3 months', hours: 24 * 90 }, { value: '3m' as TimeRange, label: 'months3', hours: 24 * 90 },
]; ];
export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => { export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMetricsDialogProps) => {
const [timeRange, setTimeRange] = useState<TimeRange>("1d"); const [timeRange, setTimeRange] = useState<TimeRange>("1d");
const { theme } = useTheme(); const { theme } = useTheme();
const { t } = useLanguage();
const { const {
data: metrics = [], data: metrics = [],
@@ -232,11 +234,11 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<DialogContent className="max-w-7xl max-h-[95vh] overflow-y-auto bg-background text-foreground"> <DialogContent className="max-w-7xl max-h-[95vh] overflow-y-auto bg-background text-foreground">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center justify-between"> <DialogTitle className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center"> <div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
<Cpu className="h-4 w-4 text-primary" /> <Cpu className="h-4 w-4 text-primary" />
</div> </div>
Container Metrics: {container.name} {t('containerMetricsTitle', 'docker', { name: container.name })}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}> <Select value={timeRange} onValueChange={(value: TimeRange) => setTimeRange(value)}>
@@ -246,7 +248,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<SelectContent> <SelectContent>
{timeRangeOptions.map((option) => ( {timeRangeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {t(option.label, 'docker')}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -254,22 +256,22 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
</div> </div>
</DialogTitle> </DialogTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Docker ID: {container.docker_id} {container.hostname} {t('dockerId', 'docker')}: {container.docker_id} {container.hostname}
</p> </p>
</DialogHeader> </DialogHeader>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin" /> <Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">Loading metrics...</span> <span className="ml-2">{t('loadingMetrics', 'docker')}</span>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex items-center justify-center h-96 text-muted-foreground"> <div className="flex items-center justify-center h-96 text-muted-foreground">
<p>Error loading metrics: {error.message}</p> <p>{t('errorLoadingMetrics', 'docker')}: {String((error as any)?.message ?? '')}</p>
</div> </div>
) : chartData.length === 0 ? ( ) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-96 text-muted-foreground"> <div className="flex items-center justify-center h-96 text-muted-foreground">
<p>No metrics data available for this container</p> <p>{t('noMetricsAvailable', 'docker')}</p>
</div> </div>
) : ( ) : (
<> <>
@@ -277,7 +279,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
{latestMetric && ( {latestMetric && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<MetricCard <MetricCard
title="CPU" title={t('cpu', 'docker')}
used={`${latestMetric.cpuUsage}%`} used={`${latestMetric.cpuUsage}%`}
total={`${latestMetric.cpuCores} cores`} total={`${latestMetric.cpuCores} cores`}
free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`} free={`${(100 - latestMetric.cpuUsage).toFixed(1)}%`}
@@ -286,7 +288,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
color={chartConfig.cpuUsage.color} color={chartConfig.cpuUsage.color}
/> />
<MetricCard <MetricCard
title="Memory" title={t('memoryTitle', 'docker')}
used={latestMetric.ramUsed} used={latestMetric.ramUsed}
total={latestMetric.ramTotal} total={latestMetric.ramTotal}
free={latestMetric.ramFree} free={latestMetric.ramFree}
@@ -295,7 +297,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
color={chartConfig.ramUsagePercent.color} color={chartConfig.ramUsagePercent.color}
/> />
<MetricCard <MetricCard
title="Disk" title={t('diskTitle', 'docker')}
used={latestMetric.diskUsed} used={latestMetric.diskUsed}
total={latestMetric.diskTotal} total={latestMetric.diskTotal}
free={latestMetric.diskFree} free={latestMetric.diskFree}
@@ -304,10 +306,10 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
color={chartConfig.diskUsagePercent.color} color={chartConfig.diskUsagePercent.color}
/> />
<MetricCard <MetricCard
title="Network" title={t('network', 'docker')}
used={`RX: ${latestMetric.networkRx}`} used={`RX: ${latestMetric.networkRx}`}
total={`TX: ${latestMetric.networkTx}`} total={`TX: ${latestMetric.networkTx}`}
free={`Speed: ${latestMetric.networkRxSpeed} KB/s`} free={`${t('networkSpeedKbs', 'docker').replace('(KB/s)', '')}: ${latestMetric.networkRxSpeed} KB/s`}
percentage={0} percentage={0}
icon={Network} icon={Network}
color={chartConfig.networkRx.color} color={chartConfig.networkRx.color}
@@ -319,19 +321,19 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<TabsList className="grid w-full grid-cols-4 bg-muted"> <TabsList className="grid w-full grid-cols-4 bg-muted">
<TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background"> <TabsTrigger value="cpu" className="flex items-center gap-2 data-[state=active]:bg-background">
<Cpu className="h-4 w-4" /> <Cpu className="h-4 w-4" />
CPU {t('cpu', 'docker')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background"> <TabsTrigger value="memory" className="flex items-center gap-2 data-[state=active]:bg-background">
<MemoryStick className="h-4 w-4" /> <MemoryStick className="h-4 w-4" />
Memory {t('memoryTitle', 'docker')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background"> <TabsTrigger value="disk" className="flex items-center gap-2 data-[state=active]:bg-background">
<HardDrive className="h-4 w-4" /> <HardDrive className="h-4 w-4" />
Disk {t('diskTitle', 'docker')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background"> <TabsTrigger value="network" className="flex items-center gap-2 data-[state=active]:bg-background">
<Network className="h-4 w-4" /> <Network className="h-4 w-4" />
Network {t('network', 'docker')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -339,7 +341,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">CPU Usage (%)</CardTitle> <CardTitle className="text-foreground">{t('cpuUsagePct', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -365,7 +367,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.cpuUsage.color} stroke={chartConfig.cpuUsage.color}
strokeWidth={2} strokeWidth={2}
dot={{ r: 3, fill: chartConfig.cpuUsage.color }} dot={{ r: 3, fill: chartConfig.cpuUsage.color }}
name="CPU Usage (%)" name={t('cpuUsagePct', 'docker')}
/> />
</LineChart> </LineChart>
</ChartContainer> </ChartContainer>
@@ -374,7 +376,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">CPU Usage vs Available</CardTitle> <CardTitle className="text-foreground">{t('cpuUsageVsAvailable', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -401,7 +403,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"} stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"} fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3} fillOpacity={0.3}
name="CPU Available (%)" name={t('cpuAvailablePct', 'docker')}
/> />
<Area <Area
type="monotone" type="monotone"
@@ -410,7 +412,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.cpuUsage.color} stroke={chartConfig.cpuUsage.color}
fill={chartConfig.cpuUsage.color} fill={chartConfig.cpuUsage.color}
fillOpacity={0.6} fillOpacity={0.6}
name="CPU Usage (%)" name={t('cpuUsagePct', 'docker')}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
@@ -423,7 +425,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Memory Usage (%)</CardTitle> <CardTitle className="text-foreground">{t('ramUsagePct', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -459,7 +461,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Memory Usage (Bytes)</CardTitle> <CardTitle className="text-foreground">{t('memoryUsageBytes', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -479,8 +481,8 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
content={<ChartTooltipContent className="bg-popover border-border" />} content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }} cursor={{ stroke: getGridColor() }}
formatter={(value, name) => [ formatter={(value, name) => [
name === 'Used Memory' ? formatBytes(Number(value)) : name === t('usedMemory', 'docker') ? formatBytes(Number(value)) :
name === 'Total Memory' ? formatBytes(Number(value)) : value, name === t('totalMemory', 'docker') ? formatBytes(Number(value)) : value,
name name
]} ]}
/> />
@@ -491,7 +493,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"} stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"} fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3} fillOpacity={0.3}
name="Total Memory" name={t('totalMemory', 'docker')}
/> />
<Area <Area
type="monotone" type="monotone"
@@ -500,7 +502,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.ramUsagePercent.color} stroke={chartConfig.ramUsagePercent.color}
fill={chartConfig.ramUsagePercent.color} fill={chartConfig.ramUsagePercent.color}
fillOpacity={0.6} fillOpacity={0.6}
name="Used Memory" name={t('usedMemory', 'docker')}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
@@ -513,7 +515,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Disk Usage (%)</CardTitle> <CardTitle className="text-foreground">{t('diskUsagePct', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -540,7 +542,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.diskUsagePercent.color} stroke={chartConfig.diskUsagePercent.color}
fill={chartConfig.diskUsagePercent.color} fill={chartConfig.diskUsagePercent.color}
fillOpacity={0.6} fillOpacity={0.6}
name="Disk Usage (%)" name={t('diskUsagePct', 'docker')}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
@@ -549,7 +551,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Disk Usage (Bytes)</CardTitle> <CardTitle className="text-foreground">{t('diskUsageBytes', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-80"> <ChartContainer config={chartConfig} className="h-80">
@@ -569,8 +571,8 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
content={<ChartTooltipContent className="bg-popover border-border" />} content={<ChartTooltipContent className="bg-popover border-border" />}
cursor={{ stroke: getGridColor() }} cursor={{ stroke: getGridColor() }}
formatter={(value, name) => [ formatter={(value, name) => [
name === 'Used Disk' ? formatBytes(Number(value)) : name === t('usedDisk', 'docker') ? formatBytes(Number(value)) :
name === 'Total Disk' ? formatBytes(Number(value)) : value, name === t('totalDisk', 'docker') ? formatBytes(Number(value)) : value,
name name
]} ]}
/> />
@@ -581,7 +583,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"} stroke={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fill={theme === 'dark' ? "#6b7280" : "#9ca3af"} fill={theme === 'dark' ? "#6b7280" : "#9ca3af"}
fillOpacity={0.3} fillOpacity={0.3}
name="Total Disk" name={t('totalDisk', 'docker')}
/> />
<Area <Area
type="monotone" type="monotone"
@@ -590,7 +592,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
stroke={chartConfig.diskUsagePercent.color} stroke={chartConfig.diskUsagePercent.color}
fill={chartConfig.diskUsagePercent.color} fill={chartConfig.diskUsagePercent.color}
fillOpacity={0.6} fillOpacity={0.6}
name="Used Disk" name={t('usedDisk', 'docker')}
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
@@ -603,7 +605,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Network Traffic</CardTitle> <CardTitle className="text-foreground">{t('networkTraffic', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-64"> <ChartContainer config={chartConfig} className="h-64">
@@ -627,7 +629,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
dataKey="networkRxBytes" dataKey="networkRxBytes"
stroke={chartConfig.networkRx.color} stroke={chartConfig.networkRx.color}
strokeWidth={2} strokeWidth={2}
name="RX Bytes" name={t('rxBytes', 'docker')}
dot={{ r: 2 }} dot={{ r: 2 }}
/> />
<Line <Line
@@ -635,7 +637,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
dataKey="networkTxBytes" dataKey="networkTxBytes"
stroke={chartConfig.networkTx.color} stroke={chartConfig.networkTx.color}
strokeWidth={2} strokeWidth={2}
name="TX Bytes" name={t('txBytes', 'docker')}
dot={{ r: 2 }} dot={{ r: 2 }}
/> />
</LineChart> </LineChart>
@@ -645,7 +647,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Network Speed (KB/s)</CardTitle> <CardTitle className="text-foreground">{t('networkSpeedKbs', 'docker')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-64"> <ChartContainer config={chartConfig} className="h-64">
@@ -669,7 +671,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
dataKey="networkRxSpeed" dataKey="networkRxSpeed"
stroke={chartConfig.networkRx.color} stroke={chartConfig.networkRx.color}
strokeWidth={2} strokeWidth={2}
name="RX Speed (KB/s)" name={t('rxSpeedKbs', 'docker')}
dot={{ r: 2 }} dot={{ r: 2 }}
/> />
<Line <Line
@@ -677,7 +679,7 @@ export const DockerMetricsDialog = ({ container, open, onOpenChange }: DockerMet
dataKey="networkTxSpeed" dataKey="networkTxSpeed"
stroke={chartConfig.networkTx.color} stroke={chartConfig.networkTx.color}
strokeWidth={2} strokeWidth={2}
name="TX Speed (KB/s)" name={t('txSpeedKbs', 'docker')}
dot={{ r: 2 }} dot={{ r: 2 }}
/> />
</LineChart> </LineChart>
@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
import { Container, Play, Square, AlertTriangle } from "lucide-react"; import { Container, Play, Square, AlertTriangle } from "lucide-react";
import { DockerStats } from "@/types/docker.types"; import { DockerStats } from "@/types/docker.types";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerStatsCardsProps { interface DockerStatsCardsProps {
stats: DockerStats; stats: DockerStats;
@@ -11,10 +12,11 @@ interface DockerStatsCardsProps {
export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => { export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
const { theme } = useTheme(); const { theme } = useTheme();
const { t } = useLanguage();
const cards = [ const cards = [
{ {
title: "Total Containers", title: t('totalContainers', 'docker'),
value: stats.total, value: stats.total,
icon: Container, icon: Container,
color: "text-blue-600", color: "text-blue-600",
@@ -23,7 +25,7 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #3b82f6 100%)"
}, },
{ {
title: "Running", title: t('running', 'docker'),
value: stats.running, value: stats.running,
icon: Play, icon: Play,
color: "text-green-600", color: "text-green-600",
@@ -32,7 +34,7 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #10b981 100%)"
}, },
{ {
title: "Stopped", title: t('stopped', 'docker'),
value: stats.stopped, value: stats.stopped,
icon: Square, icon: Square,
color: "text-gray-600", color: "text-gray-600",
@@ -41,7 +43,7 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #6b7280 100%)"
}, },
{ {
title: "Warning", title: t('warning', 'docker'),
value: stats.warning, value: stats.warning,
icon: AlertTriangle, icon: AlertTriangle,
color: "text-amber-600", color: "text-amber-600",
@@ -90,7 +92,7 @@ export const DockerStatsCards = ({ stats }: DockerStatsCardsProps) => {
variant="outline" variant="outline"
className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30" className="text-xs font-mono font-bold px-2 py-1 rounded-md bg-white/20 backdrop-blur-sm text-white border border-white/30"
> >
Containers {t('containersLabel', 'docker')}
</Badge> </Badge>
</div> </div>
</CardContent> </CardContent>
@@ -1,36 +1,38 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerStatusBadgeProps { interface DockerStatusBadgeProps {
status: 'running' | 'stopped' | 'warning'; status: 'running' | 'stopped' | 'warning';
} }
export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => { export const DockerStatusBadge = ({ status }: DockerStatusBadgeProps) => {
const { t } = useLanguage();
const getStatusConfig = (status: string) => { const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'running': case 'running':
return { return {
variant: 'default' as const, variant: 'default' as const,
className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200', className: 'bg-emerald-100 text-emerald-800 border-emerald-200 hover:bg-emerald-200',
label: 'Running' label: t('running', 'docker')
}; };
case 'stopped': case 'stopped':
return { return {
variant: 'secondary' as const, variant: 'secondary' as const,
className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200', className: 'bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-200',
label: 'Stopped' label: t('stopped', 'docker')
}; };
case 'warning': case 'warning':
return { return {
variant: 'destructive' as const, variant: 'destructive' as const,
className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200', className: 'bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-200',
label: 'Warning' label: t('warning', 'docker')
}; };
default: default:
return { return {
variant: 'outline' as const, variant: 'outline' as const,
className: 'bg-gray-100 text-gray-600 border-gray-200', className: 'bg-gray-100 text-gray-600 border-gray-200',
label: 'Unknown' label: t('unknown', 'docker')
}; };
} }
}; };
@@ -1,20 +1,22 @@
import { TableCell, TableRow } from "@/components/ui/table"; import { TableCell, TableRow } from "@/components/ui/table";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerEmptyStateProps { interface DockerEmptyStateProps {
searchTerm: string; searchTerm: string;
} }
export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => { export const DockerEmptyState = ({ searchTerm }: DockerEmptyStateProps) => {
const { t } = useLanguage();
return ( return (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center py-12 text-muted-foreground"> <TableCell colSpan={8} className="text-center py-12 text-muted-foreground">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="text-lg font-medium"> <div className="text-lg font-medium">
{searchTerm ? "No containers found" : "No containers running"} {searchTerm ? t('noContainersFound', 'docker') : t('noContainersRunning', 'docker')}
</div> </div>
<div className="text-sm"> <div className="text-sm">
{searchTerm ? "Try adjusting your search terms." : "Start some containers to see them here."} {searchTerm ? t('tryAdjustSearch', 'docker') : t('startSomeContainers', 'docker')}
</div> </div>
</div> </div>
</TableCell> </TableCell>
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react"; import { MoreHorizontal, Eye, Play, Pause, Square, Trash2, BarChart3, RefreshCw } from "lucide-react";
import { DockerContainer } from "@/types/docker.types"; import { DockerContainer } from "@/types/docker.types";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerRowActionsProps { interface DockerRowActionsProps {
container: DockerContainer; container: DockerContainer;
@@ -12,11 +13,12 @@ interface DockerRowActionsProps {
} }
export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => { export const DockerRowActions = ({ container, containerStatus, onContainerAction, onViewMetrics }: DockerRowActionsProps) => {
const { t } = useLanguage();
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted"> <Button variant="ghost" className="h-8 w-8 p-0 hover:bg-muted">
<span className="sr-only">Open menu</span> <span className="sr-only">{t('openMenu', 'docker')}</span>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -26,14 +28,14 @@ export const DockerRowActions = ({ container, containerStatus, onContainerAction
className="cursor-pointer hover:bg-muted" className="cursor-pointer hover:bg-muted"
> >
<BarChart3 className="mr-2 h-4 w-4" /> <BarChart3 className="mr-2 h-4 w-4" />
View Metrics {t('viewMetrics', 'docker')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onContainerAction('view-detail', container.id, container.name)} onClick={() => onContainerAction('view-detail', container.id, container.name)}
className="cursor-pointer hover:bg-muted" className="cursor-pointer hover:bg-muted"
> >
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
View Details {t('viewDetails', 'docker')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -1,18 +1,20 @@
import { TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useLanguage } from "@/contexts/LanguageContext";
export const DockerTableHeader = () => { export const DockerTableHeader = () => {
const { t } = useLanguage();
return ( return (
<TableHeader> <TableHeader>
<TableRow className="border-border bg-muted/30"> <TableRow className="border-border bg-muted/30">
<TableHead className="min-w-[200px] font-semibold">Container</TableHead> <TableHead className="min-w-[200px] font-semibold">{t('container', 'docker')}</TableHead>
<TableHead className="min-w-[100px] font-semibold">Status</TableHead> <TableHead className="min-w-[100px] font-semibold">{t('status', 'docker')}</TableHead>
<TableHead className="min-w-[140px] font-semibold">CPU Usage</TableHead> <TableHead className="min-w-[140px] font-semibold">{t('cpuUsage', 'docker')}</TableHead>
<TableHead className="min-w-[160px] font-semibold">Memory</TableHead> <TableHead className="min-w-[160px] font-semibold">{t('memory', 'docker')}</TableHead>
<TableHead className="min-w-[160px] font-semibold">Disk</TableHead> <TableHead className="min-w-[160px] font-semibold">{t('disk', 'docker')}</TableHead>
<TableHead className="min-w-[100px] font-semibold">Uptime</TableHead> <TableHead className="min-w-[100px] font-semibold">{t('uptime', 'docker')}</TableHead>
<TableHead className="min-w-[160px] font-semibold">Last Checked</TableHead> <TableHead className="min-w-[160px] font-semibold">{t('lastChecked', 'docker')}</TableHead>
<TableHead className="min-w-[80px] text-center font-semibold">Actions</TableHead> <TableHead className="min-w-[80px] text-center font-semibold">{t('actions', 'docker')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
); );
@@ -2,6 +2,7 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Search, RefreshCw } from "lucide-react"; import { Search, RefreshCw } from "lucide-react";
import { useLanguage } from "@/contexts/LanguageContext";
interface DockerTableSearchProps { interface DockerTableSearchProps {
searchTerm: string; searchTerm: string;
@@ -11,12 +12,13 @@ interface DockerTableSearchProps {
} }
export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => { export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoading }: DockerTableSearchProps) => {
const { t } = useLanguage();
return ( return (
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto"> <div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial"> <div className="relative flex-1 sm:flex-initial">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input <Input
placeholder="Search containers..." placeholder={t('searchContainersPlaceholder', 'docker')}
value={searchTerm} value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 sm:w-64 bg-background border-border" className="pl-10 sm:w-64 bg-background border-border"
@@ -30,7 +32,7 @@ export const DockerTableSearch = ({ searchTerm, onSearchChange, onRefresh, isLoa
className="w-full sm:w-auto bg-background border-border hover:bg-muted" className="w-full sm:w-auto bg-background border-border hover:bg-muted"
> >
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
<span className="sm:inline">Refresh</span> <span className="sm:inline">{t('refresh', 'docker')}</span>
</Button> </Button>
</div> </div>
); );
@@ -1,3 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -6,10 +7,11 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge'; 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 { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { serviceService } from '@/services/serviceService'; import { serviceService } from '@/services/serviceService';
import { useLanguage } from "@/contexts/LanguageContext";
interface ComponentsSelectorProps { interface ComponentsSelectorProps {
selectedComponents: Partial<StatusPageComponentRecord>[]; selectedComponents: Partial<StatusPageComponentRecord>[];
@@ -17,13 +19,8 @@ interface ComponentsSelectorProps {
onComponentDelete?: (componentId: string) => void; onComponentDelete?: (componentId: string) => void;
} }
const componentTypes = [
{ value: 'uptime', label: 'Uptime Service', icon: Server },
{ value: 'ssl', label: 'SSL Certificate', icon: Shield },
{ value: 'incident', label: 'Incident Monitoring', icon: AlertTriangle },
];
export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => { export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onComponentDelete }: ComponentsSelectorProps) => {
const { t } = useLanguage();
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newComponent, setNewComponent] = useState({ const [newComponent, setNewComponent] = useState({
name: '', name: '',
@@ -76,16 +73,16 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" /> <Server className="h-5 w-5" />
Status Page Components {t('statusPageComponents')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Add monitoring components like uptime services, SSL certificates, and incident tracking {t('addMonitoringComponentsDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{selectedComponents.length > 0 && ( {selectedComponents.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Selected Components</Label> <Label>{t('selectedComponents')}</Label>
<div className="space-y-2"> <div className="space-y-2">
{selectedComponents.map((component, index) => ( {selectedComponents.map((component, index) => (
<div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg"> <div key={component.id || index} className="flex items-center justify-between p-3 border rounded-lg">
@@ -97,12 +94,12 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<div className="flex gap-2 mt-1"> <div className="flex gap-2 mt-1">
{component.service_id && ( {component.service_id && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Service: {services.find(s => s.id === component.service_id)?.name || component.service_id} {t('service')}: {services.find(s => s.id === component.service_id)?.name || component.service_id}
</Badge> </Badge>
)} )}
{component.server_id && ( {component.server_id && (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Server: {component.server_id} {t('server')}: {component.server_id}
</Badge> </Badge>
)} )}
</div> </div>
@@ -127,22 +124,22 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
className="w-full" className="w-full"
> >
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Component {t('addComponent')}
</Button> </Button>
) : ( ) : (
<div className="border rounded-lg p-4 space-y-4"> <div className="border rounded-lg p-4 space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="component-name">Component Name</Label> <Label htmlFor="component-name">{t('componentName')}</Label>
<Input <Input
id="component-name" id="component-name"
placeholder="e.g., Main Website" placeholder={t('componentNamePlaceholder')}
value={newComponent.name} value={newComponent.name}
onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })} onChange={(e) => setNewComponent({ ...newComponent, name: e.target.value })}
/> />
</div> </div>
<div> <div>
<Label htmlFor="display-order">Display Order</Label> <Label htmlFor="display-order">{t('displayOrder')}</Label>
<Input <Input
id="display-order" id="display-order"
type="number" type="number"
@@ -153,10 +150,10 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
</div> </div>
<div> <div>
<Label htmlFor="component-description">Description (Optional)</Label> <Label htmlFor="component-description">{t('descriptionOptional')}</Label>
<Textarea <Textarea
id="component-description" id="component-description"
placeholder="Brief description of this component" placeholder={t('descriptionPlaceholder')}
value={newComponent.description} value={newComponent.description}
onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })} onChange={(e) => setNewComponent({ ...newComponent, description: e.target.value })}
/> />
@@ -164,12 +161,12 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="service-id">Uptime Service (Optional)</Label> <Label htmlFor="service-id">{t('uptimeServiceOptional')}</Label>
<Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}> <Select onValueChange={(value) => setNewComponent({ ...newComponent, service_id: value })}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select an uptime service" /> <SelectValue placeholder={t('selectUptimeService')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-50 bg-white border shadow-lg"> <SelectContent>
{services.map((service) => ( {services.map((service) => (
<SelectItem key={service.id} value={service.id}> <SelectItem key={service.id} value={service.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -186,10 +183,10 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
</Select> </Select>
</div> </div>
<div> <div>
<Label htmlFor="server-id">Server ID (Optional)</Label> <Label htmlFor="server-id">{t('serverIdOptional')}</Label>
<Input <Input
id="server-id" id="server-id"
placeholder="server_456" placeholder={t('serverIdPlaceholder')}
value={newComponent.server_id} value={newComponent.server_id}
onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })} onChange={(e) => setNewComponent({ ...newComponent, server_id: e.target.value })}
/> />
@@ -198,45 +195,14 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={addComponent} disabled={!newComponent.name.trim()}> <Button onClick={addComponent} disabled={!newComponent.name.trim()}>
Add Component {t('addComponent')}
</Button> </Button>
<Button variant="outline" onClick={() => setShowAddForm(false)}> <Button variant="outline" onClick={() => setShowAddForm(false)}>
Cancel {t('cancel')}
</Button> </Button>
</div> </div>
</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> </CardContent>
</Card> </Card>
); );
@@ -14,6 +14,7 @@ import { useCreateStatusPageComponent } from '@/hooks/useStatusPageComponents';
import { ComponentsSelector } from './ComponentsSelector'; import { ComponentsSelector } from './ComponentsSelector';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types'; import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useLanguage } from "@/contexts/LanguageContext";
const formSchema = z.object({ const formSchema = z.object({
title: z.string().min(1, 'Title is required'), title: z.string().min(1, 'Title is required'),
@@ -31,6 +32,7 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
export const CreateOperationalPageDialog = () => { export const CreateOperationalPageDialog = () => {
const { t } = useLanguage();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]); const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
const createMutation = useCreateOperationalPage(); const createMutation = useCreateOperationalPage();
@@ -67,13 +69,13 @@ export const CreateOperationalPageDialog = () => {
page_style: data.page_style || '', 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); const createdPage = await createMutation.mutateAsync(payload);
console.log('Created page:', createdPage); // console.log('Created page:', createdPage);
// Create components after page is created // Create components after page is created
if (selectedComponents.length > 0) { 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) { for (const component of selectedComponents) {
const componentPayload = { const componentPayload = {
operational_status_id: createdPage.id, operational_status_id: createdPage.id,
@@ -84,7 +86,7 @@ export const CreateOperationalPageDialog = () => {
display_order: component.display_order || 1, display_order: component.display_order || 1,
}; };
console.log('Creating component with payload:', componentPayload); // console.log('Creating component with payload:', componentPayload);
await createComponentMutation.mutateAsync(componentPayload); await createComponentMutation.mutateAsync(componentPayload);
} }
} }
@@ -93,7 +95,7 @@ export const CreateOperationalPageDialog = () => {
form.reset(); form.reset();
setSelectedComponents([]); setSelectedComponents([]);
} catch (error) { } catch (error) {
console.error('Error creating operational page:', error); // console.error('Error creating operational page:', error);
} }
}; };
@@ -102,14 +104,14 @@ export const CreateOperationalPageDialog = () => {
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="gap-2"> <Button className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Page {t('createPage')}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Create Operational Page</DialogTitle> <DialogTitle>{t('createOperationalPage')}</DialogTitle>
<DialogDescription> <DialogDescription>
Create a new operational status page to monitor your services and components. {t('createOperationalPageDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -121,9 +123,9 @@ export const CreateOperationalPageDialog = () => {
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Title</FormLabel> <FormLabel>{t('title')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="My Service Status" {...field} /> <Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -135,9 +137,9 @@ export const CreateOperationalPageDialog = () => {
name="slug" name="slug"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Slug</FormLabel> <FormLabel>{t('slug')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-service-status" {...field} /> <Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -150,10 +152,10 @@ export const CreateOperationalPageDialog = () => {
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>{t('description')}</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="A brief description of your operational page" placeholder={t('operationalPageDescriptionPlaceholder')}
className="min-h-[80px]" className="min-h-[80px]"
{...field} {...field}
/> />
@@ -169,17 +171,17 @@ export const CreateOperationalPageDialog = () => {
name="theme" name="theme"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Theme</FormLabel> <FormLabel>{t('theme')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select theme" /> <SelectValue placeholder={t('selectTheme')} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="default">Default</SelectItem> <SelectItem value="default">{t('themeDefault')}</SelectItem>
<SelectItem value="dark">Dark</SelectItem> <SelectItem value="dark">{t('themeDark')}</SelectItem>
<SelectItem value="light">Light</SelectItem> <SelectItem value="light">{t('themeLight')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -192,18 +194,18 @@ export const CreateOperationalPageDialog = () => {
name="status" name="status"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Initial Status</FormLabel> <FormLabel>{t('initialStatus')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select status" /> <SelectValue placeholder={t('selectStatus')} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="operational">Operational</SelectItem> <SelectItem value="operational">{t('statusOperational')}</SelectItem>
<SelectItem value="degraded">Degraded Performance</SelectItem> <SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
<SelectItem value="maintenance">Under Maintenance</SelectItem> <SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
<SelectItem value="major_outage">Major Outage</SelectItem> <SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -218,9 +220,9 @@ export const CreateOperationalPageDialog = () => {
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Public Page</FormLabel> <FormLabel>{t('publicPage')}</FormLabel>
<FormDescription> <FormDescription>
Make this page publicly accessible {t('makePagePublic')}
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -238,12 +240,12 @@ export const CreateOperationalPageDialog = () => {
name="custom_domain" name="custom_domain"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Custom Domain (Optional)</FormLabel> <FormLabel>{t('customDomainOptional')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="status.yourdomain.com" {...field} /> <Input placeholder={t('customDomainPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Custom domain for your status page {t('customDomainDescription')}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -261,13 +263,13 @@ export const CreateOperationalPageDialog = () => {
variant="outline" variant="outline"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={createMutation.isPending || createComponentMutation.isPending} disabled={createMutation.isPending || createComponentMutation.isPending}
> >
{createMutation.isPending || createComponentMutation.isPending ? 'Creating...' : 'Create Page'} {createMutation.isPending || createComponentMutation.isPending ? t('creating') : t('createPage')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -14,6 +14,7 @@ import { useCreateStatusPageComponent, useStatusPageComponentsByOperationalId, u
import { ComponentsSelector } from './ComponentsSelector'; import { ComponentsSelector } from './ComponentsSelector';
import { OperationalPageRecord } from '@/types/operational.types'; import { OperationalPageRecord } from '@/types/operational.types';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types'; import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { useLanguage } from "@/contexts/LanguageContext";
const formSchema = z.object({ const formSchema = z.object({
title: z.string().min(1, 'Title is required'), title: z.string().min(1, 'Title is required'),
@@ -37,6 +38,7 @@ interface EditOperationalPageDialogProps {
} }
export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => { export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOperationalPageDialogProps) => {
const { t } = useLanguage();
const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]); const [selectedComponents, setSelectedComponents] = useState<Partial<StatusPageComponentRecord>[]>([]);
const [isFormSubmitting, setIsFormSubmitting] = useState(false); const [isFormSubmitting, setIsFormSubmitting] = useState(false);
const [componentsLoaded, setComponentsLoaded] = useState(false); const [componentsLoaded, setComponentsLoaded] = useState(false);
@@ -195,9 +197,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Operational Page</DialogTitle> <DialogTitle>{t('editOperationalPage')}</DialogTitle>
<DialogDescription> <DialogDescription>
Update your operational status page settings and manage components. {t('updateYourOperationalPage')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -209,9 +211,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Title</FormLabel> <FormLabel>{t('title')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="My Service Status" {...field} /> <Input placeholder={t('myServiceStatusPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -223,9 +225,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="slug" name="slug"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Slug</FormLabel> <FormLabel>{t('slug')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-service-status" {...field} /> <Input placeholder={t('myServiceStatusSlugPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -238,10 +240,10 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>{t('description')}</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="A brief description of your operational page" placeholder={t('operationalPageDescriptionPlaceholder')}
className="min-h-[80px]" className="min-h-[80px]"
{...field} {...field}
/> />
@@ -257,17 +259,17 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="theme" name="theme"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Theme</FormLabel> <FormLabel>{t('theme')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select theme" /> <SelectValue placeholder={t('selectTheme')} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="default">Default</SelectItem> <SelectItem value="default">{t('themeDefault')}</SelectItem>
<SelectItem value="dark">Dark</SelectItem> <SelectItem value="dark">{t('themeDark')}</SelectItem>
<SelectItem value="light">Light</SelectItem> <SelectItem value="light">{t('themeLight')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -280,18 +282,18 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="status" name="status"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Status</FormLabel> <FormLabel>{t('status')}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select status" /> <SelectValue placeholder={t('selectStatus')} />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="operational">Operational</SelectItem> <SelectItem value="operational">{t('statusOperational')}</SelectItem>
<SelectItem value="degraded">Degraded Performance</SelectItem> <SelectItem value="degraded">{t('statusDegraded')}</SelectItem>
<SelectItem value="maintenance">Under Maintenance</SelectItem> <SelectItem value="maintenance">{t('statusMaintenance')}</SelectItem>
<SelectItem value="major_outage">Major Outage</SelectItem> <SelectItem value="major_outage">{t('statusMajorOutage')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -306,9 +308,9 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm"> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Public Page</FormLabel> <FormLabel>{t('publicPage')}</FormLabel>
<FormDescription> <FormDescription>
Make this page publicly accessible {t('makePagePublic')}
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -326,12 +328,12 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
name="custom_domain" name="custom_domain"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Custom Domain (Optional)</FormLabel> <FormLabel>{t('customDomainOptional')}</FormLabel>
<FormControl> <FormControl>
<Input placeholder="status.yourdomain.com" {...field} /> <Input placeholder={t('customDomainPlaceholder')} {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Custom domain for your status page {t('customDomainDescription')}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -350,13 +352,13 @@ export const EditOperationalPageDialog = ({ page, open, onOpenChange }: EditOper
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending} disabled={isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending}
> >
{isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? 'Updating...' : 'Update Page'} {isFormSubmitting || updateMutation.isPending || createComponentMutation.isPending ? t('updating') : t('updatePage')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -6,6 +6,7 @@ import { OperationalPageRecord } from '@/types/operational.types';
import { StatusBadge } from './StatusBadge'; import { StatusBadge } from './StatusBadge';
import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react'; import { Globe, ExternalLink, Eye, Settings, Trash2 } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useLanguage } from "@/contexts/LanguageContext";
interface OperationalPageCardProps { interface OperationalPageCardProps {
page: OperationalPageRecord; page: OperationalPageRecord;
@@ -15,6 +16,8 @@ interface OperationalPageCardProps {
} }
export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => { export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: OperationalPageCardProps) => {
const { t } = useLanguage();
return ( return (
<Card className="hover:shadow-lg transition-shadow duration-200"> <Card className="hover:shadow-lg transition-shadow duration-200">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -32,23 +35,23 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="font-medium text-muted-foreground">Slug:</span> <span className="font-medium text-muted-foreground">{t('slug')}:</span>
<p className="mt-1">{page.slug}</p> <p className="mt-1">{page.slug}</p>
</div> </div>
<div> <div>
<span className="font-medium text-muted-foreground">Theme:</span> <span className="font-medium text-muted-foreground">{t('theme')}:</span>
<p className="mt-1 capitalize">{page.theme}</p> <p className="mt-1 capitalize">{page.theme}</p>
</div> </div>
<div> <div>
<span className="font-medium text-muted-foreground">Public:</span> <span className="font-medium text-muted-foreground">{t('public')}:</span>
<p className="mt-1"> <div className="mt-1">
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}> <Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
{page.is_public === 'true' ? 'Yes' : 'No'} {page.is_public === 'true' ? t('yes') : t('no')}
</Badge> </Badge>
</p> </div>
</div> </div>
<div> <div>
<span className="font-medium text-muted-foreground">Updated:</span> <span className="font-medium text-muted-foreground">{t('updated')}:</span>
<p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p> <p className="mt-1">{format(new Date(page.updated), 'MMM dd, yyyy')}</p>
</div> </div>
</div> </div>
@@ -70,7 +73,7 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
className="flex-1" className="flex-1"
> >
<Eye className="h-4 w-4 mr-2" /> <Eye className="h-4 w-4 mr-2" />
View {t('view')}
</Button> </Button>
)} )}
{onEdit && ( {onEdit && (
@@ -81,7 +84,7 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
className="flex-1" className="flex-1"
> >
<Settings className="h-4 w-4 mr-2" /> <Settings className="h-4 w-4 mr-2" />
Edit {t('edit')}
</Button> </Button>
)} )}
{onDelete && ( {onDelete && (
@@ -9,6 +9,8 @@ import { Button } from '@/components/ui/button';
import { OperationalPageRecord } from '@/types/operational.types'; import { OperationalPageRecord } from '@/types/operational.types';
import { Activity, Plus, RefreshCw } from 'lucide-react'; import { Activity, Plus, RefreshCw } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { useLanguage } from "@/contexts/LanguageContext";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -21,6 +23,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
export const OperationalPageContent = () => { export const OperationalPageContent = () => {
const { t } = useLanguage();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages(); const { data: pages, isLoading, error, refetch, isRefetching } = useOperationalPages();
const deleteMutation = useDeleteOperationalPage(); const deleteMutation = useDeleteOperationalPage();
@@ -68,13 +71,13 @@ export const OperationalPageContent = () => {
<div className="mb-4"> <div className="mb-4">
<Activity className="h-12 w-12 text-muted-foreground mx-auto" /> <Activity className="h-12 w-12 text-muted-foreground mx-auto" />
</div> </div>
<h3 className="text-lg font-semibold mb-2">Failed to load operational pages</h3> <h3 className="text-lg font-semibold mb-2">{t('failedToLoadOperationalPages')}</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
There was an error loading your operational pages. Please try again. {t('loadingoperationalPages')}
</p> </p>
<Button onClick={() => refetch()} variant="outline"> <Button onClick={() => refetch()} variant="outline">
<RefreshCw className="h-4 w-4 mr-2" /> <RefreshCw className="h-4 w-4 mr-2" />
Try Again {t('tryagain')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -86,9 +89,9 @@ export const OperationalPageContent = () => {
{/* Header */} {/* 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> <div>
<h1 className="text-3xl font-bold tracking-tight mb-2">Operational Pages</h1> <h1 className="text-3xl font-bold tracking-tight mb-2"> {t('operationalPages')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage your public status pages and monitor service health {t('describeOperation')}
</p> </p>
</div> </div>
@@ -100,7 +103,7 @@ export const OperationalPageContent = () => {
disabled={isRefetching} 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 {t('refresh')}
</Button> </Button>
<CreateOperationalPageDialog /> <CreateOperationalPageDialog />
</div> </div>
@@ -135,9 +138,9 @@ export const OperationalPageContent = () => {
<div className="mb-4"> <div className="mb-4">
<Activity className="h-12 w-12 text-muted-foreground mx-auto" /> <Activity className="h-12 w-12 text-muted-foreground mx-auto" />
</div> </div>
<h3 className="text-lg font-semibold mb-2">No operational pages found</h3> <h3 className="text-lg font-semibold mb-2">{t('noOperationalPagesFound')}</h3>
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
Create your first operational page to start monitoring your services and communicate status to your users. {t('createYourFirstOperationalPage')}
</p> </p>
<CreateOperationalPageDialog /> <CreateOperationalPageDialog />
</CardContent> </CardContent>
@@ -164,14 +167,14 @@ export const OperationalPageContent = () => {
<div className="mt-8 pt-6 border-t"> <div className="mt-8 pt-6 border-t">
<div className="flex flex-wrap gap-6 text-sm text-muted-foreground"> <div className="flex flex-wrap gap-6 text-sm text-muted-foreground">
<div> <div>
<span className="font-medium">Total Pages:</span> {pages.length} <span className="font-medium">{t('totalPages')}:</span> {pages.length}
</div> </div>
<div> <div>
<span className="font-medium">Public Pages:</span>{' '} <span className="font-medium">{t('totalPages')}:</span>{' '}
{pages.filter(p => p.is_public === 'true').length} {pages.filter(p => p.is_public === 'true').length}
</div> </div>
<div> <div>
<span className="font-medium">Operational:</span>{' '} <span className="font-medium">{t('operational')}:</span>{' '}
{pages.filter(p => p.status === 'operational').length} {pages.filter(p => p.status === 'operational').length}
</div> </div>
</div> </div>
@@ -189,19 +192,19 @@ export const OperationalPageContent = () => {
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Operational Page</AlertDialogTitle> <AlertDialogTitle>{t('deleteOperationalPage')}</AlertDialogTitle>
<AlertDialogDescription> <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. {t('deleteOperationalPageConfirm').replace('{title}', pageToDelete?.title ?? '')}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDelete} onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700" className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
{deleteMutation.isPending ? 'Deleting...' : 'Delete'} {deleteMutation.isPending ? t('deleting') : t('delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -5,6 +5,7 @@ import { format } from 'date-fns';
import { OperationalPageRecord } from '@/types/operational.types'; import { OperationalPageRecord } from '@/types/operational.types';
import { StatusPageComponentRecord } from '@/types/statusPageComponents.types'; import { StatusPageComponentRecord } from '@/types/statusPageComponents.types';
import { Service } from '@/types/service.types'; import { Service } from '@/types/service.types';
import { useLanguage } from '@/contexts/LanguageContext';
interface CurrentStatusSectionProps { interface CurrentStatusSectionProps {
page: OperationalPageRecord; page: OperationalPageRecord;
@@ -45,18 +46,18 @@ const getActualStatus = (components: StatusPageComponentRecord[], services: Serv
return 'operational'; return 'operational';
}; };
const getStatusMessage = (status: OperationalPageRecord['status']) => { const getStatusMessage = (status: OperationalPageRecord['status'], t: (k: string, m?: string) => string) => {
switch (status) { switch (status) {
case 'operational': case 'operational':
return 'All systems are operational'; return t('allOperational', 'public');
case 'degraded': case 'degraded':
return 'Some systems are experiencing degraded performance'; return t('degradedPerformance', 'public');
case 'maintenance': case 'maintenance':
return 'Systems are currently under maintenance'; return t('underMaintenance', 'public');
case 'major_outage': case 'major_outage':
return 'We are experiencing a major service outage'; return t('majorOutage', 'public');
default: default:
return 'Status unknown'; return t('statusUnknown', 'public');
} }
}; };
@@ -106,6 +107,7 @@ const getStatusBackground = (status: OperationalPageRecord['status']) => {
}; };
export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => { export const CurrentStatusSection = ({ page, components, services }: CurrentStatusSectionProps) => {
const { t } = useLanguage();
const actualStatus = getActualStatus(components, services); const actualStatus = getActualStatus(components, services);
const displayStatus = actualStatus; // Use actual status for real-time accuracy const displayStatus = actualStatus; // Use actual status for real-time accuracy
@@ -114,7 +116,7 @@ export const CurrentStatusSection = ({ page, components, services }: CurrentStat
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-3 text-card-foreground text-xl"> <CardTitle className="flex items-center gap-3 text-card-foreground text-xl">
<Shield className="h-6 w-6" /> <Shield className="h-6 w-6" />
System Status {t('systemStatus', 'public')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@@ -123,10 +125,10 @@ export const CurrentStatusSection = ({ page, components, services }: CurrentStat
{getStatusIcon(displayStatus)} {getStatusIcon(displayStatus)}
<div> <div>
<h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}> <h3 className={`text-2xl font-bold ${getStatusColor(displayStatus)}`}>
{getStatusMessage(displayStatus)} {getStatusMessage(displayStatus, t)}
</h3> </h3>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Status automatically updated based on component health {t('autoUpdatedByHealth', 'public')}
</p> </p>
</div> </div>
</div> </div>
@@ -136,20 +138,20 @@ export const CurrentStatusSection = ({ page, components, services }: CurrentStat
displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : displayStatus === 'maintenance' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}> }`}>
{displayStatus === 'operational' ? 'All Systems Operational' : {displayStatus === 'operational' ? t('allOperational', 'public') :
displayStatus === 'degraded' ? 'Degraded Performance' : displayStatus === 'degraded' ? t('degradedPerformance', 'public') :
displayStatus === 'maintenance' ? 'Under Maintenance' : 'Major Outage'} displayStatus === 'maintenance' ? t('underMaintenance', 'public') : t('majorOutage', 'public')}
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4"> <div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
<span>Last updated: {format(new Date(), 'MMM dd, yyyy HH:mm')} UTC</span> <span>{t('lastUpdatedAt', 'public', { time: format(new Date(), 'MMM dd, yyyy HH:mm') })}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div> <div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Live status monitoring</span> <span>{t('liveStatusMonitoring', 'public')}</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -9,10 +9,12 @@ import { CurrentStatusSection } from './CurrentStatusSection';
import { ComponentsStatusSection } from './ComponentsStatusSection'; import { ComponentsStatusSection } from './ComponentsStatusSection';
import { OverallUptimeSection } from './OverallUptimeSection'; import { OverallUptimeSection } from './OverallUptimeSection';
import { PublicStatusPageFooter } from './PublicStatusPageFooter'; import { PublicStatusPageFooter } from './PublicStatusPageFooter';
import { useLanguage } from '@/contexts/LanguageContext';
export const PublicStatusPage = () => { export const PublicStatusPage = () => {
const { t } = useLanguage();
const { slug } = useParams<{ slug: string }>(); 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 { page, components, services, uptimeData, loading, error } = usePublicStatusPageData(slug);
const [lastUpdated, setLastUpdated] = useState(new Date()); const [lastUpdated, setLastUpdated] = useState(new Date());
@@ -51,7 +53,7 @@ export const PublicStatusPage = () => {
}; };
}, [page?.theme]); }, [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) { if (loading) {
return ( return (
@@ -59,9 +61,9 @@ export const PublicStatusPage = () => {
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-lg font-medium text-foreground">Loading Status Page</p> <p className="text-lg font-medium text-foreground">{t('loadingStatusPage', 'public')}</p>
<p className="text-sm text-muted-foreground">Fetching real-time system status...</p> <p className="text-sm text-muted-foreground">{t('fetchingRealtimeStatus', 'public')}</p>
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p> <p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -76,19 +78,19 @@ export const PublicStatusPage = () => {
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" /> <AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Status Page Not Found</h1> <h1 className="text-2xl font-bold text-foreground">{t('statusPageNotFound', 'public')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{error || 'The requested status page could not be found or is not publicly accessible.'} {error || t('notFoundDescription', 'public')}
</p> </p>
<p className="text-xs text-muted-foreground">Slug: {slug || 'No slug provided'}</p> <p className="text-xs text-muted-foreground">{t('slugLabel', 'public')}: {slug || 'No slug provided'}</p>
</div> </div>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button onClick={() => window.history.back()} variant="outline"> <Button onClick={() => window.history.back()} variant="outline">
Go Back {t('goBack', 'public')}
</Button> </Button>
<Button onClick={() => window.location.reload()} className="gap-2"> <Button onClick={() => window.location.reload()} className="gap-2">
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
Retry {t('retry', 'public')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -19,61 +19,61 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
useEffect(() => { useEffect(() => {
const fetchPublicPage = async () => { const fetchPublicPage = async () => {
if (!slug) { if (!slug) {
console.log('No slug provided'); // console.log('No slug provided');
setError('No status page slug provided'); setError('No status page slug provided');
setLoading(false); setLoading(false);
return; return;
} }
try { try {
console.log('Fetching public status page for slug:', slug); // console.log('Fetching public status page for slug:', slug);
setLoading(true); setLoading(true);
setError(null); setError(null);
// Fetch operational page // Fetch operational page
console.log('Fetching operational pages...'); // console.log('Fetching operational pages...');
const pages = await operationalPageService.getOperationalPages(); 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'); const foundPage = pages.find(p => p.slug === slug && p.is_public === 'true');
console.log('Found page:', foundPage); // console.log('Found page:', foundPage);
if (!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'); setError('Status page not found or not public');
setLoading(false); setLoading(false);
return; return;
} }
setPage(foundPage); setPage(foundPage);
console.log('Page set successfully'); // console.log('Page set successfully');
// Fetch components for this page // 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); const pageComponents = await statusPageComponentsService.getStatusPageComponentsByOperationalId(foundPage.id);
console.log('Components found:', pageComponents); // console.log('Components found:', pageComponents);
setComponents(pageComponents); setComponents(pageComponents);
// Fetch all services // Fetch all services
console.log('Fetching all services...'); // console.log('Fetching all services...');
const allServices = await serviceService.getServices(); const allServices = await serviceService.getServices();
console.log('Services found:', allServices); // console.log('Services found:', allServices);
setServices(allServices); setServices(allServices);
// Fetch uptime data for each component that has a service // Fetch uptime data for each component that has a service
console.log('Fetching uptime data...'); // console.log('Fetching uptime data...');
const uptimePromises = pageComponents const uptimePromises = pageComponents
.filter(component => component.service_id) .filter(component => component.service_id)
.map(async (component) => { .map(async (component) => {
try { try {
console.log('Fetching uptime for service:', component.service_id); // console.log('Fetching uptime for service:', component.service_id);
const endDate = new Date(); const endDate = new Date();
const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); // Last 90 days 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); 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 }; return { serviceId: component.service_id, history };
} catch (error) { } 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: [] }; return { serviceId: component.service_id, history: [] };
} }
}); });
@@ -84,12 +84,12 @@ export const usePublicStatusPageData = (slug: string | undefined) => {
uptimeMap[result.serviceId] = result.history; uptimeMap[result.serviceId] = result.history;
}); });
setUptimeData(uptimeMap); 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) { } catch (err) {
console.error('Error fetching public page:', err); // console.error('Error fetching public page:', err);
setError(`Failed to load status page: ${err}`); setError(`Failed to load status page: ${err}`);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -1,15 +1,12 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Copy, Download, Terminal, CheckCircle, Zap, Play } from "lucide-react";
import { regionalService } from "@/services/regionalService";
import { InstallCommand } from "@/types/regional.types";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
import { getCurrentEndpoint } from "@/lib/pocketbase";
import { RegionalAgentConfigForm } from "./RegionalAgentConfigForm";
import { RegionalOneClickTab } from "./RegionalOneClickTab";
import { RegionalManualTab } from "./RegionalManualTab";
interface AddRegionalAgentDialogProps { interface AddRegionalAgentDialogProps {
open: boolean; open: boolean;
@@ -22,30 +19,54 @@ export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
onOpenChange, onOpenChange,
onAgentAdded onAgentAdded
}) => { }) => {
const [step, setStep] = useState(1); const { t } = useLanguage();
const [regionName, setRegionName] = useState("");
const [agentIp, setAgentIp] = useState("");
const [installCommand, setInstallCommand] = useState<InstallCommand | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const [activeTab, setActiveTab] = useState("configure");
const [formData, setFormData] = useState({
regionName: "",
agentIp: "",
});
const [agentToken, setAgentToken] = useState("");
const [agentId, setAgentId] = useState("");
const [currentPocketBaseUrl, setCurrentPocketBaseUrl] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleCreateAgent = async (e: React.FormEvent) => { // Generate new credentials when dialog opens or after successful creation
const generateNewCredentials = () => {
const newToken = `rgn_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
const newAgentId = `regional_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
setAgentToken(newToken);
setAgentId(newAgentId);
};
useEffect(() => {
if (open) {
const endpoint = getCurrentEndpoint();
setCurrentPocketBaseUrl(endpoint);
generateNewCredentials();
}
}, [open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!regionName.trim() || !agentIp.trim()) return; if (!formData.regionName.trim() || !formData.agentIp.trim()) return;
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const result = await regionalService.createRegionalService({ await new Promise(resolve => setTimeout(resolve, 1000));
region_name: regionName,
agent_ip_address: agentIp,
});
setInstallCommand(result.installCommand); toast({
setStep(2); title: t('success'),
description: t('agentCreatedSuccessfully'),
});
setActiveTab("one-click");
generateNewCredentials();
onAgentAdded();
} catch (error) { } catch (error) {
toast({ toast({
title: "Error", title: t('error'),
description: "Failed to create regional agent configuration.", description: t('failedToCreateAgent'),
variant: "destructive", variant: "destructive",
}); });
} finally { } finally {
@@ -53,407 +74,65 @@ export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
} }
}; };
const copyToClipboard = async (text: string, description: string = "Content") => { const handleDialogClose = () => {
try { setFormData({
// Try the modern clipboard API first regionName: "",
if (navigator.clipboard && window.isSecureContext) { agentIp: "",
await navigator.clipboard.writeText(text);
toast({
title: "Copied!",
description: `${description} copied to clipboard.`,
});
return;
}
// Fallback for older browsers or non-secure contexts (like localhost)
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
toast({
title: "Copied!",
description: `${description} copied to clipboard.`,
});
} else {
throw new Error('execCommand failed');
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
// Show the text in a modal or alert as final fallback
const userAgent = navigator.userAgent.toLowerCase();
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let errorMessage = "Failed to copy to clipboard.";
if (isLocalhost) {
errorMessage += " This is common in local development. Please manually copy the text.";
} else if (userAgent.includes('chrome')) {
errorMessage += " Try using HTTPS or enable clipboard permissions.";
}
toast({
title: "Copy failed",
description: errorMessage,
variant: "destructive",
});
// As a last resort, select the text for manual copying
try {
const textarea = document.querySelector('textarea[readonly]') as HTMLTextAreaElement;
if (textarea) {
textarea.select();
textarea.setSelectionRange(0, 99999); // For mobile devices
}
} catch (selectError) {
console.error('Failed to select text:', selectError);
}
}
};
const downloadScript = () => {
if (!installCommand) return;
const blob = new Blob([installCommand.bash_script], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `install-regional-agent-${installCommand.agent_id}.sh`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: "Downloaded!",
description: "Installation script downloaded successfully.",
}); });
}; setActiveTab("configure");
generateNewCredentials();
const handleComplete = () => {
onAgentAdded();
setStep(1);
setRegionName("");
setAgentIp("");
setInstallCommand(null);
onOpenChange(false); onOpenChange(false);
}; };
const resetDialog = () => {
setStep(1);
setRegionName("");
setAgentIp("");
setInstallCommand(null);
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto"> <DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Add Regional Monitoring Agent</DialogTitle> <DialogTitle>{t('addRegionalMonitoringAgent')}</DialogTitle>
<DialogDescription> <DialogDescription>
Deploy a regional monitoring agent with automatic one-click installation. {t('deployRegionalMonitoringAgent')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{step === 1 && ( <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<form onSubmit={handleCreateAgent} className="space-y-6"> <TabsList className="grid w-full grid-cols-3">
<div className="space-y-4"> <TabsTrigger value="configure">{t('configureAgent')}</TabsTrigger>
<div> <TabsTrigger value="one-click">{t('oneClickInstallTab')}</TabsTrigger>
<Label htmlFor="regionName">Region Name</Label> <TabsTrigger value="manual">{t('manualInstallTab')}</TabsTrigger>
<Input </TabsList>
id="regionName"
placeholder="e.g., us-east-1, europe-west-1, asia-pacific-1"
value={regionName}
onChange={(e) => setRegionName(e.target.value)}
required
/>
</div>
<div>
<Label htmlFor="agentIp">Agent Server IP Address</Label>
<Input
id="agentIp"
placeholder="e.g., 192.168.1.100 or your-server.example.com"
value={agentIp}
onChange={(e) => setAgentIp(e.target.value)}
required
/>
</div>
</div>
<div className="flex justify-end space-x-2"> <TabsContent value="configure" className="space-y-6">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <RegionalAgentConfigForm
Cancel formData={formData}
</Button> setFormData={setFormData}
<Button type="submit" disabled={isSubmitting}> agentId={agentId}
{isSubmitting ? "Generating..." : "Generate Installation"} agentToken={agentToken}
</Button> currentPocketBaseUrl={currentPocketBaseUrl}
</div> isSubmitting={isSubmitting}
</form> onSubmit={handleSubmit}
)} />
</TabsContent>
{step === 2 && installCommand && ( <TabsContent value="one-click" className="space-y-6">
<div className="space-y-6"> <RegionalOneClickTab
<div className="text-center space-y-2"> agentToken={agentToken}
<CheckCircle className="h-12 w-12 text-green-600 mx-auto" /> currentPocketBaseUrl={currentPocketBaseUrl}
<h3 className="text-lg font-semibold">Agent Configuration Ready!</h3> formData={formData}
<p className="text-muted-foreground"> agentId={agentId}
One-click installation script generated with automatic configuration. onDialogClose={handleDialogClose}
</p> />
</div> </TabsContent>
<Tabs defaultValue="oneclicK" className="w-full"> <TabsContent value="manual" className="space-y-6">
<TabsList className="grid w-full grid-cols-3"> <RegionalManualTab
<TabsTrigger value="oneclicK">One-Click Install</TabsTrigger> agentToken={agentToken}
<TabsTrigger value="details">Agent Details</TabsTrigger> currentPocketBaseUrl={currentPocketBaseUrl}
<TabsTrigger value="manual">Manual Install</TabsTrigger> formData={formData}
</TabsList> agentId={agentId}
onDialogClose={handleDialogClose}
<TabsContent value="oneclicK" className="space-y-4"> />
<Card> </TabsContent>
<CardHeader> </Tabs>
<CardTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-yellow-500" />
One-Click Automatic Installation
</CardTitle>
<CardDescription>
Complete installation, configuration, and service startup in one command
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-green-800 font-medium mb-2">
<Play className="h-4 w-4" />
What this script does automatically:
</div>
<ul className="text-sm text-green-700 space-y-1 ml-6">
<li> Downloads the latest .deb package</li>
<li> Installs the regional monitoring agent</li>
<li> Creates configuration file with your settings</li>
<li> Starts and enables the service</li>
<li> Runs health checks</li>
</ul>
</div>
<div className="space-y-2">
<Label>Run this command on your target server:</Label>
<div className="relative">
<Textarea
readOnly
value={`# One-click installation command:
curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- \\
--region-name="${regionName}" \\
--agent-id="${installCommand.agent_id}" \\
--agent-ip="${agentIp}" \\
--token="${installCommand.token}" \\
--pocketbase-url="${installCommand.api_endpoint}"`}
className="font-mono text-sm min-h-[120px] pr-12"
/>
<Button
size="sm"
variant="outline"
className="absolute top-2 right-2"
onClick={() => copyToClipboard(`curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- --region-name="${regionName}" --agent-id="${installCommand.agent_id}" --agent-ip="${agentIp}" --token="${installCommand.token}" --pocketbase-url="${installCommand.api_endpoint}"`, "Installation command")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex gap-2">
<Button onClick={downloadScript} variant="outline" className="flex-1">
<Download className="mr-2 h-4 w-4" />
Download Complete Script
</Button>
<Button
onClick={() => copyToClipboard(installCommand.bash_script, "Installation script")}
variant="outline"
className="flex-1"
>
<Copy className="mr-2 h-4 w-4" />
Copy Full Script
</Button>
</div>
{/* Local development notice */}
{(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<p className="text-sm text-yellow-800">
<strong>Local Development Notice:</strong> If clipboard copying fails, this is normal in local development.
You can use the "Download Complete Script" button instead or manually copy the text.
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="details" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Generated Agent Configuration</CardTitle>
<CardDescription>
These values will be automatically configured during installation
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium text-foreground">Agent ID</Label>
<div className="relative">
<Input
value={installCommand.agent_id}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
/>
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(installCommand.agent_id, "Agent ID")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div>
<Label className="text-sm font-medium text-foreground">Region Name</Label>
<Input
value={regionName}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
/>
</div>
<div>
<Label className="text-sm font-medium text-foreground">Server IP</Label>
<Input
value={agentIp}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
/>
</div>
<div>
<Label className="text-sm font-medium text-foreground">API Endpoint</Label>
<Input
value={installCommand.api_endpoint}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground"
/>
</div>
</div>
<div>
<Label className="text-sm font-medium text-foreground">Authentication Token</Label>
<div className="relative">
<Input
value={installCommand.token}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 text-foreground pr-10"
/>
<Button
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(installCommand.token, "Authentication token")}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Configuration file location:</strong> /etc/regional-check-agent/regional-check-agent.env
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
Manual Installation Steps
</CardTitle>
<CardDescription>
Step-by-step manual installation process
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm space-y-4">
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 1: Download Package</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
<p className="text-xs text-muted-foreground mt-1"> If youre using the amd64 architecture, please download this package. </p> <br/>
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_amd64.deb
</code>
<code className="text-xs bg-muted p-2 rounded block mt-1">
<p className="text-xs text-muted-foreground mt-1"> If youre using the arm64 architecture, please download this package. </p> <br/>
wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_arm64.deb
</code>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 2: Install Package</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo dpkg -i distributed-regional-check-agent_1.0.0_amd64.deb<br/>
sudo apt-get install -f
</code>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 3: Configure Agent</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo nano /etc/regional-check-agent/regional-check-agent.env
</code>
<code className="text-xs bg-muted p-2 rounded block mt-1">
Copy sample configuration from https://github.com/operacle/Distributed-Regional-Monitoring/blob/main/packaging/regional-check-agent.conf
</code>
<p className="text-xs text-muted-foreground mt-1">Add the configuration values shown in the Agent Details tab</p>
</div>
<div className="border-l-4 border-blue-500 pl-4">
<p className="font-medium">Step 4: Start Service</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo systemctl enable regional-check-agent<br/>
sudo systemctl start regional-check-agent
</code>
</div>
<div className="border-l-4 border-green-500 pl-4">
<p className="font-medium">Step 5: Verify Installation</p>
<code className="text-xs bg-muted p-2 rounded block mt-1">
sudo systemctl status regional-check-agent<br/>
curl http://localhost:8091/health
</code>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={resetDialog}>
Add Another Agent
</Button>
<Button onClick={handleComplete}>
Complete Setup
</Button>
</div>
</div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -8,6 +8,7 @@ import { MapPin, Wifi, WifiOff, MoreVertical, Trash2, Terminal, Copy } from "luc
import { RegionalService } from "@/types/regional.types"; import { RegionalService } from "@/types/regional.types";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
interface RegionalAgentCardProps { interface RegionalAgentCardProps {
agent: RegionalService; agent: RegionalService;
@@ -16,6 +17,7 @@ interface RegionalAgentCardProps {
export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => { export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onDelete }) => {
const { toast } = useToast(); const { toast } = useToast();
const { t } = useLanguage();
// Check if this is the default agent that cannot be removed // Check if this is the default agent that cannot be removed
const isDefaultAgent = agent.agent_id === "1" || agent.region_name === "Default"; const isDefaultAgent = agent.agent_id === "1" || agent.region_name === "Default";
@@ -24,13 +26,13 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
try { try {
await navigator.clipboard.writeText(agent.agent_id); await navigator.clipboard.writeText(agent.agent_id);
toast({ toast({
title: "Copied!", title: t('copied'),
description: "Agent ID copied to clipboard.", description: t('copiedDescription'),
}); });
} catch (error) { } catch (error) {
toast({ toast({
title: "Copy failed", title: t('copyFailed'),
description: "Failed to copy agent ID.", description: t('copyFailedDescription'),
variant: "destructive", variant: "destructive",
}); });
} }
@@ -40,14 +42,14 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
if (agent.connection === 'online') { if (agent.connection === 'online') {
return { return {
icon: <Wifi className="h-4 w-4" />, icon: <Wifi className="h-4 w-4" />,
label: 'Online', label: t('online'),
variant: 'default' as const, variant: 'default' as const,
className: 'bg-green-100 text-green-800 hover:bg-green-100' className: 'bg-green-100 text-green-800 hover:bg-green-100'
}; };
} else { } else {
return { return {
icon: <WifiOff className="h-4 w-4" />, icon: <WifiOff className="h-4 w-4" />,
label: 'Offline', label: t('offline'),
variant: 'secondary' as const, variant: 'secondary' as const,
className: 'bg-red-100 text-red-800 hover:bg-red-100' className: 'bg-red-100 text-red-800 hover:bg-red-100'
}; };
@@ -67,7 +69,7 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
{agent.region_name} {agent.region_name}
{isDefaultAgent && ( {isDefaultAgent && (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200"> <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
Default {t('defaultBadge')}
</Badge> </Badge>
)} )}
</CardTitle> </CardTitle>
@@ -86,12 +88,12 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={copyAgentId}> <DropdownMenuItem onClick={copyAgentId}>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
Copy Agent ID {t('copyAgentId')}
</DropdownMenuItem> </DropdownMenuItem>
{!isDefaultAgent && ( {!isDefaultAgent && (
<DropdownMenuItem onClick={onDelete} className="text-red-600"> <DropdownMenuItem onClick={onDelete} className="text-red-600">
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Remove Agent {t('removeAgent')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@@ -116,12 +118,12 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Agent ID:</span> <span className="text-muted-foreground">{t('agentId')}</span>
<span className="font-mono text-xs">{agent.agent_id.substring(0, 12)}...</span> <span className="font-mono text-xs">{agent.agent_id.substring(0, 12)}...</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground">Last Updated:</span> <span className="text-muted-foreground">{t('lastUpdated')}</span>
<span className="text-xs"> <span className="text-xs">
{formatDistanceToNow(new Date(agent.updated), { addSuffix: true })} {formatDistanceToNow(new Date(agent.updated), { addSuffix: true })}
</span> </span>
@@ -132,7 +134,7 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="flex items-center text-xs text-green-600"> <div className="flex items-center text-xs text-green-600">
<div className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></div> <div className="w-2 h-2 bg-green-600 rounded-full mr-2 animate-pulse"></div>
Active monitoring {t('activeMonitoring')}
</div> </div>
</div> </div>
)} )}
@@ -141,7 +143,7 @@ export const RegionalAgentCard: React.FC<RegionalAgentCardProps> = ({ agent, onD
<div className="pt-2 border-t"> <div className="pt-2 border-t">
<div className="flex items-center text-xs text-red-600"> <div className="flex items-center text-xs text-red-600">
<div className="w-2 h-2 bg-red-600 rounded-full mr-2"></div> <div className="w-2 h-2 bg-red-600 rounded-full mr-2"></div>
Connection lost {t('connectionLost')}
</div> </div>
</div> </div>
)} )}
@@ -0,0 +1,128 @@
import React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Copy } from "lucide-react";
import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
interface RegionalAgentConfigFormProps {
formData: {
regionName: string;
agentIp: string;
};
setFormData: (data: { regionName: string; agentIp: string }) => void;
agentId: string;
agentToken: string;
currentPocketBaseUrl: string;
isSubmitting: boolean;
onSubmit: (e: React.FormEvent) => void;
}
export const RegionalAgentConfigForm: React.FC<RegionalAgentConfigFormProps> = ({
formData,
setFormData,
agentId,
agentToken,
currentPocketBaseUrl,
isSubmitting,
onSubmit,
}) => {
const { t } = useLanguage();
return (
<form onSubmit={onSubmit} className="space-y-6">
<div className="space-y-4">
<div>
<Label htmlFor="regionName">{t('regionName')}</Label>
<Input
id="regionName"
placeholder={t('regionNamePlaceholder')}
value={formData.regionName}
onChange={(e) => setFormData({ ...formData, regionName: e.target.value })}
required
/>
</div>
<div>
<Label htmlFor="agentIp">{t('agentServerIpAddress')}</Label>
<Input
id="agentIp"
placeholder={t('agentIpPlaceholder')}
value={formData.agentIp}
onChange={(e) => setFormData({ ...formData, agentIp: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium">{t('agentId')}</Label>
<div className="relative">
<Input
value={agentId}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(agentId)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div>
<Label className="text-sm font-medium">{t('apiEndpoint')}</Label>
<div className="relative">
<Input
value={currentPocketBaseUrl}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(currentPocketBaseUrl)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
<div>
<Label className="text-sm font-medium">{t('authenticationToken')}</Label>
<div className="relative">
<Input
value={agentToken}
readOnly
className="font-mono text-sm bg-muted/50 border-muted-foreground/20 pr-10"
/>
<Button
type="button"
size="sm"
variant="ghost"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 hover:bg-muted"
onClick={() => copyToClipboard(agentToken)}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t('creating') : t('createAgent')}
</Button>
</div>
</form>
);
};
@@ -0,0 +1,159 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Copy, Terminal } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
interface RegionalManualTabProps {
agentToken: string;
currentPocketBaseUrl: string;
formData: {
regionName: string;
agentIp: string;
};
agentId: string;
onDialogClose: () => void;
}
export const RegionalManualTab: React.FC<RegionalManualTabProps> = ({
agentToken,
currentPocketBaseUrl,
formData,
agentId,
onDialogClose,
}) => {
const { t } = useLanguage();
const getManualInstallSteps = () => {
return [
{
title: t('step1DownloadPackage'),
commands: [
{
label: t('downloadAmd64Notice'),
command: 'wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_amd64.deb'
},
{
label: t('downloadArm64Notice'),
command: 'wget https://github.com/operacle/Distributed-Regional-Monitoring/releases/download/V1.0.0/distributed-regional-check-agent_1.0.0_arm64.deb'
}
]
},
{
title: t('step2InstallPackage'),
commands: [
{
label: '',
command: 'sudo dpkg -i distributed-regional-check-agent_1.0.0_amd64.deb\nsudo apt-get install -f'
}
]
},
{
title: t('step3ConfigureAgent'),
commands: [
{
label: '',
command: 'sudo nano /etc/regional-check-agent/regional-check-agent.env'
},
{
label: t('addConfigurationValues'),
command: `POCKETBASE_URL=${currentPocketBaseUrl}
AGENT_TOKEN=${agentToken}
AGENT_ID=${agentId}
REGION_NAME=${formData.regionName}
AGENT_IP=${formData.agentIp}`
}
]
},
{
title: t('step4StartService'),
commands: [
{
label: '',
command: 'sudo systemctl enable regional-check-agent\nsudo systemctl start regional-check-agent'
}
]
},
{
title: t('step5VerifyInstallation'),
commands: [
{
label: '',
command: 'sudo systemctl status regional-check-agent\ncurl http://localhost:8091/health'
}
]
}
];
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
{t('manualInstallationSteps')}
</CardTitle>
<CardDescription>
{t('stepByStepManualInstallation')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
{getManualInstallSteps().map((step, stepIndex) => (
<div key={stepIndex} className="border-l-4 border-blue-500 pl-4 space-y-2">
<div className="flex items-center gap-2">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-sm font-medium">
{stepIndex + 1}
</span>
<p className="font-medium">{step.title}</p>
</div>
{step.commands.map((cmd, cmdIndex) => (
<div key={cmdIndex} className="space-y-1">
{cmd.label && (
<p className="text-xs text-muted-foreground ml-8">{cmd.label}</p>
)}
<div className="ml-8 relative">
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all">
<code>{cmd.command}</code>
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="absolute top-2 right-2"
onClick={() => copyToClipboard(cmd.command)}
>
<Copy className="h-4 w-4 mr-1" />
Copy
</Button>
</div>
</div>
))}
</div>
))}
</div>
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">Prerequisites:</h4>
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1 list-disc list-inside mb-3">
<li>Ensure you have root/sudo access on the target server</li>
<li>Make sure wget or curl is installed for downloading files</li>
<li>Internet connection required for downloading packages</li>
</ul>
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">After Installation:</h4>
<p className="text-sm text-blue-800 dark:text-blue-200">
The agent will start automatically and appear in your dashboard within a few minutes.
</p>
</div>
<div className="flex justify-end pt-4">
<Button onClick={onDialogClose}>
{t('done')}
</Button>
</div>
</CardContent>
</Card>
);
};
@@ -9,8 +9,10 @@ import { RegionalService } from "@/types/regional.types";
import { AddRegionalAgentDialog } from "./AddRegionalAgentDialog"; import { AddRegionalAgentDialog } from "./AddRegionalAgentDialog";
import { RegionalAgentCard } from "./RegionalAgentCard"; import { RegionalAgentCard } from "./RegionalAgentCard";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
export const RegionalMonitoringContent = () => { export const RegionalMonitoringContent = () => {
const { t } = useLanguage();
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -24,8 +26,8 @@ export const RegionalMonitoringContent = () => {
const handleAgentAdded = () => { const handleAgentAdded = () => {
queryClient.invalidateQueries({ queryKey: ['regional-services'] }); queryClient.invalidateQueries({ queryKey: ['regional-services'] });
toast({ toast({
title: "Regional Agent Added", title: t('regionalAgentAdded'),
description: "The regional monitoring agent has been successfully configured.", description: t('regionalAgentAddedDesc'),
}); });
}; };
@@ -34,13 +36,13 @@ export const RegionalMonitoringContent = () => {
await regionalService.deleteRegionalService(id); await regionalService.deleteRegionalService(id);
queryClient.invalidateQueries({ queryKey: ['regional-services'] }); queryClient.invalidateQueries({ queryKey: ['regional-services'] });
toast({ toast({
title: "Agent Removed", title: t('agentRemoved'),
description: "The regional monitoring agent has been removed.", description: t('agentRemovedDesc'),
}); });
} catch (error) { } catch (error) {
toast({ toast({
title: "Error", title: t('error'),
description: "Failed to remove the regional monitoring agent.", description: t('failedToRemoveAgent'),
variant: "destructive", variant: "destructive",
}); });
} }
@@ -53,14 +55,14 @@ export const RegionalMonitoringContent = () => {
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Regional Monitoring</h1> <h1 className="text-3xl font-bold tracking-tight">{t('regionalmonitoring')}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage distributed monitoring agents across different regions {t('descriptRegionPage')}
</p> </p>
</div> </div>
<Button onClick={() => setAddDialogOpen(true)}> <Button onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Regional Agent {t('addRegionalAgent')}
</Button> </Button>
</div> </div>
@@ -68,39 +70,39 @@ export const RegionalMonitoringContent = () => {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Agents</CardTitle> <CardTitle className="text-sm font-medium">{t('totalAgents')}</CardTitle>
<MapPin className="h-4 w-4 text-muted-foreground" /> <MapPin className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{totalAgents}</div> <div className="text-2xl font-bold">{totalAgents}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Regional monitoring agents {t('regionalMonitoringAgents')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Online Agents</CardTitle> <CardTitle className="text-sm font-medium">{t('onlineAgents')}</CardTitle>
<Wifi className="h-4 w-4 text-green-600" /> <Wifi className="h-4 w-4 text-green-600" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-green-600">{onlineAgents}</div> <div className="text-2xl font-bold text-green-600">{onlineAgents}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Currently connected {t('currentlyConnected')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <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">{t('offlineAgents')}</CardTitle>
<WifiOff className="h-4 w-4 text-red-600" /> <WifiOff className="h-4 w-4 text-red-600" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-red-600">{totalAgents - onlineAgents}</div> <div className="text-2xl font-bold text-red-600">{totalAgents - onlineAgents}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Disconnected agents {t('disconnectedAgents')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -108,7 +110,7 @@ export const RegionalMonitoringContent = () => {
{/* Agents List */} {/* Agents List */}
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold">Regional Agents</h2> <h2 className="text-xl font-semibold">{t('regionalAgents')}</h2>
{isLoading ? ( {isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -131,13 +133,13 @@ export const RegionalMonitoringContent = () => {
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<MapPin className="h-12 w-12 text-muted-foreground mb-4" /> <MapPin className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">No Regional Agents</h3> <h3 className="text-lg font-semibold mb-2">{t('noRegionalAgents')}</h3>
<p className="text-muted-foreground text-center mb-4"> <p className="text-muted-foreground text-center mb-4">
Get started by adding your first regional monitoring agent to extend your monitoring coverage. {t('getStartedAddAgent')}
</p> </p>
<Button onClick={() => setAddDialogOpen(true)}> <Button onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add First Agent {t('addFirstAgent')}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -0,0 +1,126 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Copy, Download, Zap, Play } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
import { useToast } from "@/hooks/use-toast";
interface RegionalOneClickTabProps {
agentToken: string;
currentPocketBaseUrl: string;
formData: {
regionName: string;
agentIp: string;
};
agentId: string;
onDialogClose: () => void;
}
export const RegionalOneClickTab: React.FC<RegionalOneClickTabProps> = ({
agentToken,
currentPocketBaseUrl,
formData,
agentId,
onDialogClose,
}) => {
const { t } = useLanguage();
const { toast } = useToast();
const getOneClickCommand = () => {
return `curl -fsSL https://raw.githubusercontent.com/operacle/checkcle/refs/heads/main/scripts/install-regional-agent.sh | sudo bash -s -- \\
--region-name="${formData.regionName}" \\
--agent-id="${agentId}" \\
--agent-ip="${formData.agentIp}" \\
--token="${agentToken}" \\
--pocketbase-url="${currentPocketBaseUrl}"`;
};
const downloadScript = () => {
const scriptContent = getOneClickCommand();
const blob = new Blob([scriptContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `install-regional-agent-${agentId}.sh`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: t('downloaded'),
description: t('downloadedDescription'),
});
};
return (
<Card className="border-green-500/20 bg-green-50/50 dark:bg-green-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400">
<Zap className="h-5 w-5" />
{t('oneClickAutomaticInstallation')}
</CardTitle>
<CardDescription className="text-green-600 dark:text-green-300">
{t('completeInstallationDescription')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4 dark:bg-green-950/50 dark:border-green-800">
<div className="flex items-center gap-2 text-green-800 dark:text-green-200 font-medium mb-2">
<Play className="h-4 w-4" />
{t('whatThisScriptDoes')}
</div>
<ul className="text-sm text-green-700 dark:text-green-300 space-y-1 ml-6">
<li> {t('scriptActionDownload')}</li>
<li> {t('scriptActionInstall')}</li>
<li> {t('scriptActionConfigure')}</li>
<li> {t('scriptActionStart')}</li>
<li> {t('scriptActionHealthChecks')}</li>
</ul>
</div>
<div className="space-y-2">
<Label className="text-green-700 dark:text-green-400">{t('runCommandOnServer')}</Label>
<div className="relative">
<Textarea
readOnly
value={getOneClickCommand()}
className="font-mono text-sm min-h-[120px] pr-12 bg-muted/50 border-green-200 dark:border-green-800"
/>
<Button
type="button"
size="sm"
variant="outline"
className="absolute top-2 right-2 bg-green-50 dark:bg-green-950/50 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900"
onClick={() => copyToClipboard(getOneClickCommand())}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="flex gap-2">
<Button onClick={downloadScript} variant="outline" className="flex-1">
<Download className="mr-2 h-4 w-4" />
{t('downloadCompleteScript')}
</Button>
</div>
{(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 dark:bg-yellow-950/20 dark:border-yellow-800">
<p className="text-sm text-yellow-800 dark:text-yellow-200" dangerouslySetInnerHTML={{ __html: t('localDevelopmentNotice') }} />
</div>
)}
<div className="flex justify-end pt-4">
<Button onClick={onDialogClose}>
{t('done')}
</Button>
</div>
</CardContent>
</Card>
);
};
@@ -25,19 +25,20 @@ export const IncidentDetailContent = ({
onClose, onClose,
assignedUser assignedUser
}: IncidentDetailContentProps) => { }: 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({ const { data: fetchedUser } = useQuery({
queryKey: ['user', incident?.assigned_to], queryKey: ['user', assigneeId],
queryFn: async () => { queryFn: async () => {
if (!incident?.assigned_to) return null; if (!assigneeId) return null;
try { try {
return await userService.getUser(incident.assigned_to); return await userService.getUser(assigneeId);
} catch (error) { } catch (error) {
console.error("Failed to fetch assigned user:", error); console.error("Failed to fetch assigned user:", error);
return null; return null;
} }
}, },
enabled: !!incident?.assigned_to && !assignedUser, enabled: !!assigneeId && !assignedUser,
staleTime: 300000 // Cache for 5 minutes staleTime: 300000 // Cache for 5 minutes
}); });
@@ -17,7 +17,7 @@ export const IncidentDetailDialog = ({
onOpenChange, onOpenChange,
incident incident
}: IncidentDetailDialogProps) => { }: 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({ const { data: users = [] } = useQuery({
queryKey: ['users'], queryKey: ['users'],
queryFn: async () => { queryFn: async () => {
@@ -25,12 +25,12 @@ export const IncidentDetailDialog = ({
return Array.isArray(usersList) ? usersList : []; return Array.isArray(usersList) ? usersList : [];
}, },
staleTime: 300000, // Cache for 5 minutes 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 // Find the assigned user (prefer assigned_users, fallback to assigned_to)
const assignedUser = incident?.assigned_to const assignedUser = (incident?.assigned_users || incident?.assigned_to)
? users.find(user => user.id === incident.assigned_to) ? users.find(user => user.id === (incident?.assigned_users || incident?.assigned_to))
: null; : null;
if (!incident) return null; if (!incident) return null;
@@ -23,19 +23,19 @@ export {
// Legacy component - keeping this for backward compatibility with other imports // Legacy component - keeping this for backward compatibility with other imports
export const IncidentDetailSections = ({ incident }: { incident: IncidentItem | null }) => { 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({ const { data: assignedUser } = useQuery({
queryKey: ['user', incident?.assigned_to], queryKey: ['user', incident?.assigned_users || incident?.assigned_to],
queryFn: async () => { queryFn: async () => {
if (!incident?.assigned_to) return null; if (!(incident?.assigned_users || incident?.assigned_to)) return null;
try { try {
return await userService.getUser(incident.assigned_to); return await userService.getUser(incident?.assigned_users || incident?.assigned_to);
} catch (error) { } catch (error) {
console.error("Failed to fetch assigned user:", error); console.error("Failed to fetch assigned user:", error);
return null; return null;
} }
}, },
enabled: !!incident?.assigned_to, enabled: !!(incident?.assigned_users || incident?.assigned_to),
staleTime: 300000 // Cache for 5 minutes staleTime: 300000 // Cache for 5 minutes
}); });
@@ -31,8 +31,8 @@ export const AssignmentSection: React.FC<AssignmentSectionProps> = ({ incident,
</Avatar> </Avatar>
<span>{assignedUser.full_name || assignedUser.username}</span> <span>{assignedUser.full_name || assignedUser.username}</span>
</div> </div>
) : incident.assigned_to ? ( ) : (incident.assigned_users || incident.assigned_to) ? (
<span>{incident.assigned_to}</span> <span>{incident.assigned_users || incident.assigned_to}</span>
) : ( ) : (
<span className="text-muted-foreground italic">{t('unassigned')}</span> <span className="text-muted-foreground italic">{t('unassigned')}</span>
)} )}
@@ -50,8 +50,8 @@ export const BasicInfoSection: React.FC<BasicInfoSectionProps> = ({ incident, as
</Avatar> </Avatar>
<span>{assignedUser.full_name || assignedUser.username}</span> <span>{assignedUser.full_name || assignedUser.username}</span>
</div> </div>
) : incident.assigned_to ? ( ) : (incident.assigned_users || incident.assigned_to) ? (
<span>{incident.assigned_to}</span> <span>{incident.assigned_users || incident.assigned_to}</span>
) : ( ) : (
<span className="text-muted-foreground italic">{t('unassigned')}</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')); const selectedUser = users.find(user => user.id === form.getValues('assigned_to'));
// Function to get user initials from name // 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) { if (user.full_name) {
const nameParts = user.full_name.split(' '); const nameParts = user.full_name.split(' ');
if (nameParts.length > 1) { if (nameParts.length > 1) {
@@ -25,7 +25,7 @@ export const useIncidentEditForm = (
impact: (incident.impact?.toLowerCase() || 'minor') as any, impact: (incident.impact?.toLowerCase() || 'minor') as any,
priority: (incident.priority?.toLowerCase() || 'medium') as any, priority: (incident.priority?.toLowerCase() || 'medium') as any,
service_id: incident.service_id || '', service_id: incident.service_id || '',
assigned_to: incident.assigned_to || '', assigned_to: incident.assigned_users || incident.assigned_to || '',
root_cause: incident.root_cause || '', root_cause: incident.root_cause || '',
resolution_steps: incident.resolution_steps || '', resolution_steps: incident.resolution_steps || '',
lessons_learned: incident.lessons_learned || '', lessons_learned: incident.lessons_learned || '',
@@ -45,11 +45,14 @@ export const useIncidentEditForm = (
impact: data.impact, impact: data.impact,
priority: data.priority, priority: data.priority,
service_id: data.service_id, 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, root_cause: data.root_cause,
resolution_steps: data.resolution_steps, resolution_steps: data.resolution_steps,
lessons_learned: data.lessons_learned, lessons_learned: data.lessons_learned,
}); });
toast({ toast({
title: t('incidentUpdated'), title: t('incidentUpdated'),
@@ -57,6 +57,7 @@ export const useIncidentForm = (onSuccess: () => void, onClose: () => void) => {
priority: data.priority, priority: data.priority,
service_id: data.service_id, service_id: data.service_id,
assigned_to: data.assigned_to, assigned_to: data.assigned_to,
assigned_users: data.assigned_to, // map to server field
root_cause: data.root_cause, root_cause: data.root_cause,
resolution_steps: data.resolution_steps, resolution_steps: data.resolution_steps,
lessons_learned: data.lessons_learned, lessons_learned: data.lessons_learned,
@@ -86,7 +86,7 @@ export const IncidentTableRow = memo(({
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<AssignedUserCell userId={localItem.assigned_to} /> <AssignedUserCell userId={localItem.assigned_users || localItem.assigned_to} />
</TableCell> </TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}> <TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end items-center space-x-2"> <div className="flex justify-end items-center space-x-2">
@@ -14,7 +14,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getCurrentEndpoint } from "@/lib/pocketbase"; import { getCurrentEndpoint } from "@/lib/pocketbase";
import { ServerAgentConfigForm } from "./ServerAgentConfigForm"; import { ServerAgentConfigForm } from "./ServerAgentConfigForm";
import { OneClickInstallTab } from "./OneClickInstallTab"; import { OneClickInstallTab } from "./OneClickInstallTab";
import { DockerOneClickTab } from "./DockerOneClickTab";
import { ManualInstallTab } from "./ManualInstallTab"; import { ManualInstallTab } from "./ManualInstallTab";
import { useLanguage } from "@/contexts/LanguageContext";
interface AddServerAgentDialogProps { interface AddServerAgentDialogProps {
open: boolean; open: boolean;
@@ -27,15 +29,13 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
onOpenChange, onOpenChange,
onAgentAdded, onAgentAdded,
}) => { }) => {
const { t } = useLanguage();
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState("configure"); const [activeTab, setActiveTab] = useState("configure");
// Get current PocketBase URL
const currentPocketBaseUrl = getCurrentEndpoint(); const currentPocketBaseUrl = getCurrentEndpoint();
// Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
serverName: "", serverName: "",
description: "", description: "",
@@ -46,23 +46,46 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
notificationEnabled: true, notificationEnabled: true,
}); });
// Generated server token and agent ID const generateNewCredentials = () => ({
const [serverToken] = useState(() => serverToken: `srv_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`,
serverId: `agent_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
});
const [serverToken, setServerToken] = useState(() =>
`srv_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` `srv_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
); );
const [serverId] = useState(() => const [serverId, setServerId] = useState(() =>
`agent_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` `agent_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`
); );
// Refresh credentials and reset form whenever the dialog opens
React.useEffect(() => {
if (open) {
const newCredentials = generateNewCredentials();
setServerToken(newCredentials.serverToken);
setServerId(newCredentials.serverId);
setActiveTab("configure");
setFormData({
serverName: "",
description: "",
osType: "",
checkInterval: "60",
retryAttempt: "3",
dockerEnabled: false,
notificationEnabled: true,
});
}
}, [open]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isSubmitting) return; if (isSubmitting) return;
if (!formData.serverName || !formData.osType) { if (!formData.serverName || !formData.osType) {
toast({ toast({
title: "Validation Error", title: t('validationError'),
description: "Please fill in all required fields.", description: t('fillRequiredFields'),
variant: "destructive", variant: "destructive",
}); });
return; return;
@@ -71,22 +94,18 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// Here you would typically create the server monitoring configuration
// For now, we'll simulate the process
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise(resolve => setTimeout(resolve, 1500));
toast({ toast({
title: "Server Agent Created", title: t('serverAgentCreated'),
description: `${formData.serverName} monitoring agent has been configured successfully.`, description: t('serverAgentCreatedDesc').replace('{name}', formData.serverName),
}); });
// Switch to one-click install tab after successful creation
setActiveTab("one-click"); setActiveTab("one-click");
onAgentAdded(); onAgentAdded();
} catch (error) { } catch (error) {
toast({ toast({
title: "Error", title: t('error'),
description: "Failed to create server monitoring agent.", description: t('failedToCreateAgent'),
variant: "destructive", variant: "destructive",
}); });
} finally { } finally {
@@ -95,17 +114,6 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
}; };
const handleDialogClose = () => { const handleDialogClose = () => {
// Reset form and tab when dialog closes
setActiveTab("configure");
setFormData({
serverName: "",
description: "",
osType: "",
checkInterval: "60",
retryAttempt: "3",
dockerEnabled: false,
notificationEnabled: true,
});
onOpenChange(false); onOpenChange(false);
}; };
@@ -115,18 +123,19 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Server className="h-5 w-5" /> <Server className="h-5 w-5" />
Add Server Monitoring Agent {t('addServerMonitoringAgent')}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Configure a new server monitoring agent to track system metrics and performance. {t('configureAgentDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <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="configure">{t('configureAgent')}</TabsTrigger>
<TabsTrigger value="one-click">One-Click Install</TabsTrigger> <TabsTrigger value="one-click">{t('oneClickInstall')}</TabsTrigger>
<TabsTrigger value="manual">Manual Installation</TabsTrigger> <TabsTrigger value="docker-one-click">{t('dockerOneClick')}</TabsTrigger>
<TabsTrigger value="manual">{t('manualInstallation')}</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="configure" className="space-y-6"> <TabsContent value="configure" className="space-y-6">
@@ -151,6 +160,16 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
/> />
</TabsContent> </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"> <TabsContent value="manual" className="space-y-6">
<ManualInstallTab <ManualInstallTab
serverToken={serverToken} serverToken={serverToken}
@@ -0,0 +1,178 @@
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";
import { useLanguage } from "@/contexts/LanguageContext";
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 { t } = useLanguage();
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" />
{t('dockerOneClickTitle')}
</CardTitle>
<CardDescription className="text-blue-600 dark:text-blue-300">
{t('dockerOneClickDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-blue-700 dark:text-blue-400">{t('dockerOneClickCommand')}</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" />
{t('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">{t('dockerScriptWill')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>{t('dockerScriptStep1')}</li>
<li>{t('dockerScriptStep2')}</li>
<li>{t('dockerScriptStep3')}</li>
<li>{t('dockerScriptStep4')}</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" />
{t('directDockerTitle')}
</CardTitle>
<CardDescription>
{t('directDockerDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('dockerRunCommand')}</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" />
{t('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">{t('dockerPrerequisites')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>{t('dockerPrereqStep1')}</li>
<li>{t('dockerPrereqStep2')}</li>
<li>{t('dockerPrereqStep3')}</li>
</ol>
</div>
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button onClick={onDialogClose}>
{t('done')}
</Button>
</div>
</div>
);
};
@@ -12,8 +12,9 @@ import { pb } from "@/lib/pocketbase";
import { Server } from "@/types/server.types"; import { Server } from "@/types/server.types";
import { RefreshCw, X } from "lucide-react"; import { RefreshCw, X } from "lucide-react";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService"; import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { templateService, NotificationTemplate } from "@/services/templateService"; import { templateService, ServerNotificationTemplate } from "@/services/templateService";
import { serverThresholdService, ServerThreshold } from "@/services/serverThresholdService"; import { serverThresholdService, ServerThreshold } from "@/services/serverThresholdService";
import { useLanguage } from "@/contexts/LanguageContext";
interface EditServerDialogProps { interface EditServerDialogProps {
server: Server | null; server: Server | null;
@@ -25,7 +26,7 @@ interface EditServerDialogProps {
interface ServerFormData { interface ServerFormData {
name: string; name: string;
check_interval: number; check_interval: number;
retry_attempts: number; max_retries: number;
docker_monitoring: boolean; docker_monitoring: boolean;
notification_enabled: boolean; notification_enabled: boolean;
notification_channels: string[]; // Changed to array for multiple selections notification_channels: string[]; // Changed to array for multiple selections
@@ -46,10 +47,11 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
onOpenChange, onOpenChange,
onServerUpdated, onServerUpdated,
}) => { }) => {
const { t } = useLanguage();
const [formData, setFormData] = useState<ServerFormData>({ const [formData, setFormData] = useState<ServerFormData>({
name: "", name: "",
check_interval: 60, check_interval: 60,
retry_attempts: 3, max_retries: 3,
docker_monitoring: false, docker_monitoring: false,
notification_enabled: false, notification_enabled: false,
notification_channels: [], // Changed to array notification_channels: [], // Changed to array
@@ -66,9 +68,9 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]); const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [templates, setTemplates] = useState<NotificationTemplate[]>([]); const [templates, setTemplates] = useState<ServerNotificationTemplate[]>([]);
const [thresholds, setThresholds] = useState<ServerThreshold[]>([]); 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 [selectedThreshold, setSelectedThreshold] = useState<ServerThreshold | null>(null);
const [loadingAlertConfigs, setLoadingAlertConfigs] = useState(false); const [loadingAlertConfigs, setLoadingAlertConfigs] = useState(false);
const [loadingTemplates, setLoadingTemplates] = useState(false); const [loadingTemplates, setLoadingTemplates] = useState(false);
@@ -78,7 +80,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
// Initialize form data when server changes // Initialize form data when server changes
useEffect(() => { useEffect(() => {
if (server) { if (server) {
// console.log("Setting form data for server:", server);
// Parse comma-separated notification_id into array // Parse comma-separated notification_id into array
const notificationChannels = server.notification_id const notificationChannels = server.notification_id
? server.notification_id.split(',').map(id => id.trim()).filter(id => id) ? server.notification_id.split(',').map(id => id.trim()).filter(id => id)
@@ -87,9 +88,9 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
setFormData({ setFormData({
name: server.name || "", name: server.name || "",
check_interval: server.check_interval || 60, check_interval: server.check_interval || 60,
retry_attempts: 3, max_retries: server.max_retries || 3,
docker_monitoring: server.docker === "true", docker_monitoring: server.docker === "true",
notification_enabled: notificationChannels.length > 0, notification_enabled: server.notification_status === true,
notification_channels: notificationChannels, notification_channels: notificationChannels,
threshold_id: server.threshold_id || "none", threshold_id: server.threshold_id || "none",
template_id: server.template_id || "none", template_id: server.template_id || "none",
@@ -109,15 +110,13 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
// Load existing threshold data when thresholds are loaded and we have a server with threshold_id // Load existing threshold data when thresholds are loaded and we have a server with threshold_id
useEffect(() => { useEffect(() => {
if (server && server.threshold_id && thresholds.length > 0) { 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); const existingThreshold = thresholds.find(t => t.id === server.threshold_id);
if (existingThreshold) { if (existingThreshold) {
// console.log("Found existing threshold:", existingThreshold);
setSelectedThreshold(existingThreshold); setSelectedThreshold(existingThreshold);
// Handle the API response format with proper field names and type conversion // Handle the API response format with proper field names and type conversion
setThresholdFormData({ setThresholdFormData({
cpu_threshold: parseInt(String(existingThreshold.cpu_threshold)) || 80, cpu_threshold: parseInt(String(existingThreshold.cpu_threshold)) || 80,
ram_threshold: parseInt(String((existingThreshold as any).ram_threshold_message || existingThreshold.ram_threshold)) || 80, ram_threshold: parseInt(String((existingThreshold.ram_threshold_message ?? existingThreshold.ram_threshold))) || 80,
disk_threshold: parseInt(String(existingThreshold.disk_threshold)) || 80, disk_threshold: parseInt(String(existingThreshold.disk_threshold)) || 80,
network_threshold: parseInt(String(existingThreshold.network_threshold)) || 80, network_threshold: parseInt(String(existingThreshold.network_threshold)) || 80,
}); });
@@ -144,7 +143,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
// Handle the API response format with proper field names and type conversion // Handle the API response format with proper field names and type conversion
setThresholdFormData({ setThresholdFormData({
cpu_threshold: parseInt(String(threshold.cpu_threshold)) || 80, cpu_threshold: parseInt(String(threshold.cpu_threshold)) || 80,
ram_threshold: parseInt(String((threshold as any).ram_threshold_message || threshold.ram_threshold)) || 80, ram_threshold: parseInt(String((threshold.ram_threshold_message ?? threshold.ram_threshold))) || 80,
disk_threshold: parseInt(String(threshold.disk_threshold)) || 80, disk_threshold: parseInt(String(threshold.disk_threshold)) || 80,
network_threshold: parseInt(String(threshold.network_threshold)) || 80, network_threshold: parseInt(String(threshold.network_threshold)) || 80,
}); });
@@ -166,11 +165,10 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
const configs = await alertConfigService.getAlertConfigurations(); const configs = await alertConfigService.getAlertConfigurations();
setAlertConfigs(configs); setAlertConfigs(configs);
} catch (error) { } catch (error) {
// console.error('Error loading alert configurations:', error);
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: t('error', 'instance'),
description: "Failed to load notification channels", description: t('failedToLoadChannels', 'instance'),
}); });
} finally { } finally {
setLoadingAlertConfigs(false); setLoadingAlertConfigs(false);
@@ -180,14 +178,14 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
const loadTemplates = async () => { const loadTemplates = async () => {
try { try {
setLoadingTemplates(true); setLoadingTemplates(true);
const templateList = await templateService.getTemplates(); const templateList = await templateService.getTemplates('server');
setTemplates(templateList); // Cast to ServerNotificationTemplate[] since we know we're getting server templates
setTemplates(templateList as ServerNotificationTemplate[]);
} catch (error) { } catch (error) {
// console.error('Error loading templates:', error);
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: t('error', 'instance'),
description: "Failed to load templates", description: t('failedToLoadTemplates', 'instance'),
}); });
} finally { } finally {
setLoadingTemplates(false); setLoadingTemplates(false);
@@ -200,58 +198,16 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
const thresholdList = await serverThresholdService.getServerThresholds(); const thresholdList = await serverThresholdService.getServerThresholds();
setThresholds(thresholdList); setThresholds(thresholdList);
} catch (error) { } catch (error) {
// console.error('Error loading server thresholds:', error);
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: t('error', 'instance'),
description: "Failed to load server thresholds", description: t('failedToLoadThresholds', 'instance'),
}); });
} finally { } finally {
setLoadingThresholds(false); setLoadingThresholds(false);
} }
}; };
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) => { const handleNotificationChannelToggle = (channelId: string, checked: boolean) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@@ -276,36 +232,69 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
setIsSubmitting(true); setIsSubmitting(true);
// Convert notification channels array to comma-separated string // Convert notification channels array to comma-separated string
const notificationChannelsString = formData.notification_enabled const notificationChannelsString = formData.notification_channels.join(',');
? formData.notification_channels.join(',')
: "";
const updateData = { const updateData = {
name: formData.name, name: formData.name,
check_interval: formData.check_interval, check_interval: formData.check_interval,
max_retries: formData.max_retries,
docker: formData.docker_monitoring ? "true" : "false", docker: formData.docker_monitoring ? "true" : "false",
notification_status: formData.notification_enabled,
notification_id: notificationChannelsString, notification_id: notificationChannelsString,
threshold_id: formData.notification_enabled && formData.threshold_id !== "none" ? formData.threshold_id : "", threshold_id: formData.threshold_id !== "none" ? formData.threshold_id : "",
template_id: formData.notification_enabled && formData.template_id !== "none" ? formData.template_id : "", template_id: formData.template_id !== "none" ? formData.template_id : "",
updated: new Date().toISOString(), updated: new Date().toISOString(),
}; };
await pb.collection('servers').update(server.id, updateData); 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: t('warning', 'instance'),
description: t('serverUpdatedButThresholdFailed', 'instance'),
});
}
}
toast({ toast({
title: "Server updated", title: t('serverUpdated', 'instance'),
description: `${formData.name} has been updated successfully.`, description: t('serverUpdatedDesc', 'instance', { name: formData.name }),
}); });
onServerUpdated(); onServerUpdated();
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
// console.error('Error updating server:', error);
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: t('error', 'instance'),
description: "Failed to update server. Please try again.", description: t('failedToUpdateServer', 'instance'),
}); });
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -321,9 +310,9 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
setFormData({ setFormData({
name: server.name || "", name: server.name || "",
check_interval: server.check_interval || 60, check_interval: server.check_interval || 60,
retry_attempts: 3, max_retries: server.max_retries || 3,
docker_monitoring: server.docker === "true", docker_monitoring: server.docker === "true",
notification_enabled: notificationChannels.length > 0, notification_enabled: server.notification_status === true,
notification_channels: notificationChannels, notification_channels: notificationChannels,
threshold_id: server.threshold_id || "none", threshold_id: server.threshold_id || "none",
template_id: server.template_id || "none", template_id: server.template_id || "none",
@@ -336,62 +325,62 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto"> <DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Server Configuration</DialogTitle> <DialogTitle>{t('editServerConfiguration', 'instance')}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="serverName">Server Name *</Label> <Label htmlFor="serverName">{t('serverNameLabel', 'instance')}</Label>
<Input <Input
id="serverName" id="serverName"
value={formData.name} value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Enter server name" placeholder={t('serverNamePlaceholder', 'instance')}
required required
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="checkInterval">Check Interval</Label> <Label htmlFor="checkInterval">{t('checkIntervalLabel', 'instance')}</Label>
<Select <Select
value={formData.check_interval.toString()} value={formData.check_interval.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, check_interval: parseInt(value) }))} onValueChange={(value) => setFormData(prev => ({ ...prev, check_interval: parseInt(value) }))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select interval" /> <SelectValue placeholder={t('selectInterval', 'instance')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="30">30 seconds</SelectItem> <SelectItem value="30">{t('interval30s', 'instance')}</SelectItem>
<SelectItem value="60">1 minute</SelectItem> <SelectItem value="60">{t('interval1m', 'instance')}</SelectItem>
<SelectItem value="120">2 minutes</SelectItem> <SelectItem value="120">{t('interval2m', 'instance')}</SelectItem>
<SelectItem value="300">5 minutes</SelectItem> <SelectItem value="300">{t('interval5m', 'instance')}</SelectItem>
<SelectItem value="600">10 minutes</SelectItem> <SelectItem value="600">{t('interval10m', 'instance')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="retryAttempts">Retry Attempts</Label> <Label htmlFor="maxRetries">{t('maxRetriesLabel', 'instance')}</Label>
<Select <Select
value={formData.retry_attempts.toString()} value={formData.max_retries.toString()}
onValueChange={(value) => setFormData(prev => ({ ...prev, retry_attempts: parseInt(value) }))} onValueChange={(value) => setFormData(prev => ({ ...prev, max_retries: parseInt(value) }))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select retry attempts" /> <SelectValue placeholder={t('selectMaxRetries', 'instance')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">1 attempt</SelectItem> <SelectItem value="1">{t('retry1', 'instance')}</SelectItem>
<SelectItem value="2">2 attempts</SelectItem> <SelectItem value="2">{t('retry2', 'instance')}</SelectItem>
<SelectItem value="3">3 attempts</SelectItem> <SelectItem value="3">{t('retry3', 'instance')}</SelectItem>
<SelectItem value="5">5 attempts</SelectItem> <SelectItem value="5">{t('retry5', 'instance')}</SelectItem>
<SelectItem value="10">10 attempts</SelectItem> <SelectItem value="10">{t('retry10', 'instance')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="dockerMonitoring">Docker Monitoring</Label> <Label htmlFor="dockerMonitoring">{t('dockerMonitoring', 'instance')}</Label>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
id="dockerMonitoring" id="dockerMonitoring"
@@ -402,7 +391,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
}))} }))}
/> />
<Label htmlFor="dockerMonitoring" className="text-sm text-muted-foreground"> <Label htmlFor="dockerMonitoring" className="text-sm text-muted-foreground">
{formData.docker_monitoring ? "Enabled" : "Disabled"} {formData.docker_monitoring ? t('enabled', 'instance') : t('disabled', 'instance')}
</Label> </Label>
</div> </div>
</div> </div>
@@ -416,28 +405,26 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
checked={formData.notification_enabled} checked={formData.notification_enabled}
onCheckedChange={(checked) => setFormData(prev => ({ onCheckedChange={(checked) => setFormData(prev => ({
...prev, ...prev,
notification_enabled: checked, notification_enabled: checked
notification_channels: checked ? prev.notification_channels : [], // Remove the automatic clearing of notification_channels, threshold_id, and template_id
threshold_id: checked ? prev.threshold_id : "none",
template_id: checked ? prev.template_id : "none"
}))} }))}
/> />
<Label htmlFor="notificationEnabled">Enable Notifications</Label> <Label htmlFor="notificationEnabled">{t('enableNotifications', 'instance')}</Label>
</div> </div>
{/* Expanded Notification Settings */} {/* Expanded Notification Settings */}
{formData.notification_enabled && ( {formData.notification_enabled && (
<Card className="border-l-4 border-l-blue-500"> <Card className="border-l-4 border-l-blue-500">
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Notification Settings</CardTitle> <CardTitle className="text-lg">{t('notificationSettings', 'instance')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Multiple Notification Channels Selection */} {/* Multiple Notification Channels Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Notification Channels</Label> <Label>{t('notificationChannels', 'instance')}</Label>
<div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3"> <div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3">
{loadingAlertConfigs ? ( {loadingAlertConfigs ? (
<div className="text-sm text-muted-foreground">Loading channels...</div> <div className="text-sm text-muted-foreground">{t('loadingChannels', 'instance')}</div>
) : alertConfigs.length > 0 ? ( ) : alertConfigs.length > 0 ? (
alertConfigs.map((config) => ( alertConfigs.map((config) => (
<div key={config.id} className="flex items-center space-x-2"> <div key={config.id} className="flex items-center space-x-2">
@@ -457,14 +444,14 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
</div> </div>
)) ))
) : ( ) : (
<div className="text-sm text-muted-foreground">No notification channels available</div> <div className="text-sm text-muted-foreground">{t('noChannelsAvailable', 'instance')}</div>
)} )}
</div> </div>
{/* Selected Channels Display */} {/* Selected Channels Display */}
{formData.notification_channels.length > 0 && ( {formData.notification_channels.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium">Selected Channels:</Label> <Label className="text-sm font-medium">{t('selectedChannels', 'instance')}</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{formData.notification_channels.map((channelId) => { {formData.notification_channels.map((channelId) => {
const channel = alertConfigs.find(c => c.id === channelId); const channel = alertConfigs.find(c => c.id === channelId);
@@ -493,17 +480,17 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
{/* Server Set Threshold Selection */} {/* Server Set Threshold Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="thresholdId">Server Set Threshold</Label> <Label htmlFor="thresholdId">{t('serverSetThreshold', 'instance')}</Label>
<Select <Select
value={formData.threshold_id} value={formData.threshold_id}
onValueChange={(value) => setFormData(prev => ({ ...prev, threshold_id: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, threshold_id: value }))}
disabled={loadingThresholds} disabled={loadingThresholds}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={loadingThresholds ? "Loading thresholds..." : "Select server threshold"} /> <SelectValue placeholder={loadingThresholds ? t('loadingThresholds', 'instance') : t('selectServerThreshold', 'instance')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No threshold (use default)</SelectItem> <SelectItem value="none">{t('noThreshold', 'instance')}</SelectItem>
{thresholds.map((threshold) => ( {thresholds.map((threshold) => (
<SelectItem key={threshold.id} value={threshold.id}> <SelectItem key={threshold.id} value={threshold.id}>
{threshold.name} {threshold.name}
@@ -516,21 +503,13 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
{/* Editable Threshold Details */} {/* Editable Threshold Details */}
{selectedThreshold && ( {selectedThreshold && (
<Card className="bg-muted/50"> <Card className="bg-muted/50">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader>
<CardTitle className="text-base">Threshold Details: {selectedThreshold.name}</CardTitle> <CardTitle className="text-base">{t('thresholdDetails', 'instance')}: {selectedThreshold.name}</CardTitle>
<Button
type="button"
onClick={handleThresholdUpdate}
size="sm"
variant="outline"
>
Update Thresholds
</Button>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">CPU Threshold (%)</Label> <Label className="text-xs font-medium text-muted-foreground">{t('cpuThresholdPct', 'instance')}</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
@@ -544,7 +523,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
/> />
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">RAM Threshold (%)</Label> <Label className="text-xs font-medium text-muted-foreground">{t('ramThresholdPct', 'instance')}</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
@@ -558,7 +537,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
/> />
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Disk Threshold (%)</Label> <Label className="text-xs font-medium text-muted-foreground">{t('diskThresholdPct', 'instance')}</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
@@ -572,7 +551,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
/> />
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Network Threshold (%)</Label> <Label className="text-xs font-medium text-muted-foreground">{t('networkThresholdPct', 'instance')}</Label>
<Input <Input
type="number" type="number"
min="0" min="0"
@@ -592,17 +571,17 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
{/* Server Template Selection */} {/* Server Template Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="templateId">Server Template</Label> <Label htmlFor="templateId">{t('serverTemplate', 'instance')}</Label>
<Select <Select
value={formData.template_id} value={formData.template_id}
onValueChange={(value) => setFormData(prev => ({ ...prev, template_id: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, template_id: value }))}
disabled={loadingTemplates} disabled={loadingTemplates}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={loadingTemplates ? "Loading templates..." : "Select server template"} /> <SelectValue placeholder={loadingTemplates ? t('loadingTemplates', 'instance') : t('selectServerTemplate', 'instance')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No template (use default)</SelectItem> <SelectItem value="none">{t('noTemplate', 'instance')}</SelectItem>
{templates.map((template) => ( {templates.map((template) => (
<SelectItem key={template.id} value={template.id}> <SelectItem key={template.id} value={template.id}>
{template.name} {template.name}
@@ -616,32 +595,44 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
{selectedTemplate && ( {selectedTemplate && (
<Card className="bg-muted/50"> <Card className="bg-muted/50">
<CardHeader> <CardHeader>
<CardTitle className="text-base">Template Details: {selectedTemplate.name}</CardTitle> <CardTitle className="text-base">{t('templateDetails', 'instance')}: {selectedTemplate.name}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="grid grid-cols-1 gap-3 text-sm"> <div className="grid grid-cols-1 gap-3 text-sm">
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">RAM Threshold Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('ramMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.up_message || "No RAM threshold message defined"} {selectedTemplate.ram_message || t('noMessageDefined', 'instance', { name: 'RAM' })}
</p> </p>
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">CPU Threshold Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('cpuMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.down_message || "No CPU threshold message defined"} {selectedTemplate.cpu_message || t('noMessageDefined', 'instance', { name: 'CPU' })}
</p> </p>
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Disk Threshold Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('diskMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.incident_message || "No disk threshold message defined"} {selectedTemplate.disk_message || t('noMessageDefined', 'instance', { name: 'disk' })}
</p> </p>
</div> </div>
<div> <div>
<Label className="text-xs font-medium text-muted-foreground">Network Threshold Message</Label> <Label className="text-xs font-medium text-muted-foreground">{t('networkMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border"> <p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.maintenance_message || "No network threshold message defined"} {selectedTemplate.network_message || t('noMessageDefined', 'instance', { name: 'network' })}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">{t('upMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.up_message || t('noMessageDefined', 'instance', { name: 'up' })}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">{t('downMessage', 'instance')}</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.down_message || t('noMessageDefined', 'instance', { name: 'down' })}
</p> </p>
</div> </div>
</div> </div>
@@ -660,16 +651,16 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
onClick={handleCancel} onClick={handleCancel}
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t('cancel', 'instance')}
</Button> </Button>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting ? ( {isSubmitting ? (
<> <>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" /> <RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Updating... {t('updating', 'instance')}
</> </>
) : ( ) : (
"Update Server" t('updateServer', 'instance')
)} )}
</Button> </Button>
</div> </div>
@@ -677,4 +668,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };
export default EditServerDialog;
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { Copy, Terminal } from "lucide-react"; import { Copy, Terminal } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils"; import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
interface ManualInstallTabProps { interface ManualInstallTabProps {
serverToken: string; serverToken: string;
@@ -24,20 +25,22 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
serverId, serverId,
onDialogClose, onDialogClose,
}) => { }) => {
const { t } = useLanguage();
const getManualInstallSteps = () => { 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 [ return [
{ {
title: "Download the installation script", title: t('downloadScript'),
command: `curl -L -o server-agent.sh "${scriptUrl}"` command: `curl -L -o server-agent.sh "${scriptUrl}"`
}, },
{ {
title: "Make the script executable", title: t('makeExecutable'),
command: `chmod +x server-agent.sh` command: `chmod +x server-agent.sh`
}, },
{ {
title: "Run the installation with your configuration", title: t('runInstall'),
command: `SERVER_TOKEN="${serverToken}" POCKETBASE_URL="${currentPocketBaseUrl}" SERVER_NAME="${formData.serverName}" AGENT_ID="${serverId}" sudo bash server-agent.sh` command: `SERVER_TOKEN="${serverToken}" POCKETBASE_URL="${currentPocketBaseUrl}" SERVER_NAME="${formData.serverName}" AGENT_ID="${serverId}" sudo bash server-agent.sh`
} }
]; ];
@@ -48,26 +51,26 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" /> <Terminal className="h-5 w-5" />
Manual Installation Steps {t('manualInstallTitle')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Step-by-step installation process {t('manualInstallDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="font-medium">Server Name:</span> {formData.serverName} <span className="font-medium">{t('serverName')}:</span> {formData.serverName}
</div> </div>
<div> <div>
<span className="font-medium">Agent ID:</span> {serverId} <span className="font-medium">{t('agentId')}:</span> {serverId}
</div> </div>
<div> <div>
<span className="font-medium">OS Type:</span> {formData.osType} <span className="font-medium">{t('osType')}:</span> {formData.osType}
</div> </div>
<div> <div>
<span className="font-medium">Check Interval:</span> {formData.checkInterval}s <span className="font-medium">{t('checkInterval')}:</span> {formData.checkInterval}s
</div> </div>
</div> </div>
</div> </div>
@@ -93,7 +96,7 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
onClick={() => copyToClipboard(step.command)} onClick={() => copyToClipboard(step.command)}
> >
<Copy className="h-4 w-4 mr-1" /> <Copy className="h-4 w-4 mr-1" />
Copy {t('copy')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -101,22 +104,22 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
</div> </div>
<div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md p-4"> <div className="bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 rounded-md p-4">
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">Prerequisites:</h4> <h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">{t('prerequisites')}</h4>
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1 list-disc list-inside mb-3"> <ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1 list-disc list-inside mb-3">
<li>Ensure you have root/sudo access on the target server</li> <li>{t('prereqRoot')}</li>
<li>Make sure curl is installed for downloading files</li> <li>{t('prereqCurl')}</li>
<li>Internet connection required for downloading script</li> <li>{t('prereqInternet')}</li>
</ul> </ul>
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">After Installation:</h4> <h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">{t('afterInstall')}</h4>
<p className="text-sm text-blue-800 dark:text-blue-200"> <p className="text-sm text-blue-800 dark:text-blue-200">
The agent will start automatically and appear in your dashboard within a few minutes. {t('agentWillStart')}
</p> </p>
</div> </div>
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button onClick={onDialogClose}> <Button onClick={onDialogClose}>
Done {t('done')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
import { Copy, Download } from "lucide-react"; import { Copy, Download } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils"; import { copyToClipboard } from "@/utils/copyUtils";
import { useLanguage } from "@/contexts/LanguageContext";
interface OneClickInstallTabProps { interface OneClickInstallTabProps {
serverToken: string; serverToken: string;
@@ -26,8 +27,10 @@ export const OneClickInstallTab: React.FC<OneClickInstallTabProps> = ({
serverId, serverId,
onDialogClose, onDialogClose,
}) => { }) => {
const { t } = useLanguage();
const getOneClickInstallCommand = () => { 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}" return `curl -L -o server-agent.sh "${scriptUrl}"
chmod +x server-agent.sh chmod +x server-agent.sh
@@ -55,15 +58,15 @@ sudo -E bash ./server-agent.sh`;
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400"> <CardTitle className="flex items-center gap-2 text-green-700 dark:text-green-400">
<Download className="h-5 w-5" /> <Download className="h-5 w-5" />
One-Click Install {t('oneClickInstallTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-green-600 dark:text-green-300"> <CardDescription className="text-green-600 dark:text-green-300">
Copy and paste this single command to install the monitoring agent instantly {t('oneClickInstallDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-green-700 dark:text-green-400">Quick Install Command</Label> <Label className="text-green-700 dark:text-green-400">{t('quickInstallCommand')}</Label>
<div className="relative"> <div className="relative">
<pre className="bg-black-50 dark:bg-green-100/950 border border-green-200 dark:border-green-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-green-800 dark:text-green-200"> <pre className="bg-black-50 dark:bg-green-100/950 border border-green-200 dark:border-green-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-green-800 dark:text-green-200">
<code>{getOneClickInstallCommand()}</code> <code>{getOneClickInstallCommand()}</code>
@@ -76,23 +79,23 @@ sudo -E bash ./server-agent.sh`;
onClick={handleCopyCommand} onClick={handleCopyCommand}
> >
<Copy className="h-4 w-4 mr-1" /> <Copy className="h-4 w-4 mr-1" />
Copy {t('copy')}
</Button> </Button>
</div> </div>
</div> </div>
<div className="text-sm text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 p-3 rounded-md"> <div className="text-sm text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 p-3 rounded-md">
<p className="font-medium mb-1">Simply run this command on your server:</p> <p className="font-medium mb-1">{t('runCommandOnServer')}</p>
<ol className="list-decimal list-inside space-y-1 text-xs"> <ol className="list-decimal list-inside space-y-1 text-xs">
<li>SSH into your target server</li> <li>{t('sshIntoServer')}</li>
<li>Paste and run the command above</li> <li>{t('pasteAndRun')}</li>
<li>The agent will be installed and started automatically</li> <li>{t('agentInstalled')}</li>
</ol> </ol>
</div> </div>
<div className="flex justify-end pt-4"> <div className="flex justify-end pt-4">
<Button onClick={onDialogClose}> <Button onClick={onDialogClose}>
Done {t('done')}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
import { copyToClipboard } from "@/utils/copyUtils"; import { copyToClipboard } from "@/utils/copyUtils";
import { OSSelector } from "./OSSelector"; import { OSSelector } from "./OSSelector";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServerAgentConfigFormProps { interface ServerAgentConfigFormProps {
formData: { formData: {
@@ -43,23 +44,25 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
isSubmitting, isSubmitting,
onSubmit, onSubmit,
}) => { }) => {
const { t } = useLanguage();
return ( return (
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="serverName">Server Name *</Label> <Label htmlFor="serverName">{t('serverName')} *</Label>
<Input <Input
id="serverName" id="serverName"
placeholder="e.g., web-server-01" placeholder={t('serverNamePlaceholder')}
value={formData.serverName} value={formData.serverName}
onChange={(e) => setFormData(prev => ({ ...prev, serverName: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, serverName: e.target.value }))}
required required
/> />
<p className="text-xs text-muted-foreground">What is the name or label used as the identifier</p> <p className="text-xs text-muted-foreground">{t('serverNameDesc')}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="serverId">Server Agent ID</Label> <Label htmlFor="serverId">{t('serverAgentId')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="serverId" id="serverId"
@@ -76,13 +79,13 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">Auto-generated unique identifier</p> <p className="text-xs text-muted-foreground">{t('serverAgentIdDesc')}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Operating System *</Label> <Label>{t('operatingSystem')} *</Label>
<OSSelector <OSSelector
value={formData.osType} value={formData.osType}
onValueChange={(value) => setFormData(prev => ({ ...prev, osType: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, osType: value }))}
@@ -91,45 +94,45 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="checkInterval">Check Interval</Label> <Label htmlFor="checkInterval">{t('checkInterval')}</Label>
<Select <Select
value={formData.checkInterval} value={formData.checkInterval}
onValueChange={(value) => setFormData(prev => ({ ...prev, checkInterval: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, checkInterval: value }))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select interval" /> <SelectValue placeholder={t('selectInterval')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="30">30 seconds</SelectItem> <SelectItem value="30">{t('interval30s')}</SelectItem>
<SelectItem value="60">1 minute</SelectItem> <SelectItem value="60">{t('interval1m')}</SelectItem>
<SelectItem value="120">2 minutes</SelectItem> <SelectItem value="120">{t('interval2m')}</SelectItem>
<SelectItem value="300">5 minutes</SelectItem> <SelectItem value="300">{t('interval5m')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground">How often to check the server and metric status</p> <p className="text-xs text-muted-foreground">{t('checkIntervalDesc')}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="retryAttempt">Retry Attempts</Label> <Label htmlFor="retryAttempt">{t('retryAttempts')}</Label>
<Select <Select
value={formData.retryAttempt} value={formData.retryAttempt}
onValueChange={(value) => setFormData(prev => ({ ...prev, retryAttempt: value }))} onValueChange={(value) => setFormData(prev => ({ ...prev, retryAttempt: value }))}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select retry attempts" /> <SelectValue placeholder={t('selectRetryAttempts')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">1 attempt</SelectItem> <SelectItem value="1">{t('attempt1')}</SelectItem>
<SelectItem value="2">2 attempts</SelectItem> <SelectItem value="2">{t('attempt2')}</SelectItem>
<SelectItem value="3">3 attempts</SelectItem> <SelectItem value="3">{t('attempt3')}</SelectItem>
<SelectItem value="5">5 attempts</SelectItem> <SelectItem value="5">{t('attempt5')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground">Number of retry attempts before marking as down</p> <p className="text-xs text-muted-foreground">{t('retryAttemptsDesc')}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Server Token</Label> <Label>{t('serverToken')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input value={serverToken} readOnly className="font-mono text-sm bg-muted" /> <Input value={serverToken} readOnly className="font-mono text-sm bg-muted" />
<Button <Button
@@ -141,11 +144,11 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">Auto-generated authentication token</p> <p className="text-xs text-muted-foreground">{t('serverTokenDesc')}</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>System URL</Label> <Label>{t('systemUrl')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input value={currentPocketBaseUrl} readOnly className="font-mono text-sm bg-muted" /> <Input value={currentPocketBaseUrl} readOnly className="font-mono text-sm bg-muted" />
<Button <Button
@@ -157,14 +160,14 @@ export const ServerAgentConfigForm: React.FC<ServerAgentConfigFormProps> = ({
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
<p className="text-xs text-muted-foreground">Current system API URL</p> <p className="text-xs text-muted-foreground">{t('systemUrlDesc')}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="pt-4"> <div className="pt-4">
<Button type="submit" disabled={isSubmitting} className="w-full"> <Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? "Creating Agent..." : "Create Server Agent"} {isSubmitting ? t('creatingAgent') : t('createServerAgent')}
</Button> </Button>
</div> </div>
</form> </form>
@@ -8,12 +8,14 @@ import { CPUChart } from "./charts/CPUChart";
import { MemoryChart } from "./charts/MemoryChart"; import { MemoryChart } from "./charts/MemoryChart";
import { DiskChart } from "./charts/DiskChart"; import { DiskChart } from "./charts/DiskChart";
import { NetworkChart } from "./charts/NetworkChart"; import { NetworkChart } from "./charts/NetworkChart";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServerHistoryChartsProps { interface ServerHistoryChartsProps {
serverId: string; serverId: string;
} }
export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => { export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
const { t } = useLanguage();
const { const {
timeRange, timeRange,
setTimeRange, setTimeRange,
@@ -33,15 +35,15 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
// Show skeleton loading state for better UX // Show skeleton loading state for better UX
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
<h2 className="text-lg font-medium">Historical Performance</h2> <h2 className="text-lg font-medium">{t("historicalPerformance")}</h2>
<div className="flex items-center gap-2 ml-2"> <div className="flex items-center gap-2 ml-2">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs text-muted-foreground">Loading...</span> <span className="text-xs text-muted-foreground">{t("loading")}</span>
</div> </div>
</div> </div>
<TimeRangeSelector value={timeRange} onChange={setTimeRange} /> <TimeRangeSelector value={timeRange} onChange={setTimeRange} />
@@ -77,7 +79,7 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
<h2 className="text-lg font-medium">Historical Performance</h2> <h2 className="text-lg font-medium">{t("historicalPerformance")}</h2>
{isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />} {isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />}
</div> </div>
<TimeRangeSelector value={timeRange} onChange={setTimeRange} /> <TimeRangeSelector value={timeRange} onChange={setTimeRange} />
@@ -85,9 +87,9 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<Card> <Card>
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<p className="text-muted-foreground">Error loading chart data</p> <p className="text-muted-foreground">{t("errorLoadingChartData")}</p>
<p className="text-xs mt-2 font-mono text-red-500">{error?.message}</p> <p className="text-xs mt-2 font-mono text-red-500">{error?.message}</p>
<p className="text-xs mt-1 text-muted-foreground">Server ID: {serverId} Time Range: {timeRange}</p> <p className="text-xs mt-1 text-muted-foreground">{t("serverIdTimeRange", { serverId, timeRange })}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -101,7 +103,7 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
<h2 className="text-lg font-medium">Historical Performance</h2> <h2 className="text-lg font-medium">{t("historicalPerformance")}</h2>
{isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />} {isFetching && <Loader2 className="h-4 w-4 animate-spin ml-2" />}
</div> </div>
<TimeRangeSelector value={timeRange} onChange={setTimeRange} /> <TimeRangeSelector value={timeRange} onChange={setTimeRange} />
@@ -109,11 +111,13 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<Card> <Card>
<CardContent className="flex items-center justify-center py-12"> <CardContent className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<p className="text-muted-foreground">No historical data available for {timeRange}</p> <p className="text-muted-foreground">{t("noHistoricalData", { timeRange })}</p>
<p className="text-xs mt-2">Raw metrics count: {metrics.length}</p> <p className="text-xs mt-2">{t("rawMetricsCount", { count: metrics.length })}</p>
<p className="text-xs mt-1">Server ID: {serverId} Time Range: {timeRange}</p> <p className="text-xs mt-1">{t("serverIdTimeRange", { serverId, timeRange })}</p>
<p className="text-xs mt-1 text-muted-foreground"> <p className="text-xs mt-1 text-muted-foreground">
{metrics.length > 0 ? 'Data exists but may be outside selected time range' : 'No metrics data found'} {metrics.length > 0
? t("dataExistsOutsideRange")
: t("noMetricsDataFound")}
</p> </p>
</div> </div>
</CardContent> </CardContent>
@@ -129,13 +133,13 @@ export const ServerHistoryCharts = ({ serverId }: ServerHistoryChartsProps) => {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" /> <TrendingUp className="h-5 w-5" />
<h2 className="text-lg font-medium">Historical Performance</h2> <h2 className="text-lg font-medium">{t("historicalPerformance")}</h2>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
({chartData.length} data points {timeRange}) ({chartData.length} data points {timeRange})
{isFetching && ( {isFetching && (
<span className="inline-flex items-center gap-1 ml-2"> <span className="inline-flex items-center gap-1 ml-2">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
<span className="text-blue-500">Updating...</span> <span className="text-blue-500">{t("updating")}</span>
</span> </span>
)} )}
</span> </span>
@@ -3,17 +3,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Server, Activity, AlertTriangle, Power } from "lucide-react"; import { Server, Activity, AlertTriangle, Power } from "lucide-react";
import { ServerStats } from "@/types/server.types"; import { ServerStats } from "@/types/server.types";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServerStatsCardsProps { interface ServerStatsCardsProps {
stats: ServerStats; stats: ServerStats;
} }
export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => { export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => {
const { t } = useLanguage();
const { theme } = useTheme(); const { theme } = useTheme();
const cards = [ const cards = [
{ {
title: "TOTAL SERVERS", title: t('totalServers', 'instance'),
value: stats.total, value: stats.total,
icon: Server, icon: Server,
gradient: theme === 'dark' gradient: theme === 'dark'
@@ -21,7 +23,7 @@ export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #a0522d 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #a0522d 100%)"
}, },
{ {
title: "ONLINE SERVERS", title: t('onlineServers', 'instance'),
value: stats.online, value: stats.online,
icon: Activity, icon: Activity,
gradient: theme === 'dark' gradient: theme === 'dark'
@@ -29,7 +31,7 @@ export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #66bb6a 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #66bb6a 100%)"
}, },
{ {
title: "OFFLINE SERVERS", title: t('offlineServers', 'instance'),
value: stats.offline, value: stats.offline,
icon: Power, icon: Power,
gradient: theme === 'dark' gradient: theme === 'dark'
@@ -37,7 +39,7 @@ export const ServerStatsCards = ({ stats }: ServerStatsCardsProps) => {
: "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ef5350 100%)" : "linear-gradient(135deg, rgba(65, 59, 55, 0.8) 0%, #ef5350 100%)"
}, },
{ {
title: "WARNING SERVERS", title: t('warningServers', 'instance'),
value: stats.warning, value: stats.warning,
icon: AlertTriangle, icon: AlertTriangle,
gradient: theme === 'dark' gradient: theme === 'dark'
@@ -3,6 +3,7 @@ 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -16,6 +17,7 @@ import { serverService } from "@/services/serverService";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServerTableProps { interface ServerTableProps {
servers: Server[]; servers: Server[];
@@ -24,6 +26,7 @@ interface ServerTableProps {
} }
export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => { export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps) => {
const { t } = useLanguage();
const { theme } = useTheme(); const { theme } = useTheme();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -31,6 +34,9 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
const [selectedServer, setSelectedServer] = useState<Server | null>(null); const [selectedServer, setSelectedServer] = useState<Server | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [pausingServers, setPausingServers] = useState<Set<string>>(new Set()); const [pausingServers, setPausingServers] = useState<Set<string>>(new Set());
const [selectedServerIds, setSelectedServerIds] = useState<Set<string>>(new Set());
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
@@ -40,6 +46,25 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
server.ip_address.toLowerCase().includes(searchTerm.toLowerCase()) server.ip_address.toLowerCase().includes(searchTerm.toLowerCase())
); );
const allVisibleSelected = filteredServers.length > 0 && filteredServers.every(s => selectedServerIds.has(s.id));
const someVisibleSelected = filteredServers.some(s => selectedServerIds.has(s.id)) && !allVisibleSelected;
const toggleSelectAllVisible = (checked: boolean) => {
const newSet = new Set(selectedServerIds);
if (checked) {
filteredServers.forEach(s => newSet.add(s.id));
} else {
filteredServers.forEach(s => newSet.delete(s.id));
}
setSelectedServerIds(newSet);
};
const toggleSelectOne = (serverId: string, checked: boolean) => {
const newSet = new Set(selectedServerIds);
if (checked) newSet.add(serverId); else newSet.delete(serverId);
setSelectedServerIds(newSet);
};
const handleViewDetails = (serverId: string) => { const handleViewDetails = (serverId: string) => {
navigate(`/server-detail/${serverId}`); navigate(`/server-detail/${serverId}`);
}; };
@@ -68,8 +93,8 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
await pb.collection('servers').update(serverId, updateData); await pb.collection('servers').update(serverId, updateData);
toast({ toast({
title: isPaused ? "Server resumed" : "Server paused", title: isPaused ? t('serverResumed') : t('serverPaused'),
description: `Monitoring ${isPaused ? 'resumed' : 'paused'} for ${server.name}`, description: isPaused ? t('monitoringResumed', { name: server.name }) : t('monitoringPaused', { name: server.name }),
}); });
// console.log(`${isPaused ? 'Resume' : 'Pause'} server monitoring: ${serverId}`); // console.log(`${isPaused ? 'Resume' : 'Pause'} server monitoring: ${serverId}`);
@@ -81,8 +106,8 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
// console.error('Error updating server status:', error); // console.error('Error updating server status:', error);
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: t('error'),
description: `Failed to ${isPaused ? 'resume' : 'pause'} server monitoring. Please try again.`, description: isPaused ? t('resumeServerError') : t('pauseServerError'),
}); });
} finally { } finally {
setPausingServers(prev => { setPausingServers(prev => {
@@ -136,6 +161,33 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
} }
}; };
const confirmBulkDelete = async () => {
if (selectedServerIds.size === 0 || isBulkDeleting) return;
try {
setIsBulkDeleting(true);
const ids = Array.from(selectedServerIds);
const deletions = ids.map(id => pb.collection('servers').delete(id));
const results = await Promise.allSettled(deletions);
const failed = results.filter(r => r.status === 'rejected').length;
if (failed === 0) {
toast({ title: "Servers deleted", description: `${ids.length} server(s) have been deleted.` });
} else if (failed === ids.length) {
toast({ variant: "destructive", title: "Error", description: "Failed to delete selected servers. Please try again." });
} else {
toast({ variant: "destructive", title: "Partial success", description: `Deleted ${ids.length - failed}, failed ${failed}.` });
}
onRefresh();
setSelectedServerIds(new Set());
setBulkDeleteDialogOpen(false);
} catch (_e) {
toast({ variant: "destructive", title: "Error", description: "Failed to delete selected servers. Please try again." });
} finally {
setIsBulkDeleting(false);
}
};
const CustomProgressBar = ({ const CustomProgressBar = ({
value, value,
label, label,
@@ -204,12 +256,12 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
return ( return (
<Card className="flex-1 flex flex-col"> <Card className="flex-1 flex flex-col">
<CardHeader className="flex-shrink-0"> <CardHeader className="flex-shrink-0">
<CardTitle>Servers</CardTitle> <CardTitle>{t('servers')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex items-center justify-center"> <CardContent className="flex-1 flex items-center justify-center">
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<RefreshCw className="h-6 w-6 animate-spin" /> <RefreshCw className="h-6 w-6 animate-spin" />
<span className="ml-2">Loading servers...</span> <span className="ml-2">{t('loadingServers')}</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -221,17 +273,29 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<Card className="bg-transparent border-0 shadow-none"> <Card className="bg-transparent border-0 shadow-none">
<CardHeader className="pb-4 px-0"> <CardHeader className="pb-4 px-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle className="text-xl font-semibold">Servers</CardTitle> <CardTitle className="text-xl font-semibold">{t('servers')}</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1 sm:w-64"> <div className="relative flex-1 sm:w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search servers..." placeholder={t('searchServersPlaceholder')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8" className="pl-8"
/> />
</div> </div>
{selectedServerIds.size > 0 && (
<div className="hidden sm:block text-sm text-muted-foreground mr-2">
{selectedServerIds.size} selected
</div>
)}
<Button
onClick={() => setBulkDeleteDialogOpen(true)}
variant="destructive"
disabled={selectedServerIds.size === 0}
>
Delete Selected
</Button>
<Button onClick={onRefresh} variant="outline" size="icon"> <Button onClick={onRefresh} variant="outline" size="icon">
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
@@ -241,23 +305,33 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<CardContent className="p-0"> <CardContent className="p-0">
{filteredServers.length === 0 ? ( {filteredServers.length === 0 ? (
<div className="flex items-center justify-center p-8"> <div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">No servers found</p> <p className="text-muted-foreground">{t('noServersFound')}</p>
</div> </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> <Table>
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}> <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'}`}> <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'} w-10`}>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Status</TableHead> <div onClick={(e) => e.stopPropagation()}>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>OS</TableHead> <Checkbox
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>IP Address</TableHead> checked={allVisibleSelected}
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>CPU</TableHead> onCheckedChange={(v) => toggleSelectAllVisible(Boolean(v))}
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Memory</TableHead> aria-label="Select all"
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Disk</TableHead> indeterminate={someVisibleSelected}
<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> </div>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right`}>Actions</TableHead> </TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('name')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('status')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('OS')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('IPAddress')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('CPU')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('memory')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('disk')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('uptime')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('lastChecked')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right`}>{t('actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -268,8 +342,20 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
const isPaused = server.status === "paused"; const isPaused = server.status === "paused";
const isProcessing = pausingServers.has(server.id); const isProcessing = pausingServers.has(server.id);
const isSelected = selectedServerIds.has(server.id);
return ( return (
<TableRow key={server.id} className="hover:bg-muted/50"> <TableRow
key={server.id}
className={`hover:bg-muted/50 cursor-pointer ${isSelected ? 'bg-muted/30' : ''}`}
onClick={() => handleViewDetails(server.id)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={(v) => toggleSelectOne(server.id, Boolean(v))}
aria-label={`Select ${server.name}`}
/>
</TableCell>
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="truncate" title={server.name}> <div className="truncate" title={server.name}>
{server.name} {server.name}
@@ -325,11 +411,11 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
{new Date(server.last_checked).toLocaleString()} {new Date(server.last_checked).toLocaleString()}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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> <span className="sr-only">{t('openMenu')}</span>
{isProcessing ? ( {isProcessing ? (
<RefreshCw className="h-4 w-4 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
) : ( ) : (
@@ -338,44 +424,44 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]"> <DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem onClick={() => handleViewDetails(server.id)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewDetails(server.id); }}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
View Server Detail {t('viewServerDetail')}
</DropdownMenuItem> </DropdownMenuItem>
{server.docker === 'true' && ( {server.docker === 'true' && (
<DropdownMenuItem onClick={() => handleViewContainers(server.id)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleViewContainers(server.id); }}>
<Activity className="mr-2 h-4 w-4" /> <Activity className="mr-2 h-4 w-4" />
Container Monitoring {t('containerMonitoring')}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => handlePauseResume(server)} onClick={(e) => { e.stopPropagation(); handlePauseResume(server); }}
disabled={isProcessing} disabled={isProcessing}
> >
{isPaused ? ( {isPaused ? (
<> <>
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
Resume Monitoring {t('resumeMonitoring')}
</> </>
) : ( ) : (
<> <>
<Pause className="mr-2 h-4 w-4" /> <Pause className="mr-2 h-4 w-4" />
Pause Monitoring {t('pauseMonitoring')}
</> </>
)} )}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleEdit(server)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleEdit(server); }}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
Edit Server {t('editServer')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleDelete(server)} onClick={(e) => { e.stopPropagation(); handleDelete(server); }}
className="text-red-600 focus:text-red-600" className="text-red-600 focus:text-red-600"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Server {t('deleteServer')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -402,29 +488,49 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this server?</AlertDialogTitle> <AlertDialogTitle>{t('deleteServerConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete{' '} {t('deleteServerConfirmDesc').replace('{name}', selectedServer?.name ?? '')}
<span className="font-semibold text-foreground">
{selectedServer?.name}
</span>{' '}
and all of its monitoring data.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}> <AlertDialogCancel disabled={isDeleting}>
Cancel {t('cancel')}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDelete} onClick={confirmDelete}
disabled={isDeleting} disabled={isDeleting}
className="bg-red-600 text-white hover:bg-red-700" className="bg-red-600 text-white hover:bg-red-700"
> >
{isDeleting ? "Deleting..." : "Delete"} {isDeleting ? t('deleting') : t('delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation Dialog */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete selected servers?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete {selectedServerIds.size} server(s) and all of their monitoring data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBulkDeleting}>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmBulkDelete}
disabled={isBulkDeleting}
className="bg-red-600 text-white hover:bg-red-700"
>
{isBulkDeleting ? t('deleting') : 'Delete Selected'}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</> </>
); );
}; };
@@ -8,6 +8,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { ServiceForm } from "./ServiceForm"; import { ServiceForm } from "./ServiceForm";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useQueryClient } from "@tanstack/react-query";
import { useLanguage } from "@/contexts/LanguageContext";
interface AddServiceDialogProps { interface AddServiceDialogProps {
open: boolean; open: boolean;
@@ -15,7 +17,12 @@ interface AddServiceDialogProps {
} }
export function AddServiceDialog({ open, onOpenChange }: 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); onOpenChange(false);
}; };
@@ -27,9 +34,9 @@ export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps)
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col"> <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl">Create New Service</DialogTitle> <DialogTitle className="text-xl">{t("createNewService")}</DialogTitle>
<DialogDescription> <DialogDescription>
Fill in the details to create a new service to monitor. {t("createNewServiceDesc")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}> <ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
@@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import { Clock, TimerOff } from "lucide-react"; import { Clock, TimerOff } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useLanguage } from "@/contexts/LanguageContext";
interface LastCheckedTimeProps { interface LastCheckedTimeProps {
lastCheckedTime: string; lastCheckedTime: string;
@@ -10,6 +11,7 @@ interface LastCheckedTimeProps {
} }
export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => { export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => {
const { t } = useLanguage();
// Format the time without seconds to display a static time // Format the time without seconds to display a static time
const formatTimeWithoutSeconds = (timeString: string) => { const formatTimeWithoutSeconds = (timeString: string) => {
try { try {
@@ -55,7 +57,7 @@ export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheck
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
)} )}
<span> <span>
{isPaused ? "Paused at " : ""} {isPaused ? t("pausedAt") : ""}
{formattedTime} {formattedTime}
</span> </span>
</div> </div>
@@ -66,14 +68,14 @@ export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheck
> >
<div className="flex flex-col gap-1 text-xs"> <div className="flex flex-col gap-1 text-xs">
<div className="font-medium"> <div className="font-medium">
{isPaused ? "Monitoring Paused" : "Last Check Details"} {isPaused ? t("monitoringPaused") : t("lastCheckDetails")}
</div> </div>
<div> <div>
{isPaused ? "No automatic checks" : `Checked at ${formattedTime}`} {isPaused ? t("noAutomaticChecks") : t("checkedAt") + `${formattedTime}`}
</div> </div>
{interval && !isPaused && ( {interval && !isPaused && (
<div> <div>
Check interval: {formatInterval(interval)} {t("checkInterval")}: {formatInterval(interval)}
</div> </div>
)} )}
<div className="text-gray-400 text-[10px]"> <div className="text-gray-400 text-[10px]">
@@ -1,5 +1,7 @@
import {useLanguage} from "@/contexts/LanguageContext.tsx";
export function LoadingState() { export function LoadingState() {
const { t } = useLanguage();
return ( return (
<div className="flex items-center justify-center h-screen bg-background text-foreground"> <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"> <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="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 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> </div>
<h3 className="text-xl font-medium mb-1">Loading server data</h3> <h3 className="text-xl font-medium mb-1">{t("loadingServerData")}</h3>
<p className="text-muted-foreground">Please wait while we retrieve your information...</p> <p className="text-muted-foreground">{t("retrievingYourInformation")}</p>
</div> </div>
</div> </div>
); );
@@ -1,4 +1,3 @@
import React from "react"; import React from "react";
import { Sidebar } from "@/components/dashboard/Sidebar"; import { Sidebar } from "@/components/dashboard/Sidebar";
import { Header } from "@/components/dashboard/Header"; import { Header } from "@/components/dashboard/Header";
@@ -7,11 +6,9 @@ import { ServiceNotFound } from "@/components/services/ServiceNotFound";
import { Service } from "@/types/service.types"; import { Service } from "@/types/service.types";
interface ServiceDetailWrapperProps { interface ServiceDetailWrapperProps {
children: React.ReactNode; children?: React.ReactNode;
isLoading: boolean; isLoading: boolean;
service: Service | null; service: Service | null;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
currentUser: any; currentUser: any;
handleLogout: () => void; handleLogout: () => void;
} }
@@ -20,20 +17,16 @@ export const ServiceDetailWrapper = ({
children, children,
isLoading, isLoading,
service, service,
sidebarCollapsed,
toggleSidebar,
currentUser, currentUser,
handleLogout handleLogout
}: ServiceDetailWrapperProps) => { }: ServiceDetailWrapperProps) => {
return ( return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground"> <div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} /> <Sidebar />
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<Header <Header
currentUser={currentUser} currentUser={currentUser}
onLogout={handleLogout} onLogout={handleLogout}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/> />
{isLoading ? ( {isLoading ? (
@@ -48,4 +41,4 @@ export const ServiceDetailWrapper = ({
</div> </div>
</div> </div>
); );
}; };
@@ -125,7 +125,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e
} else { } else {
// Fetch regional agent specific data // Fetch regional agent specific data
const [regionName, agentId] = currentAgent.split("|"); 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); history = await uptimeService.getUptimeHistoryByRegionalAgent(serviceId, limit, start, end, service.type, regionName, agentId);
// console.log(`Retrieved ${history.length} regional monitoring records`); // console.log(`Retrieved ${history.length} regional monitoring records`);
} }
@@ -1,78 +1,34 @@
import { useState, useEffect, useCallback } from "react"; import React, { useState, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { DateRangeOption } from "../DateRangeFilter"; import { DateRangeOption } from "../DateRangeFilter";
import { authService } from "@/services/authService"; import { authService } from "@/services/authService";
import { ServiceDetailContent } from "../ServiceDetailContent"; import { ServiceDetailContent } from "../ServiceDetailContent";
import { ServiceDetailWrapper } from "./ServiceDetailWrapper"; import { ServiceDetailWrapper } from "./ServiceDetailWrapper";
import { useServiceData, useRealTimeUpdates } from "./hooks"; import { useServiceData, useRealTimeUpdates } from "./hooks";
import { toast } from "@/components/ui/use-toast";
export const ServiceDetailContainer = () => { export const ServiceDetailContainer = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
// Set default to 24h
const [startDate, setStartDate] = useState<Date>(() => { const [startDate, setStartDate] = useState<Date>(() => {
const date = new Date(); const date = new Date();
date.setHours(date.getHours() - 24); // Go back 24 hours date.setHours(date.getHours() - 24);
return date; return date;
}); });
const [endDate, setEndDate] = useState<Date>(() => { const [endDate, setEndDate] = useState<Date>(() => {
const date = new Date(); const date = new Date();
date.setMinutes(date.getMinutes() + 5); // Add 5 minutes buffer to future date.setMinutes(date.getMinutes() + 5);
return date; return date;
}); });
const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h'); const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h');
// State for sidebar collapse functionality (shared with Dashboard)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
// Check if there's a saved preference in localStorage
const saved = localStorage.getItem("sidebarCollapsed");
return saved ? JSON.parse(saved) : window.innerWidth < 768;
});
// Toggle sidebar and save preference
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(prev => {
const newState = !prev;
localStorage.setItem("sidebarCollapsed", JSON.stringify(newState));
return newState;
});
}, []);
// Get current user for header
const currentUser = authService.getCurrentUser(); const currentUser = authService.getCurrentUser();
useEffect(() => {
// Verify user is authenticated
if (!authService.isAuthenticated()) {
toast({
variant: "destructive",
title: "Authentication required",
description: "Please log in to view service details",
});
navigate("/login");
}
// Auto-collapse sidebar on small screens
const handleResize = () => {
if (window.innerWidth < 768 && !sidebarCollapsed) {
setSidebarCollapsed(true);
localStorage.setItem("sidebarCollapsed", JSON.stringify(true));
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [navigate, sidebarCollapsed]);
// Handler for logout (same as Dashboard)
const handleLogout = () => { const handleLogout = () => {
authService.logout(); authService.logout();
navigate("/login"); navigate("/login");
}; };
// Use our custom hooks
const { const {
service, service,
uptimeData, uptimeData,
@@ -85,7 +41,6 @@ export const ServiceDetailContainer = () => {
handleRegionalAgentChange handleRegionalAgentChange
} = useServiceData(id, startDate, endDate); } = useServiceData(id, startDate, endDate);
// Set up real-time updates
useRealTimeUpdates({ useRealTimeUpdates({
serviceId: id, serviceId: id,
startDate, startDate,
@@ -94,18 +49,12 @@ export const ServiceDetailContainer = () => {
setUptimeData setUptimeData
}); });
// Handle date range filter changes
const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => { const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => {
console.log(`ServiceDetailContainer: Date range changed: ${start.toISOString()} to ${end.toISOString()}, option: ${option}`);
// Update state which will trigger the useEffect in useServiceData
setStartDate(start); setStartDate(start);
setEndDate(end); setEndDate(end);
setSelectedRange(option); setSelectedRange(option);
// Also explicitly fetch data with the new range to ensure immediate update
if (id) { if (id) {
console.log(`ServiceDetailContainer: Explicitly fetching data for service ${id} with new range`);
fetchUptimeData(id, start, end, option, selectedRegionalAgent); fetchUptimeData(id, start, end, option, selectedRegionalAgent);
} }
}, [id, fetchUptimeData, selectedRegionalAgent]); }, [id, fetchUptimeData, selectedRegionalAgent]);
@@ -114,12 +63,10 @@ export const ServiceDetailContainer = () => {
<ServiceDetailWrapper <ServiceDetailWrapper
isLoading={isLoading} isLoading={isLoading}
service={service} service={service}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
currentUser={currentUser} currentUser={currentUser}
handleLogout={handleLogout} handleLogout={handleLogout}
> >
{service && ( {service ? (
<ServiceDetailContent <ServiceDetailContent
service={service} service={service}
uptimeData={uptimeData} uptimeData={uptimeData}
@@ -129,7 +76,7 @@ export const ServiceDetailContainer = () => {
selectedRegionalAgent={selectedRegionalAgent} selectedRegionalAgent={selectedRegionalAgent}
onRegionalAgentChange={handleRegionalAgentChange} onRegionalAgentChange={handleRegionalAgentChange}
/> />
)} ) : null}
</ServiceDetailWrapper> </ServiceDetailWrapper>
); );
}; };
@@ -11,6 +11,7 @@ import { Service } from "@/types/service.types";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServiceEditDialogProps { interface ServiceEditDialogProps {
open: boolean; open: boolean;
@@ -19,6 +20,7 @@ interface ServiceEditDialogProps {
} }
export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) { export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) {
const { t } = useLanguage();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false); 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"> <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl">Edit Service</DialogTitle> <DialogTitle className="text-xl">{t("editService")}</DialogTitle>
<DialogDescription> <DialogDescription>
Update the details of your monitored service. {t("editServiceDesc")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{open && service && ( {open && service && (
@@ -5,7 +5,6 @@ import { Play, Pause } from "lucide-react";
import { Service } from "@/types/service.types"; import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService"; import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { notificationService } from "@/services/notificationService";
interface ServiceMonitoringButtonProps { interface ServiceMonitoringButtonProps {
service: Service; service: Service;
@@ -34,16 +33,8 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
if (onStatusChange) onStatusChange("paused"); if (onStatusChange) onStatusChange("paused");
// Send notification for paused status (only here, not in pauseMonitoring.ts) // Notification handling removed - will be handled by backend
if (service.alerts !== "muted") { // console.log("Service paused - notifications will be handled by backend");
// 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(),
});
}
toast({ toast({
title: "Monitoring paused", title: "Monitoring paused",
@@ -51,7 +42,7 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
}); });
} else { } else {
// Start/resume monitoring // 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 // First ensure we update the status in the database to not be paused anymore
await serviceService.resumeMonitoring(service.id); await serviceService.resumeMonitoring(service.id);
@@ -100,4 +91,4 @@ export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMoni
)} )}
</Button> </Button>
); );
} }
@@ -4,6 +4,7 @@ import { Service, UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useMemo } from "react"; import { useMemo } from "react";
import { formatDistanceStrict } from "date-fns"; import { formatDistanceStrict } from "date-fns";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceStatsCardsProps { interface ServiceStatsCardsProps {
service: Service; service: Service;
@@ -11,6 +12,8 @@ interface ServiceStatsCardsProps {
} }
export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) { export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) {
const { t } = useLanguage();
// Calculate uptime percentage // Calculate uptime percentage
const calculateUptimePercentage = () => { const calculateUptimePercentage = () => {
if (uptimeData.length === 0) return 100; 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"> <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"> <Card className="border-blue-400 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/50 shadow-md">
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-blue-700 dark:text-white"> <div className="text-2xl font-bold text-blue-700 dark:text-white">
{service.responseTime}ms {service.responseTime}ms
</div> </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 && ( {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> </CardContent>
</Card> </Card>
<Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md"> <Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md">
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-green-700 dark:text-white"> <div className="text-2xl font-bold text-green-700 dark:text-white">
{uptimePercentage}% {uptimePercentage}%
</div> </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 flex-col space-y-1 mt-2">
<div className="flex items-center text-xs text-green-600 dark:text-green-200"> <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" /> <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>
<div className="flex items-center text-xs text-red-600 dark:text-red-200"> <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" /> <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> </div>
{service.status !== "paused" && ( {service.status !== "paused" && (
<div className="flex items-center text-xs font-medium mt-1"> <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"}> <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> </span>
</div> </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"> <Card className="border-purple-400 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/50 shadow-md">
<CardHeader className="pb-2"> <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> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200"> <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" /> <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>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200 mt-1"> <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" /> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -63,9 +63,9 @@ export function ServicesPagination({
return ( return (
<div className="flex items-center justify-between py-4 px-4 border-t border-border"> <div className="flex items-center justify-between py-4 px-4 border-t border-border">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground whitespace-nowrap">
{t("rowsPerPage") ?? "Rows per page"}: {t("rowsPerPage") ?? "Rows per page"}:
</span> </span>
<Select <Select
value={pageSize.toString()} value={pageSize.toString()}
onValueChange={(value) => onPageSizeChange(parseInt(value) as PageSize)} onValueChange={(value) => onPageSizeChange(parseInt(value) as PageSize)}
@@ -80,9 +80,9 @@ export function ServicesPagination({
</SelectContent> </SelectContent>
</Select> </Select>
<span className="text-sm text-muted-foreground whitespace-nowrap"> <span className="text-sm text-muted-foreground whitespace-nowrap">
{totalItems > 0 {totalItems > 0
? `${startItem}-${endItem} of ${totalItems} ${t("services") || "services"}` ? t("servicesPagination", {"startItem": startItem, "endItem": endItem, "totalItems": totalItems})
: `0 ${t("services") || "services"}` : t("servicesPaginationNoService")
} }
</span> </span>
</div> </div>
@@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
import { UptimeStatusItem } from './uptime/UptimeStatusItem'; import { UptimeStatusItem } from './uptime/UptimeStatusItem';
import { uptimeService } from '@/services/uptimeService'; import { uptimeService } from '@/services/uptimeService';
import { UptimeData } from '@/types/service.types'; import { UptimeData } from '@/types/service.types';
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface UptimeBarProps { interface UptimeBarProps {
uptime: number; uptime: number;
@@ -15,6 +16,7 @@ interface UptimeBarProps {
} }
const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType = "HTTP" }: 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 // Calculate date range for last 20 checks with much more aggressive caching
const endDate = useMemo(() => new Date(), []); const endDate = useMemo(() => new Date(), []);
const startDate = useMemo(() => { const startDate = useMemo(() => {
@@ -23,19 +25,21 @@ const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType =
return start; return start;
}, [endDate, interval]); }, [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({ const { data: uptimeData = [] } = useQuery({
queryKey: ['uptime-bar', serviceId, serviceType], queryKey: ['uptime-bar', serviceId, serviceType],
queryFn: () => uptimeService.getUptimeHistory(serviceId, 20, startDate, endDate, serviceType), queryFn: () => uptimeService.getUptimeHistory(serviceId, 20, startDate, endDate, serviceType),
enabled: !!serviceId, enabled: !!serviceId,
staleTime: 30000, // Data is fresh for 30 seconds staleTime: 300000, // Data stays fresh for 5 minutes - display immediately from cache
gcTime: 600000, // Keep in cache for 10 minutes gcTime: 900000, // Keep in cache for 15 minutes
refetchInterval: 60000, // 1 minute polling refetchInterval: 300000, // Reduced to 5 minute polling
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnMount: false, refetchOnMount: false,
refetchOnReconnect: false, refetchOnReconnect: false,
retry: 3, retry: 1, // Reduce retries for faster failure
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), 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 // Memoize the uptime calculation to prevent unnecessary recalculations
@@ -84,7 +88,7 @@ const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType =
</div> </div>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Last 20 checks {t('last20Checks')}
</span> </span>
</TooltipProvider> </TooltipProvider>
); );
@@ -3,22 +3,24 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/comp
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types"; import { ServiceFormData } from "./types";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceBasicFieldsProps { interface ServiceBasicFieldsProps {
form: UseFormReturn<ServiceFormData>; form: UseFormReturn<ServiceFormData>;
} }
export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) { export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) {
const { t } = useLanguage();
return ( return (
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Service Name</FormLabel> <FormLabel>{t('serviceName')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter a descriptive name for your service" placeholder={t('serviceNameDesc')}
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -6,12 +6,14 @@ import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types"; import { ServiceFormData } from "./types";
import { ServiceUrlField } from "./ServiceUrlField"; import { ServiceUrlField } from "./ServiceUrlField";
import { useState } from "react"; import { useState } from "react";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceConfigFieldsProps { interface ServiceConfigFieldsProps {
form: UseFormReturn<ServiceFormData>; form: UseFormReturn<ServiceFormData>;
} }
export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) { export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
const { t } = useLanguage();
const [isCustomInterval, setIsCustomInterval] = useState(false); const [isCustomInterval, setIsCustomInterval] = useState(false);
const intervalValue = form.watch("interval"); const intervalValue = form.watch("interval");
@@ -35,7 +37,7 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
name="interval" name="interval"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Check Interval</FormLabel> <FormLabel>{t("checkInterval")}</FormLabel>
{!isCustomInterval ? ( {!isCustomInterval ? (
<FormControl> <FormControl>
<Select onValueChange={handleIntervalChange} value={isCustomInterval ? "custom" : field.value}> <Select onValueChange={handleIntervalChange} value={isCustomInterval ? "custom" : field.value}>
@@ -43,13 +45,13 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="30">30 seconds</SelectItem> <SelectItem value="30">30 {t("seconds")}</SelectItem>
<SelectItem value="60">1 minute</SelectItem> <SelectItem value="60">1 {t("minute")}</SelectItem>
<SelectItem value="300">5 minutes</SelectItem> <SelectItem value="300">5 {t("minutes")}</SelectItem>
<SelectItem value="900">15 minutes</SelectItem> <SelectItem value="900">15 {t("minutes")}</SelectItem>
<SelectItem value="1800">30 minutes</SelectItem> <SelectItem value="1800">30 {t("minutes")}</SelectItem>
<SelectItem value="3600">1 hour</SelectItem> <SelectItem value="3600">1 {t("hour")}</SelectItem>
<SelectItem value="custom">Custom</SelectItem> <SelectItem value="custom">{t("custom")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
@@ -58,7 +60,7 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
<FormControl> <FormControl>
<Input <Input
type="number" type="number"
placeholder="Enter interval in seconds" placeholder={t("checkIntervalPlaceholder")}
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
min="10" min="10"
@@ -72,14 +74,14 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
}} }}
className="text-sm text-blue-600 hover:text-blue-800" className="text-sm text-blue-600 hover:text-blue-800"
> >
Back to presets {t("backToPresets")}
</button> </button>
</div> </div>
)} )}
<FormDescription className="text-xs"> <FormDescription className="text-xs">
{isCustomInterval {isCustomInterval
? "Enter custom interval in seconds (minimum 10 seconds)" ? t("checkIntervalDescCustom")
: "How often to check the service status" : t("checkIntervalDesc")
} }
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
@@ -92,22 +94,22 @@ export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
name="retries" name="retries"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Retry Attempts</FormLabel> <FormLabel>{t("retryAttempts")}</FormLabel>
<FormControl> <FormControl>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} value={field.value ?? "3"}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">1 attempt</SelectItem> <SelectItem value="1">1 {t("attempt")}</SelectItem>
<SelectItem value="2">2 attempts</SelectItem> <SelectItem value="2">2 {t("attempts")}</SelectItem>
<SelectItem value="3">3 attempts</SelectItem> <SelectItem value="3">3 {t("attempts")}</SelectItem>
<SelectItem value="5">5 attempts</SelectItem> <SelectItem value="5">5 {t("attempts")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
<FormDescription className="text-xs"> <FormDescription className="text-xs">
Number of retry attempts before marking as down {t("retryAttemptsDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -3,7 +3,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { serviceSchema, ServiceFormData } from "./types"; import { useServiceSchema, ServiceFormData } from "./types";
import { ServiceBasicFields } from "./ServiceBasicFields"; import { ServiceBasicFields } from "./ServiceBasicFields";
import { ServiceTypeField } from "./ServiceTypeField"; import { ServiceTypeField } from "./ServiceTypeField";
import { ServiceConfigFields } from "./ServiceConfigFields"; import { ServiceConfigFields } from "./ServiceConfigFields";
@@ -13,6 +13,8 @@ import { serviceService } from "@/services/serviceService";
import { Service } from "@/types/service.types"; import { Service } from "@/types/service.types";
import { ServiceRegionalFields } from "./ServiceRegionalFields"; import { ServiceRegionalFields } from "./ServiceRegionalFields";
import { getServiceFormDefaults, mapServiceToFormData, mapFormDataToServiceData } from "./serviceFormUtils"; import { getServiceFormDefaults, mapServiceToFormData, mapFormDataToServiceData } from "./serviceFormUtils";
import { useQueryClient } from "@tanstack/react-query";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceFormProps { interface ServiceFormProps {
onSuccess: () => void; onSuccess: () => void;
@@ -29,8 +31,12 @@ export function ServiceForm({
isEdit = false, isEdit = false,
onSubmitStart onSubmitStart
}: ServiceFormProps) { }: ServiceFormProps) {
const { t } = useLanguage();
const { toast } = useToast(); const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const queryClient = useQueryClient();
const serviceSchema = useServiceSchema();
// Initialize form with default values // Initialize form with default values
const form = useForm<ServiceFormData>({ const form = useForm<ServiceFormData>({
@@ -68,10 +74,10 @@ export function ServiceForm({
if (onSubmitStart) onSubmitStart(); if (onSubmitStart) onSubmitStart();
try { try {
console.log("Form data being submitted:", data); // console.log("Form data being submitted:", data);
const serviceData = mapFormDataToServiceData(data); const serviceData = mapFormDataToServiceData(data);
console.log("Service data being sent:", serviceData); // console.log("Service data being sent:", serviceData);
if (isEdit && initialData) { if (isEdit && initialData) {
// Update existing service // 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(); onSuccess();
if (!isEdit) { if (!isEdit) {
form.reset(); form.reset();
@@ -112,23 +128,23 @@ export function ServiceForm({
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6">
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-4"> <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} /> <ServiceBasicFields form={form} />
<ServiceTypeField form={form} /> <ServiceTypeField form={form} />
</div> </div>
<div className="space-y-4"> <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} /> <ServiceConfigFields form={form} />
</div> </div>
<div className="space-y-4"> <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} /> <ServiceRegionalFields form={form} />
</div> </div>
<div className="space-y-4"> <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} /> <ServiceNotificationFields form={form} />
</div> </div>
</div> </div>
@@ -136,7 +152,7 @@ export function ServiceForm({
<ServiceFormActions <ServiceFormActions
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
onCancel={onCancel} onCancel={onCancel}
submitLabel={isEdit ? "Update Service" : "Create Service"} submitLabel={isEdit ? t("updateService") : t("createService")}
/> />
</form> </form>
</Form> </Form>
@@ -2,6 +2,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { MouseEvent } from "react"; import { MouseEvent } from "react";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceFormActionsProps { interface ServiceFormActionsProps {
isSubmitting: boolean; isSubmitting: boolean;
@@ -14,6 +15,8 @@ export function ServiceFormActions({
onCancel, onCancel,
submitLabel = "Create Service" submitLabel = "Create Service"
}: ServiceFormActionsProps) { }: ServiceFormActionsProps) {
const {t} = useLanguage();
const handleCancel = (e: MouseEvent<HTMLButtonElement>) => { const handleCancel = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!isSubmitting) { if (!isSubmitting) {
@@ -29,7 +32,7 @@ export function ServiceFormActions({
variant="outline" variant="outline"
disabled={isSubmitting} disabled={isSubmitting}
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -38,7 +41,7 @@ export function ServiceFormActions({
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing... {t("processing")}...
</> </>
) : ( ) : (
submitLabel submitLabel
@@ -1,4 +1,3 @@
import { FormControl, FormField, FormItem, FormLabel, FormDescription } from "@/components/ui/form"; import { FormControl, FormField, FormItem, FormLabel, FormDescription } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -8,31 +7,34 @@ import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types"; import { ServiceFormData } from "./types";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService"; import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { serviceNotificationTemplateService, ServiceNotificationTemplate } from "@/services/serviceNotificationTemplateService";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useLanguage } from "@/contexts/LanguageContext.tsx";
interface ServiceNotificationFieldsProps { interface ServiceNotificationFieldsProps {
form: UseFormReturn<ServiceFormData>; form: UseFormReturn<ServiceFormData>;
} }
export function ServiceNotificationFields({ form }: ServiceNotificationFieldsProps) { export function ServiceNotificationFields({ form }: ServiceNotificationFieldsProps) {
const { t } = useLanguage();
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]); const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
// Get the current form values for debugging // Get the current form values for debugging
const notificationStatus = form.watch("notificationStatus"); const notificationStatus = form.watch("notificationStatus");
const notificationChannels = form.watch("notificationChannels") || []; const notificationChannels = form.watch("notificationChannels") || [];
const alertTemplate = form.watch("alertTemplate"); const alertTemplate = form.watch("alertTemplate");
// console.log("Current notification values:", {
// notificationStatus,
// notificationChannels,
// alertTemplate
// });
// Fetch alert configurations for notification channels // Fetch alert configurations for notification channels
const { data: alertConfigsData } = useQuery({ const { data: alertConfigsData } = useQuery({
queryKey: ['alertConfigs'], queryKey: ['alertConfigs'],
queryFn: () => alertConfigService.getAlertConfigurations(), 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 // Update alert configs when data is loaded
useEffect(() => { useEffect(() => {
@@ -42,13 +44,19 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
setAlertConfigs(enabledChannels); setAlertConfigs(enabledChannels);
// Debug log to check what alert configs are loaded // Debug log to check what alert configs are loaded
// console.log("Loaded alert configurations:", enabledChannels);
} }
}, [alertConfigsData]); }, [alertConfigsData]);
// Debug log for service templates
useEffect(() => {
if (serviceTemplates) {
// console.log("Loaded service notification templates:", serviceTemplates);
}
}, [serviceTemplates]);
// Log when form values change to debug // Log when form values change to debug
useEffect(() => { useEffect(() => {
// console.log("Notification values changed:", { // console.log("Notification values changed:", {
// notificationStatus: form.getValues("notificationStatus"), // notificationStatus: form.getValues("notificationStatus"),
// notificationChannels: form.getValues("notificationChannels") // 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"> <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel className="text-base"> <FormLabel className="text-base">
Enable Notifications {t("enableNotifications")}
</FormLabel> </FormLabel>
<FormDescription> <FormDescription>
Enable or disable notifications for this service {t("enableNotificationsDesc")}
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -109,11 +117,11 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
name="notificationChannels" name="notificationChannels"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Notification Channels</FormLabel> <FormLabel>{t("notificationChannels")}</FormLabel>
<FormDescription> <FormDescription>
{notificationStatus === "enabled" {notificationStatus === "enabled"
? "Select notification channels for this service" ? t("notificationChannelsEnabledDesc")
: "Enable notifications first to select channels"} : t("notificationChannelsDesc")}
</FormDescription> </FormDescription>
{/* Display selected channels as badges */} {/* Display selected channels as badges */}
@@ -138,7 +146,7 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
value="" // Always reset to empty after selection value="" // Always reset to empty after selection
> >
<SelectTrigger className={notificationStatus !== "enabled" ? 'opacity-50' : ''}> <SelectTrigger className={notificationStatus !== "enabled" ? 'opacity-50' : ''}>
<SelectValue placeholder="Add a notification channel" /> <SelectValue placeholder={t("notificationChannelsPlaceholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{alertConfigs {alertConfigs
@@ -159,35 +167,35 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
control={form.control} control={form.control}
name="alertTemplate" name="alertTemplate"
render={({ field }) => { render={({ field }) => {
// Don't convert existing values to "default" // console.log("Rendering alert template field with value:", field.value);
const displayValue = field.value || "default";
// console.log("Rendering alert template field with value:", displayValue);
return ( return (
<FormItem> <FormItem>
<FormLabel>Alert Template</FormLabel> <FormLabel>{t("alertTemplate")}</FormLabel>
<FormControl> <FormControl>
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
console.log("Alert template changed to:", value); field.onChange(value);
field.onChange(value === "default" ? "" : value);
}} }}
value={displayValue} value={field.value || ""}
disabled={notificationStatus !== "enabled"} disabled={notificationStatus !== "enabled" || isLoadingTemplates}
> >
<SelectTrigger className={notificationStatus !== "enabled" ? 'opacity-50' : ''}> <SelectTrigger className={notificationStatus !== "enabled" ? 'opacity-50' : ''}>
<SelectValue placeholder="Select an alert template" /> <SelectValue placeholder={isLoadingTemplates ? t("alertTemplateLoading") : t("alertTemplatePlaceholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="default">Default</SelectItem> {serviceTemplates?.map((template) => (
{/* Add templates here when available */} <SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{notificationStatus === "enabled" {notificationStatus === "enabled"
? "Choose a template for alert messages" ? t("alertTemplateEnabledDesc")
: "Enable notifications first to select template"} : t("alertTemplateDesc")}
</FormDescription> </FormDescription>
</FormItem> </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 { MapPin, Loader2, X, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useLanguage } from "@/contexts/LanguageContext.tsx";
interface ServiceRegionalFieldsProps { interface ServiceRegionalFieldsProps {
form: UseFormReturn<ServiceFormData>; form: UseFormReturn<ServiceFormData>;
} }
export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) { export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
const { t } = useLanguage();
const regionalMonitoringEnabled = form.watch("regionalMonitoringEnabled"); const regionalMonitoringEnabled = form.watch("regionalMonitoringEnabled");
const currentRegionalAgents = form.watch("regionalAgents") || []; const currentRegionalAgents = form.watch("regionalAgents") || [];
@@ -79,10 +81,10 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel className="text-base font-medium flex items-center gap-2"> <FormLabel className="text-base font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" /> <MapPin className="h-4 w-4" />
Regional Monitoring {t("regionalMonitoring")}
</FormLabel> </FormLabel>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Assign this service to regional monitoring agents for distributed monitoring {t("regionalMonitoringDesc")}
</div> </div>
</div> </div>
<FormControl> <FormControl>
@@ -107,7 +109,7 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
name="regionalAgents" name="regionalAgents"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Regional Agents</FormLabel> <FormLabel>{t("regionalAgents")}</FormLabel>
{/* Display selected agents */} {/* Display selected agents */}
{currentRegionalAgents.length > 0 && ( {currentRegionalAgents.length > 0 && (
@@ -140,12 +142,12 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={ <SelectValue placeholder={
isLoading isLoading
? "Loading agents..." ? t("regionalAgentsLoading")
: availableAgents.length > 0 : availableAgents.length > 0
? "Select additional regional agents..." ? t("regionalAgentsAvailablePlaceholder")
: currentRegionalAgents.length > 0 : currentRegionalAgents.length > 0
? "All available agents selected" ? t("regionalAgentsAllSelected")
: "No regional agents available" : t("regionalAgentsNoAvailable")
} /> } />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
@@ -154,14 +156,14 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
<SelectItem value="loading" disabled> <SelectItem value="loading" disabled>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading agents... t("regionalAgentsLoading")
</div> </div>
</SelectItem> </SelectItem>
) : availableAgents.length === 0 ? ( ) : availableAgents.length === 0 ? (
<SelectItem value="no-agents" disabled> <SelectItem value="no-agents" disabled>
{currentRegionalAgents.length > 0 {currentRegionalAgents.length > 0
? "All available agents selected" ? t("regionalAgentsAllSelected")
: "No online regional agents available" : t("regionalAgentsNoOnlineAvailable")
} }
</SelectItem> </SelectItem>
) : ( ) : (
@@ -190,13 +192,13 @@ export function ServiceRegionalFields({ form }: ServiceRegionalFieldsProps) {
{regionalMonitoringEnabled && onlineAgents.length === 0 && !isLoading && ( {regionalMonitoringEnabled && onlineAgents.length === 0 && !isLoading && (
<p className="text-sm text-amber-600"> <p className="text-sm text-amber-600">
No online regional agents found. Services will use default monitoring. {t("regionalAgentsNotFoundMessage")}
</p> </p>
)} )}
{currentRegionalAgents.length === 0 && regionalMonitoringEnabled && ( {currentRegionalAgents.length === 0 && regionalMonitoringEnabled && (
<p className="text-sm text-orange-600"> <p className="text-sm text-orange-600">
No regional agents selected. Service will use default monitoring. {t("regionalAgentsNotSelectedMessage")}
</p> </p>
)} )}
@@ -4,12 +4,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Globe, Wifi, Server, Globe2 } from "lucide-react"; import { Globe, Wifi, Server, Globe2 } from "lucide-react";
import { UseFormReturn } from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types"; import { ServiceFormData } from "./types";
import { useLanguage } from "@/contexts/LanguageContext.tsx";
interface ServiceTypeFieldProps { interface ServiceTypeFieldProps {
form: UseFormReturn<ServiceFormData>; form: UseFormReturn<ServiceFormData>;
} }
export function ServiceTypeField({ form }: ServiceTypeFieldProps) { export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
const { t } = useLanguage();
const getServiceIcon = (type: string) => { const getServiceIcon = (type: string) => {
switch (type) { switch (type) {
case "http": case "http":
@@ -31,7 +33,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
name="type" name="type"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Service Type</FormLabel> <FormLabel>{t("serviceType")}</FormLabel>
<FormControl> <FormControl>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
@@ -56,7 +58,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
<span>HTTP/S</span> <span>HTTP/S</span>
</div> </div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Monitor websites and REST APIs with HTTP/HTTPS Protocol {t("serviceTypeHTTPDesc")}
</p> </p>
</div> </div>
</SelectItem> </SelectItem>
@@ -67,7 +69,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
<span>PING</span> <span>PING</span>
</div> </div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Monitor host availability with PING Protocol {t("serviceTypePINGDesc")}
</p> </p>
</div> </div>
</SelectItem> </SelectItem>
@@ -78,7 +80,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
<span>TCP</span> <span>TCP</span>
</div> </div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Monitor TCP port connectivity with TCP Protocol {t("serviceTypeTCPDesc")}
</p> </p>
</div> </div>
</SelectItem> </SelectItem>
@@ -89,7 +91,7 @@ export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
<span>DNS</span> <span>DNS</span>
</div> </div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Monitor DNS resolution {t("serviceTypeDNSDesc")}
</p> </p>
</div> </div>
</SelectItem> </SelectItem>
@@ -3,12 +3,14 @@ import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescripti
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form"; import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types"; import { ServiceFormData } from "./types";
import { useLanguage } from "@/contexts/LanguageContext.tsx";
interface ServiceUrlFieldProps { interface ServiceUrlFieldProps {
form: UseFormReturn<ServiceFormData>; form: UseFormReturn<ServiceFormData>;
} }
export function ServiceUrlField({ form }: ServiceUrlFieldProps) { export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
const { t } = useLanguage();
const serviceType = form.watch("type"); const serviceType = form.watch("type");
const getPlaceholder = () => { const getPlaceholder = () => {
@@ -22,35 +24,35 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
case "dns": case "dns":
return "example.com"; return "example.com";
default: default:
return "Enter URL or hostname"; return t("targetDefaultPlaceholder");
} }
}; };
const getDescription = () => { const getDescription = () => {
switch (serviceType) { switch (serviceType) {
case "http": case "http":
return "Enter the full URL including protocol (http:// or https://)"; return t("targetHTTPDesc");
case "ping": case "ping":
return "Enter hostname or IP address to ping"; return t("targetPINGDesc");
case "tcp": case "tcp":
return "Enter hostname or IP address for TCP connection test"; return t("targetTCPDesc");
case "dns": case "dns":
return "Enter domain name for DNS record monitoring (A, AAAA, MX, etc.)"; return t("targetDNSDesc");
default: default:
return "Enter the target URL or hostname for monitoring"; return t("targetDefaultDesc");
} }
}; };
const getFieldLabel = () => { const getFieldLabel = () => {
switch (serviceType) { switch (serviceType) {
case "dns": case "dns":
return "Domain Name"; return t("targetDNS");
case "ping": case "ping":
return "Hostname/IP"; return "Hostname/IP";
case "tcp": case "tcp":
return "Hostname/IP"; return "Hostname/IP";
default: default:
return "Target URL/Host"; return t("targetDefault");
} }
}; };
@@ -67,7 +69,7 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
placeholder={getPlaceholder()} placeholder={getPlaceholder()}
{...field} {...field}
onChange={(e) => { 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); field.onChange(e);
}} }}
/> />
@@ -93,13 +95,13 @@ export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
type="number" type="number"
{...field} {...field}
onChange={(e) => { onChange={(e) => {
console.log("Port field changed:", e.target.value); // console.log("Port field changed:", e.target.value);
field.onChange(e); field.onChange(e);
}} }}
/> />
</FormControl> </FormControl>
<FormDescription className="text-xs"> <FormDescription className="text-xs">
Enter the port number for TCP connection test {t("targetTCPPortDesc")}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -74,7 +74,6 @@ export const mapServiceToFormData = (service: Service): ServiceFormData => {
notificationChannels.push(...parsedChannels); notificationChannels.push(...parsedChannels);
} }
} catch (error) { } catch (error) {
// console.warn("Failed to parse notification_channel JSON:", error);
// If parsing fails, treat as single channel ID // If parsing fails, treat as single channel ID
notificationChannels.push(service.notification_channel); notificationChannels.push(service.notification_channel);
} }
@@ -91,16 +90,13 @@ export const mapServiceToFormData = (service: Service): ServiceFormData => {
} }
} }
// console.log("Mapping service to form data:", { // Handle notification_status - it can be boolean or string
// serviceName: service.name, let notificationStatus: "enabled" | "disabled" = "disabled";
// notification_status: service.notification_status, if (typeof service.notification_status === "boolean") {
// notification_channel: service.notification_channel, notificationStatus = service.notification_status ? "enabled" : "disabled";
// notificationChannel: service.notificationChannel, } else if (typeof service.notification_status === "string") {
// mappedChannels: notificationChannels, notificationStatus = service.notification_status === "enabled" ? "enabled" : "disabled";
// regionalAgents: regionalAgents, }
// region_name: service.region_name,
/// agent_id: service.agent_id
// });
return { return {
name: service.name || "", name: service.name || "",
@@ -109,7 +105,7 @@ export const mapServiceToFormData = (service: Service): ServiceFormData => {
port: portValue, port: portValue,
interval: String(service.interval || 60), interval: String(service.interval || 60),
retries: String(service.retries || 3), retries: String(service.retries || 3),
notificationStatus: service.notification_status || "disabled", notificationStatus: notificationStatus,
notificationChannels: notificationChannels, notificationChannels: notificationChannels,
alertTemplate: service.alertTemplate === "default" ? "" : service.alertTemplate || "", alertTemplate: service.alertTemplate === "default" ? "" : service.alertTemplate || "",
regionalMonitoringEnabled: isRegionalEnabled, regionalMonitoringEnabled: isRegionalEnabled,
@@ -152,7 +148,8 @@ export const mapFormDataToServiceData = (data: ServiceFormData) => {
type: data.type, type: data.type,
interval: parseInt(data.interval), interval: parseInt(data.interval),
retries: parseInt(data.retries), retries: parseInt(data.retries),
notificationStatus: data.notificationStatus || "disabled", // Convert string status to boolean for notification_status field
notificationStatus: data.notificationStatus === "enabled",
notificationChannels: data.notificationChannels || [], notificationChannels: data.notificationChannels || [],
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate, alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
// Use regional_status field and store multiple agents as comma-separated values // Use regional_status field and store multiple agents as comma-separated values
@@ -1,22 +1,46 @@
import { z } from "zod"; 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) => !/\s/.test(value),
t("spacesNotAllowed")
)
.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("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("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'}>{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'}>{t("errorMessage")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Details</TableHead> <TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("details")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>

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