From 0758b1d24b5e990d8e7cf6483a35ef649cc707c7 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 18 Jun 2025 23:13:14 +0700 Subject: [PATCH 01/14] feat: Separate host and port for TCP services --- application/src/types/service.types.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/application/src/types/service.types.ts b/application/src/types/service.types.ts index 6b987cf..a3b810a 100644 --- a/application/src/types/service.types.ts +++ b/application/src/types/service.types.ts @@ -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; -} +} \ No newline at end of file From 883a541b9bfe69a0e440e97a86018a18afd01ae7 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 18 Jun 2025 23:14:04 +0700 Subject: [PATCH 02/14] feat: Separate host and port for TCP services --- application/src/services/serviceService.ts | 42 +++++++++++++++++----- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/application/src/services/serviceService.ts b/application/src/services/serviceService.ts index 9de9850..0ed0bf7 100644 --- a/application/src/services/serviceService.ts +++ b/application/src/services/serviceService.ts @@ -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, From 0705fc89b42094a46f78860bb12ccf4ae137df76 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 18 Jun 2025 23:14:55 +0700 Subject: [PATCH 03/14] Refactor: Move service type selection --- .../add-service/ServiceBasicFields.tsx | 55 ++++++------------- 1 file changed, 16 insertions(+), 39 deletions(-) diff --git a/application/src/components/services/add-service/ServiceBasicFields.tsx b/application/src/components/services/add-service/ServiceBasicFields.tsx index 9039d71..e729b73 100644 --- a/application/src/components/services/add-service/ServiceBasicFields.tsx +++ b/application/src/components/services/add-service/ServiceBasicFields.tsx @@ -10,44 +10,21 @@ interface ServiceBasicFieldsProps { export function ServiceBasicFields({ form }: ServiceBasicFieldsProps) { return ( - <> - ( - - Service Name - - - - - - )} - /> - - ( - - Service URL - - { - console.log("URL field changed:", e.target.value); - field.onChange(e); - }} - /> - - - - )} - /> - + ( + + Service Name + + + + + + )} + /> ); } \ No newline at end of file From bdf648c2fe462f06949e79d48719510d8015a76f Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 18 Jun 2025 23:15:08 +0700 Subject: [PATCH 04/14] Add custom check interval option --- .../add-service/ServiceConfigFields.tsx | 142 +++++++++++++----- 1 file changed, 106 insertions(+), 36 deletions(-) diff --git a/application/src/components/services/add-service/ServiceConfigFields.tsx b/application/src/components/services/add-service/ServiceConfigFields.tsx index d172f5e..7145df3 100644 --- a/application/src/components/services/add-service/ServiceConfigFields.tsx +++ b/application/src/components/services/add-service/ServiceConfigFields.tsx @@ -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; } 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 ( - <> - ( - - Heartbeat Interval - - - - - )} - /> - - ( - - Maximum Retries - - - - - )} - /> - +
+ + +
+ ( + + Check Interval + {!isCustomInterval ? ( + + + + ) : ( +
+ + + + +
+ )} + + {isCustomInterval + ? "Enter custom interval in seconds (minimum 10 seconds)" + : "How often to check the service status" + } + + +
+ )} + /> + + ( + + Retry Attempts + + + + + Number of retry attempts before marking as down + + + + )} + /> +
+
); } \ No newline at end of file From 19b4de78d1b3fbc6eba8b84af30091fb34543a20 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 18 Jun 2025 23:15:22 +0700 Subject: [PATCH 05/14] feat: Separate host and port for TCP services --- .../services/add-service/ServiceForm.tsx | 92 +++++++++++++------ 1 file changed, 66 insertions(+), 26 deletions(-) diff --git a/application/src/components/services/add-service/ServiceForm.tsx b/application/src/components/services/add-service/ServiceForm.tsx index fa7f127..7e3c1ae 100644 --- a/application/src/components/services/add-service/ServiceForm.tsx +++ b/application/src/components/services/add-service/ServiceForm.tsx @@ -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 (
- - - - +
+
+

Basic Information

+ + +
+ +
+

Configuration

+ +
+ +
+

Notifications

+ +
+
+ ); -} +} \ No newline at end of file From 4a14f64e07e2083f45fceea451cb60052062dc47 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 18 Jun 2025 23:15:56 +0700 Subject: [PATCH 06/14] feat: Separate host and port for TCP services --- .../services/add-service/ServiceUrlField.tsx | 111 ++++++++++++++++++ .../components/services/add-service/types.ts | 14 ++- 2 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 application/src/components/services/add-service/ServiceUrlField.tsx diff --git a/application/src/components/services/add-service/ServiceUrlField.tsx b/application/src/components/services/add-service/ServiceUrlField.tsx new file mode 100644 index 0000000..0284f35 --- /dev/null +++ b/application/src/components/services/add-service/ServiceUrlField.tsx @@ -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; +} + +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 ( +
+ ( + + {getFieldLabel()} + + { + console.log(`${serviceType === "dns" ? "Domain" : serviceType === "tcp" ? "Host" : "URL"} field changed:`, e.target.value); + field.onChange(e); + }} + /> + + + {getDescription()} + + + + )} + /> + + {serviceType === "tcp" && ( + ( + + Port + + { + console.log("Port field changed:", e.target.value); + field.onChange(e); + }} + /> + + + Enter the port number for TCP connection test + + + + )} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/application/src/components/services/add-service/types.ts b/application/src/components/services/add-service/types.ts index eb3ac47..2aee449 100644 --- a/application/src/components/services/add-service/types.ts +++ b/application/src/components/services/add-service/types.ts @@ -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; +export type ServiceFormData = z.infer; \ No newline at end of file From c90f0dd9632bebd63777027334d56e278c8b34d1 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 18 Jun 2025 23:16:11 +0700 Subject: [PATCH 07/14] Fix: Expand service form dialog --- application/src/components/services/AddServiceDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/components/services/AddServiceDialog.tsx b/application/src/components/services/AddServiceDialog.tsx index 19bdd8b..4d37c26 100644 --- a/application/src/components/services/AddServiceDialog.tsx +++ b/application/src/components/services/AddServiceDialog.tsx @@ -25,7 +25,7 @@ export function AddServiceDialog({ open, onOpenChange }: AddServiceDialogProps) return ( - + Create New Service From 2f520109630106fb3d37cb6423fbb68c384c674d Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 18 Jun 2025 23:16:29 +0700 Subject: [PATCH 08/14] Fix: Expand service form dialog --- application/src/components/services/ServiceEditDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/components/services/ServiceEditDialog.tsx b/application/src/components/services/ServiceEditDialog.tsx index a00ef2b..8603398 100644 --- a/application/src/components/services/ServiceEditDialog.tsx +++ b/application/src/components/services/ServiceEditDialog.tsx @@ -51,7 +51,7 @@ export function ServiceEditDialog({ open, onOpenChange, service }: ServiceEditDi onOpenChange(newOpen); } }}> - + Edit Service From 879622b3648e84989dff77f6c000256091f83aa2 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Wed, 18 Jun 2025 23:16:39 +0700 Subject: [PATCH 09/14] feat: Separate host and port for TCP services --- .../src/components/services/ServiceForm.tsx | 96 +++++++++++++------ 1 file changed, 68 insertions(+), 28 deletions(-) diff --git a/application/src/components/services/ServiceForm.tsx b/application/src/components/services/ServiceForm.tsx index a6e1617..81db0c1 100644 --- a/application/src/components/services/ServiceForm.tsx +++ b/application/src/components/services/ServiceForm.tsx @@ -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 (
- - - - +
+
+

Basic Information

+ + +
+ +
+

Configuration

+ +
+ +
+

Notifications

+ +
+
+ ); -} +} \ No newline at end of file From 93b58cfa2c06b26d59e33bdc97b83868229d022a Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 19 Jun 2025 14:22:17 +0700 Subject: [PATCH 10/14] feat: Display service details in table Display host, domain, or URL under the service name in the service table. --- .../services/service-row/ServiceRowHeader.tsx | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/application/src/components/services/service-row/ServiceRowHeader.tsx b/application/src/components/services/service-row/ServiceRowHeader.tsx index 3644e48..d02a947 100644 --- a/application/src/components/services/service-row/ServiceRowHeader.tsx +++ b/application/src/components/services/service-row/ServiceRowHeader.tsx @@ -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; - + // 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 { - // Now try to parse it as a URL - const urlObj = new URL(formattedUrl); - if (shouldDisplayFullUrl) { - serviceSubtitle = formattedUrl; - } else { - serviceSubtitle = urlObj.hostname; + // 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 (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 + return service.url; } - } catch (e) { - // If any other error occurs, just show the original URL - serviceSubtitle = service.url; - console.log("Error processing URL:", e); } - } + + return ""; + }; + + const serviceSubtitle = getServiceSubtitle(); return (
{service.name}
- {service.url && ( + {serviceSubtitle && (
{serviceSubtitle}
)}
@@ -58,4 +69,4 @@ export const ServiceRowHeader = ({ service }: ServiceRowHeaderProps) => { )}
); -}; +}; \ No newline at end of file From 907fdaa0dc6a1ba8ada74cb8814a5ade61caf7cf Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 19 Jun 2025 14:47:20 +0700 Subject: [PATCH 11/14] Fix: Display uptime history in service table --- application/src/services/uptimeService.ts | 44 ++++++++++++++++------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/application/src/services/uptimeService.ts b/application/src/services/uptimeService.ts index d2e5a57..7a4d54e 100644 --- a/application/src/services/uptimeService.ts +++ b/application/src/services/uptimeService.ts @@ -10,6 +10,24 @@ const uptimeCache = new Map { + 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 { try { @@ -42,10 +60,11 @@ export const uptimeService = { serviceId: string, limit: number = 200, startDate?: Date, - endDate?: Date + endDate?: Date, + serviceType?: string ): Promise { 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`); From a2ae66fe76a048e8230fc8a7bf22118d16ec0121 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 19 Jun 2025 14:48:58 +0700 Subject: [PATCH 12/14] Fix: Display uptime history in service table --- application/src/components/services/ServiceRow.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/application/src/components/services/ServiceRow.tsx b/application/src/components/services/ServiceRow.tsx index fd9324c..a07dfaf 100644 --- a/application/src/components/services/ServiceRow.tsx +++ b/application/src/components/services/ServiceRow.tsx @@ -61,6 +61,7 @@ export const ServiceRow = ({ status={service.status} serviceId={service.id} interval={service.interval} + serviceType={service.type} /> From c1c74bb2a84e6003336947e7e14720555cffa704 Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 19 Jun 2025 14:50:22 +0700 Subject: [PATCH 13/14] Refactor: Split UptimeBar.tsx into smaller components --- .../src/components/services/UptimeBar.tsx | 251 ++---------------- 1 file changed, 26 insertions(+), 225 deletions(-) diff --git a/application/src/components/services/UptimeBar.tsx b/application/src/components/services/UptimeBar.tsx index 3dbe6e3..d7854a9 100644 --- a/application/src/components/services/UptimeBar.tsx +++ b/application/src/components/services/UptimeBar.tsx @@ -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([]); - - // 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 ( -
-
- {Array(20).fill(0).map((_, index) => ( -
- ))} -
-
- - -
-
- ); + if ((isLoading || isFetching) && displayItems.length === 0) { + return ; } // 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 ( -
-
- {Array(20).fill(0).map((_, index) => ( -
- ))} -
-
- {Math.round(uptime)}% uptime - -
-
- ); - } - - // 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); + if (error && displayItems.length === 0) { + return ; } - - // Limit to 20 items for display - const limitedItems = displayItems.slice(0, 20); return (
- {limitedItems.map((item, index) => ( - - -
- - -
-
{getStatusLabel(item.status)}
-
- {item.status !== "paused" && item.status !== "down" ? - `${item.responseTime}ms` : - "No response"} -
-
- {formatTimestamp(item.timestamp)} -
-
-
- + {displayItems.map((item, index) => ( + ))}
-
- - {Math.round(uptime)}% uptime - - - Last 20 checks - -
+
); From cca2338468c4ade2d36ce84ec52c51940014d50b Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Thu, 19 Jun 2025 15:01:06 +0700 Subject: [PATCH 14/14] Refactor: Split UptimeBar.tsx into smaller components --- .../services/hooks/useUptimeData.ts | 120 ++++++++++++++++++ .../services/uptime/UptimeErrorState.tsx | 33 +++++ .../services/uptime/UptimeLoadingState.tsx | 21 +++ .../services/uptime/UptimeStatusItem.tsx | 80 ++++++++++++ .../services/uptime/UptimeSummary.tsx | 20 +++ .../src/components/services/uptime/index.ts | 5 + 6 files changed, 279 insertions(+) create mode 100644 application/src/components/services/hooks/useUptimeData.ts create mode 100644 application/src/components/services/uptime/UptimeErrorState.tsx create mode 100644 application/src/components/services/uptime/UptimeLoadingState.tsx create mode 100644 application/src/components/services/uptime/UptimeStatusItem.tsx create mode 100644 application/src/components/services/uptime/UptimeSummary.tsx create mode 100644 application/src/components/services/uptime/index.ts diff --git a/application/src/components/services/hooks/useUptimeData.ts b/application/src/components/services/hooks/useUptimeData.ts new file mode 100644 index 0000000..92c860d --- /dev/null +++ b/application/src/components/services/hooks/useUptimeData.ts @@ -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([]); + + // 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 + }; +}; \ No newline at end of file diff --git a/application/src/components/services/uptime/UptimeErrorState.tsx b/application/src/components/services/uptime/UptimeErrorState.tsx new file mode 100644 index 0000000..f53c61b --- /dev/null +++ b/application/src/components/services/uptime/UptimeErrorState.tsx @@ -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 ( +
+
+ {Array(20).fill(0).map((_, index) => ( +
+ ))} +
+
+ {Math.round(uptime)}% uptime + +
+
+ ); +}; \ No newline at end of file diff --git a/application/src/components/services/uptime/UptimeLoadingState.tsx b/application/src/components/services/uptime/UptimeLoadingState.tsx new file mode 100644 index 0000000..a23b3b6 --- /dev/null +++ b/application/src/components/services/uptime/UptimeLoadingState.tsx @@ -0,0 +1,21 @@ + +import React from 'react'; + +export const UptimeLoadingState = () => { + return ( +
+
+ {Array(20).fill(0).map((_, index) => ( +
+ ))} +
+
+ + +
+
+ ); +}; \ No newline at end of file diff --git a/application/src/components/services/uptime/UptimeStatusItem.tsx b/application/src/components/services/uptime/UptimeStatusItem.tsx new file mode 100644 index 0000000..4759a93 --- /dev/null +++ b/application/src/components/services/uptime/UptimeStatusItem.tsx @@ -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 ( + + +
+ + +
+
{getStatusLabel(item.status)}
+
+ {item.status !== "paused" && item.status !== "down" ? + `${item.responseTime}ms` : + "No response"} +
+
+ {formatTimestamp(item.timestamp)} +
+
+
+ + ); +}; \ No newline at end of file diff --git a/application/src/components/services/uptime/UptimeSummary.tsx b/application/src/components/services/uptime/UptimeSummary.tsx new file mode 100644 index 0000000..b74e050 --- /dev/null +++ b/application/src/components/services/uptime/UptimeSummary.tsx @@ -0,0 +1,20 @@ + +import React from 'react'; + +interface UptimeSummaryProps { + uptime: number; + interval: number; +} + +export const UptimeSummary = ({ uptime, interval }: UptimeSummaryProps) => { + return ( +
+ + {Math.round(uptime)}% uptime + + + Last 20 checks ({interval}s interval) + +
+ ); +}; \ No newline at end of file diff --git a/application/src/components/services/uptime/index.ts b/application/src/components/services/uptime/index.ts new file mode 100644 index 0000000..0249c35 --- /dev/null +++ b/application/src/components/services/uptime/index.ts @@ -0,0 +1,5 @@ + +export { UptimeStatusItem } from './UptimeStatusItem'; +export { UptimeSummary } from './UptimeSummary'; +export { UptimeLoadingState } from './UptimeLoadingState'; +export { UptimeErrorState } from './UptimeErrorState'; \ No newline at end of file