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 { Service, UptimeData } from "@/types/service.types";
|
||||||
import { ServiceHeader } from "@/components/services/ServiceHeader";
|
import { ServiceHeader } from "@/components/services/ServiceHeader";
|
||||||
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
|
import { ServiceStatsCards } from "@/components/services/ServiceStatsCards";
|
||||||
@@ -37,6 +36,7 @@ export const ServiceDetailContent = ({
|
|||||||
onStatusChange={onStatusChange}
|
onStatusChange={onStatusChange}
|
||||||
selectedRegionalAgent={selectedRegionalAgent}
|
selectedRegionalAgent={selectedRegionalAgent}
|
||||||
onRegionalAgentChange={onRegionalAgentChange}
|
onRegionalAgentChange={onRegionalAgentChange}
|
||||||
|
uptimeData={uptimeData}
|
||||||
/>
|
/>
|
||||||
<ServiceStatsCards service={service} uptimeData={uptimeData} />
|
<ServiceStatsCards service={service} uptimeData={uptimeData} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,37 @@
|
|||||||
|
|
||||||
import { ArrowLeft, Globe } from "lucide-react";
|
import { ArrowLeft, Globe, BarChart3 } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { StatusBadge } from "@/components/services/StatusBadge";
|
import { StatusBadge } from "@/components/services/StatusBadge";
|
||||||
import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton";
|
import { ServiceMonitoringButton } from "@/components/services/ServiceMonitoringButton";
|
||||||
import { RegionalAgentFilter } from "@/components/services/RegionalAgentFilter";
|
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 { useLanguage } from "@/contexts/LanguageContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface ServiceHeaderProps {
|
interface ServiceHeaderProps {
|
||||||
service: Service;
|
service: Service;
|
||||||
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
onStatusChange?: (newStatus: "up" | "down" | "paused" | "warning") => void;
|
||||||
selectedRegionalAgent?: string;
|
selectedRegionalAgent?: string;
|
||||||
onRegionalAgentChange?: (agent: string) => void;
|
onRegionalAgentChange?: (agent: string) => void;
|
||||||
|
uptimeData?: UptimeData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceHeader({
|
export function ServiceHeader({
|
||||||
service,
|
service,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
selectedRegionalAgent,
|
selectedRegionalAgent,
|
||||||
onRegionalAgentChange
|
onRegionalAgentChange,
|
||||||
|
uptimeData = []
|
||||||
}: ServiceHeaderProps) {
|
}: ServiceHeaderProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -80,6 +86,15 @@ export function ServiceHeader({
|
|||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<StatusBadge status={service.status} size="lg" />
|
<StatusBadge status={service.status} size="lg" />
|
||||||
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
<ServiceMonitoringButton service={service} onStatusChange={onStatusChange} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowHeatmap(true)}
|
||||||
|
className="bg-blue-900/20 hover:bg-blue-900/30"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4 mr-2" />
|
||||||
|
Heatmap
|
||||||
|
</Button>
|
||||||
{selectedRegionalAgent !== undefined && onRegionalAgentChange && (
|
{selectedRegionalAgent !== undefined && onRegionalAgentChange && (
|
||||||
<RegionalAgentFilter
|
<RegionalAgentFilter
|
||||||
selectedAgent={selectedRegionalAgent}
|
selectedAgent={selectedRegionalAgent}
|
||||||
@@ -89,5 +104,13 @@ export function ServiceHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HeatmapDialog
|
||||||
|
open={showHeatmap}
|
||||||
|
onOpenChange={setShowHeatmap}
|
||||||
|
serviceName={service.name}
|
||||||
|
uptimeData={uptimeData}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user