import { useQuery } from "@tanstack/react-query";
import {
ArcElement,
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js";
import {
AlertTriangle,
CheckCircle,
Folder,
GitBranch,
Package,
RefreshCw,
RotateCcw,
Server,
Settings,
Shield,
TrendingUp,
Users,
WifiOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Bar, Doughnut, Line, Pie } from "react-chartjs-2";
import { useNavigate } from "react-router-dom";
import DashboardSettingsModal from "../components/DashboardSettingsModal";
import { useAuth } from "../contexts/AuthContext";
import { useTheme } from "../contexts/ThemeContext";
import {
dashboardAPI,
dashboardPreferencesAPI,
formatRelativeTime,
settingsAPI,
} from "../utils/api";
// Register Chart.js components
ChartJS.register(
ArcElement,
Tooltip,
Legend,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Title,
);
const Dashboard = () => {
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [cardPreferences, setCardPreferences] = useState([]);
const [packageTrendsPeriod, setPackageTrendsPeriod] = useState("1"); // days
const [packageTrendsHost, setPackageTrendsHost] = useState("all"); // host filter
const [systemStatsJobId, setSystemStatsJobId] = useState(null); // Track job ID for system statistics
const [isTriggeringJob, setIsTriggeringJob] = useState(false);
const navigate = useNavigate();
const { isDark } = useTheme();
const { user } = useAuth();
// Navigation handlers
const handleTotalHostsClick = () => {
navigate("/hosts", { replace: true });
};
const handleHostsNeedingUpdatesClick = () => {
navigate("/hosts?filter=needsUpdates");
};
const handleOutdatedPackagesClick = () => {
navigate("/packages?filter=outdated");
};
const handleSecurityUpdatesClick = () => {
navigate("/packages?filter=security");
};
const handleErroredHostsClick = () => {
navigate("/hosts?filter=inactive");
};
const handleOfflineHostsClick = () => {
navigate("/hosts?filter=offline");
};
// New navigation handlers for top cards
const handleUsersClick = () => {
navigate("/users");
};
const handleHostGroupsClick = () => {
navigate("/options");
};
const handleRepositoriesClick = () => {
navigate("/repositories");
};
const handleNeedsRebootClick = () => {
// Navigate to hosts with reboot filter, clearing any other filters
const newSearchParams = new URLSearchParams();
newSearchParams.set("reboot", "true");
navigate(`/hosts?${newSearchParams.toString()}`);
};
const handleUpToDateClick = () => {
// Navigate to hosts with upToDate filter, clearing any other filters
const newSearchParams = new URLSearchParams();
newSearchParams.set("filter", "upToDate");
navigate(`/hosts?${newSearchParams.toString()}`);
};
const _handleOSDistributionClick = () => {
navigate("/hosts?showFilters=true", { replace: true });
};
const handleUpdateStatusClick = () => {
navigate("/hosts?filter=needsUpdates", { replace: true });
};
const _handlePackagePriorityClick = () => {
navigate("/packages?filter=security");
};
// Chart click handlers
const handleOSChartClick = (_, elements) => {
if (elements.length > 0) {
const elementIndex = elements[0].index;
const osName =
stats.charts.osDistribution[elementIndex].name.toLowerCase();
navigate(`/hosts?osFilter=${osName}&showFilters=true`, { replace: true });
}
};
const handleUpdateStatusChartClick = (_, elements) => {
if (elements.length > 0) {
const elementIndex = elements[0].index;
const statusName =
stats.charts.updateStatusDistribution[elementIndex].name;
// Map status names to filter parameters
let filter = "";
if (statusName.toLowerCase().includes("needs updates")) {
filter = "needsUpdates";
} else if (statusName.toLowerCase().includes("up to date")) {
filter = "upToDate";
} else if (statusName.toLowerCase().includes("stale")) {
filter = "stale";
}
if (filter) {
navigate(`/hosts?filter=${filter}`, { replace: true });
}
}
};
const handlePackagePriorityChartClick = (_, elements) => {
if (elements.length > 0) {
const elementIndex = elements[0].index;
const priorityName =
stats.charts.packageUpdateDistribution[elementIndex].name;
// Map priority names to filter parameters
if (priorityName.toLowerCase().includes("security")) {
navigate("/packages?filter=security", { replace: true });
} else if (priorityName.toLowerCase().includes("regular")) {
navigate("/packages?filter=regular", { replace: true });
}
}
};
// Helper function to format the update interval threshold
const formatUpdateIntervalThreshold = () => {
if (!settings?.updateInterval) return "24 hours";
const intervalMinutes = settings.updateInterval;
const thresholdMinutes = intervalMinutes * 2; // 2x the update interval
if (thresholdMinutes < 60) {
return `${thresholdMinutes} minutes`;
} else if (thresholdMinutes < 1440) {
const hours = Math.floor(thresholdMinutes / 60);
const minutes = thresholdMinutes % 60;
if (minutes === 0) {
return `${hours} hour${hours > 1 ? "s" : ""}`;
}
return `${hours}h ${minutes}m`;
} else {
const days = Math.floor(thresholdMinutes / 1440);
const hours = Math.floor((thresholdMinutes % 1440) / 60);
if (hours === 0) {
return `${days} day${days > 1 ? "s" : ""}`;
}
return `${days}d ${hours}h`;
}
};
const {
data: stats,
isLoading,
error,
refetch,
isFetching,
} = useQuery({
queryKey: ["dashboardStats"],
queryFn: () => dashboardAPI.getStats().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
});
// Package trends data query
const {
data: packageTrendsData,
isLoading: packageTrendsLoading,
error: _packageTrendsError,
refetch: refetchPackageTrends,
isFetching: packageTrendsFetching,
} = useQuery({
queryKey: ["packageTrends", packageTrendsPeriod, packageTrendsHost],
queryFn: () => {
const params = {
days: packageTrendsPeriod,
};
if (packageTrendsHost !== "all") {
params.hostId = packageTrendsHost;
}
return dashboardAPI.getPackageTrends(params).then((res) => res.data);
},
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
// Fetch recent users (permission protected server-side)
const { data: recentUsers } = useQuery({
queryKey: ["dashboardRecentUsers"],
queryFn: () => dashboardAPI.getRecentUsers().then((res) => res.data),
staleTime: 60 * 1000,
});
// Fetch recent collection (permission protected server-side)
const { data: recentCollection } = useQuery({
queryKey: ["dashboardRecentCollection"],
queryFn: () => dashboardAPI.getRecentCollection().then((res) => res.data),
staleTime: 60 * 1000,
});
// Fetch settings to get the agent update interval
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Fetch user's dashboard preferences
const { data: preferences } = useQuery({
queryKey: ["dashboardPreferences"],
queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
});
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ["dashboardDefaultCards"],
queryFn: () =>
dashboardPreferencesAPI.getDefaults().then((res) => res.data),
});
// Merge preferences with default cards (normalize snake_case from API)
useEffect(() => {
if (preferences && defaultCards) {
const normalizedPreferences = preferences.map((p) => ({
cardId: p.cardId ?? p.card_id,
enabled: p.enabled,
order: p.order,
}));
const mergedCards = defaultCards
.map((defaultCard) => {
const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId,
);
return {
...defaultCard,
enabled: userPreference
? userPreference.enabled
: defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order,
};
})
.sort((a, b) => a.order - b.order);
setCardPreferences(mergedCards);
} else if (defaultCards) {
// If no preferences exist, use defaults
setCardPreferences(defaultCards.sort((a, b) => a.order - b.order));
}
}, [preferences, defaultCards]);
// Listen for custom event from Layout component
useEffect(() => {
const handleOpenSettings = () => {
setShowSettingsModal(true);
};
window.addEventListener("openDashboardSettings", handleOpenSettings);
return () => {
window.removeEventListener("openDashboardSettings", handleOpenSettings);
};
}, []);
// Helper function to check if a card should be displayed
const isCardEnabled = (cardId) => {
const card = cardPreferences.find((c) => c.cardId === cardId);
return card ? card.enabled : true; // Default to enabled if not found
};
// Helper function to get card type for layout grouping
const getCardType = (cardId) => {
if (
[
"totalHosts",
"hostsNeedingUpdates",
"upToDateHosts",
"totalOutdatedPackages",
"securityUpdates",
"hostsNeedingReboot",
"totalHostGroups",
"totalUsers",
"totalRepos",
].includes(cardId)
) {
return "stats";
} else if (
[
"osDistribution",
"osDistributionBar",
"osDistributionDoughnut",
"updateStatus",
"packagePriority",
"recentUsers",
"recentCollection",
].includes(cardId)
) {
return "charts";
} else if (["packageTrends"].includes(cardId)) {
return "charts";
} else if (["erroredHosts", "quickStats"].includes(cardId)) {
return "fullwidth";
}
return "fullwidth"; // Default to full width
};
// Helper function to get CSS class for card group
const getGroupClassName = (cardType) => {
switch (cardType) {
case "stats":
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4";
case "charts":
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
case "widecharts":
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
case "fullwidth":
return "space-y-6";
default:
return "space-y-6";
}
};
// Helper function to render a card by ID
const renderCard = (cardId) => {
switch (cardId) {
case "hostsNeedingReboot":
return (
);
case "totalHosts":
return (
);
case "hostsNeedingUpdates":
return (
);
case "upToDateHosts":
return (
);
case "totalOutdatedPackages":
return (
);
case "securityUpdates":
return (
);
case "totalHostGroups":
return (
);
case "totalUsers":
return (
);
case "totalRepos":
return (
);
case "erroredHosts":
return (
);
case "offlineHosts":
return (
);
case "osDistribution":
return (
);
case "osDistributionDoughnut":
return (
);
case "osDistributionBar":
return (
);
case "updateStatus":
return (
);
case "packagePriority":
return (
Outdated Packages by Priority
);
case "packageTrends":
return (
Package Trends Over Time
{/* Refresh Button */}
{/* Period Selector */}
{/* Host Selector */}
{/* Job ID Message */}
{systemStatsJobId && packageTrendsHost === "all" && (
Ran collection job #{systemStatsJobId}
)}
{packageTrendsLoading ? (
) : packageTrendsData?.chartData ? (
) : (
No data available
)}
);
case "quickStats": {
// Calculate dynamic stats
const updatePercentage =
stats.cards.totalHosts > 0
? (
(stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) *
100
).toFixed(1)
: 0;
const onlineHosts = stats.cards.totalHosts - stats.cards.erroredHosts;
const onlinePercentage =
stats.cards.totalHosts > 0
? ((onlineHosts / stats.cards.totalHosts) * 100).toFixed(0)
: 0;
const securityPercentage =
stats.cards.totalOutdatedPackages > 0
? (
(stats.cards.securityUpdates /
stats.cards.totalOutdatedPackages) *
100
).toFixed(0)
: 0;
const avgPackagesPerHost =
stats.cards.totalHosts > 0
? Math.round(
stats.cards.totalOutdatedPackages / stats.cards.totalHosts,
)
: 0;
return (
System Overview
{updatePercentage}%
Need Updates
{stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts}{" "}
hosts
{stats.cards.securityUpdates}
Security Issues
{securityPercentage}% of updates
{onlinePercentage}%
Online
{onlineHosts}/{stats.cards.totalHosts} hosts
{avgPackagesPerHost}
Avg per Host
outdated packages
);
}
case "recentUsers":
return (
Recent Users Logged in
{(recentUsers || []).slice(0, 5).map((u) => (
{u.username}
{u.last_login
? formatRelativeTime(u.last_login)
: "Never"}
))}
{(!recentUsers || recentUsers.length === 0) && (
No users found
)}
);
case "recentCollection":
return (
Recent Collection
{(recentCollection || []).slice(0, 5).map((host) => (
{host.last_update
? formatRelativeTime(host.last_update)
: "Never"}
))}
{(!recentCollection || recentCollection.length === 0) && (
No hosts found
)}
);
default:
return null;
}
};
if (isLoading) {
return (
);
}
if (error) {
return (
Error loading dashboard
{error.message || "Failed to load dashboard statistics"}
);
}
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
labels: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
},
padding: 15,
usePointStyle: true,
pointStyle: "circle",
},
},
},
layout: {
padding: {
right: 20,
},
},
onClick: handleOSChartClick,
};
const doughnutChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
labels: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
},
padding: 15,
usePointStyle: true,
pointStyle: "circle",
},
},
},
layout: {
padding: {
right: 20,
},
},
onClick: handleOSChartClick,
};
const updateStatusChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
labels: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
},
padding: 15,
usePointStyle: true,
pointStyle: "circle",
},
},
},
layout: {
padding: {
right: 20,
},
},
onClick: handleUpdateStatusChartClick,
};
const packagePriorityChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
labels: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
},
padding: 15,
usePointStyle: true,
pointStyle: "circle",
},
},
},
layout: {
padding: {
right: 20,
},
},
onClick: handlePackagePriorityChartClick,
};
const packageTrendsChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
},
padding: 20,
usePointStyle: true,
pointStyle: "circle",
},
},
tooltip: {
mode: "index",
intersect: false,
backgroundColor: isDark ? "#374151" : "#ffffff",
titleColor: isDark ? "#ffffff" : "#374151",
bodyColor: isDark ? "#ffffff" : "#374151",
borderColor: isDark ? "#4B5563" : "#E5E7EB",
borderWidth: 1,
callbacks: {
title: (context) => {
const label = context[0].label;
// Handle "Now" label
if (label === "Now") {
return "Now";
}
// Handle empty or invalid labels
if (!label || typeof label !== "string") {
return "Unknown Date";
}
// Check if it's a full ISO timestamp (for "Last 24 hours")
// Format: "2025-01-15T14:30:00.000Z" or "2025-01-15T14:30:00.000"
if (label.includes("T") && label.includes(":")) {
try {
const date = new Date(label);
// Check if date is valid
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
// Format full ISO timestamp with date and time
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
} catch (_error) {
return label; // Return original label if parsing fails
}
}
// Format hourly labels (e.g., "2025-10-07T14" -> "Oct 7, 2:00 PM")
if (label.includes("T") && !label.includes(":")) {
try {
const date = new Date(`${label}:00:00`);
// Check if date is valid
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
} catch (_error) {
return label; // Return original label if parsing fails
}
}
// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
try {
const date = new Date(label);
// Check if date is valid
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
} catch (_error) {
return label; // Return original label if parsing fails
}
},
label: (context) => {
const value = context.parsed.y;
if (value === null || value === undefined) {
return `${context.dataset.label}: No data`;
}
return `${context.dataset.label}: ${value}`;
},
},
},
},
scales: {
x: {
display: true,
title: {
display: true,
text: packageTrendsPeriod === "1" ? "Time (Hours)" : "Date",
color: isDark ? "#ffffff" : "#374151",
},
ticks: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 11,
},
callback: function (value, _index, _ticks) {
const label = this.getLabelForValue(value);
// Handle "Now" label
if (label === "Now") {
return "Now";
}
// Handle empty or invalid labels
if (!label || typeof label !== "string") {
return "Unknown";
}
// Check if it's a full ISO timestamp (for "Last 24 hours")
// Format: "2025-01-15T14:30:00.000Z" or "2025-01-15T14:30:00.000"
if (label.includes("T") && label.includes(":")) {
try {
const date = new Date(label);
// Check if date is valid
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
// Extract hour from full ISO timestamp
const hourNum = date.getHours();
return hourNum === 0
? "12 AM"
: hourNum < 12
? `${hourNum} AM`
: hourNum === 12
? "12 PM"
: `${hourNum - 12} PM`;
} catch (_error) {
return label; // Return original label if parsing fails
}
}
// Format hourly labels (e.g., "2025-10-07T14" -> "2 PM")
if (label.includes("T") && !label.includes(":")) {
try {
const hour = label.split("T")[1];
const hourNum = parseInt(hour, 10);
// Validate hour number
if (Number.isNaN(hourNum) || hourNum < 0 || hourNum > 23) {
return hour; // Return original hour if invalid
}
return hourNum === 0
? "12 AM"
: hourNum < 12
? `${hourNum} AM`
: hourNum === 12
? "12 PM"
: `${hourNum - 12} PM`;
} catch (_error) {
return label; // Return original label if parsing fails
}
}
// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
try {
const date = new Date(label);
// Check if date is valid
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
} catch (_error) {
return label; // Return original label if parsing fails
}
},
},
grid: {
color: isDark ? "#374151" : "#E5E7EB",
},
},
y: {
display: true,
title: {
display: true,
text: "Number of Packages",
color: isDark ? "#ffffff" : "#374151",
},
ticks: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 11,
},
beginAtZero: true,
},
grid: {
color: isDark ? "#374151" : "#E5E7EB",
},
},
},
interaction: {
mode: "nearest",
axis: "x",
intersect: false,
},
};
const barChartOptions = {
responsive: true,
indexAxis: "y", // Make the chart horizontal
plugins: {
legend: {
display: false,
},
},
scales: {
x: {
ticks: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
},
},
grid: {
color: isDark ? "#374151" : "#e5e7eb",
},
},
y: {
ticks: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
},
},
grid: {
color: isDark ? "#374151" : "#e5e7eb",
},
},
},
onClick: handleOSChartClick,
};
const osChartData = {
labels: stats.charts.osDistribution.map((item) => item.name),
datasets: [
{
data: stats.charts.osDistribution.map((item) => item.count),
backgroundColor: [
"#3B82F6", // Blue
"#10B981", // Green
"#F59E0B", // Yellow
"#EF4444", // Red
"#8B5CF6", // Purple
"#06B6D4", // Cyan
],
borderWidth: 2,
borderColor: "#ffffff",
},
],
};
const osBarChartData = {
labels: stats.charts.osDistribution.map((item) => item.name),
datasets: [
{
label: "Hosts",
data: stats.charts.osDistribution.map((item) => item.count),
backgroundColor: [
"#3B82F6", // Blue
"#10B981", // Green
"#F59E0B", // Yellow
"#EF4444", // Red
"#8B5CF6", // Purple
"#06B6D4", // Cyan
],
borderWidth: 1,
borderColor: isDark ? "#374151" : "#ffffff",
borderRadius: 4,
borderSkipped: false,
},
],
};
const updateStatusChartData = {
labels: stats.charts.updateStatusDistribution.map((item) => item.name),
datasets: [
{
data: stats.charts.updateStatusDistribution.map((item) => item.count),
backgroundColor: [
"#10B981", // Green - Up to date
"#F59E0B", // Yellow - Needs updates
"#EF4444", // Red - Errored
],
borderWidth: 2,
borderColor: "#ffffff",
},
],
};
const packagePriorityChartData = {
labels: stats.charts.packageUpdateDistribution.map((item) => item.name),
datasets: [
{
data: stats.charts.packageUpdateDistribution.map((item) => item.count),
backgroundColor: [
"#EF4444", // Red - Security
"#3B82F6", // Blue - Regular
],
borderWidth: 2,
borderColor: "#ffffff",
},
],
};
return (
{/* Page Header */}
Welcome back, {user?.first_name || user?.username || "User"} 👋
Overview of your PatchMon infrastructure
{/* Dynamically Rendered Cards - Unified Order */}
{(() => {
const enabledCards = cardPreferences
.filter((card) => isCardEnabled(card.cardId))
.sort((a, b) => a.order - b.order);
// Group consecutive cards of the same type for proper layout
const cardGroups = [];
let currentGroup = null;
enabledCards.forEach((card) => {
const cardType = getCardType(card.cardId);
if (!currentGroup || currentGroup.type !== cardType) {
// Start a new group
currentGroup = {
type: cardType,
cards: [card],
};
cardGroups.push(currentGroup);
} else {
// Add to existing group
currentGroup.cards.push(card);
}
});
return (
<>
{cardGroups.map((group, groupIndex) => (
{group.cards.map((card, cardIndex) => (
{renderCard(card.cardId)}
))}
))}
>
);
})()}
{/* Dashboard Settings Modal */}
setShowSettingsModal(false)}
/>
);
};
export default Dashboard;