74 Commits

Author SHA1 Message Date
Tola Leng 6462e5c551 Merge pull request #129 from operacle/develop
feat: Implement comprehensive powerful multi-channel notification system with templating:
- 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.
- Chinese language added
- Server and Service Table row clickable to detail page.
- Implement pagination for the SSL dashboard table
- Server Agent (RPM, Docker container, and general binary package) 
- Improve Uptime Service and Server connection update based on status and notification.
- Improve SSL perform the initial check automatically after creation 
- Backend Rate limiting and abuse protection
2025-08-14 23:01:14 +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 80a2eb383d Merge pull request #112 from operacle/develop
feat: add docker compose for server docker agent and Fix: Add tar.gz fallback for unsupported systems
and other improvement
2025-07-31 04:10:06 +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 27ee133e92 Merge pull request #84 from operacle/develop
Updated GitHub issue templates for Bug Report, Help Request,...
2025-07-23 22:56:43 +07: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 af64ac31ea Merge pull request #83 from operacle/develop
Fix: Display new service immediately
2025-07-23 22:29:48 +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
152 changed files with 12882 additions and 2123 deletions
+14 -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,9 @@ 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
+4 -8
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
@@ -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,9 @@ 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
+70 -62
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
@@ -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,44 @@ services:
--- ---
## Sponsors
🙏 We're incredibly thankful to our sponsors. Your contributions enable us to maintain infrastructure (hosting, domains), run tests, and continue developing valuable features.
### 🥈 Silver Uptime Ally
<a href="https://github.com/michaelortnerit">
<img src="https://avatars.githubusercontent.com/u/135371107?v=4" width="75" height="75" style="border-radius: 50%; display: block;" />
</a>
### 🧡 Ping Supporter
<a href="https://github.com/samang-dauth">
<img src="https://avatars.githubusercontent.com/u/4575656?v=4" width="75" height="75" style="border-radius: 50%; display: block;" />
</a>
### 🤝 Ecosystem & Community Partner
<a href="https://github.com/gitbookio" style="display: inline-block; margin-right: 10px; vertical-align: middle;">
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
width="75" height="75"
style="border-radius: 50%;" />
</a>
<a href="https://m.do.co/c/0c27ef82475f" style="display: inline-block; vertical-align: middle;">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg"
height="45"
alt="DigitalOcean Logo">
</a>
---
## 👥 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 +173,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: [@checkcle_oss](https://x.com/checkcle_oss)
- 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! 🌐
+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>
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: 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

@@ -1,15 +1,13 @@
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 } 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";
interface HeaderProps { interface HeaderProps {
currentUser: AuthUser | null; currentUser: AuthUser | null;
@@ -29,7 +27,6 @@ export const Header = ({
const [greeting, setGreeting] = useState<string>(""); const [greeting, setGreeting] = useState<string>("");
const { systemName } = useSystemSettings(); const { systemName } = useSystemSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [quickActionsOpen, setQuickActionsOpen] = useState(false);
// Set greeting based on time of day // Set greeting based on time of day
useEffect(() => { useEffect(() => {
@@ -53,7 +50,7 @@ export const Header = ({
// Log avatar data for debugging // Log avatar data for debugging
useEffect(() => { useEffect(() => {
if (currentUser) { if (currentUser) {
// console.log("Avatar URL in Header:", currentUser.avatar); //console.log("Avatar URL in Header:", currentUser.avatar);
} }
}, [currentUser]); }, [currentUser]);
@@ -66,7 +63,7 @@ export const Header = ({
} else { } else {
avatarUrl = currentUser.avatar; avatarUrl = currentUser.avatar;
} }
// console.log("Final avatar URL:", avatarUrl); console.log("Final avatar URL:", avatarUrl);
} }
return ( return (
@@ -91,16 +88,6 @@ export const Header = ({
{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 */}
<Button
variant="ghost"
size="icon"
onClick={() => setQuickActionsOpen(true)}
className="mr-2"
>
<Grid3x3 className="h-5 w-5 text-green-500" />
</Button>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 </h1> <h1 className="text-lg font-medium">{greeting}, {currentUser?.name || currentUser?.email?.split('@')[0] || 'User'} 👋 </h1>
</div> </div>
@@ -132,6 +119,9 @@ export const Header = ({
<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>
@@ -162,7 +152,7 @@ export const Header = ({
variant="outline" variant="outline"
size="icon" size="icon"
className="rounded-full w-8 h-8 border-border" className="rounded-full w-8 h-8 border-border"
onClick={() => window.open("https://x.com/tlengoss", "_blank")} onClick={() => window.open("https://x.com/checkcle_oss)", "_blank")}
> >
<span className="sr-only">X (Twitter)</span> <span className="sr-only">X (Twitter)</span>
<Twitter className="w-4 h-4" /> <Twitter className="w-4 h-4" />
@@ -223,9 +213,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;
@@ -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,7 +7,7 @@ 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';
@@ -17,12 +18,6 @@ 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 [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newComponent, setNewComponent] = useState({ const [newComponent, setNewComponent] = useState({
@@ -169,7 +164,7 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select an uptime service" /> <SelectValue placeholder="Select an uptime service" />
</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">
@@ -206,37 +201,6 @@ export const ComponentsSelector = ({ selectedComponents, onComponentsChange, onC
</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>
); );
@@ -67,13 +67,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 +84,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 +93,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);
} }
}; };
@@ -41,11 +41,11 @@ export const OperationalPageCard = ({ page, onEdit, onView, onDelete }: Operatio
</div> </div>
<div> <div>
<span className="font-medium text-muted-foreground">Public:</span> <span className="font-medium text-muted-foreground">Public:</span>
<p className="mt-1"> <div className="mt-1">
<Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}> <Badge variant={page.is_public === 'true' ? 'default' : 'secondary'}>
{page.is_public === 'true' ? 'Yes' : 'No'} {page.is_public === 'true' ? 'Yes' : '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">Updated:</span>
@@ -12,7 +12,7 @@ import { PublicStatusPageFooter } from './PublicStatusPageFooter';
export const PublicStatusPage = () => { export const PublicStatusPage = () => {
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 +51,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 (
@@ -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);
@@ -114,7 +114,7 @@ export const AddRegionalAgentDialog: React.FC<AddRegionalAgentDialogProps> = ({
textarea.setSelectionRange(0, 99999); // For mobile devices textarea.setSelectionRange(0, 99999); // For mobile devices
} }
} catch (selectError) { } catch (selectError) {
console.error('Failed to select text:', selectError); // console.error('Failed to select text:', selectError);
} }
} }
}; };
@@ -14,6 +14,7 @@ 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";
interface AddServerAgentDialogProps { interface AddServerAgentDialogProps {
@@ -123,9 +124,10 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
</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">Configure Agent</TabsTrigger>
<TabsTrigger value="one-click">One-Click Install</TabsTrigger> <TabsTrigger value="one-click">One-Click Install</TabsTrigger>
<TabsTrigger value="docker-one-click">Docker One-Click</TabsTrigger>
<TabsTrigger value="manual">Manual Installation</TabsTrigger> <TabsTrigger value="manual">Manual Installation</TabsTrigger>
</TabsList> </TabsList>
@@ -150,6 +152,16 @@ export const AddServerAgentDialog: React.FC<AddServerAgentDialogProps> = ({
onDialogClose={handleDialogClose} onDialogClose={handleDialogClose}
/> />
</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
@@ -0,0 +1,176 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Copy, Download, Container } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { copyToClipboard } from "@/utils/copyUtils";
interface DockerOneClickTabProps {
serverToken: string;
currentPocketBaseUrl: string;
formData: {
serverName: string;
osType: string;
checkInterval: string;
retryAttempt: string;
};
serverId: string;
onDialogClose: () => void;
}
export const DockerOneClickTab: React.FC<DockerOneClickTabProps> = ({
serverToken,
currentPocketBaseUrl,
formData,
serverId,
onDialogClose,
}) => {
const getDockerOneClickCommand = () => {
const scriptUrl = "https://cdn.checkcle.io/scripts/server-docker-agent.sh";
return `curl -L -o server-docker-agent.sh "${scriptUrl}"
chmod +x server-docker-agent.sh
SERVER_TOKEN="${serverToken}" \\
POCKETBASE_URL="${currentPocketBaseUrl}" \\
SERVER_NAME="${formData.serverName}" \\
AGENT_ID="${serverId}" \\
sudo -E bash ./server-docker-agent.sh`;
};
const getDirectDockerCommand = () => {
return `docker run -d \\
--name monitoring-agent \\
--restart unless-stopped \\
-p 8081:8081 \\
--group-add 999 \\
-e AGENT_ID="${serverId}" \\
-e SERVER_NAME="${formData.serverName}" \\
-e SERVER_TOKEN="${serverToken}" \\
-e POCKETBASE_URL="${currentPocketBaseUrl}" \\
-e POCKETBASE_ENABLED=true \\
-v /proc:/host/proc:ro \\
-v /etc:/host/etc:ro \\
-v /sys:/host/sys:ro \\
-v /:/host/root:ro \\
-v /var/run:/host/var/run:ro \\
-v /dev:/host/dev:ro \\
-v /var/run/docker.sock:/var/run/docker.sock:ro \\
-v monitoring_data:/var/lib/monitoring-agent \\
-v monitoring_logs:/var/log/monitoring-agent \\
operacle/checkcle-server-agent:latest`;
};
const handleCopyOneClickCommand = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// console.log('Copy one-click command button clicked');
const command = getDockerOneClickCommand();
// console.log('Copying one-click command:', command);
await copyToClipboard(command);
};
const handleCopyDockerCommand = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// console.log('Copy docker command button clicked');
const command = getDirectDockerCommand();
// console.log('Copying docker command:', command);
await copyToClipboard(command);
};
return (
<div className="space-y-6">
{/* One-Click Docker Installation */}
<Card className="border-blue-500/20 bg-blue-50/50 dark:bg-blue-950/20">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-700 dark:text-blue-400">
<Container className="h-5 w-5" />
Docker One-Click Install
</CardTitle>
<CardDescription className="text-blue-600 dark:text-blue-300">
Automated Docker container installation with system monitoring capabilities
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-blue-700 dark:text-blue-400">Docker One-Click Command</Label>
<div className="relative">
<pre className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all text-blue-800 dark:text-blue-200">
<code>{getDockerOneClickCommand()}</code>
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="absolute top-2 right-2 bg-blue-50 dark:bg-blue-950/50 border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-400"
onClick={handleCopyOneClickCommand}
>
<Copy className="h-4 w-4 mr-1" />
Copy
</Button>
</div>
</div>
<div className="text-sm text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 p-3 rounded-md">
<p className="font-medium mb-1">This script will automatically:</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>Download and setup the Docker monitoring agent</li>
<li>Configure all required environment variables</li>
<li>Start the container with proper system access</li>
<li>Setup monitoring data persistence</li>
</ol>
</div>
</CardContent>
</Card>
{/* Direct Docker Run Command */}
<Card className="border-gray-500/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Container className="h-5 w-5" />
Direct Docker Run Command
</CardTitle>
<CardDescription>
If you prefer to run the Docker container directly without the script
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Docker Run Command</Label>
<div className="relative">
<pre className="bg-muted border p-4 rounded-md text-sm overflow-x-auto whitespace-pre-wrap break-all">
<code>{getDirectDockerCommand()}</code>
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="absolute top-2 right-2"
onClick={handleCopyDockerCommand}
>
<Copy className="h-4 w-4 mr-1" />
Copy
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground bg-muted/50 border p-3 rounded-md">
<p className="font-medium mb-1">Prerequisites for direct Docker run:</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>Docker must be installed and running</li>
<li>The operacle/checkcle-server-agent image must be available</li>
<li>Run as root or with sudo privileges</li>
</ol>
</div>
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button onClick={onDialogClose}>
Done
</Button>
</div>
</div>
);
};
@@ -12,7 +12,7 @@ import { pb } from "@/lib/pocketbase";
import { Server } from "@/types/server.types"; import { 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";
interface EditServerDialogProps { interface EditServerDialogProps {
@@ -25,7 +25,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
@@ -49,7 +49,7 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
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 +66,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 +78,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 +86,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,10 +108,8 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
// Load existing threshold data when thresholds are loaded and we have a server with threshold_id // 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({
@@ -166,7 +163,6 @@ 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: "Error",
@@ -180,10 +176,10 @@ 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: "Error",
@@ -200,7 +196,6 @@ 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: "Error",
@@ -211,47 +206,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
} }
}; };
const handleThresholdUpdate = async () => {
if (!selectedThreshold) return;
try {
// Use the correct field name for RAM threshold
const updateData = {
cpu_threshold: thresholdFormData.cpu_threshold,
ram_threshold_message: thresholdFormData.ram_threshold, // Use the correct field name
disk_threshold: thresholdFormData.disk_threshold,
network_threshold: thresholdFormData.network_threshold,
};
await serverThresholdService.updateServerThreshold(selectedThreshold.id, updateData);
// Update local state
setSelectedThreshold({
...selectedThreshold,
...thresholdFormData,
});
// Update thresholds list
setThresholds(prev => prev.map(t =>
t.id === selectedThreshold.id
? { ...t, ...thresholdFormData }
: t
));
toast({
title: "Threshold updated",
description: "Server threshold values have been updated successfully.",
});
} catch (error) {
// console.error('Error updating threshold:', error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to update threshold values.",
});
}
};
const handleNotificationChannelToggle = (channelId: string, checked: boolean) => { const handleNotificationChannelToggle = (channelId: string, checked: boolean) => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@@ -276,22 +230,56 @@ 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: "Warning",
description: "Server updated but failed to update threshold values.",
});
}
}
toast({ toast({
title: "Server updated", title: "Server updated",
description: `${formData.name} has been updated successfully.`, description: `${formData.name} has been updated successfully.`,
@@ -301,7 +289,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
// console.error('Error updating server:', error);
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Error", title: "Error",
@@ -321,9 +308,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",
@@ -372,20 +359,20 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="retryAttempts">Retry Attempts</Label> <Label htmlFor="maxRetries">Max Retries</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="Select max retries" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">1 attempt</SelectItem> <SelectItem value="1">1 retry</SelectItem>
<SelectItem value="2">2 attempts</SelectItem> <SelectItem value="2">2 retries</SelectItem>
<SelectItem value="3">3 attempts</SelectItem> <SelectItem value="3">3 retries</SelectItem>
<SelectItem value="5">5 attempts</SelectItem> <SelectItem value="5">5 retries</SelectItem>
<SelectItem value="10">10 attempts</SelectItem> <SelectItem value="10">10 retries</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -416,10 +403,8 @@ 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">Enable Notifications</Label>
@@ -516,16 +501,8 @@ 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">Threshold Details: {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">
@@ -621,27 +598,39 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
<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">RAM Message</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 || "No RAM message defined"}
</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">CPU Message</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 || "No CPU message defined"}
</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">Disk Message</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 || "No disk message defined"}
</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">Network Message</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 || "No network message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Up Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.up_message || "No up message defined"}
</p>
</div>
<div>
<Label className="text-xs font-medium text-muted-foreground">Down Message</Label>
<p className="text-sm bg-background p-2 rounded border">
{selectedTemplate.down_message || "No down message defined"}
</p> </p>
</div> </div>
</div> </div>
@@ -677,4 +666,6 @@ export const EditServerDialog: React.FC<EditServerDialogProps> = ({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };
export default EditServerDialog;
@@ -25,7 +25,7 @@ export const ManualInstallTab: React.FC<ManualInstallTabProps> = ({
onDialogClose, onDialogClose,
}) => { }) => {
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 [
{ {
@@ -27,7 +27,7 @@ export const OneClickInstallTab: React.FC<OneClickInstallTabProps> = ({
onDialogClose, onDialogClose,
}) => { }) => {
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,7 +55,7 @@ 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 One-Click Install (Recommended)
</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 Copy and paste this single command to install the monitoring agent instantly
@@ -269,7 +269,11 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
const isProcessing = pausingServers.has(server.id); const isProcessing = pausingServers.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"
onClick={() => handleViewDetails(server.id)}
>
<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,7 +329,7 @@ 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}>
@@ -338,19 +342,19 @@ 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 View Server Detail
</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 Container Monitoring
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => handlePauseResume(server)} onClick={(e) => { e.stopPropagation(); handlePauseResume(server); }}
disabled={isProcessing} disabled={isProcessing}
> >
{isPaused ? ( {isPaused ? (
@@ -366,12 +370,12 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
)} )}
</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 Edit Server
</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" />
@@ -427,4 +431,4 @@ export const ServerTable = ({ servers, isLoading, onRefresh }: ServerTableProps)
</AlertDialog> </AlertDialog>
</> </>
); );
}; };
@@ -8,6 +8,7 @@ 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";
interface AddServiceDialogProps { interface AddServiceDialogProps {
open: boolean; open: boolean;
@@ -15,7 +16,11 @@ interface AddServiceDialogProps {
} }
export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) { export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) {
const handleSuccess = () => { 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);
}; };
@@ -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`);
} }
@@ -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>
); );
} }
@@ -13,6 +13,7 @@ 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";
interface ServiceFormProps { interface ServiceFormProps {
onSuccess: () => void; onSuccess: () => void;
@@ -31,6 +32,7 @@ export function ServiceForm({
}: ServiceFormProps) { }: ServiceFormProps) {
const { toast } = useToast(); const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const queryClient = useQueryClient();
// Initialize form with default values // Initialize form with default values
const form = useForm<ServiceFormData>({ const form = useForm<ServiceFormData>({
@@ -68,10 +70,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 +93,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();
@@ -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,6 +7,7 @@ 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";
interface ServiceNotificationFieldsProps { interface ServiceNotificationFieldsProps {
@@ -21,18 +21,18 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
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 +42,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")
// }); // });
@@ -159,9 +165,7 @@ 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>
@@ -169,18 +173,20 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
<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 ? "Loading templates..." : "Select an alert template"} />
</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>
@@ -195,4 +201,4 @@ export function ServiceNotificationFields({ form }: ServiceNotificationFieldsPro
/> />
</> </>
); );
} }
@@ -67,7 +67,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,7 +93,7 @@ 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);
}} }}
/> />
@@ -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
@@ -95,7 +95,7 @@ export const AboutSystem: React.FC = () => {
<FileText className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} /> <FileText className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('viewDocumentation')}</span> <span>{t('viewDocumentation')}</span>
</Button> </Button>
<Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://x.com/tlengoss", "_blank")}> <Button variant="outline" className="flex items-center justify-start gap-3 h-12 hover:bg-muted/50 transition-all duration-200" onClick={() => window.open("https://x.com/checkcle_oss", "_blank")}>
<Twitter className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} /> <Twitter className={`h-5 w-5 ${theme === 'dark' ? 'text-white/80' : 'text-gray-700'}`} />
<span>{t('followOnX')}</span> <span>{t('followOnX')}</span>
</Button> </Button>
@@ -1,9 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { templateService } from "@/services/templateService"; import { templateService, TemplateType } from "@/services/templateService";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Plus, RefreshCcw } from "lucide-react"; import { Plus, RefreshCcw } from "lucide-react";
import { TemplateList } from "./TemplateList"; import { TemplateList } from "./TemplateList";
import { TemplateDialog } from "./TemplateDialog"; import { TemplateDialog } from "./TemplateDialog";
@@ -11,8 +12,10 @@ import { useToast } from "@/hooks/use-toast";
export const AlertsTemplates = () => { export const AlertsTemplates = () => {
const { toast } = useToast(); const { toast } = useToast();
const [activeTab, setActiveTab] = useState<TemplateType>('service');
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<string | null>(null); const [editingTemplate, setEditingTemplate] = useState<string | null>(null);
const [editingTemplateType, setEditingTemplateType] = useState<TemplateType | null>(null);
const { const {
data: templates = [], data: templates = [],
@@ -20,17 +23,19 @@ export const AlertsTemplates = () => {
error, error,
refetch refetch
} = useQuery({ } = useQuery({
queryKey: ['notification_templates'], queryKey: ['notification_templates', activeTab],
queryFn: templateService.getTemplates, queryFn: () => templateService.getTemplates(activeTab),
}); });
const handleAddTemplate = () => { const handleAddTemplate = (templateType: TemplateType) => {
setEditingTemplate(null); setEditingTemplate(null);
setEditingTemplateType(templateType);
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
const handleEditTemplate = (id: string) => { const handleEditTemplate = (id: string, templateType: TemplateType) => {
setEditingTemplate(id); setEditingTemplate(id);
setEditingTemplateType(templateType);
setIsDialogOpen(true); setIsDialogOpen(true);
}; };
@@ -51,33 +56,103 @@ export const AlertsTemplates = () => {
<RefreshCcw className="h-4 w-4 mr-2" /> <RefreshCcw className="h-4 w-4 mr-2" />
Refresh Refresh
</Button> </Button>
<Button onClick={handleAddTemplate}> <Button onClick={() => handleAddTemplate(activeTab)}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Add Template Add Template
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{error ? ( <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TemplateType)}>
<div className="text-center p-6"> <TabsList className="grid w-full grid-cols-4">
<p className="text-destructive mb-4">Error loading templates</p> <TabsTrigger value="service">Service Uptime</TabsTrigger>
<Button variant="outline" onClick={() => refetch()}> <TabsTrigger value="server">Server Monitoring</TabsTrigger>
Try Again <TabsTrigger value="ssl">SSL Certificate</TabsTrigger>
</Button> <TabsTrigger value="server_threshold">Server Threshold</TabsTrigger>
</div> </TabsList>
) : (
<TemplateList <TabsContent value="service" className="mt-4">
templates={templates} {error ? (
isLoading={isLoading} <div className="text-center p-6">
onEdit={handleEditTemplate} <p className="text-destructive mb-4">Error loading service templates</p>
refetchTemplates={refetch} <Button variant="outline" onClick={() => refetch()}>
/> Try Again
)} </Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={(id) => handleEditTemplate(id, 'service')}
refetchTemplates={refetch}
templateType="service"
/>
)}
</TabsContent>
<TabsContent value="server" className="mt-4">
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading server templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={(id) => handleEditTemplate(id, 'server')}
refetchTemplates={refetch}
templateType="server"
/>
)}
</TabsContent>
<TabsContent value="ssl" className="mt-4">
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading SSL templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={(id) => handleEditTemplate(id, 'ssl')}
refetchTemplates={refetch}
templateType="ssl"
/>
)}
</TabsContent>
<TabsContent value="server_threshold" className="mt-4">
{error ? (
<div className="text-center p-6">
<p className="text-destructive mb-4">Error loading server threshold templates</p>
<Button variant="outline" onClick={() => refetch()}>
Try Again
</Button>
</div>
) : (
<TemplateList
templates={templates}
isLoading={isLoading}
onEdit={(id) => handleEditTemplate(id, 'server_threshold')}
refetchTemplates={refetch}
templateType="server_threshold"
/>
)}
</TabsContent>
</Tabs>
</CardContent> </CardContent>
<TemplateDialog <TemplateDialog
open={isDialogOpen} open={isDialogOpen}
templateId={editingTemplate} templateId={editingTemplate}
templateType={editingTemplateType}
onOpenChange={setIsDialogOpen} onOpenChange={setIsDialogOpen}
onSuccess={() => { onSuccess={() => {
refetch(); refetch();
@@ -86,4 +161,4 @@ export const AlertsTemplates = () => {
/> />
</Card> </Card>
); );
}; };
@@ -1,19 +1,26 @@
import React, { useEffect } from "react"; import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form"; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTemplateForm } from "./hooks/useTemplateForm"; import { useTemplateForm } from "./hooks/useTemplateForm";
import { BasicTemplateFields } from "./form/BasicTemplateFields"; import { ServerTemplateFields } from "./form/ServerTemplateFields";
import { MessagesTabContent } from "./form/MessagesTabContent"; import { ServiceTemplateFields } from "./form/ServiceTemplateFields";
import { PlaceholdersTabContent } from "./form/PlaceholdersTabContent"; import { SslTemplateFields } from "./form/SslTemplateFields";
import { ServerThresholdFields } from "./form/ServerThresholdFields";
import { Loader2, ChevronDown } from "lucide-react"; import { Loader2, ChevronDown } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { TemplateType, templateTypeConfigs } from "@/services/templateService";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
interface TemplateDialogProps { interface TemplateDialogProps {
open: boolean; open: boolean;
templateId: string | null; templateId: string | null;
templateType: TemplateType | null;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSuccess: () => void; onSuccess: () => void;
} }
@@ -21,9 +28,12 @@ interface TemplateDialogProps {
export const TemplateDialog: React.FC<TemplateDialogProps> = ({ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
open, open,
templateId, templateId,
templateType: initialTemplateType,
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}) => { }) => {
const [selectedTemplateType, setSelectedTemplateType] = useState<TemplateType>(initialTemplateType || 'service');
const { const {
form, form,
isEditMode, isEditMode,
@@ -32,28 +42,72 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
onSubmit onSubmit
} = useTemplateForm({ } = useTemplateForm({
templateId, templateId,
templateType: selectedTemplateType,
open, open,
onOpenChange, onOpenChange,
onSuccess onSuccess
}); });
// For debugging purposes // Update template type when prop changes or dialog opens
useEffect(() => { useEffect(() => {
if (open) { if (initialTemplateType) {
// console.log("Template dialog opened. Edit mode:", isEditMode, "Template ID:", templateId); setSelectedTemplateType(initialTemplateType);
} else if (open && !isEditMode) {
// Log form values when they change setSelectedTemplateType('service');
const subscription = form.watch((value) => {
// console.log("Current form values:", value);
});
return () => subscription.unsubscribe();
} }
}, [open, isEditMode, templateId, form]); }, [initialTemplateType, open, isEditMode]);
// Handle template type change
const handleTemplateTypeChange = (newType: TemplateType) => {
if (!isEditMode) {
setSelectedTemplateType(newType);
form.setValue('templateType', newType);
}
};
const renderTemplateFields = () => {
switch (selectedTemplateType) {
case 'server':
return <ServerTemplateFields control={form.control} />;
case 'service':
return <ServiceTemplateFields control={form.control} />;
case 'ssl':
return <SslTemplateFields control={form.control} />;
case 'server_threshold':
return <ServerThresholdFields control={form.control} />;
default:
return null;
}
};
const renderPlaceholderGuide = () => {
const config = templateTypeConfigs[selectedTemplateType];
if (!config) return null;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Available Placeholders</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
{config.description}. Use these placeholders in your messages:
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-sm">
{config.placeholders.map((placeholder) => (
<div key={placeholder} className="bg-muted/30 p-2 rounded">
<code className="text-xs">{placeholder}</code>
</div>
))}
</div>
</CardContent>
</Card>
);
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden"> <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>{isEditMode ? "Edit Template" : "Add Template"}</DialogTitle> <DialogTitle>{isEditMode ? "Edit Template" : "Add Template"}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -69,20 +123,85 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
<div className="relative flex-1"> <div className="relative flex-1">
<ScrollArea className="pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}> <ScrollArea className="pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="space-y-6 pb-6 pr-4"> <div className="space-y-6 pb-6 pr-4">
<BasicTemplateFields control={form.control} /> {/* Basic Fields */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Template Name</FormLabel>
<FormControl>
<Input
placeholder="Enter template name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="templateType"
render={({ field }) => (
<FormItem>
<FormLabel>Template Type</FormLabel>
<FormControl>
<Select
onValueChange={handleTemplateTypeChange}
value={selectedTemplateType}
disabled={isEditMode}
>
<SelectTrigger>
<SelectValue placeholder="Select template type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="server">Server Monitoring</SelectItem>
<SelectItem value="service">Service Uptime</SelectItem>
<SelectItem value="ssl">SSL Certificate</SelectItem>
<SelectItem value="server_threshold">Server Threshold</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{selectedTemplateType !== 'server_threshold' && (
<FormField
control={form.control}
name="placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Custom Placeholder</FormLabel>
<FormControl>
<Input
placeholder="Optional custom placeholder"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<Tabs defaultValue="messages"> <Tabs defaultValue="messages">
<TabsList className="grid w-full grid-cols-2"> <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="messages">Messages</TabsTrigger> <TabsTrigger value="messages">{selectedTemplateType === 'server_threshold' ? 'Thresholds' : 'Messages'}</TabsTrigger>
<TabsTrigger value="placeholders">Placeholders</TabsTrigger> <TabsTrigger value="placeholders">Placeholders</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="messages" className="pt-4"> <TabsContent value="messages" className="pt-4">
<MessagesTabContent control={form.control} /> {renderTemplateFields()}
</TabsContent> </TabsContent>
<TabsContent value="placeholders" className="pt-4"> <TabsContent value="placeholders" className="pt-4">
<PlaceholdersTabContent control={form.control} /> {renderPlaceholderGuide()}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
@@ -120,4 +239,4 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };
@@ -1,17 +1,15 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { NotificationTemplate, templateService } from "@/services/templateService";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Edit, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge";
import { import { Trash2, Edit, MoreVertical } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
@@ -22,63 +20,75 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { templateService, AnyTemplate, TemplateType } from "@/services/templateService";
interface TemplateListProps { interface TemplateListProps {
templates: NotificationTemplate[]; templates: AnyTemplate[];
isLoading: boolean; isLoading: boolean;
onEdit: (id: string) => void; onEdit: (id: string) => void;
refetchTemplates: () => void; refetchTemplates: () => void;
templateType: TemplateType;
} }
export const TemplateList: React.FC<TemplateListProps> = ({ export const TemplateList: React.FC<TemplateListProps> = ({
templates, templates,
isLoading, isLoading,
onEdit, onEdit,
refetchTemplates, refetchTemplates,
templateType
}) => { }) => {
const { toast } = useToast(); const { toast } = useToast();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const queryClient = useQueryClient();
const [templateToDelete, setTemplateToDelete] = useState<NotificationTemplate | null>(null); const [deleteTemplateId, setDeleteTemplateId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeletePrompt = (template: NotificationTemplate) => { // Delete mutation
setTemplateToDelete(template); const deleteMutation = useMutation({
setDeleteDialogOpen(true); mutationFn: (id: string) => templateService.deleteTemplate(id, templateType),
}; onSuccess: () => {
const handleDeleteTemplate = async () => {
if (!templateToDelete) return;
setIsDeleting(true);
try {
await templateService.deleteTemplate(templateToDelete.id);
toast({ toast({
title: "Template deleted", title: "Template deleted",
description: `Template "${templateToDelete.name}" has been removed.`, description: "The template has been deleted successfully.",
}); });
queryClient.invalidateQueries({ queryKey: ['notification_templates', templateType] });
refetchTemplates(); refetchTemplates();
} catch (error) { },
console.error("Error deleting template:", error); onError: (error) => {
// console.error("Error deleting template:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to delete template. Please try again.", description: "Failed to delete template. Please try again.",
variant: "destructive", variant: "destructive",
}); });
} finally { },
setIsDeleting(false); });
setDeleteDialogOpen(false);
setTemplateToDelete(null); const handleDelete = (id: string) => {
setDeleteTemplateId(id);
};
const confirmDelete = () => {
if (deleteTemplateId) {
deleteMutation.mutate(deleteTemplateId);
setDeleteTemplateId(null);
} }
}; };
if (isLoading) { if (isLoading) {
return ( return (
<div className="py-8 flex justify-center"> <div className="space-y-4">
<div className="animate-pulse flex flex-col items-center"> {[...Array(3)].map((_, i) => (
<div className="h-4 bg-gray-200 rounded w-32 mb-4"></div> <div key={i} className="flex items-center justify-between p-4 border border-border rounded-lg">
<div className="h-4 bg-gray-200 rounded w-64"></div> <div className="space-y-2">
</div> <div className="h-4 bg-muted animate-pulse rounded w-32"></div>
<div className="h-3 bg-muted animate-pulse rounded w-48"></div>
</div>
<div className="flex space-x-2">
<div className="h-8 w-8 bg-muted animate-pulse rounded"></div>
<div className="h-8 w-8 bg-muted animate-pulse rounded"></div>
</div>
</div>
))}
</div> </div>
); );
} }
@@ -86,83 +96,86 @@ export const TemplateList: React.FC<TemplateListProps> = ({
if (templates.length === 0) { if (templates.length === 0) {
return ( return (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-muted-foreground mb-2">No templates found</p> <p className="text-muted-foreground">No templates found. Create your first template to get started.</p>
<p className="text-sm text-muted-foreground">
Create your first notification template to get started.
</p>
</div> </div>
); );
} }
const getTemplateTypeLabel = (type: TemplateType) => {
switch (type) {
case 'server': return 'Server';
case 'service': return 'Service';
case 'ssl': return 'SSL';
default: return 'Unknown';
}
};
return ( return (
<> <>
<div className="border rounded-md overflow-hidden"> <div className="space-y-4">
<Table> {templates.map((template) => (
<TableHeader> <div key={template.id} className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors">
<TableRow> <div className="space-y-1">
<TableHead>Name</TableHead> <div className="flex items-center gap-2">
<TableHead>Type</TableHead> <h3 className="font-medium">{template.name}</h3>
<TableHead>Created</TableHead> <Badge variant="outline">{getTemplateTypeLabel(templateType)}</Badge>
<TableHead className="w-24">Actions</TableHead> </div>
</TableRow> <p className="text-sm text-muted-foreground">
</TableHeader> Created: {new Date(template.created).toLocaleDateString()}
<TableBody> {template.updated !== template.created &&
{templates.map((template) => ( ` • Updated: ${new Date(template.updated).toLocaleDateString()}`
<TableRow key={template.id}> }
<TableCell className="font-medium">{template.name}</TableCell> </p>
<TableCell> </div>
<Badge variant="outline">{template.type}</Badge> <div className="flex items-center space-x-2">
</TableCell> <Button
<TableCell> variant="outline"
{new Date(template.created).toLocaleDateString()} size="sm"
</TableCell> onClick={() => onEdit(template.id)}
<TableCell> >
<div className="flex space-x-2"> <Edit className="h-4 w-4 mr-1" />
<Button Edit
size="sm" </Button>
variant="ghost" <DropdownMenu>
onClick={() => onEdit(template.id)} <DropdownMenuTrigger asChild>
> <Button variant="outline" size="sm">
<Edit className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</Button> </Button>
<Button </DropdownMenuTrigger>
size="sm" <DropdownMenuContent align="end">
variant="ghost" <DropdownMenuItem
onClick={() => handleDeletePrompt(template)} onClick={() => handleDelete(template.id)}
> className="text-destructive focus:text-destructive"
<Trash2 className="h-4 w-4 text-destructive" /> >
</Button> <Trash2 className="h-4 w-4 mr-2" />
</div> Delete
</TableCell> </DropdownMenuItem>
</TableRow> </DropdownMenuContent>
))} </DropdownMenu>
</TableBody> </div>
</Table> </div>
))}
</div> </div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={!!deleteTemplateId} onOpenChange={() => setDeleteTemplateId(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Template</AlertDialogTitle> <AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete the template "{templateToDelete?.name}"? This action cannot be undone. This action cannot be undone. This will permanently delete the template.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={(e) => { onClick={confirmDelete}
e.preventDefault();
handleDeleteTemplate();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{isDeleting ? "Deleting..." : "Delete"} Delete
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</> </>
); );
}; };
@@ -71,15 +71,168 @@ export const PlaceholdersTabContent: React.FC<PlaceholdersTabContentProps> = ({
<FormField <FormField
control={control} control={control}
name="threshold_placeholder" name="url_placeholder"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Threshold Placeholder</FormLabel> <FormLabel>URL Placeholder</FormLabel>
<FormControl> <FormControl>
<Input placeholder="${threshold}" {...field} /> <Input placeholder="${url}" {...field} />
</FormControl> </FormControl>
<FormDescription className="text-xs"> <FormDescription className="text-xs">
Used for threshold values in alerts Used for service URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="host_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Host/IP Placeholder</FormLabel>
<FormControl>
<Input placeholder="${host}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service host or IP address
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="service_type_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Service Type Placeholder</FormLabel>
<FormControl>
<Input placeholder="${service_type}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service type (HTTP, PING, TCP, DNS)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="port_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Port Placeholder</FormLabel>
<FormControl>
<Input placeholder="${port}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service port number
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="domain_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Domain Placeholder</FormLabel>
<FormControl>
<Input placeholder="${domain}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for domain name (DNS services)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="region_name_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Region Name Placeholder</FormLabel>
<FormControl>
<Input placeholder="${region_name}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for regional agent name
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="agent_id_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Agent ID Placeholder</FormLabel>
<FormControl>
<Input placeholder="${agent_id}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for regional agent ID
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="uptime_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Uptime Placeholder</FormLabel>
<FormControl>
<Input placeholder="${uptime}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for service uptime percentage
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="time_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Time Placeholder</FormLabel>
<FormControl>
<Input placeholder="${time}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for current date and time
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="error_message_placeholder"
render={({ field }) => (
<FormItem>
<FormLabel>Error Message Placeholder</FormLabel>
<FormControl>
<Input placeholder="${error_message}" {...field} />
</FormControl>
<FormDescription className="text-xs">
Used for error details when service is down
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -111,22 +264,50 @@ export const PlaceholdersTabContent: React.FC<PlaceholdersTabContentProps> = ({
<code className="text-xs">${"{status}"}</code> <code className="text-xs">${"{status}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service status (UP, DOWN)</p> <p className="text-xs text-muted-foreground mt-1">Service status (UP, DOWN)</p>
</div> </div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{threshold}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service threshold value</p>
</div>
<div className="bg-muted/30 p-2 rounded"> <div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{url}"}</code> <code className="text-xs">${"{url}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service URL</p> <p className="text-xs text-muted-foreground mt-1">Service URL</p>
</div> </div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{host}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service host or IP address</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{service_type}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service type (HTTP, PING, TCP, DNS)</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{port}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service port number</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{domain}"}</code>
<p className="text-xs text-muted-foreground mt-1">Domain name (DNS services)</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{region_name}"}</code>
<p className="text-xs text-muted-foreground mt-1">Regional agent name</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{agent_id}"}</code>
<p className="text-xs text-muted-foreground mt-1">Regional agent ID</p>
</div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{uptime}"}</code>
<p className="text-xs text-muted-foreground mt-1">Service uptime percentage</p>
</div>
<div className="bg-muted/30 p-2 rounded"> <div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{time}"}</code> <code className="text-xs">${"{time}"}</code>
<p className="text-xs text-muted-foreground mt-1">Current date and time</p> <p className="text-xs text-muted-foreground mt-1">Current date and time</p>
</div> </div>
<div className="bg-muted/30 p-2 rounded">
<code className="text-xs">${"{error_message}"}</code>
<p className="text-xs text-muted-foreground mt-1">Error details when service is down</p>
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
}; };
@@ -0,0 +1,328 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ServerTemplateFieldsProps {
control: Control<any>;
}
export const ServerTemplateFields: React.FC<ServerTemplateFieldsProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">System Resource Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="cpu_message"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Alert Message</FormLabel>
<FormControl>
<Textarea
placeholder="CPU usage on ${server_name} is ${cpu_usage}% (threshold: ${threshold}%)"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="ram_message"
render={({ field }) => (
<FormItem>
<FormLabel>RAM Alert Message</FormLabel>
<FormControl>
<Textarea
placeholder="Memory usage on ${server_name} is ${ram_usage}% (threshold: ${threshold}%)"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="disk_message"
render={({ field }) => (
<FormItem>
<FormLabel>Disk Alert Message</FormLabel>
<FormControl>
<Textarea
placeholder="Disk usage on ${server_name} is ${disk_usage}% (threshold: ${threshold}%)"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="network_message"
render={({ field }) => (
<FormItem>
<FormLabel>Network Alert Message</FormLabel>
<FormControl>
<Textarea
placeholder="Network usage on ${server_name} is ${network_usage}% (threshold: ${threshold}%)"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">System Resource Restore Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="restore_cpu_message"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="CPU usage on ${server_name} has returned to normal: ${cpu_usage}%"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_ram_message"
render={({ field }) => (
<FormItem>
<FormLabel>RAM Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="Memory usage on ${server_name} has returned to normal: ${ram_usage}%"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_disk_message"
render={({ field }) => (
<FormItem>
<FormLabel>Disk Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="Disk usage on ${server_name} has returned to normal: ${disk_usage}%"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_network_message"
render={({ field }) => (
<FormItem>
<FormLabel>Network Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="Network usage on ${server_name} has returned to normal: ${network_usage}%"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="up_message"
render={({ field }) => (
<FormItem>
<FormLabel>Server Up Message</FormLabel>
<FormControl>
<Textarea
placeholder="Server ${server_name} is UP and responding"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="down_message"
render={({ field }) => (
<FormItem>
<FormLabel>Server Down Message</FormLabel>
<FormControl>
<Textarea
placeholder="Server ${server_name} is DOWN"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="warning_message"
render={({ field }) => (
<FormItem>
<FormLabel>Warning Message</FormLabel>
<FormControl>
<Textarea
placeholder="Warning: Server ${server_name} requires attention"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="paused_message"
render={({ field }) => (
<FormItem>
<FormLabel>Paused Message</FormLabel>
<FormControl>
<Textarea
placeholder="Monitoring for server ${server_name} is paused"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="cpu_temp_message"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Temperature Message</FormLabel>
<FormControl>
<Textarea
placeholder="CPU temperature on ${server_name} is ${cpu_temp}°C"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="disk_io_message"
render={({ field }) => (
<FormItem>
<FormLabel>Disk I/O Message</FormLabel>
<FormControl>
<Textarea
placeholder="Disk I/O on ${server_name} is ${disk_io} MB/s"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_cpu_temp_message"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Temperature Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="CPU temperature on ${server_name} has returned to normal: ${cpu_temp}°C"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="restore_disk_io_message"
render={({ field }) => (
<FormItem>
<FormLabel>Disk I/O Restore Message</FormLabel>
<FormControl>
<Textarea
placeholder="Disk I/O on ${server_name} has returned to normal: ${disk_io} MB/s"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,121 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ServerThresholdFieldsProps {
control: Control<any>;
}
export const ServerThresholdFields: React.FC<ServerThresholdFieldsProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Server Resource Thresholds</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="cpu_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>CPU Threshold (%)</FormLabel>
<FormControl>
<Input
type="number"
min="0"
max="100"
placeholder="85"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
CPU usage percentage that triggers an alert
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="ram_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>RAM Threshold (%)</FormLabel>
<FormControl>
<Input
type="number"
min="0"
max="100"
placeholder="80"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Memory usage percentage that triggers an alert
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="disk_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Disk Threshold (%)</FormLabel>
<FormControl>
<Input
type="number"
min="0"
max="100"
placeholder="90"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Disk usage percentage that triggers an alert
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="network_threshold"
render={({ field }) => (
<FormItem>
<FormLabel>Network Threshold (%)</FormLabel>
<FormControl>
<Input
type="number"
min="0"
max="100"
placeholder="75"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
<FormDescription>
Network usage percentage that triggers an alert
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,133 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface ServiceTemplateFieldsProps {
control: Control<any>;
}
export const ServiceTemplateFields: React.FC<ServiceTemplateFieldsProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Service Status Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={control}
name="up_message"
render={({ field }) => (
<FormItem>
<FormLabel>Service Up Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is UP. Response time: ${response_time}ms"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="down_message"
render={({ field }) => (
<FormItem>
<FormLabel>Service Down Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is DOWN. Status: ${status}"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="maintenance_message"
render={({ field }) => (
<FormItem>
<FormLabel>Maintenance Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} is under maintenance"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="incident_message"
render={({ field }) => (
<FormItem>
<FormLabel>Incident Message</FormLabel>
<FormControl>
<Textarea
placeholder="Service ${service_name} has an incident"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="resolved_message"
render={({ field }) => (
<FormItem>
<FormLabel>Resolved Message</FormLabel>
<FormControl>
<Textarea
placeholder="Issue with service ${service_name} has been resolved"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="warning_message"
render={({ field }) => (
<FormItem>
<FormLabel>Warning Message</FormLabel>
<FormControl>
<Textarea
placeholder="Warning: Service ${service_name} response time is high"
className="min-h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,79 @@
import React from "react";
import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { Control } from "react-hook-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface SslTemplateFieldsProps {
control: Control<any>;
}
export const SslTemplateFields: React.FC<SslTemplateFieldsProps> = ({ control }) => {
return (
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">SSL Certificate Messages</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<FormField
control={control}
name="expired"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Expired Message</FormLabel>
<FormControl>
<Textarea
placeholder="SSL certificate for ${domain} has EXPIRED on ${expiry_date}"
className="min-h-24"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="exiring_soon"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Expiring Soon Message</FormLabel>
<FormControl>
<Textarea
placeholder="SSL certificate for ${domain} will expire in ${days_left} days on ${expiry_date}"
className="min-h-24"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="warning"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Warning Message</FormLabel>
<FormControl>
<Textarea
placeholder="Warning: SSL certificate for ${domain} requires attention"
className="min-h-24"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -1,2 +1,2 @@
export * from './useTemplateForm'; export * from './useTemplateForm';
@@ -1,72 +1,180 @@
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod"; import * as z from "zod";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { templateService, CreateUpdateTemplateData } from "@/services/templateService"; import { templateService, TemplateType, AnyTemplateData } from "@/services/templateService";
import { useEffect } from "react"; import { useEffect } from "react";
// Template form schema // Base schema
export const templateFormSchema = z.object({ const baseSchema = {
name: z.string().min(2, "Name is required and must be at least 2 characters"), name: z.string().min(2, "Name is required and must be at least 2 characters"),
type: z.string().min(1, "Type is required"), templateType: z.enum(['server', 'service', 'ssl', 'server_threshold'] as const),
placeholder: z.string().optional(),
};
// Server template schema
const serverTemplateSchema = z.object({
...baseSchema,
templateType: z.literal('server'),
ram_message: z.string().min(1, "RAM message is required"),
cpu_message: z.string().min(1, "CPU message is required"),
disk_message: z.string().min(1, "Disk message is required"),
network_message: z.string().min(1, "Network message is required"),
up_message: z.string().min(1, "Up message is required"),
down_message: z.string().min(1, "Down message is required"),
notification_id: z.string().optional(),
warning_message: z.string().min(1, "Warning message is required"),
paused_message: z.string().min(1, "Paused message is required"),
cpu_temp_message: z.string().min(1, "CPU temperature message is required"),
disk_io_message: z.string().min(1, "Disk I/O message is required"),
restore_ram_message: z.string().optional(),
restore_cpu_message: z.string().optional(),
restore_disk_message: z.string().optional(),
restore_network_message: z.string().optional(),
restore_cpu_temp_message: z.string().optional(),
restore_disk_io_message: z.string().optional(),
});
// Service template schema
const serviceTemplateSchema = z.object({
...baseSchema,
templateType: z.literal('service'),
up_message: z.string().min(1, "Up message is required"), up_message: z.string().min(1, "Up message is required"),
down_message: z.string().min(1, "Down message is required"), down_message: z.string().min(1, "Down message is required"),
maintenance_message: z.string().min(1, "Maintenance message is required"), maintenance_message: z.string().min(1, "Maintenance message is required"),
incident_message: z.string().min(1, "Incident message is required"), incident_message: z.string().min(1, "Incident message is required"),
resolved_message: z.string().min(1, "Resolved message is required"), resolved_message: z.string().min(1, "Resolved message is required"),
service_name_placeholder: z.string().min(1, "Service name placeholder is required"), warning_message: z.string().min(1, "Warning message is required"),
response_time_placeholder: z.string().min(1, "Response time placeholder is required"),
status_placeholder: z.string().min(1, "Status placeholder is required"),
threshold_placeholder: z.string().min(1, "Threshold placeholder is required"),
}); });
// Define the form data type from the schema // SSL template schema
const sslTemplateSchema = z.object({
...baseSchema,
templateType: z.literal('ssl'),
expired: z.string().min(1, "Expired message is required"),
exiring_soon: z.string().min(1, "Expiring soon message is required"),
warning: z.string().min(1, "Warning message is required"),
});
// Server threshold schema
const serverThresholdSchema = z.object({
...baseSchema,
templateType: z.literal('server_threshold'),
cpu_threshold: z.number().min(0).max(100, "CPU threshold must be between 0-100"),
ram_threshold: z.number().min(0).max(100, "RAM threshold must be between 0-100"),
disk_threshold: z.number().min(0).max(100, "Disk threshold must be between 0-100"),
network_threshold: z.number().min(0).max(100, "Network threshold must be between 0-100"),
notification_id: z.string().optional(),
server_template_id: z.string().optional(),
});
// Combined schema
export const templateFormSchema = z.discriminatedUnion("templateType", [
serverTemplateSchema,
serviceTemplateSchema,
sslTemplateSchema,
serverThresholdSchema,
]);
export type TemplateFormData = z.infer<typeof templateFormSchema>; export type TemplateFormData = z.infer<typeof templateFormSchema>;
// Default form values // Default form values for each template type
const defaultFormValues: TemplateFormData = { const getDefaultValues = (templateType: TemplateType): TemplateFormData => {
name: "", const base = {
type: "default", name: "",
up_message: "Service ${service_name} is UP. Response time: ${response_time}ms", templateType,
down_message: "Service ${service_name} is DOWN. Status: ${status}", placeholder: "",
maintenance_message: "Service ${service_name} is under maintenance", };
incident_message: "Service ${service_name} has an incident",
resolved_message: "Service ${service_name} issue has been resolved", switch (templateType) {
service_name_placeholder: "${service_name}", case 'server':
response_time_placeholder: "${response_time}", return {
status_placeholder: "${status}", ...base,
threshold_placeholder: "${threshold}", templateType: 'server' as const,
ram_message: "Memory usage on ${server_name} is ${ram_usage}% (threshold: ${threshold}%)",
cpu_message: "CPU usage on ${server_name} is ${cpu_usage}% (threshold: ${threshold}%)",
disk_message: "Disk usage on ${server_name} is ${disk_usage}% (threshold: ${threshold}%)",
network_message: "Network usage on ${server_name} is ${network_usage}% (threshold: ${threshold}%)",
up_message: "Server ${server_name} is UP and responding",
down_message: "Server ${server_name} is DOWN",
notification_id: "",
warning_message: "Warning: Server ${server_name} requires attention",
paused_message: "Monitoring for server ${server_name} is paused",
cpu_temp_message: "CPU temperature on ${server_name} is ${cpu_temp}°C",
disk_io_message: "Disk I/O on ${server_name} is ${disk_io} MB/s",
restore_ram_message: "Memory usage on ${server_name} has returned to normal: ${ram_usage}%",
restore_cpu_message: "CPU usage on ${server_name} has returned to normal: ${cpu_usage}%",
restore_disk_message: "Disk usage on ${server_name} has returned to normal: ${disk_usage}%",
restore_network_message: "Network usage on ${server_name} has returned to normal: ${network_usage}%",
restore_cpu_temp_message: "CPU temperature on ${server_name} has returned to normal: ${cpu_temp}°C",
restore_disk_io_message: "Disk I/O on ${server_name} has returned to normal: ${disk_io} MB/s",
};
case 'service':
return {
...base,
templateType: 'service' as const,
up_message: "Service ${service_name} is UP. Response time: ${response_time}ms",
down_message: "Service ${service_name} is DOWN. Status: ${status}",
maintenance_message: "Service ${service_name} is under maintenance",
incident_message: "Service ${service_name} has an incident",
resolved_message: "Issue with service ${service_name} has been resolved",
warning_message: "Warning: Service ${service_name} response time is high",
};
case 'ssl':
return {
...base,
templateType: 'ssl' as const,
expired: "SSL certificate for ${domain} has EXPIRED on ${expiry_date}",
exiring_soon: "SSL certificate for ${domain} will expire in ${days_left} days on ${expiry_date}",
warning: "Warning: SSL certificate for ${domain} requires attention",
};
case 'server_threshold':
return {
...base,
templateType: 'server_threshold' as const,
cpu_threshold: 85,
ram_threshold: 80,
disk_threshold: 90,
network_threshold: 75,
notification_id: "",
server_template_id: "",
};
default:
throw new Error(`Unknown template type: ${templateType}`);
}
}; };
export interface UseTemplateFormProps { export interface UseTemplateFormProps {
templateId: string | null; templateId: string | null;
templateType: TemplateType;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSuccess: () => void; onSuccess: () => void;
} }
export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: UseTemplateFormProps) => { export const useTemplateForm = ({ templateId, templateType, open, onOpenChange, onSuccess }: UseTemplateFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isEditMode = !!templateId; const isEditMode = !!templateId;
// console.log("Template form initialized with templateId:", templateId, "isEditMode:", isEditMode);
const form = useForm<TemplateFormData>({ const form = useForm<TemplateFormData>({
resolver: zodResolver(templateFormSchema), resolver: zodResolver(templateFormSchema),
defaultValues: defaultFormValues, defaultValues: getDefaultValues(templateType),
mode: "onChange" mode: "onChange"
}); });
// Query to fetch template data for editing // Query to fetch template data for editing
const { isLoading: isLoadingTemplate, data: templateData } = useQuery({ const { isLoading: isLoadingTemplate, data: templateData } = useQuery({
queryKey: ['template', templateId], queryKey: ['template', templateId, templateType],
queryFn: () => { queryFn: () => {
if (!templateId) return null; if (!templateId) return null;
console.log("Fetching template data for ID:", templateId);
return templateService.getTemplate(templateId); return templateService.getTemplate(templateId, templateType);
}, },
enabled: !!templateId && open, enabled: !!templateId && open,
}); });
@@ -74,53 +182,71 @@ export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: U
// Set form values when template data is loaded // Set form values when template data is loaded
useEffect(() => { useEffect(() => {
if (templateData && open) { if (templateData && open) {
// console.log("Setting form values with template data:", templateData);
const formData: any = {
form.reset({
name: templateData.name || "", name: templateData.name || "",
type: templateData.type || "default", templateType: templateType,
up_message: templateData.up_message || "", placeholder: (templateData as any).placeholder || "",
down_message: templateData.down_message || "", };
maintenance_message: templateData.maintenance_message || "",
incident_message: templateData.incident_message || "",
resolved_message: templateData.resolved_message || "",
service_name_placeholder: templateData.service_name_placeholder || "${service_name}",
response_time_placeholder: templateData.response_time_placeholder || "${response_time}",
status_placeholder: templateData.status_placeholder || "${status}",
threshold_placeholder: templateData.threshold_placeholder || "${threshold}",
});
}
}, [templateData, open, form]);
// Handle form errors // Define the expected fields for each template type
useEffect(() => { const expectedFields = {
const subscription = form.formState.errors.root?.message && server: [
toast({ 'ram_message', 'cpu_message', 'disk_message', 'network_message',
title: "Error", 'up_message', 'down_message', 'notification_id', 'warning_message',
description: form.formState.errors.root.message, 'paused_message', 'cpu_temp_message', 'disk_io_message',
variant: "destructive", 'restore_ram_message', 'restore_cpu_message', 'restore_disk_message',
'restore_network_message', 'restore_cpu_temp_message', 'restore_disk_io_message'
],
service: [
'up_message', 'down_message', 'maintenance_message', 'incident_message',
'resolved_message', 'warning_message'
],
ssl: ['expired', 'exiring_soon', 'warning'],
server_threshold: [
'cpu_threshold', 'ram_threshold', 'disk_threshold', 'network_threshold',
'notification_id', 'server_template_id'
]
};
// Add template-specific fields
const fieldsToProcess = expectedFields[templateType] || [];
fieldsToProcess.forEach(key => {
let value = (templateData as any)[key];
// Convert string values to numbers for server threshold fields
if (templateType === 'server_threshold' &&
['cpu_threshold', 'ram_threshold', 'disk_threshold', 'network_threshold'].includes(key)) {
value = typeof value === 'string' ? Number(value) : value;
// Ensure valid number, fallback to default if invalid
if (isNaN(value)) {
const defaults = getDefaultValues(templateType) as any;
value = defaults[key] || 0;
}
}
// Set the value, using empty string as fallback for string fields
formData[key] = value !== undefined && value !== null ? value : "";
}); });
return () => { console.log("Final form data being set:", formData);
if (subscription) { form.reset(formData);
// Clean up if needed }
} }, [templateData, open, form, templateType]);
};
}, [form.formState.errors.root, toast]);
// Create mutation // Create mutation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: CreateUpdateTemplateData) => templateService.createTemplate(data), mutationFn: (data: AnyTemplateData) => templateService.createTemplate(data, templateType),
onSuccess: () => { onSuccess: () => {
toast({ toast({
title: "Template created", title: "Template created",
description: "Your notification template has been created successfully.", description: "Your notification template has been created successfully.",
}); });
queryClient.invalidateQueries({ queryKey: ['notification_templates'] }); queryClient.invalidateQueries({ queryKey: ['notification_templates', templateType] });
onSuccess(); onSuccess();
}, },
onError: (error) => { onError: (error) => {
// console.error("Error creating template:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to create template. Please check your inputs and try again.", description: "Failed to create template. Please check your inputs and try again.",
@@ -131,18 +257,18 @@ export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: U
// Update mutation // Update mutation
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: CreateUpdateTemplateData }) => mutationFn: ({ id, data }: { id: string; data: AnyTemplateData }) =>
templateService.updateTemplate(id, data), templateService.updateTemplate(id, data, templateType),
onSuccess: () => { onSuccess: () => {
toast({ toast({
title: "Template updated", title: "Template updated",
description: "Your notification template has been updated successfully.", description: "Your notification template has been updated successfully.",
}); });
queryClient.invalidateQueries({ queryKey: ['notification_templates'] }); queryClient.invalidateQueries({ queryKey: ['notification_templates', templateType] });
onSuccess(); onSuccess();
}, },
onError: (error) => { onError: (error) => {
// console.error("Error updating template:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to update template. Please check your inputs and try again.", description: "Failed to update template. Please check your inputs and try again.",
@@ -155,39 +281,28 @@ export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: U
// Handle form submission // Handle form submission
const onSubmit = (formData: TemplateFormData) => { const onSubmit = (formData: TemplateFormData) => {
// console.log("Submitting form data:", formData);
// Ensure all required fields are present // Remove templateType from the data before sending to API
const completeData: CreateUpdateTemplateData = { const { templateType: _, ...templateDataWithoutType } = formData;
name: formData.name, const completeData = templateDataWithoutType as AnyTemplateData;
type: formData.type,
up_message: formData.up_message,
down_message: formData.down_message,
maintenance_message: formData.maintenance_message,
incident_message: formData.incident_message,
resolved_message: formData.resolved_message,
service_name_placeholder: formData.service_name_placeholder,
response_time_placeholder: formData.response_time_placeholder,
status_placeholder: formData.status_placeholder,
threshold_placeholder: formData.threshold_placeholder,
};
if (isEditMode && templateId) { if (isEditMode && templateId) {
// console.log("Updating template with ID:", templateId);
updateMutation.mutate({ id: templateId, data: completeData }); updateMutation.mutate({ id: templateId, data: completeData });
} else { } else {
// console.log("Creating new template");
createMutation.mutate(completeData); createMutation.mutate(completeData);
} }
}; };
// Reset form when dialog closes // Reset form when dialog closes or template type changes
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
// console.log("Dialog closed, resetting form"); console.log("Dialog closed, resetting form");
form.reset(defaultFormValues); form.reset(getDefaultValues(templateType));
} }
}, [open, form]); }, [open, form, templateType]);
return { return {
form, form,
@@ -196,4 +311,4 @@ export const useTemplateForm = ({ templateId, open, onOpenChange, onSuccess }: U
isSubmitting, isSubmitting,
onSubmit onSubmit
}; };
}; };
@@ -1,4 +1,3 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { import {
Dialog, Dialog,
@@ -8,12 +7,13 @@ import {
DialogFooter DialogFooter
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService"; import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { import {
Form, Form,
FormControl, FormControl,
@@ -24,7 +24,9 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Loader2 } from "lucide-react"; import { Loader2, Copy } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "@/hooks/use-toast";
interface NotificationChannelDialogProps { interface NotificationChannelDialogProps {
open: boolean; open: boolean;
@@ -34,9 +36,9 @@ interface NotificationChannelDialogProps {
const baseSchema = z.object({ const baseSchema = z.object({
notify_name: z.string().min(1, "Name is required"), notify_name: z.string().min(1, "Name is required"),
notification_type: z.enum(["telegram", "discord", "slack", "signal", "email"]), notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "webhook"]),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
service_id: z.string().default("global"), // Assuming global for now, could be linked to specific services service_id: z.string().default("global"),
template_id: z.string().optional(), template_id: z.string().optional(),
}); });
@@ -59,11 +61,27 @@ const slackSchema = baseSchema.extend({
const signalSchema = baseSchema.extend({ const signalSchema = baseSchema.extend({
notification_type: z.literal("signal"), notification_type: z.literal("signal"),
signal_number: z.string().min(1, "Signal number is required"), signal_number: z.string().min(1, "Signal number is required"),
signal_api_endpoint: z.string().url("Must be a valid API endpoint URL"),
});
const googleChatSchema = baseSchema.extend({
notification_type: z.literal("google_chat"),
google_chat_webhook_url: z.string().url("Must be a valid URL"),
}); });
const emailSchema = baseSchema.extend({ const emailSchema = baseSchema.extend({
notification_type: z.literal("email"), notification_type: z.literal("email"),
// Email specific fields could be added here email_address: z.string().email("Valid email is required"),
email_sender_name: z.string().min(1, "Sender name is required"),
smtp_server: z.string().min(1, "SMTP server is required"),
smtp_port: z.string().min(1, "SMTP port is required"),
smtp_password: z.string().min(1, "SMTP password is required"),
});
const webhookSchema = baseSchema.extend({
notification_type: z.literal("webhook"),
webhook_url: z.string().url("Must be a valid URL"),
webhook_payload_template: z.string().optional(),
}); });
const formSchema = z.discriminatedUnion("notification_type", [ const formSchema = z.discriminatedUnion("notification_type", [
@@ -71,11 +89,104 @@ const formSchema = z.discriminatedUnion("notification_type", [
discordSchema, discordSchema,
slackSchema, slackSchema,
signalSchema, signalSchema,
emailSchema googleChatSchema,
emailSchema,
webhookSchema
]); ]);
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
const notificationTypeOptions = [
{
value: "telegram",
label: "Telegram",
description: "Send notifications via Telegram bot",
icon: "/upload/notification/telegram.png"
},
{
value: "discord",
label: "Discord",
description: "Send notifications to Discord webhook",
icon: "/upload/notification/discord.png"
},
{
value: "slack",
label: "Slack",
description: "Send notifications to Slack webhook",
icon: "/upload/notification/slack.png"
},
{
value: "signal",
label: "Signal",
description: "Send notifications via Signal",
icon: "/upload/notification/signal.png"
},
{
value: "google_chat",
label: "Google Chat",
description: "Send notifications to Google Chat webhook",
icon: "/upload/notification/google.png"
},
{
value: "email",
label: "Email",
description: "Send notifications via email",
icon: "/upload/notification/email.png"
},
{
value: "webhook",
label: "Webhook",
description: "Send notifications to custom webhook",
icon: "/upload/notification/webhook.png"
},
];
const webhookPayloadTemplates = {
server: `{
"alert_type": "server",
"server_name": "\${server_name}",
"status": "\${status}",
"cpu_usage": "\${cpu_usage}",
"ram_usage": "\${ram_usage}",
"disk_usage": "\${disk_usage}",
"network_usage": "\${network_usage}",
"cpu_temp": "\${cpu_temp}",
"disk_io": "\${disk_io}",
"threshold": "\${time}",
"message": "Server \${server_name} alert: \${status}"
}`,
service: `{
"alert_type": "service",
"service_name": "\${service_name}",
"status": "\${status}",
"response_time": "\${response_time}",
"url": "\${url}",
"uptime": "\${uptime}",
"downtime": "\${downtime}",
"timestamp": "\${time}",
"message": "Service \${service_name} is \${status}"
}`,
ssl: `{
"alert_type": "ssl",
"domain": "\${domain}",
"certificate_name": "\${certificate_name}",
"expiry_date": "\${expiry_date}",
"days_left": "\${days_left}",
"issuer": "\${issuer}",
"serial_number": "\${serial_number}",
"timestamp": "\${time}",
"message": "SSL certificate for \${domain} expires in \${days_left} days"
}`
};
const defaultPayloadTemplate = `{
"alert_type": "general",
"service_name": "\${service_name}",
"status": "\${status}",
"message": "\${message}",
"timestamp": "\${time}"
}`;
export const NotificationChannelDialog = ({ export const NotificationChannelDialog = ({
open, open,
onClose, onClose,
@@ -93,7 +204,7 @@ export const NotificationChannelDialog = ({
}, },
}); });
const { watch, reset } = form; const { watch, reset, setValue } = form;
const notificationType = watch("notification_type"); const notificationType = watch("notification_type");
const [isSubmitting, setIsSubmitting] = React.useState(false); const [isSubmitting, setIsSubmitting] = React.useState(false);
@@ -123,10 +234,14 @@ export const NotificationChannelDialog = ({
onClose(false); onClose(false);
}; };
const insertTemplate = (template: string) => {
setValue("webhook_payload_template", template);
};
const onSubmit = async (values: FormValues) => { const onSubmit = async (values: FormValues) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// Ensure service_id is always present // Handle all notification types including webhook through alert_configurations
const configData = { const configData = {
...values, ...values,
service_id: values.service_id || "global", service_id: values.service_id || "global",
@@ -137,7 +252,14 @@ export const NotificationChannelDialog = ({
} else { } else {
await alertConfigService.createAlertConfiguration(configData as any); await alertConfigService.createAlertConfiguration(configData as any);
} }
onClose(true); // Close with refresh onClose(true); // Close with refresh
} catch (error) {
toast({
title: "Error",
description: "Failed to save notification channel",
variant: "destructive"
});
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -145,7 +267,7 @@ export const NotificationChannelDialog = ({
return ( return (
<Dialog open={open} onOpenChange={() => handleClose()}> <Dialog open={open} onOpenChange={() => handleClose()}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{isEditing ? "Edit Notification Channel" : "Add Notification Channel"} {isEditing ? "Edit Notification Channel" : "Add Notification Channel"}
@@ -159,7 +281,7 @@ export const NotificationChannelDialog = ({
name="notify_name" name="notify_name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Channel Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="My Notification Channel" {...field} /> <Input placeholder="My Notification Channel" {...field} />
</FormControl> </FormControl>
@@ -175,63 +297,37 @@ export const NotificationChannelDialog = ({
control={form.control} control={form.control}
name="notification_type" name="notification_type"
render={({ field }) => ( render={({ field }) => (
<FormItem className="space-y-3"> <FormItem>
<FormLabel>Channel Type</FormLabel> <FormLabel>Channel Type</FormLabel>
<FormControl> <Select onValueChange={field.onChange} value={field.value}>
<RadioGroup <FormControl>
onValueChange={field.onChange} <SelectTrigger>
defaultValue={field.value} <SelectValue placeholder="Select notification type" />
value={field.value} </SelectTrigger>
className="flex flex-col space-y-1" </FormControl>
> <SelectContent>
<FormItem className="flex items-center space-x-3 space-y-0"> {notificationTypeOptions.map((option) => (
<FormControl> <SelectItem key={option.value} value={option.value}>
<RadioGroupItem value="telegram" /> <div className="flex items-center space-x-3">
</FormControl> <img
<FormLabel className="font-normal"> src={option.icon}
Telegram alt={`${option.label} icon`}
</FormLabel> className="w-5 h-5 object-contain"
</FormItem> />
<FormItem className="flex items-center space-x-3 space-y-0"> <div className="flex flex-col">
<FormControl> <span className="font-medium">{option.label}</span>
<RadioGroupItem value="discord" /> <span className="text-xs text-muted-foreground">{option.description}</span>
</FormControl> </div>
<FormLabel className="font-normal"> </div>
Discord </SelectItem>
</FormLabel> ))}
</FormItem> </SelectContent>
<FormItem className="flex items-center space-x-3 space-y-0"> </Select>
<FormControl>
<RadioGroupItem value="slack" />
</FormControl>
<FormLabel className="font-normal">
Slack
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="signal" />
</FormControl>
<FormLabel className="font-normal">
Signal
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="email" />
</FormControl>
<FormLabel className="font-normal">
Email
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Show different fields based on notification type */}
{notificationType === "telegram" && ( {notificationType === "telegram" && (
<> <>
<FormField <FormField
@@ -243,6 +339,9 @@ export const NotificationChannelDialog = ({
<FormControl> <FormControl>
<Input placeholder="Telegram Chat ID" {...field} /> <Input placeholder="Telegram Chat ID" {...field} />
</FormControl> </FormControl>
<FormDescription>
The Telegram chat ID to send notifications to
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -256,6 +355,9 @@ export const NotificationChannelDialog = ({
<FormControl> <FormControl>
<Input placeholder="Telegram Bot Token" {...field} type="password" /> <Input placeholder="Telegram Bot Token" {...field} type="password" />
</FormControl> </FormControl>
<FormDescription>
Your Telegram bot token from @BotFather
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -271,8 +373,11 @@ export const NotificationChannelDialog = ({
<FormItem> <FormItem>
<FormLabel>Webhook URL</FormLabel> <FormLabel>Webhook URL</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Discord Webhook URL" {...field} /> <Input placeholder="https://discord.com/api/webhooks/..." {...field} />
</FormControl> </FormControl>
<FormDescription>
Discord webhook URL from your server settings
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -287,8 +392,11 @@ export const NotificationChannelDialog = ({
<FormItem> <FormItem>
<FormLabel>Webhook URL</FormLabel> <FormLabel>Webhook URL</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Slack Webhook URL" {...field} /> <Input placeholder="https://hooks.slack.com/services/..." {...field} />
</FormControl> </FormControl>
<FormDescription>
Slack incoming webhook URL
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -296,20 +404,230 @@ export const NotificationChannelDialog = ({
)} )}
{notificationType === "signal" && ( {notificationType === "signal" && (
<>
<FormField
control={form.control}
name="signal_number"
render={({ field }) => (
<FormItem>
<FormLabel>Signal Number</FormLabel>
<FormControl>
<Input placeholder="+1234567890" {...field} />
</FormControl>
<FormDescription>
Signal phone number to send notifications to
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="signal_api_endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>Signal API Endpoint</FormLabel>
<FormControl>
<Input placeholder="https://your-signal-api.com/v2/send" {...field} />
</FormControl>
<FormDescription>
The Rest API endpoint for your Signal service
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "google_chat" && (
<FormField <FormField
control={form.control} control={form.control}
name="signal_number" name="google_chat_webhook_url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Signal Number</FormLabel> <FormLabel>Google Chat Webhook URL</FormLabel>
<FormControl> <FormControl>
<Input placeholder="+1234567890" {...field} /> <Input placeholder="https://chat.googleapis.com/v1/spaces/..." {...field} />
</FormControl> </FormControl>
<FormDescription>
Google Chat webhook URL from your Google Chat space
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)} )}
{notificationType === "email" && (
<>
<FormField
control={form.control}
name="email_address"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input placeholder="notifications@example.com" {...field} type="email" />
</FormControl>
<FormDescription>
Email address to send notifications to
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email_sender_name"
render={({ field }) => (
<FormItem>
<FormLabel>Sender Name</FormLabel>
<FormControl>
<Input placeholder="Alert System" {...field} />
</FormControl>
<FormDescription>
Display name for outgoing emails
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="smtp_server"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Server</FormLabel>
<FormControl>
<Input placeholder="smtp.gmail.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtp_port"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Port</FormLabel>
<FormControl>
<Input placeholder="587" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="smtp_password"
render={({ field }) => (
<FormItem>
<FormLabel>SMTP Password</FormLabel>
<FormControl>
<Input placeholder="Enter your SMTP password" {...field} type="password" />
</FormControl>
<FormDescription>
Password for authenticating with the SMTP server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "webhook" && (
<>
<FormField
control={form.control}
name="webhook_url"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input placeholder="https://api.example.com/webhook" {...field} />
</FormControl>
<FormDescription>
The URL where webhook notifications will be sent
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<FormField
control={form.control}
name="webhook_payload_template"
render={({ field }) => (
<FormItem>
<FormLabel>Payload Template (Optional)</FormLabel>
<FormDescription>
JSON template for the webhook payload. Leave empty to use default template.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Payload Templates</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="mt-4">
<h4 className="text-sm font-medium mb-2">Available Placeholders:</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div className="space-y-1">
<p className="font-medium text-muted-foreground">Server:</p>
<div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{server_name}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{cpu_usage}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{ram_usage}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{disk_usage}'}</code>
</div>
</div>
<div className="space-y-1">
<p className="font-medium text-muted-foreground">Service:</p>
<div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{service_name}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{response_time}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{url}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{uptime}'}</code>
</div>
</div>
<div className="space-y-1">
<p className="font-medium text-muted-foreground">SSL:</p>
<div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{domain}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{expiry_date}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{days_left}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{issuer}'}</code>
</div>
</div>
<div className="space-y-1">
<p className="font-medium text-muted-foreground">Common:</p>
<div className="space-y-0.5">
<code className="bg-muted px-1 rounded">${'{status}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{time}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{message}'}</code><br/>
<code className="bg-muted px-1 rounded">${'{threshold}'}</code>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</>
)}
<FormField <FormField
control={form.control} control={form.control}
@@ -319,7 +637,7 @@ export const NotificationChannelDialog = ({
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel>Enabled</FormLabel> <FormLabel>Enabled</FormLabel>
<FormDescription> <FormDescription>
Turn notifications on or off Enable or disable this notification channel
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -338,7 +656,7 @@ export const NotificationChannelDialog = ({
</Button> </Button>
<Button type="submit" disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditing ? "Update" : "Create"} {isEditing ? "Update Channel" : "Create Channel"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -346,4 +664,4 @@ export const NotificationChannelDialog = ({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };
@@ -13,9 +13,17 @@ import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { alertConfigService } from "@/services/alertConfigService"; import { alertConfigService } from "@/services/alertConfigService";
import { pb } from "@/lib/pocketbase";
interface CombinedChannel extends Partial<AlertConfiguration> {
isWebhook?: boolean;
url?: string;
method?: string;
description?: string;
}
interface NotificationChannelListProps { interface NotificationChannelListProps {
channels: AlertConfiguration[]; channels: CombinedChannel[];
onEdit: (config: AlertConfiguration) => void; onEdit: (config: AlertConfiguration) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
} }
@@ -25,25 +33,63 @@ export const NotificationChannelList = ({
onEdit, onEdit,
onDelete onDelete
}: NotificationChannelListProps) => { }: NotificationChannelListProps) => {
const toggleEnabled = async (config: AlertConfiguration) => { const toggleEnabled = async (config: CombinedChannel) => {
if (!config.id) return; if (!config.id) return;
await alertConfigService.updateAlertConfiguration(config.id, { if (config.isWebhook) {
enabled: !config.enabled // Handle webhook toggle
}); try {
const newEnabled = config.enabled ? "off" : "on";
// The parent component will refresh the list await pb.collection('webhook').update(config.id, {
onEdit(config); enabled: newEnabled
});
// Trigger refresh by calling onEdit with empty config
onEdit({} as AlertConfiguration);
} catch (error) {
console.error("Error updating webhook:", error);
}
} else {
// Handle alert config toggle
await alertConfigService.updateAlertConfiguration(config.id, {
enabled: !config.enabled
});
// The parent component will refresh the list
onEdit(config as AlertConfiguration);
}
}; };
const getChannelTypeLabel = (type: string) => { const getChannelTypeLabel = (type: string | undefined) => {
switch(type) { switch(type) {
case "telegram": return "Telegram"; case "telegram": return "Telegram";
case "discord": return "Discord"; case "discord": return "Discord";
case "slack": return "Slack"; case "slack": return "Slack";
case "signal": return "Signal"; case "signal": return "Signal";
case "google_chat": return "Google Chat";
case "email": return "Email"; case "email": return "Email";
default: return type; case "webhook": return "Webhook";
default: return type || "Unknown";
}
};
const getChannelDetails = (config: CombinedChannel) => {
if (config.isWebhook) {
return `${config.method || 'POST'} ${config.url || ''}`;
}
switch(config.notification_type) {
case "telegram":
return config.telegram_chat_id || '';
case "discord":
case "slack":
case "google_chat":
return config.discord_webhook_url || config.slack_webhook_url || config.google_chat_webhook_url || '';
case "signal":
return config.signal_number || '';
case "email":
return config.email_address || '';
default:
return '';
} }
}; };
@@ -66,6 +112,7 @@ export const NotificationChannelList = ({
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Details</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
@@ -78,11 +125,14 @@ export const NotificationChannelList = ({
<TableCell> <TableCell>
<Badge variant="outline">{getChannelTypeLabel(channel.notification_type)}</Badge> <Badge variant="outline">{getChannelTypeLabel(channel.notification_type)}</Badge>
</TableCell> </TableCell>
<TableCell className="max-w-xs truncate text-sm text-muted-foreground">
{getChannelDetails(channel)}
</TableCell>
<TableCell> <TableCell>
<Switch <Switch
checked={ checked={
typeof channel.enabled === 'string' typeof channel.enabled === 'string'
? channel.enabled === "true" ? channel.enabled === "true" || channel.enabled === "on"
: !!channel.enabled : !!channel.enabled
} }
onCheckedChange={() => toggleEnabled(channel)} onCheckedChange={() => toggleEnabled(channel)}
@@ -96,7 +146,8 @@ export const NotificationChannelList = ({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onEdit(channel)} onClick={() => onEdit(channel as AlertConfiguration)}
disabled={channel.isWebhook} // Disable edit for webhooks for now
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
@@ -119,4 +170,4 @@ export const NotificationChannelList = ({
</Table> </Table>
</div> </div>
); );
}; };
@@ -5,28 +5,47 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, Loader2 } from "lucide-react"; import { Plus, Loader2 } from "lucide-react";
import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService"; import { AlertConfiguration, alertConfigService } from "@/services/alertConfigService";
import { WebhookConfiguration, webhookService } from "@/services/webhookService";
import { NotificationChannelDialog } from "./NotificationChannelDialog"; import { NotificationChannelDialog } from "./NotificationChannelDialog";
import { NotificationChannelList } from "./NotificationChannelList"; import { NotificationChannelList } from "./NotificationChannelList";
import { pb } from "@/lib/pocketbase";
interface CombinedChannel extends Partial<AlertConfiguration> {
isWebhook?: boolean;
url?: string;
method?: string;
description?: string;
}
const NotificationSettings = () => { const NotificationSettings = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]); const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [webhookConfigs, setWebhookConfigs] = useState<WebhookConfiguration[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [currentTab, setCurrentTab] = useState<string>("all"); const [currentTab, setCurrentTab] = useState<string>("all");
const [editingConfig, setEditingConfig] = useState<AlertConfiguration | null>(null); const [editingConfig, setEditingConfig] = useState<AlertConfiguration | null>(null);
const fetchAlertConfigurations = async () => { const fetchNotificationChannels = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// Fetch alert configurations
const configs = await alertConfigService.getAlertConfigurations(); const configs = await alertConfigService.getAlertConfigurations();
setAlertConfigs(configs); setAlertConfigs(configs);
// Fetch webhooks
try {
const webhookResponse = await pb.collection('webhook').getList(1, 50);
setWebhookConfigs(webhookResponse.items as WebhookConfiguration[]);
} catch (webhookError) {
setWebhookConfigs([]);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
fetchAlertConfigurations(); fetchNotificationChannels();
}, []); }, []);
const handleAddNew = () => { const handleAddNew = () => {
@@ -40,22 +59,66 @@ const NotificationSettings = () => {
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
const success = await alertConfigService.deleteAlertConfiguration(id); // Check if it's a webhook first
if (success) { const isWebhook = webhookConfigs.find(w => w.id === id);
fetchAlertConfigurations();
if (isWebhook) {
// Handle webhook deletion
if (confirm("Are you sure you want to delete this webhook?")) {
try {
await pb.collection('webhook').delete(id);
fetchNotificationChannels();
} catch (error) {
console.error("Error deleting webhook:", error);
}
}
} else {
// Handle alert config deletion
const success = await alertConfigService.deleteAlertConfiguration(id);
if (success) {
fetchNotificationChannels();
}
} }
}; };
const handleDialogClose = (refreshList: boolean) => { const handleDialogClose = (refreshList: boolean) => {
setDialogOpen(false); setDialogOpen(false);
if (refreshList) { if (refreshList) {
fetchAlertConfigurations(); fetchNotificationChannels();
} }
}; };
const getCombinedChannels = (): CombinedChannel[] => {
const combined: CombinedChannel[] = [];
// Add alert configurations
alertConfigs.forEach(config => {
combined.push(config);
});
// Add webhooks as notification channels
webhookConfigs.forEach(webhook => {
combined.push({
id: webhook.id,
notify_name: webhook.name,
notification_type: "webhook" as const,
enabled: webhook.enabled === "on",
created: webhook.created,
updated: webhook.updated,
isWebhook: true,
url: webhook.url,
method: webhook.method,
description: webhook.description
});
});
return combined;
};
const getFilteredConfigs = () => { const getFilteredConfigs = () => {
if (currentTab === "all") return alertConfigs; const combined = getCombinedChannels();
return alertConfigs.filter(config => config.notification_type === currentTab); if (currentTab === "all") return combined;
return combined.filter(config => config.notification_type === currentTab);
}; };
return ( return (
@@ -86,7 +149,9 @@ const NotificationSettings = () => {
<TabsTrigger value="discord">Discord</TabsTrigger> <TabsTrigger value="discord">Discord</TabsTrigger>
<TabsTrigger value="slack">Slack</TabsTrigger> <TabsTrigger value="slack">Slack</TabsTrigger>
<TabsTrigger value="signal">Signal</TabsTrigger> <TabsTrigger value="signal">Signal</TabsTrigger>
<TabsTrigger value="google_chat">Google Chat</TabsTrigger>
<TabsTrigger value="email">Email</TabsTrigger> <TabsTrigger value="email">Email</TabsTrigger>
<TabsTrigger value="webhook">Webhook</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value={currentTab} className="mt-0"> <TabsContent value={currentTab} className="mt-0">
@@ -114,4 +179,4 @@ const NotificationSettings = () => {
); );
}; };
export default NotificationSettings; export default NotificationSettings;
@@ -1,24 +1,28 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod"; import * as z from "zod";
import { toast } from "sonner"; import { toast } from "sonner";
import { Bell } from "lucide-react"; import { Bell, X } from "lucide-react";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DialogFooter } from "@/components/ui/dialog"; import { DialogFooter } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { AddSSLCertificateDto } from "@/types/ssl.types"; import { AddSSLCertificateDto } from "@/types/ssl.types";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService"; import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { sslNotificationTemplateService, SslNotificationTemplate } from "@/services/sslNotificationTemplateService";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
const formSchema = z.object({ const formSchema = z.object({
domain: z.string().min(1, "Domain is required"), domain: z.string().min(1, "Domain is required"),
warning_threshold: z.coerce.number().int().min(1).max(365), warning_threshold: z.coerce.number().int().min(1).max(365),
expiry_threshold: z.coerce.number().int().min(1).max(30), expiry_threshold: z.coerce.number().int().min(1).max(30),
notification_channel: z.string().optional(), // Make it optional to allow empty string for "None" notification_channels: z.array(z.string()).optional(),
alert_template: z.string().optional(),
check_interval: z.coerce.number().int().min(1).max(30).optional() check_interval: z.coerce.number().int().min(1).max(30).optional()
}); });
@@ -35,6 +39,7 @@ export const AddSSLCertificateForm = ({
}: AddSSLCertificateFormProps) => { }: AddSSLCertificateFormProps) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]); const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [sslTemplates, setSslTemplates] = useState<SslNotificationTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@@ -43,58 +48,78 @@ export const AddSSLCertificateForm = ({
domain: "", domain: "",
warning_threshold: 30, warning_threshold: 30,
expiry_threshold: 7, expiry_threshold: 7,
notification_channel: "none", notification_channels: [],
alert_template: "none",
check_interval: 1 check_interval: 1
} }
}); });
// Fetch notification channels when form loads // Fetch notification channels and SSL templates when form loads
useEffect(() => { useEffect(() => {
const fetchNotificationChannels = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// Fetch notification channels
const configs = await alertConfigService.getAlertConfigurations(); const configs = await alertConfigService.getAlertConfigurations();
// console.log("Fetched notification channels:", configs);
// Only include enabled channels
const enabledConfigs = configs.filter(config => { const enabledConfigs = configs.filter(config => {
// Handle the possibility of enabled being a string
if (typeof config.enabled === 'string') { if (typeof config.enabled === 'string') {
return config.enabled === "true"; return config.enabled === "true";
} }
// Otherwise treat as boolean
return config.enabled === true; return config.enabled === true;
}); });
setAlertConfigs(enabledConfigs); setAlertConfigs(enabledConfigs);
// Fetch SSL notification templates
const templates = await sslNotificationTemplateService.getTemplates();
setSslTemplates(templates);
} catch (error) { } catch (error) {
// console.error("Error fetching notification channels:", error);
toast.error(t('failedToLoadCertificates')); toast.error(t('failedToLoadCertificates'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
fetchNotificationChannels(); fetchData();
}, [form, t]); }, [t]);
const handleSubmit = async (values: z.infer<typeof formSchema>) => { const handleSubmit = async (values: z.infer<typeof formSchema>) => {
try { try {
// Convert the form values to the required DTO format with required properties // Convert the form values to the required DTO format
const certData: AddSSLCertificateDto = { const certData: AddSSLCertificateDto = {
domain: values.domain, domain: values.domain,
warning_threshold: values.warning_threshold, warning_threshold: values.warning_threshold,
expiry_threshold: values.expiry_threshold, expiry_threshold: values.expiry_threshold,
notification_channel: values.notification_channel === "none" ? "" : (values.notification_channel || ""), // Convert "none" to empty string notification_channel: values.notification_channels && values.notification_channels.length > 0
? values.notification_channels.join(',')
: '',
notification_id: values.notification_channels && values.notification_channels.length > 0
? values.notification_channels.join(',')
: '',
template_id: values.alert_template && values.alert_template !== 'none' ? values.alert_template : '',
check_interval: values.check_interval check_interval: values.check_interval
}; };
await onSubmit(certData); await onSubmit(certData);
form.reset(); form.reset();
} catch (error) { } catch (error) {
// console.error("Error adding SSL certificate:", error);
toast.error(t('failedToAddCertificate')); toast.error(t('failedToAddCertificate'));
} }
}; };
const handleAddNotificationChannel = (channelId: string) => {
const currentChannels = form.getValues("notification_channels") || [];
if (!currentChannels.includes(channelId)) {
form.setValue("notification_channels", [...currentChannels, channelId]);
}
};
const handleRemoveNotificationChannel = (channelId: string) => {
const currentChannels = form.getValues("notification_channels") || [];
form.setValue("notification_channels", currentChannels.filter(id => id !== channelId));
};
const selectedChannels = form.watch("notification_channels") || [];
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
@@ -167,31 +192,60 @@ export const AddSSLCertificateForm = ({
<FormField <FormField
control={form.control} control={form.control}
name="notification_channel" name="notification_channels"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('notificationChannel')}</FormLabel> <FormLabel>{t('notificationChannel')} (Multi-select)</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "none"}> <div className="space-y-3">
<FormControl> <Select
<SelectTrigger> onValueChange={handleAddNotificationChannel}
<SelectValue placeholder={t('chooseChannel')} /> value=""
</SelectTrigger> >
</FormControl> <FormControl>
<SelectContent> <SelectTrigger>
<SelectItem value="none">{t('none')}</SelectItem> <SelectValue placeholder="Select channels to add..." />
{alertConfigs.length > 0 ? ( </SelectTrigger>
alertConfigs.map((config) => ( </FormControl>
<SelectItem key={config.id} value={config.id || ""}> <SelectContent>
{config.notify_name} ({config.notification_type}) {alertConfigs.length > 0 ? (
</SelectItem> alertConfigs
)) .filter(config => config.id && !selectedChannels.includes(config.id))
) : isLoading ? ( .map((config) => (
<SelectItem value="loading" disabled>{t('loadingChannels')}</SelectItem> <SelectItem key={config.id} value={config.id || "unknown"}>
) : ( {config.notify_name} ({config.notification_type})
<SelectItem value="none-disabled" disabled>{t('noChannelsFound')}</SelectItem> </SelectItem>
)} ))
</SelectContent> ) : isLoading ? (
</Select> <SelectItem value="loading-placeholder" disabled>{t('loadingChannels')}</SelectItem>
) : (
<SelectItem value="no-channels-placeholder" disabled>{t('noChannelsFound')}</SelectItem>
)}
</SelectContent>
</Select>
{/* Display selected channels */}
{selectedChannels.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedChannels.map((channelId) => {
const channel = alertConfigs.find(config => config.id === channelId);
return (
<Badge key={channelId} variant="secondary" className="flex items-center gap-1">
{channel ? `${channel.notify_name} (${channel.notification_type})` : channelId}
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0 ml-1"
onClick={() => handleRemoveNotificationChannel(channelId)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
);
})}
</div>
)}
</div>
<FormDescription className="flex items-center gap-1"> <FormDescription className="flex items-center gap-1">
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
{t('whereToSend')} {t('whereToSend')}
@@ -200,6 +254,41 @@ export const AddSSLCertificateForm = ({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="alert_template"
render={({ field }) => (
<FormItem>
<FormLabel>Alert Template</FormLabel>
<Select onValueChange={field.onChange} value={field.value || "none"}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose an alert template (optional)" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{sslTemplates.length > 0 ? (
sslTemplates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))
) : isLoading ? (
<SelectItem value="loading-templates" disabled>Loading templates...</SelectItem>
) : (
<SelectItem value="no-templates-found" disabled>No templates found</SelectItem>
)}
</SelectContent>
</Select>
<FormDescription>
Template for SSL certificate alert messages
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onCancel}>{t('cancel')}</Button> <Button type="button" variant="outline" onClick={onCancel}>{t('cancel')}</Button>
@@ -7,17 +7,20 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Badge } from "@/components/ui/badge";
import { SSLCertificate } from "@/types/ssl.types"; import { SSLCertificate } from "@/types/ssl.types";
import { Loader2, Bell } from "lucide-react"; import { Loader2, Bell, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService"; import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { sslNotificationTemplateService, SslNotificationTemplate } from "@/services/sslNotificationTemplateService";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
const formSchema = z.object({ const formSchema = z.object({
domain: z.string().min(1, "Domain is required"), domain: z.string().min(1, "Domain is required"),
warning_threshold: z.coerce.number().min(1, "Warning threshold must be at least 1 day"), warning_threshold: z.coerce.number().min(1, "Warning threshold must be at least 1 day"),
expiry_threshold: z.coerce.number().min(1, "Expiry threshold must be at least 1 day"), expiry_threshold: z.coerce.number().min(1, "Expiry threshold must be at least 1 day"),
notification_channel: z.string().min(1, "Notification channel is required"), notification_channels: z.array(z.string()).default([]),
alert_template: z.string().optional(),
check_interval: z.coerce.number().int().min(1).max(30).optional(), check_interval: z.coerce.number().int().min(1).max(30).optional(),
}); });
@@ -33,66 +36,138 @@ interface EditSSLCertificateFormProps {
export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPending }: EditSSLCertificateFormProps) => { export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPending }: EditSSLCertificateFormProps) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]); const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
const [sslTemplates, setSslTemplates] = useState<SslNotificationTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Parse existing notification channels from certificate data
const parseNotificationChannels = (cert: SSLCertificate): string[] => {
const channels: string[] = [];
// Check notification_id field first (multi-channel support)
if (cert.notification_id && cert.notification_id.trim()) {
channels.push(...cert.notification_id.split(',').map(id => id.trim()).filter(Boolean));
}
// Fallback to notification_channel field
else if (cert.notification_channel && cert.notification_channel.trim()) {
channels.push(...cert.notification_channel.split(',').map(id => id.trim()).filter(Boolean));
}
return channels;
};
// Get the alert template from certificate data
const getAlertTemplate = (cert: SSLCertificate): string => {
let templateId = "";
// Check template_id field first (new field for PocketBase)
if (cert.template_id && cert.template_id.trim()) {
templateId = cert.template_id;
}
// Fallback to alert_template field
else if (cert.alert_template && cert.alert_template.trim()) {
templateId = cert.alert_template;
}
return templateId || "none";
};
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
domain: certificate.domain, domain: certificate.domain,
warning_threshold: certificate.warning_threshold, warning_threshold: certificate.warning_threshold,
expiry_threshold: certificate.expiry_threshold, expiry_threshold: certificate.expiry_threshold,
notification_channel: certificate.notification_channel, notification_channels: parseNotificationChannels(certificate),
alert_template: getAlertTemplate(certificate),
check_interval: certificate.check_interval || 1, check_interval: certificate.check_interval || 1,
}, },
}); });
// Fetch notification channels when form loads const notificationChannels = form.watch("notification_channels") || [];
// Fetch notification channels and SSL templates when form loads
useEffect(() => { useEffect(() => {
const fetchNotificationChannels = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// Fetch notification channels
const configs = await alertConfigService.getAlertConfigurations(); const configs = await alertConfigService.getAlertConfigurations();
// console.log("Fetched notification channels:", configs);
// Only include enabled channels
const enabledConfigs = configs.filter(config => { const enabledConfigs = configs.filter(config => {
// Handle the possibility of enabled being a string
if (typeof config.enabled === 'string') { if (typeof config.enabled === 'string') {
return config.enabled === "true"; return config.enabled === "true";
} }
// Otherwise treat as boolean
return config.enabled === true; return config.enabled === true;
}); });
setAlertConfigs(enabledConfigs); setAlertConfigs(enabledConfigs);
// Fetch SSL notification templates
const templates = await sslNotificationTemplateService.getTemplates();
setSslTemplates(templates);
} catch (error) { } catch (error) {
// console.error("Error fetching notification channels:", error);
toast.error(t('failedToLoadCertificates')); toast.error(t('failedToLoadCertificates'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
fetchNotificationChannels(); fetchData();
}, [t]); }, [t]);
const handleSubmit = (data: FormValues) => { // Update form values when certificate data changes
useEffect(() => {
if (certificate) {
const channels = parseNotificationChannels(certificate);
const template = getAlertTemplate(certificate);
form.reset({
domain: certificate.domain,
warning_threshold: certificate.warning_threshold,
expiry_threshold: certificate.expiry_threshold,
notification_channels: channels,
alert_template: template,
check_interval: certificate.check_interval || 1,
});
}
}, [certificate, form]);
const handleChannelAdd = (channelId: string) => {
const currentChannels = form.getValues("notification_channels") || [];
if (!currentChannels.includes(channelId)) {
const newChannels = [...currentChannels, channelId];
form.setValue("notification_channels", newChannels);
}
};
const handleChannelRemove = (channelId: string) => {
const currentChannels = form.getValues("notification_channels") || [];
const newChannels = currentChannels.filter(id => id !== channelId);
form.setValue("notification_channels", newChannels);
};
const getSelectedChannelNames = () => {
return (notificationChannels || []).map(channelId => {
const config = alertConfigs.find(c => c.id === channelId);
return config ? `${config.notify_name} (${config.notification_type})` : channelId;
});
};
const handleSubmit = (data: FormValues) => {
// Merge the updated values with the original certificate // Merge the updated values with the original certificate
const updatedCertificate: SSLCertificate = { const updatedCertificate: SSLCertificate = {
...certificate, ...certificate,
...data, ...data,
// Ensure values are correctly typed as numbers
notification_channel: data.notification_channels.length > 0 ? data.notification_channels.join(',') : '',
notification_id: data.notification_channels.length > 0 ? data.notification_channels.join(',') : '',
template_id: data.alert_template && data.alert_template !== 'none' ? data.alert_template : '',
warning_threshold: Number(data.warning_threshold), warning_threshold: Number(data.warning_threshold),
expiry_threshold: Number(data.expiry_threshold), expiry_threshold: Number(data.expiry_threshold),
check_interval: data.check_interval ? Number(data.check_interval) : undefined check_interval: data.check_interval ? Number(data.check_interval) : undefined
}; };
console.log("Submitting updated certificate:", updatedCertificate);
onSubmit(updatedCertificate); onSubmit(updatedCertificate);
}; };
// For debugging
console.log("Certificate data:", certificate);
console.log("Form default values:", form.getValues());
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
@@ -106,7 +181,7 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
<Input <Input
{...field} {...field}
placeholder="example.com" placeholder="example.com"
disabled={true} // Domain shouldn't be editable disabled={true}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
@@ -188,41 +263,52 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
<FormField <FormField
control={form.control} control={form.control}
name="notification_channel" name="notification_channels"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('notificationChannel')}</FormLabel> <FormLabel>{t('notificationChannel')}</FormLabel>
<Select <FormDescription>
onValueChange={field.onChange} Select multiple notification channels for this SSL certificate
defaultValue={field.value} </FormDescription>
value={field.value}
disabled={isLoading} {/* Display selected channels as badges */}
> {notificationChannels && notificationChannels.length > 0 && (
<FormControl> <div className="flex flex-wrap gap-2 mb-2">
{getSelectedChannelNames().map((channelName, index) => (
<Badge key={notificationChannels[index]} variant="secondary" className="flex items-center gap-1">
{channelName}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => handleChannelRemove(notificationChannels[index])}
/>
</Badge>
))}
</div>
)}
<FormControl>
<Select
onValueChange={handleChannelAdd}
disabled={isLoading}
value=""
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t('chooseChannel')} /> <SelectValue placeholder={t('chooseChannel')} />
</SelectTrigger> </SelectTrigger>
</FormControl> <SelectContent>
<SelectContent> {alertConfigs
{alertConfigs.length > 0 ? ( .filter(config => config.id && !notificationChannels?.includes(config.id))
alertConfigs.map((config) => ( .map((config) => (
<SelectItem key={config.id} value={config.id || ""}> <SelectItem key={config.id} value={config.id || "unknown"}>
{config.notify_name} ({config.notification_type}) {config.notify_name} ({config.notification_type})
</SelectItem> </SelectItem>
)) ))}
) : isLoading ? ( {alertConfigs.filter(config => config.id && !notificationChannels?.includes(config.id)).length === 0 && (
<SelectItem value="loading" disabled>{t('loadingChannels')}</SelectItem> <SelectItem value="no-available-channels" disabled>No available channels</SelectItem>
) : ( )}
<> </SelectContent>
<SelectItem value="email">Email</SelectItem> </Select>
<SelectItem value="telegram">Telegram</SelectItem> </FormControl>
<SelectItem value="slack">Slack</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
<SelectItem value="none">None</SelectItem>
</>
)}
</SelectContent>
</Select>
<FormDescription className="flex items-center gap-1"> <FormDescription className="flex items-center gap-1">
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
{t('whereToSend')} {t('whereToSend')}
@@ -232,6 +318,45 @@ export const EditSSLCertificateForm = ({ certificate, onSubmit, onCancel, isPend
)} )}
/> />
<FormField
control={form.control}
name="alert_template"
render={({ field }) => (
<FormItem>
<FormLabel>Alert Template</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue placeholder={isLoading ? "Loading templates..." : "Select an alert template"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{sslTemplates?.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
{sslTemplates?.length === 0 && !isLoading && (
<SelectItem value="no-templates-available" disabled>No templates found</SelectItem>
)}
{isLoading && (
<SelectItem value="loading-templates-placeholder" disabled>Loading templates...</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Choose a template for SSL certificate alert messages
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-3 pt-4"> <div className="flex justify-end gap-3 pt-4">
<Button <Button
type="button" type="button"
@@ -45,20 +45,20 @@ export const SSLCertificateActions = ({
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onView(certificate)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); onView(certificate); }}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
{t('view')} {t('view')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handleCheck}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); handleCheck(); }}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Check Check
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(certificate)}> <DropdownMenuItem onClick={(e) => { e.stopPropagation(); onEdit(certificate); }}>
<Edit className="mr-2 h-4 w-4" /> <Edit className="mr-2 h-4 w-4" />
{t('edit')} {t('edit')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => onDelete(certificate)} onClick={(e) => { e.stopPropagation(); onDelete(certificate); }}
className="text-destructive" className="text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
@@ -67,4 +67,4 @@ export const SSLCertificateActions = ({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
}; };
@@ -29,6 +29,21 @@ export const SSLCertificateDetailDialog = ({
if (!certificate) return null; if (!certificate) return null;
// Parse Subject Alternative Names for better display
const formatSANs = (sans: string): string[] => {
if (!sans) return [];
// Split by common delimiters and clean up
const sansList = sans
.split(/[,;\n]/)
.map(san => san.trim())
.filter(san => san.length > 0);
return sansList;
};
const sansList = certificate.cert_sans ? formatSANs(certificate.cert_sans) : [];
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
@@ -135,7 +150,17 @@ export const SSLCertificateDetailDialog = ({
<div className="mt-4"> <div className="mt-4">
<p className="text-sm font-medium text-muted-foreground mb-2">{t('subjectAlternativeNames')}</p> <p className="text-sm font-medium text-muted-foreground mb-2">{t('subjectAlternativeNames')}</p>
<div className="bg-muted px-3 py-2 rounded"> <div className="bg-muted px-3 py-2 rounded">
<p className="text-sm whitespace-pre-wrap">{certificate.cert_sans}</p> {sansList.length > 0 ? (
<div className="space-y-1">
{sansList.map((san, index) => (
<div key={index} className="text-sm font-mono break-all">
{san}
</div>
))}
</div>
) : (
<p className="text-sm">N/A</p>
)}
</div> </div>
</div> </div>
)} )}
@@ -33,11 +33,13 @@ import { AddSSLCertificateForm } from "./AddSSLCertificateForm";
import { EditSSLCertificateForm } from "./EditSSLCertificateForm"; import { EditSSLCertificateForm } from "./EditSSLCertificateForm";
import { SSLCertificateActions } from "./SSLCertificateActions"; import { SSLCertificateActions } from "./SSLCertificateActions";
import { SSLCertificateDetailDialog } from "./SSLCertificateDetailDialog"; import { SSLCertificateDetailDialog } from "./SSLCertificateDetailDialog";
import { SSLPagination } from "./SSLPagination";
import { fetchSSLCertificates, addSSLCertificate, deleteSSLCertificate } from "@/services/sslCertificateService"; import { fetchSSLCertificates, addSSLCertificate, deleteSSLCertificate } from "@/services/sslCertificateService";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { SSLCertificate } from "@/types/ssl.types"; import { SSLCertificate } from "@/types/ssl.types";
import { useLanguage } from "@/contexts/LanguageContext"; import { useLanguage } from "@/contexts/LanguageContext";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useSSLPagination } from "@/hooks/useSSLPagination";
import { toast } from "sonner"; import { toast } from "sonner";
export const SSLCertificatesTable = () => { export const SSLCertificatesTable = () => {
@@ -56,6 +58,16 @@ export const SSLCertificatesTable = () => {
queryFn: fetchSSLCertificates, queryFn: fetchSSLCertificates,
}); });
const {
paginatedCertificates,
currentPage,
totalPages,
pageSize,
totalItems,
handlePageChange,
handlePageSizeChange,
} = useSSLPagination({ certificates });
if (isLoading) return <div>Loading...</div>; if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error loading certificates</div>; if (isError) return <div>Error loading certificates</div>;
@@ -67,7 +79,6 @@ export const SSLCertificatesTable = () => {
setShowAddDialog(false); setShowAddDialog(false);
toast.success(t('certificateAdded')); toast.success(t('certificateAdded'));
} catch (error) { } catch (error) {
// console.error("Error adding certificate:", error);
toast.error(t('failedToAddCertificate')); toast.error(t('failedToAddCertificate'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -89,7 +100,6 @@ export const SSLCertificatesTable = () => {
setSelectedCertificate(null); setSelectedCertificate(null);
toast.success(t('certificateUpdated')); toast.success(t('certificateUpdated'));
} catch (error) { } catch (error) {
// console.error("Error updating certificate:", error);
toast.error(t('failedToUpdateCertificate')); toast.error(t('failedToUpdateCertificate'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -107,7 +117,6 @@ export const SSLCertificatesTable = () => {
setSelectedCertificate(null); setSelectedCertificate(null);
toast.success(t('certificateDeleted')); toast.success(t('certificateDeleted'));
} catch (error) { } catch (error) {
// console.error("Error deleting certificate:", error);
toast.error(t('failedToDeleteCertificate')); toast.error(t('failedToDeleteCertificate'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -136,53 +145,68 @@ export const SSLCertificatesTable = () => {
{t('noCertificatesFound')} {t('noCertificatesFound')}
</div> </div>
) : ( ) : (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}> <>
<Table> <div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg border border-border shadow-sm`}>
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}> <Table>
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}> <TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'}`}>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('domain')}</TableHead> <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`}>{t('status')}</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('domain')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('issuer')}</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('validUntil')}</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('issuer')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('daysLeft')}</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('validUntil')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Check Interval</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t('daysLeft')}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right w-[50px]`}>Actions</TableHead> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>Check Interval</TableHead>
</TableRow> <TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4 text-right w-[50px]`}>Actions</TableHead>
</TableHeader>
<TableBody>
{certificates.map((certificate) => (
<TableRow key={certificate.id} className="hover:bg-muted/50">
<TableCell className="font-medium">
{certificate.domain}
</TableCell>
<TableCell>
<SSLStatusBadge status={certificate.status} />
</TableCell>
<TableCell>{certificate.issuer_o || certificate.issuer_cn || 'Unknown'}</TableCell>
<TableCell>
{certificate.valid_till ? new Date(certificate.valid_till).toLocaleDateString() : 'N/A'}
</TableCell>
<TableCell>
<span className={certificate.days_left <= 7 ? 'text-red-600 font-semibold' : certificate.days_left <= 30 ? 'text-yellow-600 font-semibold' : 'text-green-600'}>
{certificate.days_left} {t('days')}
</span>
</TableCell>
<TableCell>
{certificate.check_interval || 1} {t('days')}
</TableCell>
<TableCell className="text-right">
<SSLCertificateActions
certificate={certificate}
onView={openViewDialog}
onEdit={openEditDialog}
onDelete={openDeleteDialog}
/>
</TableCell>
</TableRow> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {paginatedCertificates.map((certificate) => (
</div> <TableRow
key={certificate.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => openViewDialog(certificate)}
>
<TableCell className="font-medium">
{certificate.domain}
</TableCell>
<TableCell>
<SSLStatusBadge status={certificate.status} />
</TableCell>
<TableCell>{certificate.issuer_o || certificate.issuer_cn || 'Unknown'}</TableCell>
<TableCell>
{certificate.valid_till ? new Date(certificate.valid_till).toLocaleDateString() : 'N/A'}
</TableCell>
<TableCell>
<span className={certificate.days_left <= 7 ? 'text-red-600 font-semibold' : certificate.days_left <= 30 ? 'text-yellow-600 font-semibold' : 'text-green-600'}>
{certificate.days_left} {t('days')}
</span>
</TableCell>
<TableCell>
{certificate.check_interval || 1} {t('days')}
</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<SSLCertificateActions
certificate={certificate}
onView={openViewDialog}
onEdit={openEditDialog}
onDelete={openDeleteDialog}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<SSLPagination
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
totalItems={totalItems}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
</>
)} )}
{/* View Certificate Dialog */} {/* View Certificate Dialog */}
@@ -1,4 +1,3 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -30,12 +29,9 @@ export const SSLDomainContent = () => {
queryKey: ['ssl-certificates'], queryKey: ['ssl-certificates'],
queryFn: async () => { queryFn: async () => {
try { try {
console.log("Fetching SSL certificates from SSLDomainContent...");
const result = await fetchSSLCertificates(); const result = await fetchSSLCertificates();
console.log("Received SSL certificates:", result);
return result; return result;
} catch (error) { } catch (error) {
console.error("Error fetching certificates:", error);
toast.error(t('failedToLoadCertificates')); toast.error(t('failedToLoadCertificates'));
throw error; throw error;
} }
@@ -53,30 +49,28 @@ export const SSLDomainContent = () => {
toast.success(t('sslCertificateAdded')); toast.success(t('sslCertificateAdded'));
}, },
onError: (error) => { onError: (error) => {
console.error("Error adding SSL certificate:", error);
toast.error(error instanceof Error ? error.message : t('failedToAddCertificate')); toast.error(error instanceof Error ? error.message : t('failedToAddCertificate'));
} }
}); });
// Edit certificate mutation - Updated to ensure thresholds are properly updated // Edit certificate mutation - Updated to include notification_id and template_id
const editMutation = useMutation({ const editMutation = useMutation({
mutationFn: async (certificate: SSLCertificate) => { mutationFn: async (certificate: SSLCertificate) => {
console.log("Updating certificate with data:", certificate);
// Create the update data object // Create the update data object with new fields
const updateData = { const updateData = {
warning_threshold: Number(certificate.warning_threshold), warning_threshold: Number(certificate.warning_threshold),
expiry_threshold: Number(certificate.expiry_threshold), expiry_threshold: Number(certificate.expiry_threshold),
notification_channel: certificate.notification_channel, notification_channel: certificate.notification_channel,
notification_id: certificate.notification_id || '', // Multi notification channels
template_id: certificate.template_id || '', // Alert template ID
check_interval: certificate.check_interval,
}; };
console.log("Update data to be sent:", updateData);
// Update certificate in the database using PocketBase directly // Update certificate in the database using PocketBase directly
const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData); const updated = await pb.collection('ssl_certificates').update(certificate.id, updateData);
console.log("PocketBase update response:", updated);
// After updating the settings, refresh the certificate to ensure it's up to date // After updating the settings, refresh the certificate to ensure it's up to date
// This will also check if notification needs to be sent based on updated thresholds // This will also check if notification needs to be sent based on updated thresholds
const refreshedCert = await checkAndUpdateCertificate(certificate.id); const refreshedCert = await checkAndUpdateCertificate(certificate.id);
@@ -90,7 +84,6 @@ export const SSLDomainContent = () => {
toast.success(t('sslCertificateUpdated')); toast.success(t('sslCertificateUpdated'));
}, },
onError: (error) => { onError: (error) => {
console.error("Error updating SSL certificate:", error);
toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate')); toast.error(error instanceof Error ? error.message : t('failedToUpdateCertificate'));
} }
}); });
@@ -103,7 +96,6 @@ export const SSLDomainContent = () => {
toast.success(t('sslCertificateDeleted')); toast.success(t('sslCertificateDeleted'));
}, },
onError: (error) => { onError: (error) => {
console.error("Error deleting SSL certificate:", error);
toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate')); toast.error(error instanceof Error ? error.message : t('failedToDeleteCertificate'));
} }
}); });
@@ -117,7 +109,6 @@ export const SSLDomainContent = () => {
// Removed individual success toast notification // Removed individual success toast notification
}, },
onError: (error) => { onError: (error) => {
console.error("Error refreshing SSL certificate:", error);
setRefreshingId(null); setRefreshingId(null);
// Still refresh the data to show any partial information that was saved // Still refresh the data to show any partial information that was saved
@@ -142,7 +133,6 @@ export const SSLDomainContent = () => {
} }
}, },
onError: (error) => { onError: (error) => {
console.error("Error refreshing all certificates:", error);
toast.error(t('failedToCheckCertificate')); toast.error(t('failedToCheckCertificate'));
setIsRefreshingAll(false); setIsRefreshingAll(false);
@@ -167,7 +157,6 @@ export const SSLDomainContent = () => {
}; };
const handleUpdateCertificate = (certificate: SSLCertificate) => { const handleUpdateCertificate = (certificate: SSLCertificate) => {
console.log("Handling certificate update with data:", certificate);
editMutation.mutate(certificate); editMutation.mutate(certificate);
}; };
@@ -0,0 +1,131 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { SSLPageSize } from "@/hooks/useSSLPagination";
import { useLanguage } from "@/contexts/LanguageContext";
interface SSLPaginationProps {
currentPage: number;
totalPages: number;
pageSize: SSLPageSize;
totalItems: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: SSLPageSize) => void;
}
export function SSLPagination({
currentPage,
totalPages,
pageSize,
totalItems,
onPageChange,
onPageSizeChange,
}: SSLPaginationProps) {
const { t } = useLanguage();
// Generate page numbers to display
const getPageNumbers = () => {
const pages = [];
const maxVisiblePages = 5;
const halfVisible = Math.floor(maxVisiblePages / 2);
let startPage = Math.max(1, currentPage - halfVisible);
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
// Adjust start page if we don't have enough pages at the end
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
const startItem = Math.min((currentPage - 1) * pageSize + 1, totalItems);
const endItem = Math.min(currentPage * pageSize, totalItems);
return (
<div className="flex items-center justify-between py-4 px-4 border-t border-border">
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">
{t("rowsPerPage") || "Rows per page"}:
</span>
<Select
value={pageSize.toString()}
onValueChange={(value) => onPageSizeChange(parseInt(value) as SSLPageSize)}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="30">30</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{totalItems > 0
? `${startItem}-${endItem} of ${totalItems} ${t("certificates") || "certificates"}`
: `0 ${t("certificates") || "certificates"}`
}
</span>
</div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
className={
currentPage === 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
{getPageNumbers().map((page) => (
<PaginationItem key={page}>
<PaginationLink
isActive={page === currentPage}
onClick={() => onPageChange(page)}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
className={
currentPage === totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useState, useMemo } from 'react';
import { SSLCertificate } from '@/types/ssl.types';
export type SSLPageSize = 10 | 30 | 50;
interface UseSSLPaginationProps {
certificates: SSLCertificate[];
initialPageSize?: SSLPageSize;
}
export const useSSLPagination = ({
certificates,
initialPageSize = 10
}: UseSSLPaginationProps) => {
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState<SSLPageSize>(initialPageSize);
const { paginatedCertificates, totalPages } = useMemo(() => {
const totalItems = certificates.length;
const pages = Math.ceil(totalItems / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return {
paginatedCertificates: certificates.slice(startIndex, endIndex),
totalPages: Math.max(1, pages)
};
}, [certificates, currentPage, pageSize]);
// Reset to first page when page size changes or certificates change
const handlePageSizeChange = (newPageSize: SSLPageSize) => {
setPageSize(newPageSize);
setCurrentPage(1);
};
// Reset to first page if current page exceeds total pages
const handlePageChange = (page: number) => {
const validPage = Math.min(Math.max(1, page), totalPages);
setCurrentPage(validPage);
};
return {
paginatedCertificates,
currentPage,
totalPages,
pageSize,
totalItems: certificates.length,
handlePageChange,
handlePageSizeChange,
};
};
+55 -85
View File
@@ -1,18 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { authService } from '@/services/authService'; import { authService } from '@/services/authService';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { toast } from '@/components/ui/use-toast'; import { toast } from '@/components/ui/use-toast';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Eye, EyeOff, Mail, LogIn, Settings, AlertCircle } from "lucide-react"; import { Eye, EyeOff, Mail, LogIn, AlertCircle, Github } from "lucide-react";
import { useLanguage } from '@/contexts/LanguageContext'; import { useLanguage } from '@/contexts/LanguageContext';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { API_ENDPOINTS, getCurrentEndpoint, setApiEndpoint } from '@/lib/pocketbase';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import { ForgotPasswordDialog } from '@/components/auth/ForgotPasswordDialog'; import { ForgotPasswordDialog } from '@/components/auth/ForgotPasswordDialog';
const Login = () => { const Login = () => {
@@ -20,30 +16,13 @@ const Login = () => {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [currentEndpoint, setCurrentEndpoint] = useState(getCurrentEndpoint());
const [showForgotPassword, setShowForgotPassword] = useState(false); const [showForgotPassword, setShowForgotPassword] = useState(false);
const [loginError, setLoginError] = useState(''); const [loginError, setLoginError] = useState('');
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useLanguage(); const { t } = useLanguage();
const { theme } = useTheme(); const { theme } = useTheme();
// Add responsiveness check const handleSubmit = useCallback(async (e: React.FormEvent) => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkScreenSize = () => {
setIsMobile(window.innerWidth < 640);
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => {
window.removeEventListener('resize', checkScreenSize);
};
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setLoginError(''); // Clear previous errors setLoginError(''); // Clear previous errors
@@ -69,7 +48,7 @@ const Login = () => {
setLoginError(error.message); setLoginError(error.message);
} }
} else { } else {
setLoginError(`${t("authenticationFailed")}. Server: ${currentEndpoint}`); setLoginError(t("authenticationFailed"));
} }
toast({ toast({
@@ -82,60 +61,35 @@ const Login = () => {
error.message.includes('invalid email or password')) error.message.includes('invalid email or password'))
? t("invalidCredentials") ? t("invalidCredentials")
: error.message : error.message
: `${t("authenticationFailed")}. Server: ${currentEndpoint}`, : t("authenticationFailed"),
}); });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [email, password, navigate, t]);
const handleEndpointChange = (value: string) => { const togglePasswordVisibility = useCallback(() => {
setCurrentEndpoint(value); setShowPassword(prev => !prev);
setApiEndpoint(value); }, []);
};
const togglePasswordVisibility = () => { const handleEmailChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setShowPassword(!showPassword); setEmail(e.target.value);
}; setLoginError(''); // Clear error when user starts typing
}, []);
const handlePasswordChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
setLoginError(''); // Clear error when user starts typing
}, []);
const openExternalLink = useCallback((url: string) => {
window.open(url, '_blank');
}, []);
return ( return (
<div className="flex items-center justify-center min-h-screen bg-background p-4"> <div className="flex flex-col items-center justify-center min-h-screen bg-background p-4">
<div className="w-full max-w-md p-4 sm:p-8 space-y-6 rounded-xl bg-card shadow-xl border border-border/20"> <div className="w-full max-w-md p-4 sm:p-8 space-y-6 rounded-xl bg-card shadow-xl border border-border/20">
<div className="text-center relative"> <div className="text-center">
{/* Commented out API Endpoint Settings button
<div className="absolute right-0 top-0">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" aria-label="API Settings">
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[260px] sm:w-80">
<div className="space-y-4">
<h4 className="font-medium">API Endpoint Settings</h4>
<RadioGroup
value={currentEndpoint}
onValueChange={handleEndpointChange}
className="gap-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value={API_ENDPOINTS.LOCAL} id="local" />
<Label htmlFor="local" className="truncate text-sm">Local: {API_ENDPOINTS.LOCAL}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={API_ENDPOINTS.REMOTE} id="remote" />
<Label htmlFor="remote" className="truncate text-sm">Remote: {API_ENDPOINTS.REMOTE}</Label>
</div>
</RadioGroup>
<div className="text-xs text-muted-foreground truncate">
Current endpoint: {currentEndpoint}
</div>
</div>
</PopoverContent>
</Popover>
</div>
*/}
{/* Logo */} {/* Logo */}
<div className="mb-4"> <div className="mb-4">
<img <img
@@ -146,11 +100,8 @@ const Login = () => {
</div> </div>
<h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">{t("signInToYourAccount")}</h1> <h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">{t("signInToYourAccount")}</h1>
{/* Removed "Don't have an account? Create one" text */}
</div> </div>
{/* Removed Google Sign in button section */}
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6"> <form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
{/* Error Alert */} {/* Error Alert */}
{loginError && ( {loginError && (
@@ -171,10 +122,7 @@ const Login = () => {
placeholder="your.email@provider.com" placeholder="your.email@provider.com"
type="email" type="email"
value={email} value={email}
onChange={(e) => { onChange={handleEmailChange}
setEmail(e.target.value);
setLoginError(''); // Clear error when user starts typing
}}
required required
className="pl-10 text-sm sm:text-base h-9 sm:h-10" className="pl-10 text-sm sm:text-base h-9 sm:h-10"
/> />
@@ -204,10 +152,7 @@ const Login = () => {
placeholder="••••••••••••" placeholder="••••••••••••"
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => { onChange={handlePasswordChange}
setPassword(e.target.value);
setLoginError(''); // Clear error when user starts typing
}}
required required
className="pl-10 text-sm sm:text-base h-9 sm:h-10" className="pl-10 text-sm sm:text-base h-9 sm:h-10"
/> />
@@ -234,13 +179,38 @@ const Login = () => {
{loading ? t("signingIn") : t("signIn")} {loading ? t("signingIn") : t("signIn")}
{!loading && <LogIn className="ml-2 h-4 w-4" />} {!loading && <LogIn className="ml-2 h-4 w-4" />}
</Button> </Button>
<p className="text-xxs sm:text-xs text-center text-muted-foreground">
{t("bySigningIn")} <a href="#" className="text-emerald-500">{t("termsAndConditions")}</a> {t("and")} <a href="#" className="text-emerald-500">{t("privacyPolicy")}</a>.
</p>
</form> </form>
</div> </div>
{/* Footer with Social Media Icons */}
<div className="mt-8 flex items-center justify-center space-x-6">
<button
onClick={() => openExternalLink('https://github.com/operacle/checkcle')}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="GitHub"
>
<Github className="h-5 w-5" />
</button>
<button
onClick={() => openExternalLink('https://x.com/checkcle_oss')}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="X (Twitter)"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
<button
onClick={() => openExternalLink('https://discord.gg/xs9gbubGwX')}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Discord"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.010c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</button>
</div>
<ForgotPasswordDialog <ForgotPasswordDialog
open={showForgotPassword} open={showForgotPassword}
onOpenChange={setShowForgotPassword} onOpenChange={setShowForgotPassword}
+76 -20
View File
@@ -7,28 +7,39 @@ export interface AlertConfiguration {
collectionId?: string; collectionId?: string;
collectionName?: string; collectionName?: string;
service_id: string; service_id: string;
notification_type: "telegram" | "discord" | "signal" | "slack" | "email"; notification_type: "telegram" | "discord" | "slack" | "signal" | "google_chat" | "email" | "webhook";
telegram_chat_id?: string; telegram_chat_id?: string;
discord_webhook_url?: string; discord_webhook_url?: string;
signal_number?: string; signal_number?: string;
signal_api_endpoint?: string;
notify_name: string; notify_name: string;
bot_token?: string; bot_token?: string;
template_id?: string; template_id?: string;
slack_webhook_url?: string; slack_webhook_url?: string;
google_chat_webhook_url?: string;
enabled: boolean; enabled: boolean;
created?: string; created?: string;
updated?: string; updated?: string;
// Email specific fields
email_address?: string;
email_sender_name?: string;
smtp_server?: string;
smtp_port?: string;
smtp_password?: string;
webhook_id?: string;
channel_id?: string;
// Webhook fields for alert_configurations
webhook_url?: string;
webhook_payload_template?: string;
} }
export const alertConfigService = { export const alertConfigService = {
async getAlertConfigurations(): Promise<AlertConfiguration[]> { async getAlertConfigurations(): Promise<AlertConfiguration[]> {
// console.info("Fetching alert configurations");
try { try {
const response = await pb.collection('alert_configurations').getList(1, 50); const response = await pb.collection('alert_configurations').getList(1, 50);
// console.info("Alert configurations response:", response);
return response.items as AlertConfiguration[]; return response.items as AlertConfiguration[];
} catch (error) { } catch (error) {
// console.error("Error fetching alert configurations:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to load notification settings", description: "Failed to load notification settings",
@@ -39,20 +50,61 @@ export const alertConfigService = {
}, },
async createAlertConfiguration(config: Omit<AlertConfiguration, 'id' | 'collectionId' | 'collectionName' | 'created' | 'updated'>): Promise<AlertConfiguration | null> { async createAlertConfiguration(config: Omit<AlertConfiguration, 'id' | 'collectionId' | 'collectionName' | 'created' | 'updated'>): Promise<AlertConfiguration | null> {
// console.info("Creating alert configuration:", config);
try { try {
const result = await pb.collection('alert_configurations').create(config); // Build the configuration object with proper field mapping
// console.info("Alert configuration created:", result); const cleanConfig: any = {
service_id: config.service_id || "global",
notification_type: config.notification_type,
notify_name: config.notify_name,
enabled: config.enabled,
template_id: config.template_id || "",
};
// Add type-specific fields based on notification type
if (config.notification_type === "telegram") {
cleanConfig.telegram_chat_id = config.telegram_chat_id || "";
cleanConfig.bot_token = config.bot_token || "";
} else if (config.notification_type === "discord") {
cleanConfig.discord_webhook_url = config.discord_webhook_url || "";
} else if (config.notification_type === "slack") {
cleanConfig.slack_webhook_url = config.slack_webhook_url || "";
} else if (config.notification_type === "signal") {
cleanConfig.signal_number = config.signal_number || "";
cleanConfig.signal_api_endpoint = config.signal_api_endpoint || "";
} else if (config.notification_type === "google_chat") {
cleanConfig.google_chat_webhook_url = config.google_chat_webhook_url || "";
} else if (config.notification_type === "email") {
cleanConfig.email_address = config.email_address || "";
cleanConfig.email_sender_name = config.email_sender_name || "";
cleanConfig.smtp_server = config.smtp_server || "";
cleanConfig.smtp_port = config.smtp_port || "";
cleanConfig.smtp_password = config.smtp_password || "";
} else if (config.notification_type === "webhook") {
cleanConfig.webhook_url = config.webhook_url || "";
cleanConfig.webhook_payload_template = config.webhook_payload_template || "";
}
const result = await pb.collection('alert_configurations').create(cleanConfig);
toast({ toast({
title: "Success", title: "Success",
description: "Notification settings saved successfully", description: "Notification channel created successfully",
}); });
return result as AlertConfiguration; return result as AlertConfiguration;
} catch (error) { } catch (error) {
// console.error("Error creating alert configuration:", error);
// Try to get more details from the error
if (error && typeof error === 'object') {
}
toast({ toast({
title: "Error", title: "Error",
description: "Failed to save notification settings", description: "Failed to create notification channel",
variant: "destructive" variant: "destructive"
}); });
return null; return null;
@@ -60,20 +112,27 @@ export const alertConfigService = {
}, },
async updateAlertConfiguration(id: string, config: Partial<AlertConfiguration>): Promise<AlertConfiguration | null> { async updateAlertConfiguration(id: string, config: Partial<AlertConfiguration>): Promise<AlertConfiguration | null> {
// console.info(`Updating alert configuration ${id}:`, config);
try { try {
const result = await pb.collection('alert_configurations').update(id, config); // Build the update config with proper field mapping
// console.info("Alert configuration updated:", result); const updateConfig: any = {};
// Copy all provided fields
Object.keys(config).forEach(key => {
if (config[key as keyof AlertConfiguration] !== undefined) {
updateConfig[key] = config[key as keyof AlertConfiguration];
}
});
const result = await pb.collection('alert_configurations').update(id, updateConfig);
toast({ toast({
title: "Success", title: "Success",
description: "Notification settings updated successfully", description: "Notification channel updated successfully",
}); });
return result as AlertConfiguration; return result as AlertConfiguration;
} catch (error) { } catch (error) {
// console.error("Error updating alert configuration:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to update notification settings", description: "Failed to update notification channel",
variant: "destructive" variant: "destructive"
}); });
return null; return null;
@@ -81,17 +140,14 @@ export const alertConfigService = {
}, },
async deleteAlertConfiguration(id: string): Promise<boolean> { async deleteAlertConfiguration(id: string): Promise<boolean> {
// console.info(`Deleting alert configuration ${id}`);
try { try {
await pb.collection('alert_configurations').delete(id); await pb.collection('alert_configurations').delete(id);
// console.info("Alert configuration deleted");
toast({ toast({
title: "Success", title: "Success",
description: "Notification channel removed", description: "Notification channel removed",
}); });
return true; return true;
} catch (error) { } catch (error) {
// console.error("Error deleting alert configuration:", error);
toast({ toast({
title: "Error", title: "Error",
description: "Failed to remove notification channel", description: "Failed to remove notification channel",
@@ -100,4 +156,4 @@ export const alertConfigService = {
return false; return false;
} }
} }
}; };
@@ -1,7 +1,6 @@
import { pb } from '@/lib/pocketbase'; import { pb } from '@/lib/pocketbase';
import { uptimeService } from '@/services/uptimeService'; import { uptimeService } from '@/services/uptimeService';
import { notificationService } from '@/services/notification'; // Import from the main notification service
import { prepareServiceForNotification } from '../utils/httpUtils'; import { prepareServiceForNotification } from '../utils/httpUtils';
import { UptimeData } from '@/types/service.types'; import { UptimeData } from '@/types/service.types';
@@ -9,7 +8,7 @@ import { UptimeData } from '@/types/service.types';
* Handle a service that is determined to be UP * Handle a service that is determined to be UP
*/ */
export async function handleServiceUp(service: any, responseTime: number, formattedTime: string): Promise<void> { export async function handleServiceUp(service: any, responseTime: number, formattedTime: string): Promise<void> {
console.log(`Service ${service.name} is UP! Response time: ${responseTime}ms`); // console.log(`Service ${service.name} is UP! Response time: ${responseTime}ms`);
// Create a history record of this check with a more accurate timestamp // Create a history record of this check with a more accurate timestamp
const uptimeData: UptimeData = { const uptimeData: UptimeData = {
@@ -41,47 +40,18 @@ export async function handleServiceUp(service: any, responseTime: number, format
try { try {
await uptimeService.recordUptimeData(uptimeData); await uptimeService.recordUptimeData(uptimeData);
} catch (error) { } catch (error) {
console.error("Failed to record uptime data on first try, retrying...", error); // console.error("Failed to record uptime data on first try, retrying...", error);
// Wait a short time and retry once // Wait a short time and retry once
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
await uptimeService.recordUptimeData(uptimeData); await uptimeService.recordUptimeData(uptimeData);
} }
// Reset notification count if service is recovered from DOWN status // Status change logging (notification logic removed - will be handled by backend)
if (previousStatus === "down") {
console.log(`Service ${service.name} recovered from DOWN status - resetting notification count`);
notificationService.resetNotificationCount(service.id);
}
// Send notification if status changed from down to up
if (statusChanged) { if (statusChanged) {
console.log(`Status changed from ${previousStatus} to UP - sending notification`); // console.log(`Status changed from ${previousStatus} to UP - notification will be handled by backend`);
// Convert PocketBase record to Service type for notification
const serviceForNotification = prepareServiceForNotification(service, "up", responseTime);
// Check if alerts are muted - STRICT check for "muted" string value
if (service.alerts === "muted" || serviceForNotification.alerts === "muted") {
console.log(`Alerts are MUTED for service ${service.name}, SKIPPING UP notification`);
return;
}
try {
// Send notification through main notification service
await notificationService.sendNotification({
service: serviceForNotification,
status: "up",
responseTime: responseTime,
timestamp: new Date().toISOString()
});
console.log("UP notification sent successfully");
} catch (error) {
console.error("Error sending UP notification:", error);
}
} }
} catch (error) { } catch (error) {
console.error("Error handling service UP state:", error); // console.error("Error handling service UP state:", error);
} }
} }
@@ -89,7 +59,7 @@ export async function handleServiceUp(service: any, responseTime: number, format
* Handle a service that is determined to be DOWN * Handle a service that is determined to be DOWN
*/ */
export async function handleServiceDown(service: any, formattedTime: string): Promise<void> { export async function handleServiceDown(service: any, formattedTime: string): Promise<void> {
console.log(`Service ${service.name} is DOWN!`); // console.log(`Service ${service.name} is DOWN!`);
// Create a history record of this check // Create a history record of this check
const uptimeData: UptimeData = { const uptimeData: UptimeData = {
@@ -106,7 +76,7 @@ export async function handleServiceDown(service: any, formattedTime: string): Pr
const previousStatus = service.status; const previousStatus = service.status;
const statusChanged = previousStatus !== "down"; const statusChanged = previousStatus !== "down";
console.log(`Service ${service.name} previous status: ${previousStatus}, statusChanged: ${statusChanged}`); // console.log(`Service ${service.name} previous status: ${previousStatus}, statusChanged: ${statusChanged}`);
try { try {
// Update service status // Update service status
@@ -123,44 +93,15 @@ export async function handleServiceDown(service: any, formattedTime: string): Pr
try { try {
await uptimeService.recordUptimeData(uptimeData); await uptimeService.recordUptimeData(uptimeData);
} catch (error) { } catch (error) {
console.error("Failed to record uptime data on first try, retrying...", error); // console.error("Failed to record uptime data on first try, retrying...", error);
// Wait a short time and retry once // Wait a short time and retry once
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
await uptimeService.recordUptimeData(uptimeData); await uptimeService.recordUptimeData(uptimeData);
} }
// Convert PocketBase record to Service type for notification // Status change logging (notification logic removed - will be handled by backend)
const serviceForNotification = prepareServiceForNotification(service, "down"); // console.log("Service DOWN status recorded - notification will be handled by backend");
// Check if alerts are muted - STRICT check for "muted" string value
if (service.alerts === "muted" || serviceForNotification.alerts === "muted") {
console.log(`Alerts are MUTED for service ${service.name}, SKIPPING DOWN notification`);
return;
}
console.log("Attempting to send DOWN notification for service:", service.name);
console.log("Service notification data:", {
name: serviceForNotification.name,
notificationChannel: serviceForNotification.notificationChannel,
alertTemplate: serviceForNotification.alertTemplate,
retries: serviceForNotification.retries || 3,
alerts: serviceForNotification.alerts
});
try {
// Use the main notification service - this handles retries internally
const result = await notificationService.sendNotification({
service: serviceForNotification,
status: "down",
responseTime: 0,
timestamp: new Date().toISOString()
});
console.log("DOWN notification sent result:", result);
} catch (error) {
console.error("Error sending DOWN notification:", error);
}
} catch (error) { } catch (error) {
console.error("Error handling service DOWN state:", error); // console.error("Error handling service DOWN state:", error);
} }
} }
@@ -13,7 +13,7 @@ export async function pauseMonitoring(serviceId: string): Promise<void> {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
monitoringIntervals.delete(serviceId); monitoringIntervals.delete(serviceId);
console.log(`Monitoring paused for service ${serviceId}`); // console.log(`Monitoring paused for service ${serviceId}`);
} }
// Get current timestamp formatted as a string // Get current timestamp formatted as a string
@@ -1,7 +1,6 @@
import { pb } from '@/lib/pocketbase'; import { pb } from '@/lib/pocketbase';
import { monitoringIntervals } from '../monitoringIntervals'; import { monitoringIntervals } from '../monitoringIntervals';
import { notificationService } from '@/services/notificationService';
import { Service } from '@/types/service.types'; import { Service } from '@/types/service.types';
import { startMonitoringService } from './startMonitoring'; import { startMonitoringService } from './startMonitoring';
@@ -16,7 +15,7 @@ export async function resumeMonitoring(serviceId: string): Promise<void> {
// Fetch the current service to get its name for better logging // Fetch the current service to get its name for better logging
const service = await pb.collection('services').getOne(serviceId); const service = await pb.collection('services').getOne(serviceId);
// console.log(`Resuming service ${service.name} at ${now}`); // console.log(`Resuming service ${service.name} at ${now}`);
// First, clear any existing interval just to be safe // First, clear any existing interval just to be safe
const existingInterval = monitoringIntervals.get(serviceId); const existingInterval = monitoringIntervals.get(serviceId);
@@ -32,50 +31,14 @@ export async function resumeMonitoring(serviceId: string): Promise<void> {
last_checked: now last_checked: now
}); });
// Convert PocketBase record to Service type for notification
const serviceForNotification: Service = {
id: service.id,
name: service.name,
url: service.url || "",
host: service.host || "", // Include host property
type: service.service_type || service.type || "HTTP",
status: "up",
responseTime: service.response_time || 0,
uptime: service.uptime || 0,
lastChecked: now,
interval: service.heartbeat_interval || 60,
retries: service.max_retries || 3,
notificationChannel: service.notification_id,
alertTemplate: service.template_id,
muteAlerts: service.mute_alerts || false,
alerts: service.alerts || "unmuted",
muteChangedAt: service.mute_changed_at,
};
// Double check if alerts are muted before sending notification
// Ensure we're using the correct field - alerts should be "muted" or "unmuted"
const alertsMuted = service.alerts === "muted" || serviceForNotification.alerts === "muted";
if (!alertsMuted) {
// console.log(`Alerts NOT muted for service ${service.name}, sending resume notification`);
// Send notification that service has been resumed
await notificationService.sendNotification({
service: serviceForNotification,
status: "up",
timestamp: now
});
} else {
// console.log(`Alerts muted for service ${service.name}, skipping resume notification`);
}
// IMPORTANT: Wait a brief moment to ensure the status update is processed // IMPORTANT: Wait a brief moment to ensure the status update is processed
await new Promise(resolve => setTimeout(resolve, 500)); await new Promise(resolve => setTimeout(resolve, 500));
// Now start the service monitoring with a clean slate // Now start the service monitoring with a clean slate
await startMonitoringService(serviceId); await startMonitoringService(serviceId);
console.log(`Service ${service.name} resumed and ready for monitoring`); // console.log(`Service ${service.name} resumed and ready for monitoring`);
} catch (error) { } catch (error) {
// console.error("Error resuming service:", error); // console.error("Error resuming service:", error);
} }
} }
+4 -232
View File
@@ -1,234 +1,6 @@
import { pb } from "@/lib/pocketbase";
import { toast } from "@/hooks/use-toast";
import { AlertConfiguration } from "../alertConfigService";
import { NotificationTemplate } from "../templateService";
import { NotificationPayload } from "./types";
import { processTemplate, generateDefaultMessage } from "./templateProcessor";
import { sendTelegramNotification } from "./telegramService";
import { sendSignalNotification } from "./signalService";
// Track last notification times for services to implement cooldown // Re-export template processor only
const lastNotifications: Record<string, { export { processTemplate, generateDefaultMessage } from './templateProcessor';
timestamp: Date;
count: number;
lastMessageTime: Date; // Track when the last message was actually sent
}> = {};
// Cooldown period in milliseconds (5 minutes) // Export types from template services
const NOTIFICATION_COOLDOWN = 5 * 60 * 1000; export type { AnyTemplate } from '../templateService';
/**
* Notification service responsible for sending notifications based on service status changes
*/
export const notificationService = {
/**
* Send notification based on service status change
*/
async sendNotification(payload: NotificationPayload): Promise<boolean> {
try {
console.log("=== NOTIFICATION SERVICE TRIGGER ===");
console.log("Sending notification for service:", payload.service.name, "Status:", payload.status);
console.log("Service type:", payload.service.type);
console.log("Service retries:", payload.service.retries || 3);
console.log("Alerts status:", payload.service.alerts);
// First check: Strictly check if alerts are muted for this specific service
if (payload.service.alerts === "muted") {
console.log(`BLOCKING NOTIFICATION: Alerts are explicitly muted for service ${payload.service.name} (ID: ${payload.service.id})`);
return true; // Return true as this is expected behavior, not a failure
}
// For DOWN status, implement the cooldown and max retries logic
if (payload.status === "down") {
const serviceId = payload.service.id;
// Get max retries from service config or default to 3
const maxRetries = typeof payload.service.retries === 'number' ? payload.service.retries : 3;
const now = new Date();
console.log(`DOWN notification for ${payload.service.name} - Max retries: ${maxRetries}`);
if (lastNotifications[serviceId]) {
const lastNotif = lastNotifications[serviceId];
const timeSinceLastNotif = now.getTime() - lastNotif.timestamp.getTime();
console.log(`Time since last notification tracking for ${payload.service.name}: ${Math.round(timeSinceLastNotif/1000)}s`);
console.log(`Current notification count: ${lastNotif.count}/${maxRetries}`);
// Check if we're within the cooldown period
if (timeSinceLastNotif < NOTIFICATION_COOLDOWN) {
// If max retries is set to 1, we should only send one notification during the cooldown period
if (maxRetries === 1) {
console.log(`Max retries is 1 - BLOCKING duplicate DOWN notification for ${payload.service.name}`);
return true; // Skip notification but return success
}
// Check if we've reached max retries
if (lastNotif.count >= maxRetries) {
console.log(`DOWN notification for ${payload.service.name} skipped: Max retries (${maxRetries}) reached. Next notification after cooldown.`);
return true; // Skip notification but return success
}
// Increment count since we're sending another notification
console.log(`DOWN notification for ${payload.service.name}: ${lastNotif.count + 1}/${maxRetries}`);
lastNotifications[serviceId].count += 1;
lastNotifications[serviceId].lastMessageTime = now;
} else {
// Reset count after cooldown period
console.log(`Cooldown period elapsed for ${payload.service.name}. Resetting notification count.`);
lastNotifications[serviceId] = { timestamp: now, count: 1, lastMessageTime: now };
}
} else {
// First notification for this service
console.log(`First DOWN notification for ${payload.service.name}: 1/${maxRetries}`);
lastNotifications[serviceId] = { timestamp: now, count: 1, lastMessageTime: now };
}
}
// Check if service has notification channel configured
const notificationChannel = payload.service.notificationChannel;
if (!notificationChannel || notificationChannel === "none") {
console.log("No notification channel configured for service", payload.service.name);
return false;
}
console.log("Using notification channel ID:", notificationChannel);
try {
// Get the alert configuration details
console.log("Fetching alert configuration with ID:", notificationChannel);
const alertConfig = await pb.collection('alert_configurations').getOne(notificationChannel);
console.log("Found alert configuration:", JSON.stringify({
...alertConfig,
bot_token: alertConfig.bot_token ? "[REDACTED]" : undefined
}, null, 2));
// Check enabled status - skip if explicitly disabled
if (alertConfig.enabled === false || alertConfig.enabled === "false") {
console.log("Alert configuration is disabled, skipping notification");
return false;
}
// Prepare message content
let messageContent = await this.prepareMessageContent(
payload.service,
payload.status,
payload.responseTime,
payload.message
);
// For DOWN status, add retry information to the message
if (payload.status === "down" && lastNotifications[payload.service.id]) {
const retryInfo = lastNotifications[payload.service.id];
const maxRetries = typeof payload.service.retries === 'number' ? payload.service.retries : 3;
if (maxRetries > 1) {
messageContent += `\n\nAlert ${retryInfo.count}/${maxRetries}`;
}
}
console.log(`Prepared message for ${payload.status} notification:`, messageContent);
// Send notification based on type
if (alertConfig.notification_type === "telegram") {
console.log("Sending Telegram notification for service status change");
return await sendTelegramNotification(alertConfig as AlertConfiguration, messageContent);
} else if (alertConfig.notification_type === "signal") {
console.log("Sending Signal notification with message:", messageContent);
return await sendSignalNotification(alertConfig as AlertConfiguration, messageContent);
}
// Add other notification types here
console.log("Notification type not supported:", alertConfig.notification_type);
toast({
title: "Notification Error",
description: `Notification type '${alertConfig.notification_type}' not supported`,
variant: "destructive"
});
return false;
} catch (error) {
console.error("Error processing notification channel:", error);
console.error("Error details:", error instanceof Error ? error.message : "Unknown error");
toast({
title: "Notification Error",
description: `Error processing notification: ${error instanceof Error ? error.message : "Unknown error"}`,
variant: "destructive"
});
return false;
}
} catch (error) {
console.error("Error sending notification:", error);
toast({
title: "Notification Error",
description: `Failed to send notification: ${error instanceof Error ? error.message : "Unknown error"}`,
variant: "destructive"
});
return false;
}
},
/**
* Prepare message content for notification
*/
async prepareMessageContent(
service: any,
status: string,
responseTime?: number,
defaultMessage?: string
): Promise<string> {
// Use provided message if available
if (defaultMessage) {
return defaultMessage;
}
// Check for a template
let templateId = service.alertTemplate;
if (templateId && templateId !== "default") {
try {
console.log("Using specified template ID:", templateId);
const template = await pb.collection('notification_templates').getOne(templateId);
console.log("Template data:", template);
return processTemplate(
template as unknown as NotificationTemplate,
service,
status,
responseTime
);
} catch (error) {
console.error("Error fetching template:", error);
// Fall back to default message format
}
}
console.log("Using default message template");
return generateDefaultMessage(service.name, status, responseTime);
},
/**
* Send a test notification for a specific service status change
*/
async testServiceStatusNotification(serviceName: string, status: "up" | "down", responseTime?: number): Promise<boolean> {
const emoji = status === "up" ? "🟢" : "🔴";
const rtText = responseTime ? ` (Response time: ${responseTime}ms)` : "";
const message = `${emoji} Test notification: Service ${serviceName} is ${status.toUpperCase()}${rtText}`;
console.log("Test notification would be sent:", message);
// Instead of calling testTelegramNotification which no longer exists, just log and return success
return true;
},
/**
* Reset notification count for a service
* This is called when a service recovers from DOWN to UP
*/
resetNotificationCount(serviceId: string): void {
if (lastNotifications[serviceId]) {
console.log(`Resetting notification count for service ${serviceId}`);
delete lastNotifications[serviceId];
}
}
};
// Re-export the types for easier imports
export * from "./types";
@@ -1,43 +1,39 @@
import { NotificationTemplate } from "../templateService"; import { AnyTemplate } from "../templateService";
/** /**
* Process a notification template with service data * Process a notification template with service data
*/ */
export function processTemplate( export function processTemplate(
template: NotificationTemplate, template: AnyTemplate,
service: any, service: any,
status: string, status: string,
responseTime?: number responseTime?: number
): string { ): string {
try { try {
console.log(`Processing template for status: ${status}`);
let templateText = ""; let templateText = "";
// Select the appropriate message template based on status // Select the appropriate message template based on status
if (status === "up") { if (status === "up") {
templateText = template.up_message || `Service ${service.name} is now UP`; templateText = (template as any).up_message || generateDefaultUptimeMessage(service, status, responseTime);
} else if (status === "down") { } else if (status === "down") {
templateText = template.down_message || `Service ${service.name} is DOWN`; templateText = (template as any).down_message || generateDefaultUptimeMessage(service, status, responseTime);
} else if (status === "warning") { } else if (status === "warning") {
templateText = template.incident_message || `Warning: Service ${service.name} has an incident`; templateText = (template as any).incident_message || generateDefaultUptimeMessage(service, status, responseTime);
} else if (status === "maintenance" || status === "paused") { } else if (status === "maintenance" || status === "paused") {
templateText = template.maintenance_message || `Service ${service.name} is in maintenance mode`; templateText = (template as any).maintenance_message || generateDefaultUptimeMessage(service, status, responseTime);
} else if (status === "resolved") { } else if (status === "resolved") {
templateText = template.resolved_message || `Issue with service ${service.name} has been resolved`; templateText = (template as any).resolved_message || generateDefaultUptimeMessage(service, status, responseTime);
} else { } else {
templateText = `Service ${service.name} status changed to: ${status}`; templateText = generateDefaultUptimeMessage(service, status, responseTime);
} }
// Skip replacement if template is empty // Skip replacement if template is empty
if (!templateText) { if (!templateText) {
console.log("Empty template for status:", status); return generateDefaultUptimeMessage(service, status, responseTime);
return generateDefaultMessage(service.name, status, responseTime);
} }
console.log("Using template text:", templateText);
// Replace placeholders with actual values // Replace placeholders with actual values
let message = templateText let message = templateText
.replace(/\${service_name}/g, service.name || 'Unknown Service') .replace(/\${service_name}/g, service.name || 'Unknown Service')
@@ -50,22 +46,113 @@ export function processTemplate(
message = message.replace(/\${response_time}/g, 'N/A'); message = message.replace(/\${response_time}/g, 'N/A');
} }
// Replace any other placeholders // Replace service-specific placeholders
message = message message = message
.replace(/\${threshold}/g, service.threshold || 'N/A') .replace(/\${url}/g, service.url || service.URL || 'N/A')
.replace(/\${url}/g, service.url || 'N/A') .replace(/\${host}/g, service.host || service.Host || 'N/A')
.replace(/\${service_type}/g, service.service_type?.toUpperCase() || service.ServiceType?.toUpperCase() || service.type?.toUpperCase() || 'N/A')
.replace(/\${port}/g, service.port ? service.port.toString() : (service.Port ? service.Port.toString() : 'N/A'))
.replace(/\${domain}/g, service.domain || service.Domain || 'N/A')
.replace(/\${region_name}/g, service.region_name || service.RegionName || 'Default')
.replace(/\${agent_id}/g, service.agent_id ? service.agent_id.toString() : (service.AgentID ? service.AgentID.toString() : '1'))
.replace(/\${uptime}/g, service.uptime ? `${service.uptime}%` : (service.Uptime ? `${service.Uptime}%` : 'N/A'))
.replace(/\${error_message}/g, service.error_message || service.ErrorMessage || service.error || '')
.replace(/\${time}/g, new Date().toLocaleString()); .replace(/\${time}/g, new Date().toLocaleString());
console.log("Processed template message:", message);
return message; return message;
} catch (error) { } catch (error) {
console.error("Error processing template:", error); return generateDefaultUptimeMessage(service, status, responseTime);
return generateDefaultMessage(service.name, status, responseTime);
} }
} }
/** /**
* Generate a default message when no template is available * Generate a default uptime message with proper formatting and emojis
*/
export function generateDefaultUptimeMessage(
service: any,
status: string,
responseTime?: number
): string {
const serviceName = service.name || service.Name || 'Unknown Service';
const statusUpper = status.toUpperCase();
// Status emoji mapping
let statusEmoji = "🔵";
if (status === "up") {
statusEmoji = "🟢";
} else if (status === "down") {
statusEmoji = "🔴";
} else if (status === "warning") {
statusEmoji = "🟡";
} else if (status === "maintenance" || status === "paused") {
statusEmoji = "🟠";
}
let message = `${statusEmoji}Service ${serviceName} is ${statusUpper}.`;
// Add service details
const host = service.host || service.Host;
const url = service.url || service.URL;
const serviceType = service.service_type || service.ServiceType || service.type;
const port = service.port || service.Port;
const domain = service.domain || service.Domain;
const regionName = service.region_name || service.RegionName;
const agentId = service.agent_id || service.AgentID;
const uptime = service.uptime || service.Uptime;
// Build formatted details
const details = [];
if (url && url !== 'N/A') {
details.push(` - Host URL: ${url}`);
} else if (host && host !== 'N/A') {
details.push(` - Host: ${host}`);
}
if (serviceType && serviceType !== 'N/A') {
details.push(` - Type: ${serviceType.toUpperCase()}`);
}
if (port && port !== 'N/A') {
details.push(` - Port: ${port}`);
}
if (domain && domain !== 'N/A') {
details.push(` - Domain: ${domain}`);
}
// Response time handling
if (responseTime !== undefined && responseTime > 0) {
details.push(` - Response time: ${responseTime}ms`);
} else {
details.push(` - Response time: N/A`);
}
if (regionName && regionName !== 'N/A') {
details.push(` - Region: ${regionName}`);
}
if (agentId && agentId !== 'N/A') {
details.push(` - Agent: ${agentId}`);
}
if (uptime !== undefined && uptime !== 'N/A') {
details.push(` - Uptime: ${uptime}%`);
}
// Add timestamp
details.push(` - Time: ${new Date().toLocaleString()}`);
// Combine message with details
if (details.length > 0) {
message += '\n' + details.join('\n');
}
return message;
}
/**
* Generate a default message when no template is available (legacy support)
*/ */
export function generateDefaultMessage( export function generateDefaultMessage(
serviceName: string, serviceName: string,
@@ -83,4 +170,4 @@ export function generateDefaultMessage(
message += `. Time: ${new Date().toLocaleString()}`; message += `. Time: ${new Date().toLocaleString()}`;
return message; return message;
} }
@@ -1,194 +0,0 @@
import { Service } from "@/types/service.types";
import { processTemplate, generateDefaultMessage } from "./notification/templateProcessor";
import { sendTelegramNotification, testSendTelegramMessage } from "./notification/telegramService";
import { pb } from "@/lib/pocketbase";
import { templateService } from "./templateService";
import { AlertConfiguration } from "./alertConfigService";
interface NotificationData {
service: Service;
status: string;
responseTime?: number;
timestamp: string;
_notificationSource?: string;
}
// Track last notification times for services to implement cooldown
const lastNotifications: Record<string, {
timestamp: Date;
count: number;
}> = {};
// Cooldown period in milliseconds (5 minutes)
const NOTIFICATION_COOLDOWN = 5 * 60 * 1000;
export const notificationService = {
/**
* Send a notification for a service status change
*/
async sendNotification(data: NotificationData): Promise<boolean> {
try {
const { service, status, responseTime } = data;
// console.log(`Preparing to send notification for service: ${service.name}, status: ${status}`);
// console.log(`Service alerts status: ${service.alerts}`);
// First check if alerts are muted for this service
// STRICT equality check against "muted" string value
if (service.alerts === "muted") {
// console.log(`NOTIFICATION BLOCKED: Alerts are muted for service: ${service.name}`);
return true; // Return true as this is expected behavior
}
// For paused status, check if this is a duplicate notification from another source
// This helps prevent the double-notification issue
if (status === "paused" && data._notificationSource === "duplicate_check") {
// console.log("NOTIFICATION BLOCKED: Duplicate pause notification detected");
return true; // Return true as this is expected behavior
}
// For DOWN status, implement the cooldown and max retries logic
if (status === "down") {
const serviceId = service.id;
const maxRetries = service.retries || 3; // Default to 3 if not specified
const now = new Date();
if (lastNotifications[serviceId]) {
const lastNotif = lastNotifications[serviceId];
const timeSinceLastNotif = now.getTime() - lastNotif.timestamp.getTime();
// Check if we're within the cooldown period
if (timeSinceLastNotif < NOTIFICATION_COOLDOWN) {
// Increment count only if we haven't reached max retries
if (lastNotif.count < maxRetries) {
// console.log(`DOWN notification for ${service.name}: ${lastNotif.count + 1}/${maxRetries}`);
lastNotifications[serviceId].count += 1;
} else {
// console.log(`DOWN notification for ${service.name} skipped: Max retries (${maxRetries}) reached. Next notification after cooldown.`);
return true; // Skip notification but return success
}
} else {
// Reset count after cooldown period
// console.log(`Cooldown period elapsed for ${service.name}. Resetting notification count.`);
lastNotifications[serviceId] = { timestamp: now, count: 1 };
}
} else {
// First notification for this service
// console.log(`First DOWN notification for ${service.name}: 1/${maxRetries}`);
lastNotifications[serviceId] = { timestamp: now, count: 1 };
}
// Update timestamp for the current notification
lastNotifications[serviceId].timestamp = now;
}
// Check if notification channel is set
if (!service.notificationChannel) {
// console.log(`No notification channel set for service: ${service.name}`);
return false;
}
// Fetch the notification configuration
const alertConfigRecord = await pb.collection('alert_configurations').getOne(service.notificationChannel);
if (!alertConfigRecord) {
// console.error(`Alert configuration not found for ID: ${service.notificationChannel}`);
return false;
}
if (!alertConfigRecord.enabled) {
// console.log(`Alert configuration is disabled for service: ${service.name}`);
return false;
}
// Convert PocketBase record to AlertConfiguration
const alertConfig: AlertConfiguration = {
id: alertConfigRecord.id,
collectionId: alertConfigRecord.collectionId,
collectionName: alertConfigRecord.collectionName,
service_id: alertConfigRecord.service_id || "",
notification_type: alertConfigRecord.notification_type,
telegram_chat_id: alertConfigRecord.telegram_chat_id,
discord_webhook_url: alertConfigRecord.discord_webhook_url,
signal_number: alertConfigRecord.signal_number,
notify_name: alertConfigRecord.notify_name,
bot_token: alertConfigRecord.bot_token,
template_id: alertConfigRecord.template_id,
slack_webhook_url: alertConfigRecord.slack_webhook_url,
enabled: alertConfigRecord.enabled,
created: alertConfigRecord.created,
updated: alertConfigRecord.updated
};
// Select template if one is specified
let template;
if (service.alertTemplate) {
try {
template = await templateService.getTemplate(service.alertTemplate);
} catch (error) {
// console.error(`Error fetching template for ID: ${service.alertTemplate}`, error);
}
}
// Generate the notification message
let message;
if (template) {
message = processTemplate(template, service, status, responseTime);
} else {
message = generateDefaultMessage(service.name, status, responseTime);
}
// For DOWN status, add retry information to the message
if (status === "down" && lastNotifications[service.id]) {
const retryInfo = lastNotifications[service.id];
const maxRetries = service.retries || 3;
message += `\n\nAlert ${retryInfo.count}/${maxRetries}`;
}
// console.log(`Prepared notification message: ${message}`);
// Send notification based on notification type
const notificationType = alertConfig.notification_type;
if (notificationType === 'telegram') {
return await sendTelegramNotification(alertConfig, message);
}
// For other types like discord, slack, etc. (not implemented yet)
// console.log(`Notification type ${notificationType} not implemented yet`);
return false;
} catch (error) {
// console.error("Error sending notification:", error);
return false;
}
},
/**
* Send a test notification for a service status change
*/
async testServiceStatusNotification(serviceName: string, status: string, responseTime?: number): Promise<boolean> {
try {
const message = status === 'up'
? `Service ${serviceName} is UP${responseTime ? ` (Response time: ${responseTime}ms)` : ''}`
: `Service ${serviceName} is DOWN`;
// console.log(`Test notification would have been sent: ${message}`);
return true; // Just log, don't actually send
} catch (error) {
// console.error("Error in test notification:", error);
return false;
}
},
/**
* Reset notification count for a service
* This can be called when a service recovers from DOWN to UP
*/
resetNotificationCount(serviceId: string): void {
if (lastNotifications[serviceId]) {
// console.log(`Resetting notification count for service ${serviceId}`);
delete lastNotifications[serviceId];
}
}
};
@@ -27,7 +27,7 @@ export const operationalPageService = {
const result = await response.json(); const result = await response.json();
return result.items || []; return result.items || [];
} catch (error) { } catch (error) {
console.error('Error fetching operational pages:', error); // console.error('Error fetching operational pages:', error);
throw error; throw error;
} }
}, },
@@ -55,7 +55,7 @@ export const operationalPageService = {
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Error fetching operational page:', error); // console.error('Error fetching operational page:', error);
throw error; throw error;
} }
}, },
@@ -84,7 +84,7 @@ export const operationalPageService = {
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Error updating operational page:', error); // console.error('Error updating operational page:', error);
throw error; throw error;
} }
}, },
@@ -113,7 +113,7 @@ export const operationalPageService = {
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Error creating operational page:', error); // console.error('Error creating operational page:', error);
throw error; throw error;
} }
}, },
@@ -139,7 +139,7 @@ export const operationalPageService = {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
} catch (error) { } catch (error) {
console.error('Error deleting operational page:', error); // console.error('Error deleting operational page:', error);
throw error; throw error;
} }
} }
@@ -0,0 +1,114 @@
import { pb } from "@/lib/pocketbase";
export interface ServerNotificationTemplate {
id: string;
collectionId: string;
collectionName: string;
name: string;
ram_message: string;
cpu_message: string;
disk_message: string;
network_message: string;
up_message: string;
down_message: string;
notification_id: string;
warning_message: string;
paused_message: string;
cpu_temp_message: string;
disk_io_message: string;
restore_ram_message: string;
restore_cpu_message: string;
restore_disk_message: string;
restore_network_message: string;
restore_disk_io_message: string;
restore_cpu_temp_message: string;
placeholder: string;
created: string;
updated: string;
}
export interface CreateUpdateServerNotificationTemplateData {
name: string;
ram_message: string;
cpu_message: string;
disk_message: string;
network_message: string;
up_message: string;
down_message: string;
notification_id: string;
warning_message: string;
paused_message: string;
cpu_temp_message: string;
disk_io_message: string;
restore_ram_message: string;
restore_cpu_message: string;
restore_disk_message: string;
restore_network_message: string;
restore_disk_io_message: string;
restore_cpu_temp_message: string;
placeholder: string;
}
export const serverNotificationTemplateService = {
async getTemplates(): Promise<ServerNotificationTemplate[]> {
try {
const response = await pb.collection('server_notification_templates').getList(1, 50, {
sort: '-created',
});
return response.items as unknown as ServerNotificationTemplate[];
} catch (error) {
throw error;
}
},
async getTemplate(id: string): Promise<ServerNotificationTemplate> {
try {
const response = await pb.collection('server_notification_templates').getOne(id);
return response as unknown as ServerNotificationTemplate;
} catch (error) {
throw error;
}
},
async createTemplate(data: CreateUpdateServerNotificationTemplateData): Promise<ServerNotificationTemplate> {
try {
const response = await pb.collection('server_notification_templates').create(data);
return response as unknown as ServerNotificationTemplate;
} catch (error) {
throw error;
}
},
async updateTemplate(id: string, data: Partial<CreateUpdateServerNotificationTemplateData>): Promise<ServerNotificationTemplate> {
try {
const response = await pb.collection('server_notification_templates').update(id, data);
return response as unknown as ServerNotificationTemplate;
} catch (error) {
throw error;
}
},
async deleteTemplate(id: string): Promise<boolean> {
try {
await pb.collection('server_notification_templates').delete(id);
return true;
} catch (error) {
throw error;
}
}
};
@@ -0,0 +1,93 @@
import { pb } from "@/lib/pocketbase";
export interface ServiceNotificationTemplate {
id: string;
collectionId: string;
collectionName: string;
name: string;
up_message: string;
down_message: string;
maintenance_message: string;
incident_message: string;
resolved_message: string;
placeholder: string;
warning_message: string;
created: string;
updated: string;
}
export interface CreateUpdateServiceNotificationTemplateData {
name: string;
up_message: string;
down_message: string;
maintenance_message: string;
incident_message: string;
resolved_message: string;
placeholder: string;
warning_message: string;
}
export const serviceNotificationTemplateService = {
async getTemplates(): Promise<ServiceNotificationTemplate[]> {
try {
// console.log("Fetching service notification templates");
const response = await pb.collection('service_notification_templates').getList(1, 50, {
sort: '-created',
});
// console.log("Service notification templates response:", response);
return response.items as unknown as ServiceNotificationTemplate[];
} catch (error) {
// console.error("Error fetching service notification templates:", error);
throw error;
}
},
async getTemplate(id: string): Promise<ServiceNotificationTemplate> {
try {
// console.log(`Fetching service notification template with id: ${id}`);
const response = await pb.collection('service_notification_templates').getOne(id);
// console.log("Service notification template response:", response);
return response as unknown as ServiceNotificationTemplate;
} catch (error) {
// console.error(`Error fetching service notification template with id ${id}:`, error);
throw error;
}
},
async createTemplate(data: CreateUpdateServiceNotificationTemplateData): Promise<ServiceNotificationTemplate> {
try {
// console.log("Creating new service notification template with data:", data);
const response = await pb.collection('service_notification_templates').create(data);
// console.log("Create service notification template response:", response);
return response as unknown as ServiceNotificationTemplate;
} catch (error) {
// console.error("Error creating service notification template:", error);
throw error;
}
},
async updateTemplate(id: string, data: Partial<CreateUpdateServiceNotificationTemplateData>): Promise<ServiceNotificationTemplate> {
try {
// console.log(`Updating service notification template with id: ${id}`, data);
const response = await pb.collection('service_notification_templates').update(id, data);
// console.log("Update service notification template response:", response);
return response as unknown as ServiceNotificationTemplate;
} catch (error) {
// console.error(`Error updating service notification template with id ${id}:`, error);
throw error;
}
},
async deleteTemplate(id: string): Promise<boolean> {
try {
// console.log(`Deleting service notification template with id: ${id}`);
await pb.collection('service_notification_templates').delete(id);
// console.log("Service notification template deleted successfully");
return true;
} catch (error) {
// console.error(`Error deleting service notification template with id ${id}:`, error);
throw error;
}
}
};
+17 -27
View File
@@ -9,7 +9,6 @@ export type { Service, UptimeData, CreateServiceParams };
export const serviceService = { export const serviceService = {
async getServices(): Promise<Service[]> { async getServices(): Promise<Service[]> {
try { try {
// First get the total count of records // First get the total count of records
const countResponse = await pb.collection('services').getList(1, 1, { const countResponse = await pb.collection('services').getList(1, 1, {
sort: 'name', sort: 'name',
@@ -37,7 +36,7 @@ export const serviceService = {
retries: item.max_retries || item.retries || 3, retries: item.max_retries || item.retries || 3,
notificationChannel: item.notification_id, notificationChannel: item.notification_id,
notification_channel: item.notification_channel, // Add this field for multiple channels support notification_channel: item.notification_channel, // Add this field for multiple channels support
notification_status: item.notification_status || "disabled", notification_status: item.notification_status || false,
alertTemplate: item.template_id, alertTemplate: item.template_id,
muteAlerts: item.alerts === "muted", // Convert string to boolean for compatibility muteAlerts: item.alerts === "muted", // Convert string to boolean for compatibility
alerts: item.alerts || "unmuted", // Store actual database field alerts: item.alerts || "unmuted", // Store actual database field
@@ -49,7 +48,6 @@ export const serviceService = {
regional_monitoring_enabled: item.regional_status === "enabled", // Backward compatibility regional_monitoring_enabled: item.regional_status === "enabled", // Backward compatibility
})); }));
} catch (error) { } catch (error) {
// console.error("Error fetching services:", error);
throw new Error('Failed to load services data.'); throw new Error('Failed to load services data.');
} }
}, },
@@ -60,7 +58,6 @@ export const serviceService = {
const serviceType = params.type.toLowerCase(); const serviceType = params.type.toLowerCase();
// Debug log to check what we're sending // Debug log to check what we're sending
// console.log("Creating service with params:", params);
const data = { const data = {
name: params.name, name: params.name,
@@ -71,16 +68,16 @@ export const serviceService = {
last_checked: new Date().toLocaleString(), last_checked: new Date().toLocaleString(),
heartbeat_interval: params.interval, heartbeat_interval: params.interval,
max_retries: params.retries, max_retries: params.retries,
notification_status: params.notificationStatus || "disabled", // Store notification_status as boolean
// Store multiple notification channels as JSON string notification_status: params.notificationStatus === true,
// Always store channels and template if provided (don't clear based on status)
notification_channel: params.notificationChannels && params.notificationChannels.length > 0 notification_channel: params.notificationChannels && params.notificationChannels.length > 0
? JSON.stringify(params.notificationChannels) ? JSON.stringify(params.notificationChannels)
: null, : null,
// Store multiple notification IDs as comma-separated string in notification_id field
notification_id: params.notificationChannels && params.notificationChannels.length > 0 notification_id: params.notificationChannels && params.notificationChannels.length > 0
? params.notificationChannels.join(',') ? params.notificationChannels.join(',')
: null, : null,
template_id: params.alertTemplate, template_id: params.alertTemplate || null,
// Regional monitoring fields - use regional_status // Regional monitoring fields - use regional_status
regional_status: params.regionalStatus || "disabled", regional_status: params.regionalStatus || "disabled",
region_name: params.regionName || "", region_name: params.regionName || "",
@@ -96,9 +93,7 @@ export const serviceService = {
) )
}; };
// console.log("Creating service with data:", data);
const record = await pb.collection('services').create(data); const record = await pb.collection('services').create(data);
// console.log("Service created, returned record:", record);
// Return the newly created service // Return the newly created service
const newService = { const newService = {
@@ -117,7 +112,7 @@ export const serviceService = {
retries: record.max_retries || 3, retries: record.max_retries || 3,
notificationChannel: record.notification_id, notificationChannel: record.notification_id,
notification_channel: record.notification_channel, notification_channel: record.notification_channel,
notification_status: record.notification_status || "disabled", notification_status: record.notification_status || false,
alertTemplate: record.template_id, alertTemplate: record.template_id,
regional_status: record.regional_status || "disabled", regional_status: record.regional_status || "disabled",
regional_monitoring_enabled: record.regional_status === "enabled", regional_monitoring_enabled: record.regional_status === "enabled",
@@ -130,7 +125,6 @@ export const serviceService = {
return newService; return newService;
} catch (error) { } catch (error) {
// console.error("Error creating service:", error);
throw new Error('Failed to create service.'); throw new Error('Failed to create service.');
} }
}, },
@@ -141,23 +135,22 @@ export const serviceService = {
const serviceType = params.type.toLowerCase(); const serviceType = params.type.toLowerCase();
// Debug log to check what we're updating // Debug log to check what we're updating
// console.log("Updating service with params:", params);
const data = { const data = {
name: params.name, name: params.name,
service_type: serviceType, service_type: serviceType,
heartbeat_interval: params.interval, heartbeat_interval: params.interval,
max_retries: params.retries, max_retries: params.retries,
notification_status: params.notificationStatus || "disabled", // Store notification_status as boolean - preserve existing channels/template
// Store multiple notification channels as JSON string notification_status: params.notificationStatus === true,
notification_channel: params.notificationChannels && params.notificationChannels.length > 0 // Only update channels and template if they are provided (don't clear them)
? JSON.stringify(params.notificationChannels) ...(params.notificationChannels && params.notificationChannels.length > 0 && {
: null, notification_channel: JSON.stringify(params.notificationChannels),
// Store multiple notification IDs as comma-separated string in notification_id field notification_id: params.notificationChannels.join(',')
notification_id: params.notificationChannels && params.notificationChannels.length > 0 }),
? params.notificationChannels.join(',') ...(params.alertTemplate && {
: null, template_id: params.alertTemplate
template_id: params.alertTemplate || null, }),
// Regional monitoring fields - use regional_status // Regional monitoring fields - use regional_status
regional_status: params.regionalStatus || "disabled", regional_status: params.regionalStatus || "disabled",
region_name: params.regionName || "", region_name: params.regionName || "",
@@ -173,7 +166,6 @@ export const serviceService = {
) )
}; };
// console.log("Updating service with data:", data);
// Use timeout to ensure the request doesn't hang // Use timeout to ensure the request doesn't hang
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
@@ -182,7 +174,6 @@ export const serviceService = {
const updatePromise = pb.collection('services').update(id, data); const updatePromise = pb.collection('services').update(id, data);
const record = await Promise.race([updatePromise, timeoutPromise]) as any; const record = await Promise.race([updatePromise, timeoutPromise]) as any;
// console.log("Service updated, returned record:", record);
// Return the updated service // Return the updated service
const updatedService = { const updatedService = {
@@ -201,7 +192,7 @@ export const serviceService = {
retries: record.max_retries || 3, retries: record.max_retries || 3,
notificationChannel: record.notification_id, notificationChannel: record.notification_id,
notification_channel: record.notification_channel, notification_channel: record.notification_channel,
notification_status: record.notification_status || "disabled", notification_status: record.notification_status || false,
alertTemplate: record.template_id, alertTemplate: record.template_id,
regional_status: record.regional_status || "disabled", regional_status: record.regional_status || "disabled",
regional_monitoring_enabled: record.regional_status === "enabled", regional_monitoring_enabled: record.regional_status === "enabled",
@@ -211,7 +202,6 @@ export const serviceService = {
return updatedService; return updatedService;
} catch (error) { } catch (error) {
//console.error("Error updating service:", error);
throw new Error(`Failed to update service: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(`Failed to update service: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
}, },
@@ -13,6 +13,8 @@ export const addSSLCertificate = async (
certificateData: AddSSLCertificateDto certificateData: AddSSLCertificateDto
): Promise<SSLCertificate> => { ): Promise<SSLCertificate> => {
try { try {
const currentTime = new Date().toISOString();
// Prepare the data for saving to database // Prepare the data for saving to database
// The Go service will handle the actual SSL checking // The Go service will handle the actual SSL checking
const data = { const data = {
@@ -23,14 +25,17 @@ export const addSSLCertificate = async (
cert_sans: "", cert_sans: "",
cert_alg: "", cert_alg: "",
serial_number: "", serial_number: "",
valid_from: new Date().toISOString(), // Will be updated by Go service valid_from: currentTime, // Will be updated by Go service
valid_till: new Date().toISOString(), // Will be updated by Go service valid_till: currentTime, // Will be updated by Go service
validity_days: 0, // Will be updated by Go service validity_days: 0, // Will be updated by Go service
days_left: 0, // Will be updated by Go service days_left: 0, // Will be updated by Go service
warning_threshold: Number(certificateData.warning_threshold) || 30, warning_threshold: Number(certificateData.warning_threshold) || 30,
expiry_threshold: Number(certificateData.expiry_threshold) || 7, expiry_threshold: Number(certificateData.expiry_threshold) || 7,
notification_channel: certificateData.notification_channel || "", notification_channel: certificateData.notification_channel || "",
notification_id: certificateData.notification_id || "", // Multi notification channels
template_id: certificateData.template_id || "", // Alert template ID
check_interval: Number(certificateData.check_interval) || 1, // New field check_interval: Number(certificateData.check_interval) || 1, // New field
check_at: currentTime, // Set to current time to trigger immediate check
}; };
// Save to database // Save to database
@@ -38,7 +43,6 @@ export const addSSLCertificate = async (
return record as unknown as SSLCertificate; return record as unknown as SSLCertificate;
} catch (error) { } catch (error) {
console.error("Error adding SSL certificate:", error);
throw error; throw error;
} }
}; };
@@ -67,7 +71,6 @@ export const checkAndUpdateCertificate = async (
// Return the current certificate data // Return the current certificate data
return typedCertificate; return typedCertificate;
} catch (error) { } catch (error) {
console.error("Error updating SSL certificate:", error);
throw error; throw error;
} }
}; };
@@ -84,10 +87,8 @@ export const triggerImmediateCheck = async (certificateId: string): Promise<void
check_at: currentTime check_at: currentTime
}); });
console.log(`Triggered immediate check for certificate ${certificateId} at ${currentTime}`);
toast.success("SSL check scheduled - certificate will be checked shortly"); toast.success("SSL check scheduled - certificate will be checked shortly");
} catch (error) { } catch (error) {
console.error("Error triggering immediate SSL check:", error);
toast.error("Failed to schedule SSL check"); toast.error("Failed to schedule SSL check");
throw error; throw error;
} }
@@ -101,7 +102,6 @@ export const deleteSSLCertificate = async (id: string): Promise<boolean> => {
await pb.collection("ssl_certificates").delete(id); await pb.collection("ssl_certificates").delete(id);
return true; return true;
} catch (error) { } catch (error) {
console.error("Error deleting SSL certificate:", error);
throw error; throw error;
} }
}; };
@@ -112,11 +112,9 @@ export const deleteSSLCertificate = async (id: string): Promise<boolean> => {
*/ */
export const refreshAllCertificates = async (): Promise<{ success: number; failed: number }> => { export const refreshAllCertificates = async (): Promise<{ success: number; failed: number }> => {
try { try {
const response = await pb.collection("ssl_certificates").getList(1, 200); const response = await pb.collection("ssl_certificates").getList(1, 100);
const certificates = response.items as unknown as SSLCertificate[]; const certificates = response.items as unknown as SSLCertificate[];
// console.log(`Refreshing ${certificates.length} certificates...`);
let success = 0; let success = 0;
let failed = 0; let failed = 0;
@@ -125,14 +123,12 @@ export const refreshAllCertificates = async (): Promise<{ success: number; faile
await checkCertificateAndNotify(cert); await checkCertificateAndNotify(cert);
success++; success++;
} catch (error) { } catch (error) {
// console.error(`Failed to refresh certificate ${cert.domain}:`, error);
failed++; failed++;
} }
} }
return { success, failed }; return { success, failed };
} catch (error) { } catch (error) {
// console.error("Error refreshing certificates:", error);
throw error; throw error;
} }
}; };
+8
View File
@@ -1,9 +1,13 @@
// SSL Certificate DTO for adding new certificates // SSL Certificate DTO for adding new certificates
export interface AddSSLCertificateDto { export interface AddSSLCertificateDto {
domain: string; domain: string;
warning_threshold: number; warning_threshold: number;
expiry_threshold: number; expiry_threshold: number;
notification_channel: string; notification_channel: string;
alert_template?: string; // New field for SSL alert template
notification_id?: string; // Multi notification channels as comma-separated string
template_id?: string; // Alert template ID for PocketBase
check_interval?: number; // New field for check interval in days check_interval?: number; // New field for check interval in days
} }
@@ -25,6 +29,10 @@ export interface SSLCertificate {
warning_threshold: number; warning_threshold: number;
expiry_threshold: number; expiry_threshold: number;
notification_channel: string; notification_channel: string;
alert_template?: string; // New field for SSL alert template
// PocketBase specific fields
notification_id?: string; // Multi notification channels as comma-separated string
template_id?: string; // Alert template ID for PocketBase
last_notified?: string; last_notified?: string;
created?: string; created?: string;
updated?: string; updated?: string;
@@ -0,0 +1,87 @@
import { pb } from "@/lib/pocketbase";
export interface SslNotificationTemplate {
id: string;
collectionId: string;
collectionName: string;
name: string;
expired: string;
exiring_soon: string;
warning: string;
placeholder: string;
created: string;
updated: string;
}
export interface CreateUpdateSslNotificationTemplateData {
name: string;
expired: string;
exiring_soon: string;
warning: string;
placeholder: string;
}
export const sslNotificationTemplateService = {
async getTemplates(): Promise<SslNotificationTemplate[]> {
try {
// console.log("Fetching SSL notification templates");
const response = await pb.collection('ssl_notification_templates').getList(1, 50, {
sort: '-created',
});
// console.log("SSL notification templates response:", response);
return response.items as unknown as SslNotificationTemplate[];
} catch (error) {
// console.error("Error fetching SSL notification templates:", error);
throw error;
}
},
async getTemplate(id: string): Promise<SslNotificationTemplate> {
try {
// console.log(`Fetching SSL notification template with id: ${id}`);
const response = await pb.collection('ssl_notification_templates').getOne(id);
// console.log("SSL notification template response:", response);
return response as unknown as SslNotificationTemplate;
} catch (error) {
// console.error(`Error fetching SSL notification template with id ${id}:`, error);
throw error;
}
},
async createTemplate(data: CreateUpdateSslNotificationTemplateData): Promise<SslNotificationTemplate> {
try {
// console.log("Creating new SSL notification template with data:", data);
const response = await pb.collection('ssl_notification_templates').create(data);
// console.log("Create SSL notification template response:", response);
return response as unknown as SslNotificationTemplate;
} catch (error) {
// console.error("Error creating SSL notification template:", error);
throw error;
}
},
async updateTemplate(id: string, data: Partial<CreateUpdateSslNotificationTemplateData>): Promise<SslNotificationTemplate> {
try {
// console.log(`Updating SSL notification template with id: ${id}`, data);
const response = await pb.collection('ssl_notification_templates').update(id, data);
// console.log("Update SSL notification template response:", response);
return response as unknown as SslNotificationTemplate;
} catch (error) {
// console.error(`Error updating SSL notification template with id ${id}:`, error);
throw error;
}
},
async deleteTemplate(id: string): Promise<boolean> {
try {
// console.log(`Deleting SSL notification template with id: ${id}`);
await pb.collection('ssl_notification_templates').delete(id);
// console.log("SSL notification template deleted successfully");
return true;
} catch (error) {
// console.error(`Error deleting SSL notification template with id ${id}:`, error);
throw error;
}
}
};
@@ -27,7 +27,7 @@ export const statusPageComponentsService = {
const result = await response.json(); const result = await response.json();
return result.items || []; return result.items || [];
} catch (error) { } catch (error) {
console.error('Error fetching status page components:', error); // console.error('Error fetching status page components:', error);
throw error; throw error;
} }
}, },
@@ -56,7 +56,7 @@ export const statusPageComponentsService = {
const result = await response.json(); const result = await response.json();
return result.items || []; return result.items || [];
} catch (error) { } catch (error) {
console.error('Error fetching status page components by operational ID:', error); // console.error('Error fetching status page components by operational ID:', error);
throw error; throw error;
} }
}, },
@@ -79,7 +79,7 @@ export const statusPageComponentsService = {
headers['Authorization'] = `Bearer ${authToken}`; headers['Authorization'] = `Bearer ${authToken}`;
} }
console.log('Creating status page component with data:', data); // console.log('Creating status page component with data:', data);
const baseUrl = getCurrentEndpoint(); const baseUrl = getCurrentEndpoint();
const response = await fetch(`${baseUrl}/api/collections/status_page_components/records`, { const response = await fetch(`${baseUrl}/api/collections/status_page_components/records`, {
@@ -95,10 +95,10 @@ export const statusPageComponentsService = {
} }
const result = await response.json(); const result = await response.json();
console.log('Created component:', result); // console.log('Created component:', result);
return result; return result;
} catch (error) { } catch (error) {
console.error('Error creating status page component:', error); // console.error('Error creating status page component:', error);
throw error; throw error;
} }
}, },
@@ -124,7 +124,7 @@ export const statusPageComponentsService = {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
} catch (error) { } catch (error) {
console.error('Error deleting status page component:', error); // console.error('Error deleting status page component:', error);
throw error; throw error;
} }
} }
+123 -80
View File
@@ -1,100 +1,143 @@
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import {
serverNotificationTemplateService,
ServerNotificationTemplate,
CreateUpdateServerNotificationTemplateData
} from "./serverNotificationTemplateService";
import {
serviceNotificationTemplateService,
ServiceNotificationTemplate,
CreateUpdateServiceNotificationTemplateData
} from "./serviceNotificationTemplateService";
import {
sslNotificationTemplateService,
SslNotificationTemplate,
CreateUpdateSslNotificationTemplateData
} from "./sslNotificationTemplateService";
import {
serverThresholdService,
ServerThreshold,
CreateUpdateServerThresholdData
} from "./serverThresholdService";
export interface NotificationTemplate { export type TemplateType = 'server' | 'service' | 'ssl' | 'server_threshold';
id: string;
collectionId: string;
collectionName: string;
name: string;
up_message: string;
down_message: string;
service_name_placeholder: string;
response_time_placeholder: string;
status_placeholder: string;
threshold_placeholder: string;
type: string;
maintenance_message: string;
incident_message: string;
resolved_message: string;
created: string;
updated: string;
}
export interface CreateUpdateTemplateData { export type AnyTemplate = ServerNotificationTemplate | ServiceNotificationTemplate | SslNotificationTemplate | ServerThreshold;
name: string; export type AnyTemplateData = CreateUpdateServerNotificationTemplateData | CreateUpdateServiceNotificationTemplateData | CreateUpdateSslNotificationTemplateData | CreateUpdateServerThresholdData;
up_message: string;
down_message: string; // Export individual template types
service_name_placeholder: string; export type { ServerNotificationTemplate, ServiceNotificationTemplate, SslNotificationTemplate, ServerThreshold };
response_time_placeholder: string; export type { CreateUpdateServerNotificationTemplateData, CreateUpdateServiceNotificationTemplateData, CreateUpdateSslNotificationTemplateData, CreateUpdateServerThresholdData };
status_placeholder: string;
threshold_placeholder: string;
type: string;
maintenance_message: string;
incident_message: string;
resolved_message: string;
}
export const templateService = { export const templateService = {
async getTemplates(): Promise<NotificationTemplate[]> { async getTemplates(type: TemplateType): Promise<AnyTemplate[]> {
try { switch (type) {
// console.log("Fetching server notification templates"); case 'server':
const response = await pb.collection('server_notification_templates').getList(1, 50, { return serverNotificationTemplateService.getTemplates();
sort: '-created', case 'service':
}); return serviceNotificationTemplateService.getTemplates();
// console.log("Server templates response:", response); case 'ssl':
return response.items as unknown as NotificationTemplate[]; return sslNotificationTemplateService.getTemplates();
} catch (error) { case 'server_threshold':
// console.error("Error fetching server templates:", error); return serverThresholdService.getServerThresholds();
throw error; default:
throw new Error(`Unknown template type: ${type}`);
} }
}, },
async getTemplate(id: string): Promise<NotificationTemplate> { async getTemplate(id: string, type: TemplateType): Promise<AnyTemplate> {
try { switch (type) {
// console.log(`Fetching server template with id: ${id}`); case 'server':
const response = await pb.collection('server_notification_templates').getOne(id); return serverNotificationTemplateService.getTemplate(id);
// console.log("Server template response:", response); case 'service':
return response as unknown as NotificationTemplate; return serviceNotificationTemplateService.getTemplate(id);
} catch (error) { case 'ssl':
// console.error(`Error fetching server template with id ${id}:`, error); return sslNotificationTemplateService.getTemplate(id);
throw error; case 'server_threshold':
return serverThresholdService.getServerThreshold(id);
default:
throw new Error(`Unknown template type: ${type}`);
} }
}, },
async createTemplate(data: CreateUpdateTemplateData): Promise<NotificationTemplate> { async createTemplate(data: AnyTemplateData, type: TemplateType): Promise<AnyTemplate> {
try { switch (type) {
// console.log("Creating new server template with data:", data); case 'server':
const response = await pb.collection('server_notification_templates').create(data); return serverNotificationTemplateService.createTemplate(data as CreateUpdateServerNotificationTemplateData);
// console.log("Create server template response:", response); case 'service':
return response as unknown as NotificationTemplate; return serviceNotificationTemplateService.createTemplate(data as CreateUpdateServiceNotificationTemplateData);
} catch (error) { case 'ssl':
// console.error("Error creating server template:", error); return sslNotificationTemplateService.createTemplate(data as CreateUpdateSslNotificationTemplateData);
throw error; case 'server_threshold':
return serverThresholdService.createServerThreshold(data as CreateUpdateServerThresholdData);
default:
throw new Error(`Unknown template type: ${type}`);
} }
}, },
async updateTemplate(id: string, data: Partial<CreateUpdateTemplateData>): Promise<NotificationTemplate> { async updateTemplate(id: string, data: Partial<AnyTemplateData>, type: TemplateType): Promise<AnyTemplate> {
try { switch (type) {
// console.log(`Updating server template with id: ${id}`, data); case 'server':
const response = await pb.collection('server_notification_templates').update(id, data); return serverNotificationTemplateService.updateTemplate(id, data as Partial<CreateUpdateServerNotificationTemplateData>);
// console.log("Update server template response:", response); case 'service':
return response as unknown as NotificationTemplate; return serviceNotificationTemplateService.updateTemplate(id, data as Partial<CreateUpdateServiceNotificationTemplateData>);
} catch (error) { case 'ssl':
// console.error(`Error updating server template with id ${id}:`, error); return sslNotificationTemplateService.updateTemplate(id, data as Partial<CreateUpdateSslNotificationTemplateData>);
throw error; case 'server_threshold':
return serverThresholdService.updateServerThreshold(id, data as Partial<CreateUpdateServerThresholdData>);
default:
throw new Error(`Unknown template type: ${type}`);
} }
}, },
async deleteTemplate(id: string): Promise<boolean> { async deleteTemplate(id: string, type: TemplateType): Promise<boolean> {
try { switch (type) {
// console.log(`Deleting server template with id: ${id}`); case 'server':
await pb.collection('server_notification_templates').delete(id); return serverNotificationTemplateService.deleteTemplate(id);
// console.log("Server template deleted successfully"); case 'service':
return true; return serviceNotificationTemplateService.deleteTemplate(id);
} catch (error) { case 'ssl':
// console.error(`Error deleting server template with id ${id}:`, error); return sslNotificationTemplateService.deleteTemplate(id);
throw error; case 'server_threshold':
return serverThresholdService.deleteServerThreshold(id);
default:
throw new Error(`Unknown template type: ${type}`);
} }
} }
}; };
// Template type configurations
export const templateTypeConfigs = {
server: {
label: 'Server Monitoring',
description: 'Templates for server resource monitoring alerts',
placeholders: [
'${server_name}', '${cpu_usage}', '${ram_usage}', '${disk_usage}',
'${network_usage}', '${cpu_temp}', '${disk_io}', '${threshold}', '${time}'
]
},
service: {
label: 'Service Uptime',
description: 'Templates for service uptime monitoring alerts',
placeholders: [
'${service_name}', '${status}', '${response_time}', '${url}',
'${host}', '${service_type}', '${port}', '${domain}',
'${region_name}', '${agent_id}', '${uptime}', '${time}'
]
},
ssl: {
label: 'SSL Certificate',
description: 'Templates for SSL certificate monitoring alerts',
placeholders: [
'${domain}', '${certificate_name}', '${expiry_date}', '${days_left}',
'${issuer}', '${serial_number}', '${time}'
]
},
server_threshold: {
label: 'Server Threshold',
description: 'Templates for server resource threshold configurations',
placeholders: [
'${name}', '${cpu_threshold}', '${ram_threshold}', '${disk_threshold}', '${network_threshold}'
]
}
};
@@ -0,0 +1,66 @@
import { pb } from "@/lib/pocketbase";
import { toast } from "@/hooks/use-toast";
export interface WebhookConfiguration {
id?: string;
collectionId?: string;
collectionName?: string;
user_id?: string;
url: string;
enabled: string;
secret?: string;
headers?: string;
retry_count?: string;
trigger_events?: string;
description?: string;
name: string;
method?: string;
payload_template?: string;
created?: string;
updated?: string;
}
export const webhookService = {
async createWebhook(config: Omit<WebhookConfiguration, 'id' | 'collectionId' | 'collectionName' | 'created' | 'updated'>): Promise<WebhookConfiguration | null> {
// console.info("Creating webhook configuration:", config);
try {
const result = await pb.collection('webhook').create(config);
// console.info("Webhook configuration created:", result);
toast({
title: "Success",
description: "Webhook created successfully",
});
return result as WebhookConfiguration;
} catch (error) {
// console.error("Error creating webhook configuration:", error);
toast({
title: "Error",
description: "Failed to create webhook",
variant: "destructive"
});
return null;
}
},
async updateWebhook(id: string, config: Partial<WebhookConfiguration>): Promise<WebhookConfiguration | null> {
// console.info(`Updating webhook configuration ${id}:`, config);
try {
const result = await pb.collection('webhook').update(id, config);
// console.info("Webhook configuration updated:", result);
toast({
title: "Success",
description: "Webhook updated successfully",
});
return result as WebhookConfiguration;
} catch (error) {
// console.error("Error updating webhook configuration:", error);
toast({
title: "Error",
description: "Failed to update webhook",
variant: "destructive"
});
return null;
}
}
};
@@ -8,6 +8,7 @@ export const commonTranslations: CommonTranslations = {
english: "Englisch", english: "Englisch",
khmer: "Khmer", khmer: "Khmer",
german: "Deutsch", german: "Deutsch",
simplifiedChinese: "Simplified Chinese",
goodMorning: "Guten Morgen", goodMorning: "Guten Morgen",
goodAfternoon: "Guten Nachmittag", goodAfternoon: "Guten Nachmittag",
goodEvening: "Guten Abend", goodEvening: "Guten Abend",
+9
View File
@@ -10,6 +10,7 @@ export const sslTranslations: SSLTranslations = {
deleteSSLCertificate: "Delete SSL Certificate", deleteSSLCertificate: "Delete SSL Certificate",
sslCertificateDetails: "SSL-Zertifikatdetails", sslCertificateDetails: "SSL-Zertifikatdetails",
detailedInfo: "Detaillierte Informationen zum SSL-Zertifikat für", detailedInfo: "Detaillierte Informationen zum SSL-Zertifikat für",
viewDetailedInformation: "View detailed information for",
// Status related // Status related
valid: "Gültig", valid: "Gültig",
@@ -48,11 +49,16 @@ export const sslTranslations: SSLTranslations = {
validFrom: "Gültig von", validFrom: "Gültig von",
validUntil: "Gültig bis", validUntil: "Gültig bis",
validityDays: "Gültigkeitsdauer Tage", validityDays: "Gültigkeitsdauer Tage",
validityPeriod: "Validity Period",
organization: "Organisation", organization: "Organisation",
commonName: "Allgemeiner Name", commonName: "Allgemeiner Name",
serialNumber: "Seriennummer", serialNumber: "Seriennummer",
algorithm: "Algorithmus", algorithm: "Algorithmus",
subjectAltNames: "Thema Alternative Bezeichnungen", subjectAltNames: "Thema Alternative Bezeichnungen",
subjectAlternativeNames: "Subject Alternative Names",
resolvedIP: "Resolved IP",
issuedTo: "Issued To",
days: "days",
// Buttons and actions // Buttons and actions
addDomain: "Domain hinzufügen", addDomain: "Domain hinzufügen",
@@ -72,8 +78,11 @@ export const sslTranslations: SSLTranslations = {
validity: "Gültigkeit", validity: "Gültigkeit",
issuerInfo: "Informationen zum Aussteller", issuerInfo: "Informationen zum Aussteller",
technicalDetails: "Technische Details", technicalDetails: "Technische Details",
technicalInformation: "Technical Information",
monitoringConfig: "Monitoring-Konfiguration", monitoringConfig: "Monitoring-Konfiguration",
monitoringConfiguration: "Monitoring Configuration",
recordInfo: "Aufzeichnungsinformationen", recordInfo: "Aufzeichnungsinformationen",
certificateDetails: "Certificate Details",
// Notifications and messages // Notifications and messages
sslCertificateAdded: "SSL-Zertifikat erfolgreich hinzugefügt", sslCertificateAdded: "SSL-Zertifikat erfolgreich hinzugefügt",
@@ -8,6 +8,7 @@ export const commonTranslations: CommonTranslations = {
english: "English", english: "English",
khmer: "Khmer", khmer: "Khmer",
german: "Deutsch", german: "Deutsch",
simplifiedChinese: "Simplified Chinese",
goodMorning: "Good morning", goodMorning: "Good morning",
goodAfternoon: "Good afternoon", goodAfternoon: "Good afternoon",
goodEvening: "Good evening", goodEvening: "Good evening",
+9
View File
@@ -10,6 +10,7 @@ export const sslTranslations: SSLTranslations = {
deleteSSLCertificate: "Delete SSL Certificate", deleteSSLCertificate: "Delete SSL Certificate",
sslCertificateDetails: "SSL Certificate Details", sslCertificateDetails: "SSL Certificate Details",
detailedInfo: "Detailed information for", detailedInfo: "Detailed information for",
viewDetailedInformation: "View detailed information for",
// Status related // Status related
valid: "Valid", valid: "Valid",
@@ -48,11 +49,16 @@ export const sslTranslations: SSLTranslations = {
validFrom: "Valid From", validFrom: "Valid From",
validUntil: "Valid Until", validUntil: "Valid Until",
validityDays: "Validity Days", validityDays: "Validity Days",
validityPeriod: "Validity Period",
organization: "Organization", organization: "Organization",
commonName: "Common Name", commonName: "Common Name",
serialNumber: "Serial Number", serialNumber: "Serial Number",
algorithm: "Algorithm", algorithm: "Algorithm",
subjectAltNames: "Subject Alternative Names", subjectAltNames: "Subject Alternative Names",
subjectAlternativeNames: "Subject Alternative Names",
resolvedIP: "Resolved IP",
issuedTo: "Issued To",
days: "days",
// Buttons and actions // Buttons and actions
addDomain: "Add Domain", addDomain: "Add Domain",
@@ -72,8 +78,11 @@ export const sslTranslations: SSLTranslations = {
validity: "Validity", validity: "Validity",
issuerInfo: "Issuer Information", issuerInfo: "Issuer Information",
technicalDetails: "Technical Details", technicalDetails: "Technical Details",
technicalInformation: "Technical Information",
monitoringConfig: "Monitoring Configuration", monitoringConfig: "Monitoring Configuration",
monitoringConfiguration: "Monitoring Configuration",
recordInfo: "Record Information", recordInfo: "Record Information",
certificateDetails: "Certificate Details",
// Notifications and messages // Notifications and messages
sslCertificateAdded: "SSL Certificate added successfully", sslCertificateAdded: "SSL Certificate added successfully",
+3 -1
View File
@@ -2,14 +2,16 @@ import enTranslations from './en';
import kmTranslations from './km'; import kmTranslations from './km';
import deTranslations from './de'; import deTranslations from './de';
import jaTranslations from './ja'; import jaTranslations from './ja';
import zhcnTranslations from './zhcn';
export type Language = "en" | "km" | "de" | "ja"; export type Language = "en" | "km" | "de" | "ja" | "zhcn";
export const translations = { export const translations = {
en: enTranslations, en: enTranslations,
km: kmTranslations, km: kmTranslations,
de: deTranslations, de: deTranslations,
ja: jaTranslations, ja: jaTranslations,
zhcn: zhcnTranslations,
}; };
// Type for accessing translations by module and key // Type for accessing translations by module and key
+2 -1
View File
@@ -7,6 +7,7 @@ export const commonTranslations: CommonTranslations = {
english: "English", english: "English",
khmer: "Khmer", khmer: "Khmer",
german: "Deutsch", german: "Deutsch",
simplifiedChinese: "简体中文",
japanese: "日本語", japanese: "日本語",
goodMorning: "おはようございます", goodMorning: "おはようございます",
goodAfternoon: "こんにちは", goodAfternoon: "こんにちは",
@@ -26,4 +27,4 @@ export const commonTranslations: CommonTranslations = {
description: "説明", description: "説明",
success: "成功", success: "成功",
error: "エラー", error: "エラー",
}; };
+9
View File
@@ -9,6 +9,7 @@ export const sslTranslations: SSLTranslations = {
deleteSSLCertificate: "SSL証明書を削除", deleteSSLCertificate: "SSL証明書を削除",
sslCertificateDetails: "SSL証明書詳細", sslCertificateDetails: "SSL証明書詳細",
detailedInfo: "詳細情報:", detailedInfo: "詳細情報:",
viewDetailedInformation: "View detailed information for",
// ステータス関連 // ステータス関連
valid: "有効", valid: "有効",
@@ -47,11 +48,16 @@ export const sslTranslations: SSLTranslations = {
validFrom: "有効開始日", validFrom: "有効開始日",
validUntil: "有効終了日", validUntil: "有効終了日",
validityDays: "有効期間", validityDays: "有効期間",
validityPeriod: "Validity Period",
organization: "組織", organization: "組織",
commonName: "コモンネーム", commonName: "コモンネーム",
serialNumber: "シリアル番号", serialNumber: "シリアル番号",
algorithm: "アルゴリズム", algorithm: "アルゴリズム",
subjectAltNames: "サブジェクト代替名", subjectAltNames: "サブジェクト代替名",
subjectAlternativeNames: "Subject Alternative Names",
resolvedIP: "Resolved IP",
issuedTo: "Issued To",
days: "days",
// ボタンとアクション // ボタンとアクション
addDomain: "ドメインを追加", addDomain: "ドメインを追加",
@@ -71,8 +77,11 @@ export const sslTranslations: SSLTranslations = {
validity: "有効性", validity: "有効性",
issuerInfo: "発行者情報", issuerInfo: "発行者情報",
technicalDetails: "技術的詳細", technicalDetails: "技術的詳細",
technicalInformation: "Technical Information",
monitoringConfig: "監視設定", monitoringConfig: "監視設定",
monitoringConfiguration: "Monitoring Configuration",
recordInfo: "レコード情報", recordInfo: "レコード情報",
certificateDetails: "Certificate Details",
// 通知とメッセージ // 通知とメッセージ
sslCertificateAdded: "SSL証明書が正常に追加されました", sslCertificateAdded: "SSL証明書が正常に追加されました",
@@ -8,6 +8,7 @@ export const commonTranslations: CommonTranslations = {
english: "អង់គ្លេស", english: "អង់គ្លេស",
khmer: "ខ្មែរ", khmer: "ខ្មែរ",
german: "Deutsch", german: "Deutsch",
simplifiedChinese: "简体中文",
goodMorning: "អរុណសួស្តី", goodMorning: "អរុណសួស្តី",
goodAfternoon: "ទិវាសួស្តី", goodAfternoon: "ទិវាសួស្តី",
goodEvening: "សាយណ្ហសួស្តី", goodEvening: "សាយណ្ហសួស្តី",
+10 -1
View File
@@ -9,7 +9,8 @@ export const sslTranslations: SSLTranslations = {
editSSLCertificate: "កែសម្រួលវិញ្ញាបនបត្រ SSL", editSSLCertificate: "កែសម្រួលវិញ្ញាបនបត្រ SSL",
deleteSSLCertificate: "លុបវិញ្ញាបនបត្រ SSL", deleteSSLCertificate: "លុបវិញ្ញាបនបត្រ SSL",
sslCertificateDetails: "ព័ត៌មានលម្អិតនៃវិញ្ញាបនបត្រ SSL", sslCertificateDetails: "ព័ត៌មានលម្អិតនៃវិញ្ញាបនបត្រ SSL",
detailedInfo: "ព័ត៌មានលម្អិតសម្រាប់", detailedInfo: "ព័ត៌មានលម្អិត",
viewDetailedInformation: "មើលព័ត៌មានលម្អិត",
// Status related // Status related
valid: "មានសុពលភាព", valid: "មានសុពលភាព",
@@ -48,11 +49,16 @@ export const sslTranslations: SSLTranslations = {
validFrom: "មានសុពលភាពចាប់ពី", validFrom: "មានសុពលភាពចាប់ពី",
validUntil: "មានសុពលភាពរហូតដល់", validUntil: "មានសុពលភាពរហូតដល់",
validityDays: "ថ្ងៃសុពលភាព", validityDays: "ថ្ងៃសុពលភាព",
validityPeriod: "រយៈពេលសុពលភាព",
organization: "អង្គការ", organization: "អង្គការ",
commonName: "ឈ្មោះទូទៅ", commonName: "ឈ្មោះទូទៅ",
serialNumber: "លេខសៀរៀល", serialNumber: "លេខសៀរៀល",
algorithm: "អាល់ហ្គោរីត", algorithm: "អាល់ហ្គោរីត",
subjectAltNames: "ឈ្មោះផ្សេងៗដែលអាចប្រើបាន", subjectAltNames: "ឈ្មោះផ្សេងៗដែលអាចប្រើបាន",
subjectAlternativeNames: "ឈ្មោះជំនួសផ្សេងៗ",
resolvedIP: "IP របស់ដូមែន",
issuedTo: "ចេញសម្រាប់",
days: "ថ្ងៃ",
// Buttons and actions // Buttons and actions
addDomain: "បន្ថែមដូមេន", addDomain: "បន្ថែមដូមេន",
@@ -72,8 +78,11 @@ export const sslTranslations: SSLTranslations = {
validity: "សុពលភាព", validity: "សុពលភាព",
issuerInfo: "ព័ត៌មានអ្នកចេញផ្សាយ", issuerInfo: "ព័ត៌មានអ្នកចេញផ្សាយ",
technicalDetails: "ព័ត៌មានបច្ចេកទេស", technicalDetails: "ព័ត៌មានបច្ចេកទេស",
technicalInformation: "ព័ត៌មានបច្ចេកទេស",
monitoringConfig: "ការកំណត់រចនាសម្ព័ន្ធការតាមដាន", monitoringConfig: "ការកំណត់រចនាសម្ព័ន្ធការតាមដាន",
monitoringConfiguration: "ការកំណត់រចនាសម្ព័ន្ធការតាមដាន",
recordInfo: "ព័ត៌មានកំណត់ត្រា", recordInfo: "ព័ត៌មានកំណត់ត្រា",
certificateDetails: "ព័ត៌មានលម្អិតវិញ្ញាបនបត្រ",
// Notifications and messages // Notifications and messages
sslCertificateAdded: "វិញ្ញាបនបត្រ SSL បានបន្ថែមដោយជោគជ័យ", sslCertificateAdded: "វិញ្ញាបនបត្រ SSL បានបន្ថែមដោយជោគជ័យ",
@@ -5,6 +5,9 @@ export interface CommonTranslations {
language: string; language: string;
english: string; english: string;
khmer: string; khmer: string;
german: string;
japanese: string;
simplifiedChinese: string;
goodMorning: string; goodMorning: string;
goodAfternoon: string; goodAfternoon: string;
goodEvening: string; goodEvening: string;
+10 -1
View File
@@ -8,6 +8,7 @@ export interface SSLTranslations {
deleteSSLCertificate: string; deleteSSLCertificate: string;
sslCertificateDetails: string; sslCertificateDetails: string;
detailedInfo: string; detailedInfo: string;
viewDetailedInformation: string;
// Status related // Status related
valid: string; valid: string;
@@ -46,11 +47,16 @@ export interface SSLTranslations {
validFrom: string; validFrom: string;
validUntil: string; validUntil: string;
validityDays: string; validityDays: string;
validityPeriod: string;
organization: string; organization: string;
commonName: string; commonName: string;
serialNumber: string; serialNumber: string;
algorithm: string; algorithm: string;
subjectAltNames: string; subjectAltNames: string;
subjectAlternativeNames: string;
resolvedIP: string;
issuedTo: string;
days: string;
// Buttons and actions // Buttons and actions
addDomain: string; addDomain: string;
@@ -70,8 +76,11 @@ export interface SSLTranslations {
validity: string; validity: string;
issuerInfo: string; issuerInfo: string;
technicalDetails: string; technicalDetails: string;
technicalInformation: string;
monitoringConfig: string; monitoringConfig: string;
monitoringConfiguration: string;
recordInfo: string; recordInfo: string;
certificateDetails: string;
// Notifications and messages // Notifications and messages
sslCertificateAdded: string; sslCertificateAdded: string;
@@ -102,4 +111,4 @@ export interface SSLTranslations {
lastUpdated: string; lastUpdated: string;
lastNotification: string; lastNotification: string;
collectionId: string; collectionId: string;
} }
@@ -0,0 +1,18 @@
import { AboutTranslations } from '../types/about';
export const aboutTranslations: AboutTranslations = {
aboutCheckcle: "关于 Checkcle",
systemDescription: "Checkcle 是一个开源监控平台,可提供有关服务器和服务健康状况的实时洞察、事件管理以及透明化运营。以 MIT 许可证发布。",
systemVersion: "系统版本",
license: "许可证",
mitLicense: "MIT 许可证",
links: "链接",
viewOnGithub: "在 GitHub 上查看",
viewDocumentation: "查看文档",
followOnX: "在 X 上关注",
joinDiscord: "加入 Discord",
quickActions: "快速操作",
quickActionsDescription: "快速访问常用的监控操作和功能。选择下面的操作开始。",
quickTips: "快速提示",
};
@@ -0,0 +1,30 @@
import { CommonTranslations } from '../types/common';
export const commonTranslations: CommonTranslations = {
welcome: "欢迎",
logout: "注销",
language: "语言",
english: "English",
khmer: "Khmer",
german: "Deutsch",
simplifiedChinese: "简体中文",
goodMorning: "早上好",
goodAfternoon: "下午好",
goodEvening: "晚上好",
profile: "个人资料",
settings: "设置",
documentation: "文档",
notifications: "通知",
close: "关闭",
cancel: "取消",
view: "查看",
edit: "编辑",
delete: "删除",
status: "状态",
time: "时间",
title: "标题",
description: "描述",
success: "成功",
error: "错误",
};
@@ -0,0 +1,55 @@
import { IncidentTranslations } from '../types/incident';
export const incidentTranslations: IncidentTranslations = {
incidentManagement: '事件管理',
incidentsManagementDesc: '跟踪和管理服务事件及其解决方案',
unresolvedIncidents: '未解决',
resolvedIncidents: '已解决',
activeIncidents: '活动事件',
criticalIssues: '关键问题',
avgResolutionTime: '平均解决时间',
noIncidents: '没有活动事件',
createIncident: '创建事件',
investigating: '调查中',
identified: '已识别',
monitoring: '监控',
resolved: '已解决',
scheduleIncidentManagement: '计划与事件管理',
incidentName: '事件名称',
incidentStatus: '事件状态',
highPriority: '高优先级',
configurationSettings: '配置设置',
incidentCreatedSuccess: '事件创建成功',
basicInfo: '基本信息',
serviceId: '服务 ID',
assignedTo: '分配给',
unassigned: '未分配',
timeline: '时间线',
incidentTime: '事件时间',
resolutionTime: '解决时间',
systems: '系统',
noSystems: '没有受影响的系统',
impactAnalysis: '影响分析',
rootCause: '根本原因',
resolutionSteps: '解决步骤',
lessonsLearned: '经验',
resolutionDetails: '解决详情',
assignment: '分配',
download: '下载',
downloadPdf: '下载 PDF',
print: '打印',
confidentialNote: '此文档为机密文件,仅供内部使用。',
generatedOn: '生成时间',
enterResolutionSteps: '输入解决此事件的步骤',
enterLessonsLearned: '输入从此事件中学到的经验',
editIncident: '编辑事件',
editIncidentDesc: '更新此事件的详细信息',
updating: '更新中...',
update: '更新',
create: '创建',
creating: '创建中...',
configuration: '配置',
failedToUpdateStatus: '更新状态失败',
inProgress: '进行中',
};
@@ -0,0 +1,25 @@
import { Translations } from '../types';
import { commonTranslations } from './common';
import { menuTranslations } from './menu';
import { loginTranslations } from './login';
import { aboutTranslations } from './about';
import { servicesTranslations } from './services';
import { maintenanceTranslations } from './maintenance';
import { incidentTranslations } from './incident';
import { sslTranslations } from './ssl';
import { settingsTranslations } from './settings';
const zhcnTranslations: Translations = {
common: commonTranslations,
menu: menuTranslations,
login: loginTranslations,
about: aboutTranslations,
services: servicesTranslations,
maintenance: maintenanceTranslations,
incident: incidentTranslations,
ssl: sslTranslations,
settings: settingsTranslations
};
export default zhcnTranslations;
@@ -0,0 +1,23 @@
import { LoginTranslations } from '../types/login';
export const loginTranslations: LoginTranslations = {
signInToYourAccount: "登录您的账号",
dontHaveAccount: "没有账号?",
createOne: "创建一个",
signInWithGoogle: "使用 Google 登录",
orContinueWith: "或",
email: "邮箱",
password: "密码",
forgot: "忘记密码?",
signIn: "登录",
signingIn: "登录中...",
loginSuccessful: "登录成功",
loginSuccessMessage: "您已成功登录。",
loginFailed: "登录失败",
authenticationFailed: "身份验证失败",
bySigningIn: "通过登录,您同意我们的",
termsAndConditions: "条款与条件",
and: "和",
privacyPolicy: "隐私政策",
};
@@ -0,0 +1,68 @@
import { MaintenanceTranslations } from '../types/maintenance';
export const maintenanceTranslations: MaintenanceTranslations = {
scheduledMaintenance: '计划维护',
scheduledMaintenanceDesc: '查看和管理您系统和服务的计划维护窗口',
upcomingMaintenance: '即将',
ongoingMaintenance: '进行中',
completedMaintenance: '已完成',
createMaintenanceWindow: '创建维护',
totalScheduledHours: '总计划小时',
maintenanceName: '维护名称',
maintenanceStatus: '状态',
scheduledStart: '计划开始时间',
scheduledEnd: '计划结束时间',
affectedServices: '受影响的服务',
impact: '影响',
minor: '次要',
major: '主要',
critical: '关键',
none: '无',
actions: '操作',
scheduled: '计划',
inprogress: '进行中',
completed: '已完成',
cancelled: '已取消',
markAsInProgress: '标记为进行中',
markAsCompleted: '标记为已完成',
markAsCancelled: '标记为已取消',
confirmDelete: '确认删除',
deleteMaintenanceConfirmation: '您确定要删除此维护窗口吗?',
thisActionCannotBeUndone: '此操作无法撤销。',
maintenanceDeleted: '维护已删除',
maintenanceDeletedDesc: '维护窗口已成功删除。',
errorDeletingMaintenance: '删除维护窗口时出错。',
statusUpdated: '状态更新',
maintenanceStatusUpdated: '维护状态已成功更新。',
errorUpdatingMaintenanceStatus: '更新维护状态时出错。',
createMaintenance: '创建维护',
createMaintenanceDesc: '为您的服务计划新的维护窗口',
enterTitle: '输入维护标题',
enterDescription: '输入详细的维护描述',
startTime: '开始时间',
endTime: '结束时间',
selectDate: '选择日期',
enterAffectedServices: '输入受影响的服务',
separateServicesWithComma: '用逗号分隔多个服务',
priority: '优先级',
selectPriority: '选择优先级',
selectStatus: '选择状态',
selectImpact: '选择影响',
notifySubscribers: '通知订阅者',
notifySubscribersDesc: '当此维护开始时,向所有订阅者发送通知',
maintenanceCreated: '创建维护',
maintenanceCreatedDesc: '维护窗口已成功计划。',
errorCreatingMaintenance: '创建维护窗口时出错。',
errorFetchingMaintenanceData: '获取维护数据时出错。',
low: '低',
medium: '中',
high: '高',
created: '创建',
lastUpdated: '最后更新',
subscribersWillBeNotified: '订阅者将在维护开始时收到通知',
noNotifications: '不会发送通知',
noScheduledMaintenance: '没有计划的维护',
noMaintenanceWindows: '没有此时间段的维护窗口。通过点击“创建维护”按钮创建一个。',
maintenanceCreatedSuccess: '维护窗口创建成功',
};
+21
View File
@@ -0,0 +1,21 @@
import { MenuTranslations } from '../types/menu';
export const menuTranslations: MenuTranslations = {
uptimeMonitoring: "Uptime 监控",
instanceMonitoring: "实例监控",
sslDomain: "SSL & 域名",
scheduleIncident: "计划与事件",
operationalPage: "运营页面",
reports: "报告",
regionalMonitoring: "区域监控",
settingPanel: "设置面板",
generalSettings: "一般设置",
userManagement: "用户管理",
notificationSettings: "通知设置",
alertsTemplates: "警报模板",
rolesManagement: "角色管理",
dataRetention: "数据保留",
backupSettings: "备份设置",
aboutSystem: "关于系统",
};
@@ -0,0 +1,12 @@
import { ServicesTranslations } from '../types/services';
export const servicesTranslations: ServicesTranslations = {
serviceName: "服务名称",
serviceType: "服务类型",
serviceStatus: "服务状态",
responseTime: "响应时间",
uptime: "Uptime",
lastChecked: "最后检查时间",
noServices: "没有符合您筛选条件的服务。",
};
@@ -0,0 +1,52 @@
import { SettingsTranslations } from '../types/settings';
export const settingsTranslations: SettingsTranslations = {
// Tabs
systemSettings: "系统设置",
mailSettings: "邮件设置",
// System Settings
appName: "应用名称",
appURL: "应用 URL",
senderName: "发送者名称",
senderEmail: "发送者邮箱地址",
hideControls: "隐藏控件",
// Mail Settings
smtpSettings: "SMTP 配置",
smtpEnabled: "启用 SMTP",
smtpHost: "SMTP 主机",
smtpPort: "SMTP 端口",
smtpUsername: "SMTP 用户名",
smtpPassword: "SMTP 密码",
smtpAuthMethod: "认证方法",
enableTLS: "启用 TLS",
localName: "本地名称",
// Test Email
testEmail: "测试邮箱",
sendTestEmail: "发送测试邮箱",
emailTemplate: "邮箱模板",
verification: "验证",
passwordReset: "密码重置",
confirmEmailChange: "确认邮箱变更",
otp: "OTP",
loginAlert: "登录警报",
authCollection: "认证集合",
selectCollection: "选择集合",
toEmailAddress: "收件人邮箱地址",
enterEmailAddress: "输入收件人邮箱地址",
sending: "发送中...",
// Actions and status
save: "保存变更",
saving: "保存中...",
settingsUpdated: "设置已成功更新",
errorSavingSettings: "保存设置时出错",
errorFetchingSettings: "加载设置时出错",
testConnection: "测试连接",
testingConnection: "测试连接中...",
connectionSuccess: "连接成功",
connectionFailed: "连接失败"
};
+116
View File
@@ -0,0 +1,116 @@
import { SSLTranslations } from '../types/ssl';
export const sslTranslations: SSLTranslations = {
// Page and section titles
sslDomainManagement: "SSL & 域名管理",
monitorSSLCertificates: "监控和管理您的域名的 SSL 证书",
addSSLCertificate: "添加 SSL 证书",
editSSLCertificate: "编辑 SSL 证书",
deleteSSLCertificate: "删除 SSL 证书",
sslCertificateDetails: "SSL 证书详情",
detailedInfo: "关于",
viewDetailedInformation: "查看详细信息",
// Status related
valid: "有效",
expiringSoon: "即将过期",
expired: "已过期",
pending: "待处理",
// Statistics and cards
validCertificates: "有效证书",
expiringSoonCertificates: "即将过期",
expiredCertificates: "已过期",
// Form fields
domain: "域",
domainName: "域名",
domainCannotChange: "域名创建后不能更改",
warningThreshold: "警告阈值",
warningThresholdDays: "警告阈值(天)",
expiryThreshold: "过期阈值",
expiryThresholdDays: "过期阈值(天)",
notificationChannel: "通知渠道",
chooseChannel: "选择通知渠道",
whereToSend: "通知渠道",
daysBeforeExpiration: "过期前警告天数",
daysBeforeCritical: "过期前关键警告天数",
getNotifiedExpiration: "获取证书即将过期的通知",
getNotifiedCritical: "获取证书即将过期的关键通知",
// Table headers and fields
issuer: "发行机构",
expirationDate: "过期日期",
daysLeft: "剩余天数",
status: "状态",
lastNotified: "最后通知时间",
actions: "操作",
validFrom: "有效从",
validUntil: "有效至",
validityDays: "有效期天数",
validityPeriod: "有效期",
organization: "组织",
commonName: "通用名称",
serialNumber: "序列号",
algorithm: "算法",
subjectAltNames: "主题备用名称",
subjectAlternativeNames: "主题备用名称",
resolvedIP: "解析 IP",
issuedTo: "颁发给",
days: "天",
// Buttons and actions
addDomain: "添加域名",
refreshAll: "刷新所有",
cancel: "取消",
addCertificate: "添加证书",
check: "检查",
view: "查看",
edit: "编辑",
delete: "删除",
close: "关闭",
saveChanges: "保存更改",
updating: "更新中",
// Sections in detail view
basicInformation: "基本信息",
validity: "有效期",
issuerInfo: "发行机构信息",
technicalDetails: "技术详情",
technicalInformation: "技术信息",
monitoringConfig: "监控配置",
monitoringConfiguration: "监控配置",
recordInfo: "记录信息",
certificateDetails: "证书详情",
// Notifications and messages
sslCertificateAdded: "SSL 证书添加成功",
sslCertificateUpdated: "SSL 证书更新成功",
sslCertificateDeleted: "SSL 证书删除成功",
sslCertificateRefreshed: "SSL 证书为 {domain} 刷新成功",
allCertificatesRefreshed: "所有 {count} 证书刷新成功",
someCertificatesFailed: "{success} 证书刷新成功,{failed} 失败",
failedToAddCertificate: "添加 SSL 证书失败",
failedToLoadCertificates: "加载 SSL 证书失败",
failedToUpdateCertificate: "更新 SSL 证书失败",
failedToDeleteCertificate: "删除 SSL 证书失败",
failedToCheckCertificate: "检查 SSL 证书失败",
noCertificatesToRefresh: "没有证书可刷新",
startingRefreshAll: "开始刷新 {count} 证书",
checkingSSLCertificate: "检查 SSL 证书...",
deleteConfirmation: "确定要删除 {domain} 的证书吗?",
deleteWarning: "此操作无法撤销。这将永久删除证书。",
// Misc
unknown: "未知",
never: "从不",
none: "无",
loadingChannels: "加载通知渠道...",
noChannelsFound: "未找到通知渠道",
noSSLCertificates: "未找到 SSL 证书",
created: "创建时间",
lastUpdated: "最后更新时间",
lastNotification: "最后通知时间",
collectionId: "集合 ID"
};
+2
View File
@@ -21,6 +21,8 @@ export interface Server {
template_id: string; template_id: string;
threshold_id: string; threshold_id: string;
notification_id: string; notification_id: string;
notification_status: boolean;
max_retries: number;
timestamp: string; timestamp: string;
connection: string; connection: string;
agent_status: string; agent_status: string;
+8
View File
@@ -1,3 +1,4 @@
export interface SSLCertificate { export interface SSLCertificate {
id: string; id: string;
domain: string; domain: string;
@@ -15,6 +16,10 @@ export interface SSLCertificate {
warning_threshold: number; warning_threshold: number;
expiry_threshold: number; expiry_threshold: number;
notification_channel: string; notification_channel: string;
alert_template?: string; // New field for SSL alert template
// PocketBase specific fields
notification_id?: string; // Multi notification channels as comma-separated string
template_id?: string; // Alert template ID for PocketBase
last_notified?: string; last_notified?: string;
created?: string; created?: string;
updated?: string; updated?: string;
@@ -33,5 +38,8 @@ export interface AddSSLCertificateDto {
warning_threshold: number; warning_threshold: number;
expiry_threshold: number; expiry_threshold: number;
notification_channel: string; notification_channel: string;
alert_template?: string; // New field for SSL alert template
notification_id?: string; // Multi notification channels as comma-separated string
template_id?: string; // Alert template ID for PocketBase
check_interval?: number; // New field for check interval in days check_interval?: number; // New field for check interval in days
} }
+5 -5
View File
@@ -2,12 +2,12 @@
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
export const copyToClipboard = async (text: string) => { export const copyToClipboard = async (text: string) => {
console.log('copyToClipboard called with text:', text); // Debug log // console.log('copyToClipboard called with text:', text); // Debug log
try { try {
// Try modern clipboard API first // Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
console.log('Using modern clipboard API'); // Debug log // console.log('Using modern clipboard API'); // Debug log
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
toast({ toast({
title: "Copied!", title: "Copied!",
@@ -16,7 +16,7 @@ export const copyToClipboard = async (text: string) => {
return; return;
} }
console.log('Using fallback clipboard method'); // Debug log // console.log('Using fallback clipboard method'); // Debug log
// Fallback for older browsers or non-secure contexts // Fallback for older browsers or non-secure contexts
const textArea = document.createElement("textarea"); const textArea = document.createElement("textarea");
@@ -39,7 +39,7 @@ export const copyToClipboard = async (text: string) => {
document.body.removeChild(textArea); document.body.removeChild(textArea);
if (successful) { if (successful) {
console.log('Copy successful with execCommand'); // Debug log // console.log('Copy successful with execCommand'); // Debug log
toast({ toast({
title: "Copied!", title: "Copied!",
description: "Content copied to clipboard successfully.", description: "Content copied to clipboard successfully.",
@@ -48,7 +48,7 @@ export const copyToClipboard = async (text: string) => {
throw new Error('Copy command failed'); throw new Error('Copy command failed');
} }
} catch (error) { } catch (error) {
console.error('Failed to copy to clipboard:', error); // console.error('Failed to copy to clipboard:', error);
// Show error toast // Show error toast
toast({ toast({
+35
View File
@@ -0,0 +1,35 @@
name: checkcle-server-agent
services:
checkcle-server-agent:
container_name: monitoring-agent
restart: unless-stopped
ports:
- "8081:8081"
group_add:
- 999
environment:
AGENT_ID: agent_124343535
SERVER_NAME: "test"
SERVER_TOKEN: srv_123243434
POCKETBASE_URL: http://x.x.x.x:8090
POCKETBASE_ENABLED: "true"
volumes:
- /proc:/host/proc:ro
- /etc:/host/etc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
- /var/run:/host/var/run:ro
- /dev:/host/dev:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- monitoring_data:/var/lib/monitoring-agent
- monitoring_logs:/var/log/monitoring-agent
image: operacle/checkcle-server-agent:latest
volumes:
monitoring_data:
external: true
name: monitoring_data
monitoring_logs:
external: true
name: monitoring_logs

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