Initial Release

This commit is contained in:
Tola Leng
2025-05-09 21:28:07 +07:00
commit 0c6cd18e6f
244 changed files with 50633 additions and 0 deletions
@@ -0,0 +1,43 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ServiceForm } from "./ServiceForm";
import { ScrollArea } from "@/components/ui/scroll-area";
interface AddServiceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) {
const handleSuccess = () => {
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl">Create New Service</DialogTitle>
<DialogDescription className="text-gray-400">
Fill in the details to create a new service to monitor.
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="pr-2">
<ServiceForm onSuccess={handleSuccess} onCancel={handleCancel} />
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,156 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { format, subDays, subHours, subMonths, subWeeks, subYears } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export type DateRangeOption = '60min' | '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;
}
export function DateRangeFilter({ onRangeChange, selectedOption = '24h' }: DateRangeFilterProps) {
const [currentOption, setCurrentOption] = useState<DateRangeOption>(selectedOption);
const [customDateRange, setCustomDateRange] = useState<DateRange>({
from: undefined,
to: undefined,
});
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const handleOptionChange = (value: string) => {
const option = value as DateRangeOption;
setCurrentOption(option);
const now = new Date();
let startDate: Date;
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);
break;
case '7d':
startDate = subDays(now, 7);
break;
case '30d':
startDate = subDays(now, 30);
break;
case '1y':
startDate = subYears(now, 1);
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);
}
console.log(`DateRangeFilter: Option changed to ${option}, date range: ${startDate.toISOString()} to ${now.toISOString()}`);
onRangeChange(startDate, now, option);
};
// Handle custom date range selection
const handleCustomRangeSelect = (range: DateRange | undefined) => {
if (!range) {
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);
}
};
return (
<div className="flex items-center space-x-2">
<Select value={currentOption} onValueChange={handleOptionChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="60min">Last 60 minutes</SelectItem>
<SelectItem value="24h">Last 24 hours</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="1y">Last year</SelectItem>
<SelectItem value="custom">Custom range</SelectItem>
</SelectContent>
</Select>
{currentOption === 'custom' && (
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
className={cn(
"w-[280px] justify-start text-left font-normal",
!customDateRange.from && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{customDateRange.from ? (
customDateRange.to ? (
<>
{format(customDateRange.from, "LLL dd, y")} -{" "}
{format(customDateRange.to, "LLL dd, y")}
</>
) : (
format(customDateRange.from, "LLL dd, y")
)
) : (
"Pick a date range"
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center">
<Calendar
mode="range"
selected={customDateRange}
onSelect={handleCustomRangeSelect}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
)}
</div>
);
}
@@ -0,0 +1,87 @@
import React from "react";
import { Clock, TimerOff } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
interface LastCheckedTimeProps {
lastCheckedTime: string;
status?: string;
interval?: number;
}
export const LastCheckedTime = ({ lastCheckedTime, status, interval }: LastCheckedTimeProps) => {
// Format the time without seconds to display a static time
const formatTimeWithoutSeconds = (timeString: string) => {
try {
const date = new Date(timeString);
// Check if it's a valid date
if (isNaN(date.getTime())) {
// If it's already in HH:MM format, just return it
if (timeString.includes(':') && !timeString.includes(':00:')) {
return timeString;
}
return timeString;
}
// Format to only show hours and minutes (HH:MM)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch (e) {
return timeString;
}
};
// Get the formatted time without creating a new Date for paused services
const formattedTime = formatTimeWithoutSeconds(lastCheckedTime);
// Explicitly prevent real-time updates for paused services
const isPaused = status === "paused";
// Format the interval for display
const formatInterval = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.round(seconds / 60);
return `${minutes}min`;
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 text-sm text-gray-400 cursor-help">
{isPaused ? (
<TimerOff className="h-4 w-4" />
) : (
<Clock className="h-4 w-4" />
)}
<span>
{isPaused ? "Paused at " : ""}
{formattedTime}
</span>
</div>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">
{isPaused ? "Monitoring Paused" : "Last Check Details"}
</div>
<div>
{isPaused ? "No automatic checks" : `Checked at ${formattedTime}`}
</div>
{interval && !isPaused && (
<div>
Check interval: {formatInterval(interval)}
</div>
)}
<div className="text-gray-400 text-[10px]">
{new Date(lastCheckedTime).toLocaleString()}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -0,0 +1,15 @@
export function LoadingState() {
return (
<div className="flex items-center justify-center h-screen bg-background text-foreground">
<div className="flex flex-col items-center text-center py-8 px-4 rounded-lg shadow-lg bg-card animate-fade-in">
<div className="relative flex items-center justify-center mb-4">
<div className="absolute w-12 h-12 rounded-full border-4 border-primary/20"></div>
<div className="w-12 h-12 rounded-full border-4 border-t-primary border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
<h3 className="text-xl font-medium mb-1">Loading server data</h3>
<p className="text-muted-foreground">Please wait while we retrieve your information...</p>
</div>
</div>
);
}
@@ -0,0 +1,234 @@
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from "recharts";
import { UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTheme } from "@/contexts/ThemeContext";
import { useMemo } from "react";
import { format } from "date-fns";
interface ResponseTimeChartProps {
uptimeData: UptimeData[];
}
export function ResponseTimeChart({ uptimeData }: ResponseTimeChartProps) {
const { theme } = useTheme();
// Format data for the chart with enhanced time formatting
const chartData = useMemo(() => {
if (!uptimeData || uptimeData.length === 0) return [];
// Sort by timestamp ascending (oldest to newest)
const sortedData = [...uptimeData].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Get time format based on data density
const isShortTimeRange = uptimeData.length > 0 &&
(new Date(uptimeData[0].timestamp).getTime() - new Date(uptimeData[uptimeData.length - 1].timestamp).getTime()) < 2 * 60 * 60 * 1000;
return sortedData.map(data => {
const timestamp = new Date(data.timestamp);
return {
time: isShortTimeRange
? format(timestamp, 'HH:mm:ss') // Include seconds for short time ranges like 60min
: format(timestamp, 'HH:mm'),
rawTime: timestamp.getTime(),
date: format(timestamp, 'MMM dd, yyyy'),
value: data.status === "paused" ? null : data.responseTime,
status: data.status,
};
});
}, [uptimeData]);
// Create a custom tooltip for the chart
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
// Set background color based on status
let statusColor = "bg-emerald-800";
let statusText = "Up";
if (data.status === "down") {
statusColor = "bg-red-800";
statusText = "Down";
} else if (data.status === "warning") {
statusColor = "bg-yellow-800";
statusText = "Warning";
} else if (data.status === "paused") {
statusColor = "bg-gray-800";
statusText = "Paused";
}
return (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} p-2 border ${theme === 'dark' ? 'border-gray-700' : 'border-gray-200'} rounded shadow-md`}>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{data.date}</p>
<div className={`flex items-center gap-2 mt-1 ${data.status === "paused" ? "opacity-70" : ""}`}>
<div className={`w-3 h-3 rounded-full ${statusColor}`}></div>
<span>{statusText}</span>
</div>
<p className="mt-1 font-mono text-sm">
{data.status === "paused" ? "Monitoring paused" :
data.value !== null ? `${data.value} ms` : "No data"}
</p>
</div>
);
}
return null;
};
// Compute status segments for different areas
const getStatusSegments = () => {
const segments = {
up: [] as any[],
down: [] as any[],
warning: [] as any[]
};
chartData.forEach(point => {
if (point.status === "paused") return;
if (point.status === "up") {
segments.up.push(point);
} else if (point.status === "down") {
segments.down.push(point);
} else if (point.status === "warning") {
segments.warning.push(point);
}
});
return segments;
};
const segments = getStatusSegments();
// Check if we have any data to display - be more lenient by checking raw uptimeData
const hasData = uptimeData.length > 0;
// Get date range for display
const dateRange = useMemo(() => {
if (!chartData.length) return { start: "", end: "" };
const sortedData = [...chartData].sort((a, b) => a.rawTime - b.rawTime);
return {
start: sortedData[0]?.date || "",
end: sortedData[sortedData.length - 1]?.date || ""
};
}, [chartData]);
// Display date range if different dates
const dateRangeDisplay = dateRange.start === dateRange.end
? dateRange.start
: `${dateRange.start} - ${dateRange.end}`;
return (
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex flex-col md:flex-row md:items-center gap-2 justify-between">
<span>Response Time History</span>
{hasData && (
<span className="text-sm font-normal text-muted-foreground">
{dateRangeDisplay}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
{!hasData ? (
<div className="h-80 flex items-center justify-center text-muted-foreground">
<p>No data available for the selected time period.</p>
</div>
) : (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 30 }}>
<defs>
<linearGradient id="colorUp" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0.2}/>
</linearGradient>
<linearGradient id="colorDown" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#ef4444" stopOpacity={0.2}/>
</linearGradient>
<linearGradient id="colorWarning" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.8}/>
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0.2}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={theme === 'dark' ? '#333' : '#e5e7eb'} />
<XAxis
dataKey="time"
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
angle={-45}
textAnchor="end"
tick={{ fontSize: 10 }}
height={60}
interval="preserveStartEnd"
minTickGap={5}
/>
<YAxis
stroke={theme === 'dark' ? '#666' : '#9ca3af'}
allowDecimals={false}
domain={['dataMin - 10', 'dataMax + 10']}
/>
<Tooltip content={<CustomTooltip />} />
{/* Area charts for different statuses */}
{segments.up.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.up}
stroke="#10b981"
fillOpacity={1}
fill="url(#colorUp)"
connectNulls
/>
)}
{segments.down.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.down}
stroke="#ef4444"
fillOpacity={1}
fill="url(#colorDown)"
connectNulls
/>
)}
{segments.warning.length > 0 && (
<Area
type="monotone"
dataKey="value"
data={segments.warning}
stroke="#f59e0b"
fillOpacity={1}
fill="url(#colorWarning)"
connectNulls
/>
)}
{/* Add reference lines for paused periods */}
{chartData.map((entry, index) =>
entry.status === 'paused' ? (
<ReferenceLine
key={`ref-${index}`}
x={entry.time}
stroke="#9ca3af"
strokeDasharray="3 3"
/>
) : null
)}
</AreaChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,77 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Service } from "@/types/service.types";
import { useTheme } from "@/contexts/ThemeContext";
import { Loader2 } from "lucide-react";
interface ServiceDeleteDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
selectedService: Service | null;
onConfirmDelete: () => Promise<void>;
isDeleting?: boolean;
}
export const ServiceDeleteDialog = ({
isOpen,
onOpenChange,
selectedService,
onConfirmDelete,
isDeleting = false,
}: ServiceDeleteDialogProps) => {
const { theme } = useTheme();
const handleConfirm = async () => {
if (!isDeleting) {
await onConfirmDelete();
}
};
return (
<AlertDialog open={isOpen} onOpenChange={onOpenChange}>
<AlertDialogContent className={`${theme === 'dark' ? 'bg-gray-900 text-white border-gray-800' : 'bg-background text-foreground border-border'}`}>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this service?</AlertDialogTitle>
<AlertDialogDescription className={theme === 'dark' ? 'text-gray-400' : 'text-muted-foreground'}>
This action cannot be undone. This will permanently delete{' '}
<span className={theme === 'dark' ? 'font-semibold text-white' : 'font-semibold text-foreground'}>
{selectedService?.name}
</span>{' '}
and all of its uptime records.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
className={theme === 'dark' ? 'bg-gray-800 text-white border-gray-700 hover:bg-gray-700' : 'bg-secondary'}
disabled={isDeleting}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isDeleting}
className={theme === 'dark' ? 'bg-red-900 text-white hover:bg-red-800' : 'bg-red-600 text-white hover:bg-red-700'}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,6 @@
import { ServiceDetailContainer as NewServiceDetailContainer } from "./ServiceDetailContainer/index";
export const ServiceDetailContainer = () => {
return <NewServiceDetailContainer />;
};
@@ -0,0 +1,51 @@
import React from "react";
import { Sidebar } from "@/components/dashboard/Sidebar";
import { Header } from "@/components/dashboard/Header";
import { LoadingState } from "@/components/services/LoadingState";
import { ServiceNotFound } from "@/components/services/ServiceNotFound";
import { Service } from "@/types/service.types";
interface ServiceDetailWrapperProps {
children: React.ReactNode;
isLoading: boolean;
service: Service | null;
sidebarCollapsed: boolean;
toggleSidebar: () => void;
currentUser: any;
handleLogout: () => void;
}
export const ServiceDetailWrapper = ({
children,
isLoading,
service,
sidebarCollapsed,
toggleSidebar,
currentUser,
handleLogout
}: ServiceDetailWrapperProps) => {
return (
<div className="flex h-screen w-full overflow-hidden bg-background text-foreground">
<Sidebar collapsed={sidebarCollapsed} />
<div className="flex flex-col flex-1 min-w-0">
<Header
currentUser={currentUser}
onLogout={handleLogout}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
/>
{isLoading ? (
<LoadingState />
) : !service ? (
<ServiceNotFound />
) : (
<div className="flex-1 overflow-auto">
{children}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,3 @@
export * from './useServiceData';
export * from './useRealTimeUpdates';
@@ -0,0 +1,87 @@
import { useEffect } from "react";
import { pb } from "@/lib/pocketbase";
import { Service, UptimeData } from "@/types/service.types";
interface UseRealTimeUpdatesProps {
serviceId: string | undefined;
startDate: Date;
endDate: Date;
setService: React.Dispatch<React.SetStateAction<Service | null>>;
setUptimeData: React.Dispatch<React.SetStateAction<UptimeData[]>>;
}
export const useRealTimeUpdates = ({
serviceId,
startDate,
endDate,
setService,
setUptimeData
}: UseRealTimeUpdatesProps) => {
// Listen for real-time updates to this service
useEffect(() => {
if (!serviceId) return;
console.log(`Setting up real-time updates for service: ${serviceId}`);
try {
// Subscribe to the service record for real-time updates
const subscription = pb.collection('services').subscribe(serviceId, function(e) {
console.log("Service updated:", e.record);
// Update our local state with the new data
if (e.record) {
setService(prev => {
if (!prev) return null;
return {
...prev,
status: e.record.status || prev.status,
responseTime: e.record.response_time || e.record.responseTime || prev.responseTime,
uptime: e.record.uptime || prev.uptime,
lastChecked: e.record.last_checked || e.record.lastChecked || prev.lastChecked,
};
});
}
});
// Subscribe to uptime data updates
const uptimeSubscription = pb.collection('uptime_data').subscribe('*', function(e) {
if (e.record && e.record.service_id === serviceId) {
console.log("New uptime data:", e.record);
// Add the new uptime data to our list if it's within the selected date range
const timestamp = new Date(e.record.timestamp);
if (timestamp >= startDate && timestamp <= endDate) {
setUptimeData(prev => {
const newData: UptimeData = {
id: e.record.id,
serviceId: e.record.service_id,
timestamp: e.record.timestamp,
status: e.record.status,
responseTime: e.record.response_time || 0,
date: e.record.timestamp, // Adding required date property
uptime: e.record.uptime || 0 // Adding required uptime property
};
// Add at the beginning of the array to maintain newest first sorting
return [newData, ...prev];
});
}
}
});
// Clean up the subscriptions
return () => {
console.log(`Cleaning up subscriptions for service: ${serviceId}`);
try {
pb.collection('services').unsubscribe(serviceId);
pb.collection('uptime_data').unsubscribe('*');
} catch (error) {
console.error("Error cleaning up subscriptions:", error);
}
};
} catch (error) {
console.error("Error setting up real-time updates:", error);
}
}, [serviceId, startDate, endDate, setService, setUptimeData]);
};
@@ -0,0 +1,173 @@
import { useState, useEffect } from "react";
import { pb } from "@/lib/pocketbase";
import { Service, UptimeData } from "@/types/service.types";
import { useToast } from "@/hooks/use-toast";
import { useNavigate } from "react-router-dom";
import { uptimeService } from "@/services/uptimeService";
export const useServiceData = (serviceId: string | undefined, startDate: Date, endDate: Date) => {
const [service, setService] = useState<Service | null>(null);
const [uptimeData, setUptimeData] = useState<UptimeData[]>([]);
const [isLoading, setIsLoading] = useState(true);
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
});
toast({
title: "Status updated",
description: `Service status changed to ${newStatus}`,
});
} catch (error) {
console.error("Failed to update service status:", error);
// Revert the optimistic update
setService(prevService => prevService);
toast({
variant: "destructive",
title: "Update failed",
description: "Could not update service status. Please try again.",
});
}
};
// Function to fetch uptime data with date filters
const fetchUptimeData = async (serviceId: string, start: Date, end: Date, selectedRange: string) => {
try {
console.log(`Fetching uptime data from ${start.toISOString()} to ${end.toISOString()}`);
// Set appropriate limits based on time range to ensure enough granularity
let limit = 200; // default
// Adjust limits based on selected range
if (selectedRange === '60min') {
limit = 300; // More points for shorter time ranges
} else if (selectedRange === '24h') {
limit = 200;
} else if (selectedRange === '7d') {
limit = 250;
} else if (selectedRange === '30d' || selectedRange === '1y') {
limit = 300; // More points for longer time ranges
}
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}`);
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) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
setUptimeData(sortedHistory);
return sortedHistory;
} catch (error) {
console.error("Error fetching uptime data:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load uptime history. Please try again.",
});
return [];
}
};
// Initial data loading
useEffect(() => {
const fetchServiceData = async () => {
try {
if (!serviceId) {
setIsLoading(false);
return;
}
setIsLoading(true);
// Add a timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Request timed out")), 10000);
});
const fetchPromise = pb.collection('services').getOne(serviceId);
const serviceData = await Promise.race([fetchPromise, timeoutPromise]) as any;
const formattedService: Service = {
id: serviceData.id,
name: serviceData.name,
url: serviceData.url || "",
type: serviceData.service_type || serviceData.type || "HTTP",
status: serviceData.status || "paused",
responseTime: serviceData.response_time || serviceData.responseTime || 0,
uptime: serviceData.uptime || 0,
lastChecked: serviceData.last_checked || serviceData.lastChecked || new Date().toLocaleString(),
interval: serviceData.heartbeat_interval || serviceData.interval || 60,
retries: serviceData.max_retries || serviceData.retries || 3,
notificationChannel: serviceData.notification_id,
alertTemplate: serviceData.template_id,
alerts: serviceData.alerts || "unmuted"
};
setService(formattedService);
// Fetch uptime history with date range
await fetchUptimeData(serviceId, startDate, endDate, '24h');
} catch (error) {
console.error("Error fetching service:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load service data. Please try again.",
});
navigate("/dashboard");
} finally {
setIsLoading(false);
}
};
fetchServiceData();
}, [serviceId, navigate, toast]);
// Update data when date range changes
useEffect(() => {
if (serviceId && !isLoading) {
fetchUptimeData(serviceId, startDate, endDate, '24h');
}
}, [startDate, endDate]);
return {
service,
setService,
uptimeData,
setUptimeData,
isLoading,
handleStatusChange,
fetchUptimeData
};
};
@@ -0,0 +1,126 @@
import { useState, useEffect, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { DateRangeOption } from "../DateRangeFilter";
import { authService } from "@/services/authService";
import { ServiceDetailContent } from "../ServiceDetailContent";
import { ServiceDetailWrapper } from "./ServiceDetailWrapper";
import { useServiceData, useRealTimeUpdates } from "./hooks";
import { toast } from "@/components/ui/use-toast";
export const ServiceDetailContainer = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
// Ensure we use exact timestamp for startDate
const [startDate, setStartDate] = useState<Date>(() => {
const date = new Date();
date.setHours(date.getHours() - 24);
return date;
});
const [endDate, setEndDate] = useState<Date>(new Date());
const [selectedRange, setSelectedRange] = useState<DateRangeOption>('24h');
// State for sidebar collapse functionality (shared with Dashboard)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
// Check if there's a saved preference in localStorage
const saved = localStorage.getItem("sidebarCollapsed");
return saved ? JSON.parse(saved) : window.innerWidth < 768;
});
// Toggle sidebar and save preference
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(prev => {
const newState = !prev;
localStorage.setItem("sidebarCollapsed", JSON.stringify(newState));
return newState;
});
}, []);
// Get current user for header
const currentUser = authService.getCurrentUser();
useEffect(() => {
// Verify user is authenticated
if (!authService.isAuthenticated()) {
toast({
variant: "destructive",
title: "Authentication required",
description: "Please log in to view service details",
});
navigate("/login");
}
// Auto-collapse sidebar on small screens
const handleResize = () => {
if (window.innerWidth < 768 && !sidebarCollapsed) {
setSidebarCollapsed(true);
localStorage.setItem("sidebarCollapsed", JSON.stringify(true));
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [navigate, sidebarCollapsed]);
// Handler for logout (same as Dashboard)
const handleLogout = () => {
authService.logout();
navigate("/login");
};
// Use our custom hooks
const {
service,
uptimeData,
isLoading,
handleStatusChange,
fetchUptimeData,
setService,
setUptimeData
} = useServiceData(id, startDate, endDate);
// Set up real-time updates
useRealTimeUpdates({
serviceId: id,
startDate,
endDate,
setService,
setUptimeData
});
// 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}`);
setStartDate(start);
setEndDate(end);
setSelectedRange(option);
// Refetch uptime data with new date range, passing the selected range option
if (id) {
fetchUptimeData(id, start, end, option);
}
}, [id, fetchUptimeData]);
return (
<ServiceDetailWrapper
isLoading={isLoading}
service={service}
sidebarCollapsed={sidebarCollapsed}
toggleSidebar={toggleSidebar}
currentUser={currentUser}
handleLogout={handleLogout}
>
{service && (
<ServiceDetailContent
service={service}
uptimeData={uptimeData}
onDateRangeChange={handleDateRangeChange}
onStatusChange={handleStatusChange}
selectedDateOption={selectedRange}
/>
)}
</ServiceDetailWrapper>
);
};
@@ -0,0 +1,64 @@
import { Service, UptimeData } from "@/types/service.types";
import { ServiceHeader } from "@/components/services/ServiceHeader";
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
import { ResponseTimeChart } from "@/components/services/ResponseTimeChart";
import { LatestChecksTable } from "@/components/services/incident-history";
import { DateRangeFilter, DateRangeOption } from "@/components/services/DateRangeFilter";
import { Card, CardContent } from "@/components/ui/card";
import { AlertTriangle } from "lucide-react";
interface ServiceDetailContentProps {
service: Service;
uptimeData: UptimeData[];
onDateRangeChange: (start: Date, end: Date, option: DateRangeOption) => void;
onStatusChange: (newStatus: "up" | "down" | "paused" | "warning") => void;
selectedDateOption: DateRangeOption;
}
export const ServiceDetailContent = ({
service,
uptimeData,
onDateRangeChange,
onStatusChange,
selectedDateOption
}: ServiceDetailContentProps) => {
// Check if data is available
const hasUptimeData = uptimeData && uptimeData.length > 0;
return (
<div className="p-4 md:p-6 pb-0 h-full overflow-auto">
<ServiceHeader service={service} onStatusChange={onStatusChange} />
<ServiceStatsCards service={service} uptimeData={uptimeData} />
<div className="mb-4 md:mb-6 mt-6 md:mt-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4 md:gap-0">
<h2 className="text-lg md:text-xl font-medium">Response Time History</h2>
<DateRangeFilter
onRangeChange={onDateRangeChange}
selectedOption={selectedDateOption}
/>
</div>
{!hasUptimeData && (
<Card className="mb-6 md:mb-8">
<CardContent className="flex items-center justify-center py-8 md:py-12">
<div className="text-center">
<AlertTriangle className="h-10 w-10 md:h-12 md:w-12 text-amber-500 mx-auto mb-2 md:mb-3 opacity-70" />
<h3 className="text-base md:text-lg font-medium mb-1">No uptime data available</h3>
<p className="text-muted-foreground max-w-md text-sm md:text-base">
There's no monitoring data for this service in the selected time period.
This could be because the service was recently added or monitoring is paused.
</p>
</div>
</CardContent>
</Card>
)}
{hasUptimeData && <ResponseTimeChart uptimeData={uptimeData} />}
<div className="pb-6">
<LatestChecksTable uptimeData={uptimeData} />
</div>
</div>
);
};
@@ -0,0 +1,77 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ServiceForm } from "./ServiceForm";
import { Service } from "@/types/service.types";
import { useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
interface ServiceEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
service: Service | null;
}
export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDialogProps) {
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset submission state when dialog opens/closes
useEffect(() => {
if (!open) {
setIsSubmitting(false);
}
}, [open]);
const handleSuccess = () => {
// Invalidate the services query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["services"] });
setIsSubmitting(false);
onOpenChange(false);
};
const handleCancel = () => {
if (!isSubmitting) {
onOpenChange(false);
}
};
// Only render the form if dialog is open and service data exists
// This prevents form validation errors when dialog is closed
return (
<Dialog open={open} onOpenChange={(newOpen) => {
// Only allow closing if not currently submitting
if (!isSubmitting || !newOpen) {
onOpenChange(newOpen);
}
}}>
<DialogContent className="bg-black text-white border-gray-800 sm:max-w-[500px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-xl">Edit Service</DialogTitle>
<DialogDescription className="text-gray-400">
Update the details of your monitored service.
</DialogDescription>
</DialogHeader>
{open && service && (
<ScrollArea className="flex-1 pr-4 overflow-auto" style={{ height: "calc(80vh - 180px)" }}>
<div className="pr-2">
<ServiceForm
onSuccess={handleSuccess}
onCancel={handleCancel}
initialData={service}
isEdit={true}
onSubmitStart={() => setIsSubmitting(true)}
/>
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,142 @@
import { Form } from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { serviceSchema, ServiceFormData } from "./add-service/types";
import { ServiceBasicFields } from "./add-service/ServiceBasicFields";
import { ServiceTypeField } from "./add-service/ServiceTypeField";
import { ServiceConfigFields } from "./add-service/ServiceConfigFields";
import { ServiceNotificationFields } from "./add-service/ServiceNotificationFields";
import { ServiceFormActions } from "./add-service/ServiceFormActions";
import { serviceService } from "@/services/serviceService";
import { Service } from "@/types/service.types";
interface ServiceFormProps {
onSuccess: () => void;
onCancel: () => void;
initialData?: Service | null;
isEdit?: boolean;
onSubmitStart?: () => void;
}
export function ServiceForm({
onSuccess,
onCancel,
initialData,
isEdit = false,
onSubmitStart
}: ServiceFormProps) {
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form with default values
const form = useForm<ServiceFormData>({
resolver: zodResolver(serviceSchema),
defaultValues: {
name: "",
type: "http",
url: "",
interval: "60",
retries: "3",
notificationChannel: "",
alertTemplate: "",
},
mode: "onBlur",
});
// Populate form when initialData changes (separate from initialization)
useEffect(() => {
if (initialData && isEdit) {
// Reset the form with initial data values
form.reset({
name: initialData.name || "",
type: (initialData.type || "http").toLowerCase(),
url: initialData.url || "",
interval: String(initialData.interval || 60),
retries: String(initialData.retries || 3),
notificationChannel: initialData.notificationChannel || "",
alertTemplate: initialData.alertTemplate || "",
});
// Log for debugging
console.log("Populating form with URL:", initialData.url);
}
}, [initialData, isEdit, form]);
const handleSubmit = async (data: ServiceFormData) => {
if (isSubmitting) return;
setIsSubmitting(true);
if (onSubmitStart) onSubmitStart();
try {
console.log("Form data being submitted:", data); // Debug log for submitted data
if (isEdit && initialData) {
// Update existing service
await serviceService.updateService(initialData.id, {
name: data.name,
type: data.type,
url: data.url,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel === "none" ? "" : data.notificationChannel,
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
});
toast({
title: "Service updated",
description: `${data.name} has been updated successfully.`,
});
} else {
// Create new service
await serviceService.createService({
name: data.name,
type: data.type,
url: data.url,
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel === "none" ? undefined : data.notificationChannel,
alertTemplate: data.alertTemplate === "default" ? undefined : data.alertTemplate,
});
toast({
title: "Service created",
description: `${data.name} has been added to monitoring.`,
});
}
onSuccess();
if (!isEdit) {
form.reset();
}
} catch (error) {
console.error(`Error ${isEdit ? 'updating' : 'creating'} service:`, error);
toast({
title: `Failed to ${isEdit ? 'update' : 'create'} service`,
description: `An error occurred while ${isEdit ? 'updating' : 'creating'} the service.`,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-6">
<ServiceBasicFields form={form} />
<ServiceTypeField form={form} />
<ServiceConfigFields form={form} />
<ServiceNotificationFields form={form} />
<ServiceFormActions
isSubmitting={isSubmitting}
onCancel={onCancel}
submitLabel={isEdit ? "Update Service" : "Create Service"}
/>
</form>
</Form>
);
}
@@ -0,0 +1,83 @@
import { ArrowLeft, Globe, MoreVertical, FileText, Github, Twitter, MessageSquare, Bell } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/services/StatusBadge";
import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton";
import { Service } from "@/types/service.types";
import { useLanguage } from "@/contexts/LanguageContext";
import { cn } from "@/lib/utils";
interface ServiceHeaderProps {
service: Service;
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
}
export function ServiceHeader({ service, onStatusChange }: ServiceHeaderProps) {
const navigate = useNavigate();
const { t } = useLanguage();
return (
<div className="mb-6">
<Button
variant="ghost"
className="mb-4 pl-0 hover:bg-transparent"
onClick={() => navigate("/dashboard")}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{t("back")}
</Button>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center">
<h1 className="text-2xl font-bold">{service.name}</h1>
{/* Pulsating Circle Animation */}
<div className="relative ml-2 flex items-center">
<span
className={cn(
"flex h-3 w-3 relative",
service.status === "up" ? "bg-green-500" :
service.status === "down" ? "bg-red-500" :
service.status === "warning" ? "bg-yellow-500" :
"bg-blue-500",
"rounded-full"
)}
/>
<span
className={cn(
"animate-ping absolute h-3 w-3",
service.status === "up" ? "bg-green-400" :
service.status === "down" ? "bg-red-400" :
service.status === "warning" ? "bg-yellow-400" :
"bg-blue-400",
"rounded-full opacity-75"
)}
/>
</div>
{service.url && (
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
>
<Globe className="h-3 w-3 mr-1" />
{service.url}
</a>
)}
</div>
<div className="flex items-center space-x-4">
<StatusBadge status={service.status} size="lg" />
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
<Button variant="outline" size="icon" className="rounded-full w-8 h-8 border-border">
<span className="sr-only">More options</span>
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from "@/components/ui/dialog";
import { Service } from "@/types/service.types";
import { ServiceUptimeHistory } from "@/components/services/ServiceUptimeHistory";
import { useTheme } from "@/contexts/ThemeContext";
import { DateRangeFilter, DateRangeOption } from "@/components/services/DateRangeFilter";
interface ServiceHistoryDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
selectedService: Service | null;
}
export const ServiceHistoryDialog = ({
isOpen,
onOpenChange,
selectedService,
}: ServiceHistoryDialogProps) => {
const { theme } = useTheme();
const [startDate, setStartDate] = useState<Date>(new Date(Date.now() - 24 * 60 * 60 * 1000)); // Default to 24h ago
const [endDate, setEndDate] = useState<Date>(new Date());
// Reset date range when dialog opens to ensure fresh data
useEffect(() => {
if (isOpen) {
setStartDate(new Date(Date.now() - 24 * 60 * 60 * 1000));
setEndDate(new Date());
}
}, [isOpen]);
// Handle date range filter changes
const handleDateRangeChange = (start: Date, end: Date, option: DateRangeOption) => {
console.log(`ServiceHistoryDialog: Date range changed to ${start.toISOString()} - ${end.toISOString()}`);
setStartDate(start);
setEndDate(end);
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className={`${theme === 'dark' ? 'bg-black text-white border-gray-800' : 'bg-background text-foreground border-border'} sm:max-w-[800px]`}>
<DialogHeader>
<DialogTitle className="text-xl">
{selectedService?.name} - Uptime History
</DialogTitle>
<DialogDescription className={theme === 'dark' ? 'text-gray-400' : 'text-muted-foreground'}>
Showing the most recent uptime checks for this service.
{selectedService?.interval && (
<span> Checked every {selectedService.interval} seconds.</span>
)}
</DialogDescription>
</DialogHeader>
<div className="mb-4">
<DateRangeFilter onRangeChange={handleDateRangeChange} />
</div>
{selectedService && (
<ServiceUptimeHistory
serviceId={selectedService.id}
startDate={startDate}
endDate={endDate}
/>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,103 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Play, Pause } from "lucide-react";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast";
import { notificationService } from "@/services/notificationService";
interface ServiceMonitoringButtonProps {
service: Service;
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
}
export function ServiceMonitoringButton({ service, onStatusChange }: ServiceMonitoringButtonProps) {
const [isMonitoring, setIsMonitoring] = useState(service.status !== "paused");
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
// Update local state when service prop changes
useEffect(() => {
setIsMonitoring(service.status !== "paused");
}, [service.status]);
const handleToggleMonitoring = async () => {
try {
setIsLoading(true);
if (isMonitoring) {
// Pause monitoring
console.log(`Pausing monitoring for service ${service.id} (${service.name})`);
await serviceService.pauseMonitoring(service.id);
setIsMonitoring(false);
if (onStatusChange) onStatusChange("paused");
// Send notification for paused status (only here, not in pauseMonitoring.ts)
if (service.alerts !== "muted") {
console.log("Sending pause notification from UI component");
// IMPORTANT: Direct call to the notification service to ensure a message is sent
await notificationService.sendNotification({
service: service,
status: "paused",
timestamp: new Date().toISOString(),
});
}
toast({
title: "Monitoring paused",
description: `Monitoring for ${service.name} has been paused.`,
});
} else {
// Start/resume monitoring
console.log(`Starting monitoring for service ${service.id} (${service.name})`);
// First ensure we update the status in the database to not be paused anymore
await serviceService.resumeMonitoring(service.id);
setIsMonitoring(true);
// Perform an immediate check
await serviceService.startMonitoringService(service.id);
toast({
title: "Monitoring resumed",
description: `Monitoring for ${service.name} has been resumed. First check is running now.`,
});
}
} catch (error) {
console.error("Error toggling monitoring:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to change monitoring status. Please try again.",
});
} finally {
setIsLoading(false);
}
};
return (
<Button
variant="outline"
size="sm"
onClick={handleToggleMonitoring}
disabled={isLoading}
className={isMonitoring ? "bg-red-900/20 hover:bg-red-900/30" : "bg-green-900/20 hover:bg-green-900/30"}
>
{isLoading ? (
"Processing..."
) : isMonitoring ? (
<>
<Pause className="h-4 w-4 mr-2" />
Pause Monitoring
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Start Monitoring
</>
)}
</Button>
);
}
@@ -0,0 +1,25 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom";
export function ServiceNotFound() {
const navigate = useNavigate();
return (
<div className="flex items-center justify-center h-screen bg-background text-foreground">
<div className="text-center">
<AlertTriangle className="mx-auto h-12 w-12 text-yellow-500" />
<h2 className="text-xl font-bold mt-4">Service Not Found</h2>
<p className="mt-2 text-muted-foreground">The service you're looking for doesn't exist or has been deleted.</p>
<Button
className="mt-4"
variant="outline"
onClick={() => navigate("/dashboard")}
>
Back to Dashboard
</Button>
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import React from "react";
import { TableRow, TableCell } from "@/components/ui/table";
import { Service } from "@/types/service.types";
import { StatusBadge } from "./StatusBadge";
import { UptimeBar } from "./UptimeBar";
import { LastCheckedTime } from "./LastCheckedTime";
import {
ServiceRowActions,
ServiceRowHeader,
ServiceRowResponseTime
} from "./service-row";
import { useTheme } from "@/contexts/ThemeContext";
interface ServiceRowProps {
service: Service;
onViewDetail: (service: Service) => void;
onPauseResume: (service: Service) => Promise<void>;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
onMuteAlerts?: (service: Service) => Promise<void>;
}
export const ServiceRow = ({
service,
onViewDetail,
onPauseResume,
onEdit,
onDelete,
onMuteAlerts
}: ServiceRowProps) => {
const { theme } = useTheme();
const handleRowClick = () => {
onViewDetail(service);
};
// Get the timestamp to display - use only lastChecked since that's what's defined in the Service type
const displayTimestamp = service.lastChecked || new Date().toLocaleString();
return (
<TableRow
key={service.id}
className={`border-b ${theme === 'dark' ? 'border-gray-800 hover:bg-gray-900/60' : 'border-gray-200 hover:bg-gray-50'} cursor-pointer`}
onClick={handleRowClick}
>
<TableCell className="font-medium py-4" onClick={(e) => e.stopPropagation()}>
<ServiceRowHeader service={service} />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className={`text-base py-4 ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
{service.type}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<StatusBadge status={service.status} size="md" />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<ServiceRowResponseTime responseTime={service.responseTime} />
</TableCell>
<TableCell className="w-52 py-4" onClick={(e) => e.stopPropagation()}>
<UptimeBar uptime={service.uptime} status={service.status} serviceId={service.id} />
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<LastCheckedTime
lastCheckedTime={displayTimestamp}
status={service.status}
interval={service.interval}
/>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()} className="py-4">
<ServiceRowActions
service={service}
onViewDetail={onViewDetail}
onPauseResume={onPauseResume}
onEdit={onEdit}
onDelete={onDelete}
onMuteAlerts={onMuteAlerts}
/>
</TableCell>
</TableRow>
);
};
@@ -0,0 +1,171 @@
import { Clock, Server, ArrowUp, ArrowDown } from "lucide-react";
import { Service, UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useMemo } from "react";
import { formatDistanceStrict } from "date-fns";
interface ServiceStatsCardsProps {
service: Service;
uptimeData: UptimeData[];
}
export function ServiceStatsCards({ service, uptimeData }: ServiceStatsCardsProps) {
// Calculate uptime percentage
const calculateUptimePercentage = () => {
if (uptimeData.length === 0) return 100;
const totalChecks = uptimeData.length;
const upChecks = uptimeData.filter(data => data.status === "up").length;
return Math.round((upChecks / totalChecks) * 100);
};
// Calculate total uptime/downtime
const uptimeStats = useMemo(() => {
if (uptimeData.length === 0) {
return {
totalUptimeFormatted: "N/A",
totalDowntimeFormatted: "N/A",
currentStatusDuration: "N/A"
};
}
// Sort data by timestamp for chronological analysis
const sortedData = [...uptimeData].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Get the status change events
const statusChanges = sortedData.reduce((changes, check, index) => {
if (index === 0 || (index > 0 && sortedData[index-1].status !== check.status)) {
changes.push(check);
}
return changes;
}, [] as UptimeData[]);
let totalUptime = 0;
let totalDowntime = 0;
let currentStatus = service.status;
let lastChangeTime = new Date();
// Calculate durations between status changes
for (let i = 0; i < statusChanges.length; i++) {
const currentChange = statusChanges[i];
const nextChange = statusChanges[i + 1];
if (nextChange) {
const duration = new Date(nextChange.timestamp).getTime() - new Date(currentChange.timestamp).getTime();
if (currentChange.status === "up") {
totalUptime += duration;
} else {
totalDowntime += duration;
}
} else {
// For the last status, calculate time until now
lastChangeTime = new Date(currentChange.timestamp);
currentStatus = currentChange.status;
}
}
// Add time from last change until now for current status
const now = new Date();
const sinceLastChange = now.getTime() - lastChangeTime.getTime();
if (currentStatus === "up") {
totalUptime += sinceLastChange;
} else if (currentStatus === "down") {
totalDowntime += sinceLastChange;
}
// Format durations
const formatDuration = (ms: number): string => {
if (ms < 1000) return "0s";
return formatDistanceStrict(0, ms, { addSuffix: false });
};
return {
totalUptimeFormatted: formatDuration(totalUptime),
totalDowntimeFormatted: formatDuration(totalDowntime),
currentStatusDuration: formatDuration(sinceLastChange)
};
}, [uptimeData, service.status]);
// Calculate average response time from recent checks
const calculateAverageResponseTime = () => {
const upChecks = uptimeData.filter(data => data.status === "up" && data.responseTime > 0);
if (upChecks.length === 0) return service.responseTime || 0;
const sum = upChecks.reduce((total, check) => total + check.responseTime, 0);
return Math.round((sum / upChecks.length) * 100) / 100; // Two decimal precision
};
const uptimePercentage = calculateUptimePercentage();
const avgResponseTime = calculateAverageResponseTime();
// Define upChecks here so we can use it in both the calculation and the JSX
const upChecks = uptimeData.filter(data => data.status === "up" && data.responseTime > 0);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<Card className="border-blue-400 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-blue-800 dark:text-blue-100">Response Time</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-700 dark:text-white">
{service.responseTime}ms
</div>
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Last checked at {service.lastChecked}</p>
{avgResponseTime > 0 && avgResponseTime !== service.responseTime && (
<p className="text-xs text-blue-600 dark:text-blue-200 mt-1">Avg: {avgResponseTime}ms (last {upChecks.length} up checks)</p>
)}
</CardContent>
</Card>
<Card className="border-green-400 dark:border-green-700 bg-green-50 dark:bg-green-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-green-800 dark:text-green-100">Uptime</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-700 dark:text-white">
{uptimePercentage}%
</div>
<p className="text-xs text-green-600 dark:text-green-200 mt-1">Based on last {uptimeData.length} checks</p>
<div className="flex flex-col space-y-1 mt-2">
<div className="flex items-center text-xs text-green-600 dark:text-green-200">
<ArrowUp className="h-3 w-3 mr-1 text-green-600 dark:text-green-400" />
<span>Total uptime: {uptimeStats.totalUptimeFormatted}</span>
</div>
<div className="flex items-center text-xs text-red-600 dark:text-red-200">
<ArrowDown className="h-3 w-3 mr-1 text-red-600 dark:text-red-400" />
<span>Total downtime: {uptimeStats.totalDowntimeFormatted}</span>
</div>
{service.status !== "paused" && (
<div className="flex items-center text-xs font-medium mt-1">
<span className={service.status === "up" ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>
{service.status === "up" ? "Up" : "Down"} for {uptimeStats.currentStatusDuration}
</span>
</div>
)}
</div>
</CardContent>
</Card>
<Card className="border-purple-400 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/50 shadow-md">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-purple-800 dark:text-purple-100">Monitoring Settings</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200">
<Clock className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>Checked every {service.interval} seconds</span>
</div>
<div className="flex items-center text-sm text-purple-700 dark:text-purple-200 mt-1">
<Server className="h-4 w-4 mr-2 text-purple-600 dark:text-purple-400" />
<span>{service.type} monitoring</span>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,138 @@
import { useQuery } from "@tanstack/react-query";
import { UptimeData } from "@/types/service.types";
import { uptimeService } from "@/services/uptimeService";
import { format, parseISO } from "date-fns";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { useTheme } from "@/contexts/ThemeContext";
import { Check, X, AlertTriangle, Pause } from "lucide-react";
interface ServiceUptimeHistoryProps {
serviceId: string;
startDate?: Date;
endDate?: Date;
}
export function ServiceUptimeHistory({
serviceId,
startDate = new Date(Date.now() - 24 * 60 * 60 * 1000),
endDate = new Date()
}: ServiceUptimeHistoryProps) {
const { theme } = useTheme();
const { data: uptimeHistory, isLoading, error } = useQuery({
queryKey: ['uptimeHistory', serviceId, startDate?.toISOString(), endDate?.toISOString()],
queryFn: () => uptimeService.getUptimeHistory(serviceId, 200, startDate, endDate),
enabled: !!serviceId,
refetchInterval: 5000, // Refresh UI every 5 seconds
});
if (isLoading) {
return (
<div className="py-4 text-center">
<p>Loading uptime history...</p>
</div>
);
}
if (error) {
return (
<div className="py-4 text-center">
<p>Error loading uptime history.</p>
</div>
);
}
if (!uptimeHistory || uptimeHistory.length === 0) {
return (
<div className="py-4 text-center">
<p>No uptime history available for the selected time period.</p>
</div>
);
}
// Function to get appropriate status badge styling
const getStatusBadge = (status: string) => {
switch(status) {
case 'up':
return {
variant: 'default' as const,
className: 'bg-emerald-800 text-white hover:bg-emerald-700',
icon: <Check className="h-3 w-3 mr-1" />
};
case 'warning':
return {
variant: 'outline' as const,
className: 'bg-yellow-800/80 text-yellow-300 border-yellow-700 hover:bg-yellow-800',
icon: <AlertTriangle className="h-3 w-3 mr-1" />
};
case 'paused':
return {
variant: 'outline' as const,
className: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800',
icon: <Pause className="h-3 w-3 mr-1" />
};
default:
return {
variant: 'destructive' as const,
className: '',
icon: <X className="h-3 w-3 mr-1" />
};
}
};
return (
<div className="mt-4 bg-card rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-b border-border">
<TableHead className="text-muted-foreground font-medium">Time</TableHead>
<TableHead className="text-muted-foreground font-medium">Status</TableHead>
<TableHead className="text-muted-foreground font-medium">Response Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{uptimeHistory.map((record) => {
const statusBadge = getStatusBadge(record.status);
const isResponseTimeHigh = record.responseTime >= 1000;
return (
<TableRow key={record.id} className="border-b border-border">
<TableCell>
{format(parseISO(record.timestamp), 'yyyy-MM-dd HH:mm:ss')}
</TableCell>
<TableCell>
<Badge
variant={statusBadge.variant}
className={statusBadge.className}
>
<span className="flex items-center">
{statusBadge.icon}
{record.status === 'up' ? 'Up' :
record.status === 'down' ? 'Down' :
record.status === 'warning' ? 'Warning' : 'Paused'}
</span>
</Badge>
</TableCell>
<TableCell>
<div className="font-mono flex items-center gap-1.5">
{record.responseTime > 0 ? (
<>
<span className={isResponseTimeHigh ? "text-amber-500 font-semibold" : ""}>
{record.responseTime}ms
</span>
{isResponseTimeHigh && (
<AlertTriangle className="h-3 w-3 text-amber-500" />
)}
</>
) : 'N/A'}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,94 @@
import { useEffect } from "react";
import { Service } from "@/types/service.types";
import { ServicesTableView } from "./ServicesTableView";
import { ServiceDeleteDialog } from "./ServiceDeleteDialog";
import { ServiceHistoryDialog } from "./ServiceHistoryDialog";
import { ServiceEditDialog } from "./ServiceEditDialog";
import { useServiceActions, useDialogState } from "./hooks";
interface ServicesTableContainerProps {
services: Service[];
}
export const ServicesTableContainer = ({ services }: ServicesTableContainerProps) => {
const {
services: localServices,
selectedService,
isDeleting,
setSelectedService,
updateServices,
handleViewDetail,
handlePauseResume,
handleEdit,
handleDelete,
confirmDelete,
handleMuteAlerts
} = useServiceActions(services);
const {
isHistoryDialogOpen,
isDeleteDialogOpen,
isEditDialogOpen,
setIsHistoryDialogOpen,
setIsDeleteDialogOpen,
handleEditDialogChange,
handleDeleteDialogChange
} = useDialogState();
// Update local services state when props change
useEffect(() => {
updateServices(services);
}, [services]);
// Handler functions that combine local state management
const onEdit = (service: Service) => {
const selectedService = handleEdit(service);
setTimeout(() => {
handleEditDialogChange(true);
}, 0);
};
const onDelete = (service: Service) => {
handleDelete(service);
setIsDeleteDialogOpen(true);
};
const openHistoryDialog = (service: Service) => {
setSelectedService(service);
setIsHistoryDialogOpen(true);
};
return (
<div className="flex-1 flex flex-col h-full">
<ServicesTableView
services={localServices}
onViewDetail={handleViewDetail}
onPauseResume={handlePauseResume}
onEdit={onEdit}
onDelete={onDelete}
onMuteAlerts={handleMuteAlerts}
/>
<ServiceHistoryDialog
isOpen={isHistoryDialogOpen}
onOpenChange={setIsHistoryDialogOpen}
selectedService={selectedService}
/>
<ServiceDeleteDialog
isOpen={isDeleteDialogOpen}
onOpenChange={(open) => handleDeleteDialogChange(open, isDeleting)}
selectedService={selectedService}
onConfirmDelete={confirmDelete}
isDeleting={isDeleting}
/>
<ServiceEditDialog
open={isEditDialogOpen}
onOpenChange={handleEditDialogChange}
service={selectedService}
/>
</div>
);
}
@@ -0,0 +1,68 @@
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Service } from "@/types/service.types";
import { ServiceRow } from "@/components/services/ServiceRow";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface ServicesTableViewProps {
services: Service[];
onViewDetail: (service: Service) => void;
onPauseResume: (service: Service) => Promise<void>;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
onMuteAlerts?: (service: Service) => Promise<void>;
}
export const ServicesTableView = ({
services,
onViewDetail,
onPauseResume,
onEdit,
onDelete,
onMuteAlerts
}: ServicesTableViewProps) => {
const { theme } = useTheme();
const { t } = useLanguage();
return (
<div className={`${theme === 'dark' ? 'bg-gray-900' : 'bg-white'} rounded-lg overflow-hidden border border-border flex-1 flex flex-col shadow-sm`}>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className={`${theme === 'dark' ? 'bg-gray-800' : 'bg-gray-50'} sticky top-0 z-10`}>
<TableRow className={`${theme === 'dark' ? 'border-gray-700 hover:bg-gray-800' : 'border-gray-200 hover:bg-gray-100'}`}>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceName")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceType")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("serviceStatus")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("responseTime")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("uptime")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("lastChecked")}</TableHead>
<TableHead className={`${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'} font-medium text-base py-4`}>{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.length > 0 ? (
services.map((service) => (
<ServiceRow
key={service.id}
service={service}
onViewDetail={onViewDetail}
onPauseResume={onPauseResume}
onEdit={onEdit}
onDelete={onDelete}
onMuteAlerts={onMuteAlerts}
/>
))
) : (
<TableRow>
<TableCell colSpan={7} className={`text-center py-8 text-base ${theme === 'dark' ? 'text-gray-300' : 'text-gray-500'}`}>
{t("noServices")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
@@ -0,0 +1,71 @@
import React from "react";
import { Check, X, Pause, AlertTriangle } from "lucide-react";
export interface StatusBadgeProps {
status: string;
size?: "sm" | "md" | "lg";
}
export const StatusBadge = ({ status, size = "sm" }: StatusBadgeProps) => {
// Determine the sizing classes based on the size prop
const getSizeClasses = () => {
switch (size) {
case "lg":
return "px-3 py-1.5 text-sm gap-1.5";
case "md":
return "px-2.5 py-1 text-sm gap-1.5";
case "sm":
default:
return "px-2 py-0.5 text-xs gap-0.5";
}
};
const getIconSize = () => {
switch (size) {
case "lg":
return "h-4 w-4";
case "md":
return "h-4 w-4";
case "sm":
default:
return "h-3 w-3";
}
};
const sizeClasses = getSizeClasses();
const iconSize = getIconSize();
switch (status) {
case "up":
return (
<div className={`flex items-center bg-emerald-950/60 dark:bg-emerald-950/60 text-emerald-400 font-medium rounded-full w-fit ${sizeClasses}`}>
<Check className={iconSize} />
<span>Up</span>
</div>
);
case "down":
return (
<div className={`flex items-center bg-red-950/60 dark:bg-red-950/60 text-red-400 font-medium rounded-full w-fit ${sizeClasses}`}>
<X className={iconSize} />
<span>Down</span>
</div>
);
case "warning":
return (
<div className={`flex items-center bg-yellow-950/60 dark:bg-yellow-950/60 text-yellow-400 font-medium rounded-full w-fit ${sizeClasses}`}>
<AlertTriangle className={iconSize} />
<span>Warning</span>
</div>
);
case "paused":
return (
<div className={`flex items-center bg-gray-200/30 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 font-medium rounded-full w-fit ${sizeClasses}`}>
<Pause className={iconSize} />
<span>Paused</span>
</div>
);
default:
return null;
}
};
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from "react";
import { Progress } from "@/components/ui/progress";
import { Check, X, AlertTriangle, Pause, Clock, Info, RefreshCcw } from "lucide-react";
import { useTheme } from "@/contexts/ThemeContext";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger
} from "@/components/ui/hover-card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { uptimeService } from "@/services/uptimeService";
import { UptimeData } from "@/types/service.types";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
interface UptimeBarProps {
uptime: number;
status: string;
serviceId?: string; // Optional serviceId to fetch history
}
export const UptimeBar = ({ uptime, status, serviceId }: UptimeBarProps) => {
const { theme } = useTheme();
const [historyItems, setHistoryItems] = useState<UptimeData[]>([]);
// 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([]),
enabled: !!serviceId,
refetchInterval: 30000, // Refresh every 30 seconds
staleTime: 15000, // Consider data fresh for 15 seconds
placeholderData: (previousData) => previousData, // Show previous data while refetching
retry: 3, // Retry failed requests three times
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // Exponential backoff with max 10s
});
// Update history items when data changes
useEffect(() => {
if (uptimeData && uptimeData.length > 0) {
setHistoryItems(uptimeData);
} 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")
? status
: "paused"; // Default to paused if not a valid status
const placeholderHistory: UptimeData[] = Array(20).fill(null).map((_, index) => ({
id: `placeholder-${index}`,
serviceId: serviceId || "",
timestamp: new Date().toISOString(),
status: statusValue as "up" | "down" | "warning" | "paused",
responseTime: 0
}));
setHistoryItems(placeholderHistory);
}
}, [uptimeData, serviceId, status]);
// Get appropriate color classes for each status type
const getStatusColor = (itemStatus: string) => {
switch(itemStatus) {
case "up":
return theme === "dark" ? "bg-emerald-500" : "bg-emerald-500";
case "down":
return theme === "dark" ? "bg-red-500" : "bg-red-500";
case "warning":
return theme === "dark" ? "bg-yellow-500" : "bg-yellow-500";
case "paused":
default:
return theme === "dark" ? "bg-gray-500" : "bg-gray-400";
}
};
// Get status label
const getStatusLabel = (itemStatus: string): string => {
switch(itemStatus) {
case "up": return "Online";
case "down": return "Offline";
case "warning": return "Degraded";
case "paused": return "Paused";
default: return "Unknown";
}
};
// Format timestamp for display
const formatTimestamp = (timestamp: string): string => {
try {
return new Date(timestamp).toLocaleString([], {
hour: '2-digit',
minute: '2-digit',
month: 'short',
day: 'numeric'
});
} catch (e) {
return timestamp;
}
};
// If still loading and no history, show improved loading state
if ((isLoading || isFetching) && historyItems.length === 0) {
// Show skeleton loading UI instead of text
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`skeleton-${index}`}
className={`h-5 w-1.5 rounded-sm bg-muted animate-pulse`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground w-16 h-4 bg-muted animate-pulse rounded"></span>
<span className="text-muted-foreground w-24 h-4 bg-muted animate-pulse rounded"></span>
</div>
</div>
);
}
// If there's an error and no history, show improved error state with retry button
if (error && historyItems.length === 0) {
// Provide visual error state that matches the design system
return (
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{Array(20).fill(0).map((_, index) => (
<div
key={`error-${index}`}
className={`h-5 w-1.5 rounded-sm bg-gray-700 opacity-40`}
/>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{Math.round(uptime)}% uptime</span>
<button
onClick={() => refetch()}
className="text-xs text-red-400 flex items-center gap-1 hover:text-red-300 transition-colors"
>
<X className="h-3 w-3" /> Connection error
<RefreshCcw className="h-3 w-3 ml-1" />
</button>
</div>
</div>
);
}
// Ensure we always have 20 items by padding with the last known status
const displayItems = [...historyItems];
if (displayItems.length < 20) {
const lastItem = displayItems.length > 0 ? displayItems[displayItems.length - 1] : null;
const lastStatus = lastItem ? lastItem.status :
(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
}));
displayItems.push(...paddingItems);
}
// Limit to 20 items for display
const limitedItems = displayItems.slice(0, 20);
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-col w-full gap-1">
<div className="flex items-center space-x-0.5 w-full h-6">
{limitedItems.map((item, index) => (
<Tooltip key={item.id || `status-${index}`}>
<TooltipTrigger asChild>
<div
className={`h-5 w-1.5 rounded-sm ${getStatusColor(item.status)} cursor-pointer hover:opacity-80 transition-opacity`}
/>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-900 text-white border-gray-800 px-3 py-2"
>
<div className="flex flex-col gap-1 text-xs">
<div className="font-medium">{getStatusLabel(item.status)}</div>
<div>
{item.status !== "paused" && item.status !== "down" ?
`${item.responseTime}ms` :
"No response"}
</div>
<div className="text-gray-400">
{formatTimestamp(item.timestamp)}
</div>
</div>
</TooltipContent>
</Tooltip>
))}
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{Math.round(uptime)}% uptime
</span>
<span className="text-xs text-muted-foreground">
Last 20 checks
</span>
</div>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,55 @@
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceBasicFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) {
return (
<>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Service Name</FormLabel>
<FormControl>
<Input
placeholder="Service Name"
className="bg-black border-gray-700"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>Service URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com"
className="bg-black border-gray-700"
{...field}
onChange={(e) => {
console.log("URL field changed:", e.target.value);
field.onChange(e);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
@@ -0,0 +1,51 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceConfigFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
return (
<>
<FormField
control={form.control}
name="interval"
render={({ field }) => (
<FormItem>
<FormLabel>Heartbeat Interval</FormLabel>
<FormControl>
<Input
type="number"
placeholder="60"
className="bg-black border-gray-700"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="retries"
render={({ field }) => (
<FormItem>
<FormLabel>Maximum Retries</FormLabel>
<FormControl>
<Input
type="number"
placeholder="3"
className="bg-black border-gray-700"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</>
);
}
@@ -0,0 +1,143 @@
import { Form } from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { serviceSchema, ServiceFormData } from "./types";
import { ServiceBasicFields } from "./ServiceBasicFields";
import { ServiceTypeField } from "./ServiceTypeField";
import { ServiceConfigFields } from "./ServiceConfigFields";
import { ServiceNotificationFields } from "./ServiceNotificationFields";
import { ServiceFormActions } from "./ServiceFormActions";
import { serviceService } from "@/services/serviceService";
import { Service } from "@/types/service.types";
interface ServiceFormProps {
onSuccess: () => void;
onCancel: () => void;
initialData?: Service | null;
isEdit?: boolean;
onSubmitStart?: () => void;
}
export function ServiceForm({
onSuccess,
onCancel,
initialData,
isEdit = false,
onSubmitStart
}: ServiceFormProps) {
const { toast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form with default values
const form = useForm<ServiceFormData>({
resolver: zodResolver(serviceSchema),
defaultValues: {
name: "",
type: "http",
url: "",
interval: "60",
retries: "3",
notificationChannel: "",
alertTemplate: "",
},
mode: "onBlur",
});
// Populate form when initialData changes (separate from initialization)
useEffect(() => {
if (initialData && isEdit) {
// Reset the form with initial data values
form.reset({
name: initialData.name || "",
type: (initialData.type || "http").toLowerCase(),
url: initialData.url || "",
interval: String(initialData.interval || 60),
retries: String(initialData.retries || 3),
notificationChannel: initialData.notificationChannel || "",
alertTemplate: initialData.alertTemplate || "",
});
// Log for debugging
console.log("Populating form with URL:", initialData.url);
}
}, [initialData, isEdit, form]);
const handleSubmit = async (data: ServiceFormData) => {
if (isSubmitting) return;
setIsSubmitting(true);
if (onSubmitStart) onSubmitStart();
try {
console.log("Form data being submitted:", data); // Debug log for submitted data
if (isEdit && initialData) {
// Update existing service
await serviceService.updateService(initialData.id, {
name: data.name,
type: data.type,
url: data.url, // Ensure URL is included here
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel || undefined,
alertTemplate: data.alertTemplate || undefined,
});
toast({
title: "Service updated",
description: `${data.name} has been updated successfully.`,
});
} else {
// Create new service
await serviceService.createService({
name: data.name,
type: data.type,
url: data.url, // Ensure URL is included here
interval: parseInt(data.interval),
retries: parseInt(data.retries),
notificationChannel: data.notificationChannel || undefined,
alertTemplate: data.alertTemplate || undefined,
});
toast({
title: "Service created",
description: `${data.name} has been added to monitoring.`,
});
}
onSuccess();
if (!isEdit) {
form.reset();
}
} catch (error) {
console.error(`Error ${isEdit ? 'updating' : 'creating'} service:`, error);
toast({
title: `Failed to ${isEdit ? 'update' : 'create'} service`,
description: `An error occurred while ${isEdit ? 'updating' : 'creating'} the service.`,
variant: "destructive",
});
} finally {
setIsSubmitting(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6 pb-4">
<ServiceBasicFields form={form} />
<ServiceTypeField form={form} />
<ServiceConfigFields form={form} />
<ServiceNotificationFields form={form} />
<ServiceFormActions
isSubmitting={isSubmitting}
onCancel={onCancel}
submitLabel={isEdit ? "Update Service" : "Create Service"}
/>
</form>
</Form>
);
}
@@ -0,0 +1,51 @@
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { MouseEvent } from "react";
interface ServiceFormActionsProps {
isSubmitting: boolean;
onCancel: () => void;
submitLabel?: string;
}
export function ServiceFormActions({
isSubmitting,
onCancel,
submitLabel = "Create Service"
}: ServiceFormActionsProps) {
const handleCancel = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!isSubmitting) {
onCancel();
}
};
return (
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
onClick={handleCancel}
variant="outline"
disabled={isSubmitting}
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-white"
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
submitLabel
)}
</Button>
</div>
);
}
@@ -0,0 +1,139 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
import { useQuery } from "@tanstack/react-query";
import { templateService } from "@/services/templateService";
import { alertConfigService, AlertConfiguration } from "@/services/alertConfigService";
import { useState, useEffect } from "react";
interface ServiceNotificationFieldsProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceNotificationFields({ form }: ServiceNotificationFieldsProps) {
const [alertConfigs, setAlertConfigs] = useState<AlertConfiguration[]>([]);
// Get the current form values for debugging
const notificationChannel = form.watch("notificationChannel");
const alertTemplate = form.watch("alertTemplate");
console.log("Current notification values:", {
notificationChannel,
alertTemplate
});
// Fetch alert configurations for notification channels
const { data: alertConfigsData } = useQuery({
queryKey: ['alertConfigs'],
queryFn: () => alertConfigService.getAlertConfigurations(),
});
// Fetch templates for template selection
const { data: templates } = useQuery({
queryKey: ['templates'],
queryFn: () => templateService.getTemplates(),
});
// Update alert configs when data is loaded
useEffect(() => {
if (alertConfigsData) {
setAlertConfigs(alertConfigsData);
// Debug log to check what alert configs are loaded
console.log("Loaded alert configurations:", alertConfigsData);
}
}, [alertConfigsData]);
// Log when form values change to debug
useEffect(() => {
console.log("Notification values changed:", {
notificationChannel: form.getValues("notificationChannel"),
alertTemplate: form.getValues("alertTemplate")
});
}, [form.watch("notificationChannel"), form.watch("alertTemplate")]);
return (
<>
<FormField
control={form.control}
name="notificationChannel"
render={({ field }) => {
// Important: We need to preserve the actual value for notification channel
const fieldValue = field.value || "";
const displayValue = fieldValue === "" ? "none" : fieldValue;
console.log("Rendering notification channel field with value:", {
fieldValue,
displayValue
});
return (
<FormItem>
<FormLabel>Notification Channel</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
console.log("Notification channel changed to:", value);
field.onChange(value === "none" ? "" : value);
}}
value={displayValue}
>
<SelectTrigger className="bg-black border-gray-700">
<SelectValue placeholder="Select a notification channel" />
</SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700">
<SelectItem value="none">None</SelectItem>
{alertConfigs.map((config) => (
<SelectItem key={config.id} value={config.id || ""}>
{config.notify_name} ({config.notification_type})
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="alertTemplate"
render={({ field }) => {
// Don't convert existing values to "default"
const displayValue = field.value || "default";
console.log("Rendering alert template field with value:", displayValue);
return (
<FormItem>
<FormLabel>Alert Template</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
console.log("Alert template changed to:", value);
field.onChange(value === "default" ? "" : value);
}}
value={displayValue}
>
<SelectTrigger className="bg-black border-gray-700">
<SelectValue placeholder="Select an alert template" />
</SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700">
<SelectItem value="default">Default</SelectItem>
{templates?.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
);
}}
/>
</>
);
}
@@ -0,0 +1,58 @@
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Globe } from "lucide-react";
import { UseFormReturn } from "react-hook-form";
import { ServiceFormData } from "./types";
interface ServiceTypeFieldProps {
form: UseFormReturn<ServiceFormData>;
}
export function ServiceTypeField({ form }: ServiceTypeFieldProps) {
return (
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Service Type</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger className="bg-black border-gray-700">
<SelectValue>
{field.value === "http" && (
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
<span>HTTP/S</span>
</div>
)}
{field.value !== "http" && "Select a service type"}
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-gray-900 text-white border-gray-700">
<SelectItem value="http">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" />
<span>HTTP/S</span>
</div>
<p className="text-xs text-gray-400 mt-1">
Monitor websites and REST APIs with HTTP/HTTPS protocol
</p>
</div>
</SelectItem>
<SelectItem value="ping">PING</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="dns">DNS</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
);
}
@@ -0,0 +1,3 @@
export * from "./ServiceForm";
export * from "./types";
@@ -0,0 +1,14 @@
import { z } from "zod";
export const serviceSchema = z.object({
name: z.string().min(1, "Service name is required"),
type: z.string().min(1, "Service type is required"),
url: z.string().min(1, "Service URL is required"),
interval: z.string().min(1, "Heartbeat interval is required"),
retries: z.string().min(1, "Maximum retries is required"),
notificationChannel: z.string().optional(),
alertTemplate: z.string().optional(),
});
export type ServiceFormData = z.infer<typeof serviceSchema>;
@@ -0,0 +1,3 @@
export * from './useServiceActions';
export * from './useDialogState';
@@ -0,0 +1,30 @@
import { useState } from "react";
export function useDialogState() {
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const handleEditDialogChange = (open: boolean) => {
setIsEditDialogOpen(open);
};
const handleDeleteDialogChange = (open: boolean, isDeleting: boolean = false) => {
// Only allow closing if not currently deleting
if (!isDeleting || !open) {
setIsDeleteDialogOpen(open);
}
};
return {
isHistoryDialogOpen,
isDeleteDialogOpen,
isEditDialogOpen,
setIsHistoryDialogOpen,
setIsDeleteDialogOpen,
setIsEditDialogOpen,
handleEditDialogChange,
handleDeleteDialogChange
};
}
@@ -0,0 +1,205 @@
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { pb } from "@/lib/pocketbase";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { recordMuteStatusChange } from "@/services/monitoring/utils/notificationUtils";
export function useServiceActions(initialServices: Service[]) {
const [services, setServices] = useState<Service[]>(initialServices);
const [selectedService, setSelectedService] = useState<Service | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
// Update services state when props change
const updateServices = (newServices: Service[]) => {
if (JSON.stringify(services) !== JSON.stringify(newServices)) {
setServices(newServices);
}
};
const handleViewDetail = (service: Service) => {
navigate(`/service/${service.id}`);
};
const handlePauseResume = async (service: Service) => {
try {
if (service.status === "paused") {
// Resume monitoring
await serviceService.startMonitoringService(service.id);
toast({
title: "Service resumed",
description: `${service.name} monitoring has been resumed successfully.`,
});
// Update local state - ensure status is properly typed as "up"
const updatedServices = services.map(s =>
s.id === service.id ? { ...s, status: "up" as const } : s
);
setServices(updatedServices);
} else {
// Pause monitoring
await serviceService.pauseMonitoring(service.id);
// Get the pause time and update local state
const pauseTime = new Date().toISOString();
const updatedServices = services.map(s =>
s.id === service.id ? { ...s, status: "paused" as const, lastChecked: pauseTime } : s
);
setServices(updatedServices);
toast({
title: "Service paused",
description: `${service.name} monitoring has been paused successfully.`,
});
}
// Invalidate the services query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["services"] });
} catch (error) {
console.error("Error updating service status:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to update service status. Please try again.",
});
}
};
const handleEdit = (service: Service) => {
setSelectedService({...service}); // Create a copy to avoid reference issues
return service;
};
const handleDelete = (service: Service) => {
setSelectedService(service);
return service;
};
// Modified to return Promise<void> instead of Promise<boolean>
const confirmDelete = async (): Promise<void> => {
if (!selectedService || isDeleting) return;
try {
setIsDeleting(true);
// First try to pause monitoring for this service to prevent any concurrency issues
if (selectedService.status !== "paused") {
await serviceService.pauseMonitoring(selectedService.id);
}
// Set a timeout to prevent hanging UI
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Delete request timed out")), 10000);
});
const deletePromise = pb.collection('services').delete(selectedService.id);
await Promise.race([deletePromise, timeoutPromise]);
toast({
title: "Service deleted",
description: `${selectedService.name} has been deleted successfully.`,
});
// Update local state
const updatedServices = services.filter(s => s.id !== selectedService.id);
setServices(updatedServices);
// Invalidate the services query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["services"] });
setSelectedService(null);
} catch (error) {
console.error("Error deleting service:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to delete service. Please try again.",
});
} finally {
setIsDeleting(false);
}
};
const handleMuteAlerts = async (service: Service) => {
try {
// Check alerts status - check both fields for backward compatibility
const isMuted = service.alerts === "muted" || service.muteAlerts === true;
// Toggle the mute alerts status for this specific service
const newMuteStatus = !isMuted;
console.log(`${newMuteStatus ? "Muting" : "Unmuting"} alerts for service ${service.id} (${service.name})`);
// First update the local state immediately for better UI responsiveness
// Using proper type casting to ensure TypeScript knows we're creating valid Service objects
const updatedServices = services.map(s => {
if (s.id === service.id) {
return {
...s,
muteAlerts: newMuteStatus,
alerts: newMuteStatus ? "muted" as const : "unmuted" as const
};
}
return s;
});
setServices(updatedServices);
// Record the mute status change (this will also update the service record)
await recordMuteStatusChange(service.id, service.name, newMuteStatus);
// Show a toast message
toast({
title: newMuteStatus ? "Alerts muted" : "Alerts unmuted",
description: `Notifications for ${service.name} are now ${newMuteStatus ? "muted" : "enabled"}.`,
});
// Immediately invalidate the services query to trigger a refetch
// This ensures our local state matches the database state
await queryClient.invalidateQueries({ queryKey: ["services"] });
} catch (error) {
console.error("Error updating alert settings:", error);
// Revert the local state change if the server update failed
const revertedServices = services.map(s => {
if (s.id === service.id) {
return {
...s,
muteAlerts: service.muteAlerts,
alerts: service.alerts
};
}
return s;
});
setServices(revertedServices);
toast({
variant: "destructive",
title: "Error",
description: `Failed to ${!service.muteAlerts ? "mute" : "unmute"} alerts for ${service.name}. Please try again.`,
});
}
};
return {
services,
selectedService,
isDeleting,
setSelectedService,
updateServices,
handleViewDetail,
handlePauseResume,
handleEdit,
handleDelete,
confirmDelete,
handleMuteAlerts
};
}
@@ -0,0 +1,19 @@
interface EmptyStateProps {
statusFilter: string;
}
export function EmptyState({ statusFilter }: EmptyStateProps) {
return (
<div className="text-center py-8 text-muted-foreground">
{statusFilter === "all"
? "No incidents recorded in selected time period"
: `No ${
statusFilter === "up" ? "uptime" :
statusFilter === "down" ? "downtime" :
statusFilter === "warning" ? "warning" :
"paused"
} incidents recorded in selected time period`}
</div>
);
}
@@ -0,0 +1,61 @@
import { format } from "date-fns";
import { UptimeData } from "@/types/service.types";
import { getStatusInfo } from "./utils";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useTheme } from "@/contexts/ThemeContext";
import { useLanguage } from "@/contexts/LanguageContext";
interface IncidentTableProps {
incidents: UptimeData[];
}
export function IncidentTable({ incidents }: IncidentTableProps) {
const { theme } = useTheme();
const { t } = useLanguage();
if (incidents.length === 0) {
return null;
}
return (
<Table className={theme === 'dark' ? 'text-gray-200' : 'text-gray-700'}>
<TableHeader className={theme === 'dark' ? 'bg-gray-800/50' : 'bg-gray-50'}>
<TableRow className={theme === 'dark' ? 'border-gray-700' : 'border-gray-200'}>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("time")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("status")}</TableHead>
<TableHead className={theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}>{t("responseTime")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{incidents.map((check, index) => {
const statusInfo = getStatusInfo(check.status);
const timestamp = new Date(check.timestamp);
return (
<TableRow key={check.id || `incident-${index}`} className={theme === 'dark' ? 'border-gray-800 hover:bg-gray-800/30' : 'border-gray-200 hover:bg-gray-50'}>
<TableCell>
<div className="flex flex-col">
<span>{format(timestamp, 'MMM dd, yyyy')}</span>
<span className={`text-xs ${theme === 'dark' ? 'text-gray-400' : 'text-gray-500'}`}>
{format(timestamp, 'h:mm a')}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center">
{statusInfo.badge}
</div>
</TableCell>
<TableCell className={`font-mono ${theme === 'dark' ? 'text-gray-300' : 'text-gray-700'}`}>
{check.status !== "paused" && check.responseTime > 0
? `${check.responseTime}ms`
: "N/A"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
}
@@ -0,0 +1,94 @@
import { useState, useEffect, useMemo } from "react";
import { UptimeData } from "@/types/service.types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { StatusFilterTabs } from "./StatusFilterTabs";
import { TablePagination } from "./TablePagination";
import { EmptyState } from "./EmptyState";
import { IncidentTable } from "./IncidentTable";
import { StatusFilter, PageSize } from "./types";
import { getStatusChangeEvents } from "./utils";
import { useTheme } from "@/contexts/ThemeContext";
export function LatestChecksTable({ uptimeData }: { uptimeData: UptimeData[] }) {
// Get current theme
const { theme } = useTheme();
// Filter state
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState<PageSize>("25");
// Reset to first page when filters change
useEffect(() => {
setCurrentPage(1);
}, [statusFilter, pageSize]);
// Filter incidents by status
const incidents = useMemo(() => {
const statusChanges = getStatusChangeEvents(uptimeData);
console.log(`Total status changes: ${statusChanges.length}`);
console.log(`Status types in incidents: ${[...new Set(statusChanges.map(i => i.status))].join(', ')}`);
if (statusFilter === "all") return statusChanges;
return statusChanges.filter(incident => incident.status === statusFilter);
}, [uptimeData, statusFilter]);
// Calculate pagination
const { paginatedIncidents, totalPages } = useMemo(() => {
if (pageSize === "all") {
return {
paginatedIncidents: incidents,
totalPages: 1,
};
}
const itemsPerPage = parseInt(pageSize, 10);
const pages = Math.ceil(incidents.length / itemsPerPage);
const start = (currentPage - 1) * itemsPerPage;
const end = start + itemsPerPage;
return {
paginatedIncidents: incidents.slice(start, end),
totalPages: Math.max(1, pages),
};
}, [incidents, currentPage, pageSize]);
// Calculate items per page for pagination display
const itemsPerPage = pageSize === "all" ? incidents.length : parseInt(pageSize, 10);
console.log(`Status Filter: ${statusFilter}, Incidents: ${incidents.length}, Includes paused: ${incidents.some(i => i.status === 'paused')}`);
return (
<Card className={`mb-6 transition-colors ${theme === 'dark' ? 'bg-card border-border' : 'bg-white border-gray-200'}`}>
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<CardTitle className="text-card-foreground">
<span>Incident History</span>
</CardTitle>
<StatusFilterTabs statusFilter={statusFilter} onStatusFilterChange={setStatusFilter} />
</div>
</CardHeader>
<CardContent>
{incidents.length === 0 ? (
<EmptyState statusFilter={statusFilter} />
) : (
<>
<IncidentTable incidents={paginatedIncidents} />
<TablePagination
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
totalItems={incidents.length}
itemsPerPage={itemsPerPage}
onPageChange={setCurrentPage}
onPageSizeChange={setPageSize}
/>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,66 @@
import { Activity, AlertTriangle, CheckCircle, Pause, X } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusFilter } from "./types";
import { useTheme } from "@/contexts/ThemeContext";
interface StatusFilterTabsProps {
statusFilter: StatusFilter;
onStatusFilterChange: (value: StatusFilter) => void;
}
export function StatusFilterTabs({
statusFilter,
onStatusFilterChange
}: StatusFilterTabsProps) {
// Get current theme to apply appropriate styling
const { theme } = useTheme();
return (
<Tabs
value={statusFilter}
onValueChange={value => onStatusFilterChange(value as StatusFilter)}
className="w-full"
>
<TabsList className={`grid grid-cols-5 w-full max-w-md rounded-full ${
theme === 'dark' ? 'bg-secondary' : 'bg-slate-100'
}`}>
<TabsTrigger
value="all"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<Activity className="h-4 w-4" />
<span>All</span>
</TabsTrigger>
<TabsTrigger
value="up"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<CheckCircle className="h-4 w-4" />
<span>Up</span>
</TabsTrigger>
<TabsTrigger
value="down"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<X className="h-4 w-4" />
<span>Down</span>
</TabsTrigger>
<TabsTrigger
value="warning"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<AlertTriangle className="h-4 w-4" />
<span>Warning</span>
</TabsTrigger>
<TabsTrigger
value="paused"
className="rounded-full flex items-center gap-1 data-[state=active]:bg-[#1A1F2C] data-[state=active]:text-[#D6BCFA] text-[#8E9196]"
>
<Pause className="h-4 w-4" />
<span>Paused</span>
</TabsTrigger>
</TabsList>
</Tabs>
);
}
@@ -0,0 +1,118 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { PageSize } from "./types";
interface TablePaginationProps {
currentPage: number;
totalPages: number;
pageSize: PageSize;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: PageSize) => void;
}
export function TablePagination({
currentPage,
totalPages,
pageSize,
totalItems,
itemsPerPage,
onPageChange,
onPageSizeChange,
}: TablePaginationProps) {
// Generate page numbers
const getPageNumbers = () => {
const pages = [];
const maxVisiblePages = 5;
const halfVisible = Math.floor(maxVisiblePages / 2);
let startPage = Math.max(1, currentPage - halfVisible);
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
return (
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">Rows per page:</span>
<Select
value={pageSize}
onValueChange={(value) => onPageSizeChange(value as PageSize)}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder="25" />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="250">250</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{pageSize === 'all'
? `Showing all ${totalItems} items`
: `Showing ${Math.min((currentPage - 1) * itemsPerPage + 1, totalItems)}-${Math.min(currentPage * itemsPerPage, totalItems)} of ${totalItems} items`}
</span>
</div>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{getPageNumbers().map((page) => (
<PaginationItem key={page}>
<PaginationLink
isActive={page === currentPage}
onClick={() => onPageChange(page)}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}
@@ -0,0 +1,11 @@
// Export components
export { LatestChecksTable } from './LatestChecksTable';
export { StatusFilterTabs } from './StatusFilterTabs';
export { TablePagination } from './TablePagination';
export { IncidentTable } from './IncidentTable';
export { EmptyState } from './EmptyState';
// Export types and utils
export * from './types';
export * from './utils';
@@ -0,0 +1,16 @@
import { UptimeData } from "@/types/service.types";
export type StatusFilter = "all" | "up" | "down" | "paused" | "warning";
export type PageSize = "10" | "25" | "50" | "100" | "250" | "all";
export interface LatestChecksTableProps {
uptimeData: UptimeData[];
}
export interface StatusInfo {
icon: JSX.Element;
text: string;
textColor: string;
badge: JSX.Element;
}
@@ -0,0 +1,89 @@
import { CheckCircle, AlertTriangle, X, Pause } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { UptimeData } from "@/types/service.types";
import { format } from "date-fns";
import { StatusInfo } from "./types";
// Get appropriate icon and style for each status
export const getStatusInfo = (status: string): StatusInfo => {
switch (status) {
case "up":
return {
icon: <CheckCircle className="h-4 w-4 text-green-500 mr-2" />,
text: "Up",
textColor: "text-green-500",
badge: <Badge variant="default" className="bg-emerald-800 text-white hover:bg-emerald-700">Up</Badge>
};
case "warning":
return {
icon: <AlertTriangle className="h-4 w-4 text-yellow-500 mr-2" />,
text: "Warning",
textColor: "text-yellow-500",
badge: <Badge variant="outline" className="bg-yellow-800/80 text-yellow-300 border-yellow-700 hover:bg-yellow-800">Warning</Badge>
};
case "paused":
return {
icon: <Pause className="h-4 w-4 text-gray-500 mr-2" />,
text: "Paused",
textColor: "text-gray-500",
badge: <Badge variant="outline" className="bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800">Paused</Badge>
};
default: // down
return {
icon: <X className="h-4 w-4 text-red-500 mr-2" />,
text: "Down",
textColor: "text-red-500",
badge: <Badge variant="destructive">Down</Badge>
};
}
};
// Filter the uptimeData to only include status changes (incidents)
export const getStatusChangeEvents = (uptimeData: UptimeData[]): UptimeData[] => {
if (!uptimeData.length) return [];
// First sort the data by timestamp (newest first for display)
const sortedData = [...uptimeData].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// We need to find all status changes, including to/from paused
const statusChanges: UptimeData[] = [];
// Always include the most recent check as a baseline
if (sortedData.length > 0) {
statusChanges.push(sortedData[0]);
}
// Compare each check with the previous one to detect status changes
for (let i = 0; i < sortedData.length - 1; i++) {
const currentCheck = sortedData[i];
const nextCheck = sortedData[i + 1]; // This is actually the "older" check
// If the status changed, add the "older" check to our incidents list
// This shows what the status changed TO
if (currentCheck.status !== nextCheck.status) {
statusChanges.push(nextCheck);
}
}
console.log(`Found ${statusChanges.length} status changes, including paused status changes: ${statusChanges.some(i => i.status === 'paused')}`);
return statusChanges;
};
// Get date range for display
export const getDateRangeDisplay = (incidents: UptimeData[]): string => {
if (!incidents.length) return "";
const sortedData = [...incidents].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
const start = format(new Date(sortedData[0].timestamp), 'MMM dd, yyyy');
const end = format(new Date(sortedData[sortedData.length - 1].timestamp), 'MMM dd, yyyy');
// Display date range if different dates
return start === end ? start : `${start} - ${end}`;
};
@@ -0,0 +1,15 @@
// Export all service components for easier imports
export * from './ServiceHeader';
export * from './ServiceStatsCards';
export * from './ResponseTimeChart';
export * from './incident-history/LatestChecksTable';
export * from './LoadingState';
export * from './ServiceNotFound';
export * from './ServiceMonitoringButton';
export * from './AddServiceDialog';
export * from './ServicesTableContainer';
export * from './ServicesTableView';
export * from './ServiceDeleteDialog';
export * from './ServiceHistoryDialog';
export * from './ServiceEditDialog';
@@ -0,0 +1,184 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { MoreHorizontal, Eye, Play, Pause, Edit, Bell, BellOff, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Service } from "@/types/service.types";
import { serviceService } from "@/services/serviceService";
import { useToast } from "@/hooks/use-toast";
interface ServiceRowActionsProps {
service: Service;
onViewDetail: (service: Service) => void;
onPauseResume: (service: Service) => Promise<void>;
onEdit: (service: Service) => void;
onDelete: (service: Service) => void;
onMuteAlerts?: (service: Service) => Promise<void>;
}
export const ServiceRowActions = ({
service,
onViewDetail,
onPauseResume,
onEdit,
onDelete,
onMuteAlerts
}: ServiceRowActionsProps) => {
const { toast } = useToast();
// Handle pause/resume directly from dropdown
const handlePauseResume = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
if (service.status === "paused") {
// Resume monitoring
console.log(`Resuming monitoring for service ${service.id} (${service.name}) from dropdown`);
// First ensure we update the status
await serviceService.resumeMonitoring(service.id);
// Then start monitoring service (performs an immediate check)
await serviceService.startMonitoringService(service.id);
toast({
title: "Monitoring resumed",
description: `Monitoring for ${service.name} has been resumed. First check is running now.`,
});
} else {
// Pause monitoring
console.log(`Pausing monitoring for service ${service.id} (${service.name}) from dropdown`);
await serviceService.pauseMonitoring(service.id);
toast({
title: "Monitoring paused",
description: `Monitoring for ${service.name} has been paused.`,
});
}
// Call the parent handler to refresh the UI
onPauseResume(service);
} catch (error) {
console.error("Error toggling monitoring:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to change monitoring status. Please try again.",
});
}
};
// Check alerts status - check both fields for backward compatibility
const alertsMuted = service.alerts === "muted" || service.muteAlerts === true;
// Handle mute/unmute alerts
const handleMuteAlerts = async (e: React.MouseEvent) => {
e.stopPropagation();
if (onMuteAlerts) {
try {
console.log(`Attempting to ${alertsMuted ? 'unmute' : 'mute'} alerts for service ${service.id} (${service.name})`);
await onMuteAlerts(service);
} catch (error) {
console.error("Error toggling alerts:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to change alert settings. Please try again.",
});
}
}
};
return (
<div className="flex space-x-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
title="More options"
className="opacity-70 hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48 bg-gray-900 border border-gray-800 text-white"
>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={(e) => {
e.stopPropagation();
onViewDetail(service);
}}
>
<Eye className="h-4 w-4" />
<span>View Detail</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={handlePauseResume}
>
{service.status === "paused" ? (
<>
<Play className="h-4 w-4" />
<span>Resume Monitoring</span>
</>
) : (
<>
<Pause className="h-4 w-4" />
<span>Pause Monitoring</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={(e) => {
e.stopPropagation();
onEdit(service);
}}
>
<Edit className="h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={handleMuteAlerts}
>
{alertsMuted ? (
<>
<Bell className="h-4 w-4" />
<span>Unmute Alerts</span>
</>
) : (
<>
<BellOff className="h-4 w-4" />
<span>Mute Alerts</span>
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-gray-700" />
<DropdownMenuItem
className="flex items-center gap-2 text-red-500 cursor-pointer hover:bg-gray-800 focus:bg-gray-800 text-base py-2.5"
onClick={(e) => {
e.stopPropagation();
onDelete(service);
}}
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};
@@ -0,0 +1,61 @@
import React from "react";
import { BellOff } from "lucide-react";
import { Service } from "@/types/service.types";
interface ServiceRowHeaderProps {
service: Service;
}
export const ServiceRowHeader = ({ service }: ServiceRowHeaderProps) => {
// Display URL for HTTP services, hostname for others
const shouldDisplayFullUrl = service.type.toLowerCase() === "http";
let serviceSubtitle = "";
// Check alerts status - check both fields for backward compatibility
const alertsMuted = service.alerts === "muted" || service.muteAlerts === true;
if (service.url) {
try {
const url = service.url;
// If the URL doesn't start with http:// or https://, add https:// prefix
const formattedUrl = (!url.startsWith('http://') && !url.startsWith('https://'))
? `https://${url}`
: url;
try {
// Now try to parse it as a URL
const urlObj = new URL(formattedUrl);
if (shouldDisplayFullUrl) {
serviceSubtitle = formattedUrl;
} else {
serviceSubtitle = urlObj.hostname;
}
} catch (urlError) {
// If URL parsing still fails, just show the original URL
serviceSubtitle = url;
}
} catch (e) {
// If any other error occurs, just show the original URL
serviceSubtitle = service.url;
console.log("Error processing URL:", e);
}
}
return (
<div className="flex items-center gap-2">
<div>
<div className="text-base font-medium">{service.name}</div>
{service.url && (
<div className="text-sm text-gray-500 mt-1">{serviceSubtitle}</div>
)}
</div>
{/* Add a visual indicator if alerts are muted for this service */}
{alertsMuted && (
<div className="ml-1" title="Alerts muted">
<BellOff className="h-4 w-4 text-gray-400" />
</div>
)}
</div>
);
};
@@ -0,0 +1,27 @@
import React from "react";
import { AlertTriangle } from "lucide-react";
interface ServiceRowResponseTimeProps {
responseTime: number;
}
export const ServiceRowResponseTime = ({ responseTime }: ServiceRowResponseTimeProps) => {
// Determine if response time is high (≥ 1000ms)
const isResponseTimeHigh = responseTime >= 1000;
return (
<div className="font-mono text-base flex items-center gap-1.5">
{responseTime > 0 ? (
<>
<span className={isResponseTimeHigh ? "text-amber-500 font-semibold" : ""}>
{responseTime}ms
</span>
{isResponseTimeHigh && (
<AlertTriangle className="h-4 w-4 text-amber-500" />
)}
</>
) : 'N/A'}
</div>
);
};
@@ -0,0 +1,4 @@
export * from './ServiceRowActions';
export * from './ServiceRowHeader';
export * from './ServiceRowResponseTime';