33 Commits

Author SHA1 Message Date
Tola Leng 257718e9d6 feat: Add notification, integrate data retention service, and improve i18n support (#164)
* fix(ssl):  Ensure edit form saves notification_id and template_id in DB.

- The SSL edit form was not saving the `notification_id` and `template_id` fields to the PB database when re-assigning the Notification Channel and Alert Template.

* chore(partners): add Cloudflare to Ecosystem & Community Partner section

- Cloudflare has been added to the Ecosystem & Community Partner list to acknowledge their support and contribution.

*  chore(project): update development status and progress

- fix(ssl): ensure edit form saves notification_id and template_id in DB
- feat(notifications): add Notifiarr notification channel (planned)

* feat: Add Notifiarr to notification channels (UI)

- Add Notifiarr as a new channel type in the notification settings. The Notifiarr channel will use the `api_token` field for API key storage in PB.

* feat: Add channel_id field for Notifiarr

- Add channel_id field for Notifiarr to store record of the discord channel_id into the PB.

* feat: Implement Notifiarr notification service

- The Notifiarr notification functionality has been implemented. The alert configuration channel will now use the `api_token` and `channel_id` field for send to the Notifiarr API endpoint.

*  chore(project): update development status and progress

- feat: Add channel_id field for Notifiarr
- feat: Implement Notifiarr notification service

Closes: #133

* feat: add SSL history and Notifiarr notification channel schema

* Fix: Remove duplicate close button

- Removed the duplicate close button from the test email dialog.

* feat: Add Gotify to notification channels

- Add Gotify  as a new channel type in the notification settings. The Gotify channel will use the `api_token` and `server_url` field for API key storage in PB.

* feat: Implement Gotify notification service

- The Gotify notification functionality has been implemented. The alert configuration channel will now use the `api_token` and `server_url` field for send to the Gotify API endpoint.

*  chore(project): update development status and progress
- feat: Add server_url field for Gotify
- feat: Implement Gotify notification service

* refactory(i18n): Improve internationalization configuration and add new translations

- Added multiple new translation items
- Updated the two language files: en and zhcn
- Added type definitions for new translation items in types/services.ts
- Modified some components to use the new translation items

* refactory(i18n): Added architecture import function translation for the "About" page

- Added translation items related to schema import in English, Chinese, and type definition files
- Replaced hard-coded text with new translation items in the AboutSystem component

* refactory(i18n): Added architecture import function translation for the "About" page

- Added translation items related to schema import in English, Chinese, and type definition files
- Replaced hard-coded text with new translation items in the AboutSystem component

* refactory(i18n): Add internationalization support for the service statistics card

* feat: Add NTFY API token field for Token-based authentication to ntfy server

- Add the api_token field for NTFY notifications in both the dialog and service to save it to PB. NTFY schema to include api_token field.

* feat(auth): add token support for ntfy

- Updated the NTFY service to support API token authentication by adding Authorization Bearer header when api_token is provided.

Closes: #89

* refactor(i18n): Added Chinese translations for Create Event and Maintenance

- Supplemented missing Chinese translations
- Added translation entries related to Create Event and Maintenance in both English and Chinese translation files

* chore: deactivate GitHub donation

* refactor(i18n): Optimize internationalization configuration and add new translation items

- Add internationalization support to the LoadingState component
- Implement internationalization translations in the Profile page
- Use internationalized text in the TestEmailDialog component
- Update English and Chinese translation files by adding new translation entries

* docs(readme): deactivate donations, accept only infra support (cloud, domain, hosting)

* feat(i18n): Enhanced internationalization support with variable interpolation

- Refactored the `t` function in LanguageContext to support multiple parameter combinations
- Added variable interpolation feature, allowing variables in translated texts
- Optimized translation logic to improve accuracy and performance
- Enhanced type safety by using TypeScript generics to ensure correct typing

* refactor(i18n): Optimize service pagination internationalization copy

- Added service pagination related copy in English and Chinese translation files
- Updated type definitions to include new translation fields
- Modified the ServicesPagination component to use the newly added translation fields

* docs(README_zhcn): Update documentation content and sponsorship policy

- Update image links in the documentation
- Revise the sponsorship policy to no longer accept individual sponsorships, only enterprise-level collaborations
- Update the display method for supporters and partners
- Add Cloudflare and DigitalOcean as new partners

* feat: Integrate data retention service

- Implemented the data retention service in Go (service-operation) that manages cleanup of old records based on configured retention periods. The service runs once per day to ensure outdated data is removed automatically.

* refactor: Remove manual cleanup buttons from Data Retention Settings

- The three manual cleanup buttons have been removed from the dashboard.
- The data retention service now automatically runs once per day, cleaning up older data based on the configured retention settings.

---------

Co-authored-by: YiZixuan <sqkkyzx@qq.com>
Co-authored-by: YiZixuan <sqkkyzx@outlook.com>
2025-09-13 21:38:38 +07:00
Tola Leng c1464202c7 Merge pull request #155 from operacle/develop
Merge pull request  from operacle/develop
2025-08-29 00:45:32 +07:00
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 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 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 af64ac31ea Merge pull request #83 from operacle/develop
Fix: Display new service immediately
2025-07-23 22:29:48 +07:00
Tola Leng 72984f5da4 Merge pull request #79 from operacle/develop
feat: Add create instance monitoring agent and Add Uptime Heatmap to service detail page
2025-07-22 16:08:14 +07:00
Tola Leng cc132a66d9 Merge pull request #70 from operacle/develop
- Improve service listing to display all records correctly
- Fetch all data and handle pagination for better UX
- Improve SSL Certs listing to display all records correctly
- Added ISSUE TEMPLATE 
- Added CODE OF CONDUCT
2025-07-15 22:18:48 +07:00
Tola Leng 3f975db568 Merge pull request #68 from operacle/develop
- feat: Implement notification channels based on status (Service dialog form) and refactor Service form into smaller components
- feat: Allow multiple selections for regional agents and support multiple notifications in the Service form
- feat: Add Japanese language support. Thank you @gnsworks for the contribution!
2025-07-13 18:44:44 +07:00
Tola Leng 547a4c4041 Merge pull request #65 from operacle/develop
feat: Implement notification channel based on status(multi selection) and Refactor Split ServiceForm into smaller components.
2025-07-10 22:17:02 +07:00
Tola Leng e29f60447d Merge pull request #64 from operacle/develop
feat: Improve server overview cards and fix regional_status to Service type
2025-07-08 22:03:15 +07:00
Tola Leng ad869c17aa add codeowners and pull request workflow 2025-07-03 21:03:09 +07:00
Tola Leng 6702bfa5e3 Merge pull request #62 from operacle/develop
Fix: Instance monitoring dashboard styling and Improved docker compose configuration
2025-07-03 20:59:59 +07:00
Tola Leng cb6946b7dd Create FUNDING.yml 2025-07-03 20:56:11 +07:00
Tola Leng 8bd8ae25e6 Merge pull request #61 from operacle/develop
Fix: Server Historical Performance Metrics date filter and improved the container entrypoint script pipeline
2025-07-02 20:05:50 +07:00
Tola Leng eddc12018f Merge pull request #60 from operacle/develop
Fix: Server history chart and date filter and Added Distributed Regional Monitoring Dockerfile
2025-07-01 20:07:23 +07:00
Tola Leng 474a439515 Merge pull request #59 from operacle/develop
feat: Implement instance and docker monitoring dashboard and detail metrics page
2025-06-30 23:07:58 +07:00
Tola Leng 9a4cc7c3db Merge pull request #57 from operacle/develop
Refactor: Separate data display by monitoring source and feat: Implement "Regional Monitoring" option
2025-06-26 16:28:32 +07:00
Tola Leng 23f3c5b8ca Merge pull request #53 from operacle/develop
Integrate regional agents in service-operation and Enhance Regional Agent Installation
2025-06-21 21:49:19 +07:00
Tola Leng f88cc32cf9 Merge pull request #52 from operacle/develop
Implement SSL certificate checker in Go instead of TS and feat: Add error message and details columns
2025-06-20 20:24:55 +07:00
Tola Leng 9f4e759b01 Merge pull request #51 from operacle/develop
Implement CheckCle Microservice Operation
2025-06-19 17:51:39 +07:00
Tola Leng 9ed4d4c7ac Merge pull request #50 from operacle/develop
feat: New Create Service Form and  Split UptimeBar.tsx into smaller components
2025-06-19 15:04:07 +07:00
Tola Leng 9a001f6aec Merge pull request #49 from operacle/develop
Remove HTTP service check and Refactor: Split settings API into modules
2025-06-18 22:09:40 +07:00
Tola Leng cfdedb9636 Merge pull request #45 from operacle/develop
Fix: Implement public status page functionality
2025-06-15 20:49:51 +07:00
Tola Leng 9d5fa805c9 Merge pull request #42 from operacle/develop
Implement password reset functionality.
2025-06-12 17:15:08 +07:00
Tola Leng 68a4ad5478 Merge pull request #38 from operacle/develop
Refactor: Split Sidebar.tsx into smaller components
2025-06-08 21:46:27 +07:00
Tola Leng 9b1918e25a Merge pull request #36 from operacle/develop
feat: Implement Operational and Public Status Page Feature
2025-06-06 19:23:54 +07:00
Tola Leng 1dc25cc461 Merge pull request #35 from operacle/develop
feat: Add mail test functionality
2025-06-06 16:48:46 +07:00
Tola Leng 4dc005c7e4 Merge pull request #33 from operacle/develop
Add SMTP password field in Mail Settings and  Fix: Ensure Add Service dialog respects theme
2025-06-05 17:40:17 +07:00
Tola Leng 4ec0c7a8fa Merge pull request #32 from operacle/develop
Fix: Improve email change flow and auto-logout and feat: Implement uptime monitoring data retention
2025-06-04 16:33:42 +07:00
Tola Leng dd758b9334 Merge pull request #27 from operacle/develop
feat: Implement uptime monitoring data retention
2025-05-31 17:44:43 +07:00
Tola Leng aec7118496 Merge pull request #25 from operacle/develop
feat: Auto logout after password change
2025-05-28 15:37:53 +07:00
Tola Leng 759c3249e1 Merge pull request #22 from operacle/develop
feat: release maintenance and incident management features; apply hotfixes
2025-05-26 21:57:45 +07:00
54 changed files with 2148 additions and 237 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# These are supported funding model platforms
github: [tolaleng]
github: #[tolaleng]
patreon: # Replace with a single Patreon username
open_collective: #checkcle
ko_fi: # Replace with a single Ko-fi username
+8 -19
View File
@@ -128,19 +128,9 @@ docker run -d \
---
## Sponsors
🙏 We're incredibly thankful to our sponsors. Your contributions enable us to maintain infrastructure (hosting, domains), run tests, and continue developing valuable features.
Sponsorships will no longer be accepted. From now on, support will only be accepted in the form of ecosystem and community partnerships that provide infrastructure such as cloud servers, domains, or hosting credits.
### 🥈 Silver Uptime
<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>
If youre a tech company interested in supporting CheckCle, please contact the author directly at tolaleng@checkcle.io
### 🤝 Ecosystem & Community Partner
<div style="display: flex; align-items: center; gap: 10px;">
@@ -151,19 +141,18 @@ docker run -d \
alt="GitBook Logo" />
</a>
<a href="https://m.do.co/c/0c27ef82475f">
<a href="https://www.cloudflare.com">
<img src="https://cdn.checkcle.io/images/sponsor/cloudflare-checkcle_logo.png"
height="60"
alt="Cloudflare Logo" />
</a>
<a href="https://m.do.co/c/0c27ef82475f">
<img src="https://cdn.checkcle.io/images/sponsor/digitalocean_checkcle.png"
height="50"
alt="DigitalOcean Logo" />
</a>
</div>
---
@@ -153,7 +153,8 @@
"google_chat",
"email",
"ntfy",
"pushover"
"pushover",
"notifiarr"
]
},
{
@@ -4823,6 +4824,169 @@
"indexes": [],
"system": false
},
{
"id": "pbc_1753185970",
"listRule": "",
"viewRule": "",
"createRule": "",
"updateRule": "",
"deleteRule": "",
"name": "ssl_history",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text2812878347",
"max": 0,
"min": 0,
"name": "cert_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1881225410",
"max": 0,
"min": 0,
"name": "days_before_expiry",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text3062427817",
"max": 0,
"min": 0,
"name": "days_before_warning",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text98176952",
"max": 0,
"min": 0,
"name": "template_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text4011498884",
"max": 0,
"min": 0,
"name": "notification_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "select3839288252",
"maxSelect": 1,
"name": "warning_status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"sent",
"pending",
"failed"
]
},
{
"hidden": false,
"id": "select2670434693",
"maxSelect": 1,
"name": "expiry_status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"sent",
"pending",
"failed"
]
},
{
"hidden": false,
"id": "select3824245652",
"maxSelect": 1,
"name": "critical_status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"sent",
"pending",
"failed"
]
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [],
"system": false
},
{
"id": "pbc_351174173",
"listRule": "",
@@ -5479,4 +5643,4 @@
"indexes": [],
"system": false
}
]
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

@@ -2,6 +2,7 @@
import React from "react";
import { Clock, TimerOff } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useLanguage } from "@/contexts/LanguageContext";
interface LastCheckedTimeProps {
lastCheckedTime: string;
@@ -10,6 +11,7 @@ interface LastCheckedTimeProps {
}
export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => {
const { t } = useLanguage();
// Format the time without seconds to display a static time
const formatTimeWithoutSeconds = (timeString: string) => {
try {
@@ -55,7 +57,7 @@ export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheck
<Clock className="h-4 w-4" />
)}
<span>
{isPaused ? "Paused at " : ""}
{isPaused ? t("pausedAt") : ""}
{formattedTime}
</span>
</div>
@@ -66,14 +68,14 @@ export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheck
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">
{isPaused ? "Monitoring Paused" : "Last Check Details"}
{isPaused ? t("monitoringPaused") : t("lastCheckDetails")}
</div>
<div>
{isPaused ? "No automatic checks" : `Checked at ${formattedTime}`}
{isPaused ? t("noAutomaticChecks") : t("checkedAt") + `${formattedTime}`}
</div>
{interval && !isPaused && (
<div>
Check interval: {formatInterval(interval)}
{t("checkInterval")}: {formatInterval(interval)}
</div>
)}
<div className="text-gray-400 text-[10px]">
@@ -1,5 +1,7 @@
import {useLanguage} from "@/contexts/LanguageContext.tsx";
export function LoadingState() {
const { t } = useLanguage();
return (
<div className="flex items-center justify-center h-screen bg-background text-foreground">
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
@@ -7,8 +9,8 @@ export function LoadingState() {
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
<h3 className="text-xl font-medium mb-1">Loading server data</h3>
<p className="text-muted-foreground">Please wait while we retrieve your information...</p>
<h3 className="text-xl font-medium mb-1">{t("loadingServerData")}</h3>
<p className="text-muted-foreground">{t("retrievingYourInformation")}</p>
</div>
</div>
);
@@ -11,6 +11,7 @@ import { Service } from "@/types/service.types";
import { useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServiceEditDialogProps {
open: boolean;
@@ -19,6 +20,7 @@ interface ServiceEditDialogProps {
}
export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) {
const { t } = useLanguage();
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -53,9 +55,9 @@ export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDi
}}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl">Edit Service</DialogTitle>
<DialogTitle className="text-xl">{t("editService")}</DialogTitle>
<DialogDescription>
Update the details of your monitored service.
{t("editServiceDesc")}
</DialogDescription>
</DialogHeader>
{open && service && (
@@ -4,6 +4,7 @@ import { Service, UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useMemo } from "react";
import { formatDistanceStrict } from "date-fns";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceStatsCardsProps {
service: Service;
@@ -11,6 +12,8 @@ interface ServiceStatsCardsProps {
}
export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) {
const { t } = useLanguage();
// Calculate uptime percentage
const calculateUptimePercentage = () => {
if (uptimeData.length === 0) return 100;
@@ -109,41 +112,46 @@ export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProp
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card className="border-blue-400 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">Response Time</CardTitle>
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">{t('responseTime')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-700 dark:text-white">
{service.responseTime}ms
</div>
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Last checked at {service.lastChecked}</p>
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">{t('lastCheckedAt').replace('{datetime}', service.lastChecked)}</p>
{avgResponseTime > 0 && avgResponseTime !== service.responseTime && (
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Avg: {avgResponseTime}ms (last {upChecks.length} up checks)</p>
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">{t('avg')}: {avgResponseTime}ms ({t('lastUpChecksCount').replace('{count}', String(upChecks.length))})</p>
)}
</CardContent>
</Card>
<Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">Uptime</CardTitle>
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">{t('uptime')}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-700 dark:text-white">
{uptimePercentage}%
</div>
<p className="text-xs text-green-600 dark:text-green-200 mt-1">Based on last {uptimeData.length} checks</p>
<p className="text-xs text-green-600 dark:text-green-200 mt-1">{t('basedOnlastChecksCount').replace('{count}', String(uptimeData.length))}</p>
<div className="flex flex-col space-y-1 mt-2">
<div className="flex items-center text-xs text-green-600 dark:text-green-200">
<ArrowUp className="h-3 w-3 mr-1 text-green-600 dark:text-green-400" />
<span>Total uptime: {uptimeStats.totalUptimeFormatted}</span>
<span>{t("totalUptime")}: {uptimeStats.totalUptimeFormatted}</span>
</div>
<div className="flex items-center text-xs text-red-600 dark:text-red-200">
<ArrowDown className="h-3 w-3 mr-1 text-red-600 dark:text-red-400" />
<span>Total downtime: {uptimeStats.totalDowntimeFormatted}</span>
<span>{t("totalDowntime")}: {uptimeStats.totalDowntimeFormatted}</span>
</div>
{service.status !== "paused" && (
<div className="flex items-center text-xs font-medium mt-1">
<span className={service.status === "up" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{service.status === "up" ? "Up" : "Down"} for {uptimeStats.currentStatusDuration}
{
service.status === "up" ?
t('upStatusDuration').replace("{duration}", uptimeStats.currentStatusDuration)
:
t('downStatusDuration').replace("{duration}", uptimeStats.currentStatusDuration)
}
</span>
</div>
)}
@@ -153,16 +161,16 @@ export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProp
<Card className="border-purple-400 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">Monitoring Settings</CardTitle>
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">{t('monitoringSettings')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200">
<Clock className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>Checked every {service.interval} seconds</span>
<span>{t('monitoringSettingsInterval').replace('{interval}', String(service.interval))}</span>
</div>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200 mt-1">
<Server className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>{service.type} monitoring</span>
<span>{service.type} {t('monitoringSettingsType')}</span>
</div>
</CardContent>
</Card>
@@ -80,9 +80,9 @@ export function ServicesPagination({
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
{totalItems > 0
? `${startItem}-${endItem} of ${totalItems} ${t("services") || "services"}`
: `0 ${t("services") || "services"}`
{totalItems > 0
? t("servicesPagination", {"startItem": startItem, "endItem": endItem, "totalItems": totalItems})
: t("servicesPaginationNoService")
}
</span>
</div>
@@ -5,6 +5,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
import { UptimeStatusItem } from './uptime/UptimeStatusItem';
import { uptimeService } from '@/services/uptimeService';
import { UptimeData } from '@/types/service.types';
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface UptimeBarProps {
uptime: number;
@@ -15,6 +16,7 @@ interface UptimeBarProps {
}
const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType = "HTTP" }: UptimeBarProps) => {
const { t } = useLanguage();
// Calculate date range for last 20 checks with much more aggressive caching
const endDate = useMemo(() => new Date(), []);
const startDate = useMemo(() => {
@@ -84,7 +86,7 @@ const UptimeBarComponent = ({ uptime, status, serviceId, interval, serviceType =
</div>
<span className="text-xs text-muted-foreground">
Last 20 checks
{t('last20Checks')}
</span>
</TooltipProvider>
);
@@ -2,6 +2,7 @@
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { MouseEvent } from "react";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface ServiceFormActionsProps {
isSubmitting: boolean;
@@ -14,6 +15,8 @@ export function ServiceFormActions({
onCancel,
submitLabel = "Create Service"
}: ServiceFormActionsProps) {
const {t} = useLanguage();
const handleCancel = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!isSubmitting) {
@@ -29,7 +32,7 @@ export function ServiceFormActions({
variant="outline"
disabled={isSubmitting}
>
Cancel
{t("cancel")}
</Button>
<Button
type="submit"
@@ -38,7 +41,7 @@ export function ServiceFormActions({
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
{t("processing")}...
</>
) : (
submitLabel
@@ -25,8 +25,8 @@ export function IncidentTable({ incidents }: IncidentTableProps) {
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Error Message</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>Details</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("errorMessage")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("details")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -9,10 +9,12 @@ import { IncidentTable } from "./IncidentTable";
import { StatusFilter, PageSize } from "./types";
import { getStatusChangeEvents } from "./utils";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] }) {
// Get current theme
const { theme } = useTheme();
const { t } = useLanguage();
// Filter state
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
@@ -65,7 +67,7 @@ export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] })
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<CardTitle className="text-card-foreground">
<span>Incident History</span>
<span>{t("incidentHistory")}</span>
</CardTitle>
<StatusFilterTabs statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
</div>
@@ -15,6 +15,7 @@ import {
SelectValue
} from "@/components/ui/select";
import { PageSize } from "./types";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface TablePaginationProps {
currentPage: number;
@@ -35,6 +36,7 @@ export function TablePagination({
onPageChange,
onPageSizeChange,
}: TablePaginationProps) {
const {t} = useLanguage()
// Generate page numbers
const getPageNumbers = () => {
const pages = [];
@@ -58,7 +60,7 @@ export function TablePagination({
return (
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<span className="text-sm text-muted-foreground">{t("rowsPerPage")}:</span>
<Select
value={pageSize}
onValueChange={(value) => onPageSizeChange(value as PageSize)}
@@ -12,6 +12,7 @@ import {
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServiceRowActionsProps {
service: Service;
@@ -31,6 +32,7 @@ export const ServiceRowActions = ({
onMuteAlerts
}: ServiceRowActionsProps) => {
const { toast } = useToast();
const { t } = useLanguage();
// Handle pause/resume directly from dropdown
const handlePauseResume = async (e: React.MouseEvent) => {
@@ -122,7 +124,7 @@ export const ServiceRowActions = ({
}}
>
<Eye className="h-4 w-4" />
<span>View Detail</span>
<span>{t("viewDetail")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer text-base py-2.5"
@@ -131,12 +133,12 @@ export const ServiceRowActions = ({
{service.status === "paused" ? (
<>
<Play className="h-4 w-4" />
<span>Resume Monitoring</span>
<span>{t("resumeMonitoring")}</span>
</>
) : (
<>
<Pause className="h-4 w-4" />
<span>Pause Monitoring</span>
<span>{t("pauseMonitoring")}</span>
</>
)}
</DropdownMenuItem>
@@ -148,7 +150,7 @@ export const ServiceRowActions = ({
}}
>
<Edit className="h-4 w-4" />
<span>Edit</span>
<span>{t("edit")}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer text-base py-2.5"
@@ -157,12 +159,12 @@ export const ServiceRowActions = ({
{alertsMuted ? (
<>
<Bell className="h-4 w-4" />
<span>Unmute Alerts</span>
<span>{t("unmuteAlerts")}</span>
</>
) : (
<>
<BellOff className="h-4 w-4" />
<span>Mute Alerts</span>
<span>{t("muteAlerts")}</span>
</>
)}
</DropdownMenuItem>
@@ -175,7 +177,7 @@ export const ServiceRowActions = ({
}}
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
<span>{t("delete")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1,5 +1,6 @@
import React from 'react';
import {useLanguage} from "@/contexts/LanguageContext.tsx";
interface UptimeSummaryProps {
uptime: number;
@@ -7,13 +8,14 @@ interface UptimeSummaryProps {
}
export const UptimeSummary = ({ uptime, interval }: UptimeSummaryProps) => {
const { t } = useLanguage();
return (
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-muted-foreground">
{Math.round(uptime)}% uptime
</span>
<span className="text-xs text-muted-foreground">
Last 20 checks
{t('last20Checks')}
</span>
</div>
);
@@ -355,9 +355,9 @@ export const AboutSystem: React.FC = () => {
<CardHeader className="bg-muted/50 pb-4">
<CardTitle className="flex items-center gap-2">
<Database className={`h-5 w-5 ${theme === 'dark' ? 'text-blue-400' : 'text-blue-600'}`} />
<span>Update Schema</span>
<span>{t('updateSchema')}</span>
</CardTitle>
<CardDescription className="font-medium text-base">Automatic import collections schema</CardDescription>
<CardDescription className="font-medium text-base">{t('updateSchemaDesc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="space-y-3">
@@ -368,7 +368,7 @@ export const AboutSystem: React.FC = () => {
onCheckedChange={(checked) => setMergeFields(checked === true)}
/>
<Label htmlFor="merge-fields" className="text-sm font-medium">
Merge fields with existing collections (safe - preserves data)
{t('mergeFieldsLabel')}
</Label>
</div>
</div>
@@ -382,7 +382,7 @@ export const AboutSystem: React.FC = () => {
}`}
>
<Database className={`h-4 w-4 ${isImporting ? 'animate-spin' : ''}`} />
{isImporting ? 'Importing...' : 'Click to update Schema'}
{isImporting ? t('importing') : t('clickToUpdateSchema')}
</Button>
</div>
@@ -403,7 +403,7 @@ export const AboutSystem: React.FC = () => {
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}>
{importResult.success ? 'Import Successful' : 'Import Failed'}
{importResult.success ? t('importSuccessful') : t('importFailed')}
</span>
</div>
<div className={`mt-2 text-sm ${
@@ -411,21 +411,21 @@ export const AboutSystem: React.FC = () => {
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}>
{importResult.created > 0 && `${importResult.created} collections created`}
{importResult.updated > 0 && (importResult.created > 0 ? ', ' : '') + `${importResult.updated} collections updated`}
{importResult.skipped > 0 && ((importResult.created > 0 || importResult.updated > 0) ? ', ' : '') + `${importResult.skipped} collections skipped`}
{importResult.created > 0 && t('collectionsCreatedCount').replace('{count}', String(importResult.created))}
{importResult.updated > 0 && (importResult.created > 0 ? ', ' : '') + t('collectionsUpdatedCount').replace('{count}', String(importResult.updated))}
{importResult.skipped > 0 && ((importResult.created > 0 || importResult.updated > 0) ? ', ' : '') + t('collectionsSkippedCount').replace('{count}', String(importResult.skipped))}
</div>
</div>
)}
<div className="text-sm text-muted-foreground">
<p className="mb-2">
<strong>Instructions:</strong>
<strong>{t('instructions')}:</strong>
</p>
<ul className="list-disc list-inside space-y-1 ml-2">
<li><strong>Merge fields:</strong> Safely add new fields to existing collections, preserves all data</li>
<li>System collections (starting with _) and users collection will be skipped automatically</li>
<li>Only authenticated admins can perform schema imports</li>
<li><strong>{t('mergeFields')}:</strong> {t('instructionsMergeFields')}</li>
<li>{t('instructionsCollections')}</li>
<li>{t('instructionsImportAuth')}</li>
</ul>
</div>
</CardContent>
@@ -16,6 +16,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { TemplateType, templateTypeConfigs } from "@/services/templateService";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import {z} from "zod";
interface TemplateDialogProps {
open: boolean;
@@ -81,6 +82,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
};
const renderPlaceholderGuide = () => {
const config = templateTypeConfigs[selectedTemplateType];
if (!config) return null;
@@ -52,7 +52,6 @@ const DataRetentionSettings = () => {
setLastCleanup(result.lastCleanup);
}
} catch (error) {
console.error("Error loading retention settings:", error);
toast({
title: "Error",
description: "Failed to load retention settings",
@@ -72,7 +71,6 @@ const DataRetentionSettings = () => {
description: "Data retention settings have been updated",
});
} catch (error) {
console.error("Error saving retention settings:", error);
toast({
title: "Error",
description: "Failed to save retention settings",
@@ -96,7 +94,6 @@ const DataRetentionSettings = () => {
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during uptime cleanup:", error);
toast({
title: "Error",
description: "Failed to perform uptime data cleanup",
@@ -120,7 +117,6 @@ const DataRetentionSettings = () => {
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during server cleanup:", error);
toast({
title: "Error",
description: "Failed to perform server data cleanup",
@@ -144,7 +140,6 @@ const DataRetentionSettings = () => {
// Reload settings to get updated last cleanup time
await loadSettings();
} catch (error) {
console.error("Error during manual cleanup:", error);
toast({
title: "Error",
description: "Failed to perform database cleanup",
@@ -170,7 +165,7 @@ const DataRetentionSettings = () => {
<Alert className="border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800">
<AlertTriangle className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<AlertDescription className="text-blue-700 dark:text-blue-300">
<span className="font-medium">{t("permissionNotice")}</span> As an admin user, you do not have access to data retention settings. These settings can only be accessed and modified by Super Admins.
<span className="font-medium">Permission Notice:</span> As an admin user, you do not have access to data retention settings. These settings can only be accessed and modified by Super Admins.
</AlertDescription>
</Alert>
</CardContent>
@@ -210,10 +205,16 @@ const DataRetentionSettings = () => {
min="1"
max="365"
value={settings.uptimeRetentionDays}
onChange={(e) => setSettings(prev => ({
...prev,
uptimeRetentionDays: parseInt(e.target.value) || 30
}))}
onChange={(e) => {
const value = e.target.value;
if (value === '' || isNaN(Number(value))) {
return;
}
setSettings(prev => ({
...prev,
uptimeRetentionDays: Number(value)
}));
}}
className="mt-1"
/>
<p className="text-sm text-muted-foreground mt-1">
@@ -229,10 +230,16 @@ const DataRetentionSettings = () => {
min="1"
max="365"
value={settings.serverRetentionDays}
onChange={(e) => setSettings(prev => ({
...prev,
serverRetentionDays: parseInt(e.target.value) || 30
}))}
onChange={(e) => {
const value = e.target.value;
if (value === '' || isNaN(Number(value))) {
return;
}
setSettings(prev => ({
...prev,
serverRetentionDays: Number(value)
}));
}}
className="mt-1"
/>
<p className="text-sm text-muted-foreground mt-1">
@@ -250,63 +257,17 @@ const DataRetentionSettings = () => {
</Alert>
)}
</CardContent>
<CardFooter className="flex flex-col gap-4">
<div className="flex flex-wrap gap-2 w-full">
<Button
variant="outline"
onClick={handleUptimeShrink}
disabled={isUptimeShrinking}
className="flex items-center gap-2"
>
{isUptimeShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Globe className="h-4 w-4" />
)}
Shrink Uptime Data
</Button>
<Button
variant="outline"
onClick={handleServerShrink}
disabled={isServerShrinking}
className="flex items-center gap-2"
>
{isServerShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Server className="h-4 w-4" />
)}
Shrink Server Data
</Button>
<Button
variant="outline"
onClick={handleFullShrink}
disabled={isFullShrinking}
className="flex items-center gap-2"
>
{isFullShrinking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Full Database Shrink
</Button>
</div>
<div className="flex justify-end w-full">
<Button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save Changes
</Button>
</div>
<CardFooter className="flex justify-end">
<Button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : null}
Save Changes
</Button>
</CardFooter>
</Card>
</div>
@@ -126,15 +126,6 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
<Mail className="h-5 w-5" />
{t("sendTestEmail", "settings")}
</DialogTitle>
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4"
onClick={handleClose}
disabled={isLoading}
>
<X className="h-4 w-4" />
</Button>
</DialogHeader>
<div className="space-y-4 py-4">
@@ -203,9 +194,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
{/* Info message */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This will send a test email using your configured SMTP settings. Make sure SMTP is properly configured first.
</AlertDescription>
<AlertDescription>{t("testEmailAlert", "settings")}</AlertDescription>
</Alert>
</div>
@@ -223,7 +212,7 @@ const TestEmailDialog: React.FC<TestEmailDialogProps> = ({
) : (
<Mail className="h-4 w-4" />
)}
{isLoading ? t("sending", "settings") : t("send", "common")}
{isLoading ? t("sending", "settings") : t("send", "settings")}
</Button>
</DialogFooter>
</DialogContent>
@@ -27,6 +27,7 @@ import { Switch } from "@/components/ui/switch";
import { Loader2, Copy } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "@/hooks/use-toast";
import { useLanguage } from "@/contexts/LanguageContext";
interface NotificationChannelDialogProps {
open: boolean;
@@ -34,9 +35,10 @@ interface NotificationChannelDialogProps {
editingConfig: AlertConfiguration | null;
}
const baseSchema = z.object({
notify_name: z.string().min(1, "Name is required"),
notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "webhook"]),
notification_type: z.enum(["telegram", "discord", "slack", "signal", "google_chat", "email", "ntfy", "pushover", "notifiarr", "gotify", "webhook"]),
enabled: z.boolean().default(true),
service_id: z.string().default("global"),
template_id: z.string().optional(),
@@ -81,6 +83,19 @@ const emailSchema = baseSchema.extend({
const ntfySchema = baseSchema.extend({
notification_type: z.literal("ntfy"),
ntfy_endpoint: z.string().url("Must be a valid NTFY endpoint URL"),
api_token: z.string().optional(),
});
const pushoverSchema = baseSchema.extend({
notification_type: z.literal("pushover"),
api_token: z.string().min(1, "API token is required"),
user_key: z.string().min(1, "User key is required"),
});
const notifiarrSchema = baseSchema.extend({
notification_type: z.literal("notifiarr"),
api_token: z.string().min(1, "API token is required"),
channel_id: z.string().min(1, "Channel ID is required"),
});
const webhookSchema = baseSchema.extend({
@@ -89,10 +104,10 @@ const webhookSchema = baseSchema.extend({
webhook_payload_template: z.string().optional(),
});
const pushoverSchema = baseSchema.extend({
notification_type: z.literal("pushover"),
const gotifySchema = baseSchema.extend({
notification_type: z.literal("gotify"),
api_token: z.string().min(1, "API token is required"),
user_key: z.string().min(1, "User key is required"),
server_url: z.string().url("Must be a valid server URL"),
});
const formSchema = z.discriminatedUnion("notification_type", [
@@ -104,6 +119,8 @@ const formSchema = z.discriminatedUnion("notification_type", [
emailSchema,
ntfySchema,
pushoverSchema,
notifiarrSchema,
gotifySchema,
webhookSchema,
]);
@@ -158,6 +175,19 @@ const notificationTypeOptions = [
description: "Send push notifications via Pushover",
icon: "/upload/notification/pushover.png"
},
{
value: "notifiarr",
label: "Notifiarr",
description: "Send notifications via Notifiarr",
icon: "/upload/notification/notifiarr.png"
},
{
value: "gotify",
label: "Gotify",
description: "Send push notifications via Gotify",
icon: "/upload/notification/gotify.png"
},
{
value: "webhook",
label: "Webhook",
@@ -566,22 +596,40 @@ export const NotificationChannelDialog = ({
)}
{notificationType === "ntfy" && (
<FormField
control={form.control}
name="ntfy_endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>NTFY Endpoint</FormLabel>
<FormControl>
<Input placeholder="https://ntfy.sh/your-topic" {...field} />
</FormControl>
<FormDescription>
The NTFY endpoint URL including your topic (e.g., https://ntfy.sh/checkcle)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<>
<FormField
control={form.control}
name="ntfy_endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>NTFY Endpoint</FormLabel>
<FormControl>
<Input placeholder="https://ntfy.sh/your-topic" {...field} />
</FormControl>
<FormDescription>
The NTFY endpoint URL including your topic (e.g., https://ntfy.sh/checkcle)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token (Optional)</FormLabel>
<FormControl>
<Input placeholder="Enter NTFY API token" {...field} type="password" />
</FormControl>
<FormDescription>
Optional API token for authentication with NTFY server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "pushover" && (
@@ -620,6 +668,80 @@ export const NotificationChannelDialog = ({
/>
</>
)}
{notificationType === "notifiarr" && (
<>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="Your Notifiarr API token" {...field} type="password" />
</FormControl>
<FormDescription>
Your Notifiarr API token for sending notifications
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="channel_id"
render={({ field }) => (
<FormItem>
<FormLabel>Channel ID</FormLabel>
<FormControl>
<Input placeholder="Discord Channel ID" {...field} />
</FormControl>
<FormDescription>
The Discord channel ID where notifications will be sent
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "gotify" && (
<>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input placeholder="Your Gotify API token" {...field} type="password" />
</FormControl>
<FormDescription>
Your Gotify application API token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="server_url"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input placeholder="https://your-gotify-server.com" {...field} />
</FormControl>
<FormDescription>
The URL of your Gotify server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{notificationType === "webhook" && (
<>
@@ -67,6 +67,8 @@ export const NotificationChannelList = ({
case "signal": return "Signal";
case "google_chat": return "Google Chat";
case "email": return "Email";
case "pushover": return "Pushover";
case "notifiarr": return "Notifiarr";
case "webhook": return "Webhook";
default: return type || "Unknown";
}
@@ -92,6 +92,8 @@ export const SSLCertificatesTable = () => {
warning_threshold: updatedCertificate.warning_threshold,
expiry_threshold: updatedCertificate.expiry_threshold,
notification_channel: updatedCertificate.notification_channel,
notification_id: updatedCertificate.notification_id,
template_id: updatedCertificate.template_id,
check_interval: updatedCertificate.check_interval,
});
+72 -23
View File
@@ -1,16 +1,22 @@
import React, { createContext, useContext, useState, ReactNode, useCallback } from "react";
import { translations, Language, TranslationModule, TranslationKey } from "@/translations";
type TranslationVars = Record<string, string | number>;
type LanguageContextType = {
language: Language;
setLanguage: (language: Language) => void;
t: <M extends TranslationModule>(key: string, module?: M) => string;
t: {
(key: string): string;
(key: string, vars: TranslationVars): string;
<M extends TranslationModule>(key: string, module: M): string;
<M extends TranslationModule>(key: string, module: M, vars: TranslationVars): string;
};
};
// ❗ Create the context with `undefined` to enforce provider usage
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
// ✅ Stable custom hook
// eslint-disable-next-line react-refresh/only-export-components
export const useLanguage = () => {
const context = useContext(LanguageContext);
if (!context) {
@@ -23,33 +29,76 @@ export const LanguageProvider = ({ children }: { children: ReactNode }) => {
const [language, setLanguage] = useState<Language>("en");
const fallbackLanguage: Language = "en";
const t = useCallback(<M extends TranslationModule>(key: string, module?: M): string => {
const interpolate = (template: string, vars: TranslationVars): string => {
return template.replace(/{(\w+)}/g, (match, key) => {
return vars[key] !== undefined ? String(vars[key]) : match;
});
};
const t = useCallback(((
key: string,
moduleOrVars?: TranslationModule | TranslationVars,
vars?: TranslationVars
): string => {
let module: TranslationModule | undefined;
let variables: TranslationVars | undefined;
if (typeof moduleOrVars === "string") {
module = moduleOrVars;
variables = vars;
} else if (typeof moduleOrVars === "object") {
variables = moduleOrVars;
}
const langPack = translations[language];
const fallbackPack = translations[fallbackLanguage];
let translatedText = "";
if (module) {
const valCur = langPack?.[module]?.[key as TranslationKey<M>];
if (typeof valCur === "string") return valCur;
const moduleKey = key as TranslationKey<typeof module>;
const valCur = langPack?.[module]?.[moduleKey];
if (typeof valCur === "string") {
translatedText = valCur;
} else {
const valEn = fallbackPack?.[module]?.[moduleKey];
translatedText = typeof valEn === "string" ? valEn : key;
}
} else {
let found = false;
const valEn = fallbackPack?.[module]?.[key as TranslationKey<M>];
if (typeof valEn === "string") return valEn;
for (const mod of Object.keys(langPack) as TranslationModule[]) {
const moduleTranslations = langPack[mod];
if (moduleTranslations && key in moduleTranslations) {
const val = moduleTranslations[key as keyof typeof moduleTranslations];
if (typeof val === "string") {
translatedText = val;
found = true;
break;
}
}
}
return key;
if (!found) {
for (const mod of Object.keys(fallbackPack) as TranslationModule[]) {
const moduleTranslations = fallbackPack[mod];
if (moduleTranslations && key in moduleTranslations) {
const val = moduleTranslations[key as keyof typeof moduleTranslations];
if (typeof val === "string") {
translatedText = val;
found = true;
break;
}
}
}
}
if (!found) {
translatedText = key;
}
}
for (const mod in langPack) {
const m = mod as TranslationModule;
const val = langPack[m]?.[key as any];
if (typeof val === "string") return val;
}
for (const mod in fallbackPack) {
const m = mod as TranslationModule;
const val = fallbackPack[m]?.[key as any];
if (typeof val === "string") return val;
}
return key;
}, [language]);
return variables ? interpolate(translatedText, variables) : translatedText;
}) as LanguageContextType['t'], [language]);
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
+8 -5
View File
@@ -10,8 +10,11 @@ import { User } from "@/services/userService";
import { useToast } from "@/hooks/use-toast";
import { Loader2 } from "lucide-react";
import { useSidebar } from "@/contexts/SidebarContext";
import {useLanguage} from "@/contexts/LanguageContext.tsx";
const Profile = () => {
const { t } = useLanguage()
// Use shared sidebar state
const { sidebarCollapsed, toggleSidebar } = useSidebar();
@@ -84,12 +87,12 @@ const Profile = () => {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-center">
<p>Please login to view your profile</p>
<p>{t("loginToViewProfile")}</p>
<button
onClick={() => navigate("/login")}
className="mt-4 px-4 py-2 bg-primary text-white rounded"
>
Go to Login
{t("goToLogin")}
</button>
</div>
</div>
@@ -110,7 +113,7 @@ const Profile = () => {
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<span className="ml-2">Loading user data...</span>
<span className="ml-2">{t("loadingUserData")}</span>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full">
@@ -121,7 +124,7 @@ const Profile = () => {
onClick={() => fetchUserData()}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md"
>
Retry
{t("retry")}
</button>
</div>
) : (
@@ -136,4 +139,4 @@ const Profile = () => {
);
};
export default Profile;
export default Profile;
+11 -1
View File
@@ -7,7 +7,7 @@ export interface AlertConfiguration {
collectionId?: string;
collectionName?: string;
service_id: string;
notification_type: "telegram" | "discord" | "slack" | "signal" | "google_chat" | "email" | "ntfy" | "pushover" | "webhook";
notification_type: "telegram" | "discord" | "slack" | "signal" | "google_chat" | "email" | "ntfy" | "pushover" | "notifiarr" | "gotify" | "webhook";
telegram_chat_id?: string;
discord_webhook_url?: string;
signal_number?: string;
@@ -31,6 +31,7 @@ export interface AlertConfiguration {
ntfy_endpoint?: string;
api_token?: string;
user_key?: string;
server_url?: string;
webhook_url?: string;
webhook_payload_template?: string;
}
@@ -87,10 +88,19 @@ export const alertConfigService = {
} else if (config.notification_type === "ntfy") {
cleanConfig.ntfy_endpoint = config.ntfy_endpoint || "";
cleanConfig.api_token = config.api_token || "";
} else if (config.notification_type === "pushover") {
cleanConfig.api_token = config.api_token || "";
cleanConfig.user_key = config.user_key || "";
} else if (config.notification_type === "notifiarr") {
cleanConfig.api_token = config.api_token || "";
cleanConfig.channel_id = config.channel_id || "";
} else if (config.notification_type === "gotify") {
cleanConfig.api_token = config.api_token || "";
cleanConfig.server_url = config.server_url || "";
} else if (config.notification_type === "webhook") {
cleanConfig.webhook_url = config.webhook_url || "";
+15
View File
@@ -16,4 +16,19 @@ export const aboutTranslations: AboutTranslations = {
quickActionsDescription: "Access common monitoring operations and features quickly. Select an action below to get started.",
quickTips: "Quick Tips",
releasedOn: "Released On",
updateSchema: "Update Schema",
updateSchemaDesc: "Automatic import collections schema",
mergeFieldsLabel: "Merge fields with existing collections (safe - preserves data)",
importing: "Importing...",
clickToUpdateSchema: "Click to update Schema",
importSuccessful: "Import Successful",
importFailed: "Import Failed",
instructions: "Instructions",
mergeFields: "Merge fields",
instructionsMergeFields: "Safely add new fields to existing collections, preserves all data",
instructionsCollections: "System collections (starting with _) and users collection will be skipped automatically",
instructionsImportAuth: "Only authenticated admins can perform schema imports",
collectionsUpdatedCount: "{count} collections updated",
collectionsCreatedCount: "{count} collections created",
collectionsSkippedCount: "{count} collections created",
};
@@ -61,4 +61,5 @@ export const incidentTranslations: IncidentTranslations = {
affectedSystems: 'Affected Systems',
enterAffectedSystems: 'Enter Affected Systems',
separateSystemsWithComma: 'Enter affected systems seperated by comma',
createIncidentDesc: 'Create a new incident',
};
@@ -78,4 +78,12 @@ export const maintenanceTranslations: MaintenanceTranslations = {
selectNotificationChannel: 'Add a notification channel',
enableNotificationsFirst: 'Enable notifications first to select channel',
updateMaintenance: 'Update Maintenance',
loginToViewProfile: 'Please login to view your profile',
goToLogin: 'Go to Login',
loadingUserData: 'Loading user data...',
retry: 'Retry',
loadingServerData: 'Loading server data...',
retrievingYourInformation: 'Please wait while we retrieve your information...',
servicesPagination: '{startItem}-{endItem} of {totalItems} services',
servicesPaginationNoService: '0 services',
};
+42 -1
View File
@@ -3,7 +3,6 @@ import { ServicesTranslations } from '../types/services';
export const servicesTranslations: ServicesTranslations = {
serviceStatus: "Service Status",
responseTime: "Response Time",
uptime: "Uptime",
lastChecked: "Last Checked",
noServices: "No services match your filter criteria.",
@@ -95,4 +94,46 @@ export const servicesTranslations: ServicesTranslations = {
downServices: "DOWN SERVICES",
pausedServices: "PAUSED SERVICES",
warningServices: "WARNING SERVICES",
// ServiceRowActions.tsx
viewDetail: "View Detail",
resumeMonitoring: "Resume Monitoring",
pauseMonitoring: "Pause Monitoring",
//IncidentTable.tsx
responseTime: "Response Time",
errorMessage: "Error Message",
details: "Details",
unmuteAlerts: "Unmute Alerts",
muteAlerts: "Mute Alerts",
//LastCheckedTime.tsx
pausedAt: "Paused at ",
lastCheckDetails: "Last Check Details",
checkedAt: "Checked at ",
monitoringPaused: "Monitoring Paused",
noAutomaticChecks: "No automatic checks",
//ServiveEditDialog.tsx
editService: "Edit Service",
editServiceDesc: "Update the details of your monitored service.",
//ServiceStatsCards.tsx
lastCheckedAt: "Last checked at {datetime}",
avg: "Avg",
lastUpChecksCount: "last {count} up checks",
basedOnlastChecksCount: "Based on last {count} checks",
totalUptime: "Total Uptime",
totalDowntime: "Total Downtime",
monitoringSettings: "Monitoring Settings",
monitoringSettingsInterval: "Checked every {interval} seconds",
monitoringSettingsType: "monitoring",
upStatusDuration: "Up for {duration}",
downStatusDuration: "Down for {duration}",
incidentHistory: "Incident History",
processing: "Processing",
back: "Back",
last20Checks: "Last 20 checks",
};
+5 -1
View File
@@ -37,8 +37,12 @@ export const settingsTranslations: SettingsTranslations = {
selectCollection: "Select collection",
toEmailAddress: "To email address",
enterEmailAddress: "Enter email address",
send: "Send",
sending: "Sending...",
testEmailSettings: "Test Email Settings",
testEmailDescription: "Test whether the current Email Settings are available",
testEmailAlert: "This will send a test email using your configured SMTP settings. Make sure SMTP is properly configured first.",
// General Settings - Actions and status
save: "Save Changes",
saving: "Saving...",
@@ -14,4 +14,19 @@ export interface AboutTranslations {
quickActionsDescription: string;
quickTips: string;
releasedOn: string;
updateSchema: string;
updateSchemaDesc: string;
mergeFieldsLabel: string;
importing: string;
clickToUpdateSchema: string;
importSuccessful: string;
importFailed: string;
instructions: string;
mergeFields: string;
instructionsMergeFields: string;
instructionsCollections: string;
instructionsImportAuth: string;
collectionsUpdatedCount: string;
collectionsCreatedCount: string;
collectionsSkippedCount: string;
}
@@ -59,4 +59,5 @@ export interface IncidentTranslations {
affectedSystems: string;
enterAffectedSystems: string;
separateSystemsWithComma: string;
createIncidentDesc: string;
}
@@ -76,4 +76,12 @@ export interface MaintenanceTranslations {
selectNotificationChannel: string;
enableNotificationsFirst: string;
updateMaintenance: string;
loginToViewProfile: string;
goToLogin: string;
loadingUserData: string;
retry: string;
loadingServerData: string;
retrievingYourInformation: string;
servicesPagination: string;
servicesPaginationNoService: string;
}
+42 -2
View File
@@ -2,7 +2,6 @@
export interface ServicesTranslations {
serviceStatus: string;
responseTime: string;
uptime: string;
lastChecked: string;
noServices: string;
@@ -93,5 +92,46 @@ export interface ServicesTranslations {
upServices: string;
downServices: string;
pausedServices: string;
warningServices: string;
warningServices: string;
// ServiceRowActions.tsx
viewDetail: string;
resumeMonitoring: string;
pauseMonitoring: string;
unmuteAlerts: string;
muteAlerts: string;
//IncidentTable.tsx
responseTime: string;
errorMessage: string;
details: string;
//LastCheckedTime.tsx
pausedAt: string;
lastCheckDetails: string;
checkedAt: string;
monitoringPaused: string;
noAutomaticChecks: string;
//ServiveEditDialog.tsx
editService: string;
editServiceDesc: string;
//ServiceStatsCards.tsx
lastCheckedAt: string;
avg: string;
lastUpChecksCount: string;
basedOnlastChecksCount: string;
totalUptime: string;
totalDowntime: string;
monitoringSettings: string;
monitoringSettingsInterval: string;
monitoringSettingsType: string;
upStatusDuration: string;
downStatusDuration: string;
incidentHistory: string;
processing: string;
back: string;
last20Checks: string;
}
@@ -21,7 +21,7 @@ export interface SettingsTranslations {
smtpAuthMethod: string;
enableTLS: string;
localName: string;
// General Settings - Test Email
testEmail: string;
sendTestEmail: string;
@@ -35,8 +35,12 @@ export interface SettingsTranslations {
selectCollection: string;
toEmailAddress: string;
enterEmailAddress: string;
send: string;
sending: string;
testEmailSettings: string;
testEmailDescription: string;
testEmailAlert: string;
// General Settings - Actions and status
save: string;
saving: string;
@@ -16,4 +16,19 @@ export const aboutTranslations: AboutTranslations = {
quickActionsDescription: "快速访问常用的监控操作和功能。选择下面的操作开始。",
quickTips: "快速提示",
releasedOn: "发布于",
updateSchema: "更新架构",
updateSchemaDesc: "自动导入集合架构",
mergeFieldsLabel: "合并字段到现有集合(安全 - 保留数据)",
importing: "导入中...",
clickToUpdateSchema: "点击更新架构",
importSuccessful: "导入成功",
importFailed: "导入失败",
instructions: "说明",
mergeFields: "合并字段",
instructionsMergeFields: "安全地向现有集合添加新字段,保留所有数据",
instructionsCollections: "系统集合(以_开头的)和用户集合将被自动跳过",
instructionsImportAuth: "只有经过身份验证的管理员才能执行架构导入",
collectionsUpdatedCount: "已更新 {count} 个集合",
collectionsCreatedCount: "已创建 {count} 个集合",
collectionsSkippedCount: "已跳过 {count} 个集合",
};
@@ -53,4 +53,13 @@ export const incidentTranslations: IncidentTranslations = {
failedToUpdateStatus: '更新状态失败',
inProgress: '进行中',
enterRootCause: '输入故障根源',
enterIncidentTitle: '输入事件标题',
enterIncidentDescription: '输入事件描述',
enterServiceId: '输入以逗号分隔的服务ID',
selectAssignedUser: '选择分配用户',
noAssignedUser: '该事件当前未分配给任何用户',
affectedSystems: '受影响系统',
enterAffectedSystems: '受影响系统',
separateSystemsWithComma: '输入受影响的系统,以逗号分隔',
createIncidentDesc: '创建一个新的事件',
};
@@ -65,4 +65,25 @@ export const maintenanceTranslations: MaintenanceTranslations = {
noScheduledMaintenance: '没有计划的维护',
noMaintenanceWindows: '没有此时间段的维护窗口。通过点击“创建维护”按钮创建一个。',
maintenanceCreatedSuccess: '维护窗口创建成功',
editMaintenanceWindow: '编辑维护',
editMaintenanceDesc: '编辑维护详情',
priorityDescription: '选择维护优先级',
statusDescription: '选择维护状态',
impactLevel: '影响级别',
impactLevelDescription: '选择影响级别',
selectAssignedUsers: '选择分配的用户',
assignedPersonnelDescription: '当前分配的用户',
mutedNotifications: '通知已禁用',
notificationsAreMuted: '此维护期间的通知当前已静音',
selectNotificationChannel: '添加通知渠道',
enableNotificationsFirst: '首先启用通知以选择频道',
updateMaintenance: '更新维护',
loginToViewProfile: '请登录查看您的个人资料',
goToLogin: '立即登录',
loadingUserData: '正在加载用户数据...',
retry: '重试',
loadingServerData: '正在加载服务器数据...',
retrievingYourInformation: '正在获取您的信息,请稍候...',
servicesPagination: '{startItem}-{endItem}, 共 {totalItems} 个服务',
servicesPaginationNoService: '0 服务',
};
+41 -1
View File
@@ -3,7 +3,6 @@ import { ServicesTranslations } from '../types/services';
export const servicesTranslations: ServicesTranslations = {
serviceStatus: "服务状态",
responseTime: "响应时间",
uptime: "在线时间",
lastChecked: "最后检查时间",
noServices: "没有符合您筛选条件的服务。",
@@ -95,4 +94,45 @@ export const servicesTranslations: ServicesTranslations = {
downServices: "下线服务",
pausedServices: "暂停服务",
warningServices: "告警服务",
// ServiceRowActions.tsx
viewDetail: "查看详情",
resumeMonitoring: "恢复监控",
pauseMonitoring: "暂停监控",
unmuteAlerts: "取消静音警报",
muteAlerts: "静音警报",
//IncidentTable.tsx
responseTime: "响应时间",
errorMessage: "错误信息",
details: "详情",
//LastCheckedTime.tsx
pausedAt: "暂停于 ",
lastCheckDetails: "最近检查详情",
checkedAt: "检查于 ",
monitoringPaused: "监控已暂停",
noAutomaticChecks: "无自动检查",
//ServiveEditDialog.tsx
editService: "编辑服务",
editServiceDesc: "更新您所监控服务的详细信息。",
//ServiceStatsCards.tsx
lastCheckedAt: "最新检查时间 {datetime}",
avg: "平均",
lastUpChecksCount: "最近 {count} 次检查",
basedOnlastChecksCount: "基于最近的 {count} 次检查",
totalUptime: "总在线时间",
totalDowntime: "总宕机时间",
monitoringSettings: "监控设置",
monitoringSettingsInterval: "每 {interval} 秒检查一次",
monitoringSettingsType: "监控",
upStatusDuration: "已在线 {duration}",
downStatusDuration: "已宕机 {duration}",
incidentHistory: "事件历史",
processing: "处理中",
back: "返回",
last20Checks: "最近 20 次检查",
};
@@ -37,8 +37,12 @@ export const settingsTranslations: SettingsTranslations = {
selectCollection: "选择集合",
toEmailAddress: "收件人邮箱地址",
enterEmailAddress: "输入收件人邮箱地址",
send: "发送",
sending: "发送中...",
testEmailSettings: "测试邮箱配置",
testEmailDescription: "测试当前的邮箱设置是否可用",
testEmailAlert: "这将使用您配置的SMTP设置发送一封测试邮件。请确保SMTP已正确配置。",
// General Settings - Actions and status
save: "保存变更",
saving: "保存中...",
+4 -7
View File
@@ -86,6 +86,9 @@ The roadmap is divided into the following stages:
- [ ] ✅ sync German translations across incident, maintenance, services & settings. By @ghotso [#141](https://github.com/operacle/checkcle/pull/141)
- [ ] ✅ fix(services-form): max retry attempts. By @ghotso [#147](https://github.com/operacle/checkcle/pull/147)
- [ ] ✅ Add Pushover notifications
- [ ] ✅ fix(ssl): Ensure edit form saves notification_id and template_id in DB.
- [ ] ✅ Add Notifiarr notifications
- [ ] ✅ Add Gotify notifications
- [ ] 🔧 Enhance the Data retention feature
- [ ] 🔧 Improve docker agent for server monitoring
- [ ] 🔧 Improve the Server Agent on Unraid
@@ -96,6 +99,7 @@ The roadmap is divided into the following stages:
- [ ] 🔧 Server support with Multiple Network Interfaces
- [ ] 🔧 Improve the Operational status page
- [ ] 🔧 Server network bandwidth monitoring
- [ ] 🔧 Grouping uptime services
- [ ] 🎯 Improve the Schedule and Incident for automation
- [ ] 🎯 Uptime Monitoring option for choose: HTTP/HTTPS. Add Option to Bypass SSL Verification in Uptime
- [ ] 🎯 Add 2FA support
@@ -106,7 +110,6 @@ The roadmap is divided into the following stages:
These are community-suggested or experimental features under review:
- [ ] Grouping uptime services
- [ ] OIDC Connect | OAuth2 integration
- [ ] PWA support
- [ ] Server outbound and inbound traffic usage
@@ -125,12 +128,6 @@ To get involved:
- Join us in shaping the roadmap via [Discussions](https://github.com/operacle/checkcle/discussions)
---
## 📅 Last Updated
_This roadmap was last updated on **July 26, 2025**._
---
With ❤️ from the CheckCle
[Website](https://checkcle.io) | [GitHub](https://github.com/operacle/checkcle)
+21 -17
View File
@@ -117,7 +117,7 @@ docker run -d \
![Service Detail Page](https://cdn.checkcle.io/images/uptime/uptime-regional-detail.png)
![checkcle-server-instance](https://cdn.checkcle.io/images/server/server-list.png)
![SSL Monitoring](https://cdn.checkcle.io/images/ssl-domain/ssl-list.png)
![Schedule Maintenance](https://pub-4a4062303020445f8f289a2fee84f9e8.r2.dev/images/checkcle-schedule-maintenance.png)
![Notification System](https://cdn.checkcle.io/general/powerfull_notification.png)
## 🌟 面向社区的 CheckCle
@@ -129,26 +129,30 @@ docker run -d \
---
## 赞助商
🙏 我们非常感谢我们的赞助商。你们的贡献使我们能够维护基础设施(托管、域名)、运行测试,并继续开发有价值的功能。
我们将不再接受赞助形式。从今以后,仅接受以生态系统和社区合作形式提供的支持,例如云服务器、域名或托管资源等基础设施支持。
### 🥈 白银在线保障合作伙伴
<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>
如果您是科技公司且有意支持 CheckCle ,请直接通过邮箱 tolaleng@checkcle.io 联系作者。
### 🧡 支持者
<div style="display: flex; align-items: center; gap: 10px;">
<a href="https://github.com/gitbookio">
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
width="75" height="75"
style="border-radius: 50%;"
alt="GitBook Logo" />
</a>
<a href="https://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>
### 🤝 生态系统与社区合作伙伴
<a href="https://github.com/gitbookio">
<img src="https://avatars.githubusercontent.com/u/7111340?s=200&v=4"
width="75" height="75"
style="border-radius: 50%; display: block;" />
</a>
<a href="https://www.cloudflare.com">
<img src="https://cdn.checkcle.io/images/sponsor/cloudflare-checkcle_logo.png"
height="60"
alt="Cloudflare Logo" />
</a>
<a href="https://m.do.co/c/0c27ef82475f">
<img src="https://cdn.checkcle.io/images/sponsor/digitalocean_checkcle.png"
height="50"
alt="DigitalOcean Logo" />
</a>
</div>
---
@@ -0,0 +1,59 @@
package dataretention
import (
"encoding/json"
"net/http"
"service-operation/pocketbase"
)
// Handler handles HTTP requests for data retention operations
type Handler struct {
service *Service
}
// NewHandler creates a new data retention handler
func NewHandler(pbClient *pocketbase.PocketBaseClient) *Handler {
return &Handler{
service: NewService(pbClient),
}
}
// GetSettings handles GET requests for data retention settings
func (h *Handler) GetSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
settings, err := h.service.GetDataSettings()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(settings)
}
// TriggerCleanup handles POST requests to trigger manual cleanup
func (h *Handler) TriggerCleanup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
summary, err := h.service.CleanupOldRecords()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(summary)
}
// SetupRoutes sets up the HTTP routes for data retention
func (h *Handler) SetupRoutes(mux *http.ServeMux) {
mux.HandleFunc("/data-retention/settings", h.GetSettings)
mux.HandleFunc("/data-retention/cleanup", h.TriggerCleanup)
}
@@ -0,0 +1,63 @@
package dataretention
import (
"service-operation/pocketbase"
"time"
)
// Scheduler handles periodic data retention cleanup
type Scheduler struct {
service *Service
interval time.Duration
stopChan chan bool
}
// NewScheduler creates a new cleanup scheduler
func NewScheduler(pbClient *pocketbase.PocketBaseClient, interval time.Duration) *Scheduler {
return &Scheduler{
service: NewService(pbClient),
interval: interval,
stopChan: make(chan bool),
}
}
// Start begins the periodic cleanup process
func (s *Scheduler) Start() {
// log.Printf("Starting data retention scheduler with interval: %v", s.interval)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
// Run initial cleanup
go func() {
if err := s.service.ScheduleCleanup(); err != nil {
// log.Printf("Initial cleanup failed: %v", err)
}
}()
for {
select {
case <-ticker.C:
go func() {
if err := s.service.ScheduleCleanup(); err != nil {
// log.Printf("Scheduled cleanup failed: %v", err)
}
}()
case <-s.stopChan:
// log.Println("Data retention scheduler stopped")
return
}
}
}
// Stop stops the scheduler
func (s *Scheduler) Stop() {
// log.Println("Stopping data retention scheduler...")
s.stopChan <- true
}
// RunOnce executes cleanup immediately (for manual triggers)
func (s *Scheduler) RunOnce() (*CleanupSummary, error) {
// log.Println("Running manual data retention cleanup...")
return s.service.CleanupOldRecords()
}
@@ -0,0 +1,398 @@
package dataretention
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"service-operation/pocketbase"
"sync"
"time"
)
// Service handles data retention operations
type Service struct {
pbClient *pocketbase.PocketBaseClient
}
// NewService creates a new data retention service
func NewService(pbClient *pocketbase.PocketBaseClient) *Service {
return &Service{
pbClient: pbClient,
}
}
// GetDataSettings fetches the current data retention settings
func (s *Service) GetDataSettings() (*DataSettings, error) {
resp, err := s.pbClient.GetHTTPClient().Get(
fmt.Sprintf("%s/api/collections/data_settings/records", s.pbClient.GetBaseURL()),
)
if err != nil {
return nil, fmt.Errorf("failed to fetch data settings: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch data settings, status: %d", resp.StatusCode)
}
var response DataSettingsResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, fmt.Errorf("failed to decode data settings response: %w", err)
}
if len(response.Items) == 0 {
return nil, fmt.Errorf("no data settings found")
}
return &response.Items[0], nil
}
// CleanupOldRecords performs cleanup of old records based on retention settings
func (s *Service) CleanupOldRecords() (*CleanupSummary, error) {
startTime := time.Now()
settings, err := s.GetDataSettings()
if err != nil {
return nil, fmt.Errorf("failed to get data settings: %w", err)
}
// Check if cleanup should run (only once per day)
if !s.shouldRunCleanup(settings) {
// log.Println("Skipping cleanup - less than 24 hours since last run")
return &CleanupSummary{
StartTime: startTime,
EndTime: time.Now(),
Duration: 0,
Results: make([]CleanupResult, 0),
}, nil
}
summary := &CleanupSummary{
StartTime: startTime,
Results: make([]CleanupResult, 0),
}
// Define collections and their corresponding retention days
uptimeCollections := []string{"dns_data", "ping_data", "tcp_data", "uptime_data", "services_metrics"}
serverCollections := []string{"server_metrics", "docker_metrics"}
// Process collections in parallel for better performance
var wg sync.WaitGroup
resultsChan := make(chan CleanupResult, len(uptimeCollections)+len(serverCollections))
// Cleanup uptime-related collections in parallel
if settings.UptimeRetentionDays > 0 {
cutoffDate := time.Now().AddDate(0, 0, -settings.UptimeRetentionDays)
// log.Printf("Cleaning uptime collections with %d days retention (cutoff: %s)",
// settings.UptimeRetentionDays, cutoffDate.Format("2006-01-02 15:04:05"))
for _, collection := range uptimeCollections {
wg.Add(1)
go func(col string) {
defer wg.Done()
result := s.cleanupCollection(col, cutoffDate)
resultsChan <- result
}(collection)
}
} else {
// log.Println("Skipping uptime collections cleanup - retention days is 0")
}
// Cleanup server-related collections in parallel
if settings.ServerRetentionDays > 0 {
cutoffDate := time.Now().AddDate(0, 0, -settings.ServerRetentionDays)
// log.Printf("Cleaning server collections with %d days retention (cutoff: %s)",
// settings.ServerRetentionDays, cutoffDate.Format("2006-01-02 15:04:05"))
for _, collection := range serverCollections {
wg.Add(1)
go func(col string) {
defer wg.Done()
result := s.cleanupCollection(col, cutoffDate)
resultsChan <- result
}(collection)
}
} else {
// log.Println("Skipping server collections cleanup - retention days is 0")
}
// Wait for all collections to complete and collect results
go func() {
wg.Wait()
close(resultsChan)
}()
// Collect all results
for result := range resultsChan {
summary.Results = append(summary.Results, result)
summary.TotalDeleted += result.RecordsDeleted
}
summary.EndTime = time.Now()
summary.Duration = summary.EndTime.Sub(summary.StartTime)
// Update last cleanup time after successful completion
if err := s.updateLastCleanupTime(); err != nil {
// log.Printf("Warning: Failed to update last cleanup time: %v", err)
}
// log.Printf("Data retention cleanup completed. Total records deleted: %d, Duration: %v",
// summary.TotalDeleted, summary.Duration)
return summary, nil
}
// cleanupCollection deletes records older than the cutoff date from a specific collection
func (s *Service) cleanupCollection(collection string, cutoffDate time.Time) CleanupResult {
result := CleanupResult{
Collection: collection,
}
// Format cutoff date for PocketBase filter (RFC3339 format)
cutoffStr := cutoffDate.UTC().Format("2006-01-02T15:04:05.000Z")
// Get records to delete (balanced batch size for performance vs rate limits)
batchSize := 50
totalDeleted := 0
for {
// Always fetch page 1 since we're deleting records (pagination shifts after deletes)
filter := fmt.Sprintf("created < '%s'", cutoffStr)
encodedFilter := url.QueryEscape(filter)
requestURL := fmt.Sprintf("%s/api/collections/%s/records?page=1&perPage=%d&filter=%s",
s.pbClient.GetBaseURL(), collection, batchSize, encodedFilter)
resp, err := s.pbClient.GetHTTPClient().Get(requestURL)
if err != nil {
result.Error = fmt.Sprintf("failed to fetch records: %v", err)
return result
}
defer resp.Body.Close()
// Check if response is successful
if resp.StatusCode != http.StatusOK {
result.Error = fmt.Sprintf("API request failed with status %d for collection %s", resp.StatusCode, collection)
return result
}
var response struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
TotalItems int `json:"totalItems"`
}
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
result.Error = fmt.Sprintf("failed to decode response for collection %s: %v", collection, err)
return result
}
if len(response.Items) == 0 {
// log.Printf("No more old records found in collection %s", collection)
break // No more records to delete
}
// log.Printf("Found %d old records in collection %s (batch), total items in DB: %d",
// len(response.Items), collection, response.TotalItems)
// Delete records with conservative rate limiting to avoid 429 errors
semaphore := make(chan struct{}, 3) // Further reduced to 3 concurrent deletes
var deleteWg sync.WaitGroup
var mu sync.Mutex
batchDeleted := 0
rateLimitHit := false
for _, record := range response.Items {
deleteWg.Add(1)
go func(recordID string) {
defer deleteWg.Done()
semaphore <- struct{}{} // Acquire
defer func() { <-semaphore }() // Release
deleted := false
retryCount := 0
maxRetries := 5
for !deleted && retryCount < maxRetries {
if err := s.deleteRecord(collection, recordID); err != nil {
if retryCount < maxRetries-1 {
// Progressive backoff with longer delays for rate limits
baseWait := time.Duration(1<<retryCount) * 500 * time.Millisecond
if retryCount >= 2 {
// Extra penalty for persistent rate limits
baseWait += time.Duration(retryCount) * 2 * time.Second
}
mu.Lock()
rateLimitHit = true
mu.Unlock()
time.Sleep(baseWait)
retryCount++
continue
} else {
// log.Printf("Failed to delete record %s from %s after %d retries: %v",
// recordID, collection, maxRetries, err)
break
}
} else {
deleted = true
mu.Lock()
batchDeleted++
mu.Unlock()
}
}
// Increased delay between deletions with adaptive timing
mu.Lock()
currentRateLimitHit := rateLimitHit
mu.Unlock()
if currentRateLimitHit {
time.Sleep(150 * time.Millisecond) // Longer delay if rate limits detected
} else {
time.Sleep(75 * time.Millisecond) // Standard delay
}
}(record.ID)
}
deleteWg.Wait()
totalDeleted += batchDeleted
// If we got fewer records than batch size, we're done
if len(response.Items) < batchSize {
// log.Printf("Finished processing collection %s - no more old records", collection)
break
}
// More conservative delay between batches with adaptive timing
if len(response.Items) > 0 {
mu.Lock()
currentRateLimitHit := rateLimitHit
mu.Unlock()
baseDelay := 300 * time.Millisecond
if currentRateLimitHit {
baseDelay = 1 * time.Second // Much longer delay if rate limits were hit
// log.Printf("Processed batch for collection %s (%d deleted), rate limits detected - waiting longer before next batch...", collection, batchDeleted)
} else {
// log.Printf("Processed batch for collection %s (%d deleted), processing next batch...", collection, batchDeleted)
}
time.Sleep(baseDelay)
}
}
result.RecordsDeleted = totalDeleted
if totalDeleted > 0 {
// log.Printf("Deleted %d records from collection %s", totalDeleted, collection)
}
return result
}
// deleteRecord deletes a single record from a collection
func (s *Service) deleteRecord(collection, recordID string) error {
requestURL := fmt.Sprintf("%s/api/collections/%s/records/%s", s.pbClient.GetBaseURL(), collection, recordID)
req, err := http.NewRequest("DELETE", requestURL, nil)
if err != nil {
return err
}
resp, err := s.pbClient.GetHTTPClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to delete record, status: %d", resp.StatusCode)
}
return nil
}
// ScheduleCleanup runs cleanup based on schedule (can be called periodically)
func (s *Service) ScheduleCleanup() error {
// log.Println("Starting scheduled data retention cleanup...")
_, err := s.CleanupOldRecords()
if err != nil {
// log.Printf("Scheduled cleanup failed: %v", err)
return err
}
// log.Printf("Scheduled cleanup completed successfully. Summary: %+v", summary)
return nil
}
// shouldRunCleanup checks if cleanup should run based on last cleanup time (once per day)
func (s *Service) shouldRunCleanup(settings *DataSettings) bool {
if settings.LastCleanup == "" {
// log.Println("No previous cleanup found, proceeding with cleanup...")
return true
}
lastCleanup, err := time.Parse(time.RFC3339, settings.LastCleanup)
if err != nil {
// log.Printf("Failed to parse last cleanup time '%s', proceeding with cleanup: %v", settings.LastCleanup, err)
return true
}
timeSinceLastCleanup := time.Since(lastCleanup)
minInterval := 24 * time.Hour
if timeSinceLastCleanup < minInterval {
// log.Printf("Last cleanup was %v ago (less than 24h), skipping cleanup", timeSinceLastCleanup)
return false
}
// log.Printf("Last cleanup was %v ago, proceeding with cleanup", timeSinceLastCleanup)
return true
}
// updateLastCleanupTime updates the last cleanup timestamp in data settings
func (s *Service) updateLastCleanupTime() error {
settings, err := s.GetDataSettings()
if err != nil {
return fmt.Errorf("failed to get data settings: %w", err)
}
// Update the last cleanup time
now := time.Now().UTC().Format(time.RFC3339)
// Create the update payload
updateData := map[string]interface{}{
"last_cleanup": now,
}
jsonData, err := json.Marshal(updateData)
if err != nil {
return fmt.Errorf("failed to marshal update data: %w", err)
}
// Update the record via PocketBase API
requestURL := fmt.Sprintf("%s/api/collections/data_settings/records/%s", s.pbClient.GetBaseURL(), settings.ID)
req, err := http.NewRequest("PATCH", requestURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create update request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.pbClient.GetHTTPClient().Do(req)
if err != nil {
return fmt.Errorf("failed to update last cleanup time: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to update last cleanup time, status: %d", resp.StatusCode)
}
// log.Printf("Updated last cleanup time to: %s", now)
return nil
}
@@ -0,0 +1,42 @@
package dataretention
import "time"
// DataSettings represents the data retention configuration from PocketBase
type DataSettings struct {
ID string `json:"id"`
CollectionId string `json:"collectionId"`
CollectionName string `json:"collectionName"`
RetentionDays int `json:"retention_days"`
ServerRetentionDays int `json:"server_retention_days"`
UptimeRetentionDays int `json:"uptime_retention_days"`
LastCleanup string `json:"last_cleanup"`
Backup string `json:"backup"`
Created string `json:"created"`
Updated string `json:"updated"`
}
// DataSettingsResponse represents the response from PocketBase
type DataSettingsResponse struct {
Items []DataSettings `json:"items"`
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
}
// CleanupResult represents the result of a cleanup operation
type CleanupResult struct {
Collection string `json:"collection"`
RecordsDeleted int `json:"records_deleted"`
Error string `json:"error,omitempty"`
}
// CleanupSummary represents the overall cleanup summary
type CleanupSummary struct {
TotalDeleted int `json:"total_deleted"`
Results []CleanupResult `json:"results"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Duration time.Duration `json:"duration"`
}
+16
View File
@@ -6,6 +6,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
"github.com/gorilla/mux"
"service-operation/config"
@@ -15,6 +16,7 @@ import (
servermonitoring "service-operation/server-monitoring"
sslmonitoring "service-operation/ssl-monitoring"
uptimemonitoring "service-operation/uptime-monitoring"
dataretention "service-operation/data-retention"
)
func main() {
@@ -35,6 +37,7 @@ func main() {
var sslNotificationService *sslmonitoring.SSLMonitor
var serverMonitoringService *servermonitoring.ServerMonitoringService
var uptimeMonitoringService *uptimemonitoring.UptimeMonitor
var dataRetentionScheduler *dataretention.Scheduler
if cfg.PocketBaseEnabled {
//log.Println("🔧 Initializing PocketBase client...")
@@ -80,6 +83,12 @@ func main() {
uptimeMonitoringService = uptimemonitoring.NewUptimeMonitor(pbClient)
go uptimeMonitoringService.Start()
//log.Println("✅ Uptime monitoring started with notification support")
// Initialize and start data retention scheduler
//log.Println("🔧 Initializing data retention scheduler...")
dataRetentionScheduler = dataretention.NewScheduler(pbClient, 24*time.Hour) // Run daily
go dataRetentionScheduler.Start()
//log.Println("✅ Data retention scheduler started (daily cleanup)")
}
}
}
@@ -123,6 +132,9 @@ func main() {
log.Printf("✓Uptime monitoring enabled with notification support")
}
log.Printf("✓Supported operations: ping, dns, tcp, http, ssl")
if dataRetentionScheduler != nil {
log.Printf("✓Data retention scheduler enabled (daily cleanup)")
}
// Setup graceful shutdown
@@ -154,6 +166,10 @@ func main() {
log.Println("🛑 Stopping uptime monitoring...")
uptimeMonitoringService.Stop()
}
if dataRetentionScheduler != nil {
log.Println("🛑 Stopping data retention scheduler...")
dataRetentionScheduler.Stop()
}
log.Println("✅ All services stopped gracefully")
log.Println("🛑 === SERVICE OPERATION SERVER STOPPED ===")
@@ -0,0 +1,357 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// GotifyService handles Gotify notifications
type GotifyService struct{}
// NewGotifyService creates a new Gotify notification service
func NewGotifyService() *GotifyService {
return &GotifyService{}
}
// GotifyPayload represents the payload for Gotify API
type GotifyPayload struct {
Message string `json:"message"`
Title string `json:"title,omitempty"`
Priority int `json:"priority,omitempty"`
}
// GotifyResponse represents the response from Gotify API
type GotifyResponse struct {
ID int `json:"id"`
AppID int `json:"appid"`
Message string `json:"message"`
Title string `json:"title"`
Priority int `json:"priority"`
Date string `json:"date"`
}
// SendNotification sends a notification via Gotify
func (gs *GotifyService) SendNotification(config *AlertConfiguration, message string) error {
// fmt.Printf("📱 [GOTIFY] Attempting to send notification...\n")
// fmt.Printf("📱 [GOTIFY] Config - API Token: %s, Server URL: %s, Notify Name: %s\n",
// maskToken(config.APIToken), config.ServerURL, config.NotifyName)
// fmt.Printf("📱 [GOTIFY] Message: %s\n", message)
if config.APIToken == "" || config.ServerURL == "" {
return fmt.Errorf("Gotify API token and server URL are required")
}
// Prepare the payload
payload := GotifyPayload{
Message: message,
Title: config.NotifyName, // Use notify_name as the title
Priority: 5, // Default priority
}
jsonData, err := json.Marshal(payload)
if err != nil {
// fmt.Printf("❌ [GOTIFY] JSON marshal error: %v\n", err)
return err
}
// fmt.Printf("📱 [GOTIFY] Payload: %s\n", string(jsonData))
// fmt.Printf("📱 [GOTIFY] Sending POST request to Gotify API...\n")
// Construct the Gotify API URL
apiURL := strings.TrimRight(config.ServerURL, "/") + "/message?token=" + config.APIToken
// Send the request to Gotify API
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
// fmt.Printf("❌ [GOTIFY] HTTP error: %v\n", err)
return err
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// fmt.Printf("❌ [GOTIFY] HTTP status error: %d\n", resp.StatusCode)
return fmt.Errorf("Gotify API error: HTTP %d", resp.StatusCode)
}
// Parse the response
var gotifyResp GotifyResponse
if err := json.NewDecoder(resp.Body).Decode(&gotifyResp); err != nil {
// fmt.Printf("❌ [GOTIFY] Response decode error: %v\n", err)
return err
}
// fmt.Printf("📱 [GOTIFY] API response: %+v\n", gotifyResp)
// Check if the message was created successfully
if gotifyResp.ID == 0 {
// fmt.Printf("❌ [GOTIFY] API error: No message ID returned\n")
return fmt.Errorf("Gotify API error: message not created")
}
// fmt.Printf("✅ [GOTIFY] Message sent successfully! ID: %d\n", gotifyResp.ID)
return nil
}
// SendServerNotification sends a server-specific notification via Gotify
func (gs *GotifyService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
message := gs.generateServerMessage(payload, template, resourceType)
return gs.SendNotification(config, message)
}
// SendServiceNotification sends a service-specific notification via Gotify
func (gs *GotifyService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
message := gs.generateServiceMessage(payload, template)
return gs.SendNotification(config, message)
}
// generateServerMessage creates a message for server notifications using server template
func (gs *GotifyService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string {
var templateMessage string
// Select appropriate template message based on status and resource type
switch strings.ToLower(payload.Status) {
case "down":
templateMessage = template.DownMessage
case "up":
templateMessage = template.UpMessage
case "warning":
templateMessage = template.WarningMessage
case "paused":
templateMessage = template.PausedMessage
default:
// Handle resource-specific messages
switch resourceType {
case "cpu":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreCPUMessage
} else {
templateMessage = template.CPUMessage
}
case "ram", "memory":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreRAMMessage
} else {
templateMessage = template.RAMMessage
}
case "disk":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreDiskMessage
} else {
templateMessage = template.DiskMessage
}
case "network":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreNetworkMessage
} else {
templateMessage = template.NetworkMessage
}
case "cpu_temp", "cpu_temperature":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreCPUTempMessage
} else {
templateMessage = template.CPUTempMessage
}
case "disk_io":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreDiskIOMessage
} else {
templateMessage = template.DiskIOMessage
}
default:
templateMessage = template.WarningMessage
}
}
// If no template message found, use a default
if templateMessage == "" {
templateMessage = gs.generateDefaultServerMessage(payload, resourceType)
}
return gs.replacePlaceholders(templateMessage, payload)
}
// generateServiceMessage creates a message for service notifications using service template
func (gs *GotifyService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string {
var templateMessage string
// Select appropriate template message based on status
switch strings.ToLower(payload.Status) {
case "up":
templateMessage = template.UpMessage
case "down":
templateMessage = template.DownMessage
case "maintenance":
templateMessage = template.MaintenanceMessage
case "incident":
templateMessage = template.IncidentMessage
case "resolved":
templateMessage = template.ResolvedMessage
case "warning":
templateMessage = template.WarningMessage
default:
templateMessage = template.WarningMessage
}
// If no template message found, use a default
if templateMessage == "" {
templateMessage = gs.generateDefaultUptimeMessage(payload)
}
return gs.replacePlaceholders(templateMessage, payload)
}
// replacePlaceholders replaces all placeholders in the message with actual values
func (gs *GotifyService) replacePlaceholders(message string, payload *NotificationPayload) string {
// Replace basic placeholders
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
message = strings.ReplaceAll(message, "${server_name}", payload.ServiceName) // server_name maps to service_name
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
message = strings.ReplaceAll(message, "${host}", gs.safeString(payload.Host))
message = strings.ReplaceAll(message, "${hostname}", gs.safeString(payload.Hostname))
// Replace URL with fallback to host
url := gs.safeString(payload.URL)
if url == "N/A" && payload.Host != "" {
url = payload.Host
}
message = strings.ReplaceAll(message, "${url}", url)
// Replace domain
message = strings.ReplaceAll(message, "${domain}", gs.safeString(payload.Domain))
// Replace service type
if payload.ServiceType != "" {
message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType))
} else {
message = strings.ReplaceAll(message, "${service_type}", "N/A")
}
// Replace region and agent info
message = strings.ReplaceAll(message, "${region_name}", gs.safeString(payload.RegionName))
message = strings.ReplaceAll(message, "${agent_id}", gs.safeString(payload.AgentID))
// Handle numeric fields safely
if payload.Port > 0 {
message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port))
} else {
message = strings.ReplaceAll(message, "${port}", "N/A")
}
if payload.ResponseTime > 0 {
message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime))
} else {
message = strings.ReplaceAll(message, "${response_time}", "N/A")
}
if payload.Uptime > 0 {
message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime))
} else {
message = strings.ReplaceAll(message, "${uptime}", "N/A")
}
// Replace server monitoring fields
message = strings.ReplaceAll(message, "${cpu_usage}", gs.safeString(payload.CPUUsage))
message = strings.ReplaceAll(message, "${ram_usage}", gs.safeString(payload.RAMUsage))
message = strings.ReplaceAll(message, "${disk_usage}", gs.safeString(payload.DiskUsage))
message = strings.ReplaceAll(message, "${network_usage}", gs.safeString(payload.NetworkUsage))
message = strings.ReplaceAll(message, "${cpu_temp}", gs.safeString(payload.CPUTemp))
message = strings.ReplaceAll(message, "${disk_io}", gs.safeString(payload.DiskIO))
message = strings.ReplaceAll(message, "${threshold}", gs.safeString(payload.Threshold))
// Replace SSL certificate fields
message = strings.ReplaceAll(message, "${certificate_name}", gs.safeString(payload.CertificateName))
message = strings.ReplaceAll(message, "${expiry_date}", gs.safeString(payload.ExpiryDate))
message = strings.ReplaceAll(message, "${days_left}", gs.safeString(payload.DaysLeft))
message = strings.ReplaceAll(message, "${issuer_cn}", gs.safeString(payload.IssuerCN))
message = strings.ReplaceAll(message, "${issuer}", gs.safeString(payload.IssuerCN)) // alias
// Replace error message - important for uptime services
message = strings.ReplaceAll(message, "${error_message}", gs.safeString(payload.ErrorMessage))
message = strings.ReplaceAll(message, "${error}", gs.safeString(payload.ErrorMessage))
message = strings.ReplaceAll(message, "${message}", gs.safeString(payload.Message))
// Replace time placeholders
message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05"))
message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05"))
return message
}
// safeString returns the string value or "N/A" if empty
func (gs *GotifyService) safeString(value string) string {
if value == "" {
return "N/A"
}
return value
}
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
func (gs *GotifyService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
message := fmt.Sprintf("Service %s is %s.", payload.ServiceName, strings.ToUpper(payload.Status))
// Build formatted details
details := []string{}
// Add URL or host
if payload.URL != "" {
details = append(details, fmt.Sprintf("Host URL: %s", payload.URL))
} else if payload.Host != "" {
details = append(details, fmt.Sprintf("Host: %s", payload.Host))
}
// Add service type
if payload.ServiceType != "" {
details = append(details, fmt.Sprintf("Type: %s", strings.ToUpper(payload.ServiceType)))
}
// Add port if available
if payload.Port > 0 {
details = append(details, fmt.Sprintf("Port: %d", payload.Port))
}
// Add domain if available
if payload.Domain != "" {
details = append(details, fmt.Sprintf("Domain: %s", payload.Domain))
}
// Add response time
if payload.ResponseTime > 0 {
details = append(details, fmt.Sprintf("Response time: %dms", payload.ResponseTime))
} else {
details = append(details, "Response time: N/A")
}
// Add region info
if payload.RegionName != "" {
details = append(details, fmt.Sprintf("Region: %s", payload.RegionName))
}
// Add agent info
if payload.AgentID != "" {
details = append(details, fmt.Sprintf("Agent: %s", payload.AgentID))
}
// Add uptime if available
if payload.Uptime > 0 {
details = append(details, fmt.Sprintf("Uptime: %d%%", payload.Uptime))
}
// Add timestamp
details = append(details, fmt.Sprintf("Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05")))
// Combine message with details
if len(details) > 0 {
message += "\n" + strings.Join(details, "\n")
}
return message
}
// generateDefaultServerMessage creates a default server message
func (gs *GotifyService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string {
return fmt.Sprintf("Server %s (%s) status: %s", payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status))
}
@@ -31,6 +31,8 @@ func NewNotificationManager(pbClient *pocketbase.PocketBaseClient) *Notification
services["webhook"] = NewWebhookService()
services["ntfy"] = NewNtfyService()
services["pushover"] = NewPushoverService()
services["notifiarr"] = NewNotifiarrService()
services["gotify"] = NewGotifyService()
// log.Printf("✅ Notification services initialized: %v", getKeys(services))
@@ -0,0 +1,416 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
)
// NotifiarrService handles Notifiarr notifications
type NotifiarrService struct{}
// NewNotifiarrService creates a new Notifiarr notification service
func NewNotifiarrService() *NotifiarrService {
return &NotifiarrService{}
}
// NotifiarrPayload represents the payload for Notifiarr API
type NotifiarrPayload struct {
Notification NotifiarrNotification `json:"notification"`
Discord NotifiarrDiscord `json:"discord"`
}
type NotifiarrNotification struct {
Update bool `json:"update"`
Name string `json:"name"`
Event string `json:"event,omitempty"`
}
type NotifiarrDiscord struct {
Color string `json:"color,omitempty"`
Ping NotifiarrPing `json:"ping,omitempty"`
Images NotifiarrImages `json:"images,omitempty"`
Text NotifiarrText `json:"text"`
IDs NotifiarrIDs `json:"ids,omitempty"`
}
type NotifiarrPing struct {
PingUser int64 `json:"pingUser,omitempty"`
PingRole int64 `json:"pingRole,omitempty"`
}
type NotifiarrImages struct {
Thumbnail string `json:"thumbnail,omitempty"`
Image string `json:"image,omitempty"`
}
type NotifiarrText struct {
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Content string `json:"content,omitempty"`
Description string `json:"description"`
Fields []any `json:"fields,omitempty"`
Footer string `json:"footer,omitempty"`
}
type NotifiarrIDs struct {
Channel int64 `json:"channel,omitempty"`
}
// SendNotification sends a notification via Notifiarr
func (ns *NotifiarrService) SendNotification(config *AlertConfiguration, message string) error {
// fmt.Printf("📡 [NOTIFIARR] Attempting to send notification...\n")
// fmt.Printf("📡 [NOTIFIARR] Config - API Token present: %v\n", config.APIToken != "")
// fmt.Printf("📡 [NOTIFIARR] Message: %s\n", message)
if config.APIToken == "" {
return fmt.Errorf("notifiarr API token is required")
}
// Parse channel ID if provided
var channelID int64
if config.ChannelID != "" {
if parsed, err := strconv.ParseInt(config.ChannelID, 10, 64); err == nil {
channelID = parsed
}
}
url := fmt.Sprintf("https://notifiarr.com/api/v1/notification/passthrough/%s", config.APIToken)
// fmt.Printf("📡 [NOTIFIARR] API URL: %s\n", strings.Replace(url, config.APIToken, "[REDACTED]", 1))
// Create status-based color
color := ns.getStatusColor(message)
payload := NotifiarrPayload{
Notification: NotifiarrNotification{
Update: false,
Name: "This is an automated notification from CheckCle System",
Event: "",
},
Discord: NotifiarrDiscord{
Color: color,
Text: NotifiarrText{
Title: "Service Alert",
Description: message,
Footer: "CheckCle Monitoring System",
},
IDs: NotifiarrIDs{
Channel: channelID,
},
},
}
jsonData, err := json.Marshal(payload)
if err != nil {
return err
}
// fmt.Printf("📡 [NOTIFIARR] Sending POST request...\n")
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
// fmt.Printf("❌ [NOTIFIARR] HTTP error: %v\n", err)
return err
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK {
// fmt.Printf("❌ [NOTIFIARR] HTTP status error: %d\n", resp.StatusCode)
return fmt.Errorf("notifiarr API error: status code %d", resp.StatusCode)
}
// fmt.Printf("✅ [NOTIFIARR] Message sent successfully!\n")
return nil
}
// SendServerNotification sends a server-specific notification via Notifiarr
func (ns *NotifiarrService) SendServerNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) error {
message := ns.generateServerMessage(payload, template, resourceType)
return ns.SendNotification(config, message)
}
// SendServiceNotification sends a service-specific notification via Notifiarr
func (ns *NotifiarrService) SendServiceNotification(config *AlertConfiguration, payload *NotificationPayload, template *ServiceNotificationTemplate) error {
message := ns.generateServiceMessage(payload, template)
return ns.SendNotification(config, message)
}
// generateServerMessage creates a message for server notifications using server template
func (ns *NotifiarrService) generateServerMessage(payload *NotificationPayload, template *ServerNotificationTemplate, resourceType string) string {
var templateMessage string
// Select appropriate template message based on status and resource type
switch strings.ToLower(payload.Status) {
case "down":
templateMessage = template.DownMessage
case "up":
templateMessage = template.UpMessage
case "warning":
templateMessage = template.WarningMessage
case "paused":
templateMessage = template.PausedMessage
default:
// Handle resource-specific messages
switch resourceType {
case "cpu":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreCPUMessage
} else {
templateMessage = template.CPUMessage
}
case "ram", "memory":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreRAMMessage
} else {
templateMessage = template.RAMMessage
}
case "disk":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreDiskMessage
} else {
templateMessage = template.DiskMessage
}
case "network":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreNetworkMessage
} else {
templateMessage = template.NetworkMessage
}
case "cpu_temp", "cpu_temperature":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreCPUTempMessage
} else {
templateMessage = template.CPUTempMessage
}
case "disk_io":
if strings.Contains(strings.ToLower(payload.Status), "restore") {
templateMessage = template.RestoreDiskIOMessage
} else {
templateMessage = template.DiskIOMessage
}
default:
templateMessage = template.WarningMessage
}
}
// If no template message found, use a default
if templateMessage == "" {
templateMessage = ns.generateDefaultServerMessage(payload, resourceType)
}
return ns.replacePlaceholders(templateMessage, payload)
}
// generateServiceMessage creates a message for service notifications using service template
func (ns *NotifiarrService) generateServiceMessage(payload *NotificationPayload, template *ServiceNotificationTemplate) string {
var templateMessage string
// Select appropriate template message based on status
switch strings.ToLower(payload.Status) {
case "up":
templateMessage = template.UpMessage
case "down":
templateMessage = template.DownMessage
case "maintenance":
templateMessage = template.MaintenanceMessage
case "incident":
templateMessage = template.IncidentMessage
case "resolved":
templateMessage = template.ResolvedMessage
case "warning":
templateMessage = template.WarningMessage
default:
templateMessage = template.WarningMessage
}
// If no template message found, use a default
if templateMessage == "" {
templateMessage = ns.generateDefaultUptimeMessage(payload)
}
return ns.replacePlaceholders(templateMessage, payload)
}
// replacePlaceholders replaces all placeholders in the message with actual values
func (ns *NotifiarrService) replacePlaceholders(message string, payload *NotificationPayload) string {
// Replace basic placeholders
message = strings.ReplaceAll(message, "${service_name}", payload.ServiceName)
message = strings.ReplaceAll(message, "${status}", strings.ToUpper(payload.Status))
message = strings.ReplaceAll(message, "${host}", ns.safeString(payload.Host))
message = strings.ReplaceAll(message, "${hostname}", ns.safeString(payload.Hostname))
// Replace URL with fallback to host
url := ns.safeString(payload.URL)
if url == "N/A" && payload.Host != "" {
url = payload.Host
}
message = strings.ReplaceAll(message, "${url}", url)
// Replace domain
message = strings.ReplaceAll(message, "${domain}", ns.safeString(payload.Domain))
// Replace service type
if payload.ServiceType != "" {
message = strings.ReplaceAll(message, "${service_type}", strings.ToUpper(payload.ServiceType))
} else {
message = strings.ReplaceAll(message, "${service_type}", "N/A")
}
// Replace region and agent info
message = strings.ReplaceAll(message, "${region_name}", ns.safeString(payload.RegionName))
message = strings.ReplaceAll(message, "${agent_id}", ns.safeString(payload.AgentID))
// Handle numeric fields safely
if payload.Port > 0 {
message = strings.ReplaceAll(message, "${port}", fmt.Sprintf("%d", payload.Port))
} else {
message = strings.ReplaceAll(message, "${port}", "N/A")
}
if payload.ResponseTime > 0 {
message = strings.ReplaceAll(message, "${response_time}", fmt.Sprintf("%dms", payload.ResponseTime))
} else {
message = strings.ReplaceAll(message, "${response_time}", "N/A")
}
if payload.Uptime > 0 {
message = strings.ReplaceAll(message, "${uptime}", fmt.Sprintf("%d%%", payload.Uptime))
} else {
message = strings.ReplaceAll(message, "${uptime}", "N/A")
}
// Replace server monitoring fields
message = strings.ReplaceAll(message, "${cpu_usage}", ns.safeString(payload.CPUUsage))
message = strings.ReplaceAll(message, "${ram_usage}", ns.safeString(payload.RAMUsage))
message = strings.ReplaceAll(message, "${disk_usage}", ns.safeString(payload.DiskUsage))
message = strings.ReplaceAll(message, "${network_usage}", ns.safeString(payload.NetworkUsage))
message = strings.ReplaceAll(message, "${cpu_temp}", ns.safeString(payload.CPUTemp))
message = strings.ReplaceAll(message, "${disk_io}", ns.safeString(payload.DiskIO))
message = strings.ReplaceAll(message, "${threshold}", ns.safeString(payload.Threshold))
// Replace error message - important for uptime services
message = strings.ReplaceAll(message, "${error_message}", ns.safeString(payload.ErrorMessage))
message = strings.ReplaceAll(message, "${error}", ns.safeString(payload.ErrorMessage))
// Replace time placeholders
message = strings.ReplaceAll(message, "${time}", payload.Timestamp.Format("2006-01-02 15:04:05"))
message = strings.ReplaceAll(message, "${timestamp}", payload.Timestamp.Format("2006-01-02 15:04:05"))
return message
}
// safeString returns the string value or "N/A" if empty
func (ns *NotifiarrService) safeString(value string) string {
if value == "" {
return "N/A"
}
return value
}
// getStatusColor returns appropriate color for status
func (ns *NotifiarrService) getStatusColor(message string) string {
messageLower := strings.ToLower(message)
if strings.Contains(messageLower, "down") || strings.Contains(messageLower, "error") || strings.Contains(messageLower, "failed") {
return "FF0000" // Red
} else if strings.Contains(messageLower, "up") || strings.Contains(messageLower, "restored") || strings.Contains(messageLower, "resolved") {
return "00FF00" // Green
} else if strings.Contains(messageLower, "warning") || strings.Contains(messageLower, "alert") {
return "FFA500" // Orange
}
return "0099FF" // Blue (default)
}
// generateDefaultUptimeMessage creates a default uptime message with proper formatting
func (ns *NotifiarrService) generateDefaultUptimeMessage(payload *NotificationPayload) string {
// Status emoji mapping
statusEmoji := "🔵"
switch strings.ToLower(payload.Status) {
case "up":
statusEmoji = "🟢"
case "down":
statusEmoji = "🔴"
case "warning":
statusEmoji = "🟡"
case "maintenance", "paused":
statusEmoji = "🟠"
}
message := fmt.Sprintf("%s Service %s is %s.", statusEmoji, payload.ServiceName, strings.ToUpper(payload.Status))
// Build formatted details
details := []string{}
// Add URL or host
if payload.URL != "" {
details = append(details, fmt.Sprintf(" - Host URL: %s", payload.URL))
} else if payload.Host != "" {
details = append(details, fmt.Sprintf(" - Host: %s", payload.Host))
}
// Add service type
if payload.ServiceType != "" {
details = append(details, fmt.Sprintf(" - Type: %s", strings.ToUpper(payload.ServiceType)))
}
// Add port if available
if payload.Port > 0 {
details = append(details, fmt.Sprintf(" - Port: %d", payload.Port))
}
// Add domain if available
if payload.Domain != "" {
details = append(details, fmt.Sprintf(" - Domain: %s", payload.Domain))
}
// Add response time
if payload.ResponseTime > 0 {
details = append(details, fmt.Sprintf(" - Response time: %dms", payload.ResponseTime))
} else {
details = append(details, " - Response time: N/A")
}
// Add region info
if payload.RegionName != "" {
details = append(details, fmt.Sprintf(" - Region: %s", payload.RegionName))
}
// Add agent info
if payload.AgentID != "" {
details = append(details, fmt.Sprintf(" - Agent: %s", payload.AgentID))
}
// Add uptime if available
if payload.Uptime > 0 {
details = append(details, fmt.Sprintf(" - Uptime: %d%%", payload.Uptime))
}
// Add timestamp
details = append(details, fmt.Sprintf(" - Time: %s", payload.Timestamp.Format("2006-01-02 15:04:05")))
// Combine message with details
if len(details) > 0 {
message += "\n" + strings.Join(details, "\n")
}
return message
}
// generateDefaultServerMessage creates a default server message
func (ns *NotifiarrService) generateDefaultServerMessage(payload *NotificationPayload, resourceType string) string {
statusEmoji := "🔵"
switch strings.ToLower(payload.Status) {
case "up":
statusEmoji = "🟢"
case "down":
statusEmoji = "🔴"
case "warning":
statusEmoji = "🟡"
}
return fmt.Sprintf("%s 🖥️ Server %s (%s) status: %s", statusEmoji, payload.ServiceName, payload.Hostname, strings.ToUpper(payload.Status))
}
@@ -32,6 +32,11 @@ func (ns *NtfyService) SendNotification(config *AlertConfiguration, message stri
req.Header.Set("Title", "🔔 CheckCle Service Alert")
req.Header.Set("Tags", "monitoring")
req.Header.Set("Priority", "default")
// Add API token authentication if provided
if config.APIToken != "" {
req.Header.Set("Authorization", "Bearer "+config.APIToken)
}
// Send the request
client := &http.Client{}
@@ -111,6 +116,11 @@ func (ns *NtfyService) SendNotificationWithDetails(config *AlertConfiguration, m
req.Header.Set("Tags", tags)
req.Header.Set("Priority", priority)
// Add API token authentication if provided
if config.APIToken != "" {
req.Header.Set("Authorization", "Bearer "+config.APIToken)
}
// Send the request
client := &http.Client{}
@@ -53,7 +53,7 @@ type AlertConfiguration struct {
TemplateID string `json:"template_id"`
SlackWebhookURL string `json:"slack_webhook_url"`
GoogleChatWebhookURL string `json:"google_chat_webhook_url"`
Enabled string `json:"enabled"` // String because PocketBase returns it as string
Enabled string `json:"enabled"`
EmailAddress string `json:"email_address"`
EmailSenderName string `json:"email_sender_name"`
SMTPServer string `json:"smtp_server"`
@@ -66,6 +66,7 @@ type AlertConfiguration struct {
NtfyEndpoint string `json:"ntfy_endpoint"`
APIToken string `json:"api_token"`
UserKey string `json:"user_key"`
ServerURL string `json:"server_url"`
}
// ServerNotificationTemplate represents a server notification template