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 (

OS Distribution

); case "osDistributionDoughnut": return (

OS Distribution

); case "osDistributionBar": return (

OS Distribution

); 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;