diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e4f6115..2647363 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -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 diff --git a/README.md b/README.md index 372a237..0a74b58 100644 --- a/README.md +++ b/README.md @@ -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 - - - - - -### 🧡 Ping Supporter - - - - +If you’re a tech company interested in supporting CheckCle, please contact the author directly at tolaleng@checkcle.io ### 🤝 Ecosystem & Community Partner
@@ -151,19 +141,18 @@ docker run -d \ alt="GitBook Logo" /> - + + Cloudflare Logo + + DigitalOcean Logo
- - - - - - --- diff --git a/application/public/upload/data/pb_schema_latest.json b/application/public/upload/data/pb_schema_latest.json index 79237a3..6db52b0 100644 --- a/application/public/upload/data/pb_schema_latest.json +++ b/application/public/upload/data/pb_schema_latest.json @@ -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 } -] \ No newline at end of file +] diff --git a/application/public/upload/notification/gotify.png b/application/public/upload/notification/gotify.png new file mode 100644 index 0000000..105fc6c Binary files /dev/null and b/application/public/upload/notification/gotify.png differ diff --git a/application/public/upload/notification/notifiarr.png b/application/public/upload/notification/notifiarr.png new file mode 100644 index 0000000..ae4d12e Binary files /dev/null and b/application/public/upload/notification/notifiarr.png differ diff --git a/application/src/components/services/LastCheckedTime.tsx b/application/src/components/services/LastCheckedTime.tsx index 3757f12..85756a3 100644 --- a/application/src/components/services/LastCheckedTime.tsx +++ b/application/src/components/services/LastCheckedTime.tsx @@ -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 )} - {isPaused ? "Paused at " : ""} + {isPaused ? t("pausedAt") : ""} {formattedTime} @@ -66,14 +68,14 @@ export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheck >
- {isPaused ? "Monitoring Paused" : "Last Check Details"} + {isPaused ? t("monitoringPaused") : t("lastCheckDetails")}
- {isPaused ? "No automatic checks" : `Checked at ${formattedTime}`} + {isPaused ? t("noAutomaticChecks") : t("checkedAt") + `${formattedTime}`}
{interval && !isPaused && (
- Check interval: {formatInterval(interval)} + {t("checkInterval")}: {formatInterval(interval)}
)}
diff --git a/application/src/components/services/LoadingState.tsx b/application/src/components/services/LoadingState.tsx index a96a562..ebf909c 100644 --- a/application/src/components/services/LoadingState.tsx +++ b/application/src/components/services/LoadingState.tsx @@ -1,5 +1,7 @@ +import {useLanguage} from "@/contexts/LanguageContext.tsx"; export function LoadingState() { + const { t } = useLanguage(); return (
@@ -7,8 +9,8 @@ export function LoadingState() {
-

Loading server data

-

Please wait while we retrieve your information...

+

{t("loadingServerData")}

+

{t("retrievingYourInformation")}

); diff --git a/application/src/components/services/ServiceEditDialog.tsx b/application/src/components/services/ServiceEditDialog.tsx index 8603398..d020d56 100644 --- a/application/src/components/services/ServiceEditDialog.tsx +++ b/application/src/components/services/ServiceEditDialog.tsx @@ -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 }}> - Edit Service + {t("editService")} - Update the details of your monitored service. + {t("editServiceDesc")} {open && service && ( diff --git a/application/src/components/services/ServiceStatsCards.tsx b/application/src/components/services/ServiceStatsCards.tsx index db469e1..4d84708 100644 --- a/application/src/components/services/ServiceStatsCards.tsx +++ b/application/src/components/services/ServiceStatsCards.tsx @@ -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
- Response Time + {t('responseTime')}
{service.responseTime}ms
-

Last checked at {service.lastChecked}

+

{t('lastCheckedAt').replace('{datetime}', service.lastChecked)}

{avgResponseTime > 0 && avgResponseTime !== service.responseTime && ( -

Avg: {avgResponseTime}ms (last {upChecks.length} up checks)

+

{t('avg')}: {avgResponseTime}ms ({t('lastUpChecksCount').replace('{count}', String(upChecks.length))})

)}
- Uptime + {t('uptime')}
{uptimePercentage}%
-

Based on last {uptimeData.length} checks

+

{t('basedOnlastChecksCount').replace('{count}', String(uptimeData.length))}

- Total uptime: {uptimeStats.totalUptimeFormatted} + {t("totalUptime")}: {uptimeStats.totalUptimeFormatted}
- Total downtime: {uptimeStats.totalDowntimeFormatted} + {t("totalDowntime")}: {uptimeStats.totalDowntimeFormatted}
{service.status !== "paused" && (
- {service.status === "up" ? "Up" : "Down"} for {uptimeStats.currentStatusDuration} + { + service.status === "up" ? + t('upStatusDuration').replace("{duration}", uptimeStats.currentStatusDuration) + : + t('downStatusDuration').replace("{duration}", uptimeStats.currentStatusDuration) + }
)} @@ -153,16 +161,16 @@ export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProp - Monitoring Settings + {t('monitoringSettings')}
- Checked every {service.interval} seconds + {t('monitoringSettingsInterval').replace('{interval}', String(service.interval))}
- {service.type} monitoring + {service.type} {t('monitoringSettingsType')}
diff --git a/application/src/components/services/ServicesPagination.tsx b/application/src/components/services/ServicesPagination.tsx index a9fab1a..2fc48c4 100644 --- a/application/src/components/services/ServicesPagination.tsx +++ b/application/src/components/services/ServicesPagination.tsx @@ -80,9 +80,9 @@ export function ServicesPagination({ - {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") }
diff --git a/application/src/components/services/UptimeBar.tsx b/application/src/components/services/UptimeBar.tsx index 642e35e..7a85f05 100644 --- a/application/src/components/services/UptimeBar.tsx +++ b/application/src/components/services/UptimeBar.tsx @@ -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 =
- Last 20 checks + {t('last20Checks')} ); diff --git a/application/src/components/services/add-service/ServiceFormActions.tsx b/application/src/components/services/add-service/ServiceFormActions.tsx index 4f6ad59..e9a5552 100644 --- a/application/src/components/services/add-service/ServiceFormActions.tsx +++ b/application/src/components/services/add-service/ServiceFormActions.tsx @@ -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) => { e.preventDefault(); if (!isSubmitting) { @@ -29,7 +32,7 @@ export function ServiceFormActions({ variant="outline" disabled={isSubmitting} > - Cancel + {t("cancel")}
@@ -110,7 +113,7 @@ const Profile = () => { {loading ? (
- Loading user data... + {t("loadingUserData")}
) : error ? (
@@ -121,7 +124,7 @@ const Profile = () => { onClick={() => fetchUserData()} className="px-4 py-2 bg-primary text-primary-foreground rounded-md" > - Retry + {t("retry")}
) : ( @@ -136,4 +139,4 @@ const Profile = () => { ); }; -export default Profile; \ No newline at end of file +export default Profile; diff --git a/application/src/services/alertConfigService.ts b/application/src/services/alertConfigService.ts index 83a702c..d6f0c30 100644 --- a/application/src/services/alertConfigService.ts +++ b/application/src/services/alertConfigService.ts @@ -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 || ""; diff --git a/application/src/translations/en/about.ts b/application/src/translations/en/about.ts index ef6ddcc..3e399ac 100644 --- a/application/src/translations/en/about.ts +++ b/application/src/translations/en/about.ts @@ -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", }; diff --git a/application/src/translations/en/incident.ts b/application/src/translations/en/incident.ts index 7a7943e..3e30c90 100644 --- a/application/src/translations/en/incident.ts +++ b/application/src/translations/en/incident.ts @@ -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', }; diff --git a/application/src/translations/en/maintenance.ts b/application/src/translations/en/maintenance.ts index 4d8563f..a4a4413 100644 --- a/application/src/translations/en/maintenance.ts +++ b/application/src/translations/en/maintenance.ts @@ -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', }; diff --git a/application/src/translations/en/services.ts b/application/src/translations/en/services.ts index 3058a85..9204fe2 100644 --- a/application/src/translations/en/services.ts +++ b/application/src/translations/en/services.ts @@ -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", }; \ No newline at end of file diff --git a/application/src/translations/en/settings.ts b/application/src/translations/en/settings.ts index 16077c5..2c4474d 100644 --- a/application/src/translations/en/settings.ts +++ b/application/src/translations/en/settings.ts @@ -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...", diff --git a/application/src/translations/types/about.ts b/application/src/translations/types/about.ts index 9d8a1f6..e2972c1 100644 --- a/application/src/translations/types/about.ts +++ b/application/src/translations/types/about.ts @@ -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; } diff --git a/application/src/translations/types/incident.ts b/application/src/translations/types/incident.ts index 4ad5ccf..42c983d 100644 --- a/application/src/translations/types/incident.ts +++ b/application/src/translations/types/incident.ts @@ -59,4 +59,5 @@ export interface IncidentTranslations { affectedSystems: string; enterAffectedSystems: string; separateSystemsWithComma: string; + createIncidentDesc: string; } diff --git a/application/src/translations/types/maintenance.ts b/application/src/translations/types/maintenance.ts index 4189301..8e75bae 100644 --- a/application/src/translations/types/maintenance.ts +++ b/application/src/translations/types/maintenance.ts @@ -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; } diff --git a/application/src/translations/types/services.ts b/application/src/translations/types/services.ts index bd9e7a9..cda603a 100644 --- a/application/src/translations/types/services.ts +++ b/application/src/translations/types/services.ts @@ -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; } diff --git a/application/src/translations/types/settings.ts b/application/src/translations/types/settings.ts index cf4de01..1569f51 100644 --- a/application/src/translations/types/settings.ts +++ b/application/src/translations/types/settings.ts @@ -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; diff --git a/application/src/translations/zhcn/about.ts b/application/src/translations/zhcn/about.ts index f8eb611..5e0d3de 100644 --- a/application/src/translations/zhcn/about.ts +++ b/application/src/translations/zhcn/about.ts @@ -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} 个集合", }; diff --git a/application/src/translations/zhcn/incident.ts b/application/src/translations/zhcn/incident.ts index 16c21c6..dc5a838 100644 --- a/application/src/translations/zhcn/incident.ts +++ b/application/src/translations/zhcn/incident.ts @@ -53,4 +53,13 @@ export const incidentTranslations: IncidentTranslations = { failedToUpdateStatus: '更新状态失败', inProgress: '进行中', enterRootCause: '输入故障根源', + enterIncidentTitle: '输入事件标题', + enterIncidentDescription: '输入事件描述', + enterServiceId: '输入以逗号分隔的服务ID', + selectAssignedUser: '选择分配用户', + noAssignedUser: '该事件当前未分配给任何用户', + affectedSystems: '受影响系统', + enterAffectedSystems: '受影响系统', + separateSystemsWithComma: '输入受影响的系统,以逗号分隔', + createIncidentDesc: '创建一个新的事件', }; diff --git a/application/src/translations/zhcn/maintenance.ts b/application/src/translations/zhcn/maintenance.ts index fa8f7c3..3568711 100644 --- a/application/src/translations/zhcn/maintenance.ts +++ b/application/src/translations/zhcn/maintenance.ts @@ -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 服务', }; diff --git a/application/src/translations/zhcn/services.ts b/application/src/translations/zhcn/services.ts index 4e9cc95..f7ba25a 100644 --- a/application/src/translations/zhcn/services.ts +++ b/application/src/translations/zhcn/services.ts @@ -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 次检查", }; \ No newline at end of file diff --git a/application/src/translations/zhcn/settings.ts b/application/src/translations/zhcn/settings.ts index a57757e..f5544aa 100644 --- a/application/src/translations/zhcn/settings.ts +++ b/application/src/translations/zhcn/settings.ts @@ -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: "保存中...", diff --git a/docs/DEVELOPMENT_ROADMAP.md b/docs/DEVELOPMENT_ROADMAP.md index be995f4..1a87bf4 100644 --- a/docs/DEVELOPMENT_ROADMAP.md +++ b/docs/DEVELOPMENT_ROADMAP.md @@ -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) diff --git a/docs/README_zhcn.md b/docs/README_zhcn.md index e72b236..7bdd5af 100644 --- a/docs/README_zhcn.md +++ b/docs/README_zhcn.md @@ -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 \ --- ## 赞助商 -🙏 我们非常感谢我们的赞助商。你们的贡献使我们能够维护基础设施(托管、域名)、运行测试,并继续开发有价值的功能。 +我们将不再接受赞助形式。从今以后,仅接受以生态系统和社区合作形式提供的支持,例如云服务器、域名或托管资源等基础设施支持。 -### 🥈 白银在线保障合作伙伴 - - - - +如果您是科技公司且有意支持 CheckCle ,请直接通过邮箱 tolaleng@checkcle.io 联系作者。 ### 🧡 支持者 +
+ + GitBook Logo + - - - - -### 🤝 生态系统与社区合作伙伴 - - - + + Cloudflare Logo + + + DigitalOcean Logo + +
--- diff --git a/server/service-operation/data-retention/handler.go b/server/service-operation/data-retention/handler.go new file mode 100644 index 0000000..514db3d --- /dev/null +++ b/server/service-operation/data-retention/handler.go @@ -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) +} \ No newline at end of file diff --git a/server/service-operation/data-retention/scheduler.go b/server/service-operation/data-retention/scheduler.go new file mode 100644 index 0000000..a064cee --- /dev/null +++ b/server/service-operation/data-retention/scheduler.go @@ -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() +} \ No newline at end of file diff --git a/server/service-operation/data-retention/service.go b/server/service-operation/data-retention/service.go new file mode 100644 index 0000000..75e224c --- /dev/null +++ b/server/service-operation/data-retention/service.go @@ -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<= 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 +} \ No newline at end of file diff --git a/server/service-operation/data-retention/types.go b/server/service-operation/data-retention/types.go new file mode 100644 index 0000000..7ebdb1c --- /dev/null +++ b/server/service-operation/data-retention/types.go @@ -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"` +} \ No newline at end of file diff --git a/server/service-operation/main.go b/server/service-operation/main.go index e337318..37999aa 100644 --- a/server/service-operation/main.go +++ b/server/service-operation/main.go @@ -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 ===") diff --git a/server/service-operation/notification/gotify.go b/server/service-operation/notification/gotify.go new file mode 100644 index 0000000..d63a395 --- /dev/null +++ b/server/service-operation/notification/gotify.go @@ -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)) +} \ No newline at end of file diff --git a/server/service-operation/notification/manager.go b/server/service-operation/notification/manager.go index 39d5d69..eaed9d5 100644 --- a/server/service-operation/notification/manager.go +++ b/server/service-operation/notification/manager.go @@ -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)) diff --git a/server/service-operation/notification/notifiarr.go b/server/service-operation/notification/notifiarr.go new file mode 100644 index 0000000..21a6538 --- /dev/null +++ b/server/service-operation/notification/notifiarr.go @@ -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)) +} \ No newline at end of file diff --git a/server/service-operation/notification/ntfy.go b/server/service-operation/notification/ntfy.go index 88ec7a5..9674445 100644 --- a/server/service-operation/notification/ntfy.go +++ b/server/service-operation/notification/ntfy.go @@ -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{} diff --git a/server/service-operation/notification/types.go b/server/service-operation/notification/types.go index 3a8a5d2..125cbef 100644 --- a/server/service-operation/notification/types.go +++ b/server/service-operation/notification/types.go @@ -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