Files
patchmon.net/frontend/src/pages/Dashboard.jsx
Muhammad Ibrahim 5e4c628110 Dashboard Card ecit
2025-10-08 09:53:03 +01:00

1490 lines
40 KiB
JavaScript

import { useQuery } from "@tanstack/react-query";
import {
ArcElement,
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js";
import {
AlertTriangle,
Folder,
GitBranch,
Package,
RefreshCw,
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 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 _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,
} = 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",
"totalOutdatedPackages",
"securityUpdates",
"upToDateHosts",
"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-4 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 "upToDateHosts":
return (
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<TrendingUp className="h-5 w-5 text-success-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Up to date
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.upToDateHosts}/{stats.cards.totalHosts}
</p>
</div>
</div>
</div>
);
case "totalHosts":
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleTotalHostsClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleTotalHostsClick();
}
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Server className="h-5 w-5 text-primary-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Total Hosts
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalHosts}
</p>
</div>
</div>
</button>
);
case "hostsNeedingUpdates":
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleHostsNeedingUpdatesClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleHostsNeedingUpdatesClick();
}
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<AlertTriangle className="h-5 w-5 text-warning-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Needs Updating
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.hostsNeedingUpdates}
</p>
</div>
</div>
</button>
);
case "totalOutdatedPackages":
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleOutdatedPackagesClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOutdatedPackagesClick();
}
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Package className="h-5 w-5 text-secondary-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Outdated Packages
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalOutdatedPackages}
</p>
</div>
</div>
</button>
);
case "securityUpdates":
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleSecurityUpdatesClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSecurityUpdatesClick();
}
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Shield className="h-5 w-5 text-danger-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Security Updates
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.securityUpdates}
</p>
</div>
</div>
</button>
);
case "totalHostGroups":
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleHostGroupsClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleHostGroupsClick();
}
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Folder className="h-5 w-5 text-primary-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Host Groups
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalHostGroups}
</p>
</div>
</div>
</button>
);
case "totalUsers":
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleUsersClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleUsersClick();
}
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Users className="h-5 w-5 text-success-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Users
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalUsers}
</p>
</div>
</div>
</button>
);
case "totalRepos":
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleRepositoriesClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleRepositoriesClick();
}
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<GitBranch className="h-5 w-5 text-warning-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Repositories
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalRepos}
</p>
</div>
</div>
</button>
);
case "erroredHosts":
return (
<button
type="button"
className={`border rounded-lg p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left ${
stats.cards.erroredHosts > 0
? "bg-danger-50 border-danger-200"
: "bg-success-50 border-success-200"
}`}
onClick={handleErroredHostsClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleErroredHostsClick();
}
}}
>
<div className="flex">
<AlertTriangle
className={`h-5 w-5 ${
stats.cards.erroredHosts > 0
? "text-danger-400"
: "text-success-400"
}`}
/>
<div className="ml-3">
{stats.cards.erroredHosts > 0 ? (
<>
<h3 className="text-sm font-medium text-danger-800">
{stats.cards.erroredHosts} host
{stats.cards.erroredHosts > 1 ? "s" : ""} haven't reported
in {formatUpdateIntervalThreshold()}+
</h3>
<p className="text-sm text-danger-700 mt-1">
These hosts may be offline or experiencing connectivity
issues.
</p>
</>
) : (
<>
<h3 className="text-sm font-medium text-success-800">
All hosts are reporting normally
</h3>
<p className="text-sm text-success-700 mt-1">
No hosts have failed to report in the last{" "}
{formatUpdateIntervalThreshold()}.
</p>
</>
)}
</div>
</div>
</button>
);
case "offlineHosts":
return (
<button
type="button"
className={`border rounded-lg p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left ${
stats.cards.offlineHosts > 0
? "bg-warning-50 border-warning-200"
: "bg-success-50 border-success-200"
}`}
onClick={handleOfflineHostsClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOfflineHostsClick();
}
}}
>
<div className="flex">
<WifiOff
className={`h-5 w-5 ${
stats.cards.offlineHosts > 0
? "text-warning-400"
: "text-success-400"
}`}
/>
<div className="ml-3">
{stats.cards.offlineHosts > 0 ? (
<>
<h3 className="text-sm font-medium text-warning-800">
{stats.cards.offlineHosts} host
{stats.cards.offlineHosts > 1 ? "s" : ""} offline/stale
</h3>
<p className="text-sm text-warning-700 mt-1">
These hosts haven't reported in{" "}
{formatUpdateIntervalThreshold() * 3}+ minutes.
</p>
</>
) : (
<>
<h3 className="text-sm font-medium text-success-800">
All hosts are online
</h3>
<p className="text-sm text-success-700 mt-1">
No hosts are offline or stale.
</p>
</>
)}
</div>
</div>
</button>
);
case "osDistribution":
return (
<div className="card p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
OS Distribution
</h3>
<div className="h-64 w-full flex items-center justify-center">
<div className="w-full h-full max-w-sm">
<Pie data={osChartData} options={chartOptions} />
</div>
</div>
</div>
);
case "osDistributionDoughnut":
return (
<div className="card p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
OS Distribution
</h3>
<div className="h-64 w-full flex items-center justify-center">
<div className="w-full h-full max-w-sm">
<Doughnut data={osChartData} options={doughnutChartOptions} />
</div>
</div>
</div>
);
case "osDistributionBar":
return (
<div className="card p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
OS Distribution
</h3>
<div className="h-64">
<Bar data={osBarChartData} options={barChartOptions} />
</div>
</div>
);
case "updateStatus":
return (
<button
type="button"
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleUpdateStatusClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleUpdateStatusClick();
}
}}
>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Update Status
</h3>
<div className="h-64 w-full flex items-center justify-center">
<div className="w-full h-full max-w-sm">
<Pie
data={updateStatusChartData}
options={updateStatusChartOptions}
/>
</div>
</div>
</button>
);
case "packagePriority":
return (
<div className="card p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Outdated Packages by Priority
</h3>
<div className="h-64 w-full flex items-center justify-center">
<div className="w-full h-full max-w-sm">
<Pie
data={packagePriorityChartData}
options={packagePriorityChartOptions}
/>
</div>
</div>
</div>
);
case "packageTrends":
return (
<div className="card p-6 w-full">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Package Trends Over Time
</h3>
<div className="flex items-center gap-3">
{/* Period Selector */}
<select
value={packageTrendsPeriod}
onChange={(e) => setPackageTrendsPeriod(e.target.value)}
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="1">Last 24 hours</option>
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="180">Last 6 months</option>
<option value="365">Last year</option>
</select>
{/* Host Selector */}
<select
value={packageTrendsHost}
onChange={(e) => setPackageTrendsHost(e.target.value)}
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="all">All Hosts</option>
{packageTrendsData?.hosts?.length > 0 ? (
packageTrendsData.hosts.map((host) => (
<option key={host.id} value={host.id}>
{host.friendly_name || host.hostname}
</option>
))
) : (
<option disabled>
{packageTrendsLoading
? "Loading hosts..."
: "No hosts available"}
</option>
)}
</select>
</div>
</div>
<div className="h-64 w-full">
{packageTrendsLoading ? (
<div className="flex items-center justify-center h-full">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
) : packageTrendsData?.chartData ? (
<Line
data={packageTrendsData.chartData}
options={packageTrendsChartOptions}
/>
) : (
<div className="flex items-center justify-center h-full text-secondary-500 dark:text-secondary-400">
No data available
</div>
)}
</div>
</div>
);
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 (
<div className="card p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
System Overview
</h3>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-primary-600">
{updatePercentage}%
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">
Need Updates
</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
{stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts}{" "}
hosts
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-danger-600">
{stats.cards.securityUpdates}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">
Security Issues
</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
{securityPercentage}% of updates
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-success-600">
{onlinePercentage}%
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">
Online
</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
{onlineHosts}/{stats.cards.totalHosts} hosts
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-secondary-600">
{avgPackagesPerHost}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">
Avg per Host
</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
outdated packages
</div>
</div>
</div>
</div>
);
}
case "recentUsers":
return (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Recent Users Logged in
</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-3">
{(recentUsers || []).slice(0, 5).map((u) => (
<div
key={u.id}
className="flex items-center justify-between py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-b-0"
>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{u.username}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-400">
{u.last_login
? formatRelativeTime(u.last_login)
: "Never"}
</div>
</div>
))}
{(!recentUsers || recentUsers.length === 0) && (
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
No users found
</div>
)}
</div>
</div>
</div>
);
case "recentCollection":
return (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Recent Collection
</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-3">
{(recentCollection || []).slice(0, 5).map((host) => (
<div
key={host.id}
className="flex items-center justify-between py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-b-0"
>
<button
type="button"
onClick={() => navigate(`/hosts/${host.id}`)}
className="text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline text-left"
>
{host.friendly_name || host.hostname}
</button>
<div className="text-sm text-secondary-500 dark:text-secondary-400">
{host.last_update
? formatRelativeTime(host.last_update)
: "Never"}
</div>
</div>
))}
{(!recentCollection || recentCollection.length === 0) && (
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
No hosts found
</div>
)}
</div>
</div>
</div>
);
default:
return null;
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
);
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading dashboard
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || "Failed to load dashboard statistics"}
</p>
<button
type="button"
onClick={() => refetch()}
className="mt-2 btn-danger text-xs"
>
Try again
</button>
</div>
</div>
</div>
);
}
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 empty or invalid labels
if (!label || typeof label !== "string") {
return "Unknown Date";
}
// Format hourly labels (e.g., "2025-10-07T14" -> "Oct 7, 2:00 PM")
if (label.includes("T")) {
try {
const date = new Date(`${label}:00:00`);
// Check if date is valid
if (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 (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
}
},
},
},
},
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 empty or invalid labels
if (!label || typeof label !== "string") {
return "Unknown";
}
// Format hourly labels (e.g., "2025-10-07T14" -> "2 PM")
if (label.includes("T")) {
try {
const hour = label.split("T")[1];
const hourNum = parseInt(hour, 10);
// Validate hour number
if (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 (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 (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Welcome back, {user?.first_name || user?.username || "User"} 👋
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Overview of your PatchMon infrastructure
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowSettingsModal(true)}
className="btn-outline flex items-center gap-2"
title="Customize dashboard layout"
>
<Settings className="h-4 w-4" />
Customize Dashboard
</button>
<button
type="button"
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh dashboard data"
>
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
{isFetching ? "Refreshing..." : "Refresh"}
</button>
</div>
</div>
{/* 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) => (
<div
key={`group-${group.type}-${groupIndex}`}
className={getGroupClassName(group.type)}
>
{group.cards.map((card, cardIndex) => (
<div
key={`card-${card.cardId}-${groupIndex}-${cardIndex}`}
className={
card.cardId === "packageTrends" ? "lg:col-span-2" : ""
}
>
{renderCard(card.cardId)}
</div>
))}
</div>
))}
</>
);
})()}
{/* Dashboard Settings Modal */}
<DashboardSettingsModal
isOpen={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
/>
</div>
);
};
export default Dashboard;