Initial Release
This commit is contained in:
@@ -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';
|
||||
+87
@@ -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';
|
||||
Reference in New Issue
Block a user