mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-22 23:32:03 +00:00
@@ -15,6 +15,7 @@ import Login from "./pages/Login";
|
||||
import PackageDetail from "./pages/PackageDetail";
|
||||
import Packages from "./pages/Packages";
|
||||
import Profile from "./pages/Profile";
|
||||
import Queue from "./pages/Queue";
|
||||
import Repositories from "./pages/Repositories";
|
||||
import RepositoryDetail from "./pages/RepositoryDetail";
|
||||
import AlertChannels from "./pages/settings/AlertChannels";
|
||||
@@ -115,6 +116,16 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/queue"
|
||||
element={
|
||||
<ProtectedRoute requirePermission="can_view_hosts">
|
||||
<Layout>
|
||||
<Queue />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
|
@@ -11,6 +11,7 @@ import {
|
||||
Github,
|
||||
Globe,
|
||||
Home,
|
||||
List,
|
||||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
Star,
|
||||
UserCircle,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FaYoutube } from "react-icons/fa";
|
||||
@@ -134,6 +136,22 @@ const Layout = ({ children }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Add Pro-Action and Queue items (available to all users with inventory access)
|
||||
inventoryItems.push(
|
||||
{
|
||||
name: "Pro-Action",
|
||||
href: "/pro-action",
|
||||
icon: Zap,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
name: "Queue",
|
||||
href: "/queue",
|
||||
icon: List,
|
||||
comingSoon: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (inventoryItems.length > 0) {
|
||||
nav.push({
|
||||
section: "Inventory",
|
||||
@@ -191,6 +209,8 @@ const Layout = ({ children }) => {
|
||||
return "Repositories";
|
||||
if (path === "/services") return "Services";
|
||||
if (path === "/docker") return "Docker";
|
||||
if (path === "/pro-action") return "Pro-Action";
|
||||
if (path === "/queue") return "Queue";
|
||||
if (path === "/users") return "Users";
|
||||
if (path === "/permissions") return "Permissions";
|
||||
if (path === "/settings") return "Settings";
|
||||
|
@@ -82,6 +82,7 @@ const SettingsLayout = ({ children }) => {
|
||||
name: "Alert Channels",
|
||||
href: "/settings/alert-channels",
|
||||
icon: Bell,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
name: "Notifications",
|
||||
@@ -118,7 +119,6 @@ const SettingsLayout = ({ children }) => {
|
||||
name: "Integrations",
|
||||
href: "/settings/integrations",
|
||||
icon: Wrench,
|
||||
comingSoon: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@@ -6,6 +6,8 @@ import {
|
||||
Chart as ChartJS,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "chart.js";
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bar, Doughnut, Pie } from "react-chartjs-2";
|
||||
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";
|
||||
@@ -43,12 +45,16 @@ ChartJS.register(
|
||||
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();
|
||||
@@ -91,7 +97,7 @@ const Dashboard = () => {
|
||||
navigate("/repositories");
|
||||
};
|
||||
|
||||
const handleOSDistributionClick = () => {
|
||||
const _handleOSDistributionClick = () => {
|
||||
navigate("/hosts?showFilters=true", { replace: true });
|
||||
};
|
||||
|
||||
@@ -99,7 +105,7 @@ const Dashboard = () => {
|
||||
navigate("/hosts?filter=needsUpdates", { replace: true });
|
||||
};
|
||||
|
||||
const handlePackagePriorityClick = () => {
|
||||
const _handlePackagePriorityClick = () => {
|
||||
navigate("/packages?filter=security");
|
||||
};
|
||||
|
||||
@@ -189,6 +195,26 @@ const Dashboard = () => {
|
||||
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"],
|
||||
@@ -299,6 +325,8 @@ const Dashboard = () => {
|
||||
].includes(cardId)
|
||||
) {
|
||||
return "charts";
|
||||
} else if (["packageTrends"].includes(cardId)) {
|
||||
return "charts";
|
||||
} else if (["erroredHosts", "quickStats"].includes(cardId)) {
|
||||
return "fullwidth";
|
||||
}
|
||||
@@ -312,6 +340,8 @@ const Dashboard = () => {
|
||||
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:
|
||||
@@ -733,6 +763,71 @@ const Dashboard = () => {
|
||||
</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 =
|
||||
@@ -1028,6 +1123,119 @@ const Dashboard = () => {
|
||||
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;
|
||||
// Format hourly labels (e.g., "2025-10-07T14" -> "Oct 7, 2:00 PM")
|
||||
if (label.includes("T")) {
|
||||
const date = new Date(`${label}:00:00`);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
|
||||
const date = new Date(label);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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);
|
||||
// Format hourly labels (e.g., "2025-10-07T14" -> "2 PM")
|
||||
if (label.includes("T")) {
|
||||
const hour = label.split("T")[1];
|
||||
const hourNum = parseInt(hour, 10);
|
||||
return hourNum === 0
|
||||
? "12 AM"
|
||||
: hourNum < 12
|
||||
? `${hourNum} AM`
|
||||
: hourNum === 12
|
||||
? "12 PM"
|
||||
: `${hourNum - 12} PM`;
|
||||
}
|
||||
// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
|
||||
const date = new Date(label);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
},
|
||||
},
|
||||
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
|
||||
@@ -1206,7 +1414,12 @@ const Dashboard = () => {
|
||||
className={getGroupClassName(group.type)}
|
||||
>
|
||||
{group.cards.map((card, cardIndex) => (
|
||||
<div key={`card-${card.cardId}-${groupIndex}-${cardIndex}`}>
|
||||
<div
|
||||
key={`card-${card.cardId}-${groupIndex}-${cardIndex}`}
|
||||
className={
|
||||
card.cardId === "packageTrends" ? "lg:col-span-2" : ""
|
||||
}
|
||||
>
|
||||
{renderCard(card.cardId)}
|
||||
</div>
|
||||
))}
|
||||
|
@@ -5,8 +5,6 @@ import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Copy,
|
||||
Cpu,
|
||||
|
699
frontend/src/pages/Queue.jsx
Normal file
699
frontend/src/pages/Queue.jsx
Normal file
@@ -0,0 +1,699 @@
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Eye,
|
||||
Filter,
|
||||
Package,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
const Queue = () => {
|
||||
const [activeTab, setActiveTab] = useState("server");
|
||||
const [filterStatus, setFilterStatus] = useState("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Mock data for demonstration
|
||||
const serverQueueData = [
|
||||
{
|
||||
id: 1,
|
||||
type: "Server Update Check",
|
||||
description: "Check for server updates from GitHub",
|
||||
status: "running",
|
||||
priority: "high",
|
||||
createdAt: "2024-01-15 10:30:00",
|
||||
estimatedCompletion: "2024-01-15 10:35:00",
|
||||
progress: 75,
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: "Session Cleanup",
|
||||
description: "Clear expired login sessions",
|
||||
status: "pending",
|
||||
priority: "medium",
|
||||
createdAt: "2024-01-15 10:25:00",
|
||||
estimatedCompletion: "2024-01-15 10:40:00",
|
||||
progress: 0,
|
||||
retryCount: 0,
|
||||
maxRetries: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: "Database Optimization",
|
||||
description: "Optimize database indexes and cleanup old records",
|
||||
status: "completed",
|
||||
priority: "low",
|
||||
createdAt: "2024-01-15 09:00:00",
|
||||
completedAt: "2024-01-15 09:45:00",
|
||||
progress: 100,
|
||||
retryCount: 0,
|
||||
maxRetries: 1,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: "Backup Creation",
|
||||
description: "Create system backup",
|
||||
status: "failed",
|
||||
priority: "high",
|
||||
createdAt: "2024-01-15 08:00:00",
|
||||
errorMessage: "Insufficient disk space",
|
||||
progress: 45,
|
||||
retryCount: 2,
|
||||
maxRetries: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const agentQueueData = [
|
||||
{
|
||||
id: 1,
|
||||
hostname: "web-server-01",
|
||||
ip: "192.168.1.100",
|
||||
type: "Agent Update Collection",
|
||||
description: "Agent v1.2.7 → v1.2.8",
|
||||
status: "pending",
|
||||
priority: "medium",
|
||||
lastCommunication: "2024-01-15 10:00:00",
|
||||
nextExpectedCommunication: "2024-01-15 11:00:00",
|
||||
currentVersion: "1.2.7",
|
||||
targetVersion: "1.2.8",
|
||||
retryCount: 0,
|
||||
maxRetries: 5,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
hostname: "db-server-02",
|
||||
ip: "192.168.1.101",
|
||||
type: "Data Collection",
|
||||
description: "Collect package and system information",
|
||||
status: "running",
|
||||
priority: "high",
|
||||
lastCommunication: "2024-01-15 10:15:00",
|
||||
nextExpectedCommunication: "2024-01-15 11:15:00",
|
||||
currentVersion: "1.2.8",
|
||||
targetVersion: "1.2.8",
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
hostname: "app-server-03",
|
||||
ip: "192.168.1.102",
|
||||
type: "Agent Update Collection",
|
||||
description: "Agent v1.2.6 → v1.2.8",
|
||||
status: "completed",
|
||||
priority: "low",
|
||||
lastCommunication: "2024-01-15 09:30:00",
|
||||
completedAt: "2024-01-15 09:45:00",
|
||||
currentVersion: "1.2.8",
|
||||
targetVersion: "1.2.8",
|
||||
retryCount: 0,
|
||||
maxRetries: 5,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
hostname: "test-server-04",
|
||||
ip: "192.168.1.103",
|
||||
type: "Data Collection",
|
||||
description: "Collect package and system information",
|
||||
status: "failed",
|
||||
priority: "medium",
|
||||
lastCommunication: "2024-01-15 08:00:00",
|
||||
errorMessage: "Connection timeout",
|
||||
retryCount: 3,
|
||||
maxRetries: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const patchQueueData = [
|
||||
{
|
||||
id: 1,
|
||||
hostname: "web-server-01",
|
||||
ip: "192.168.1.100",
|
||||
packages: ["nginx", "openssl", "curl"],
|
||||
type: "Security Updates",
|
||||
description: "Apply critical security patches",
|
||||
status: "pending",
|
||||
priority: "high",
|
||||
scheduledFor: "2024-01-15 19:00:00",
|
||||
lastCommunication: "2024-01-15 18:00:00",
|
||||
nextExpectedCommunication: "2024-01-15 19:00:00",
|
||||
retryCount: 0,
|
||||
maxRetries: 3,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
hostname: "db-server-02",
|
||||
ip: "192.168.1.101",
|
||||
packages: ["postgresql", "python3"],
|
||||
type: "Feature Updates",
|
||||
description: "Update database and Python packages",
|
||||
status: "running",
|
||||
priority: "medium",
|
||||
scheduledFor: "2024-01-15 20:00:00",
|
||||
lastCommunication: "2024-01-15 19:15:00",
|
||||
nextExpectedCommunication: "2024-01-15 20:15:00",
|
||||
retryCount: 0,
|
||||
maxRetries: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
hostname: "app-server-03",
|
||||
ip: "192.168.1.102",
|
||||
packages: ["nodejs", "npm"],
|
||||
type: "Maintenance Updates",
|
||||
description: "Update Node.js and npm packages",
|
||||
status: "completed",
|
||||
priority: "low",
|
||||
scheduledFor: "2024-01-15 18:30:00",
|
||||
completedAt: "2024-01-15 18:45:00",
|
||||
retryCount: 0,
|
||||
maxRetries: 2,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
hostname: "test-server-04",
|
||||
ip: "192.168.1.103",
|
||||
packages: ["docker", "docker-compose"],
|
||||
type: "Security Updates",
|
||||
description: "Update Docker components",
|
||||
status: "failed",
|
||||
priority: "high",
|
||||
scheduledFor: "2024-01-15 17:00:00",
|
||||
errorMessage: "Package conflicts detected",
|
||||
retryCount: 2,
|
||||
maxRetries: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <RefreshCw className="h-4 w-4 text-blue-500 animate-spin" />;
|
||||
case "completed":
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case "failed":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case "pending":
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
case "paused":
|
||||
return <Pause className="h-4 w-4 text-gray-500" />;
|
||||
default:
|
||||
return <AlertCircle className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "completed":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "failed":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "pending":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "paused":
|
||||
return "bg-gray-100 text-gray-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority) => {
|
||||
switch (priority) {
|
||||
case "high":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "medium":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "low":
|
||||
return "bg-green-100 text-green-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = (data) => {
|
||||
let filtered = data;
|
||||
|
||||
if (filterStatus !== "all") {
|
||||
filtered = filtered.filter((item) => item.status === filterStatus);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(
|
||||
(item) =>
|
||||
item.hostname?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.type?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "server",
|
||||
name: "Server Queue",
|
||||
icon: Server,
|
||||
data: serverQueueData,
|
||||
count: serverQueueData.length,
|
||||
},
|
||||
{
|
||||
id: "agent",
|
||||
name: "Agent Queue",
|
||||
icon: Download,
|
||||
data: agentQueueData,
|
||||
count: agentQueueData.length,
|
||||
},
|
||||
{
|
||||
id: "patch",
|
||||
name: "Patch Management",
|
||||
icon: Package,
|
||||
data: patchQueueData,
|
||||
count: patchQueueData.length,
|
||||
},
|
||||
];
|
||||
|
||||
const renderServerQueueItem = (item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{item.type}
|
||||
</h3>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(item.status)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getPriorityColor(item.priority)}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{item.status === "running" && (
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{item.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="font-medium">Created:</span> {item.createdAt}
|
||||
</div>
|
||||
{item.status === "running" && (
|
||||
<div>
|
||||
<span className="font-medium">ETA:</span>{" "}
|
||||
{item.estimatedCompletion}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "completed" && (
|
||||
<div>
|
||||
<span className="font-medium">Completed:</span>{" "}
|
||||
{item.completedAt}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "failed" && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">Error:</span> {item.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.retryCount > 0 && (
|
||||
<div className="mt-2 text-xs text-orange-600">
|
||||
Retries: {item.retryCount}/{item.maxRetries}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-4">
|
||||
{item.status === "running" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{item.status === "paused" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{item.status === "failed" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderAgentQueueItem = (item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{item.hostname}
|
||||
</h3>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(item.status)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getPriorityColor(item.priority)}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{item.type}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-3">{item.description}</p>
|
||||
|
||||
{item.type === "Agent Update Collection" && (
|
||||
<div className="mb-3 p-2 bg-gray-50 dark:bg-gray-700 rounded">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">Version:</span>{" "}
|
||||
{item.currentVersion} → {item.targetVersion}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="font-medium">IP:</span> {item.ip}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Last Comm:</span>{" "}
|
||||
{item.lastCommunication}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Next Expected:</span>{" "}
|
||||
{item.nextExpectedCommunication}
|
||||
</div>
|
||||
{item.status === "completed" && (
|
||||
<div>
|
||||
<span className="font-medium">Completed:</span>{" "}
|
||||
{item.completedAt}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "failed" && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">Error:</span> {item.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.retryCount > 0 && (
|
||||
<div className="mt-2 text-xs text-orange-600">
|
||||
Retries: {item.retryCount}/{item.maxRetries}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-4">
|
||||
{item.status === "failed" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPatchQueueItem = (item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<h3 className="font-medium text-gray-900 dark:text-white">
|
||||
{item.hostname}
|
||||
</h3>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(item.status)}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getPriorityColor(item.priority)}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{item.type}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-3">{item.description}</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
<span className="font-medium">Packages:</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.packages.map((pkg) => (
|
||||
<span
|
||||
key={pkg}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded"
|
||||
>
|
||||
{pkg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-xs text-gray-500">
|
||||
<div>
|
||||
<span className="font-medium">IP:</span> {item.ip}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Scheduled:</span>{" "}
|
||||
{item.scheduledFor}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Last Comm:</span>{" "}
|
||||
{item.lastCommunication}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Next Expected:</span>{" "}
|
||||
{item.nextExpectedCommunication}
|
||||
</div>
|
||||
{item.status === "completed" && (
|
||||
<div>
|
||||
<span className="font-medium">Completed:</span>{" "}
|
||||
{item.completedAt}
|
||||
</div>
|
||||
)}
|
||||
{item.status === "failed" && (
|
||||
<div className="col-span-2">
|
||||
<span className="font-medium">Error:</span> {item.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.retryCount > 0 && (
|
||||
<div className="mt-2 text-xs text-orange-600">
|
||||
Retries: {item.retryCount}/{item.maxRetries}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-4">
|
||||
{item.status === "failed" && (
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const currentTab = tabs.find((tab) => tab.id === activeTab);
|
||||
const filteredItems = filteredData(currentTab?.data || []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Queue Management
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Monitor and manage server operations, agent communications, and
|
||||
patch deployments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
activeTab === tab.id
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.name}
|
||||
<span className="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-0.5 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="mb-6 flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search queues..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="paused">Paused</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
More Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue Items */}
|
||||
<div className="space-y-4">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Activity className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
No queue items found
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{searchQuery
|
||||
? "Try adjusting your search criteria"
|
||||
: "No items match the current filters"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => {
|
||||
switch (activeTab) {
|
||||
case "server":
|
||||
return renderServerQueueItem(item);
|
||||
case "agent":
|
||||
return renderAgentQueueItem(item);
|
||||
case "patch":
|
||||
return renderPatchQueueItem(item);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Queue;
|
@@ -56,6 +56,11 @@ export const dashboardAPI = {
|
||||
const url = `/dashboard/hosts/${hostId}${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getPackageTrends: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const url = `/dashboard/package-trends${queryString ? `?${queryString}` : ""}`;
|
||||
return api.get(url);
|
||||
},
|
||||
getRecentUsers: () => api.get("/dashboard/recent-users"),
|
||||
getRecentCollection: () => api.get("/dashboard/recent-collection"),
|
||||
};
|
||||
|
Reference in New Issue
Block a user