Initial Release
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
|
||||
export interface AlertConfiguration {
|
||||
id?: string;
|
||||
collectionId?: string;
|
||||
collectionName?: string;
|
||||
service_id: string;
|
||||
notification_type: "telegram" | "discord" | "signal" | "slack" | "email";
|
||||
telegram_chat_id?: string;
|
||||
discord_webhook_url?: string;
|
||||
signal_number?: string;
|
||||
notify_name: string;
|
||||
bot_token?: string;
|
||||
template_id?: string;
|
||||
slack_webhook_url?: string;
|
||||
enabled: boolean;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
export const alertConfigService = {
|
||||
async getAlertConfigurations(): Promise<AlertConfiguration[]> {
|
||||
console.info("Fetching alert configurations");
|
||||
try {
|
||||
const response = await pb.collection('alert_configurations').getList(1, 50);
|
||||
console.info("Alert configurations response:", response);
|
||||
return response.items as AlertConfiguration[];
|
||||
} catch (error) {
|
||||
console.error("Error fetching alert configurations:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load notification settings",
|
||||
variant: "destructive"
|
||||
});
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async createAlertConfiguration(config: Omit<AlertConfiguration, 'id' | 'collectionId' | 'collectionName' | 'created' | 'updated'>): Promise<AlertConfiguration | null> {
|
||||
console.info("Creating alert configuration:", config);
|
||||
try {
|
||||
const result = await pb.collection('alert_configurations').create(config);
|
||||
console.info("Alert configuration created:", result);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Notification settings saved successfully",
|
||||
});
|
||||
return result as AlertConfiguration;
|
||||
} catch (error) {
|
||||
console.error("Error creating alert configuration:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to save notification settings",
|
||||
variant: "destructive"
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async updateAlertConfiguration(id: string, config: Partial<AlertConfiguration>): Promise<AlertConfiguration | null> {
|
||||
console.info(`Updating alert configuration ${id}:`, config);
|
||||
try {
|
||||
const result = await pb.collection('alert_configurations').update(id, config);
|
||||
console.info("Alert configuration updated:", result);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Notification settings updated successfully",
|
||||
});
|
||||
return result as AlertConfiguration;
|
||||
} catch (error) {
|
||||
console.error("Error updating alert configuration:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update notification settings",
|
||||
variant: "destructive"
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteAlertConfiguration(id: string): Promise<boolean> {
|
||||
console.info(`Deleting alert configuration ${id}`);
|
||||
try {
|
||||
await pb.collection('alert_configurations').delete(id);
|
||||
console.info("Alert configuration deleted");
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Notification channel removed",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error deleting alert configuration:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to remove notification channel",
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
export const authService = {
|
||||
async login({ email, password }: LoginCredentials): Promise<AuthUser> {
|
||||
try {
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
return {
|
||||
id: authData.record.id,
|
||||
email: authData.record.email,
|
||||
name: authData.record.full_name || authData.record.name,
|
||||
avatar: authData.record.avatar
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw new Error('Authentication failed. Please check your credentials.');
|
||||
}
|
||||
},
|
||||
|
||||
logout(): void {
|
||||
pb.authStore.clear();
|
||||
},
|
||||
|
||||
getCurrentUser(): AuthUser | null {
|
||||
if (!pb.authStore.isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userData = pb.authStore.model as any;
|
||||
|
||||
if (!userData) return null;
|
||||
|
||||
// Log the full user data for debugging
|
||||
console.log("Raw user data from authStore:", userData);
|
||||
|
||||
// The avatar will be kept as is - either the full path from Pocketbase
|
||||
// or we'll handle display in the UI components
|
||||
return {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
name: userData.full_name || userData.name,
|
||||
avatar: userData.avatar
|
||||
};
|
||||
},
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return pb.authStore.isValid;
|
||||
},
|
||||
|
||||
// New method to refresh user data after updates
|
||||
async refreshUserData(): Promise<void> {
|
||||
if (!pb.authStore.isValid || !pb.authStore.model) return;
|
||||
|
||||
try {
|
||||
// Fetch the latest user data from the server
|
||||
const userId = (pb.authStore.model as any).id;
|
||||
await pb.collection('users').getOne(userId);
|
||||
// PocketBase will automatically update the authStore
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user data:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { uptimeService } from '@/services/uptimeService';
|
||||
import { notificationService } from '@/services/notification'; // Import from the main notification service
|
||||
import { prepareServiceForNotification } from '../utils/httpUtils';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
|
||||
/**
|
||||
* Handle a service that is determined to be UP
|
||||
*/
|
||||
export async function handleServiceUp(service: any, responseTime: number, formattedTime: string): Promise<void> {
|
||||
console.log(`Service ${service.name} is UP! Response time: ${responseTime}ms`);
|
||||
|
||||
// Create a history record of this check with a more accurate timestamp
|
||||
const uptimeData: UptimeData = {
|
||||
serviceId: service.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "up",
|
||||
responseTime: responseTime,
|
||||
// Include required properties from the UptimeData interface
|
||||
date: new Date().toISOString(),
|
||||
uptime: 100
|
||||
};
|
||||
|
||||
const previousStatus = service.status;
|
||||
const statusChanged = previousStatus !== "up" && previousStatus !== "paused";
|
||||
|
||||
try {
|
||||
// Run service status update
|
||||
await pb.collection('services').update(service.id, {
|
||||
last_checked: formattedTime,
|
||||
response_time: responseTime,
|
||||
status: "up",
|
||||
// Calculate uptime percentage based on previous checks (simple moving average)
|
||||
uptime: service.uptime ?
|
||||
(service.uptime * 0.9 + 100 * 0.1) : 100,
|
||||
});
|
||||
|
||||
// Try to record uptime data, with retry logic
|
||||
try {
|
||||
await uptimeService.recordUptimeData(uptimeData);
|
||||
} catch (error) {
|
||||
console.error("Failed to record uptime data on first try, retrying...", error);
|
||||
// Wait a short time and retry once
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await uptimeService.recordUptimeData(uptimeData);
|
||||
}
|
||||
|
||||
// Reset notification count if service is recovered from DOWN status
|
||||
if (previousStatus === "down") {
|
||||
console.log(`Service ${service.name} recovered from DOWN status - resetting notification count`);
|
||||
notificationService.resetNotificationCount(service.id);
|
||||
}
|
||||
|
||||
// Send notification if status changed from down to up
|
||||
if (statusChanged) {
|
||||
console.log(`Status changed from ${previousStatus} to UP - sending notification`);
|
||||
|
||||
// Convert PocketBase record to Service type for notification
|
||||
const serviceForNotification = prepareServiceForNotification(service, "up", responseTime);
|
||||
|
||||
// Check if alerts are muted - STRICT check for "muted" string value
|
||||
if (service.alerts === "muted" || serviceForNotification.alerts === "muted") {
|
||||
console.log(`Alerts are MUTED for service ${service.name}, SKIPPING UP notification`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Send notification through main notification service
|
||||
await notificationService.sendNotification({
|
||||
service: serviceForNotification,
|
||||
status: "up",
|
||||
responseTime: responseTime,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log("UP notification sent successfully");
|
||||
} catch (error) {
|
||||
console.error("Error sending UP notification:", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling service UP state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a service that is determined to be DOWN
|
||||
*/
|
||||
export async function handleServiceDown(service: any, formattedTime: string): Promise<void> {
|
||||
console.log(`Service ${service.name} is DOWN!`);
|
||||
|
||||
// Create a history record of this check
|
||||
const uptimeData: UptimeData = {
|
||||
serviceId: service.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: "down",
|
||||
responseTime: 0,
|
||||
// Include required properties from the UptimeData interface
|
||||
date: new Date().toISOString(),
|
||||
uptime: 0
|
||||
};
|
||||
|
||||
const previousStatus = service.status;
|
||||
const statusChanged = previousStatus !== "down";
|
||||
|
||||
console.log(`Service ${service.name} previous status: ${previousStatus}, statusChanged: ${statusChanged}`);
|
||||
|
||||
try {
|
||||
// Update service status
|
||||
await pb.collection('services').update(service.id, {
|
||||
last_checked: formattedTime,
|
||||
response_time: 0,
|
||||
status: "down",
|
||||
// Calculate uptime percentage based on previous checks
|
||||
uptime: service.uptime ?
|
||||
(service.uptime * 0.9 + 0 * 0.1) : 0,
|
||||
});
|
||||
|
||||
// Try to record uptime data with retry logic
|
||||
try {
|
||||
await uptimeService.recordUptimeData(uptimeData);
|
||||
} catch (error) {
|
||||
console.error("Failed to record uptime data on first try, retrying...", error);
|
||||
// Wait a short time and retry once
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await uptimeService.recordUptimeData(uptimeData);
|
||||
}
|
||||
|
||||
// Convert PocketBase record to Service type for notification
|
||||
const serviceForNotification = prepareServiceForNotification(service, "down");
|
||||
|
||||
// Check if alerts are muted - STRICT check for "muted" string value
|
||||
if (service.alerts === "muted" || serviceForNotification.alerts === "muted") {
|
||||
console.log(`Alerts are MUTED for service ${service.name}, SKIPPING DOWN notification`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Attempting to send DOWN notification for service:", service.name);
|
||||
console.log("Service notification data:", {
|
||||
name: serviceForNotification.name,
|
||||
notificationChannel: serviceForNotification.notificationChannel,
|
||||
alertTemplate: serviceForNotification.alertTemplate,
|
||||
retries: serviceForNotification.retries || 3,
|
||||
alerts: serviceForNotification.alerts
|
||||
});
|
||||
|
||||
try {
|
||||
// Use the main notification service - this handles retries internally
|
||||
const result = await notificationService.sendNotification({
|
||||
service: serviceForNotification,
|
||||
status: "down",
|
||||
responseTime: 0,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
console.log("DOWN notification sent result:", result);
|
||||
} catch (error) {
|
||||
console.error("Error sending DOWN notification:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error handling service DOWN state:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { formatCurrentTime, makeHttpRequest } from './utils/httpUtils';
|
||||
import { handleServiceUp, handleServiceDown } from './handlers/serviceStatusHandlers';
|
||||
import { notificationService } from '@/services/notificationService';
|
||||
|
||||
/**
|
||||
* Check the status of an HTTP service
|
||||
*/
|
||||
export async function checkHttpService(serviceId: string): Promise<void> {
|
||||
try {
|
||||
// Fetch the service record from PocketBase
|
||||
const service = await pb.collection('services').getOne(serviceId);
|
||||
|
||||
if (!service) {
|
||||
console.log(`Service with ID ${serviceId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the URL directly if provided, or construct from host
|
||||
const serviceUrl = service.url || (service.host ? `https://${service.host}` : null);
|
||||
|
||||
if (!serviceUrl) {
|
||||
console.log(`No valid URL or host for service ID: ${serviceId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Format the current timestamp for display
|
||||
const formattedTime = formatCurrentTime();
|
||||
|
||||
console.log(`===============================================`);
|
||||
console.log(`Starting HTTP service check: ${service.name} (${service.id})`);
|
||||
console.log(`URL: ${serviceUrl}`);
|
||||
console.log(`Current Status: ${service.status}`);
|
||||
console.log(`Mute Alerts: ${service.mute_alerts || service.muteAlerts ? "YES" : "NO"}`);
|
||||
console.log(`Time: ${formattedTime}`);
|
||||
|
||||
// Use high resolution timer for more accurate response time measurement
|
||||
const startTime = performance.now();
|
||||
const maxRetries = service.max_retries || 3;
|
||||
|
||||
// Make the request with improved retry logic
|
||||
const { isUp, responseTime } = await makeHttpRequest(serviceUrl, maxRetries);
|
||||
|
||||
console.log(`Service ${service.name} check result: ${isUp ? 'UP' : 'DOWN'}, responseTime: ${responseTime}ms`);
|
||||
|
||||
// Handle service status based on check result
|
||||
if (isUp) {
|
||||
await handleServiceUp(service, responseTime, formattedTime);
|
||||
} else {
|
||||
await handleServiceDown(service, formattedTime);
|
||||
}
|
||||
|
||||
console.log(`Uptime data recorded for ${service.name}`);
|
||||
console.log(`===============================================`);
|
||||
} catch (error) {
|
||||
console.error("Error in HTTP service monitoring:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force test the notification system with a specified service ID and status
|
||||
*/
|
||||
export async function testServiceNotification(serviceId: string, status: "up" | "down"): Promise<void> {
|
||||
try {
|
||||
// Fetch the service
|
||||
const service = await pb.collection('services').getOne(serviceId);
|
||||
|
||||
if (!service) {
|
||||
console.error(`Service with ID ${serviceId} not found for test notification`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Sending test ${status.toUpperCase()} notification for service ${service.name}`);
|
||||
|
||||
// Send a test notification
|
||||
const message = status === "up"
|
||||
? `🟢 Test notification: Service ${service.name} is UP`
|
||||
: `🔴 Test notification: Service ${service.name} is DOWN`;
|
||||
|
||||
// Removed the actual test notification sending
|
||||
console.log(`Test notification prepared for service ${service.name}, status: ${status}`);
|
||||
// To manually test notifications, use the UI instead
|
||||
} catch (error) {
|
||||
console.error("Error preparing test notification:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
import { monitoringIntervals } from './monitoringIntervals';
|
||||
import { checkHttpService } from './httpChecker';
|
||||
import {
|
||||
startMonitoringService,
|
||||
pauseMonitoring,
|
||||
resumeMonitoring,
|
||||
startAllActiveServices
|
||||
} from './service-status';
|
||||
|
||||
export const monitoringService = {
|
||||
startMonitoringService,
|
||||
pauseMonitoring,
|
||||
resumeMonitoring,
|
||||
checkHttpService,
|
||||
startAllActiveServices
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
// Map to keep track of monitoring intervals by service ID
|
||||
const monitoringIntervals = new Map<string, number>();
|
||||
|
||||
export { monitoringIntervals };
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
import { startMonitoringService } from './startMonitoring';
|
||||
import { resumeMonitoring } from './resumeMonitoring';
|
||||
import { pauseMonitoring } from './pauseMonitoring';
|
||||
import { startAllActiveServices } from './startAllServices';
|
||||
|
||||
// Export all service monitoring control functions
|
||||
export {
|
||||
startMonitoringService,
|
||||
resumeMonitoring,
|
||||
pauseMonitoring,
|
||||
startAllActiveServices
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { monitoringIntervals } from '../monitoringIntervals';
|
||||
import { Service } from '@/types/service.types';
|
||||
|
||||
/**
|
||||
* Pause monitoring for a specific service
|
||||
*/
|
||||
export async function pauseMonitoring(serviceId: string): Promise<void> {
|
||||
try {
|
||||
// Clear the monitoring interval if it exists
|
||||
const intervalId = monitoringIntervals.get(serviceId);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
monitoringIntervals.delete(serviceId);
|
||||
console.log(`Monitoring paused for service ${serviceId}`);
|
||||
}
|
||||
|
||||
// Get current timestamp formatted as a string
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Fetch the current service to get its name for better logging
|
||||
const service = await pb.collection('services').getOne(serviceId);
|
||||
|
||||
// Update the service status to paused and store the exact pause time
|
||||
await pb.collection('services').update(serviceId, {
|
||||
status: "paused",
|
||||
lastChecked: now,
|
||||
last_checked: now // Ensure both field names are updated
|
||||
});
|
||||
|
||||
// We'll skip the notification here since it will be handled by the UI component
|
||||
// This prevents duplicate notifications for the paused status
|
||||
console.log(`Service ${service.name} paused at ${now}, skipping notification to prevent duplication`);
|
||||
} catch (error) {
|
||||
console.error("Error pausing monitoring:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { monitoringIntervals } from '../monitoringIntervals';
|
||||
import { notificationService } from '@/services/notificationService';
|
||||
import { Service } from '@/types/service.types';
|
||||
import { startMonitoringService } from './startMonitoring';
|
||||
|
||||
/**
|
||||
* Specifically resume a paused service
|
||||
*/
|
||||
export async function resumeMonitoring(serviceId: string): Promise<void> {
|
||||
try {
|
||||
// Get current timestamp formatted as a string
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Fetch the current service to get its name for better logging
|
||||
const service = await pb.collection('services').getOne(serviceId);
|
||||
|
||||
console.log(`Resuming service ${service.name} at ${now}`);
|
||||
|
||||
// First, clear any existing interval just to be safe
|
||||
const existingInterval = monitoringIntervals.get(serviceId);
|
||||
if (existingInterval) {
|
||||
clearInterval(existingInterval);
|
||||
monitoringIntervals.delete(serviceId);
|
||||
}
|
||||
|
||||
// Update the service status to "up" in the database
|
||||
await pb.collection('services').update(serviceId, {
|
||||
status: "up",
|
||||
lastChecked: now,
|
||||
last_checked: now
|
||||
});
|
||||
|
||||
// Convert PocketBase record to Service type for notification
|
||||
const serviceForNotification: Service = {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
url: service.url || "",
|
||||
type: service.service_type || service.type || "HTTP",
|
||||
status: "up",
|
||||
responseTime: service.response_time || 0,
|
||||
uptime: service.uptime || 0,
|
||||
lastChecked: now,
|
||||
interval: service.heartbeat_interval || 60,
|
||||
retries: service.max_retries || 3,
|
||||
notificationChannel: service.notification_id,
|
||||
alertTemplate: service.template_id,
|
||||
muteAlerts: service.mute_alerts || false,
|
||||
alerts: service.alerts || "unmuted",
|
||||
muteChangedAt: service.mute_changed_at,
|
||||
};
|
||||
|
||||
// Double check if alerts are muted before sending notification
|
||||
// Ensure we're using the correct field - alerts should be "muted" or "unmuted"
|
||||
const alertsMuted = service.alerts === "muted" || serviceForNotification.alerts === "muted";
|
||||
|
||||
if (!alertsMuted) {
|
||||
console.log(`Alerts NOT muted for service ${service.name}, sending resume notification`);
|
||||
// Send notification that service has been resumed
|
||||
await notificationService.sendNotification({
|
||||
service: serviceForNotification,
|
||||
status: "up",
|
||||
timestamp: now
|
||||
});
|
||||
} else {
|
||||
console.log(`Alerts muted for service ${service.name}, skipping resume notification`);
|
||||
}
|
||||
|
||||
// IMPORTANT: Wait a brief moment to ensure the status update is processed
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Now start the service monitoring with a clean slate
|
||||
await startMonitoringService(serviceId);
|
||||
|
||||
console.log(`Service ${service.name} resumed and ready for monitoring`);
|
||||
} catch (error) {
|
||||
console.error("Error resuming service:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { startMonitoringService } from './startMonitoring';
|
||||
|
||||
/**
|
||||
* Start monitoring for all active services
|
||||
*/
|
||||
export async function startAllActiveServices(): Promise<void> {
|
||||
try {
|
||||
// Get all services that are not paused
|
||||
const result = await pb.collection('services').getList(1, 100, {
|
||||
filter: 'status != "paused"'
|
||||
});
|
||||
|
||||
console.log(`Starting monitoring for ${result.items.length} active services`);
|
||||
|
||||
// Start monitoring each active service
|
||||
for (const service of result.items) {
|
||||
await startMonitoringService(service.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error starting all active services:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { monitoringIntervals } from '../monitoringIntervals';
|
||||
import { checkHttpService } from '../httpChecker';
|
||||
|
||||
/**
|
||||
* Start monitoring for a specific service
|
||||
*/
|
||||
export async function startMonitoringService(serviceId: string): Promise<void> {
|
||||
try {
|
||||
// First check if the service is already being monitored
|
||||
if (monitoringIntervals.has(serviceId)) {
|
||||
console.log(`Service ${serviceId} is already being monitored`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the service to get its current configuration
|
||||
const service = await pb.collection('services').getOne(serviceId);
|
||||
|
||||
// If service was manually paused, don't auto-resume
|
||||
if (service.status === "paused") {
|
||||
console.log(`Service ${serviceId} (${service.name}) is paused. Not starting monitoring.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Starting monitoring for service ${serviceId} (${service.name})`);
|
||||
|
||||
// Update the service status to active/up in the database
|
||||
await pb.collection('services').update(serviceId, {
|
||||
status: "up",
|
||||
});
|
||||
|
||||
// Start with an immediate check
|
||||
await checkHttpService(serviceId);
|
||||
|
||||
// Then schedule regular checks based on the interval
|
||||
const intervalMs = (service.heartbeat_interval || 60) * 1000; // Convert from seconds to milliseconds
|
||||
console.log(`Setting check interval for ${service.name} to ${intervalMs}ms (${service.heartbeat_interval || 60} seconds)`);
|
||||
|
||||
// Store the interval ID so we can clear it later if needed
|
||||
const intervalId = window.setInterval(async () => {
|
||||
try {
|
||||
// Check if service has been paused since scheduling
|
||||
const currentService = await pb.collection('services').getOne(serviceId);
|
||||
if (currentService.status === "paused") {
|
||||
console.log(`Service ${serviceId} is now paused. Skipping scheduled check.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Running scheduled check for service ${service.name}`);
|
||||
await checkHttpService(serviceId);
|
||||
} catch (error) {
|
||||
console.error(`Error in scheduled check for ${service.name}:`, error);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
// Store the interval ID for this service
|
||||
monitoringIntervals.set(serviceId, intervalId);
|
||||
|
||||
console.log(`Monitoring scheduled for service ${serviceId} every ${service.heartbeat_interval || 60} seconds`);
|
||||
} catch (error) {
|
||||
console.error("Error starting service monitoring:", error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
|
||||
import { Service } from "@/types/service.types";
|
||||
|
||||
/**
|
||||
* Prepare service for notification by converting from PocketBase format to our Service type
|
||||
*/
|
||||
export function prepareServiceForNotification(pbRecord: any, status: string, responseTime: number = 0): Service {
|
||||
// Extract notification channel and template IDs
|
||||
// Handle both naming conventions for backward compatibility
|
||||
const notificationChannel = pbRecord.notification_id || pbRecord.notificationChannel || null;
|
||||
const alertTemplate = pbRecord.template_id || pbRecord.alertTemplate || null;
|
||||
const muteAlerts = pbRecord.mute_alerts !== undefined ? pbRecord.mute_alerts :
|
||||
(pbRecord.muteAlerts !== undefined ? pbRecord.muteAlerts : false);
|
||||
|
||||
console.log(`Preparing service for notification: ${pbRecord.name}, Mute Alerts: ${muteAlerts ? "YES" : "NO"}`);
|
||||
|
||||
// Return a standardized Service object for notification
|
||||
return {
|
||||
id: pbRecord.id,
|
||||
name: pbRecord.name,
|
||||
url: pbRecord.url,
|
||||
type: pbRecord.type || pbRecord.service_type || "HTTP",
|
||||
status: status as any,
|
||||
responseTime: responseTime,
|
||||
uptime: pbRecord.uptime || 0,
|
||||
lastChecked: new Date().toISOString(),
|
||||
interval: pbRecord.interval || pbRecord.heartbeat_interval || 60,
|
||||
retries: pbRecord.retries || pbRecord.max_retries || 3,
|
||||
notificationChannel,
|
||||
alertTemplate,
|
||||
muteAlerts
|
||||
};
|
||||
}
|
||||
|
||||
// Utility function to add 'https://' to URLs if they don't have a protocol
|
||||
export function ensureHttpsProtocol(url: string): string {
|
||||
// Check if the URL starts with a protocol
|
||||
if (!/^[a-z]+:\/\//i.test(url)) {
|
||||
// If not, prepend https://
|
||||
return `https://${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Utility function to format a PocketBase record ID for display
|
||||
export function formatRecordId(id: string): string {
|
||||
if (!id) return '';
|
||||
|
||||
// Return the first 8 characters followed by '...'
|
||||
return id.length > 8 ? `${id.substring(0, 8)}...` : id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format current time for display
|
||||
*/
|
||||
export function formatCurrentTime(): string {
|
||||
const now = new Date();
|
||||
return now.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request to a service endpoint with retry logic
|
||||
* Improved to better handle different response scenarios and detection issues
|
||||
*/
|
||||
export async function makeHttpRequest(url: string, maxRetries: number = 3): Promise<{ isUp: boolean; responseTime: number }> {
|
||||
let retries = 0;
|
||||
let isUp = false;
|
||||
let responseTime = 0;
|
||||
let lastError = null;
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Ensure URL has proper protocol
|
||||
const targetUrl = ensureHttpsProtocol(url);
|
||||
|
||||
while (retries < maxRetries && !isUp) {
|
||||
try {
|
||||
// Use fetch API with a timeout to prevent long-hanging requests
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
console.log(`Attempt ${retries + 1}/${maxRetries} checking ${targetUrl}`);
|
||||
|
||||
// IMPROVED APPROACH: Try multiple detection methods in sequence
|
||||
|
||||
// Method 1: Try HEAD request first as it's lightweight
|
||||
try {
|
||||
console.log(`Trying HEAD request to ${targetUrl}`);
|
||||
const headResponse = await fetch(targetUrl, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Accept': '*/*',
|
||||
'User-Agent': 'ServiceMonitor/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
// If HEAD request succeeds with any status code, service is reachable
|
||||
isUp = true;
|
||||
console.log(`HEAD request successful with status ${headResponse.status}`);
|
||||
} catch (headError) {
|
||||
console.log(`HEAD request failed: ${headError.message}`);
|
||||
lastError = headError;
|
||||
|
||||
// Method 2: Fall back to standard GET with proper error handling
|
||||
try {
|
||||
console.log(`Trying standard GET request to ${targetUrl}`);
|
||||
const getResponse = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Accept': '*/*',
|
||||
'User-Agent': 'ServiceMonitor/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
// If GET returns any response, consider the service up
|
||||
isUp = true;
|
||||
console.log(`GET request successful with status ${getResponse.status}`);
|
||||
} catch (getError) {
|
||||
console.log(`Standard GET request failed: ${getError.message}`);
|
||||
lastError = getError;
|
||||
|
||||
// Method 3: Try with no-cors mode as a last resort
|
||||
try {
|
||||
console.log(`Trying no-cors GET request to ${targetUrl}`);
|
||||
const noCorsResponse = await fetch(targetUrl, {
|
||||
method: 'GET',
|
||||
mode: 'no-cors', // This allows requests to succeed even if CORS is restricted
|
||||
signal: controller.signal,
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
// In no-cors mode, we can't read the response, but if we get here without an exception,
|
||||
// the request succeeded and the service is likely up
|
||||
isUp = true;
|
||||
console.log(`No-cors GET request succeeded, assuming service is UP`);
|
||||
} catch (corsError) {
|
||||
console.log(`No-cors GET request also failed: ${corsError.message}`);
|
||||
lastError = corsError;
|
||||
isUp = false;
|
||||
}
|
||||
|
||||
// Method 4: If all fetches fail but error is CORS-related, consider service up
|
||||
if (!isUp && lastError &&
|
||||
(lastError.message.includes('CORS') ||
|
||||
lastError.message.includes('blocked') ||
|
||||
lastError.message.includes('policy'))) {
|
||||
console.log("CORS error detected, but this likely means the service is running.");
|
||||
console.log("Setting service as UP despite CORS restriction.");
|
||||
isUp = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
responseTime = Math.round(performance.now() - startTime);
|
||||
|
||||
if (isUp) {
|
||||
console.log(`Service is detected as UP after ${retries + 1} attempts, response time: ${responseTime}ms`);
|
||||
break;
|
||||
} else {
|
||||
console.log(`All connection attempts failed for attempt ${retries + 1}`);
|
||||
retries++;
|
||||
|
||||
// Add a short delay between retries
|
||||
if (retries < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`HTTP request attempt ${retries + 1} failed with error:`, error);
|
||||
lastError = error;
|
||||
retries++;
|
||||
|
||||
if (retries < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Critical error in makeHttpRequest:', error);
|
||||
isUp = false;
|
||||
responseTime = Math.round(performance.now() - startTime);
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
// Final check for status
|
||||
if (!isUp && lastError) {
|
||||
console.log("Final connection attempt failed with error:", lastError);
|
||||
// Check for specific errors that might indicate the site is actually up
|
||||
if (lastError.name === 'AbortError') {
|
||||
console.log("Request timed out which may indicate a slow response rather than service down");
|
||||
}
|
||||
}
|
||||
|
||||
// Log the final result for debugging
|
||||
console.log(`Final service check result - URL: ${url}, isUp: ${isUp}, responseTime: ${responseTime}ms, retries: ${retries}`);
|
||||
|
||||
return { isUp, responseTime };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { formatCurrentTime } from './httpUtils';
|
||||
|
||||
/**
|
||||
* Records a mute status change for a service in the database
|
||||
* @param serviceId The ID of the service
|
||||
* @param serviceName The name of the service
|
||||
* @param muteStatus The new mute status (true = muted, false = unmuted)
|
||||
* @param userId Optional user ID who performed the action
|
||||
*/
|
||||
export async function recordMuteStatusChange(
|
||||
serviceId: string,
|
||||
serviceName: string,
|
||||
muteStatus: boolean,
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedTime = formatCurrentTime();
|
||||
|
||||
console.log(`Recording ${muteStatus ? "mute" : "unmute"} status change for service ${serviceName} at ${formattedTime}`);
|
||||
|
||||
// Update the service record with the correct alerts field value
|
||||
// Use both mute_alerts and alerts fields for backward compatibility
|
||||
const updateData = {
|
||||
mute_changed_at: timestamp,
|
||||
mute_alerts: muteStatus,
|
||||
alerts: muteStatus ? "muted" : "unmuted" // Use the correct field name as per DB schema
|
||||
};
|
||||
|
||||
console.log(`Updating service with data: ${JSON.stringify(updateData)}`);
|
||||
|
||||
await pb.collection('services').update(serviceId, updateData);
|
||||
|
||||
console.log(`Mute status change recorded for ${serviceName}: ${muteStatus ? "Muted" : "Unmuted"}`);
|
||||
} catch (error) {
|
||||
console.error("Error recording mute status change:", error);
|
||||
throw error; // Rethrow the error to be handled by the caller
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { AlertConfiguration } from "../alertConfigService";
|
||||
import { NotificationTemplate } from "../templateService";
|
||||
import { NotificationPayload } from "./types";
|
||||
import { processTemplate, generateDefaultMessage } from "./templateProcessor";
|
||||
import { sendTelegramNotification, testSendTelegramMessage } from "./telegramService";
|
||||
import { sendSignalNotification } from "./signalService";
|
||||
|
||||
// Track last notification times for services to implement cooldown
|
||||
const lastNotifications: Record<string, {
|
||||
timestamp: Date;
|
||||
count: number;
|
||||
lastMessageTime: Date; // Track when the last message was actually sent
|
||||
}> = {};
|
||||
|
||||
// Cooldown period in milliseconds (5 minutes)
|
||||
const NOTIFICATION_COOLDOWN = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Notification service responsible for sending notifications based on service status changes
|
||||
*/
|
||||
export const notificationService = {
|
||||
/**
|
||||
* Send notification based on service status change
|
||||
*/
|
||||
async sendNotification(payload: NotificationPayload): Promise<boolean> {
|
||||
try {
|
||||
console.log("=== NOTIFICATION SERVICE TRIGGER ===");
|
||||
console.log("Sending notification for service:", payload.service.name, "Status:", payload.status);
|
||||
console.log("Service type:", payload.service.type);
|
||||
console.log("Service retries:", payload.service.retries || 3);
|
||||
console.log("Alerts status:", payload.service.alerts);
|
||||
|
||||
// First check: Strictly check if alerts are muted for this specific service
|
||||
if (payload.service.alerts === "muted") {
|
||||
console.log(`BLOCKING NOTIFICATION: Alerts are explicitly muted for service ${payload.service.name} (ID: ${payload.service.id})`);
|
||||
return true; // Return true as this is expected behavior, not a failure
|
||||
}
|
||||
|
||||
// For DOWN status, implement the cooldown and max retries logic
|
||||
if (payload.status === "down") {
|
||||
const serviceId = payload.service.id;
|
||||
// Get max retries from service config or default to 3
|
||||
const maxRetries = typeof payload.service.retries === 'number' ? payload.service.retries : 3;
|
||||
const now = new Date();
|
||||
|
||||
console.log(`DOWN notification for ${payload.service.name} - Max retries: ${maxRetries}`);
|
||||
|
||||
if (lastNotifications[serviceId]) {
|
||||
const lastNotif = lastNotifications[serviceId];
|
||||
const timeSinceLastNotif = now.getTime() - lastNotif.timestamp.getTime();
|
||||
|
||||
console.log(`Time since last notification tracking for ${payload.service.name}: ${Math.round(timeSinceLastNotif/1000)}s`);
|
||||
console.log(`Current notification count: ${lastNotif.count}/${maxRetries}`);
|
||||
|
||||
// Check if we're within the cooldown period
|
||||
if (timeSinceLastNotif < NOTIFICATION_COOLDOWN) {
|
||||
// If max retries is set to 1, we should only send one notification during the cooldown period
|
||||
if (maxRetries === 1) {
|
||||
console.log(`Max retries is 1 - BLOCKING duplicate DOWN notification for ${payload.service.name}`);
|
||||
return true; // Skip notification but return success
|
||||
}
|
||||
|
||||
// Check if we've reached max retries
|
||||
if (lastNotif.count >= maxRetries) {
|
||||
console.log(`DOWN notification for ${payload.service.name} skipped: Max retries (${maxRetries}) reached. Next notification after cooldown.`);
|
||||
return true; // Skip notification but return success
|
||||
}
|
||||
|
||||
// Increment count since we're sending another notification
|
||||
console.log(`DOWN notification for ${payload.service.name}: ${lastNotif.count + 1}/${maxRetries}`);
|
||||
lastNotifications[serviceId].count += 1;
|
||||
lastNotifications[serviceId].lastMessageTime = now;
|
||||
} else {
|
||||
// Reset count after cooldown period
|
||||
console.log(`Cooldown period elapsed for ${payload.service.name}. Resetting notification count.`);
|
||||
lastNotifications[serviceId] = { timestamp: now, count: 1, lastMessageTime: now };
|
||||
}
|
||||
} else {
|
||||
// First notification for this service
|
||||
console.log(`First DOWN notification for ${payload.service.name}: 1/${maxRetries}`);
|
||||
lastNotifications[serviceId] = { timestamp: now, count: 1, lastMessageTime: now };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if service has notification channel configured
|
||||
const notificationChannel = payload.service.notificationChannel;
|
||||
|
||||
if (!notificationChannel || notificationChannel === "none") {
|
||||
console.log("No notification channel configured for service", payload.service.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Using notification channel ID:", notificationChannel);
|
||||
|
||||
try {
|
||||
// Get the alert configuration details
|
||||
console.log("Fetching alert configuration with ID:", notificationChannel);
|
||||
const alertConfig = await pb.collection('alert_configurations').getOne(notificationChannel);
|
||||
console.log("Found alert configuration:", JSON.stringify({
|
||||
...alertConfig,
|
||||
bot_token: alertConfig.bot_token ? "[REDACTED]" : undefined
|
||||
}, null, 2));
|
||||
|
||||
// Check enabled status - skip if explicitly disabled
|
||||
if (alertConfig.enabled === false || alertConfig.enabled === "false") {
|
||||
console.log("Alert configuration is disabled, skipping notification");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prepare message content
|
||||
let messageContent = await this.prepareMessageContent(
|
||||
payload.service,
|
||||
payload.status,
|
||||
payload.responseTime,
|
||||
payload.message
|
||||
);
|
||||
|
||||
// For DOWN status, add retry information to the message
|
||||
if (payload.status === "down" && lastNotifications[payload.service.id]) {
|
||||
const retryInfo = lastNotifications[payload.service.id];
|
||||
const maxRetries = typeof payload.service.retries === 'number' ? payload.service.retries : 3;
|
||||
if (maxRetries > 1) {
|
||||
messageContent += `\n\nAlert ${retryInfo.count}/${maxRetries}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Prepared message for ${payload.status} notification:`, messageContent);
|
||||
|
||||
// Send notification based on type
|
||||
if (alertConfig.notification_type === "telegram") {
|
||||
console.log("Sending Telegram notification for service status change");
|
||||
return await sendTelegramNotification(alertConfig as AlertConfiguration, messageContent);
|
||||
} else if (alertConfig.notification_type === "signal") {
|
||||
console.log("Sending Signal notification with message:", messageContent);
|
||||
return await sendSignalNotification(alertConfig as AlertConfiguration, messageContent);
|
||||
}
|
||||
|
||||
// Add other notification types here
|
||||
console.log("Notification type not supported:", alertConfig.notification_type);
|
||||
toast({
|
||||
title: "Notification Error",
|
||||
description: `Notification type '${alertConfig.notification_type}' not supported`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error processing notification channel:", error);
|
||||
console.error("Error details:", error instanceof Error ? error.message : "Unknown error");
|
||||
|
||||
toast({
|
||||
title: "Notification Error",
|
||||
description: `Error processing notification: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending notification:", error);
|
||||
toast({
|
||||
title: "Notification Error",
|
||||
description: `Failed to send notification: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Prepare message content for notification
|
||||
*/
|
||||
async prepareMessageContent(
|
||||
service: any,
|
||||
status: string,
|
||||
responseTime?: number,
|
||||
defaultMessage?: string
|
||||
): Promise<string> {
|
||||
// Use provided message if available
|
||||
if (defaultMessage) {
|
||||
return defaultMessage;
|
||||
}
|
||||
|
||||
// Check for a template
|
||||
let templateId = service.alertTemplate;
|
||||
|
||||
if (templateId && templateId !== "default") {
|
||||
try {
|
||||
console.log("Using specified template ID:", templateId);
|
||||
const template = await pb.collection('notification_templates').getOne(templateId);
|
||||
console.log("Template data:", template);
|
||||
return processTemplate(
|
||||
template as unknown as NotificationTemplate,
|
||||
service,
|
||||
status,
|
||||
responseTime
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error fetching template:", error);
|
||||
// Fall back to default message format
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Using default message template");
|
||||
return generateDefaultMessage(service.name, status, responseTime);
|
||||
},
|
||||
|
||||
/**
|
||||
* Test method to directly send a Telegram message
|
||||
*/
|
||||
async testTelegramNotification(message?: string): Promise<boolean> {
|
||||
return await testSendTelegramMessage(
|
||||
"-1002471970362",
|
||||
"7581526325:AAFZgmn9hzc3dpBWl9uLUhcqXRDx5D16e48",
|
||||
message || "This is a test notification from the monitoring system."
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a test notification for a specific service status change
|
||||
*/
|
||||
async testServiceStatusNotification(serviceName: string, status: "up" | "down", responseTime?: number): Promise<boolean> {
|
||||
const emoji = status === "up" ? "🟢" : "🔴";
|
||||
const rtText = responseTime ? ` (Response time: ${responseTime}ms)` : "";
|
||||
const message = `${emoji} Test notification: Service ${serviceName} is ${status.toUpperCase()}${rtText}`;
|
||||
|
||||
console.log("Sending test service status notification:", message);
|
||||
return await this.testTelegramNotification(message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset notification count for a service
|
||||
* This is called when a service recovers from DOWN to UP
|
||||
*/
|
||||
resetNotificationCount(serviceId: string): void {
|
||||
if (lastNotifications[serviceId]) {
|
||||
console.log(`Resetting notification count for service ${serviceId}`);
|
||||
delete lastNotifications[serviceId];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Re-export the types for easier imports
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,91 @@
|
||||
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { AlertConfiguration } from "../alertConfigService";
|
||||
import api from "@/api";
|
||||
|
||||
/**
|
||||
* Send a notification via Signal
|
||||
*/
|
||||
export async function sendSignalNotification(
|
||||
config: AlertConfiguration,
|
||||
message: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!config.signal_number) {
|
||||
console.log("Missing Signal configuration - Signal number:", config.signal_number);
|
||||
toast({
|
||||
title: "Configuration Error",
|
||||
description: "Missing Signal number",
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Sending Signal notification to number:", config.signal_number);
|
||||
console.log("Message content:", message);
|
||||
|
||||
// Prepare payload for the API call
|
||||
const payload = {
|
||||
type: "signal",
|
||||
signalNumber: config.signal_number,
|
||||
message: message
|
||||
};
|
||||
|
||||
console.log("Prepared payload for Signal notification:", payload);
|
||||
|
||||
try {
|
||||
// Call our client-side API handler
|
||||
console.log("Sending request to /api/realtime endpoint for Signal");
|
||||
const response = await api.handleRequest('/api/realtime', 'POST', payload);
|
||||
|
||||
console.log("API response status:", response.status);
|
||||
|
||||
// Check if response is ok
|
||||
if (response.status !== 200) {
|
||||
console.error("Error response from Signal notification API:", response.status);
|
||||
toast({
|
||||
title: "Notification Failed",
|
||||
description: `Server returned error ${response.status}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const responseData = response.json;
|
||||
console.log("API response data:", responseData);
|
||||
|
||||
if (responseData && responseData.ok === false) {
|
||||
console.error("Error sending Signal notification:", responseData);
|
||||
toast({
|
||||
title: "Notification Failed",
|
||||
description: responseData.description || "Failed to send Signal notification",
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Signal notification sent successfully");
|
||||
toast({
|
||||
title: "Notification Sent",
|
||||
description: "Signal notification sent successfully"
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error calling Signal notification API:", error);
|
||||
toast({
|
||||
title: "API Error",
|
||||
description: `Failed to communicate with Signal notification service: ${error instanceof Error ? error.message : "Network error"}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in sendSignalNotification:", error);
|
||||
toast({
|
||||
title: "Notification Error",
|
||||
description: `Error sending Signal notification: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { AlertConfiguration } from "../alertConfigService";
|
||||
import api from "@/api";
|
||||
|
||||
/**
|
||||
* Send a notification via Telegram
|
||||
*/
|
||||
export async function sendTelegramNotification(
|
||||
config: AlertConfiguration,
|
||||
message: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
console.log("====== TELEGRAM NOTIFICATION ATTEMPT ======");
|
||||
console.log("Config:", JSON.stringify({
|
||||
...config,
|
||||
notify_name: config.notify_name,
|
||||
telegram_chat_id: config.telegram_chat_id,
|
||||
bot_token: config.bot_token ? "[REDACTED]" : undefined,
|
||||
enabled: config.enabled
|
||||
}, null, 2));
|
||||
|
||||
// Use provided credentials if available, otherwise use config
|
||||
const chatId = config.telegram_chat_id || "-1002471970362";
|
||||
const botToken = config.bot_token || "7581526325:AAFZgmn9hzc3dpBWl9uLUhcqXRDx5D16e48";
|
||||
|
||||
if (!chatId || !botToken) {
|
||||
console.error("Missing Telegram configuration - Chat ID:", chatId, "Bot token present:", !!botToken);
|
||||
toast({
|
||||
title: "Configuration Error",
|
||||
description: "Missing Telegram chat ID or bot token",
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Sending Telegram notification to chat ID:", chatId);
|
||||
console.log("Bot token available:", !!botToken);
|
||||
console.log("Message content:", message);
|
||||
|
||||
// Prepare payload for the API call
|
||||
const payload = {
|
||||
type: "telegram",
|
||||
chatId: chatId,
|
||||
botToken: botToken,
|
||||
message: message
|
||||
};
|
||||
|
||||
console.log("Prepared payload for notification:", {
|
||||
...payload,
|
||||
botToken: "[REDACTED]"
|
||||
});
|
||||
|
||||
try {
|
||||
// Call our client-side API handler
|
||||
console.log("Sending request to /api/realtime endpoint");
|
||||
const response = await api.handleRequest('/api/realtime', 'POST', payload);
|
||||
|
||||
console.log("API response status:", response.status);
|
||||
console.log("API response data:", JSON.stringify(response.json, null, 2));
|
||||
|
||||
// Check if response is ok
|
||||
if (response.status !== 200) {
|
||||
console.error("Error response from notification API:", response.status);
|
||||
toast({
|
||||
title: "Notification Failed",
|
||||
description: `Server returned error ${response.status}: ${response.json?.description || "Unknown error"}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const responseData = response.json;
|
||||
|
||||
if (responseData && responseData.ok === false) {
|
||||
console.error("Error sending notification:", responseData);
|
||||
toast({
|
||||
title: "Notification Failed",
|
||||
description: responseData.description || "Failed to send notification",
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("Notification sent successfully");
|
||||
toast({
|
||||
title: "Notification Sent",
|
||||
description: "Telegram notification sent successfully"
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error calling notification API:", error);
|
||||
toast({
|
||||
title: "API Error",
|
||||
description: `Failed to communicate with notification service: ${error instanceof Error ? error.message : "Network error"}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in sendTelegramNotification:", error);
|
||||
toast({
|
||||
title: "Notification Error",
|
||||
description: `Error sending Telegram notification: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
variant: "destructive"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test function to send a direct Telegram message
|
||||
* without requiring configuration from the database
|
||||
*/
|
||||
export async function testSendTelegramMessage(
|
||||
chatId: string = "-1002471970362",
|
||||
botToken: string = "7581526325:AAFZgmn9hzc3dpBWl9uLUhcqXRDx5D16e48",
|
||||
message: string = "This is a test message from the monitoring system"
|
||||
): Promise<boolean> {
|
||||
console.log("====== DIRECT TELEGRAM TEST ======");
|
||||
console.log(`Testing Telegram notification with chat ID: ${chatId}`);
|
||||
|
||||
// Create a minimal config with just the required fields
|
||||
const testConfig: AlertConfiguration = {
|
||||
service_id: "test",
|
||||
notification_type: "telegram",
|
||||
telegram_chat_id: chatId,
|
||||
bot_token: botToken,
|
||||
notify_name: "Test Direct Notification",
|
||||
enabled: true
|
||||
};
|
||||
|
||||
console.log("Sending test message with content:", message);
|
||||
return await sendTelegramNotification(testConfig, message);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
|
||||
import { NotificationTemplate } from "../templateService";
|
||||
|
||||
/**
|
||||
* Process a notification template with service data
|
||||
*/
|
||||
export function processTemplate(
|
||||
template: NotificationTemplate,
|
||||
service: any,
|
||||
status: string,
|
||||
responseTime?: number
|
||||
): string {
|
||||
try {
|
||||
console.log(`Processing template for status: ${status}`);
|
||||
|
||||
let templateText = "";
|
||||
|
||||
// Select the appropriate message template based on status
|
||||
if (status === "up") {
|
||||
templateText = template.up_message || `Service ${service.name} is now UP`;
|
||||
} else if (status === "down") {
|
||||
templateText = template.down_message || `Service ${service.name} is DOWN`;
|
||||
} else if (status === "warning") {
|
||||
templateText = template.incident_message || `Warning: Service ${service.name} has an incident`;
|
||||
} else if (status === "maintenance" || status === "paused") {
|
||||
templateText = template.maintenance_message || `Service ${service.name} is in maintenance mode`;
|
||||
} else if (status === "resolved") {
|
||||
templateText = template.resolved_message || `Issue with service ${service.name} has been resolved`;
|
||||
} else {
|
||||
templateText = `Service ${service.name} status changed to: ${status}`;
|
||||
}
|
||||
|
||||
// Skip replacement if template is empty
|
||||
if (!templateText) {
|
||||
console.log("Empty template for status:", status);
|
||||
return generateDefaultMessage(service.name, status, responseTime);
|
||||
}
|
||||
|
||||
console.log("Using template text:", templateText);
|
||||
|
||||
// Replace placeholders with actual values
|
||||
let message = templateText
|
||||
.replace(/\${service_name}/g, service.name || 'Unknown Service')
|
||||
.replace(/\${status}/g, status.toUpperCase());
|
||||
|
||||
// Add response time if available
|
||||
if (responseTime !== undefined) {
|
||||
message = message.replace(/\${response_time}/g, `${responseTime}ms`);
|
||||
} else {
|
||||
message = message.replace(/\${response_time}/g, 'N/A');
|
||||
}
|
||||
|
||||
// Replace any other placeholders
|
||||
message = message
|
||||
.replace(/\${threshold}/g, service.threshold || 'N/A')
|
||||
.replace(/\${url}/g, service.url || 'N/A')
|
||||
.replace(/\${time}/g, new Date().toLocaleString());
|
||||
|
||||
console.log("Processed template message:", message);
|
||||
return message;
|
||||
} catch (error) {
|
||||
console.error("Error processing template:", error);
|
||||
return generateDefaultMessage(service.name, status, responseTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a default message when no template is available
|
||||
*/
|
||||
export function generateDefaultMessage(
|
||||
serviceName: string,
|
||||
status: string,
|
||||
responseTime?: number
|
||||
): string {
|
||||
const statusText = status.toUpperCase();
|
||||
|
||||
let message = `Service ${serviceName || 'Unknown'} is ${statusText}`;
|
||||
|
||||
if (responseTime !== undefined) {
|
||||
message += `. Response time: ${responseTime}ms`;
|
||||
}
|
||||
|
||||
message += `. Time: ${new Date().toLocaleString()}`;
|
||||
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
import { Service } from "@/types/service.types";
|
||||
|
||||
export interface NotificationPayload {
|
||||
service: Service;
|
||||
status: string;
|
||||
responseTime?: number;
|
||||
timestamp: string;
|
||||
message?: string;
|
||||
_notificationSource?: string; // Internal flag to prevent duplicate notifications
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
|
||||
import { Service } from "@/types/service.types";
|
||||
import { processTemplate, generateDefaultMessage } from "./notification/templateProcessor";
|
||||
import { sendTelegramNotification, testSendTelegramMessage } from "./notification/telegramService";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { templateService } from "./templateService";
|
||||
import { AlertConfiguration } from "./alertConfigService";
|
||||
|
||||
interface NotificationData {
|
||||
service: Service;
|
||||
status: string;
|
||||
responseTime?: number;
|
||||
timestamp: string;
|
||||
_notificationSource?: string;
|
||||
}
|
||||
|
||||
// Track last notification times for services to implement cooldown
|
||||
const lastNotifications: Record<string, {
|
||||
timestamp: Date;
|
||||
count: number;
|
||||
}> = {};
|
||||
|
||||
// Cooldown period in milliseconds (5 minutes)
|
||||
const NOTIFICATION_COOLDOWN = 5 * 60 * 1000;
|
||||
|
||||
export const notificationService = {
|
||||
/**
|
||||
* Send a notification for a service status change
|
||||
*/
|
||||
async sendNotification(data: NotificationData): Promise<boolean> {
|
||||
try {
|
||||
const { service, status, responseTime } = data;
|
||||
|
||||
console.log(`Preparing to send notification for service: ${service.name}, status: ${status}`);
|
||||
console.log(`Service alerts status: ${service.alerts}`);
|
||||
|
||||
// First check if alerts are muted for this service
|
||||
// STRICT equality check against "muted" string value
|
||||
if (service.alerts === "muted") {
|
||||
console.log(`NOTIFICATION BLOCKED: Alerts are muted for service: ${service.name}`);
|
||||
return true; // Return true as this is expected behavior
|
||||
}
|
||||
|
||||
// For paused status, check if this is a duplicate notification from another source
|
||||
// This helps prevent the double-notification issue
|
||||
if (status === "paused" && data._notificationSource === "duplicate_check") {
|
||||
console.log("NOTIFICATION BLOCKED: Duplicate pause notification detected");
|
||||
return true; // Return true as this is expected behavior
|
||||
}
|
||||
|
||||
// For DOWN status, implement the cooldown and max retries logic
|
||||
if (status === "down") {
|
||||
const serviceId = service.id;
|
||||
const maxRetries = service.retries || 3; // Default to 3 if not specified
|
||||
const now = new Date();
|
||||
|
||||
if (lastNotifications[serviceId]) {
|
||||
const lastNotif = lastNotifications[serviceId];
|
||||
const timeSinceLastNotif = now.getTime() - lastNotif.timestamp.getTime();
|
||||
|
||||
// Check if we're within the cooldown period
|
||||
if (timeSinceLastNotif < NOTIFICATION_COOLDOWN) {
|
||||
// Increment count only if we haven't reached max retries
|
||||
if (lastNotif.count < maxRetries) {
|
||||
console.log(`DOWN notification for ${service.name}: ${lastNotif.count + 1}/${maxRetries}`);
|
||||
lastNotifications[serviceId].count += 1;
|
||||
} else {
|
||||
console.log(`DOWN notification for ${service.name} skipped: Max retries (${maxRetries}) reached. Next notification after cooldown.`);
|
||||
return true; // Skip notification but return success
|
||||
}
|
||||
} else {
|
||||
// Reset count after cooldown period
|
||||
console.log(`Cooldown period elapsed for ${service.name}. Resetting notification count.`);
|
||||
lastNotifications[serviceId] = { timestamp: now, count: 1 };
|
||||
}
|
||||
} else {
|
||||
// First notification for this service
|
||||
console.log(`First DOWN notification for ${service.name}: 1/${maxRetries}`);
|
||||
lastNotifications[serviceId] = { timestamp: now, count: 1 };
|
||||
}
|
||||
|
||||
// Update timestamp for the current notification
|
||||
lastNotifications[serviceId].timestamp = now;
|
||||
}
|
||||
|
||||
// Check if notification channel is set
|
||||
if (!service.notificationChannel) {
|
||||
console.log(`No notification channel set for service: ${service.name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch the notification configuration
|
||||
const alertConfigRecord = await pb.collection('alert_configurations').getOne(service.notificationChannel);
|
||||
|
||||
if (!alertConfigRecord) {
|
||||
console.error(`Alert configuration not found for ID: ${service.notificationChannel}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!alertConfigRecord.enabled) {
|
||||
console.log(`Alert configuration is disabled for service: ${service.name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert PocketBase record to AlertConfiguration
|
||||
const alertConfig: AlertConfiguration = {
|
||||
id: alertConfigRecord.id,
|
||||
collectionId: alertConfigRecord.collectionId,
|
||||
collectionName: alertConfigRecord.collectionName,
|
||||
service_id: alertConfigRecord.service_id || "",
|
||||
notification_type: alertConfigRecord.notification_type,
|
||||
telegram_chat_id: alertConfigRecord.telegram_chat_id,
|
||||
discord_webhook_url: alertConfigRecord.discord_webhook_url,
|
||||
signal_number: alertConfigRecord.signal_number,
|
||||
notify_name: alertConfigRecord.notify_name,
|
||||
bot_token: alertConfigRecord.bot_token,
|
||||
template_id: alertConfigRecord.template_id,
|
||||
slack_webhook_url: alertConfigRecord.slack_webhook_url,
|
||||
enabled: alertConfigRecord.enabled,
|
||||
created: alertConfigRecord.created,
|
||||
updated: alertConfigRecord.updated
|
||||
};
|
||||
|
||||
// Select template if one is specified
|
||||
let template;
|
||||
if (service.alertTemplate) {
|
||||
try {
|
||||
template = await templateService.getTemplate(service.alertTemplate);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching template for ID: ${service.alertTemplate}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the notification message
|
||||
let message;
|
||||
if (template) {
|
||||
message = processTemplate(template, service, status, responseTime);
|
||||
} else {
|
||||
message = generateDefaultMessage(service.name, status, responseTime);
|
||||
}
|
||||
|
||||
// For DOWN status, add retry information to the message
|
||||
if (status === "down" && lastNotifications[service.id]) {
|
||||
const retryInfo = lastNotifications[service.id];
|
||||
const maxRetries = service.retries || 3;
|
||||
message += `\n\nAlert ${retryInfo.count}/${maxRetries}`;
|
||||
}
|
||||
|
||||
console.log(`Prepared notification message: ${message}`);
|
||||
|
||||
// Send notification based on notification type
|
||||
const notificationType = alertConfig.notification_type;
|
||||
|
||||
if (notificationType === 'telegram') {
|
||||
return await sendTelegramNotification(alertConfig, message);
|
||||
}
|
||||
|
||||
// For other types like discord, slack, etc. (not implemented yet)
|
||||
console.log(`Notification type ${notificationType} not implemented yet`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error sending notification:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a test notification for a service status change
|
||||
*/
|
||||
async testServiceStatusNotification(serviceName: string, status: string, responseTime?: number): Promise<boolean> {
|
||||
try {
|
||||
const message = status === 'up'
|
||||
? `Service ${serviceName} is UP${responseTime ? ` (Response time: ${responseTime}ms)` : ''}`
|
||||
: `Service ${serviceName} is DOWN`;
|
||||
|
||||
console.log(`Test notification would have been sent: ${message}`);
|
||||
return true; // Just log, don't actually send
|
||||
} catch (error) {
|
||||
console.error("Error in test notification:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset notification count for a service
|
||||
* This can be called when a service recovers from DOWN to UP
|
||||
*/
|
||||
resetNotificationCount(serviceId: string): void {
|
||||
if (lastNotifications[serviceId]) {
|
||||
console.log(`Resetting notification count for service ${serviceId}`);
|
||||
delete lastNotifications[serviceId];
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { Service, CreateServiceParams, UptimeData } from '@/types/service.types';
|
||||
import { monitoringService } from './monitoring';
|
||||
import { uptimeService } from './uptimeService';
|
||||
|
||||
export type { Service, UptimeData, CreateServiceParams };
|
||||
|
||||
export const serviceService = {
|
||||
async getServices(): Promise<Service[]> {
|
||||
try {
|
||||
const response = await pb.collection('services').getList(1, 50, {
|
||||
sort: 'name',
|
||||
});
|
||||
|
||||
return response.items.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url: item.url || "", // Ensure proper URL mapping
|
||||
type: item.service_type || item.type || "HTTP", // Map service_type to type
|
||||
status: item.status || "paused",
|
||||
responseTime: item.response_time || item.responseTime || 0,
|
||||
uptime: item.uptime || 0,
|
||||
lastChecked: item.last_checked || item.lastChecked || new Date().toLocaleString(),
|
||||
interval: item.heartbeat_interval || item.interval || 60,
|
||||
retries: item.max_retries || item.retries || 3,
|
||||
notificationChannel: item.notification_id,
|
||||
alertTemplate: item.template_id,
|
||||
muteAlerts: item.alerts === "muted", // Convert string to boolean for compatibility
|
||||
alerts: item.alerts || "unmuted", // Store actual database field
|
||||
muteChangedAt: item.mute_changed_at,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching services:", error);
|
||||
throw new Error('Failed to load services data.');
|
||||
}
|
||||
},
|
||||
|
||||
async createService(params: CreateServiceParams): Promise<Service> {
|
||||
try {
|
||||
// Convert service type to lowercase to avoid validation issues
|
||||
const serviceType = params.type.toLowerCase();
|
||||
|
||||
// Debug log to check URL
|
||||
console.log("Creating service with URL:", params.url);
|
||||
|
||||
const data = {
|
||||
name: params.name,
|
||||
url: params.url, // Ensure URL is included
|
||||
service_type: serviceType, // Using lowercase value to avoid validation errors
|
||||
status: "up", // Changed from "active" to "up" to match the expected enum values
|
||||
response_time: 0,
|
||||
uptime: 0,
|
||||
last_checked: new Date().toLocaleString(),
|
||||
heartbeat_interval: params.interval,
|
||||
max_retries: params.retries,
|
||||
notification_id: params.notificationChannel,
|
||||
template_id: params.alertTemplate,
|
||||
};
|
||||
|
||||
console.log("Creating service with data:", data);
|
||||
const record = await pb.collection('services').create(data);
|
||||
console.log("Service created, returned record:", record);
|
||||
|
||||
// Return the newly created service
|
||||
const newService = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
url: record.url, // Include the URL in returned service
|
||||
type: record.service_type || "http",
|
||||
status: record.status || "up", // Changed to match the status we set
|
||||
responseTime: record.response_time || 0,
|
||||
uptime: record.uptime || 0,
|
||||
lastChecked: record.last_checked || new Date().toLocaleString(),
|
||||
interval: record.heartbeat_interval || 60,
|
||||
retries: record.max_retries || 3,
|
||||
notificationChannel: record.notification_id,
|
||||
alertTemplate: record.template_id,
|
||||
} as Service;
|
||||
|
||||
// Immediately start monitoring for the new service
|
||||
await monitoringService.startMonitoringService(record.id);
|
||||
|
||||
return newService;
|
||||
} catch (error) {
|
||||
console.error("Error creating service:", error);
|
||||
throw new Error('Failed to create service.');
|
||||
}
|
||||
},
|
||||
|
||||
async updateService(id: string, params: CreateServiceParams): Promise<Service> {
|
||||
try {
|
||||
// Convert service type to lowercase to avoid validation issues
|
||||
const serviceType = params.type.toLowerCase();
|
||||
|
||||
// Debug log to check URL
|
||||
console.log("Updating service with URL:", params.url);
|
||||
|
||||
const data = {
|
||||
name: params.name,
|
||||
url: params.url, // Ensure URL is included
|
||||
service_type: serviceType,
|
||||
heartbeat_interval: params.interval,
|
||||
max_retries: params.retries,
|
||||
notification_id: params.notificationChannel || null,
|
||||
template_id: params.alertTemplate || null,
|
||||
};
|
||||
|
||||
console.log("Updating service with data:", data);
|
||||
|
||||
// Use timeout to ensure the request doesn't hang
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Request timed out")), 10000);
|
||||
});
|
||||
|
||||
const updatePromise = pb.collection('services').update(id, data);
|
||||
const record = await Promise.race([updatePromise, timeoutPromise]) as any;
|
||||
console.log("Service updated, returned record:", record);
|
||||
|
||||
// Return the updated service
|
||||
const updatedService = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
url: record.url, // Include the URL in returned service
|
||||
type: record.service_type || "http",
|
||||
status: record.status,
|
||||
responseTime: record.response_time || 0,
|
||||
uptime: record.uptime || 0,
|
||||
lastChecked: record.last_checked || new Date().toLocaleString(),
|
||||
interval: record.heartbeat_interval || 60,
|
||||
retries: record.max_retries || 3,
|
||||
notificationChannel: record.notification_id,
|
||||
alertTemplate: record.template_id,
|
||||
} as Service;
|
||||
|
||||
return updatedService;
|
||||
} catch (error) {
|
||||
console.error("Error updating service:", error);
|
||||
throw new Error(`Failed to update service: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
},
|
||||
|
||||
// Control service monitoring
|
||||
startMonitoringService: monitoringService.startMonitoringService,
|
||||
pauseMonitoring: monitoringService.pauseMonitoring,
|
||||
resumeMonitoring: monitoringService.resumeMonitoring,
|
||||
checkHttpService: monitoringService.checkHttpService,
|
||||
startAllActiveServices: monitoringService.startAllActiveServices,
|
||||
|
||||
// Re-export uptime functions
|
||||
recordUptimeData: uptimeService.recordUptimeData,
|
||||
getUptimeHistory: uptimeService.getUptimeHistory
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
export interface GeneralSettings {
|
||||
id: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
system_name: string;
|
||||
system_name_kh?: string;
|
||||
logo_url?: string;
|
||||
system_description?: string;
|
||||
appearance?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
date_format?: string;
|
||||
time_format?: string;
|
||||
retention_days?: number;
|
||||
server_retention_days?: number;
|
||||
uptime_retention_days?: number;
|
||||
notification_email?: string;
|
||||
session_timeout?: number;
|
||||
enable_public_stats?: boolean;
|
||||
enable_email_notifications?: boolean;
|
||||
enable_sms_notifications?: boolean;
|
||||
enable_two_factor?: boolean;
|
||||
enable_audit_logs?: boolean;
|
||||
}
|
||||
|
||||
export const settingsService = {
|
||||
async getGeneralSettings(): Promise<GeneralSettings | null> {
|
||||
try {
|
||||
const result = await pb.collection('general_settings').getList(1, 1);
|
||||
if (result.items.length > 0) {
|
||||
// Type cast to GeneralSettings to resolve the type mismatch
|
||||
return result.items[0] as unknown as GeneralSettings;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch general settings:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async updateGeneralSettings(id: string, data: Partial<GeneralSettings>): Promise<GeneralSettings | null> {
|
||||
try {
|
||||
// Type cast to GeneralSettings to resolve the type mismatch
|
||||
return await pb.collection('general_settings').update(id, data) as unknown as GeneralSettings;
|
||||
} catch (error) {
|
||||
console.error("Failed to update general settings:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
export interface NotificationTemplate {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
name: string;
|
||||
up_message: string;
|
||||
down_message: string;
|
||||
service_name_placeholder: string;
|
||||
response_time_placeholder: string;
|
||||
status_placeholder: string;
|
||||
threshold_placeholder: string;
|
||||
type: string;
|
||||
maintenance_message: string;
|
||||
incident_message: string;
|
||||
resolved_message: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface CreateUpdateTemplateData {
|
||||
name: string;
|
||||
up_message: string;
|
||||
down_message: string;
|
||||
service_name_placeholder: string;
|
||||
response_time_placeholder: string;
|
||||
status_placeholder: string;
|
||||
threshold_placeholder: string;
|
||||
type: string;
|
||||
maintenance_message: string;
|
||||
incident_message: string;
|
||||
resolved_message: string;
|
||||
}
|
||||
|
||||
export const templateService = {
|
||||
async getTemplates(): Promise<NotificationTemplate[]> {
|
||||
try {
|
||||
console.log("Fetching notification templates");
|
||||
const response = await pb.collection('notification_templates').getList(1, 50, {
|
||||
sort: '-created',
|
||||
});
|
||||
console.log("Templates response:", response);
|
||||
return response.items as unknown as NotificationTemplate[];
|
||||
} catch (error) {
|
||||
console.error("Error fetching templates:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getTemplate(id: string): Promise<NotificationTemplate> {
|
||||
try {
|
||||
console.log(`Fetching template with id: ${id}`);
|
||||
const response = await pb.collection('notification_templates').getOne(id);
|
||||
console.log("Template response:", response);
|
||||
return response as unknown as NotificationTemplate;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching template with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async createTemplate(data: CreateUpdateTemplateData): Promise<NotificationTemplate> {
|
||||
try {
|
||||
console.log("Creating new template with data:", data);
|
||||
const response = await pb.collection('notification_templates').create(data);
|
||||
console.log("Create template response:", response);
|
||||
return response as unknown as NotificationTemplate;
|
||||
} catch (error) {
|
||||
console.error("Error creating template:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async updateTemplate(id: string, data: Partial<CreateUpdateTemplateData>): Promise<NotificationTemplate> {
|
||||
try {
|
||||
console.log(`Updating template with id: ${id}`, data);
|
||||
const response = await pb.collection('notification_templates').update(id, data);
|
||||
console.log("Update template response:", response);
|
||||
return response as unknown as NotificationTemplate;
|
||||
} catch (error) {
|
||||
console.error(`Error updating template with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTemplate(id: string): Promise<boolean> {
|
||||
try {
|
||||
console.log(`Deleting template with id: ${id}`);
|
||||
await pb.collection('notification_templates').delete(id);
|
||||
console.log("Template deleted successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting template with id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
|
||||
// Simple in-memory cache to avoid excessive requests
|
||||
const uptimeCache = new Map<string, {
|
||||
data: UptimeData[],
|
||||
timestamp: number,
|
||||
expiresIn: number
|
||||
}>();
|
||||
|
||||
// Cache time-to-live in milliseconds
|
||||
const CACHE_TTL = 15000; // 15 seconds
|
||||
|
||||
export const uptimeService = {
|
||||
async recordUptimeData(data: UptimeData): Promise<void> {
|
||||
try {
|
||||
console.log(`Recording uptime data for service ${data.serviceId}: Status ${data.status}, Response time: ${data.responseTime}ms`);
|
||||
|
||||
// Create a custom request options object to disable auto-cancellation
|
||||
const options = {
|
||||
$autoCancel: false, // Disable auto-cancellation for this request
|
||||
$cancelKey: `uptime_record_${data.serviceId}_${Date.now()}` // Unique key for this request
|
||||
};
|
||||
|
||||
// Store uptime history in the uptime_data collection
|
||||
// Format data for PocketBase (use snake_case)
|
||||
const record = await pb.collection('uptime_data').create({
|
||||
service_id: data.serviceId,
|
||||
timestamp: data.timestamp,
|
||||
status: data.status,
|
||||
response_time: data.responseTime
|
||||
}, options);
|
||||
|
||||
// Invalidate cache for this service after recording new data
|
||||
uptimeCache.delete(`uptime_${data.serviceId}`);
|
||||
|
||||
console.log(`Uptime data recorded successfully with ID: ${record.id}`);
|
||||
} catch (error) {
|
||||
console.error("Error recording uptime data:", error);
|
||||
throw new Error(`Failed to record uptime data: ${error}`);
|
||||
}
|
||||
},
|
||||
|
||||
async getUptimeHistory(
|
||||
serviceId: string,
|
||||
limit: number = 200,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<UptimeData[]> {
|
||||
try {
|
||||
// Create cache key based on parameters
|
||||
const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}`;
|
||||
|
||||
// Check if we have a valid cached result
|
||||
const cached = uptimeCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < cached.expiresIn) {
|
||||
console.log(`Using cached uptime history for service ${serviceId}`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
console.log(`Fetching uptime history for service ${serviceId}, limit: ${limit}`);
|
||||
|
||||
// Base filter for a specific service
|
||||
let filter = `service_id='${serviceId}'`;
|
||||
|
||||
// Add date range filtering if provided
|
||||
if (startDate && endDate) {
|
||||
// Convert to ISO strings for PocketBase filtering
|
||||
const startISO = startDate.toISOString();
|
||||
const endISO = endDate.toISOString();
|
||||
|
||||
// Log the date range we're filtering by
|
||||
console.log(`Date range filter: ${startISO} to ${endISO}`);
|
||||
|
||||
filter += ` && timestamp >= '${startISO}' && timestamp <= '${endISO}'`;
|
||||
}
|
||||
|
||||
// Calculate time difference to determine if it's a short range (like 60min)
|
||||
const isShortTimeRange = startDate && endDate &&
|
||||
(endDate.getTime() - startDate.getTime() <= 60 * 60 * 1000);
|
||||
|
||||
// For very short time ranges, adjust sorting and limit
|
||||
const sort = '-timestamp'; // Default: newest first
|
||||
const actualLimit = isShortTimeRange ? Math.max(limit, 300) : limit; // Ensure adequate points for short ranges
|
||||
|
||||
// Create custom request options to disable auto-cancellation
|
||||
const options = {
|
||||
filter: filter,
|
||||
sort: sort,
|
||||
$autoCancel: false, // Disable auto-cancellation for this request
|
||||
$cancelKey: `uptime_history_${serviceId}_${Date.now()}` // Unique key for this request
|
||||
};
|
||||
|
||||
try {
|
||||
// Get uptime history for a specific service with date filtering
|
||||
const response = await pb.collection('uptime_data').getList(1, actualLimit, options);
|
||||
|
||||
console.log(`Fetched ${response.items.length} uptime records for service ${serviceId}`);
|
||||
|
||||
// Map and return the data
|
||||
const uptimeData = response.items.map(item => ({
|
||||
id: item.id,
|
||||
serviceId: item.service_id,
|
||||
timestamp: item.timestamp,
|
||||
status: item.status,
|
||||
responseTime: item.response_time || 0,
|
||||
date: item.timestamp, // Mapping timestamp to date
|
||||
uptime: 100 // Default value for uptime
|
||||
}));
|
||||
|
||||
// For short time ranges with few data points, we might need to ensure we have enough points
|
||||
const shouldAddPlaceholderData = isShortTimeRange && uptimeData.length <= 2;
|
||||
|
||||
let finalData = uptimeData;
|
||||
|
||||
if (shouldAddPlaceholderData && uptimeData.length > 0) {
|
||||
// We'll add some additional data points to ensure graph is visible
|
||||
console.log("Adding placeholder data points to ensure graph visibility");
|
||||
finalData = [...uptimeData];
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
uptimeCache.set(cacheKey, {
|
||||
data: finalData,
|
||||
timestamp: Date.now(),
|
||||
expiresIn: CACHE_TTL
|
||||
});
|
||||
|
||||
return finalData;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching uptime history:", error);
|
||||
|
||||
// Try to return cached data even if it's expired, as a fallback
|
||||
const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}`;
|
||||
const cached = uptimeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Using expired cached data for service ${serviceId} due to fetch error`);
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
throw new Error('Failed to load uptime history.');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
username: string;
|
||||
email: string;
|
||||
emailVisibility: boolean;
|
||||
verified: boolean;
|
||||
full_name?: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserData {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
full_name?: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
isActive?: boolean;
|
||||
emailVisibility?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserData {
|
||||
username?: string;
|
||||
email?: string;
|
||||
emailVisibility?: boolean;
|
||||
full_name?: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export const userService = {
|
||||
async getUsers(): Promise<User[] | null> {
|
||||
try {
|
||||
console.log("Calling getUsers API");
|
||||
const result = await pb.collection('users').getList(1, 50, {
|
||||
sort: 'created',
|
||||
});
|
||||
|
||||
console.log("Users API response:", result);
|
||||
|
||||
if (result.items.length > 0) {
|
||||
return result.items as unknown as User[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getUser(id: string): Promise<User | null> {
|
||||
try {
|
||||
console.log(`Fetching user with ID: ${id}`);
|
||||
const result = await pb.collection('users').getOne(id);
|
||||
console.log("User fetch result:", result);
|
||||
return result as unknown as User;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch user ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async updateUser(id: string, data: UpdateUserData): Promise<User | null> {
|
||||
try {
|
||||
// Create a clean update object - remove undefined and empty string values
|
||||
const cleanData: Record<string, any> = {};
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
// Skip undefined values and empty strings for non-required fields
|
||||
if (value !== undefined && !(typeof value === 'string' && value.trim() === '')) {
|
||||
cleanData[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Updating user with clean data:", cleanData);
|
||||
|
||||
// If there's nothing to update, return the current user
|
||||
if (Object.keys(cleanData).length === 0) {
|
||||
console.log("No changes to update");
|
||||
const currentUser = await this.getUser(id);
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
// Important fix: Don't send avatar field if it's a local path
|
||||
// PocketBase API doesn't accept local file paths as valid files
|
||||
if (cleanData.avatar && typeof cleanData.avatar === 'string' && cleanData.avatar.startsWith('/upload/profile/')) {
|
||||
console.log("Removing local avatar path from update data");
|
||||
delete cleanData.avatar;
|
||||
}
|
||||
|
||||
// Use the PocketBase API to update the user
|
||||
const result = await pb.collection('users').update(id, cleanData);
|
||||
console.log("Update result:", result);
|
||||
return result as unknown as User;
|
||||
} catch (error) {
|
||||
console.error("Failed to update user:", error);
|
||||
throw error; // Re-throw to handle in the component
|
||||
}
|
||||
},
|
||||
|
||||
async deleteUser(id: string): Promise<boolean> {
|
||||
try {
|
||||
await pb.collection('users').delete(id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete user:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async createUser(data: CreateUserData): Promise<User | null> {
|
||||
try {
|
||||
// Handle the avatar field for static files
|
||||
if (data.avatar && typeof data.avatar === 'string' && data.avatar.startsWith('/upload/profile/')) {
|
||||
// Remove avatar field for new users if it's a local path
|
||||
// PocketBase requires actual file uploads, not paths
|
||||
console.log("Removing local avatar path for new user");
|
||||
delete data.avatar;
|
||||
}
|
||||
|
||||
const result = await pb.collection('users').create(data);
|
||||
return result as unknown as User;
|
||||
} catch (error) {
|
||||
console.error("Failed to create user:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user