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).
This commit is contained in:
@@ -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<string, number>);
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
Service Health - {format(selectedMonth, 'MMMM yyyy')}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Daily status overview for the current month
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="space-y-4">
|
||||
{/* Day labels */}
|
||||
<div className="grid grid-cols-7 gap-2 text-sm text-gray-400 font-medium">
|
||||
<div className="text-center py-2">Sun</div>
|
||||
<div className="text-center py-2">Mon</div>
|
||||
<div className="text-center py-2">Tue</div>
|
||||
<div className="text-center py-2">Wed</div>
|
||||
<div className="text-center py-2">Thu</div>
|
||||
<div className="text-center py-2">Fri</div>
|
||||
<div className="text-center py-2">Sat</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar days */}
|
||||
<div className="space-y-2">
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="grid grid-cols-7 gap-2">
|
||||
{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 (
|
||||
<div
|
||||
key={dayIndex}
|
||||
className={`
|
||||
relative aspect-square rounded-lg flex items-center justify-center text-sm font-medium text-white
|
||||
transition-all duration-200 cursor-pointer
|
||||
${isPlaceholder ? 'invisible' : `
|
||||
${getStatusColor(status)} hover:scale-110 hover:shadow-lg
|
||||
`}
|
||||
`}
|
||||
title={isPlaceholder ? '' : `${format(day, 'MMM d, yyyy')}: ${getStatusLabel(status)}${dayData.length > 0 ? ` (${dayData.length} checks)` : ''}`}
|
||||
>
|
||||
{isPlaceholder ? '' : format(day, 'd')}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Legend */}
|
||||
<div className="flex items-center justify-center gap-6 pt-4 border-t border-gray-700">
|
||||
<span className="text-gray-400 text-sm font-medium">Status:</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-emerald-500 rounded-full"></div>
|
||||
<span className="text-gray-300 text-sm">Up</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-amber-500 rounded-full"></div>
|
||||
<span className="text-gray-300 text-sm">Warning</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<span className="text-gray-300 text-sm">Down</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-slate-500 rounded-full"></div>
|
||||
<span className="text-gray-300 text-sm">Paused</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-slate-900 border-slate-700">
|
||||
<DialogHeader className="pb-4">
|
||||
<DialogTitle className="text-xl font-semibold text-white">
|
||||
Health Heatmap - {serviceName}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-gray-400">
|
||||
Monthly overview of service health status with daily breakdown
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Month Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousMonth}
|
||||
className="flex items-center gap-2 bg-slate-800 border-slate-600 text-white hover:bg-slate-700"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCurrentMonth}
|
||||
className="bg-slate-700 text-white hover:bg-slate-600"
|
||||
>
|
||||
Current Month
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextMonth}
|
||||
className="flex items-center gap-2 bg-slate-800 border-slate-600 text-white hover:bg-slate-700"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Heatmap Chart */}
|
||||
<HeatmapChart
|
||||
uptimeData={uptimeData}
|
||||
selectedMonth={selectedMonth}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
<ServiceStatsCards service={service} uptimeData={uptimeData} />
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-4 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold">{service.name}</h1>
|
||||
|
||||
{/* Pulsating Circle Animation */}
|
||||
<div className="relative ml-2 flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-3 w-3 relative",
|
||||
service.status === "up" ? "bg-green-500" :
|
||||
service.status === "down" ? "bg-red-500" :
|
||||
service.status === "warning" ? "bg-yellow-500" :
|
||||
"bg-blue-500",
|
||||
"rounded-full"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"animate-ping absolute h-3 w-3",
|
||||
service.status === "up" ? "bg-green-400" :
|
||||
service.status === "down" ? "bg-red-400" :
|
||||
service.status === "warning" ? "bg-yellow-400" :
|
||||
"bg-blue-400",
|
||||
"rounded-full opacity-75"
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-4 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigate("/dashboard")}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t("back")}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-bold">{service.name}</h1>
|
||||
|
||||
{/* Pulsating Circle Animation */}
|
||||
<div className="relative ml-2 flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-3 w-3 relative",
|
||||
service.status === "up" ? "bg-green-500" :
|
||||
service.status === "down" ? "bg-red-500" :
|
||||
service.status === "warning" ? "bg-yellow-500" :
|
||||
"bg-blue-500",
|
||||
"rounded-full"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"animate-ping absolute h-3 w-3",
|
||||
service.status === "up" ? "bg-green-400" :
|
||||
service.status === "down" ? "bg-red-400" :
|
||||
service.status === "warning" ? "bg-yellow-400" :
|
||||
"bg-blue-400",
|
||||
"rounded-full opacity-75"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{service.url && (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
|
||||
>
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
{service.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{service.url && (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary/80 hover:text-primary text-sm flex items-center mt-1 ml-1"
|
||||
<div className="flex items-center space-x-4">
|
||||
<StatusBadge status={service.status} size="lg" />
|
||||
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowHeatmap(true)}
|
||||
className="bg-blue-900/20 hover:bg-blue-900/30"
|
||||
>
|
||||
<Globe className="h-3 w-3 mr-1" />
|
||||
{service.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<StatusBadge status={service.status} size="lg" />
|
||||
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
||||
{selectedRegionalAgent !== undefined && onRegionalAgentChange && (
|
||||
<RegionalAgentFilter
|
||||
selectedAgent={selectedRegionalAgent}
|
||||
onAgentChange={onRegionalAgentChange}
|
||||
/>
|
||||
)}
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
Heatmap
|
||||
</Button>
|
||||
{selectedRegionalAgent !== undefined && onRegionalAgentChange && (
|
||||
<RegionalAgentFilter
|
||||
selectedAgent={selectedRegionalAgent}
|
||||
onAgentChange={onRegionalAgentChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeatmapDialog
|
||||
open={showHeatmap}
|
||||
onOpenChange={setShowHeatmap}
|
||||
serviceName={service.name}
|
||||
uptimeData={uptimeData}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user