From 549a3793a536692053b5b2bc48b33505e74bcc93 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 19 Jun 2025 15:23:48 +0700 Subject: [PATCH 1/4] Fix: Uptime history timestamp and service ID --- .../services/hooks/useUptimeData.ts | 94 ++++++++++--------- .../services/uptime/UptimeSummary.tsx | 2 +- application/src/services/uptimeService.ts | 32 +++---- 3 files changed, 63 insertions(+), 65 deletions(-) diff --git a/application/src/components/services/hooks/useUptimeData.ts b/application/src/components/services/hooks/useUptimeData.ts index 92c860d..91e5391 100644 --- a/application/src/components/services/hooks/useUptimeData.ts +++ b/application/src/components/services/hooks/useUptimeData.ts @@ -14,100 +14,102 @@ interface UseUptimeDataProps { export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseUptimeDataProps) => { const [historyItems, setHistoryItems] = useState([]); - // Fetch real uptime history data if serviceId is provided with improved caching and error handling + // Fetch real uptime history data if serviceId is provided const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({ queryKey: ['uptimeHistory', serviceId, serviceType], - queryFn: () => serviceId ? uptimeService.getUptimeHistory(serviceId, 50, undefined, undefined, serviceType) : Promise.resolve([]), + queryFn: () => { + if (!serviceId) { + console.log('No serviceId provided, skipping fetch'); + return Promise.resolve([]); + } + console.log(`Fetching uptime data for service ${serviceId} of type ${serviceType}`); + return uptimeService.getUptimeHistory(serviceId, 50, undefined, undefined, serviceType); + }, enabled: !!serviceId, - refetchInterval: 30000, // Refresh every 30 seconds - staleTime: 15000, // Consider data fresh for 15 seconds - placeholderData: (previousData) => previousData, // Show previous data while refetching - retry: 3, // Retry failed requests three times - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff with max 10s + refetchInterval: 30000, + staleTime: 15000, + placeholderData: (previousData) => previousData, + retry: 3, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), }); - // Filter uptime data to respect the service interval - const filterUptimeDataByInterval = (data: UptimeData[], intervalSeconds: number): UptimeData[] => { + // Filter and process uptime data + const processUptimeData = (data: UptimeData[], intervalSeconds: number): UptimeData[] => { if (!data || data.length === 0) return []; + console.log(`Processing ${data.length} uptime records for service ${serviceId}`); + // Sort data by timestamp (newest first) const sortedData = [...data].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); - const filtered: UptimeData[] = []; - let lastIncludedTime: number | null = null; - const intervalMs = intervalSeconds * 1000; // Convert to milliseconds + // Take the most recent 20 records to ensure we have enough data + const recentData = sortedData.slice(0, 20); - // Include the most recent record first - if (sortedData.length > 0) { - filtered.push(sortedData[0]); - lastIncludedTime = new Date(sortedData[0].timestamp).getTime(); - } + console.log(`Using ${recentData.length} most recent records`); - // Filter subsequent records to maintain proper interval spacing - for (let i = 1; i < sortedData.length && filtered.length < 20; i++) { - const currentTime = new Date(sortedData[i].timestamp).getTime(); - - // Only include if enough time has passed since the last included record - if (lastIncludedTime && (lastIncludedTime - currentTime) >= intervalMs) { - filtered.push(sortedData[i]); - lastIncludedTime = currentTime; - } - } - - return filtered; + return recentData; }; // Update history items when data changes useEffect(() => { if (uptimeData && uptimeData.length > 0) { - // Filter data based on the service interval - const filteredData = filterUptimeDataByInterval(uptimeData, interval); - setHistoryItems(filteredData); - } else if (status === "paused" || (uptimeData && uptimeData.length === 0)) { - // For paused services with no history, or empty history data, show all as paused + console.log(`Received ${uptimeData.length} uptime records for service ${serviceId}`); + const processedData = processUptimeData(uptimeData, interval); + setHistoryItems(processedData); + } else if (!serviceId || (uptimeData && uptimeData.length === 0)) { + // Generate placeholder data when no real data is available + console.log(`No uptime data available for service ${serviceId}, generating placeholder`); + const statusValue = (status === "up" || status === "down" || status === "warning" || status === "paused") ? status - : "paused"; // Default to paused if not a valid status + : "paused"; const placeholderHistory: UptimeData[] = Array(20).fill(null).map((_, index) => ({ - id: `placeholder-${index}`, + id: `placeholder-${serviceId}-${index}`, serviceId: serviceId || "", timestamp: new Date(Date.now() - (index * interval * 1000)).toISOString(), status: statusValue as "up" | "down" | "warning" | "paused", responseTime: 0 })); + setHistoryItems(placeholderHistory); } }, [uptimeData, serviceId, status, interval]); - // Ensure we always have 20 items by padding with the last known status + // Ensure we always have exactly 20 items for consistent display const getDisplayItems = (): UptimeData[] => { - const displayItems = [...historyItems]; - if (displayItems.length < 20) { - const lastItem = displayItems.length > 0 ? displayItems[displayItems.length - 1] : null; + const items = [...historyItems]; + + // If we have fewer than 20 items, pad with older placeholder data + if (items.length < 20) { + const lastItem = items.length > 0 ? items[items.length - 1] : null; const lastStatus = lastItem ? lastItem.status : (status === "up" || status === "down" || status === "warning" || status === "paused") ? status as "up" | "down" | "warning" | "paused" : "paused"; - // Generate padding items with proper time spacing - const paddingItems: UptimeData[] = Array(20 - displayItems.length).fill(null).map((_, index) => { + const paddingCount = 20 - items.length; + const paddingItems: UptimeData[] = Array(paddingCount).fill(null).map((_, index) => { const baseTime = lastItem ? new Date(lastItem.timestamp).getTime() : Date.now(); - const timeOffset = (index + 1) * interval * 1000; // Respect the interval + const timeOffset = (index + 1) * interval * 1000; return { - id: `padding-${index}`, + id: `padding-${serviceId}-${index}`, serviceId: serviceId || "", timestamp: new Date(baseTime - timeOffset).toISOString(), status: lastStatus, responseTime: 0 }; }); - displayItems.push(...paddingItems); + + items.push(...paddingItems); } - return displayItems.slice(0, 20); + // Return exactly 20 items, sorted by timestamp (newest first) + return items.slice(0, 20).sort((a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); }; return { diff --git a/application/src/components/services/uptime/UptimeSummary.tsx b/application/src/components/services/uptime/UptimeSummary.tsx index b74e050..9f4eb4e 100644 --- a/application/src/components/services/uptime/UptimeSummary.tsx +++ b/application/src/components/services/uptime/UptimeSummary.tsx @@ -13,7 +13,7 @@ export const UptimeSummary = ({ uptime, interval }: UptimeSummaryProps) => { {Math.round(uptime)}% uptime - Last 20 checks ({interval}s interval) + Last 20 checks ); diff --git a/application/src/services/uptimeService.ts b/application/src/services/uptimeService.ts index 7a4d54e..5a6808c 100644 --- a/application/src/services/uptimeService.ts +++ b/application/src/services/uptimeService.ts @@ -64,6 +64,11 @@ export const uptimeService = { serviceType?: string ): Promise { try { + if (!serviceId) { + console.log('No serviceId provided to getUptimeHistory'); + return []; + } + const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}_${serviceType || 'default'}`; // Check cache @@ -77,6 +82,7 @@ export const uptimeService = { const collection = serviceType ? getCollectionForServiceType(serviceType) : 'uptime_data'; console.log(`Fetching uptime history for service ${serviceId} from collection ${collection}, limit: ${limit}`); + // Build filter to get records for specific service_id let filter = `service_id='${serviceId}'`; // Add date range filtering if provided @@ -90,7 +96,7 @@ export const uptimeService = { const options = { filter: filter, - sort: '-timestamp', + sort: '-timestamp', // Sort by timestamp descending (newest first) $autoCancel: false, $cancelKey: `uptime_history_${serviceId}_${Date.now()}` }; @@ -104,27 +110,15 @@ export const uptimeService = { if (response.items.length > 0) { console.log(`Date range in results: ${response.items[response.items.length - 1].timestamp} to ${response.items[0].timestamp}`); } else { - console.log(`No records found for filter: ${filter} in collection: ${collection}`); - - // Try a fallback query without date filter to see if there's any data at all - const fallbackResponse = await pb.collection(collection).getList(1, 10, { - filter: `service_id='${serviceId}'`, - sort: '-timestamp', - $autoCancel: false - }); - - console.log(`Fallback query found ${fallbackResponse.items.length} total records for service in ${collection}`); - if (fallbackResponse.items.length > 0) { - console.log(`Latest record timestamp: ${fallbackResponse.items[0].timestamp}`); - console.log(`Oldest record timestamp: ${fallbackResponse.items[fallbackResponse.items.length - 1].timestamp}`); - } + console.log(`No records found for service_id '${serviceId}' in collection: ${collection}`); } + // Transform the response items to UptimeData format const uptimeData = response.items.map(item => ({ id: item.id, serviceId: item.service_id, timestamp: item.timestamp, - status: item.status, + status: item.status as "up" | "down" | "warning" | "paused", responseTime: item.response_time || 0, date: item.timestamp, uptime: 100 @@ -139,7 +133,7 @@ export const uptimeService = { return uptimeData; } catch (error) { - console.error("Error fetching uptime history:", error); + console.error(`Error fetching uptime history for service ${serviceId}:`, error); // Try to return cached data as fallback const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}_${serviceType || 'default'}`; @@ -149,7 +143,9 @@ export const uptimeService = { return cached.data; } - throw new Error('Failed to load uptime history.'); + // Return empty array instead of throwing to prevent UI crashes + console.log(`Returning empty array for service ${serviceId} due to fetch error`); + return []; } } }; \ No newline at end of file From dfe98f9fa6f034f0a222379403681b2aa49ec368 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 19 Jun 2025 17:35:12 +0700 Subject: [PATCH 2/4] Fix: Pass serviceType to ServiceUptimeHistory and Refactor service detail page data fetching --- .../hooks/useServiceData.tsx | 26 +++++++++++++------ .../services/ServiceDetailContent.tsx | 17 ++++++++---- .../services/ServiceUptimeHistory.tsx | 15 +++++++---- 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/application/src/components/services/ServiceDetailContainer/hooks/useServiceData.tsx b/application/src/components/services/ServiceDetailContainer/hooks/useServiceData.tsx index 32db1f8..8473786 100644 --- a/application/src/components/services/ServiceDetailContainer/hooks/useServiceData.tsx +++ b/application/src/components/services/ServiceDetailContainer/hooks/useServiceData.tsx @@ -42,7 +42,12 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange?: DateRangeOption | string) => { try { - console.log(`Fetching uptime data: ${start.toISOString()} to ${end.toISOString()} for range: ${selectedRange}`); + if (!service) { + console.log('No service data available for uptime fetch'); + return []; + } + + console.log(`Fetching uptime data: ${start.toISOString()} to ${end.toISOString()} for range: ${selectedRange}, service type: ${service.type}`); let limit = 500; // Default limit @@ -54,8 +59,9 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e console.log(`Using limit ${limit} for range ${selectedRange}`); - const history = await uptimeService.getUptimeHistory(serviceId, limit, start, end); - console.log(`Retrieved ${history.length} uptime records`); + // Use the service type to fetch from the correct collection + const history = await uptimeService.getUptimeHistory(serviceId, limit, start, end, service.type); + console.log(`Retrieved ${history.length} uptime records from collection for ${service.type} service`); // Sort by timestamp (newest first) const filteredHistory = [...history].sort((a, b) => @@ -97,6 +103,9 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e id: serviceData.id, name: serviceData.name, url: serviceData.url || "", + host: serviceData.host || "", + port: serviceData.port || undefined, + domain: serviceData.domain || "", type: serviceData.service_type || serviceData.type || "HTTP", status: serviceData.status || "paused", responseTime: serviceData.response_time || serviceData.responseTime || 0, @@ -109,10 +118,11 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e alerts: serviceData.alerts || "unmuted" }; + console.log(`Loaded service: ${formattedService.name} (${formattedService.type})`); setService(formattedService); - // Fetch initial uptime history with 24h default - await fetchUptimeData(serviceId, startDate, endDate, '24h'); + // Fetch initial uptime history with 24h default - wait for service to be set + await new Promise(resolve => setTimeout(resolve, 100)); // Small delay to ensure state is updated } catch (error) { console.error("Error fetching service:", error); toast({ @@ -129,11 +139,11 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e fetchServiceData(); }, [serviceId, navigate, toast]); - // Update data when date range changes + // Update data when date range changes or when service is loaded useEffect(() => { if (serviceId && !isLoading && service) { - console.log(`Date range changed, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`); - fetchUptimeData(serviceId, startDate, endDate); + console.log(`Date range changed or service loaded, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`); + fetchUptimeData(serviceId, startDate, endDate, '24h'); } }, [startDate, endDate, serviceId, isLoading, service]); diff --git a/application/src/components/services/ServiceDetailContent.tsx b/application/src/components/services/ServiceDetailContent.tsx index 78dabb1..fd9aef9 100644 --- a/application/src/components/services/ServiceDetailContent.tsx +++ b/application/src/components/services/ServiceDetailContent.tsx @@ -33,10 +33,17 @@ export const ServiceDetailContent = ({

Response Time History

- +
+ + Collection: {service.type.toLowerCase() === 'ping' || service.type.toLowerCase() === 'icmp' ? 'ping_data' : + service.type.toLowerCase() === 'dns' ? 'dns_data' : + service.type.toLowerCase() === 'tcp' ? 'tcp_data' : 'uptime_data'} + + +
{!hasUptimeData && ( @@ -61,4 +68,4 @@ export const ServiceDetailContent = ({ ); -}; +}; \ No newline at end of file diff --git a/application/src/components/services/ServiceUptimeHistory.tsx b/application/src/components/services/ServiceUptimeHistory.tsx index 11fa123..9dca3a6 100644 --- a/application/src/components/services/ServiceUptimeHistory.tsx +++ b/application/src/components/services/ServiceUptimeHistory.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { UptimeData } from "@/types/service.types"; +import { UptimeData, Service } from "@/types/service.types"; import { uptimeService } from "@/services/uptimeService"; import { format, parseISO } from "date-fns"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -10,20 +10,25 @@ import { Check, X, AlertTriangle, Pause } from "lucide-react"; interface ServiceUptimeHistoryProps { serviceId: string; + serviceType: string; // Add service type to determine collection startDate?: Date; endDate?: Date; } export function ServiceUptimeHistory({ serviceId, + serviceType, startDate = new Date(Date.now() - 24 * 60 * 60 * 1000), endDate = new Date() }: ServiceUptimeHistoryProps) { const { theme } = useTheme(); const { data: uptimeHistory, isLoading, error } = useQuery({ - queryKey: ['uptimeHistory', serviceId, startDate?.toISOString(), endDate?.toISOString()], - queryFn: () => uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate), - enabled: !!serviceId, + queryKey: ['uptimeHistory', serviceId, serviceType, startDate?.toISOString(), endDate?.toISOString()], + queryFn: () => { + console.log(`ServiceUptimeHistory: Fetching for service ${serviceId} of type ${serviceType}`); + return uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate, serviceType); + }, + enabled: !!serviceId && !!serviceType, refetchInterval: 5000, // Refresh UI every 5 seconds }); @@ -135,4 +140,4 @@ export function ServiceUptimeHistory({ ); -} +} \ No newline at end of file From 89572c44b7a0c6fa38fe6c8b40d9f08dd8d9ec74 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 19 Jun 2025 17:43:42 +0700 Subject: [PATCH 3/4] Updated an entrypoint to start service-operation --- scripts/entrypoint.sh | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 4732954..17b40f6 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,7 +1,28 @@ #!/bin/sh -if [ -z "$(ls -A /app/pb_data)" ]; then - echo "Initializing pb_data from image..." - cp -r /app/pb_data_seed/* /app/pb_data/ + +echo "Running on architecture: $(uname -m)" + +# Copy PocketBase data if empty +if [ ! -f /mnt/pb_data/data.db ] && [ -d /app/pb_data ] && [ "$(ls -A /app/pb_data)" ]; then + cp -a /app/pb_data/. /mnt/pb_data/ fi -exec /app/pocketbase serve --dir /app/pb_data +# Start PocketBase in the background +echo "Starting PocketBase..." +/app/pocketbase serve --http=0.0.0.0:8090 --dir /mnt/pb_data 2>&1 | grep -vE 'REST API|Dashboard' & + +# Wait for PocketBase to become available +echo "Waiting for PocketBase to become available..." +until curl -s http://localhost:8090/api/health >/dev/null; do + echo "Waiting on http://localhost:8090..." + sleep 1 +done + +echo "PocketBase is up!" + +# Start Go service +echo "Starting Go service..." +/app/service-operation & + +# Keep container alive +wait From d3ea5dc77cff40cff05c7d3604869fb833ffb27f Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 19 Jun 2025 17:49:30 +0700 Subject: [PATCH 4/4] Implement CheckCle Microservice Operation A Go-based microservice for service operations including ICMP ping, DNS resolution, and TCP connectivity --- server/service-operation/.env | 16 + server/service-operation/.env.example | 16 + server/service-operation/README.md | 138 ++++++ server/service-operation/config/config.go | 71 +++ server/service-operation/go.mod | 10 + server/service-operation/go.sum | 6 + .../handlers/health_handler.go | 21 + .../handlers/metrics_handler.go | 12 + .../service-operation/handlers/operation.go | 19 + .../handlers/operation_handler.go | 104 ++++ .../handlers/quick_operation_handler.go | 67 +++ server/service-operation/handlers/utils.go | 45 ++ server/service-operation/main.go | 91 ++++ .../service-operation/monitoring/checker.go | 122 +++++ .../monitoring/metrics_saver.go | 33 ++ .../service-operation/monitoring/monitor.go | 52 ++ .../service-operation/monitoring/service.go | 112 +++++ server/service-operation/monitoring/utils.go | 10 + server/service-operation/operations/dns.go | 254 ++++++++++ server/service-operation/operations/http.go | 136 ++++++ server/service-operation/operations/ping.go | 460 ++++++++++++++++++ server/service-operation/operations/tcp.go | 49 ++ server/service-operation/ping/icmp.go | 117 +++++ server/service-operation/ping/types.go | 25 + server/service-operation/pocketbase/client.go | 52 ++ .../service-operation/pocketbase/metrics.go | 22 + .../service-operation/pocketbase/services.go | 138 ++++++ server/service-operation/pocketbase/types.go | 126 +++++ server/service-operation/pocketbase/utils.go | 47 ++ .../shared/savers/dns_saver.go | 74 +++ .../shared/savers/metrics_saver.go | 90 ++++ .../shared/savers/ping_saver.go | 72 +++ .../shared/savers/tcp_saver.go | 66 +++ .../shared/savers/uptime_saver.go | 72 +++ .../service-operation/shared/savers/utils.go | 89 ++++ server/service-operation/types/operation.go | 61 +++ 36 files changed, 2895 insertions(+) create mode 100644 server/service-operation/.env create mode 100644 server/service-operation/.env.example create mode 100644 server/service-operation/README.md create mode 100644 server/service-operation/config/config.go create mode 100644 server/service-operation/go.mod create mode 100644 server/service-operation/go.sum create mode 100644 server/service-operation/handlers/health_handler.go create mode 100644 server/service-operation/handlers/metrics_handler.go create mode 100644 server/service-operation/handlers/operation.go create mode 100644 server/service-operation/handlers/operation_handler.go create mode 100644 server/service-operation/handlers/quick_operation_handler.go create mode 100644 server/service-operation/handlers/utils.go create mode 100644 server/service-operation/main.go create mode 100644 server/service-operation/monitoring/checker.go create mode 100644 server/service-operation/monitoring/metrics_saver.go create mode 100644 server/service-operation/monitoring/monitor.go create mode 100644 server/service-operation/monitoring/service.go create mode 100644 server/service-operation/monitoring/utils.go create mode 100644 server/service-operation/operations/dns.go create mode 100644 server/service-operation/operations/http.go create mode 100644 server/service-operation/operations/ping.go create mode 100644 server/service-operation/operations/tcp.go create mode 100644 server/service-operation/ping/icmp.go create mode 100644 server/service-operation/ping/types.go create mode 100644 server/service-operation/pocketbase/client.go create mode 100644 server/service-operation/pocketbase/metrics.go create mode 100644 server/service-operation/pocketbase/services.go create mode 100644 server/service-operation/pocketbase/types.go create mode 100644 server/service-operation/pocketbase/utils.go create mode 100644 server/service-operation/shared/savers/dns_saver.go create mode 100644 server/service-operation/shared/savers/metrics_saver.go create mode 100644 server/service-operation/shared/savers/ping_saver.go create mode 100644 server/service-operation/shared/savers/tcp_saver.go create mode 100644 server/service-operation/shared/savers/uptime_saver.go create mode 100644 server/service-operation/shared/savers/utils.go create mode 100644 server/service-operation/types/operation.go diff --git a/server/service-operation/.env b/server/service-operation/.env new file mode 100644 index 0000000..4afb2f6 --- /dev/null +++ b/server/service-operation/.env @@ -0,0 +1,16 @@ + +# Port configuration +PORT=8091 + +# Operation defaults +DEFAULT_COUNT=4 +DEFAULT_TIMEOUT=3s +MAX_COUNT=20 +MAX_TIMEOUT=30s + +# Logging +ENABLE_LOGGING=true + +# PocketBase integration (no authentication required) +POCKETBASE_ENABLED=true +POCKETBASE_URL=http://localhost:8090 \ No newline at end of file diff --git a/server/service-operation/.env.example b/server/service-operation/.env.example new file mode 100644 index 0000000..c347693 --- /dev/null +++ b/server/service-operation/.env.example @@ -0,0 +1,16 @@ + +# Port configuration +PORT=8091 + +# Operation defaults +DEFAULT_COUNT=4 +DEFAULT_TIMEOUT=3s +MAX_COUNT=20 +MAX_TIMEOUT=30s + +# Logging +ENABLE_LOGGING=true + +# PocketBase integration (no authentication required) +POCKETBASE_ENABLED=true +POCKETBASE_URL=https://pb-api.k8sops.asia \ No newline at end of file diff --git a/server/service-operation/README.md b/server/service-operation/README.md new file mode 100644 index 0000000..46084f2 --- /dev/null +++ b/server/service-operation/README.md @@ -0,0 +1,138 @@ + +# CheckCle Microservice Operation + +A Go-based microservice for service operations including ICMP ping, DNS resolution, and TCP connectivity testing. + +## Features + +- **ICMP Ping**: Full ping functionality with packet statistics +- **DNS Resolution**: A, AAAA, MX, and TXT record lookups +- **TCP Connectivity**: Port connectivity testing +- REST API endpoints +- Health check endpoint +- Configurable via environment variables +- Docker support +- Comprehensive operation statistics + +## API Endpoints + +### POST /operation +Perform various network operations (ping, dns, tcp). + +**Ping Request:** +```json +{ + "type": "ping", + "host": "google.com", + "count": 4, + "timeout": 3 +} +``` + +**DNS Request:** +```json +{ + "type": "dns", + "host": "google.com", + "query": "A", + "timeout": 3 +} +``` + +**TCP Request:** +```json +{ + "type": "tcp", + "host": "google.com", + "port": 443, + "timeout": 3 +} +``` + +**Response:** +```json +{ + "type": "ping", + "host": "google.com", + "success": true, + "response_time": "20ms", + "packets_sent": 4, + "packets_recv": 4, + "packet_loss": 0, + "min_rtt": "15ms", + "max_rtt": "25ms", + "avg_rtt": "20ms", + "rtts": ["15ms", "20ms", "25ms", "18ms"], + "start_time": "2023-12-01T10:00:00Z", + "end_time": "2023-12-01T10:00:03Z" +} +``` + +### GET /operation/quick +Quick operation test with query parameters. + +**Examples:** +- `/operation/quick?type=ping&host=google.com&count=1` +- `/operation/quick?type=dns&host=google.com&query=A` +- `/operation/quick?type=tcp&host=google.com&port=443` + +### GET /health +Health check endpoint. + +### Legacy Endpoints +- `POST /ping` - Legacy ping endpoint (backward compatibility) +- `GET /ping/quick` - Legacy quick ping endpoint + +## Operation Types + +### Ping (ICMP) +- **Type**: `ping` +- **Parameters**: `host`, `count`, `timeout` +- **Features**: Packet loss calculation, RTT statistics, multiple packets + +### DNS Resolution +- **Type**: `dns` +- **Parameters**: `host`, `query` (A, AAAA, MX, TXT), `timeout` +- **Features**: Multiple record types, resolution time tracking + +### TCP Connectivity +- **Type**: `tcp` +- **Parameters**: `host`, `port`, `timeout` +- **Features**: Connection testing, response time measurement + +## Configuration + +Environment variables: + +- `PORT` - Service port (default: 8080) +- `DEFAULT_COUNT` - Default ping count (default: 4) +- `DEFAULT_TIMEOUT` - Default timeout (default: 3s) +- `MAX_COUNT` - Maximum ping count (default: 20) +- `MAX_TIMEOUT` - Maximum timeout (default: 30s) +- `ENABLE_LOGGING` - Enable logging (default: true) + +## Running + +### Local Development +```bash +go run main.go +``` + +### Docker +```bash +docker build -t service-operation . +docker run -p 8080:8080 service-operation +``` + +## Requirements + +- Go 1.21+ +- Root privileges for ICMP (on Linux) + +## Note + +This service requires elevated privileges to send ICMP packets. Run with sudo on Linux systems or use capabilities: + +```bash +sudo setcap cap_net_raw=+ep ./service-operation +``` diff --git a/server/service-operation/config/config.go b/server/service-operation/config/config.go new file mode 100644 index 0000000..0ff5cc9 --- /dev/null +++ b/server/service-operation/config/config.go @@ -0,0 +1,71 @@ +package config + +import ( + "os" + "strconv" + "time" +) + +type Config struct { + Port string + DefaultCount int + DefaultTimeout time.Duration + MaxCount int + MaxTimeout time.Duration + EnableLogging bool + + // PocketBase configuration (no auth required) + PocketBaseEnabled bool + PocketBaseURL string +} + +func Load() *Config { + cfg := &Config{ + Port: getEnv("PORT", "8091"), + DefaultCount: getEnvInt("DEFAULT_COUNT", 4), + DefaultTimeout: getEnvDuration("DEFAULT_TIMEOUT", 3*time.Second), + MaxCount: getEnvInt("MAX_COUNT", 20), + MaxTimeout: getEnvDuration("MAX_TIMEOUT", 30*time.Second), + EnableLogging: getEnvBool("ENABLE_LOGGING", true), + + // PocketBase settings (no credentials needed) + PocketBaseEnabled: getEnvBool("POCKETBASE_ENABLED", true), + PocketBaseURL: getEnv("POCKETBASE_URL", ""), + } + + return cfg +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + +func getEnvDuration(key string, defaultValue time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + if boolValue, err := strconv.ParseBool(value); err == nil { + return boolValue + } + } + return defaultValue +} \ No newline at end of file diff --git a/server/service-operation/go.mod b/server/service-operation/go.mod new file mode 100644 index 0000000..eeb5e5c --- /dev/null +++ b/server/service-operation/go.mod @@ -0,0 +1,10 @@ +module service-operation + +go 1.21 + +require ( + github.com/gorilla/mux v1.8.1 + golang.org/x/net v0.17.0 +) + +require golang.org/x/sys v0.13.0 // indirect diff --git a/server/service-operation/go.sum b/server/service-operation/go.sum new file mode 100644 index 0000000..afa7a8f --- /dev/null +++ b/server/service-operation/go.sum @@ -0,0 +1,6 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/server/service-operation/handlers/health_handler.go b/server/service-operation/handlers/health_handler.go new file mode 100644 index 0000000..7c66df4 --- /dev/null +++ b/server/service-operation/handlers/health_handler.go @@ -0,0 +1,21 @@ + +package handlers + +import ( + "encoding/json" + "net/http" + "time" +) + +func (h *OperationHandler) HandleHealth(w http.ResponseWriter, r *http.Request) { + health := map[string]interface{}{ + "status": "healthy", + "service": "service-operation", + "timestamp": time.Now().Unix(), + "version": "1.0.0", + "operations": []string{"ping", "dns", "tcp", "http"}, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(health) +} \ No newline at end of file diff --git a/server/service-operation/handlers/metrics_handler.go b/server/service-operation/handlers/metrics_handler.go new file mode 100644 index 0000000..52bb09a --- /dev/null +++ b/server/service-operation/handlers/metrics_handler.go @@ -0,0 +1,12 @@ + +package handlers + +import ( + "service-operation/shared/savers" + "service-operation/types" +) + +func (h *OperationHandler) saveMetricsToPocketBase(result *types.OperationResult, serviceID string) { + metricsSaver := savers.NewMetricsSaver(h.pbClient) + metricsSaver.SaveMetricsToPocketBase(result, serviceID) +} \ No newline at end of file diff --git a/server/service-operation/handlers/operation.go b/server/service-operation/handlers/operation.go new file mode 100644 index 0000000..af96744 --- /dev/null +++ b/server/service-operation/handlers/operation.go @@ -0,0 +1,19 @@ + +package handlers + +import ( + "service-operation/config" + "service-operation/pocketbase" +) + +type OperationHandler struct { + config *config.Config + pbClient *pocketbase.PocketBaseClient +} + +func NewOperationHandler(cfg *config.Config, pbClient *pocketbase.PocketBaseClient) *OperationHandler { + return &OperationHandler{ + config: cfg, + pbClient: pbClient, + } +} \ No newline at end of file diff --git a/server/service-operation/handlers/operation_handler.go b/server/service-operation/handlers/operation_handler.go new file mode 100644 index 0000000..5cc89b3 --- /dev/null +++ b/server/service-operation/handlers/operation_handler.go @@ -0,0 +1,104 @@ + +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "service-operation/operations" + "service-operation/types" +) + +func (h *OperationHandler) HandleOperation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req types.OperationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON payload", http.StatusBadRequest) + return + } + + if req.Host == "" && req.URL == "" { + http.Error(w, "Host or URL is required", http.StatusBadRequest) + return + } + + // Set defaults + if req.Count <= 0 { + req.Count = h.config.DefaultCount + } + if req.Count > h.config.MaxCount { + req.Count = h.config.MaxCount + } + + if req.Timeout <= 0 { + req.Timeout = int(h.config.DefaultTimeout.Seconds()) + } + if time.Duration(req.Timeout)*time.Second > h.config.MaxTimeout { + req.Timeout = int(h.config.MaxTimeout.Seconds()) + } + + timeout := time.Duration(req.Timeout) * time.Second + var result *types.OperationResult + var err error + + switch req.Type { + case types.OperationPing: + pingOp := operations.NewPingOperation(timeout) + result, err = pingOp.Execute(req.Host, req.Count) + + case types.OperationDNS: + dnsOp := operations.NewDNSOperation(timeout) + query := req.Query + if query == "" { + query = "A" + } + result, err = dnsOp.Execute(req.Host, query) + + case types.OperationTCP: + if req.Port <= 0 { + http.Error(w, "Port is required for TCP operations", http.StatusBadRequest) + return + } + tcpOp := operations.NewTCPOperation(timeout) + result, err = tcpOp.Execute(req.Host, req.Port) + + case types.OperationHTTP: + httpOp := operations.NewHTTPOperation(timeout) + url := req.URL + if url == "" { + url = req.Host + } + method := req.Method + if method == "" { + method = "GET" + } + result, err = httpOp.Execute(url, method) + + default: + http.Error(w, "Invalid operation type", http.StatusBadRequest) + return + } + + if err != nil { + result = &types.OperationResult{ + Type: req.Type, + Host: req.Host, + Port: req.Port, + Success: false, + Error: err.Error(), + } + } + + // Save metrics to PocketBase if available + if h.pbClient != nil && h.pbClient.IsAuthenticated() { + go h.saveMetricsToPocketBase(result, req.ServiceID) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} \ No newline at end of file diff --git a/server/service-operation/handlers/quick_operation_handler.go b/server/service-operation/handlers/quick_operation_handler.go new file mode 100644 index 0000000..0ee7d25 --- /dev/null +++ b/server/service-operation/handlers/quick_operation_handler.go @@ -0,0 +1,67 @@ + +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "service-operation/types" +) + +func (h *OperationHandler) HandleQuickOperation(w http.ResponseWriter, r *http.Request) { + opType := r.URL.Query().Get("type") + host := r.URL.Query().Get("host") + + if host == "" || opType == "" { + http.Error(w, "Type and host parameters are required", http.StatusBadRequest) + return + } + + req := types.OperationRequest{ + Type: types.OperationType(opType), + Host: host, + } + + // Parse optional parameters + if countStr := r.URL.Query().Get("count"); countStr != "" { + if c, err := strconv.Atoi(countStr); err == nil && c > 0 && c <= h.config.MaxCount { + req.Count = c + } + } + + if portStr := r.URL.Query().Get("port"); portStr != "" { + if p, err := strconv.Atoi(portStr); err == nil && p > 0 && p <= 65535 { + req.Port = p + } + } + + if query := r.URL.Query().Get("query"); query != "" { + req.Query = query + } + + if url := r.URL.Query().Get("url"); url != "" { + req.URL = url + } + + if method := r.URL.Query().Get("method"); method != "" { + req.Method = method + } + + if serviceID := r.URL.Query().Get("service_id"); serviceID != "" { + req.ServiceID = serviceID + } + + // Forward to main handler + reqBody, _ := json.Marshal(req) + r.Body = http.NoBody + r.Method = http.MethodPost + r.Header.Set("Content-Type", "application/json") + + // Create a new request with the body + newReq := r.Clone(r.Context()) + newReq.Body = http.NoBody + newReq.ContentLength = int64(len(reqBody)) + + h.HandleOperation(w, newReq) +} \ No newline at end of file diff --git a/server/service-operation/handlers/utils.go b/server/service-operation/handlers/utils.go new file mode 100644 index 0000000..116731b --- /dev/null +++ b/server/service-operation/handlers/utils.go @@ -0,0 +1,45 @@ + +package handlers + +import ( + "encoding/json" + + "service-operation/types" +) + +func getStatusString(success bool) string { + if success { + return "up" + } + return "down" +} + +func formatResultDetails(result *types.OperationResult) string { + details := map[string]interface{}{ + "type": result.Type, + "response_time": result.ResponseTime, + "start_time": result.StartTime, + "end_time": result.EndTime, + } + + // Add type-specific details + switch result.Type { + case types.OperationPing: + details["packets_sent"] = result.PacketsSent + details["packets_recv"] = result.PacketsRecv + details["packet_loss"] = result.PacketLoss + details["avg_rtt"] = result.AvgRTT + case types.OperationHTTP: + details["status_code"] = result.HTTPStatusCode + details["method"] = result.HTTPMethod + details["content_length"] = result.ContentLength + case types.OperationTCP: + details["tcp_connected"] = result.TCPConnected + case types.OperationDNS: + details["dns_records"] = result.DNSRecords + details["dns_type"] = result.DNSType + } + + jsonData, _ := json.Marshal(details) + return string(jsonData) +} \ No newline at end of file diff --git a/server/service-operation/main.go b/server/service-operation/main.go new file mode 100644 index 0000000..8ee0670 --- /dev/null +++ b/server/service-operation/main.go @@ -0,0 +1,91 @@ + +package main + +import ( + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/gorilla/mux" + "service-operation/config" + "service-operation/handlers" + "service-operation/monitoring" + "service-operation/pocketbase" +) + +func main() { + cfg := config.Load() + + // Initialize PocketBase client (no credentials required) + var pbClient *pocketbase.PocketBaseClient + var monitoringService *monitoring.MonitoringService + + if cfg.PocketBaseEnabled { + var err error + pbClient, err = pocketbase.NewPocketBaseClient(cfg.PocketBaseURL) + if err != nil { + log.Printf("Warning: Failed to initialize PocketBase client: %v", err) + } else { + if err := pbClient.TestConnection(); err != nil { + log.Printf("Warning: PocketBase connection test failed: %v", err) + } else { + // Initialize and start monitoring service + monitoringService = monitoring.NewMonitoringService(pbClient) + go monitoringService.Start() + log.Println("Monitoring service started (public access mode)") + } + } + } + + handler := handlers.NewOperationHandler(cfg, pbClient) + + router := mux.NewRouter() + + // Main operation endpoint + router.HandleFunc("/operation", handler.HandleOperation).Methods("POST") + + // Quick operation endpoint with query parameters + router.HandleFunc("/operation/quick", handler.HandleQuickOperation).Methods("GET") + + // Legacy ping endpoint for backward compatibility + router.HandleFunc("/ping", handler.HandleOperation).Methods("POST") + router.HandleFunc("/ping/quick", handler.HandleQuickOperation).Methods("GET") + + // Health check + router.HandleFunc("/health", handler.HandleHealth).Methods("GET") + + log.Printf("Service Operation starting on port %s", cfg.Port) + if pbClient != nil { + log.Printf("PocketBase integration enabled at %s (public access)", pbClient.GetBaseURL()) + } + if monitoringService != nil { + log.Printf("Automatic service monitoring enabled") + } + log.Printf("Endpoints:") + log.Printf(" POST /operation - Full operation test (ping, dns, tcp, http)") + log.Printf(" GET /operation/quick?type=&host= - Quick operation test") + log.Printf(" POST /ping - Legacy ping endpoint") + log.Printf(" GET /ping/quick?host= - Legacy quick ping test") + log.Printf(" GET /health - Health check") + log.Printf("Supported operations: ping, dns, tcp, http") + + // Setup graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("Shutting down monitoring service...") + if monitoringService != nil { + monitoringService.Stop() + } + log.Println("Service stopped") + os.Exit(0) + }() + + if err := http.ListenAndServe(":"+cfg.Port, router); err != nil { + log.Fatal("Failed to start server:", err) + } +} \ No newline at end of file diff --git a/server/service-operation/monitoring/checker.go b/server/service-operation/monitoring/checker.go new file mode 100644 index 0000000..41466b2 --- /dev/null +++ b/server/service-operation/monitoring/checker.go @@ -0,0 +1,122 @@ + +package monitoring + +import ( + "log" + "strings" + "time" + + "service-operation/operations" + "service-operation/pocketbase" + "service-operation/shared/savers" + "service-operation/types" +) + +func (ms *MonitoringService) performCheck(service pocketbase.Service) { + // First, fetch the latest service status from PocketBase to ensure we have current data + latestService, err := ms.pbClient.GetService(service.ID) + if err != nil { + log.Printf("Failed to fetch latest service status for %s: %v", service.Name, err) + return + } + + // Respect the service status - don't check paused services + if latestService.Status == "paused" { + return // Silently skip paused services + } + + timeout := 10 * time.Second // Default timeout + var result *types.OperationResult + + serviceType := strings.ToLower(latestService.ServiceType) + + // Single log message for check start + //log.Printf("Checking %s (%s)", latestService.Name, serviceType) + + switch serviceType { + case "ping", "icmp": + pingOp := operations.NewPingOperation(timeout) + host := latestService.Host + if host == "" { + host = latestService.URL + } + result, err = pingOp.Execute(host, 1) // Single ping for monitoring + + case "dns": + dnsOp := operations.NewDNSOperation(timeout) + host := latestService.Host + if host == "" { + host = latestService.Domain + } + // Default to A record, but could be made configurable + queryType := "A" + result, err = dnsOp.Execute(host, queryType) + + case "tcp": + tcpOp := operations.NewTCPOperation(timeout) + host := latestService.Host + if host == "" { + host = latestService.URL + } + port := latestService.Port + if port <= 0 { + port = 80 // Default port + } + result, err = tcpOp.Execute(host, port) + + case "http", "https": + httpOp := operations.NewHTTPOperation(timeout) + url := latestService.URL + if url == "" { + url = latestService.Host + } + result, err = httpOp.Execute(url, "GET") + + default: + log.Printf("Unknown service type: %s for service %s", latestService.ServiceType, latestService.Name) + return + } + + // Determine status based on result + status := "down" + errorMessage := "" + responseTime := int64(0) + + if err != nil { + errorMessage = err.Error() + log.Printf("❌ %s failed: %v", latestService.Name, err) + } else if result != nil { + responseTime = result.ResponseTime.Milliseconds() + if result.Success { + status = "up" + //log.Printf("✅ %s: %.0fms", latestService.Name, float64(responseTime)) + } else { + status = "down" + errorMessage = result.Error + log.Printf("❌ %s failed: %s", latestService.Name, errorMessage) + } + } + + // Only update service status if the service is not paused + // Check one more time before updating to prevent race conditions + currentService, err := ms.pbClient.GetService(latestService.ID) + if err != nil { + log.Printf("Failed to verify service status before update for %s: %v", latestService.Name, err) + return + } + + if currentService.Status == "paused" { + return // Silently skip status update for paused services + } + + // Update service status in PocketBase only if not paused + if err := ms.pbClient.UpdateServiceStatus(latestService.ID, status, responseTime, errorMessage); err != nil { + log.Printf("Failed to update service status for %s: %v", latestService.Name, err) + } + + // Save metrics data in ONE place to prevent duplicates + if result != nil { + metricsSaver := savers.NewMetricsSaver(ms.pbClient) + metricsSaver.SaveMetricsForService(*latestService, result) + } +} \ No newline at end of file diff --git a/server/service-operation/monitoring/metrics_saver.go b/server/service-operation/monitoring/metrics_saver.go new file mode 100644 index 0000000..1835e03 --- /dev/null +++ b/server/service-operation/monitoring/metrics_saver.go @@ -0,0 +1,33 @@ + +package monitoring + +import ( + "log" + "time" + + "service-operation/pocketbase" +) + +// Simplified metrics saver for basic service status updates only +func (ms *MonitoringService) saveMetrics(service pocketbase.Service, status string, responseTime int64, errorMessage string) { + // Save general metrics using the new structure + metrics := pocketbase.MetricsRecord{ + ServiceName: service.Name, + Host: service.Host, + Uptime: 0, // This would need to be calculated based on your requirements + ResponseTime: responseTime, + LastChecked: time.Now().Format(time.RFC3339), + Port: service.Port, + ServiceType: service.ServiceType, + Status: status, + ErrorMessage: errorMessage, + CheckedAt: time.Now().Format(time.RFC3339), + } + + if err := ms.pbClient.SaveMetrics(metrics); err != nil { + log.Printf("Failed to save metrics for %s: %v", service.Name, err) + } +} + +// This method is now removed to prevent duplicate saves +// The shared savers handle everything in SaveMetricsForService \ No newline at end of file diff --git a/server/service-operation/monitoring/monitor.go b/server/service-operation/monitoring/monitor.go new file mode 100644 index 0000000..ccd42d9 --- /dev/null +++ b/server/service-operation/monitoring/monitor.go @@ -0,0 +1,52 @@ + +package monitoring + +import ( + "log" + "time" + + "service-operation/pocketbase" +) + +type ServiceMonitor struct { + service pocketbase.Service + ticker *time.Ticker + stopChan chan bool +} + +func (ms *MonitoringService) startMonitor(service pocketbase.Service) { + if service.HeartbeatInterval <= 0 { + service.HeartbeatInterval = 60 // Default to 60 seconds + } + + monitor := &ServiceMonitor{ + service: service, + ticker: time.NewTicker(time.Duration(service.HeartbeatInterval) * time.Second), + stopChan: make(chan bool), + } + + ms.activeServices[service.ID] = monitor + + log.Printf("Starting monitor for service: %s (%s)", service.Name, service.ServiceType) + + go func() { + // Perform initial check + ms.performCheck(service) + + for { + select { + case <-monitor.ticker.C: + ms.performCheck(service) + case <-monitor.stopChan: + monitor.ticker.Stop() + return + } + } + }() +} + +func (ms *MonitoringService) stopMonitor(serviceID string, monitor *ServiceMonitor) { + log.Printf("Stopping monitor for service: %s", serviceID) + monitor.stopChan <- true + delete(ms.activeServices, serviceID) +} \ No newline at end of file diff --git a/server/service-operation/monitoring/service.go b/server/service-operation/monitoring/service.go new file mode 100644 index 0000000..802cdc0 --- /dev/null +++ b/server/service-operation/monitoring/service.go @@ -0,0 +1,112 @@ + +package monitoring + +import ( + "log" + "sync" + "time" + + "service-operation/pocketbase" +) + +type MonitoringService struct { + pbClient *pocketbase.PocketBaseClient + activeServices map[string]*ServiceMonitor + mu sync.RWMutex + stopChan chan bool + isRunning bool +} + +func NewMonitoringService(pbClient *pocketbase.PocketBaseClient) *MonitoringService { + return &MonitoringService{ + pbClient: pbClient, + activeServices: make(map[string]*ServiceMonitor), + stopChan: make(chan bool), + isRunning: false, + } +} + +func (ms *MonitoringService) Start() { + ms.mu.Lock() + defer ms.mu.Unlock() + + if ms.isRunning { + log.Println("Monitoring service is already running") + return + } + + ms.isRunning = true + log.Println("Starting monitoring service...") + + // Start monitoring all services from PocketBase + go ms.monitoringLoop() +} + +func (ms *MonitoringService) Stop() { + ms.mu.Lock() + defer ms.mu.Unlock() + + if !ms.isRunning { + return + } + + log.Println("Stopping monitoring service...") + ms.isRunning = false + + // Stop all active monitors + for serviceID, monitor := range ms.activeServices { + ms.stopMonitor(serviceID, monitor) + } + + ms.stopChan <- true +} + +func (ms *MonitoringService) monitoringLoop() { + ticker := time.NewTicker(30 * time.Second) // Check for new services every 30 seconds + defer ticker.Stop() + + // Initial load of services + ms.loadAndStartServices() + + for { + select { + case <-ticker.C: + ms.loadAndStartServices() + case <-ms.stopChan: + return + } + } +} + +func (ms *MonitoringService) loadAndStartServices() { + // Only get services that are NOT paused + services, err := ms.pbClient.GetActiveServices() + if err != nil { + log.Printf("Failed to load services: %v", err) + return + } + + ms.mu.Lock() + defer ms.mu.Unlock() + + // Filter out paused services and start monitoring for active ones + activeServiceIDs := make(map[string]bool) + for _, service := range services { + if service.Status != "paused" { + activeServiceIDs[service.ID] = true + + // Start monitoring if not already active + if _, exists := ms.activeServices[service.ID]; !exists { + ms.startMonitor(service) + } + } + } + + // Stop monitoring for paused or removed services + for serviceID, monitor := range ms.activeServices { + if !activeServiceIDs[serviceID] { + log.Printf("Stopping monitoring for service %s (paused or removed)", serviceID) + ms.stopMonitor(serviceID, monitor) + } + } +} \ No newline at end of file diff --git a/server/service-operation/monitoring/utils.go b/server/service-operation/monitoring/utils.go new file mode 100644 index 0000000..efb79be --- /dev/null +++ b/server/service-operation/monitoring/utils.go @@ -0,0 +1,10 @@ + +package monitoring + +// This file previously contained saveDetailedDataByType which was causing duplicate saves +// All detailed data saving is now handled by the shared savers in a single place +// to prevent duplicate records in PocketBase + +// The monitoring service now uses only: +// 1. SaveMetricsForService from shared/savers for complete data saving +// 2. Direct service status updates for the services table \ No newline at end of file diff --git a/server/service-operation/operations/dns.go b/server/service-operation/operations/dns.go new file mode 100644 index 0000000..e78862e --- /dev/null +++ b/server/service-operation/operations/dns.go @@ -0,0 +1,254 @@ +package operations + +import ( + "fmt" + "net" + "strings" + "time" + + "service-operation/types" +) + +type DNSOperation struct { + timeout time.Duration +} + +func NewDNSOperation(timeout time.Duration) *DNSOperation { + return &DNSOperation{timeout: timeout} +} + +func (d *DNSOperation) Execute(host, query string) (*types.OperationResult, error) { + // Validate inputs + if host == "" { + return nil, fmt.Errorf("host cannot be empty") + } + + if query == "" { + query = "A" // Default to A record + } + + result := &types.OperationResult{ + Type: types.OperationDNS, + Host: host, + DNSType: query, + StartTime: time.Now(), + } + + start := time.Now() + + // Resolve the host first to get detailed info + var resolvedIPs []string + var err error + + switch strings.ToUpper(query) { + case "A": + resolvedIPs, err = d.performARecordLookup(host) + + case "AAAA": + resolvedIPs, err = d.performAAAARecordLookup(host) + + case "MX": + resolvedIPs, err = d.performMXRecordLookup(host) + + case "TXT": + resolvedIPs, err = d.performTXTRecordLookup(host) + + case "CNAME": + resolvedIPs, err = d.performCNAMERecordLookup(host) + + case "NS": + resolvedIPs, err = d.performNSRecordLookup(host) + + default: + // Default to A record lookup for unknown types + resolvedIPs, err = d.performARecordLookup(host) + } + + result.ResponseTime = time.Since(start) + result.EndTime = time.Now() + + if err != nil { + result.Error = err.Error() + result.Success = false + result.Details = d.createDetailedErrorMessage(err.Error(), host, query) + } else { + result.DNSRecords = resolvedIPs + result.Success = len(resolvedIPs) > 0 + + if result.Success { + result.Details = d.createDetailedSuccessMessage(result, host, query, resolvedIPs) + } else { + result.Error = "No DNS records found" + result.Details = d.createDetailedErrorMessage("no records found", host, query) + } + } + + return result, nil +} + +func (d *DNSOperation) performARecordLookup(host string) ([]string, error) { + ips, err := net.LookupIP(host) + if err != nil { + return nil, err + } + + var ipv4Records []string + for _, ip := range ips { + if ipv4 := ip.To4(); ipv4 != nil { + ipv4Records = append(ipv4Records, ipv4.String()) + } + } + + return ipv4Records, nil +} + +func (d *DNSOperation) performAAAARecordLookup(host string) ([]string, error) { + ips, err := net.LookupIP(host) + if err != nil { + return nil, err + } + + var ipv6Records []string + for _, ip := range ips { + if ipv6 := ip.To16(); ipv6 != nil && ip.To4() == nil { + ipv6Records = append(ipv6Records, ipv6.String()) + } + } + + return ipv6Records, nil +} + +func (d *DNSOperation) performMXRecordLookup(host string) ([]string, error) { + mxRecords, err := net.LookupMX(host) + if err != nil { + return nil, err + } + + var records []string + for _, mx := range mxRecords { + records = append(records, fmt.Sprintf("%s (priority: %d)", mx.Host, mx.Pref)) + } + + return records, nil +} + +func (d *DNSOperation) performTXTRecordLookup(host string) ([]string, error) { + txtRecords, err := net.LookupTXT(host) + if err != nil { + return nil, err + } + + return txtRecords, nil +} + +func (d *DNSOperation) performCNAMERecordLookup(host string) ([]string, error) { + cname, err := net.LookupCNAME(host) + if err != nil { + return nil, err + } + + return []string{cname}, nil +} + +func (d *DNSOperation) performNSRecordLookup(host string) ([]string, error) { + nsRecords, err := net.LookupNS(host) + if err != nil { + return nil, err + } + + var records []string + for _, ns := range nsRecords { + records = append(records, ns.Host) + } + + return records, nil +} + +func (d *DNSOperation) createDetailedSuccessMessage(result *types.OperationResult, host, queryType string, records []string) string { + var details strings.Builder + + // Success indicator with basic info + details.WriteString(fmt.Sprintf("đŸŸĸ DNS SUCCESS - %s query for %s", + strings.ToUpper(queryType), host)) + + // Response time + details.WriteString(fmt.Sprintf(" | Response time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000)) + + // Record count + details.WriteString(fmt.Sprintf(" | Records found: %d", len(records))) + + // Show first few records for context + if len(records) > 0 { + details.WriteString(" | ") + if len(records) <= 3 { + details.WriteString(fmt.Sprintf("Results: %s", strings.Join(records, ", "))) + } else { + details.WriteString(fmt.Sprintf("Results: %s... (+%d more)", + strings.Join(records[:3], ", "), len(records)-3)) + } + } + + return details.String() +} + +func (d *DNSOperation) createDetailedErrorMessage(errorMsg, host, queryType string) string { + var details strings.Builder + + errorLower := strings.ToLower(errorMsg) + + if strings.Contains(errorLower, "timeout") { + details.WriteString("âąī¸ DNS TIMEOUT - Query timed out") + } else if strings.Contains(errorLower, "no such host") || strings.Contains(errorLower, "host not found") { + details.WriteString("🔍 HOST NOT FOUND - DNS resolution failed") + } else if strings.Contains(errorLower, "no answer") || strings.Contains(errorLower, "no records found") { + details.WriteString("📝 NO RECORDS - No DNS records of requested type") + } else if strings.Contains(errorLower, "server failure") { + details.WriteString("🔧 SERVER FAILURE - DNS server error") + } else if strings.Contains(errorLower, "refused") { + details.WriteString("đŸšĢ QUERY REFUSED - DNS server refused query") + } else { + details.WriteString("❌ DNS FAILED - Query error") + } + + // Add query details + details.WriteString(fmt.Sprintf(" | Query: %s %s", + strings.ToUpper(queryType), host)) + + // Add specific error details + if errorMsg != "" { + details.WriteString(fmt.Sprintf(" | Error: %s", d.getShortErrorMessage(errorMsg))) + } + + return details.String() +} + +func (d *DNSOperation) getShortErrorMessage(errorMessage string) string { + if errorMessage == "" { + return "Unknown error" + } + + errorLower := strings.ToLower(errorMessage) + + if strings.Contains(errorLower, "timeout") { + return "Query timeout" + } else if strings.Contains(errorLower, "no such host") { + return "Host not found" + } else if strings.Contains(errorLower, "no answer") { + return "No records found" + } else if strings.Contains(errorLower, "server failure") { + return "DNS server failure" + } else if strings.Contains(errorLower, "refused") { + return "Query refused" + } else if strings.Contains(errorLower, "network unreachable") { + return "Network unreachable" + } + + // For other errors, take first 50 characters and clean it up + shortMsg := errorMessage + if len(shortMsg) > 50 { + shortMsg = shortMsg[:50] + "..." + } + + return shortMsg +} \ No newline at end of file diff --git a/server/service-operation/operations/http.go b/server/service-operation/operations/http.go new file mode 100644 index 0000000..887350c --- /dev/null +++ b/server/service-operation/operations/http.go @@ -0,0 +1,136 @@ + +package operations + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "service-operation/types" +) + +type HTTPOperation struct { + timeout time.Duration + client *http.Client +} + +func NewHTTPOperation(timeout time.Duration) *HTTPOperation { + return &HTTPOperation{ + timeout: timeout, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +func (h *HTTPOperation) Execute(url, method string) (*types.OperationResult, error) { + result := &types.OperationResult{ + Type: types.OperationHTTP, + StartTime: time.Now(), + HTTPMethod: method, + } + + // Default to GET if no method specified + if method == "" { + method = "GET" + result.HTTPMethod = "GET" + } + + // Ensure URL has protocol + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + url = "https://" + url + } + + start := time.Now() + + req, err := http.NewRequest(method, url, nil) + if err != nil { + result.Error = fmt.Sprintf("Failed to create request: %v", err) + result.Success = false + result.EndTime = time.Now() + return result, nil + } + + // Set a user agent + req.Header.Set("User-Agent", "ServiceOperation/1.0") + + resp, err := h.client.Do(req) + + result.ResponseTime = time.Since(start) + result.EndTime = time.Now() + + if err != nil { + // More detailed error messages + if strings.Contains(err.Error(), "timeout") { + result.Error = fmt.Sprintf("🕐 Request timeout after %.2fs - Server did not respond within the expected time", h.timeout.Seconds()) + } else if strings.Contains(err.Error(), "connection refused") { + result.Error = "đŸšĢ Connection refused - Server is not accepting connections on this port" + } else if strings.Contains(err.Error(), "no such host") { + result.Error = "🌐 DNS resolution failed - Host not found" + } else if strings.Contains(err.Error(), "certificate") { + result.Error = "🔒 SSL/TLS certificate error - Certificate verification failed" + } else { + result.Error = fmt.Sprintf("🔌 Connection error: %v", err) + } + result.Success = false + return result, nil + } + defer resp.Body.Close() + + result.HTTPStatusCode = resp.StatusCode + result.ContentLength = resp.ContentLength + result.Success = resp.StatusCode >= 200 && resp.StatusCode < 400 + + // Capture important headers + result.HTTPHeaders = make(map[string]string) + for key, values := range resp.Header { + if len(values) > 0 { + switch strings.ToLower(key) { + case "content-type", "server", "cache-control", "content-encoding", "x-powered-by": + result.HTTPHeaders[key] = values[0] + } + } + } + + // Read response body for keyword checking and additional details + body, err := io.ReadAll(resp.Body) + if err == nil && len(body) > 0 { + result.ResponseBody = string(body) + // Update content length if not set by server + if result.ContentLength <= 0 { + result.ContentLength = int64(len(body)) + } + } + + // Create detailed status message with emoji + if !result.Success { + switch { + case resp.StatusCode >= 500: + result.Error = fmt.Sprintf("đŸ”Ĩ Server Error (HTTP %d): %s - The server encountered an internal error", resp.StatusCode, resp.Status) + case resp.StatusCode >= 400: + result.Error = fmt.Sprintf("❌ Client Error (HTTP %d): %s - The request was invalid or unauthorized", resp.StatusCode, resp.Status) + case resp.StatusCode >= 300: + result.Error = fmt.Sprintf("â†Šī¸ Redirect (HTTP %d): %s - Resource has moved", resp.StatusCode, resp.Status) + default: + result.Error = fmt.Sprintf("âš ī¸ Unexpected Status (HTTP %d): %s", resp.StatusCode, resp.Status) + } + } else { + // Success message with emoji + switch resp.StatusCode { + case 200: + result.Error = "✅ OK - Request successful" + case 201: + result.Error = "🆕 Created - Resource created successfully" + case 202: + result.Error = "âŗ Accepted - Request accepted for processing" + case 204: + result.Error = "📭 No Content - Request successful, no content returned" + default: + result.Error = fmt.Sprintf("✅ Success (HTTP %d): %s", resp.StatusCode, resp.Status) + } + } + + return result, nil +} \ No newline at end of file diff --git a/server/service-operation/operations/ping.go b/server/service-operation/operations/ping.go new file mode 100644 index 0000000..9b79923 --- /dev/null +++ b/server/service-operation/operations/ping.go @@ -0,0 +1,460 @@ +package operations + +import ( + "fmt" + "net" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "service-operation/types" +) + +type PingOperation struct { + timeout time.Duration +} + +func NewPingOperation(timeout time.Duration) *PingOperation { + return &PingOperation{timeout: timeout} +} + +func (p *PingOperation) Execute(host string, count int) (*types.OperationResult, error) { + // Validate host/IP + if host == "" { + return nil, fmt.Errorf("host cannot be empty") + } + + // Always try system ping first for better reliability + result, err := p.executeSystemPing(host, count) + if err == nil && result.PacketsRecv > 0 { + return result, nil + } + + // If system ping fails, try raw ICMP as fallback + fmt.Printf("System ping failed (%v), trying raw ICMP\n", err) + rawResult, rawErr := p.executeRawICMP(host, count) + if rawErr == nil && rawResult.PacketsRecv > 0 { + return rawResult, nil + } + + // If both fail, return the system ping result with error info + if result != nil { + return result, nil + } + + return nil, fmt.Errorf("both system ping and raw ICMP failed: system_err=%v, raw_err=%v", err, rawErr) +} + +func (p *PingOperation) executeSystemPing(host string, count int) (*types.OperationResult, error) { + result := &types.OperationResult{ + Type: types.OperationPing, + Host: host, + PacketsSent: count, + StartTime: time.Now(), + } + + // Resolve host to get IP address for better details + ips, err := net.LookupIP(host) + var resolvedIP string + if err == nil && len(ips) > 0 { + resolvedIP = ips[0].String() + } + + // Build ping command based on OS + var cmd *exec.Cmd + timeoutSeconds := int(p.timeout.Seconds()) + if timeoutSeconds < 1 { + timeoutSeconds = 10 // Minimum 10 seconds timeout + } + + switch runtime.GOOS { + case "linux": + // Linux ping: -c count -W timeout_in_seconds + cmd = exec.Command("ping", "-c", fmt.Sprintf("%d", count), "-W", fmt.Sprintf("%d", timeoutSeconds), host) + case "darwin": + // macOS ping: -c count -W timeout_in_milliseconds + cmd = exec.Command("ping", "-c", fmt.Sprintf("%d", count), "-W", fmt.Sprintf("%d", timeoutSeconds*1000), host) + case "windows": + // Windows ping: -n count -w timeout_in_milliseconds + cmd = exec.Command("ping", "-n", fmt.Sprintf("%d", count), "-w", fmt.Sprintf("%d", timeoutSeconds*1000), host) + default: + // Default to Linux-style + cmd = exec.Command("ping", "-c", fmt.Sprintf("%d", count), "-W", fmt.Sprintf("%d", timeoutSeconds), host) + } + + // Set command timeout slightly longer than ping timeout + cmdTimeout := time.Duration(timeoutSeconds+5) * time.Second + done := make(chan error, 1) + var output []byte + var cmdErr error + + go func() { + output, cmdErr = cmd.Output() + done <- cmdErr + }() + + select { + case err := <-done: + result.EndTime = time.Now() + + if err != nil { + // Even if command fails, try to parse partial output + result.PacketLoss = 100.0 + if len(output) > 0 { + p.parseSystemPingOutput(result, string(output), count) + } + // Set detailed error information + result.Error = p.createDetailedErrorMessage(err.Error(), host, resolvedIP) + return result, nil // Don't return error, return result with failure info + } + + // Parse successful output + if len(output) > 0 { + p.parseSystemPingOutput(result, string(output), count) + } + + // Create detailed success message + if result.Success { + result.Details = p.createDetailedSuccessMessage(result, host, resolvedIP) + } + + return result, nil + + case <-time.After(cmdTimeout): + // Command timed out + if cmd.Process != nil { + cmd.Process.Kill() + } + result.EndTime = time.Now() + result.PacketLoss = 100.0 + result.Error = fmt.Sprintf("Ping request timed out after %v", cmdTimeout) + result.Details = p.createDetailedErrorMessage("timeout", host, resolvedIP) + return result, fmt.Errorf("ping command timed out after %v", cmdTimeout) + } +} + +func (p *PingOperation) createDetailedSuccessMessage(result *types.OperationResult, host, resolvedIP string) string { + var details strings.Builder + + // Success indicator with basic info + details.WriteString(fmt.Sprintf("đŸŸĸ PING SUCCESS - %d/%d packets received", + result.PacketsRecv, result.PacketsSent)) + + // Host and IP information + if resolvedIP != "" && resolvedIP != host { + details.WriteString(fmt.Sprintf(" | Host: %s (%s)", host, resolvedIP)) + } else { + details.WriteString(fmt.Sprintf(" | Host: %s", host)) + } + + // Timing information + if result.AvgRTT > 0 { + details.WriteString(fmt.Sprintf(" | Avg RTT: %.2fms", + float64(result.AvgRTT.Nanoseconds())/1000000)) + } + + if result.MinRTT > 0 && result.MaxRTT > 0 { + details.WriteString(fmt.Sprintf(" | Range: %.2f-%.2fms", + float64(result.MinRTT.Nanoseconds())/1000000, + float64(result.MaxRTT.Nanoseconds())/1000000)) + } + + // Packet loss information + if result.PacketLoss > 0 { + details.WriteString(fmt.Sprintf(" | Packet Loss: %.1f%%", result.PacketLoss)) + } else { + details.WriteString(" | No packet loss") + } + + return details.String() +} + +func (p *PingOperation) createDetailedErrorMessage(errorMsg, host, resolvedIP string) string { + var details strings.Builder + + errorLower := strings.ToLower(errorMsg) + + if strings.Contains(errorLower, "timeout") { + details.WriteString("âąī¸ PING TIMEOUT - Host did not respond within timeout period") + } else if strings.Contains(errorLower, "unreachable") { + details.WriteString("đŸšĢ HOST UNREACHABLE - Network path to host is blocked") + } else if strings.Contains(errorLower, "no route") { + details.WriteString("đŸ›¤ī¸ NO ROUTE - No network route to destination") + } else if strings.Contains(errorLower, "name resolution") || strings.Contains(errorLower, "unknown host") { + details.WriteString("🔍 DNS RESOLUTION FAILED - Unable to resolve hostname") + } else if strings.Contains(errorLower, "permission denied") { + details.WriteString("🔐 PERMISSION DENIED - Insufficient privileges for ICMP") + } else { + details.WriteString("❌ PING FAILED - Connection error") + } + + // Add host information + if resolvedIP != "" && resolvedIP != host { + details.WriteString(fmt.Sprintf(" | Target: %s (%s)", host, resolvedIP)) + } else { + details.WriteString(fmt.Sprintf(" | Target: %s", host)) + } + + // Add specific error details + if errorMsg != "" { + details.WriteString(fmt.Sprintf(" | Error: %s", p.getShortErrorMessage(errorMsg))) + } + + return details.String() +} + +func (p *PingOperation) getShortErrorMessage(errorMessage string) string { + if errorMessage == "" { + return "Unknown error" + } + + errorLower := strings.ToLower(errorMessage) + + if strings.Contains(errorLower, "timeout") { + return "Request timeout" + } else if strings.Contains(errorLower, "unreachable") { + return "Host unreachable" + } else if strings.Contains(errorLower, "no route") { + return "No route to host" + } else if strings.Contains(errorLower, "name resolution") || strings.Contains(errorLower, "unknown host") { + return "DNS resolution failed" + } else if strings.Contains(errorLower, "permission denied") { + return "Permission denied" + } else if strings.Contains(errorLower, "network is down") { + return "Network is down" + } + + // For other errors, take first 50 characters and clean it up + shortMsg := errorMessage + if len(shortMsg) > 50 { + shortMsg = shortMsg[:50] + "..." + } + + return shortMsg +} + +func (p *PingOperation) parseSystemPingOutput(result *types.OperationResult, output string, expectedCount int) { + // Extract packet loss + lossRegex := regexp.MustCompile(`(\d+(?:\.\d+)?)%.*packet.*loss`) + if matches := lossRegex.FindStringSubmatch(output); len(matches) > 1 { + if loss, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.PacketLoss = loss + } + } + + // Extract timing information (Linux/macOS format) + timingRegex := regexp.MustCompile(`rtt min/avg/max/(?:mdev|stddev) = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+) ms`) + if matches := timingRegex.FindStringSubmatch(output); len(matches) > 4 { + if min, err := strconv.ParseFloat(matches[1], 64); err == nil { + result.MinRTT = time.Duration(min * float64(time.Millisecond)) + } + if avg, err := strconv.ParseFloat(matches[2], 64); err == nil { + result.AvgRTT = time.Duration(avg * float64(time.Millisecond)) + result.ResponseTime = result.AvgRTT + } + if max, err := strconv.ParseFloat(matches[3], 64); err == nil { + result.MaxRTT = time.Duration(max * float64(time.Millisecond)) + } + } + + // Extract individual ping times and count successful pings + timeRegex := regexp.MustCompile(`time[<=]([\d.]+) ?ms`) + timeMatches := timeRegex.FindAllStringSubmatch(output, -1) + result.PacketsRecv = len(timeMatches) + + for _, match := range timeMatches { + if len(match) > 1 { + if t, err := strconv.ParseFloat(match[1], 64); err == nil { + rtt := time.Duration(t * float64(time.Millisecond)) + result.RTTs = append(result.RTTs, rtt) + + // Update min/max if not set by summary stats + if result.MinRTT == 0 || rtt < result.MinRTT { + result.MinRTT = rtt + } + if result.MaxRTT == 0 || rtt > result.MaxRTT { + result.MaxRTT = rtt + } + } + } + } + + // Calculate average RTT if not set by summary stats + if result.AvgRTT == 0 && len(result.RTTs) > 0 { + var total time.Duration + for _, rtt := range result.RTTs { + total += rtt + } + result.AvgRTT = total / time.Duration(len(result.RTTs)) + result.ResponseTime = result.AvgRTT + } + + // Recalculate packet loss if we have individual pings + if expectedCount > 0 { + result.PacketLoss = float64(expectedCount-result.PacketsRecv) / float64(expectedCount) * 100 + } + + // Mark as successful if we got any responses + if result.PacketsRecv > 0 { + result.Success = true + } + + // Handle Windows output format if Linux/macOS parsing didn't work + if result.PacketsRecv == 0 && strings.Contains(strings.ToLower(output), "reply from") { + p.parseWindowsPingOutput(result, output, expectedCount) + } +} + +func (p *PingOperation) parseWindowsPingOutput(result *types.OperationResult, output string, expectedCount int) { + // Windows: "Reply from 1.1.1.1: bytes=32 time=14ms TTL=56" + timeRegex := regexp.MustCompile(`time[<=]([\d.]+)ms`) + timeMatches := timeRegex.FindAllStringSubmatch(output, -1) + result.PacketsRecv = len(timeMatches) + + for _, match := range timeMatches { + if len(match) > 1 { + if t, err := strconv.ParseFloat(match[1], 64); err == nil { + rtt := time.Duration(t * float64(time.Millisecond)) + result.RTTs = append(result.RTTs, rtt) + + if result.MinRTT == 0 || rtt < result.MinRTT { + result.MinRTT = rtt + } + if result.MaxRTT == 0 || rtt > result.MaxRTT { + result.MaxRTT = rtt + } + } + } + } + + if len(result.RTTs) > 0 { + var total time.Duration + for _, rtt := range result.RTTs { + total += rtt + } + result.AvgRTT = total / time.Duration(len(result.RTTs)) + result.ResponseTime = result.AvgRTT + result.Success = true + } + + if expectedCount > 0 { + result.PacketLoss = float64(expectedCount-result.PacketsRecv) / float64(expectedCount) * 100 + } +} + +func (p *PingOperation) executeRawICMP(host string, count int) (*types.OperationResult, error) { + dst, err := net.ResolveIPAddr("ip4", host) + if err != nil { + return nil, fmt.Errorf("failed to resolve host %s: %v", host, err) + } + + // Try to create ICMP connection with better error handling + conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") + if err != nil { + return nil, fmt.Errorf("failed to create ICMP connection (requires root/admin privileges): %v", err) + } + defer conn.Close() + + result := &types.OperationResult{ + Type: types.OperationPing, + Host: host, + PacketsSent: count, + StartTime: time.Now(), + } + + var totalRTT time.Duration + var minRTT, maxRTT time.Duration + pid := os.Getpid() & 0xffff + + for i := 0; i < count; i++ { + message := &icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ + ID: pid, + Seq: i + 1, + Data: []byte(fmt.Sprintf("Hello, World! %d", i)), + }, + } + + data, err := message.Marshal(nil) + if err != nil { + continue + } + + start := time.Now() + _, err = conn.WriteTo(data, dst) + if err != nil { + continue + } + + // Set read deadline + err = conn.SetReadDeadline(time.Now().Add(p.timeout)) + if err != nil { + continue + } + + reply := make([]byte, 1500) + _, peer, err := conn.ReadFrom(reply) + if err != nil { + continue + } + + rtt := time.Since(start) + + // Parse the reply with better error handling - fix the type conversion + rm, err := icmp.ParseMessage(int(ipv4.ICMPTypeEchoReply), reply) + if err != nil { + continue + } + + switch rm.Type { + case ipv4.ICMPTypeEchoReply: + if peer.String() == dst.String() { + echoReply, ok := rm.Body.(*icmp.Echo) + if ok && echoReply.ID == pid { + result.PacketsRecv++ + totalRTT += rtt + + if result.PacketsRecv == 1 || rtt < minRTT { + minRTT = rtt + } + if result.PacketsRecv == 1 || rtt > maxRTT { + maxRTT = rtt + } + + result.RTTs = append(result.RTTs, rtt) + } + } + } + + // Sleep between pings except for the last one + if i < count-1 { + time.Sleep(1 * time.Second) + } + } + + result.EndTime = time.Now() + result.PacketLoss = float64(count-result.PacketsRecv) / float64(count) * 100 + + if result.PacketsRecv > 0 { + result.MinRTT = minRTT + result.MaxRTT = maxRTT + result.AvgRTT = totalRTT / time.Duration(result.PacketsRecv) + result.ResponseTime = result.AvgRTT + result.Success = true + // Create detailed success message for raw ICMP + result.Details = p.createDetailedSuccessMessage(result, host, dst.String()) + } else { + result.Error = "No ICMP echo replies received" + result.Details = p.createDetailedErrorMessage("no replies", host, dst.String()) + } + + return result, nil +} \ No newline at end of file diff --git a/server/service-operation/operations/tcp.go b/server/service-operation/operations/tcp.go new file mode 100644 index 0000000..1d0f4af --- /dev/null +++ b/server/service-operation/operations/tcp.go @@ -0,0 +1,49 @@ + +package operations + +import ( + "fmt" + "net" + "time" + + "service-operation/types" +) + +type TCPOperation struct { + timeout time.Duration +} + +func NewTCPOperation(timeout time.Duration) *TCPOperation { + return &TCPOperation{timeout: timeout} +} + +func (t *TCPOperation) Execute(host string, port int) (*types.OperationResult, error) { + result := &types.OperationResult{ + Type: types.OperationTCP, + Host: host, + Port: port, + StartTime: time.Now(), + } + + start := time.Now() + + address := fmt.Sprintf("%s:%d", host, port) + conn, err := net.DialTimeout("tcp", address, t.timeout) + + result.ResponseTime = time.Since(start) + result.EndTime = time.Now() + + if err != nil { + result.Error = err.Error() + result.TCPConnected = false + result.Success = false + result.Details = fmt.Sprintf("Failed to connect to %s:%d - %s", host, port, err.Error()) + } else { + conn.Close() + result.TCPConnected = true + result.Success = true + result.Details = fmt.Sprintf("Successfully connected to %s:%d", host, port) + } + + return result, nil +} \ No newline at end of file diff --git a/server/service-operation/ping/icmp.go b/server/service-operation/ping/icmp.go new file mode 100644 index 0000000..3e6354d --- /dev/null +++ b/server/service-operation/ping/icmp.go @@ -0,0 +1,117 @@ + +package ping + +import ( + "fmt" + "net" + "os" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" +) + +type ICMPPinger struct { + timeout time.Duration +} + +func NewICMPPinger(timeout time.Duration) *ICMPPinger { + return &ICMPPinger{timeout: timeout} +} + +func (p *ICMPPinger) Ping(host string, count int) (*PingResult, error) { + dst, err := net.ResolveIPAddr("ip4", host) + if err != nil { + return nil, fmt.Errorf("failed to resolve host %s: %v", host, err) + } + + conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0") + if err != nil { + return nil, fmt.Errorf("failed to create ICMP connection: %v", err) + } + defer conn.Close() + + result := &PingResult{ + Host: host, + PacketsSent: count, + StartTime: time.Now(), + } + + var totalRTT time.Duration + var minRTT, maxRTT time.Duration + + for i := 0; i < count; i++ { + message := &icmp.Message{ + Type: ipv4.ICMPTypeEcho, + Code: 0, + Body: &icmp.Echo{ + ID: os.Getpid() & 0xffff, + Seq: i + 1, + Data: []byte("Hello, World!"), + }, + } + + data, err := message.Marshal(nil) + if err != nil { + continue + } + + start := time.Now() + _, err = conn.WriteTo(data, dst) + if err != nil { + continue + } + + err = conn.SetReadDeadline(time.Now().Add(p.timeout)) + if err != nil { + continue + } + + reply := make([]byte, 1500) + _, peer, err := conn.ReadFrom(reply) + if err != nil { + continue + } + + rtt := time.Since(start) + + // Parse the reply + rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply, reply) + if err != nil { + continue + } + + switch rm.Type { + case ipv4.ICMPTypeEchoReply: + if peer.String() == dst.String() { + result.PacketsRecv++ + totalRTT += rtt + + if result.PacketsRecv == 1 || rtt < minRTT { + minRTT = rtt + } + if result.PacketsRecv == 1 || rtt > maxRTT { + maxRTT = rtt + } + + result.RTTs = append(result.RTTs, rtt) + } + } + + if i < count-1 { + time.Sleep(1 * time.Second) + } + } + + result.EndTime = time.Now() + result.PacketLoss = float64(count-result.PacketsRecv) / float64(count) * 100 + + if result.PacketsRecv > 0 { + result.MinRTT = minRTT + result.MaxRTT = maxRTT + result.AvgRTT = totalRTT / time.Duration(result.PacketsRecv) + result.Success = true + } + + return result, nil +} diff --git a/server/service-operation/ping/types.go b/server/service-operation/ping/types.go new file mode 100644 index 0000000..e89be4e --- /dev/null +++ b/server/service-operation/ping/types.go @@ -0,0 +1,25 @@ + +package ping + +import "time" + +type PingResult struct { + Host string `json:"host"` + Success bool `json:"success"` + PacketsSent int `json:"packets_sent"` + PacketsRecv int `json:"packets_recv"` + PacketLoss float64 `json:"packet_loss"` + MinRTT time.Duration `json:"min_rtt"` + MaxRTT time.Duration `json:"max_rtt"` + AvgRTT time.Duration `json:"avg_rtt"` + RTTs []time.Duration `json:"rtts"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Error string `json:"error,omitempty"` +} + +type PingRequest struct { + Host string `json:"host"` + Count int `json:"count,omitempty"` + Timeout int `json:"timeout,omitempty"` +} diff --git a/server/service-operation/pocketbase/client.go b/server/service-operation/pocketbase/client.go new file mode 100644 index 0000000..25bd76a --- /dev/null +++ b/server/service-operation/pocketbase/client.go @@ -0,0 +1,52 @@ + +package pocketbase + +import ( + "fmt" + "net/http" + "time" +) + +type PocketBaseClient struct { + baseURL string + httpClient *http.Client +} + +func NewPocketBaseClient(baseURL string) (*PocketBaseClient, error) { + // Use provided baseURL or default to localhost + if baseURL == "" { + baseURL = "http://localhost:8090" + } + + client := &PocketBaseClient{ + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } + + return client, nil +} + +func (c *PocketBaseClient) GetBaseURL() string { + return c.baseURL +} + +func (c *PocketBaseClient) TestConnection() error { + resp, err := c.httpClient.Get(fmt.Sprintf("%s/api/health", c.baseURL)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("PocketBase health check failed with status: %d", resp.StatusCode) + } + + return nil +} + +func (c *PocketBaseClient) IsAuthenticated() bool { + // Since we're using public access mode, always return true + return true +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/metrics.go b/server/service-operation/pocketbase/metrics.go new file mode 100644 index 0000000..244274d --- /dev/null +++ b/server/service-operation/pocketbase/metrics.go @@ -0,0 +1,22 @@ + +package pocketbase + +func (c *PocketBaseClient) SaveMetrics(metrics MetricsRecord) error { + return c.createRecord("services_metrics", metrics) +} + +func (c *PocketBaseClient) SavePingData(pingData PingDataRecord) error { + return c.createRecord("ping_data", pingData) +} + +func (c *PocketBaseClient) SaveUptimeData(uptimeData UptimeDataRecord) error { + return c.createRecord("uptime_data", uptimeData) +} + +func (c *PocketBaseClient) SaveDNSData(dnsData DNSDataRecord) error { + return c.createRecord("dns_data", dnsData) +} + +func (c *PocketBaseClient) SaveTCPData(tcpData TCPDataRecord) error { + return c.createRecord("tcp_data", tcpData) +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/services.go b/server/service-operation/pocketbase/services.go new file mode 100644 index 0000000..249f7a1 --- /dev/null +++ b/server/service-operation/pocketbase/services.go @@ -0,0 +1,138 @@ + +package pocketbase + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +func (c *PocketBaseClient) GetServices() ([]Service, error) { + req, err := http.NewRequest("GET", + fmt.Sprintf("%s/api/collections/services/records", c.baseURL), nil) + if err != nil { + return nil, err + } + + // No authentication header needed for public access + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch services, status: %d", resp.StatusCode) + } + + var servicesResponse ServicesResponse + if err := json.NewDecoder(resp.Body).Decode(&servicesResponse); err != nil { + return nil, err + } + + return servicesResponse.Items, nil +} + +func (c *PocketBaseClient) GetService(serviceID string) (*Service, error) { + req, err := http.NewRequest("GET", + fmt.Sprintf("%s/api/collections/services/records/%s", c.baseURL, serviceID), nil) + if err != nil { + return nil, err + } + + // No authentication header needed for public access + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch service %s, status: %d", serviceID, resp.StatusCode) + } + + var service Service + if err := json.NewDecoder(resp.Body).Decode(&service); err != nil { + return nil, err + } + + return &service, nil +} + +func (c *PocketBaseClient) GetActiveServices() ([]Service, error) { + // Only fetch services that are not paused + req, err := http.NewRequest("GET", + fmt.Sprintf("%s/api/collections/services/records?filter=(status!='paused')", c.baseURL), nil) + if err != nil { + return nil, err + } + + // No authentication header needed for public access + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch active services, status: %d", resp.StatusCode) + } + + var servicesResponse ServicesResponse + if err := json.NewDecoder(resp.Body).Decode(&servicesResponse); err != nil { + return nil, err + } + + return servicesResponse.Items, nil +} + +func (c *PocketBaseClient) UpdateServiceStatus(serviceID string, status string, responseTime int64, errorMessage string) error { + // First check if the service is paused before updating + service, err := c.GetService(serviceID) + if err != nil { + return fmt.Errorf("failed to check service status before update: %v", err) + } + + if service.Status == "paused" { + return fmt.Errorf("service %s is paused, skipping status update", serviceID) + } + + updateData := map[string]interface{}{ + "status": status, + "response_time": responseTime, + "last_checked": time.Now().Format(time.RFC3339), + } + + if errorMessage != "" { + updateData["error_message"] = errorMessage + } + + jsonData, err := json.Marshal(updateData) + if err != nil { + return err + } + + req, err := http.NewRequest("PATCH", + fmt.Sprintf("%s/api/collections/services/records/%s", c.baseURL, serviceID), + bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + // No authentication header needed + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to update service status, status: %d", resp.StatusCode) + } + + return nil +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/types.go b/server/service-operation/pocketbase/types.go new file mode 100644 index 0000000..9e0812e --- /dev/null +++ b/server/service-operation/pocketbase/types.go @@ -0,0 +1,126 @@ + +package pocketbase + +import "time" + +type AuthResponse struct { + Token string `json:"token"` + Record interface{} `json:"record"` +} + +type MetricsRecord struct { + ServiceName string `json:"service_name"` + Host string `json:"host"` + Uptime float64 `json:"uptime"` + ResponseTime int64 `json:"response_time"` + LastChecked string `json:"last_checked"` + Port int `json:"port,omitempty"` + Domain string `json:"domain,omitempty"` + HeartbeatInterval int `json:"heartbeat_interval,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` + NotificationID string `json:"notification_id,omitempty"` + TemplateID string `json:"template_id,omitempty"` + ServiceType string `json:"service_type"` + Status string `json:"status"` + URL string `json:"url,omitempty"` + Alerts string `json:"alerts,omitempty"` + StatusCodes string `json:"status_codes,omitempty"` + Keyword string `json:"keyword,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + Details string `json:"details,omitempty"` + CheckedAt string `json:"checked_at"` +} + +type PingDataRecord struct { + ServiceID string `json:"service_id"` + Timestamp time.Time `json:"timestamp"` + ResponseTime int64 `json:"response_time"` + Status string `json:"status"` + PacketLoss string `json:"packet_loss"` + Latency string `json:"latency"` + MaxRTT string `json:"max_rtt"` + MinRTT string `json:"min_rtt"` + PacketsSent string `json:"packets_sent"` + PacketsRecv string `json:"packets_recv"` + AvgRTT string `json:"avg_rtt"` + RTTs string `json:"rtts"` + Details string `json:"details,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type UptimeDataRecord struct { + ServiceID string `json:"service_id"` + Timestamp time.Time `json:"timestamp"` + ResponseTime int64 `json:"response_time"` + Status string `json:"status"` + Packets string `json:"packets"` + Latency string `json:"latency"` + StatusCodes string `json:"status_codes"` + Keyword string `json:"keyword"` + ErrorMessage string `json:"error_message"` + Details string `json:"details"` + Region string `json:"region,omitempty"` + RegionID string `json:"region_id,omitempty"` +} + +type DNSDataRecord struct { + ServiceID string `json:"service_id"` + Timestamp time.Time `json:"timestamp"` + ResponseTime int64 `json:"response_time"` + Status string `json:"status"` + QueryType string `json:"query_type"` + ResolveIP string `json:"resolve_ip"` + MsgSize string `json:"msg_size"` + Question string `json:"question"` + Answer string `json:"answer"` + Authority string `json:"authority"` + ErrorMessage string `json:"error_message,omitempty"` + Details string `json:"details,omitempty"` + RegionName string `json:"region_name,omitempty"` + AgentID string `json:"agent_id,omitempty"` +} + +type TCPDataRecord struct { + ServiceID string `json:"service_id"` + Timestamp time.Time `json:"timestamp"` + ResponseTime int64 `json:"response_time"` + Status string `json:"status"` + Connection string `json:"connection"` + Latency string `json:"latency"` + Port string `json:"port"` + ErrorMessage string `json:"error_message,omitempty"` + Details string `json:"details,omitempty"` + RegionName string `json:"region_name,omitempty"` + AgentID string `json:"agent_id,omitempty"` +} + +type Service struct { + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + Uptime float64 `json:"uptime"` + ResponseTime int64 `json:"response_time"` + LastChecked string `json:"last_checked"` + Port int `json:"port"` + Domain string `json:"domain"` + HeartbeatInterval int `json:"heartbeat_interval"` + MaxRetries int `json:"max_retries"` + NotificationID string `json:"notification_id"` + TemplateID string `json:"template_id"` + ServiceType string `json:"service_type"` + Status string `json:"status"` + URL string `json:"url"` + Alerts string `json:"alerts"` + StatusCodes string `json:"status_codes"` + Keyword string `json:"keyword"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +type ServicesResponse struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` + Items []Service `json:"items"` +} \ No newline at end of file diff --git a/server/service-operation/pocketbase/utils.go b/server/service-operation/pocketbase/utils.go new file mode 100644 index 0000000..096cfe3 --- /dev/null +++ b/server/service-operation/pocketbase/utils.go @@ -0,0 +1,47 @@ + +package pocketbase + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +func (c *PocketBaseClient) createRecord(collection string, data interface{}) error { + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", + fmt.Sprintf("%s/api/collections/%s/records", c.baseURL, collection), + bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + // No authentication header needed + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + // Read response body for better error details + bodyBytes, _ := io.ReadAll(resp.Body) + bodyString := string(bodyBytes) + + fmt.Printf("Failed to create record in %s collection. Status: %d, Response: %s\n", + collection, resp.StatusCode, bodyString) + + return fmt.Errorf("failed to create record in %s, status: %d, response: %s", + collection, resp.StatusCode, bodyString) + } + + return nil +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/dns_saver.go b/server/service-operation/shared/savers/dns_saver.go new file mode 100644 index 0000000..6ad3dc9 --- /dev/null +++ b/server/service-operation/shared/savers/dns_saver.go @@ -0,0 +1,74 @@ + +package savers + +import ( + "fmt" + "strings" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +func (ms *MetricsSaver) SaveDNSDataToPocketBase(result *types.OperationResult, serviceID string) { + // Create a short, professional status message + var details string + + if result.Success { + // Success message with record count and query info + recordCount := len(result.DNSRecords) + details = fmt.Sprintf("✅ DNS %s Query OK - %d records found", + strings.ToUpper(result.DNSType), recordCount) + + // Add response time + details += fmt.Sprintf(" | Response time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + + // Add first few records for context + if recordCount > 0 { + if recordCount <= 2 { + details += fmt.Sprintf(" | Records: %s", strings.Join(result.DNSRecords, ", ")) + } else { + details += fmt.Sprintf(" | Records: %s... (+%d more)", + strings.Join(result.DNSRecords[:2], ", "), recordCount-2) + } + } + } else { + // Error message with query type + details = fmt.Sprintf("❌ DNS %s Query Failed - %s", + strings.ToUpper(result.DNSType), + GetShortErrorMessage(result.Error)) + + // Add response time if available + if result.ResponseTime > 0 { + details += fmt.Sprintf(" | Response time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + } + } + + dnsData := pocketbase.DNSDataRecord{ + ServiceID: serviceID, + Timestamp: time.Now(), + ResponseTime: result.ResponseTime.Milliseconds(), + Status: GetStatusString(result.Success), + QueryType: result.DNSType, + ResolveIP: strings.Join(result.DNSRecords, ","), + MsgSize: fmt.Sprintf("%d", len(result.DNSRecords)), + Question: result.Host, + Answer: strings.Join(result.DNSRecords, ","), + Authority: "", // Not available in current implementation + ErrorMessage: result.Error, + Details: details, // Short, clean message + RegionName: "default", // You can make this configurable + AgentID: "1", // You can make this configurable + } + + if err := ms.pbClient.SaveDNSData(dnsData); err != nil { + println("Failed to save DNS data to PocketBase:", err.Error()) + } +} + +// Method for monitoring service usage +func (ms *MetricsSaver) SaveDNSDataForService(service pocketbase.Service, result *types.OperationResult) { + ms.SaveDNSDataToPocketBase(result, service.ID) +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/metrics_saver.go b/server/service-operation/shared/savers/metrics_saver.go new file mode 100644 index 0000000..cdf2ab6 --- /dev/null +++ b/server/service-operation/shared/savers/metrics_saver.go @@ -0,0 +1,90 @@ + +package savers + +import ( + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +type MetricsSaver struct { + pbClient *pocketbase.PocketBaseClient +} + +func NewMetricsSaver(pbClient *pocketbase.PocketBaseClient) *MetricsSaver { + return &MetricsSaver{ + pbClient: pbClient, + } +} + +func (ms *MetricsSaver) SaveMetricsToPocketBase(result *types.OperationResult, serviceID string) { + // Save general metrics using the new structure + metrics := pocketbase.MetricsRecord{ + ServiceName: result.Host, + Host: result.Host, + Uptime: 0, // This would need to be calculated based on your requirements + ResponseTime: result.ResponseTime.Milliseconds(), + LastChecked: time.Now().Format(time.RFC3339), + Port: result.Port, + ServiceType: string(result.Type), + Status: GetStatusString(result.Success), + ErrorMessage: result.Error, + Details: FormatResultDetails(result), + CheckedAt: time.Now().Format(time.RFC3339), + } + + if err := ms.pbClient.SaveMetrics(metrics); err != nil { + // Log error but don't fail the operation + println("Failed to save metrics to PocketBase:", err.Error()) + } + + // Save detailed data based on operation type - only once per check + if serviceID != "" { + switch result.Type { + case types.OperationPing: + ms.SavePingDataToPocketBase(result, serviceID) + case types.OperationHTTP: + ms.SaveUptimeDataToPocketBase(result, serviceID) + case types.OperationDNS: + ms.SaveDNSDataToPocketBase(result, serviceID) + case types.OperationTCP: + ms.SaveTCPDataToPocketBase(result, serviceID) + } + } +} + +// Primary method for monitoring service usage - this prevents duplicates +func (ms *MetricsSaver) SaveMetricsForService(service pocketbase.Service, result *types.OperationResult) { + // Save general metrics first - reduced logging + metrics := pocketbase.MetricsRecord{ + ServiceName: service.Name, + Host: service.Host, + Uptime: 0, // This would need to be calculated based on your requirements + ResponseTime: result.ResponseTime.Milliseconds(), + LastChecked: time.Now().Format(time.RFC3339), + Port: service.Port, + ServiceType: service.ServiceType, + Status: GetStatusString(result.Success), + ErrorMessage: result.Error, + Details: FormatResultDetails(result), + CheckedAt: time.Now().Format(time.RFC3339), + } + + if err := ms.pbClient.SaveMetrics(metrics); err != nil { + // Silent error - no logging to reduce output + return + } + + // Save detailed data based on service type - only once per service with minimal logging + switch service.ServiceType { + case "ping", "icmp": + ms.SavePingDataToPocketBase(result, service.ID) + case "dns": + ms.SaveDNSDataToPocketBase(result, service.ID) + case "http", "https": + ms.SaveUptimeDataToPocketBase(result, service.ID) + case "tcp": + ms.SaveTCPDataToPocketBase(result, service.ID) + } +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/ping_saver.go b/server/service-operation/shared/savers/ping_saver.go new file mode 100644 index 0000000..1a0b79b --- /dev/null +++ b/server/service-operation/shared/savers/ping_saver.go @@ -0,0 +1,72 @@ + +package savers + +import ( + "fmt" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +func (ms *MetricsSaver) SavePingDataToPocketBase(result *types.OperationResult, serviceID string) { + // Create a short, professional status message + var details string + + if result.Success { + // Success message with packet stats + details = fmt.Sprintf("✅ Ping OK - %d/%d packets received", + result.PacketsRecv, result.PacketsSent) + + // Add loss percentage if there was any loss + if result.PacketLoss > 0 { + details += fmt.Sprintf(" (%.1f%% loss)", result.PacketLoss) + } + + // Add response time info + details += fmt.Sprintf(" | Avg: %.2fms", + float64(result.AvgRTT.Nanoseconds())/1000000) + + // Add min/max if different from average (significant variance) + if result.MinRTT != result.MaxRTT { + details += fmt.Sprintf(", Min: %.2fms, Max: %.2fms", + float64(result.MinRTT.Nanoseconds())/1000000, + float64(result.MaxRTT.Nanoseconds())/1000000) + } + } else { + // Error message + if result.PacketLoss >= 100 { + details = fmt.Sprintf("❌ Ping Failed - 100%% packet loss (%s)", + GetShortErrorMessage(result.Error)) + } else { + details = fmt.Sprintf("âš ī¸ Ping Partial - %.1f%% packet loss (%s)", + result.PacketLoss, GetShortErrorMessage(result.Error)) + } + } + + pingData := pocketbase.PingDataRecord{ + ServiceID: serviceID, + Timestamp: time.Now(), + ResponseTime: result.ResponseTime.Milliseconds(), + Status: GetStatusString(result.Success), + PacketsSent: fmt.Sprintf("%d", result.PacketsSent), + PacketsRecv: fmt.Sprintf("%d", result.PacketsRecv), + PacketLoss: fmt.Sprintf("%.1f%%", result.PacketLoss), + MinRTT: fmt.Sprintf("%.2fms", float64(result.MinRTT.Nanoseconds())/1000000), + MaxRTT: fmt.Sprintf("%.2fms", float64(result.MaxRTT.Nanoseconds())/1000000), + AvgRTT: fmt.Sprintf("%.2fms", float64(result.AvgRTT.Nanoseconds())/1000000), + RTTs: "", // Not currently tracked + Latency: fmt.Sprintf("%.2fms", float64(result.AvgRTT.Nanoseconds())/1000000), + ErrorMessage: result.Error, + Details: details, // Short, clean message + } + + if err := ms.pbClient.SavePingData(pingData); err != nil { + println("Failed to save ping data to PocketBase:", err.Error()) + } +} + +// Method for monitoring service usage +func (ms *MetricsSaver) SavePingDataForService(service pocketbase.Service, result *types.OperationResult) { + ms.SavePingDataToPocketBase(result, service.ID) +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/tcp_saver.go b/server/service-operation/shared/savers/tcp_saver.go new file mode 100644 index 0000000..6b24876 --- /dev/null +++ b/server/service-operation/shared/savers/tcp_saver.go @@ -0,0 +1,66 @@ + +package savers + +import ( + "fmt" + "strconv" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +func (ms *MetricsSaver) SaveTCPDataToPocketBase(result *types.OperationResult, serviceID string) { + // Create a short, professional status message + var details string + + if result.Success && result.TCPConnected { + // Success message with connection info + details = fmt.Sprintf("✅ TCP Connection OK - Port %d accessible", result.Port) + + // Add response time + details += fmt.Sprintf(" | Connection time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + } else { + // Error message with port info + details = fmt.Sprintf("❌ TCP Connection Failed - Port %d unreachable", result.Port) + + if result.Error != "" { + details += fmt.Sprintf(" (%s)", GetShortErrorMessage(result.Error)) + } + + // Add response time if available + if result.ResponseTime > 0 { + details += fmt.Sprintf(" | Timeout: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + } + } + + connectionStatus := "disconnected" + if result.TCPConnected { + connectionStatus = "connected" + } + + tcpData := pocketbase.TCPDataRecord{ + ServiceID: serviceID, + Timestamp: time.Now(), + ResponseTime: result.ResponseTime.Milliseconds(), + Status: GetStatusString(result.Success), + Connection: connectionStatus, + Latency: fmt.Sprintf("%.2fms", float64(result.ResponseTime.Nanoseconds())/1000000), + Port: strconv.Itoa(result.Port), + ErrorMessage: result.Error, + Details: details, + RegionName: "default", + AgentID: "1", + } + + if err := ms.pbClient.SaveTCPData(tcpData); err != nil { + fmt.Printf("Failed to save TCP data to PocketBase: %v\n", err) + } +} + +// Method for monitoring service usage +func (ms *MetricsSaver) SaveTCPDataForService(service pocketbase.Service, result *types.OperationResult) { + ms.SaveTCPDataToPocketBase(result, service.ID) +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/uptime_saver.go b/server/service-operation/shared/savers/uptime_saver.go new file mode 100644 index 0000000..af4924b --- /dev/null +++ b/server/service-operation/shared/savers/uptime_saver.go @@ -0,0 +1,72 @@ + +package savers + +import ( + "fmt" + "time" + + "service-operation/pocketbase" + "service-operation/types" +) + +func (ms *MetricsSaver) SaveUptimeDataToPocketBase(result *types.OperationResult, serviceID string) { + // Create a short, professional status message + var details string + + if result.Success { + // Success message with basic info + details = fmt.Sprintf("✅ HTTP %d OK - Response time: %.2fms", + result.HTTPStatusCode, + float64(result.ResponseTime.Nanoseconds())/1000000) + + // Add content info if available + if result.ContentLength > 0 { + details += fmt.Sprintf(" | Content: %s", FormatBytes(result.ContentLength)) + } + + // Add server info if available + if server, exists := result.HTTPHeaders["Server"]; exists { + details += fmt.Sprintf(" | Server: %s", server) + } + } else { + // Error message with status code if available + if result.HTTPStatusCode > 0 { + details = fmt.Sprintf("❌ HTTP %d Error - %s", + result.HTTPStatusCode, + GetShortErrorMessage(result.Error)) + } else { + details = fmt.Sprintf("🔌 Connection Error - %s", + GetShortErrorMessage(result.Error)) + } + + // Add response time if available + if result.ResponseTime > 0 { + details += fmt.Sprintf(" | Response time: %.2fms", + float64(result.ResponseTime.Nanoseconds())/1000000) + } + } + + uptimeData := pocketbase.UptimeDataRecord{ + ServiceID: serviceID, + Timestamp: time.Now(), + ResponseTime: result.ResponseTime.Milliseconds(), + Status: GetStatusString(result.Success), + Packets: "N/A", // Not applicable for HTTP + Latency: fmt.Sprintf("%.2fms", float64(result.ResponseTime.Nanoseconds())/1000000), + StatusCodes: fmt.Sprintf("%d", result.HTTPStatusCode), + Keyword: "", // Can be populated later if needed + ErrorMessage: result.Error, + Details: details, // Short, clean message + Region: "default", // You can make this configurable + RegionID: "1", // You can make this configurable + } + + if err := ms.pbClient.SaveUptimeData(uptimeData); err != nil { + println("Failed to save uptime data to PocketBase:", err.Error()) + } +} + +// Method for monitoring service usage +func (ms *MetricsSaver) SaveUptimeDataForService(service pocketbase.Service, result *types.OperationResult) { + ms.SaveUptimeDataToPocketBase(result, service.ID) +} \ No newline at end of file diff --git a/server/service-operation/shared/savers/utils.go b/server/service-operation/shared/savers/utils.go new file mode 100644 index 0000000..f540b6f --- /dev/null +++ b/server/service-operation/shared/savers/utils.go @@ -0,0 +1,89 @@ + +package savers + +import ( + "fmt" + "strings" + + "service-operation/types" +) + +// Helper function to format bytes in a readable way +func FormatBytes(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } else if bytes < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(bytes)/1024) + } else { + return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) + } +} + +// Helper function to create short error messages +func GetShortErrorMessage(errorMessage string) string { + if errorMessage == "" { + return "Unknown error" + } + + errorLower := strings.ToLower(errorMessage) + + if strings.Contains(errorLower, "timeout") { + return "Request timeout" + } else if strings.Contains(errorLower, "connection refused") { + return "Connection refused" + } else if strings.Contains(errorLower, "dns") || strings.Contains(errorLower, "no such host") { + return "DNS resolution failed" + } else if strings.Contains(errorLower, "certificate") || strings.Contains(errorLower, "ssl") || strings.Contains(errorLower, "tls") { + return "SSL certificate error" + } else if strings.Contains(errorLower, "server error") || strings.Contains(errorLower, "internal server error") { + return "Internal server error" + } else if strings.Contains(errorLower, "not found") { + return "Page not found" + } else if strings.Contains(errorLower, "unauthorized") { + return "Unauthorized access" + } else if strings.Contains(errorLower, "forbidden") { + return "Access forbidden" + } + + // For other errors, take first 50 characters and clean it up + shortMsg := errorMessage + if len(shortMsg) > 50 { + shortMsg = shortMsg[:50] + "..." + } + + return shortMsg +} + +func GetStatusString(success bool) string { + if success { + return "up" + } + return "down" +} + +func FormatResultDetails(result *types.OperationResult) string { + // This can be expanded based on operation type + if result.Details != "" { + return result.Details + } + + switch result.Type { + case types.OperationPing: + if result.Success { + return fmt.Sprintf("Ping successful - %d packets sent, %d received", result.PacketsSent, result.PacketsRecv) + } + return fmt.Sprintf("Ping failed - %s", result.Error) + case types.OperationHTTP: + if result.Success { + return fmt.Sprintf("HTTP %d - Response time: %.2fms", result.HTTPStatusCode, float64(result.ResponseTime.Nanoseconds())/1000000) + } + return fmt.Sprintf("HTTP failed - %s", result.Error) + case types.OperationDNS: + if result.Success { + return fmt.Sprintf("DNS %s query successful - %d records found", result.DNSType, len(result.DNSRecords)) + } + return fmt.Sprintf("DNS query failed - %s", result.Error) + default: + return "Operation completed" + } +} \ No newline at end of file diff --git a/server/service-operation/types/operation.go b/server/service-operation/types/operation.go new file mode 100644 index 0000000..a6f81ff --- /dev/null +++ b/server/service-operation/types/operation.go @@ -0,0 +1,61 @@ + +package types + +import "time" + +type OperationType string + +const ( + OperationPing OperationType = "ping" + OperationDNS OperationType = "dns" + OperationTCP OperationType = "tcp" + OperationHTTP OperationType = "http" +) + +type OperationRequest struct { + Type OperationType `json:"type"` + Host string `json:"host"` + Port int `json:"port,omitempty"` // For TCP + Count int `json:"count,omitempty"` // For ping + Timeout int `json:"timeout,omitempty"` // In seconds + Query string `json:"query,omitempty"` // For DNS + URL string `json:"url,omitempty"` // For HTTP + Method string `json:"method,omitempty"` // For HTTP (GET, POST, etc.) + ServiceID string `json:"service_id,omitempty"` // For linking to specific service +} + +type OperationResult struct { + Type OperationType `json:"type"` + Host string `json:"host"` + Port int `json:"port,omitempty"` + Success bool `json:"success"` + ResponseTime time.Duration `json:"response_time"` + Error string `json:"error,omitempty"` + Details string `json:"details,omitempty"` + + // Ping specific fields + PacketsSent int `json:"packets_sent,omitempty"` + PacketsRecv int `json:"packets_recv,omitempty"` + PacketLoss float64 `json:"packet_loss,omitempty"` + MinRTT time.Duration `json:"min_rtt,omitempty"` + MaxRTT time.Duration `json:"max_rtt,omitempty"` + AvgRTT time.Duration `json:"avg_rtt,omitempty"` + RTTs []time.Duration `json:"rtts,omitempty"` + + // DNS specific fields + DNSRecords []string `json:"dns_records,omitempty"` + DNSType string `json:"dns_type,omitempty"` + + // TCP specific fields + TCPConnected bool `json:"tcp_connected,omitempty"` + + // HTTP specific fields + HTTPStatusCode int `json:"http_status_code,omitempty"` + HTTPMethod string `json:"http_method,omitempty"` + HTTPHeaders map[string]string `json:"http_headers,omitempty"` + ContentLength int64 `json:"content_length,omitempty"` + ResponseBody string `json:"response_body,omitempty"` + + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} \ No newline at end of file