From e5afe276eeae2703b85b9e3eb17844b8043927fa Mon Sep 17 00:00:00 2001 From: Tola Leng Date: Mon, 21 Jul 2025 00:57:04 +0700 Subject: [PATCH] Feat: Add Heatmap button to service detail page - Adds a "Heatmap" button to the service detail page, displaying a monthly health heatmap of the service, with statuses color-coded as green, red, and warning (yellow/orange). --- .../src/components/services/HeatmapChart.tsx | 174 ++++++++++++++++++ .../src/components/services/HeatmapDialog.tsx | 97 ++++++++++ .../services/ServiceDetailContent.tsx | 2 +- .../src/components/services/ServiceHeader.tsx | 145 +++++++++------ 4 files changed, 356 insertions(+), 62 deletions(-) create mode 100644 application/src/components/services/HeatmapChart.tsx create mode 100644 application/src/components/services/HeatmapDialog.tsx diff --git a/application/src/components/services/HeatmapChart.tsx b/application/src/components/services/HeatmapChart.tsx new file mode 100644 index 0000000..f0b5539 --- /dev/null +++ b/application/src/components/services/HeatmapChart.tsx @@ -0,0 +1,174 @@ + +import { UptimeData } from "@/types/service.types"; +import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay } from "date-fns"; + +interface HeatmapChartProps { + uptimeData: UptimeData[]; + selectedMonth: Date; +} + +export const HeatmapChart = ({ uptimeData, selectedMonth }: HeatmapChartProps) => { + const monthStart = startOfMonth(selectedMonth); + const monthEnd = endOfMonth(selectedMonth); + const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const getStatusForDay = (day: Date) => { + const dayData = uptimeData.filter(data => + isSameDay(new Date(data.timestamp), day) + ); + + if (dayData.length === 0) return 'no-data'; + + // Calculate the predominant status for the day + const statusCounts = dayData.reduce((acc, data) => { + acc[data.status] = (acc[data.status] || 0) + 1; + return acc; + }, {} as Record); + + const predominantStatus = Object.entries(statusCounts) + .sort(([,a], [,b]) => b - a)[0][0]; + + return predominantStatus; + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'up': + return 'bg-emerald-500'; + case 'down': + return 'bg-red-500'; + case 'warning': + return 'bg-amber-500'; + case 'paused': + return 'bg-slate-500'; + default: + return 'bg-slate-700'; + } + }; + + const getStatusLabel = (status: string) => { + switch (status) { + case 'up': + return 'Up'; + case 'down': + return 'Down'; + case 'warning': + return 'Warning'; + case 'paused': + return 'Paused'; + default: + return 'No Data'; + } + }; + + // Group days by weeks + const weeks: Date[][] = []; + let currentWeek: Date[] = []; + + daysInMonth.forEach((day, index) => { + if (index === 0) { + // Fill empty days at the start of the month + const dayOfWeek = day.getDay(); + for (let i = 0; i < dayOfWeek; i++) { + currentWeek.push(new Date(0)); // Placeholder for empty cells + } + } + + currentWeek.push(day); + + if (currentWeek.length === 7) { + weeks.push([...currentWeek]); + currentWeek = []; + } + }); + + // Add remaining days to last week + if (currentWeek.length > 0) { + while (currentWeek.length < 7) { + currentWeek.push(new Date(0)); // Placeholder for empty cells + } + weeks.push(currentWeek); + } + + return ( +
+ {/* Header */} +
+

+ Service Health - {format(selectedMonth, 'MMMM yyyy')} +

+

+ Daily status overview for the current month +

+
+ + {/* Calendar Grid */} +
+ {/* Day labels */} +
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
+ + {/* Calendar days */} +
+ {weeks.map((week, weekIndex) => ( +
+ {week.map((day, dayIndex) => { + const isPlaceholder = day.getTime() === 0; + const status = isPlaceholder ? 'no-data' : getStatusForDay(day); + const dayData = isPlaceholder ? [] : uptimeData.filter(data => + isSameDay(new Date(data.timestamp), day) + ); + + return ( +
0 ? ` (${dayData.length} checks)` : ''}`} + > + {isPlaceholder ? '' : format(day, 'd')} +
+ ); + })} +
+ ))} +
+
+ + {/* Status Legend */} +
+ Status: +
+
+
+ Up +
+
+
+ Warning +
+
+
+ Down +
+
+
+ Paused +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/application/src/components/services/HeatmapDialog.tsx b/application/src/components/services/HeatmapDialog.tsx new file mode 100644 index 0000000..ef15c8b --- /dev/null +++ b/application/src/components/services/HeatmapDialog.tsx @@ -0,0 +1,97 @@ + +import { useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { HeatmapChart } from "./HeatmapChart"; +import { UptimeData } from "@/types/service.types"; +import { addMonths, subMonths, format } from "date-fns"; + +interface HeatmapDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serviceName: string; + uptimeData: UptimeData[]; +} + +export const HeatmapDialog = ({ + open, + onOpenChange, + serviceName, + uptimeData +}: HeatmapDialogProps) => { + const [selectedMonth, setSelectedMonth] = useState(new Date()); + + const handlePreviousMonth = () => { + setSelectedMonth(prev => subMonths(prev, 1)); + }; + + const handleNextMonth = () => { + setSelectedMonth(prev => addMonths(prev, 1)); + }; + + const handleCurrentMonth = () => { + setSelectedMonth(new Date()); + }; + + return ( + + + + + Health Heatmap - {serviceName} + + + Monthly overview of service health status with daily breakdown + + + +
+ {/* Month Navigation */} +
+ + + + + +
+ + {/* Heatmap Chart */} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/application/src/components/services/ServiceDetailContent.tsx b/application/src/components/services/ServiceDetailContent.tsx index d925312..8c26143 100644 --- a/application/src/components/services/ServiceDetailContent.tsx +++ b/application/src/components/services/ServiceDetailContent.tsx @@ -1,4 +1,3 @@ - import { Service, UptimeData } from "@/types/service.types"; import { ServiceHeader } from "@/components/services/ServiceHeader"; import { ServiceStatsCards } from "@/components/services/ServiceStatsCards"; @@ -37,6 +36,7 @@ export const ServiceDetailContent = ({ onStatusChange={onStatusChange} selectedRegionalAgent={selectedRegionalAgent} onRegionalAgentChange={onRegionalAgentChange} + uptimeData={uptimeData} /> diff --git a/application/src/components/services/ServiceHeader.tsx b/application/src/components/services/ServiceHeader.tsx index 5016297..9bf2b15 100644 --- a/application/src/components/services/ServiceHeader.tsx +++ b/application/src/components/services/ServiceHeader.tsx @@ -1,93 +1,116 @@ -import { ArrowLeft, Globe } from "lucide-react"; +import { ArrowLeft, Globe, BarChart3 } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/services/StatusBadge"; import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton"; import { RegionalAgentFilter } from "@/components/services/RegionalAgentFilter"; -import { Service } from "@/types/service.types"; +import { HeatmapDialog } from "./HeatmapDialog"; +import { Service, UptimeData } from "@/types/service.types"; import { useLanguage } from "@/contexts/LanguageContext"; import { cn } from "@/lib/utils"; +import { useState } from "react"; interface ServiceHeaderProps { service: Service; onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void; selectedRegionalAgent?: string; onRegionalAgentChange?: (agent: string) => void; + uptimeData?: UptimeData[]; } export function ServiceHeader({ service, onStatusChange, selectedRegionalAgent, - onRegionalAgentChange + onRegionalAgentChange, + uptimeData = [] }: ServiceHeaderProps) { const navigate = useNavigate(); const { t } = useLanguage(); + const [showHeatmap, setShowHeatmap] = useState(false); return ( -
- - -
-
-

{service.name}

- - {/* Pulsating Circle Animation */} -
- - + <> +
+ + +
+
+

{service.name}

+ + {/* Pulsating Circle Animation */} +
+ + +
+ + {service.url && ( + + + {service.url} + + )}
- {service.url && ( - + + +
- -
- - - {selectedRegionalAgent !== undefined && onRegionalAgentChange && ( - - )} + + Heatmap + + {selectedRegionalAgent !== undefined && onRegionalAgentChange && ( + + )} +
-
+ + + ); } \ No newline at end of file