Merge pull request #50 from operacle/develop
feat: New Create Service Form and Split UptimeBar.tsx into smaller components
This commit is contained in:
@@ -25,7 +25,7 @@ export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Create New Service</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDi
|
||||
onOpenChange(newOpen);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">Edit Service</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -38,6 +38,7 @@ export function ServiceForm({
|
||||
name: "",
|
||||
type: "http",
|
||||
url: "",
|
||||
port: "",
|
||||
interval: "60",
|
||||
retries: "3",
|
||||
notificationChannel: "",
|
||||
@@ -49,19 +50,41 @@ export function ServiceForm({
|
||||
// Populate form when initialData changes (separate from initialization)
|
||||
useEffect(() => {
|
||||
if (initialData && isEdit) {
|
||||
// Ensure the type is one of the allowed values
|
||||
const serviceType = (initialData.type || "http").toLowerCase();
|
||||
const validType = ["http", "ping", "tcp", "dns"].includes(serviceType)
|
||||
? serviceType as "http" | "ping" | "tcp" | "dns"
|
||||
: "http";
|
||||
|
||||
// For PING services, use host field; for DNS use domain field; for TCP use host field; others use url
|
||||
let urlValue = "";
|
||||
let portValue = "";
|
||||
|
||||
if (validType === "ping") {
|
||||
urlValue = initialData.host || "";
|
||||
} else if (validType === "dns") {
|
||||
urlValue = initialData.domain || "";
|
||||
} else if (validType === "tcp") {
|
||||
urlValue = initialData.host || "";
|
||||
portValue = String(initialData.port || "");
|
||||
} else {
|
||||
urlValue = initialData.url || "";
|
||||
}
|
||||
|
||||
// Reset the form with initial data values
|
||||
form.reset({
|
||||
name: initialData.name || "",
|
||||
type: (initialData.type || "http").toLowerCase(),
|
||||
url: initialData.url || "",
|
||||
type: validType,
|
||||
url: urlValue,
|
||||
port: portValue,
|
||||
interval: String(initialData.interval || 60),
|
||||
retries: String(initialData.retries || 3),
|
||||
notificationChannel: initialData.notificationChannel || "",
|
||||
alertTemplate: initialData.alertTemplate || "",
|
||||
notificationChannel: initialData.notificationChannel === "none" ? "" : initialData.notificationChannel || "",
|
||||
alertTemplate: initialData.alertTemplate === "default" ? "" : initialData.alertTemplate || "",
|
||||
});
|
||||
|
||||
// Log for debugging
|
||||
console.log("Populating form with URL:", initialData.url);
|
||||
console.log("Populating form with data:", { type: validType, url: urlValue, port: portValue });
|
||||
}
|
||||
}, [initialData, isEdit, form]);
|
||||
|
||||
@@ -74,17 +97,28 @@ export function ServiceForm({
|
||||
try {
|
||||
console.log("Form data being submitted:", data); // Debug log for submitted data
|
||||
|
||||
// Prepare service data with proper field mapping
|
||||
const serviceData = {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
interval: parseInt(data.interval),
|
||||
retries: parseInt(data.retries),
|
||||
notificationChannel: data.notificationChannel === "none" ? "" : data.notificationChannel,
|
||||
alertTemplate: data.alertTemplate === "default" ? "" : data.alertTemplate,
|
||||
// Map the URL field to appropriate database field based on service type
|
||||
...(data.type === "dns"
|
||||
? { domain: data.url, url: "", host: "", port: undefined } // DNS: store in domain field
|
||||
: data.type === "ping"
|
||||
? { host: data.url, url: "", domain: "", port: undefined } // PING: store in host field
|
||||
: data.type === "tcp"
|
||||
? { host: data.url, port: parseInt(data.port || "80"), url: "", domain: "" } // TCP: store in host and port fields
|
||||
: { url: data.url, domain: "", host: "", port: undefined } // HTTP: store in url field
|
||||
)
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
await serviceService.updateService(initialData.id, serviceData);
|
||||
|
||||
toast({
|
||||
title: "Service updated",
|
||||
@@ -92,15 +126,7 @@ export function ServiceForm({
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
await serviceService.createService(serviceData);
|
||||
|
||||
toast({
|
||||
title: "Service created",
|
||||
@@ -127,10 +153,24 @@ export function ServiceForm({
|
||||
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} />
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Basic Information</h3>
|
||||
<ServiceBasicFields form={form} />
|
||||
<ServiceTypeField form={form} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Configuration</h3>
|
||||
<ServiceConfigFields form={form} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Notifications</h3>
|
||||
<ServiceNotificationFields form={form} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ServiceFormActions
|
||||
isSubmitting={isSubmitting}
|
||||
onCancel={onCancel}
|
||||
@@ -139,4 +179,4 @@ export function ServiceForm({
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export const ServiceRow = ({
|
||||
status={service.status}
|
||||
serviceId={service.id}
|
||||
interval={service.interval}
|
||||
serviceType={service.type}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
|
||||
@@ -1,250 +1,51 @@
|
||||
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";
|
||||
|
||||
import React from "react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useUptimeData } from "./hooks/useUptimeData";
|
||||
import { UptimeStatusItem } from "./uptime/UptimeStatusItem";
|
||||
import { UptimeSummary } from "./uptime/UptimeSummary";
|
||||
import { UptimeLoadingState } from "./uptime/UptimeLoadingState";
|
||||
import { UptimeErrorState } from "./uptime/UptimeErrorState";
|
||||
|
||||
interface UptimeBarProps {
|
||||
uptime: number;
|
||||
status: string;
|
||||
serviceId?: string;
|
||||
interval?: number; // Service monitoring interval in seconds
|
||||
serviceType?: string; // Add service type for proper data fetching
|
||||
}
|
||||
|
||||
export const UptimeBar = ({ uptime, status, serviceId, interval = 60 }: 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, 50) : 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
|
||||
export const UptimeBar = ({ uptime, status, serviceId, interval = 60, serviceType }: UptimeBarProps) => {
|
||||
const { displayItems, isLoading, error, isFetching, refetch } = useUptimeData({
|
||||
serviceId,
|
||||
serviceType,
|
||||
status,
|
||||
interval
|
||||
});
|
||||
|
||||
// Filter uptime data to respect the service interval
|
||||
const filterUptimeDataByInterval = (data: UptimeData[], intervalSeconds: number): UptimeData[] => {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
// Sort data by timestamp (newest first)
|
||||
const sortedData = [...data].sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
const filtered: UptimeData[] = [];
|
||||
let lastIncludedTime: number | null = null;
|
||||
const intervalMs = intervalSeconds * 1000; // Convert to milliseconds
|
||||
|
||||
// Include the most recent record first
|
||||
if (sortedData.length > 0) {
|
||||
filtered.push(sortedData[0]);
|
||||
lastIncludedTime = new Date(sortedData[0].timestamp).getTime();
|
||||
}
|
||||
|
||||
// Filter subsequent records to maintain proper interval spacing
|
||||
for (let i = 1; i < sortedData.length && filtered.length < 20; i++) {
|
||||
const currentTime = new Date(sortedData[i].timestamp).getTime();
|
||||
|
||||
// Only include if enough time has passed since the last included record
|
||||
if (lastIncludedTime && (lastIncludedTime - currentTime) >= intervalMs) {
|
||||
filtered.push(sortedData[i]);
|
||||
lastIncludedTime = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Update history items when data changes
|
||||
useEffect(() => {
|
||||
if (uptimeData && uptimeData.length > 0) {
|
||||
// Filter data based on the service interval
|
||||
const filteredData = filterUptimeDataByInterval(uptimeData, interval);
|
||||
setHistoryItems(filteredData);
|
||||
} else if (status === "paused" || (uptimeData && uptimeData.length === 0)) {
|
||||
// For paused services with no history, or empty history data, show all as paused
|
||||
const statusValue = (status === "up" || status === "down" || status === "warning" || status === "paused")
|
||||
? 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(Date.now() - (index * interval * 1000)).toISOString(),
|
||||
status: statusValue as "up" | "down" | "warning" | "paused",
|
||||
responseTime: 0
|
||||
}));
|
||||
setHistoryItems(placeholderHistory);
|
||||
}
|
||||
}, [uptimeData, serviceId, status, interval]);
|
||||
|
||||
// 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 ((isLoading || isFetching) && displayItems.length === 0) {
|
||||
return <UptimeLoadingState />;
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
if (error && displayItems.length === 0) {
|
||||
return <UptimeErrorState uptime={uptime} onRetry={refetch} />;
|
||||
}
|
||||
|
||||
// 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";
|
||||
|
||||
// Generate padding items with proper time spacing
|
||||
const paddingItems: UptimeData[] = Array(20 - displayItems.length).fill(null).map((_, index) => {
|
||||
const baseTime = lastItem ? new Date(lastItem.timestamp).getTime() : Date.now();
|
||||
const timeOffset = (index + 1) * interval * 1000; // Respect the interval
|
||||
|
||||
return {
|
||||
id: `padding-${index}`,
|
||||
serviceId: serviceId || "",
|
||||
timestamp: new Date(baseTime - timeOffset).toISOString(),
|
||||
status: lastStatus,
|
||||
responseTime: 0
|
||||
};
|
||||
});
|
||||
displayItems.push(...paddingItems);
|
||||
}
|
||||
|
||||
// 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>
|
||||
{displayItems.map((item, index) => (
|
||||
<UptimeStatusItem
|
||||
key={item.id || `status-${index}`}
|
||||
item={item}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
<UptimeSummary uptime={uptime} interval={interval} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -10,44 +10,21 @@ interface ServiceBasicFieldsProps {
|
||||
|
||||
export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Service Name"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
console.log("URL field changed:", e.target.value);
|
||||
field.onChange(e);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Service Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter a descriptive name for your service"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,119 @@
|
||||
|
||||
import { FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { ServiceFormData } from "./types";
|
||||
import { ServiceUrlField } from "./ServiceUrlField";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ServiceConfigFieldsProps {
|
||||
form: UseFormReturn<ServiceFormData>;
|
||||
}
|
||||
|
||||
export function ServiceConfigFields({ form }: ServiceConfigFieldsProps) {
|
||||
const [isCustomInterval, setIsCustomInterval] = useState(false);
|
||||
const intervalValue = form.watch("interval");
|
||||
|
||||
const handleIntervalChange = (value: string) => {
|
||||
if (value === "custom") {
|
||||
setIsCustomInterval(true);
|
||||
form.setValue("interval", "");
|
||||
} else {
|
||||
setIsCustomInterval(false);
|
||||
form.setValue("interval", value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Heartbeat Interval</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="60"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retries"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Maximum Retries</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
<div className="space-y-4">
|
||||
<ServiceUrlField form={form} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Check Interval</FormLabel>
|
||||
{!isCustomInterval ? (
|
||||
<FormControl>
|
||||
<Select onValueChange={handleIntervalChange} value={isCustomInterval ? "custom" : field.value}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30">30 seconds</SelectItem>
|
||||
<SelectItem value="60">1 minute</SelectItem>
|
||||
<SelectItem value="300">5 minutes</SelectItem>
|
||||
<SelectItem value="900">15 minutes</SelectItem>
|
||||
<SelectItem value="1800">30 minutes</SelectItem>
|
||||
<SelectItem value="3600">1 hour</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter interval in seconds"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
min="10"
|
||||
/>
|
||||
</FormControl>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCustomInterval(false);
|
||||
form.setValue("interval", "60");
|
||||
}}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Back to presets
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<FormDescription className="text-xs">
|
||||
{isCustomInterval
|
||||
? "Enter custom interval in seconds (minimum 10 seconds)"
|
||||
: "How often to check the service status"
|
||||
}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retries"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Retry Attempts</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 attempt</SelectItem>
|
||||
<SelectItem value="2">2 attempts</SelectItem>
|
||||
<SelectItem value="3">3 attempts</SelectItem>
|
||||
<SelectItem value="5">5 attempts</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Number of retry attempts before marking as down
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export function ServiceForm({
|
||||
name: "",
|
||||
type: "http",
|
||||
url: "",
|
||||
port: "",
|
||||
interval: "60",
|
||||
retries: "3",
|
||||
notificationChannel: "",
|
||||
@@ -50,11 +51,33 @@ export function ServiceForm({
|
||||
// Populate form when initialData changes (separate from initialization)
|
||||
useEffect(() => {
|
||||
if (initialData && isEdit) {
|
||||
// Ensure the type is one of the allowed values
|
||||
const serviceType = (initialData.type || "http").toLowerCase();
|
||||
const validType = ["http", "ping", "tcp", "dns"].includes(serviceType)
|
||||
? serviceType as "http" | "ping" | "tcp" | "dns"
|
||||
: "http";
|
||||
|
||||
// For PING services, use host field; for DNS use domain field; for TCP use host field; others use url
|
||||
let urlValue = "";
|
||||
let portValue = "";
|
||||
|
||||
if (validType === "ping") {
|
||||
urlValue = initialData.host || "";
|
||||
} else if (validType === "dns") {
|
||||
urlValue = initialData.domain || "";
|
||||
} else if (validType === "tcp") {
|
||||
urlValue = initialData.host || "";
|
||||
portValue = String(initialData.port || "");
|
||||
} else {
|
||||
urlValue = initialData.url || "";
|
||||
}
|
||||
|
||||
// Reset the form with initial data values
|
||||
form.reset({
|
||||
name: initialData.name || "",
|
||||
type: (initialData.type || "http").toLowerCase(),
|
||||
url: initialData.url || "",
|
||||
type: validType,
|
||||
url: urlValue,
|
||||
port: portValue,
|
||||
interval: String(initialData.interval || 60),
|
||||
retries: String(initialData.retries || 3),
|
||||
notificationChannel: initialData.notificationChannel || "",
|
||||
@@ -62,7 +85,7 @@ export function ServiceForm({
|
||||
});
|
||||
|
||||
// Log for debugging
|
||||
console.log("Populating form with URL:", initialData.url);
|
||||
console.log("Populating form with data:", { type: validType, url: urlValue, port: portValue });
|
||||
}
|
||||
}, [initialData, isEdit, form]);
|
||||
|
||||
@@ -75,17 +98,28 @@ export function ServiceForm({
|
||||
try {
|
||||
console.log("Form data being submitted:", data); // Debug log for submitted data
|
||||
|
||||
// Prepare service data with proper field mapping
|
||||
const serviceData = {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
interval: parseInt(data.interval),
|
||||
retries: parseInt(data.retries),
|
||||
notificationChannel: data.notificationChannel || undefined,
|
||||
alertTemplate: data.alertTemplate || undefined,
|
||||
// Map the URL field to appropriate database field based on service type
|
||||
...(data.type === "dns"
|
||||
? { domain: data.url, url: "", host: "", port: undefined } // DNS: store in domain field
|
||||
: data.type === "ping"
|
||||
? { host: data.url, url: "", domain: "", port: undefined } // PING: store in host field
|
||||
: data.type === "tcp"
|
||||
? { host: data.url, port: parseInt(data.port || "80"), url: "", domain: "" } // TCP: store in host and port fields
|
||||
: { url: data.url, domain: "", host: "", port: undefined } // HTTP: store in url field
|
||||
)
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
await serviceService.updateService(initialData.id, serviceData);
|
||||
|
||||
toast({
|
||||
title: "Service updated",
|
||||
@@ -93,15 +127,7 @@ export function ServiceForm({
|
||||
});
|
||||
} 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,
|
||||
});
|
||||
await serviceService.createService(serviceData);
|
||||
|
||||
toast({
|
||||
title: "Service created",
|
||||
@@ -128,10 +154,24 @@ export function ServiceForm({
|
||||
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} />
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Basic Information</h3>
|
||||
<ServiceBasicFields form={form} />
|
||||
<ServiceTypeField form={form} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Configuration</h3>
|
||||
<ServiceConfigFields form={form} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-muted-foreground border-b pb-2">Notifications</h3>
|
||||
<ServiceNotificationFields form={form} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ServiceFormActions
|
||||
isSubmitting={isSubmitting}
|
||||
onCancel={onCancel}
|
||||
@@ -140,4 +180,4 @@ export function ServiceForm({
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormDescription } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
import { ServiceFormData } from "./types";
|
||||
|
||||
interface ServiceUrlFieldProps {
|
||||
form: UseFormReturn<ServiceFormData>;
|
||||
}
|
||||
|
||||
export function ServiceUrlField({ form }: ServiceUrlFieldProps) {
|
||||
const serviceType = form.watch("type");
|
||||
|
||||
const getPlaceholder = () => {
|
||||
switch (serviceType) {
|
||||
case "http":
|
||||
return "https://example.com";
|
||||
case "ping":
|
||||
return "example.com or 192.168.1.1";
|
||||
case "tcp":
|
||||
return "example.com or 192.168.1.1";
|
||||
case "dns":
|
||||
return "example.com";
|
||||
default:
|
||||
return "Enter URL or hostname";
|
||||
}
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
switch (serviceType) {
|
||||
case "http":
|
||||
return "Enter the full URL including protocol (http:// or https://)";
|
||||
case "ping":
|
||||
return "Enter hostname or IP address to ping";
|
||||
case "tcp":
|
||||
return "Enter hostname or IP address for TCP connection test";
|
||||
case "dns":
|
||||
return "Enter domain name for DNS record monitoring (A, AAAA, MX, etc.)";
|
||||
default:
|
||||
return "Enter the target URL or hostname for monitoring";
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldLabel = () => {
|
||||
switch (serviceType) {
|
||||
case "dns":
|
||||
return "Domain Name";
|
||||
case "ping":
|
||||
return "Hostname/IP";
|
||||
case "tcp":
|
||||
return "Hostname/IP";
|
||||
default:
|
||||
return "Target URL/Host";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{getFieldLabel()}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={getPlaceholder()}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value);
|
||||
field.onChange(e);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
{getDescription()}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{serviceType === "tcp" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="8080"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
console.log("Port field changed:", e.target.value);
|
||||
field.onChange(e);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-xs">
|
||||
Enter the port number for TCP connection test
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,16 @@ 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"),
|
||||
type: z.enum(["http", "ping", "tcp", "dns"]),
|
||||
url: z.string().min(1, "URL/Domain/Host is required").refine((value) => {
|
||||
// Basic validation - more specific validation can be added per type
|
||||
return value.trim().length > 0;
|
||||
}, "Please enter a valid URL, hostname, or domain"),
|
||||
port: z.string().optional(),
|
||||
interval: z.string(),
|
||||
retries: z.string(),
|
||||
notificationChannel: z.string().optional(),
|
||||
alertTemplate: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ServiceFormData = z.infer<typeof serviceSchema>;
|
||||
export type ServiceFormData = z.infer<typeof serviceSchema>;
|
||||
@@ -0,0 +1,120 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { uptimeService } from '@/services/uptimeService';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
|
||||
interface UseUptimeDataProps {
|
||||
serviceId?: string;
|
||||
serviceType?: string;
|
||||
status: string;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export const useUptimeData = ({ serviceId, serviceType, status, interval }: UseUptimeDataProps) => {
|
||||
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, serviceType],
|
||||
queryFn: () => serviceId ? uptimeService.getUptimeHistory(serviceId, 50, undefined, undefined, serviceType) : 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
|
||||
});
|
||||
|
||||
// Filter uptime data to respect the service interval
|
||||
const filterUptimeDataByInterval = (data: UptimeData[], intervalSeconds: number): UptimeData[] => {
|
||||
if (!data || data.length === 0) return [];
|
||||
|
||||
// Sort data by timestamp (newest first)
|
||||
const sortedData = [...data].sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
const filtered: UptimeData[] = [];
|
||||
let lastIncludedTime: number | null = null;
|
||||
const intervalMs = intervalSeconds * 1000; // Convert to milliseconds
|
||||
|
||||
// Include the most recent record first
|
||||
if (sortedData.length > 0) {
|
||||
filtered.push(sortedData[0]);
|
||||
lastIncludedTime = new Date(sortedData[0].timestamp).getTime();
|
||||
}
|
||||
|
||||
// Filter subsequent records to maintain proper interval spacing
|
||||
for (let i = 1; i < sortedData.length && filtered.length < 20; i++) {
|
||||
const currentTime = new Date(sortedData[i].timestamp).getTime();
|
||||
|
||||
// Only include if enough time has passed since the last included record
|
||||
if (lastIncludedTime && (lastIncludedTime - currentTime) >= intervalMs) {
|
||||
filtered.push(sortedData[i]);
|
||||
lastIncludedTime = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Update history items when data changes
|
||||
useEffect(() => {
|
||||
if (uptimeData && uptimeData.length > 0) {
|
||||
// Filter data based on the service interval
|
||||
const filteredData = filterUptimeDataByInterval(uptimeData, interval);
|
||||
setHistoryItems(filteredData);
|
||||
} else if (status === "paused" || (uptimeData && uptimeData.length === 0)) {
|
||||
// For paused services with no history, or empty history data, show all as paused
|
||||
const statusValue = (status === "up" || status === "down" || status === "warning" || status === "paused")
|
||||
? 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(Date.now() - (index * interval * 1000)).toISOString(),
|
||||
status: statusValue as "up" | "down" | "warning" | "paused",
|
||||
responseTime: 0
|
||||
}));
|
||||
setHistoryItems(placeholderHistory);
|
||||
}
|
||||
}, [uptimeData, serviceId, status, interval]);
|
||||
|
||||
// Ensure we always have 20 items by padding with the last known status
|
||||
const getDisplayItems = (): UptimeData[] => {
|
||||
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";
|
||||
|
||||
// Generate padding items with proper time spacing
|
||||
const paddingItems: UptimeData[] = Array(20 - displayItems.length).fill(null).map((_, index) => {
|
||||
const baseTime = lastItem ? new Date(lastItem.timestamp).getTime() : Date.now();
|
||||
const timeOffset = (index + 1) * interval * 1000; // Respect the interval
|
||||
|
||||
return {
|
||||
id: `padding-${index}`,
|
||||
serviceId: serviceId || "",
|
||||
timestamp: new Date(baseTime - timeOffset).toISOString(),
|
||||
status: lastStatus,
|
||||
responseTime: 0
|
||||
};
|
||||
});
|
||||
displayItems.push(...paddingItems);
|
||||
}
|
||||
|
||||
return displayItems.slice(0, 20);
|
||||
};
|
||||
|
||||
return {
|
||||
displayItems: getDisplayItems(),
|
||||
isLoading,
|
||||
error,
|
||||
isFetching,
|
||||
refetch
|
||||
};
|
||||
};
|
||||
@@ -8,45 +8,56 @@ interface ServiceRowHeaderProps {
|
||||
}
|
||||
|
||||
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);
|
||||
// Determine what to display based on service type
|
||||
const getServiceSubtitle = () => {
|
||||
const serviceType = service.type.toLowerCase();
|
||||
|
||||
if (serviceType === "dns" && service.domain) {
|
||||
return service.domain;
|
||||
}
|
||||
}
|
||||
|
||||
if ((serviceType === "ping" || serviceType === "tcp") && service.host) {
|
||||
if (serviceType === "tcp" && service.port) {
|
||||
return `${service.host}:${service.port}`;
|
||||
}
|
||||
return service.host;
|
||||
}
|
||||
|
||||
if (service.url) {
|
||||
try {
|
||||
// If the URL doesn't start with http:// or https://, add https:// prefix
|
||||
const formattedUrl = (!service.url.startsWith('http://') && !service.url.startsWith('https://'))
|
||||
? `https://${service.url}`
|
||||
: service.url;
|
||||
|
||||
try {
|
||||
// Try to parse it as a URL
|
||||
const urlObj = new URL(formattedUrl);
|
||||
// For HTTP services, show full URL; for others show hostname
|
||||
return serviceType === "http" ? formattedUrl : urlObj.hostname;
|
||||
} catch (urlError) {
|
||||
// If URL parsing fails, just show the original URL
|
||||
return service.url;
|
||||
}
|
||||
} catch (e) {
|
||||
// If any other error occurs, just show the original URL
|
||||
return service.url;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const serviceSubtitle = getServiceSubtitle();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div>
|
||||
<div className="text-base font-medium">{service.name}</div>
|
||||
{service.url && (
|
||||
{serviceSubtitle && (
|
||||
<div className="text-sm text-gray-500 mt-1">{serviceSubtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -58,4 +69,4 @@ export const ServiceRowHeader = ({ service }: ServiceRowHeaderProps) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
|
||||
import React from 'react';
|
||||
import { X, RefreshCcw } from 'lucide-react';
|
||||
|
||||
interface UptimeErrorStateProps {
|
||||
uptime: number;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export const UptimeErrorState = ({ uptime, onRetry }: UptimeErrorStateProps) => {
|
||||
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={onRetry}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const UptimeLoadingState = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { UptimeData } from '@/types/service.types';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
interface UptimeStatusItemProps {
|
||||
item: UptimeData;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export const UptimeStatusItem = ({ item, index }: UptimeStatusItemProps) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface UptimeSummaryProps {
|
||||
uptime: number;
|
||||
interval: number;
|
||||
}
|
||||
|
||||
export const UptimeSummary = ({ uptime, interval }: UptimeSummaryProps) => {
|
||||
return (
|
||||
<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 ({interval}s interval)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
export { UptimeStatusItem } from './UptimeStatusItem';
|
||||
export { UptimeSummary } from './UptimeSummary';
|
||||
export { UptimeLoadingState } from './UptimeLoadingState';
|
||||
export { UptimeErrorState } from './UptimeErrorState';
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { pb } from '@/lib/pocketbase';
|
||||
import { Service, CreateServiceParams, UptimeData } from '@/types/service.types';
|
||||
import { monitoringService } from './monitoring';
|
||||
@@ -16,6 +17,9 @@ export const serviceService = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url: item.url || "", // Ensure proper URL mapping
|
||||
host: item.host || "", // Map host field for PING and TCP services
|
||||
port: item.port || undefined, // Map port field for TCP services
|
||||
domain: item.domain || "", // Map domain field
|
||||
type: item.service_type || item.type || "HTTP", // Map service_type to type
|
||||
status: item.status || "paused",
|
||||
responseTime: item.response_time || item.responseTime || 0,
|
||||
@@ -40,12 +44,11 @@ export const serviceService = {
|
||||
// Convert service type to lowercase to avoid validation issues
|
||||
const serviceType = params.type.toLowerCase();
|
||||
|
||||
// Debug log to check URL
|
||||
console.log("Creating service with URL:", params.url);
|
||||
// Debug log to check what we're sending
|
||||
console.log("Creating service with params:", params);
|
||||
|
||||
const data = {
|
||||
name: params.name,
|
||||
url: params.url, // Ensure URL is included
|
||||
service_type: serviceType, // Using lowercase value to avoid validation errors
|
||||
status: "up", // Changed from "active" to "up" to match the expected enum values
|
||||
response_time: 0,
|
||||
@@ -55,6 +58,15 @@ export const serviceService = {
|
||||
max_retries: params.retries,
|
||||
notification_id: params.notificationChannel,
|
||||
template_id: params.alertTemplate,
|
||||
// Conditionally add fields based on service type
|
||||
...(serviceType === "dns"
|
||||
? { domain: params.domain, url: "", host: "", port: null } // DNS: store in domain field
|
||||
: serviceType === "ping"
|
||||
? { host: params.host, url: "", domain: "", port: null } // PING: store in host field
|
||||
: serviceType === "tcp"
|
||||
? { host: params.host, port: params.port, url: "", domain: "" } // TCP: store in host and port fields
|
||||
: { url: params.url, domain: "", host: "", port: null } // HTTP: store in url field
|
||||
)
|
||||
};
|
||||
|
||||
console.log("Creating service with data:", data);
|
||||
@@ -65,7 +77,10 @@ export const serviceService = {
|
||||
const newService = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
url: record.url, // Include the URL in returned service
|
||||
url: record.url || "",
|
||||
host: record.host || "",
|
||||
port: record.port || undefined,
|
||||
domain: record.domain || "",
|
||||
type: record.service_type || "http",
|
||||
status: record.status || "up", // Changed to match the status we set
|
||||
responseTime: record.response_time || 0,
|
||||
@@ -92,17 +107,25 @@ export const serviceService = {
|
||||
// Convert service type to lowercase to avoid validation issues
|
||||
const serviceType = params.type.toLowerCase();
|
||||
|
||||
// Debug log to check URL
|
||||
console.log("Updating service with URL:", params.url);
|
||||
// Debug log to check what we're updating
|
||||
console.log("Updating service with params:", params);
|
||||
|
||||
const data = {
|
||||
name: params.name,
|
||||
url: params.url, // Ensure URL is included
|
||||
service_type: serviceType,
|
||||
heartbeat_interval: params.interval,
|
||||
max_retries: params.retries,
|
||||
notification_id: params.notificationChannel || null,
|
||||
template_id: params.alertTemplate || null,
|
||||
// Conditionally update fields based on service type
|
||||
...(serviceType === "dns"
|
||||
? { domain: params.domain, url: "", host: "", port: null } // DNS: update domain field
|
||||
: serviceType === "ping"
|
||||
? { host: params.host, url: "", domain: "", port: null } // PING: update host field
|
||||
: serviceType === "tcp"
|
||||
? { host: params.host, port: params.port, url: "", domain: "" } // TCP: update host and port fields
|
||||
: { url: params.url, domain: "", host: "", port: null } // HTTP: update url field
|
||||
)
|
||||
};
|
||||
|
||||
console.log("Updating service with data:", data);
|
||||
@@ -120,7 +143,10 @@ export const serviceService = {
|
||||
const updatedService = {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
url: record.url, // Include the URL in returned service
|
||||
url: record.url || "",
|
||||
host: record.host || "",
|
||||
port: record.port || undefined,
|
||||
domain: record.domain || "",
|
||||
type: record.service_type || "http",
|
||||
status: record.status,
|
||||
responseTime: record.response_time || 0,
|
||||
|
||||
@@ -10,6 +10,24 @@ const uptimeCache = new Map<string, {
|
||||
|
||||
const CACHE_TTL = 3000; // 3 seconds for faster updates
|
||||
|
||||
// Map service types to their corresponding collections
|
||||
const getCollectionForServiceType = (serviceType: string): string => {
|
||||
const type = serviceType.toLowerCase();
|
||||
switch (type) {
|
||||
case 'ping':
|
||||
case 'icmp':
|
||||
return 'ping_data';
|
||||
case 'dns':
|
||||
return 'dns_data';
|
||||
case 'tcp':
|
||||
return 'tcp_data';
|
||||
case 'http':
|
||||
case 'https':
|
||||
default:
|
||||
return 'uptime_data';
|
||||
}
|
||||
};
|
||||
|
||||
export const uptimeService = {
|
||||
async recordUptimeData(data: UptimeData): Promise<void> {
|
||||
try {
|
||||
@@ -42,10 +60,11 @@ export const uptimeService = {
|
||||
serviceId: string,
|
||||
limit: number = 200,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
endDate?: Date,
|
||||
serviceType?: string
|
||||
): Promise<UptimeData[]> {
|
||||
try {
|
||||
const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}`;
|
||||
const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}_${serviceType || 'default'}`;
|
||||
|
||||
// Check cache
|
||||
const cached = uptimeCache.get(cacheKey);
|
||||
@@ -54,19 +73,18 @@ export const uptimeService = {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
console.log(`Fetching uptime history for service ${serviceId}, limit: ${limit}`);
|
||||
// Determine the correct collection based on service type
|
||||
const collection = serviceType ? getCollectionForServiceType(serviceType) : 'uptime_data';
|
||||
console.log(`Fetching uptime history for service ${serviceId} from collection ${collection}, limit: ${limit}`);
|
||||
|
||||
let filter = `service_id='${serviceId}'`;
|
||||
|
||||
// Add date range filtering if provided
|
||||
if (startDate && endDate) {
|
||||
// Convert dates to UTC strings in the format PocketBase expects
|
||||
const startUTC = startDate.toISOString();
|
||||
const endUTC = endDate.toISOString();
|
||||
|
||||
console.log(`Date filter: ${startUTC} to ${endUTC}`);
|
||||
|
||||
// Use proper PocketBase date filtering syntax
|
||||
filter += ` && timestamp >= "${startUTC}" && timestamp <= "${endUTC}"`;
|
||||
}
|
||||
|
||||
@@ -77,25 +95,25 @@ export const uptimeService = {
|
||||
$cancelKey: `uptime_history_${serviceId}_${Date.now()}`
|
||||
};
|
||||
|
||||
console.log(`Filter query: ${filter}`);
|
||||
console.log(`Filter query: ${filter} on collection: ${collection}`);
|
||||
|
||||
const response = await pb.collection('uptime_data').getList(1, limit, options);
|
||||
const response = await pb.collection(collection).getList(1, limit, options);
|
||||
|
||||
console.log(`Fetched ${response.items.length} uptime records for service ${serviceId}`);
|
||||
console.log(`Fetched ${response.items.length} uptime records for service ${serviceId} from ${collection}`);
|
||||
|
||||
if (response.items.length > 0) {
|
||||
console.log(`Date range in results: ${response.items[response.items.length - 1].timestamp} to ${response.items[0].timestamp}`);
|
||||
} else {
|
||||
console.log(`No records found for filter: ${filter}`);
|
||||
console.log(`No records found for filter: ${filter} in collection: ${collection}`);
|
||||
|
||||
// Try a fallback query without date filter to see if there's any data at all
|
||||
const fallbackResponse = await pb.collection('uptime_data').getList(1, 10, {
|
||||
const fallbackResponse = await pb.collection(collection).getList(1, 10, {
|
||||
filter: `service_id='${serviceId}'`,
|
||||
sort: '-timestamp',
|
||||
$autoCancel: false
|
||||
});
|
||||
|
||||
console.log(`Fallback query found ${fallbackResponse.items.length} total records for service`);
|
||||
console.log(`Fallback query found ${fallbackResponse.items.length} total records for service in ${collection}`);
|
||||
if (fallbackResponse.items.length > 0) {
|
||||
console.log(`Latest record timestamp: ${fallbackResponse.items[0].timestamp}`);
|
||||
console.log(`Oldest record timestamp: ${fallbackResponse.items[fallbackResponse.items.length - 1].timestamp}`);
|
||||
@@ -124,7 +142,7 @@ export const uptimeService = {
|
||||
console.error("Error fetching uptime history:", error);
|
||||
|
||||
// Try to return cached data as fallback
|
||||
const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}`;
|
||||
const cacheKey = `uptime_${serviceId}_${limit}_${startDate?.toISOString() || ''}_${endDate?.toISOString() || ''}_${serviceType || 'default'}`;
|
||||
const cached = uptimeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Using expired cached data for service ${serviceId} due to fetch error`);
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
host?: string; // Add host field for PING and TCP services
|
||||
port?: number; // Add port field for TCP services
|
||||
type: "HTTP" | "HTTPS" | "TCP" | "DNS" | "PING" | "HTTP" | "http" | "https" | "tcp" | "dns" | "ping" | "smtp" | "icmp";
|
||||
status: "up" | "down" | "paused" | "pending" | "warning";
|
||||
responseTime: number;
|
||||
@@ -15,11 +17,15 @@ export interface Service {
|
||||
muteAlerts?: boolean; // Keep this to avoid breaking existing code
|
||||
alerts?: "muted" | "unmuted"; // Make sure alerts is properly typed as union
|
||||
muteChangedAt?: string;
|
||||
domain?: string; // Add domain field for DNS services
|
||||
}
|
||||
|
||||
export interface CreateServiceParams {
|
||||
name: string;
|
||||
url: string;
|
||||
url?: string;
|
||||
host?: string; // Add host field for PING and TCP services
|
||||
port?: number; // Add port field for TCP services
|
||||
domain?: string; // Add domain field for DNS services
|
||||
type: string;
|
||||
interval: number;
|
||||
retries: number;
|
||||
@@ -35,4 +41,4 @@ export interface UptimeData {
|
||||
timestamp: string;
|
||||
status: "up" | "down" | "paused" | "pending" | "warning";
|
||||
responseTime: number;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user