diff --git a/README.md b/README.md index 3db7fe4..a39ee3c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ CheckCle is an Open Source solution for seamless, real-time monitoring of full-s ## #️⃣ Getting Started +### Current Architecture Support +* ✅ x86_64 PCs, laptops, servers (amd64) +* ✅ Modern Raspberry Pi 3/4/5 with (64-bit OS), Apple Silicon Macs (arm64) + ### Installation with Docker Run and Compose 1. Copy ready docker run command ```bash @@ -71,24 +75,25 @@ services: ## 📝 Development Roadmap -- [x] Health check & uptime monitoring (HTTP) -- [x] Dashboard UI with live stats -- [x] Auth with Multi-users system (admin) -- [x] Notifications (Telegram) -- [x] Docker containerization -- [x] CheckCle Website -- [x] CheckCle Demo Server -- [x] SSL & Domain Monitoring -- [x] Schedule Maintenance -- [x] Incident Management +- ✅ Health check & uptime monitoring (HTTP) +- ✅ Dashboard UI with live stats +- ✅ Auth with Multi-users system (admin) +- ✅ Notifications (Telegram) +- ✅ Docker containerization +- ✅ CheckCle Website +- ✅ CheckCle Demo Server +- ✅ SSL & Domain Monitoring +- ✅ Schedule Maintenance +- ✅ Incident Management - [ ] Uptime monitoring (PING - Inprogress) - [ ] Infrastructure Server Monitoring - [ ] Operational Status / Public Status Pages - [ ] Uptime monitoring (TCP, PING, DNS) -- [x] System Setting Panel and Mail Settings -- [x] User Permission Roles +- ✅ System Setting Panel and Mail Settings +- ✅ User Permission Roles - [ ] Notifications (Email/Slack/Discord/Signal) -- [x] Open-source release with full documentation +- ✅ Data Retention & Automate Strink (Muti Options to Shrink Data & Database ) +- ✅ Open-source release with full documentation ## 🌟 CheckCle for Communities? - **Built with Passion**: Created by an open-source enthusiast for the community diff --git a/application/src/components/dashboard/Sidebar.tsx b/application/src/components/dashboard/Sidebar.tsx index 6fe582c..24b4c6d 100644 --- a/application/src/components/dashboard/Sidebar.tsx +++ b/application/src/components/dashboard/Sidebar.tsx @@ -117,10 +117,10 @@ export const Sidebar = ({ {t("alertsTemplates")} -
+ handleSettingsItemClick('data-retention')}> {t("dataRetention")} -
+ handleSettingsItemClick('about')}> {t("aboutSystem")} diff --git a/application/src/components/profile/UpdateProfileForm.tsx b/application/src/components/profile/UpdateProfileForm.tsx index ac601ec..05ef7ff 100644 --- a/application/src/components/profile/UpdateProfileForm.tsx +++ b/application/src/components/profile/UpdateProfileForm.tsx @@ -11,6 +11,7 @@ import { useToast } from "@/hooks/use-toast"; import { authService } from "@/services/authService"; import { AlertCircle, CheckCircle } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { useNavigate } from "react-router-dom"; // Profile update form schema const profileFormSchema = z.object({ @@ -33,10 +34,10 @@ interface UpdateProfileFormProps { export function UpdateProfileForm({ user }: UpdateProfileFormProps) { const [isSubmitting, setIsSubmitting] = useState(false); - const [emailChangeRequested, setEmailChangeRequested] = useState(false); const [updateError, setUpdateError] = useState(null); const [updateSuccess, setUpdateSuccess] = useState(null); const { toast } = useToast(); + const navigate = useNavigate(); // Initialize the form with current user data const form = useForm({ @@ -52,7 +53,6 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) { setIsSubmitting(true); setUpdateError(null); setUpdateSuccess(null); - setEmailChangeRequested(false); try { console.log("Submitting profile update with data:", data); @@ -75,19 +75,25 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) { // Update user data using the userService await userService.updateUser(user.id, updateData); - // Refresh user data in auth context - await authService.refreshUserData(); - - // If email was changed, show a specific message + // If email was changed, show success message and auto-logout if (isEmailChanged) { - setEmailChangeRequested(true); - setUpdateSuccess("A verification email has been sent to your new email address. Please check your inbox to complete the change."); + setUpdateSuccess("Email changed successfully! You will be logged out for security reasons. Please log in again with your new email."); + toast({ - title: "Email verification sent", - description: "A verification email has been sent to your new email address. Please check your inbox and follow the instructions to complete the change.", + title: "Email changed successfully", + description: "You will be logged out for security reasons. Please log in again with your new email.", variant: "default", }); + + // Auto-logout after 3 seconds + setTimeout(() => { + authService.logout(); + navigate("/login"); + }, 3000); } else { + // Refresh user data in auth context for other field changes + await authService.refreshUserData(); + setUpdateSuccess("Your profile information has been updated successfully."); toast({ title: "Profile updated", @@ -132,16 +138,6 @@ export function UpdateProfileForm({ user }: UpdateProfileFormProps) { )} - - {emailChangeRequested && ( - - - - A verification email has been sent to your new email address. - Please check your inbox and follow the instructions to complete the change. - - - )} {field.value !== user.email && (

- Changing your email requires verification. A verification email will be sent. + Changing your email will log you out for security reasons. You will need to log in again with your new email.

)} diff --git a/application/src/components/services/DateRangeFilter.tsx b/application/src/components/services/DateRangeFilter.tsx index 51ddcf9..1e1a736 100644 --- a/application/src/components/services/DateRangeFilter.tsx +++ b/application/src/components/services/DateRangeFilter.tsx @@ -14,18 +14,17 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { format, subDays, subHours, subMonths, subWeeks, subYears } from "date-fns"; +import { format } from "date-fns"; import { Calendar as CalendarIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -export type DateRangeOption = '60min' | '24h' | '7d' | '30d' | '1y' | 'custom'; +export type DateRangeOption = '24h' | '7d' | '30d' | '1y' | 'custom'; interface DateRangeFilterProps { onRangeChange: (startDate: Date, endDate: Date, option: DateRangeOption) => void; selectedOption?: DateRangeOption; } -// Define a proper type for the date range interface DateRange { from: Date | undefined; to: Date | undefined; @@ -45,57 +44,48 @@ export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateR const now = new Date(); let startDate: Date; + let endDate: Date = new Date(now.getTime() + (5 * 60 * 1000)); // Add 5 minutes buffer to future switch (option) { - case '60min': - // Ensure we're getting exactly 60 minutes ago - startDate = new Date(now.getTime() - 60 * 60 * 1000); - console.log(`60min option selected: ${startDate.toISOString()} to ${now.toISOString()}`); - break; case '24h': - startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); + startDate = new Date(now.getTime() - (24 * 60 * 60 * 1000)); break; case '7d': - startDate = subDays(now, 7); + startDate = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000)); break; case '30d': - startDate = subDays(now, 30); + startDate = new Date(now.getTime() - (30 * 24 * 60 * 60 * 1000)); break; case '1y': - startDate = subYears(now, 1); + startDate = new Date(now.getTime() - (365 * 24 * 60 * 60 * 1000)); break; case 'custom': - // Don't trigger onRangeChange for custom until both dates are selected setIsCalendarOpen(true); return; default: - startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); + startDate = new Date(now.getTime() - (24 * 60 * 60 * 1000)); // Default to 24 hours } - console.log(`DateRangeFilter: Option changed to ${option}, date range: ${startDate.toISOString()} to ${now.toISOString()}`); - onRangeChange(startDate, now, option); + console.log(`DateRangeFilter: ${option} selected, range: ${startDate.toISOString()} to ${endDate.toISOString()}`); + onRangeChange(startDate, endDate, option); }; - // Handle custom date range selection const handleCustomRangeSelect = (range: DateRange | undefined) => { - if (!range) { + if (!range || !range.from || !range.to) { return; } setCustomDateRange(range); - if (range.from && range.to) { - // Ensure that we have both from and to dates before triggering the change - const startOfDay = new Date(range.from); - startOfDay.setHours(0, 0, 0, 0); - - const endOfDay = new Date(range.to); - endOfDay.setHours(23, 59, 59, 999); - - console.log(`DateRangeFilter: Custom range selected: ${startOfDay.toISOString()} to ${endOfDay.toISOString()}`); - onRangeChange(startOfDay, endOfDay, 'custom'); - setIsCalendarOpen(false); - } + const startOfDay = new Date(range.from); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(range.to); + endOfDay.setHours(23, 59, 59, 999); + + console.log(`Custom range: ${startOfDay.toISOString()} to ${endOfDay.toISOString()}`); + onRangeChange(startOfDay, endOfDay, 'custom'); + setIsCalendarOpen(false); }; return ( @@ -105,7 +95,6 @@ export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateR - Last 60 minutes Last 24 hours Last 7 days Last 30 days @@ -153,4 +142,4 @@ export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateR )} ); -} +} \ No newline at end of file diff --git a/application/src/components/services/ServiceDetailContainer/hooks/useServiceData.tsx b/application/src/components/services/ServiceDetailContainer/hooks/useServiceData.tsx index b344764..32db1f8 100644 --- a/application/src/components/services/ServiceDetailContainer/hooks/useServiceData.tsx +++ b/application/src/components/services/ServiceDetailContainer/hooks/useServiceData.tsx @@ -5,6 +5,7 @@ import { Service, UptimeData } from "@/types/service.types"; import { useToast } from "@/hooks/use-toast"; import { useNavigate } from "react-router-dom"; import { uptimeService } from "@/services/uptimeService"; +import { DateRangeOption } from "../../DateRangeFilter"; export const useServiceData = (serviceId: string | undefined, startDate: Date, endDate: Date) => { const [service, setService] = useState(null); @@ -13,15 +14,12 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e const { toast } = useToast(); const navigate = useNavigate(); - // Handler for service status changes const handleStatusChange = async (newStatus: "up" | "down" | "paused" | "warning") => { if (!service || !serviceId) return; try { - // Optimistic UI update setService({ ...service, status: newStatus as Service["status"] }); - // Update the service status in PocketBase await pb.collection('services').update(serviceId, { status: newStatus }); @@ -32,7 +30,6 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e }); } catch (error) { console.error("Failed to update service status:", error); - // Revert the optimistic update setService(prevService => prevService); toast({ @@ -43,51 +40,30 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e } }; - // Function to fetch uptime data with date filters - const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange: string) => { + const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange?: DateRangeOption | string) => { try { - console.log(`Fetching uptime data from ${start.toISOString()} to ${end.toISOString()}`); + console.log(`Fetching uptime data: ${start.toISOString()} to ${end.toISOString()} for range: ${selectedRange}`); - // Set appropriate limits based on time range to ensure enough granularity - let limit = 200; // default + let limit = 500; // Default limit - // Adjust limits based on selected range - if (selectedRange === '60min') { - limit = 300; // More points for shorter time ranges - } else if (selectedRange === '24h') { - limit = 200; + if (selectedRange === '24h') { + limit = 300; } else if (selectedRange === '7d') { - limit = 250; - } else if (selectedRange === '30d' || selectedRange === '1y') { - limit = 300; // More points for longer time ranges + limit = 400; } console.log(`Using limit ${limit} for range ${selectedRange}`); const history = await uptimeService.getUptimeHistory(serviceId, limit, start, end); - console.log(`Fetched ${history.length} uptime records for time range ${selectedRange}`); + console.log(`Retrieved ${history.length} uptime records`); - if (history.length === 0) { - console.log("No data returned from API, checking if we need to fetch with a higher limit"); - // If no data, try with a higher limit as fallback - if (limit < 500) { - const extendedHistory = await uptimeService.getUptimeHistory(serviceId, 500, start, end); - console.log(`Fallback: Fetched ${extendedHistory.length} uptime records with higher limit`); - - if (extendedHistory.length > 0) { - setUptimeData(extendedHistory); - return extendedHistory; - } - } - } - - // Sort data by timestamp (newest first) - const sortedHistory = [...history].sort((a, b) => + // Sort by timestamp (newest first) + const filteredHistory = [...history].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ); - setUptimeData(sortedHistory); - return sortedHistory; + setUptimeData(filteredHistory); + return filteredHistory; } catch (error) { console.error("Error fetching uptime data:", error); toast({ @@ -110,7 +86,6 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e setIsLoading(true); - // Add a timeout to prevent hanging const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error("Request timed out")), 10000); }); @@ -136,7 +111,7 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e setService(formattedService); - // Fetch uptime history with date range + // Fetch initial uptime history with 24h default await fetchUptimeData(serviceId, startDate, endDate, '24h'); } catch (error) { console.error("Error fetching service:", error); @@ -156,10 +131,11 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e // Update data when date range changes useEffect(() => { - if (serviceId && !isLoading) { - fetchUptimeData(serviceId, startDate, endDate, '24h'); + if (serviceId && !isLoading && service) { + console.log(`Date range changed, refetching data for ${serviceId}: ${startDate.toISOString()} to ${endDate.toISOString()}`); + fetchUptimeData(serviceId, startDate, endDate); } - }, [startDate, endDate]); + }, [startDate, endDate, serviceId, isLoading, service]); return { service, @@ -170,4 +146,4 @@ export const useServiceData = (serviceId: string | undefined, startDate: Date, e handleStatusChange, fetchUptimeData }; -}; +}; \ No newline at end of file diff --git a/application/src/components/services/ServiceDetailContainer/index.tsx b/application/src/components/services/ServiceDetailContainer/index.tsx index 2bd7d2c..e23e054 100644 --- a/application/src/components/services/ServiceDetailContainer/index.tsx +++ b/application/src/components/services/ServiceDetailContainer/index.tsx @@ -1,4 +1,3 @@ - import { useState, useEffect, useCallback } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { DateRangeOption } from "../DateRangeFilter"; @@ -12,13 +11,17 @@ export const ServiceDetailContainer = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - // Ensure we use exact timestamp for startDate + // Set default to 24h const [startDate, setStartDate] = useState(() => { const date = new Date(); - date.setHours(date.getHours() - 24); + date.setHours(date.getHours() - 24); // Go back 24 hours + return date; + }); + const [endDate, setEndDate] = useState(() => { + const date = new Date(); + date.setMinutes(date.getMinutes() + 5); // Add 5 minutes buffer to future return date; }); - const [endDate, setEndDate] = useState(new Date()); const [selectedRange, setSelectedRange] = useState('24h'); // State for sidebar collapse functionality (shared with Dashboard) @@ -91,14 +94,16 @@ export const ServiceDetailContainer = () => { // Handle date range filter changes const handleDateRangeChange = useCallback((start: Date, end: Date, option: DateRangeOption) => { - console.log(`Date range changed: ${start.toISOString()} to ${end.toISOString()}, option: ${option}`); + console.log(`ServiceDetailContainer: Date range changed: ${start.toISOString()} to ${end.toISOString()}, option: ${option}`); + // Update state which will trigger the useEffect in useServiceData setStartDate(start); setEndDate(end); setSelectedRange(option); - // Refetch uptime data with new date range, passing the selected range option + // Also explicitly fetch data with the new range to ensure immediate update if (id) { + console.log(`ServiceDetailContainer: Explicitly fetching data for service ${id} with new range`); fetchUptimeData(id, start, end, option); } }, [id, fetchUptimeData]); @@ -123,4 +128,4 @@ export const ServiceDetailContainer = () => { )} ); -}; +}; \ No newline at end of file diff --git a/application/src/components/services/ServiceRow.tsx b/application/src/components/services/ServiceRow.tsx index 313f79e..fd9324c 100644 --- a/application/src/components/services/ServiceRow.tsx +++ b/application/src/components/services/ServiceRow.tsx @@ -56,7 +56,12 @@ export const ServiceRow = ({ - + { +export const UptimeBar = ({ uptime, status, serviceId, interval = 60 }: UptimeBarProps) => { const { theme } = useTheme(); const [historyItems, setHistoryItems] = useState([]); // Fetch real uptime history data if serviceId is provided with improved caching and error handling const { data: uptimeData, isLoading, error, isFetching, refetch } = useQuery({ queryKey: ['uptimeHistory', serviceId], - queryFn: () => serviceId ? uptimeService.getUptimeHistory(serviceId, 20) : Promise.resolve([]), + queryFn: () => serviceId ? uptimeService.getUptimeHistory(serviceId, 50) : Promise.resolve([]), enabled: !!serviceId, refetchInterval: 30000, // Refresh every 30 seconds staleTime: 15000, // Consider data fresh for 15 seconds @@ -36,10 +36,45 @@ export const UptimeBar = ({ uptime, status, serviceId }: UptimeBarProps) => { retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff with max 10s }); + // Filter uptime data to respect the service interval + const filterUptimeDataByInterval = (data: UptimeData[], intervalSeconds: number): UptimeData[] => { + if (!data || data.length === 0) return []; + + // 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 + + // Include the most recent record first + if (sortedData.length > 0) { + filtered.push(sortedData[0]); + lastIncludedTime = new Date(sortedData[0].timestamp).getTime(); + } + + // 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; + }; + // Update history items when data changes useEffect(() => { if (uptimeData && uptimeData.length > 0) { - setHistoryItems(uptimeData); + // 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 const statusValue = (status === "up" || status === "down" || status === "warning" || status === "paused") @@ -49,13 +84,13 @@ export const UptimeBar = ({ uptime, status, serviceId }: UptimeBarProps) => { const placeholderHistory: UptimeData[] = Array(20).fill(null).map((_, index) => ({ id: `placeholder-${index}`, serviceId: serviceId || "", - timestamp: new Date().toISOString(), + timestamp: new Date(Date.now() - (index * interval * 1000)).toISOString(), status: statusValue as "up" | "down" | "warning" | "paused", responseTime: 0 })); setHistoryItems(placeholderHistory); } - }, [uptimeData, serviceId, status]); + }, [uptimeData, serviceId, status, interval]); // Get appropriate color classes for each status type const getStatusColor = (itemStatus: string) => { @@ -153,13 +188,19 @@ export const UptimeBar = ({ uptime, status, serviceId }: UptimeBarProps) => { (status === "up" || status === "down" || status === "warning" || status === "paused") ? status as "up" | "down" | "warning" | "paused" : "paused"; - const paddingItems: UptimeData[] = Array(20 - displayItems.length).fill(null).map((_, index) => ({ - id: `padding-${index}`, - serviceId: serviceId || "", - timestamp: new Date().toISOString(), - status: lastStatus, - responseTime: 0 - })); + // Generate padding items with proper time spacing + const paddingItems: UptimeData[] = Array(20 - displayItems.length).fill(null).map((_, index) => { + const baseTime = lastItem ? new Date(lastItem.timestamp).getTime() : Date.now(); + const timeOffset = (index + 1) * interval * 1000; // Respect the interval + + return { + id: `padding-${index}`, + serviceId: serviceId || "", + timestamp: new Date(baseTime - timeOffset).toISOString(), + status: lastStatus, + responseTime: 0 + }; + }); displayItems.push(...paddingItems); } @@ -201,10 +242,10 @@ export const UptimeBar = ({ uptime, status, serviceId }: UptimeBarProps) => { {Math.round(uptime)}% uptime - Last 20 checks + Last 20 checks ); -} +} \ No newline at end of file diff --git a/application/src/components/settings/about-system/AboutSystem.tsx b/application/src/components/settings/about-system/AboutSystem.tsx index 9509d2e..ae2ce8d 100644 --- a/application/src/components/settings/about-system/AboutSystem.tsx +++ b/application/src/components/settings/about-system/AboutSystem.tsx @@ -1,30 +1,51 @@ - -import React from 'react'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import React, { useEffect, useState } from 'react'; +import { format } from 'date-fns'; +import { + Card, CardContent, CardDescription, CardHeader, CardTitle, +} from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { Github, FileText, Twitter, MessageCircle, Code2, ServerIcon } from "lucide-react"; +import { + Github, FileText, Twitter, MessageCircle, Code2, ServerIcon, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { useLanguage } from "@/contexts/LanguageContext"; import { useTheme } from "@/contexts/ThemeContext"; import { useSystemSettings } from "@/hooks/useSystemSettings"; + export const AboutSystem: React.FC = () => { - const { - t - } = useLanguage(); - const { - theme - } = useTheme(); - const { - systemName - } = useSystemSettings(); - return
+ const { t } = useLanguage(); + const { theme } = useTheme(); + const { systemName } = useSystemSettings(); + + const [version, setVersion] = useState('...'); + const [releaseDate, setReleaseDate] = useState('...'); + + useEffect(() => { + const fetchLatestRelease = async () => { + try { + const res = await fetch('https://api.github.com/repos/operacle/checkcle/releases/latest'); + const data = await res.json(); + setVersion(data.tag_name || 'v1.x.x'); + setReleaseDate(data.published_at ? format(new Date(data.published_at), 'MMMM d, yyyy') : t('unknown')); + } catch (err) { + setVersion('v1.x.x'); + setReleaseDate(t('unknown')); + } + }; + fetchLatestRelease(); + }, [t]); + + return ( +

{t('aboutSystem')}

-

{t('aboutCheckcle')}

+

+ {t('aboutCheckcle')} +

- + - +
@@ -38,7 +59,7 @@ export const AboutSystem: React.FC = () => {
{t('systemVersion')} - {t('version')} 1.1.0 + {version}
@@ -48,20 +69,22 @@ export const AboutSystem: React.FC = () => {
{t('releasedOn')} - May 16, 2025 + {releaseDate}
- + {t('links')} - {systemName || 'ReamStack'} {t('resources').toLowerCase()} + + {systemName || 'ReamStack'} {t('resources').toLowerCase()} +
@@ -85,6 +108,8 @@ export const AboutSystem: React.FC = () => {
-
; +
+ ); }; -export default AboutSystem; \ No newline at end of file + +export default AboutSystem; diff --git a/application/src/components/settings/data-retention/DataRetentionSettings.tsx b/application/src/components/settings/data-retention/DataRetentionSettings.tsx new file mode 100644 index 0000000..15c1d58 --- /dev/null +++ b/application/src/components/settings/data-retention/DataRetentionSettings.tsx @@ -0,0 +1,316 @@ + +import React, { useState, useEffect } from 'react'; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, Database, Trash2, AlertTriangle, Globe, Server } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { useLanguage } from "@/contexts/LanguageContext"; +import { authService } from "@/services/authService"; +import { dataRetentionService } from "@/services/dataRetentionService"; + +interface RetentionSettings { + uptimeRetentionDays: number; + serverRetentionDays: number; +} + +const DataRetentionSettings = () => { + const { t } = useLanguage(); + const { toast } = useToast(); + const [settings, setSettings] = useState({ + uptimeRetentionDays: 30, + serverRetentionDays: 30 + }); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isUptimeShrinking, setIsUptimeShrinking] = useState(false); + const [isServerShrinking, setIsServerShrinking] = useState(false); + const [isFullShrinking, setIsFullShrinking] = useState(false); + const [lastCleanup, setLastCleanup] = useState(null); + + // Check if user is super admin + const currentUser = authService.getCurrentUser(); + const isSuperAdmin = currentUser?.role === "superadmin"; + + useEffect(() => { + if (isSuperAdmin) { + loadSettings(); + } + }, [isSuperAdmin]); + + const loadSettings = async () => { + try { + setIsLoading(true); + const result = await dataRetentionService.getRetentionSettings(); + if (result) { + setSettings({ + uptimeRetentionDays: result.uptimeRetentionDays || 30, + serverRetentionDays: result.serverRetentionDays || 30 + }); + setLastCleanup(result.lastCleanup); + } + } catch (error) { + console.error("Error loading retention settings:", error); + toast({ + title: "Error", + description: "Failed to load retention settings", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const handleSave = async () => { + try { + setIsSaving(true); + await dataRetentionService.updateRetentionSettings(settings); + toast({ + title: "Settings saved", + description: "Data retention settings have been updated", + }); + } catch (error) { + console.error("Error saving retention settings:", error); + toast({ + title: "Error", + description: "Failed to save retention settings", + variant: "destructive", + }); + } finally { + setIsSaving(false); + } + }; + + const handleUptimeShrink = async () => { + try { + setIsUptimeShrinking(true); + const result = await dataRetentionService.manualUptimeCleanup(); + + toast({ + title: "Uptime cleanup completed", + description: `Deleted ${result.deletedRecords} old uptime records`, + }); + + // Reload settings to get updated last cleanup time + await loadSettings(); + } catch (error) { + console.error("Error during uptime cleanup:", error); + toast({ + title: "Error", + description: "Failed to perform uptime data cleanup", + variant: "destructive", + }); + } finally { + setIsUptimeShrinking(false); + } + }; + + const handleServerShrink = async () => { + try { + setIsServerShrinking(true); + const result = await dataRetentionService.manualServerCleanup(); + + toast({ + title: "Server cleanup completed", + description: `Deleted ${result.deletedRecords} old server records`, + }); + + // Reload settings to get updated last cleanup time + await loadSettings(); + } catch (error) { + console.error("Error during server cleanup:", error); + toast({ + title: "Error", + description: "Failed to perform server data cleanup", + variant: "destructive", + }); + } finally { + setIsServerShrinking(false); + } + }; + + const handleFullShrink = async () => { + try { + setIsFullShrinking(true); + const result = await dataRetentionService.manualCleanup(); + + toast({ + title: "Database cleanup completed", + description: `Deleted ${result.deletedRecords} old records`, + }); + + // Reload settings to get updated last cleanup time + await loadSettings(); + } catch (error) { + console.error("Error during manual cleanup:", error); + toast({ + title: "Error", + description: "Failed to perform database cleanup", + variant: "destructive", + }); + } finally { + setIsFullShrinking(false); + } + }; + + // Show permission notice for admin users + if (!isSuperAdmin) { + return ( +
+ + + + + {t("dataRetention", "settings")} + + + + + + + Permission Notice: As an admin user, you do not have access to data retention settings. These settings can only be accessed and modified by Super Admins. + + + + +
+ ); + } + + if (isLoading) { + return ( +
+ + Loading retention settings... +
+ ); + } + + return ( +
+ + + + + Data Retention Settings + + + Configure how long monitoring data is kept in the system + + + +
+
+ + setSettings(prev => ({ + ...prev, + uptimeRetentionDays: parseInt(e.target.value) || 30 + }))} + className="mt-1" + /> +

+ Service uptime and incident data older than this will be automatically deleted +

+
+ +
+ + setSettings(prev => ({ + ...prev, + serverRetentionDays: parseInt(e.target.value) || 30 + }))} + className="mt-1" + /> +

+ Server metrics and process data older than this will be automatically deleted +

+
+
+ + {lastCleanup && ( + + + + Last automatic cleanup: {new Date(lastCleanup).toLocaleString()} + + + )} +
+ +
+ + + + + +
+ +
+ +
+
+
+
+ ); +}; + +export default DataRetentionSettings; \ No newline at end of file diff --git a/application/src/components/settings/data-retention/index.ts b/application/src/components/settings/data-retention/index.ts new file mode 100644 index 0000000..bc25d28 --- /dev/null +++ b/application/src/components/settings/data-retention/index.ts @@ -0,0 +1,5 @@ + +import DataRetentionSettings from './DataRetentionSettings'; + +export default DataRetentionSettings; +export { DataRetentionSettings }; \ No newline at end of file diff --git a/application/src/components/settings/user-management/hooks/useUserOperations.ts b/application/src/components/settings/user-management/hooks/useUserOperations.ts index 33537b5..1882fb1 100644 --- a/application/src/components/settings/user-management/hooks/useUserOperations.ts +++ b/application/src/components/settings/user-management/hooks/useUserOperations.ts @@ -4,6 +4,8 @@ import { useToast } from "@/hooks/use-toast"; import { userService, User, UpdateUserData, CreateUserData } from "@/services/userService"; import { UserFormValues, NewUserFormValues } from "../userForms"; import { avatarOptions } from "../avatarOptions"; +import { authService } from "@/services/authService"; +import { useNavigate } from "react-router-dom"; export const useUserOperations = ( fetchUsers: () => Promise, @@ -15,6 +17,7 @@ export const useUserOperations = ( newUserFormReset: (values: any) => void ) => { const { toast } = useToast(); + const navigate = useNavigate(); const handleDeleteUser = async (userToDelete: User | null) => { if (!userToDelete) return; @@ -47,6 +50,11 @@ export const useUserOperations = ( setUpdateError(null); try { + // Get current logged-in user to check if we're editing ourselves + const loggedInUser = authService.getCurrentUser(); + const isEditingSelf = loggedInUser?.id === currentUser.id; + const isEmailChanged = data.email !== currentUser.email; + // Create update object with only the fields we want to update const updateData: UpdateUserData = { full_name: data.full_name, @@ -57,7 +65,6 @@ export const useUserOperations = ( }; // For avatar, only include if it's different from current one - // Note: We're still sending the avatar path, but our updated userService will handle it properly if (data.avatar && data.avatar !== currentUser.avatar) { updateData.avatar = data.avatar; } @@ -66,9 +73,26 @@ export const useUserOperations = ( await userService.updateUser(currentUser.id, updateData); - // After successful update, refresh the auth user data if this is the current user - // In a real app, you'd check if the updated user is the current logged-in user + // Handle email change for current user + if (isEditingSelf && isEmailChanged) { + toast({ + title: "Email changed successfully", + description: "You will be logged out for security reasons. Please log in again with your new email.", + variant: "default", + }); + + setIsDialogOpen(false); + + // Auto-logout after 2 seconds if editing own email + setTimeout(() => { + authService.logout(); + navigate("/login"); + }, 2000); + + return; // Don't continue with normal flow + } + // Normal success flow for other users or non-email changes toast({ title: "User updated", description: `${data.full_name || data.username}'s profile has been updated.`, @@ -155,4 +179,4 @@ export const useUserOperations = ( onSubmit, onAddUser, }; -}; +}; \ No newline at end of file diff --git a/application/src/pages/Settings.tsx b/application/src/pages/Settings.tsx index 5c23f96..6dd3516 100644 --- a/application/src/pages/Settings.tsx +++ b/application/src/pages/Settings.tsx @@ -9,6 +9,7 @@ import UserManagement from "@/components/settings/user-management"; import { NotificationSettings } from "@/components/settings/notification-settings"; import { AlertsTemplates } from "@/components/settings/alerts-templates"; import { AboutSystem } from "@/components/settings/about-system"; +import DataRetentionSettings from "@/components/settings/data-retention/DataRetentionSettings"; const Settings = () => { // State for sidebar collapse functionality @@ -58,6 +59,7 @@ const Settings = () => { {activePanel === "users" && } {activePanel === "notifications" && } {activePanel === "templates" && } + {activePanel === "data-retention" && } {activePanel === "about" && }
@@ -65,4 +67,4 @@ const Settings = () => { ); }; -export default Settings; +export default Settings; \ No newline at end of file diff --git a/application/src/services/dataRetentionService.ts b/application/src/services/dataRetentionService.ts new file mode 100644 index 0000000..05c7e76 --- /dev/null +++ b/application/src/services/dataRetentionService.ts @@ -0,0 +1,293 @@ + +import { pb } from '@/lib/pocketbase'; + +interface RetentionSettings { + uptimeRetentionDays: number; + serverRetentionDays: number; + lastCleanup?: string; +} + +interface CleanupResult { + deletedRecords: number; + collections: string[]; +} + +export const dataRetentionService = { + async getRetentionSettings(): Promise { + try { + // Try to get existing settings from data_settings collection + const records = await pb.collection('data_settings').getFullList({ + sort: '-created' + }); + + if (records.length > 0) { + const settings = records[0]; + return { + uptimeRetentionDays: settings.uptime_retention_days || 30, + serverRetentionDays: settings.server_retention_days || 30, + lastCleanup: settings.last_cleanup + }; + } + + // Return default settings if none exist + return { + uptimeRetentionDays: 30, + serverRetentionDays: 30 + }; + } catch (error) { + console.error("Error fetching retention settings:", error); + // Return default settings on error + return { + uptimeRetentionDays: 30, + serverRetentionDays: 30 + }; + } + }, + + async updateRetentionSettings(settings: RetentionSettings): Promise { + try { + // Check if settings already exist + const existingRecords = await pb.collection('data_settings').getFullList({ + sort: '-created' + }); + + const data = { + uptime_retention_days: settings.uptimeRetentionDays, + server_retention_days: settings.serverRetentionDays, + retention_days: Math.max(settings.uptimeRetentionDays, settings.serverRetentionDays), // General retention days + backup: "auto", // Default backup setting + updated: new Date().toISOString() + }; + + if (existingRecords.length > 0) { + // Update existing record + await pb.collection('data_settings').update(existingRecords[0].id, data); + } else { + // Create new record + await pb.collection('data_settings').create({ + ...data, + created: new Date().toISOString() + }); + } + + console.log("Retention settings updated successfully"); + } catch (error) { + console.error("Error updating retention settings:", error); + throw new Error("Failed to update retention settings"); + } + }, + + async manualUptimeCleanup(): Promise { + try { + const settings = await this.getRetentionSettings(); + if (!settings) { + throw new Error("Could not load retention settings"); + } + + let totalDeleted = 0; + const cleanedCollections: string[] = []; + + // Calculate cutoff date for uptime data + const uptimeCutoffDate = new Date(); + uptimeCutoffDate.setDate(uptimeCutoffDate.getDate() - settings.uptimeRetentionDays); + + console.log(`Starting uptime cleanup - Cutoff: ${uptimeCutoffDate.toISOString()}`); + + // Clean uptime_data collection + try { + const uptimeRecords = await pb.collection('uptime_data').getFullList({ + filter: `created < "${uptimeCutoffDate.toISOString()}"` + }); + + console.log(`Found ${uptimeRecords.length} uptime records to delete`); + + for (const record of uptimeRecords) { + await pb.collection('uptime_data').delete(record.id); + totalDeleted++; + } + + if (uptimeRecords.length > 0) { + cleanedCollections.push('uptime_data'); + } + } catch (error) { + console.error("Error cleaning uptime_data:", error); + } + + console.log(`Uptime cleanup completed. Deleted ${totalDeleted} records`); + + return { + deletedRecords: totalDeleted, + collections: cleanedCollections + }; + } catch (error) { + console.error("Error during uptime cleanup:", error); + throw new Error("Failed to perform uptime data cleanup"); + } + }, + + async manualServerCleanup(): Promise { + try { + const settings = await this.getRetentionSettings(); + if (!settings) { + throw new Error("Could not load retention settings"); + } + + let totalDeleted = 0; + const cleanedCollections: string[] = []; + + // Calculate cutoff date for server data + const serverCutoffDate = new Date(); + serverCutoffDate.setDate(serverCutoffDate.getDate() - settings.serverRetentionDays); + + console.log(`Starting server cleanup - Cutoff: ${serverCutoffDate.toISOString()}`); + + // Clean ping_data collection + try { + const pingRecords = await pb.collection('ping_data').getFullList({ + filter: `created < "${serverCutoffDate.toISOString()}"` + }); + + console.log(`Found ${pingRecords.length} ping records to delete`); + + for (const record of pingRecords) { + await pb.collection('ping_data').delete(record.id); + totalDeleted++; + } + + if (pingRecords.length > 0) { + cleanedCollections.push('ping_data'); + } + } catch (error) { + console.error("Error cleaning ping_data:", error); + } + + console.log(`Server cleanup completed. Deleted ${totalDeleted} records`); + + return { + deletedRecords: totalDeleted, + collections: cleanedCollections + }; + } catch (error) { + console.error("Error during server cleanup:", error); + throw new Error("Failed to perform server data cleanup"); + } + }, + + async manualCleanup(): Promise { + try { + // Get current retention settings + const settings = await this.getRetentionSettings(); + if (!settings) { + throw new Error("Could not load retention settings"); + } + + let totalDeleted = 0; + const cleanedCollections: string[] = []; + + // Calculate cutoff dates + const uptimeCutoffDate = new Date(); + uptimeCutoffDate.setDate(uptimeCutoffDate.getDate() - settings.uptimeRetentionDays); + + const serverCutoffDate = new Date(); + serverCutoffDate.setDate(serverCutoffDate.getDate() - settings.serverRetentionDays); + + console.log(`Starting manual cleanup - Uptime cutoff: ${uptimeCutoffDate.toISOString()}, Server cutoff: ${serverCutoffDate.toISOString()}`); + + // Clean uptime_data collection + try { + const uptimeRecords = await pb.collection('uptime_data').getFullList({ + filter: `created < "${uptimeCutoffDate.toISOString()}"` + }); + + console.log(`Found ${uptimeRecords.length} uptime records to delete`); + + for (const record of uptimeRecords) { + await pb.collection('uptime_data').delete(record.id); + totalDeleted++; + } + + if (uptimeRecords.length > 0) { + cleanedCollections.push('uptime_data'); + } + } catch (error) { + console.error("Error cleaning uptime_data:", error); + } + + // Clean ping_data collection + try { + const pingRecords = await pb.collection('ping_data').getFullList({ + filter: `created < "${serverCutoffDate.toISOString()}"` + }); + + console.log(`Found ${pingRecords.length} ping records to delete`); + + for (const record of pingRecords) { + await pb.collection('ping_data').delete(record.id); + totalDeleted++; + } + + if (pingRecords.length > 0) { + cleanedCollections.push('ping_data'); + } + } catch (error) { + console.error("Error cleaning ping_data:", error); + } + + // Update last cleanup timestamp + await this.updateLastCleanupTime(); + + console.log(`Manual cleanup completed. Deleted ${totalDeleted} records from collections: ${cleanedCollections.join(', ')}`); + + return { + deletedRecords: totalDeleted, + collections: cleanedCollections + }; + } catch (error) { + console.error("Error during manual cleanup:", error); + throw new Error("Failed to perform database cleanup"); + } + }, + + async updateLastCleanupTime(): Promise { + try { + const existingRecords = await pb.collection('data_settings').getFullList({ + sort: '-created' + }); + + const data = { + last_cleanup: new Date().toISOString() + }; + + if (existingRecords.length > 0) { + await pb.collection('data_settings').update(existingRecords[0].id, data); + } + } catch (error) { + console.error("Error updating last cleanup time:", error); + } + }, + + async scheduleAutomaticCleanup(): Promise { + try { + const settings = await this.getRetentionSettings(); + if (!settings) return; + + // Check if enough time has passed since last cleanup (run daily) + if (settings.lastCleanup) { + const lastCleanup = new Date(settings.lastCleanup); + const now = new Date(); + const hoursSinceLastCleanup = (now.getTime() - lastCleanup.getTime()) / (1000 * 60 * 60); + + if (hoursSinceLastCleanup < 24) { + console.log("Skipping automatic cleanup - last cleanup was less than 24 hours ago"); + return; + } + } + + console.log("Starting scheduled automatic cleanup"); + const result = await this.manualCleanup(); + console.log(`Automatic cleanup completed. Deleted ${result.deletedRecords} records`); + } catch (error) { + console.error("Error during automatic cleanup:", error); + } + } +}; \ No newline at end of file diff --git a/application/src/services/uptimeService.ts b/application/src/services/uptimeService.ts index 33325de..d2e5a57 100644 --- a/application/src/services/uptimeService.ts +++ b/application/src/services/uptimeService.ts @@ -2,29 +2,24 @@ import { pb } from '@/lib/pocketbase'; import { UptimeData } from '@/types/service.types'; -// Simple in-memory cache to avoid excessive requests const uptimeCache = new Map(); -// Cache time-to-live in milliseconds -const CACHE_TTL = 15000; // 15 seconds +const CACHE_TTL = 3000; // 3 seconds for faster updates export const uptimeService = { async recordUptimeData(data: UptimeData): Promise { 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 + $autoCancel: false, + $cancelKey: `uptime_record_${data.serviceId}_${Date.now()}` }; - // 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, @@ -32,8 +27,9 @@ export const uptimeService = { response_time: data.responseTime }, options); - // Invalidate cache for this service after recording new data - uptimeCache.delete(`uptime_${data.serviceId}`); + // Invalidate cache for this service + const keysToDelete = Array.from(uptimeCache.keys()).filter(key => key.includes(`uptime_${data.serviceId}`)); + keysToDelete.forEach(key => uptimeCache.delete(key)); console.log(`Uptime data recorded successfully with ID: ${record.id}`); } catch (error) { @@ -49,10 +45,9 @@ export const uptimeService = { endDate?: Date ): Promise { try { - // Create cache key based on parameters const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}`; - // Check if we have a valid cached result + // Check cache const cached = uptimeCache.get(cacheKey); if (cached && (Date.now() - cached.timestamp) < cached.expiresIn) { console.log(`Using cached uptime history for service ${serviceId}`); @@ -61,80 +56,74 @@ export const uptimeService = { 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(); + // Convert dates to UTC strings in the format PocketBase expects + const startUTC = startDate.toISOString(); + const endUTC = endDate.toISOString(); - // Log the date range we're filtering by - console.log(`Date range filter: ${startISO} to ${endISO}`); + console.log(`Date filter: ${startUTC} to ${endUTC}`); - filter += ` && timestamp >= '${startISO}' && timestamp <= '${endISO}'`; + // Use proper PocketBase date filtering syntax + filter += ` && timestamp >= "${startUTC}" && timestamp <= "${endUTC}"`; } - // 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 + sort: '-timestamp', + $autoCancel: false, + $cancelKey: `uptime_history_${serviceId}_${Date.now()}` }; - 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]; - } + console.log(`Filter query: ${filter}`); + + const response = await pb.collection('uptime_data').getList(1, limit, options); + + console.log(`Fetched ${response.items.length} uptime records for service ${serviceId}`); + + 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}`); - // Cache the result - uptimeCache.set(cacheKey, { - data: finalData, - timestamp: Date.now(), - expiresIn: CACHE_TTL + // Try a fallback query without date filter to see if there's any data at all + const fallbackResponse = await pb.collection('uptime_data').getList(1, 10, { + filter: `service_id='${serviceId}'`, + sort: '-timestamp', + $autoCancel: false }); - return finalData; - } catch (err) { - throw err; + console.log(`Fallback query found ${fallbackResponse.items.length} total records for service`); + 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}`); + } } + + 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, + uptime: 100 + })); + + // Cache the result + uptimeCache.set(cacheKey, { + data: uptimeData, + timestamp: Date.now(), + expiresIn: CACHE_TTL + }); + + return uptimeData; } catch (error) { console.error("Error fetching uptime history:", error); - // Try to return cached data even if it's expired, as a fallback + // Try to return cached data as fallback const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}`; const cached = uptimeCache.get(cacheKey); if (cached) { @@ -145,4 +134,4 @@ export const uptimeService = { throw new Error('Failed to load uptime history.'); } } -}; +}; \ No newline at end of file diff --git a/application/src/services/userService.ts b/application/src/services/userService.ts index 7193c68..73e41bb 100644 --- a/application/src/services/userService.ts +++ b/application/src/services/userService.ts @@ -147,22 +147,11 @@ export const userService = { if (roleChange) { delete cleanData.role; } - - // Handle email updates separately + + // Handle email updates with proper error handling const hasEmailChange = cleanData.email !== undefined; const emailToUpdate = hasEmailChange ? cleanData.email : null; - // Remove email from regular update if it's being changed - if (hasEmailChange) { - console.log("Email change detected, will handle separately:", emailToUpdate); - delete cleanData.email; - - // For email changes, we should always set emailVisibility - if (cleanData.emailVisibility === undefined) { - cleanData.emailVisibility = true; - } - } - let updatedUser: User | null = null; // First, determine if this is currently a regular user or superadmin @@ -194,7 +183,6 @@ export const userService = { ...cleanData }; - // We need to create in the new collection and delete from the old one try { if (targetRole === "superadmin") { // Create in superadmin collection @@ -217,7 +205,6 @@ export const userService = { } } else { // Regular update without changing collections - // Only perform the regular update if there are fields to update if (Object.keys(cleanData).length > 0) { console.log("Final update payload to PocketBase:", cleanData); @@ -227,47 +214,31 @@ export const userService = { const updatedRecord = await pb.collection(collection).update(id, cleanData); updatedUser = convertToUserType(updatedRecord, isCurrentlySuperadmin ? "superadmin" : "admin"); - console.log("PocketBase update response for regular fields:", updatedUser); + console.log("PocketBase update response:", updatedUser); + + // If email was updated successfully, show success message + if (hasEmailChange) { + console.log("Email updated successfully to:", emailToUpdate); + } + } catch (error) { - console.error("Error updating user regular fields:", error); + console.error("Error updating user:", error); + + // Provide more specific error messages for email issues + if (hasEmailChange && error instanceof Error) { + if (error.message.includes("email")) { + throw new Error("Email update failed. The email address may already be in use or invalid."); + } + } + throw error; } } else { - // If no other fields to update, get the current user + // If no fields to update, get the current user updatedUser = await this.getUser(id); } } - // Now handle email change separately if needed - if (emailToUpdate && updatedUser) { - try { - console.log("Processing email change request for new email:", emailToUpdate); - - // For email changes, we need to directly update the email field instead of using requestEmailChange - // This is because requestEmailChange has permission issues when cross-collection requests are made - const collection = updatedUser.role === "superadmin" ? '_superusers' : 'users'; - - console.log(`Updating email directly in ${collection} collection`); - - // Update the email directly in the user record - const emailUpdateData = { - email: emailToUpdate, - emailVisibility: true, - verified: false // Reset verification status when email changes - }; - - const updatedRecord = await pb.collection(collection).update(id, emailUpdateData); - updatedUser = convertToUserType(updatedRecord, updatedUser.role === "superadmin" ? "superadmin" : "admin"); - - console.log("Email updated successfully:", updatedUser); - - } catch (error) { - console.error("Failed to update email:", error); - // Don't throw error for email update failure, just log it - console.log("Email update failed, but other fields were updated successfully"); - } - } - return updatedUser; } catch (error) { console.error("Failed to update user:", error);