- )
-}
+ return "PatchMon";
+ };
-export default Layout
\ No newline at end of file
+ const handleLogout = async () => {
+ await logout();
+ setUserMenuOpen(false);
+ };
+
+ const handleAddHost = () => {
+ // Navigate to hosts page with add modal parameter
+ window.location.href = "/hosts?action=add";
+ };
+
+ // Fetch GitHub stars count
+ const fetchGitHubStars = async () => {
+ try {
+ const response = await fetch(
+ "https://api.github.com/repos/9technologygroup/patchmon.net",
+ );
+ if (response.ok) {
+ const data = await response.json();
+ setGithubStars(data.stargazers_count);
+ }
+ } catch (error) {
+ console.error("Failed to fetch GitHub stars:", error);
+ }
+ };
+
+ // Short format for navigation area
+ const formatRelativeTimeShort = (date) => {
+ if (!date) return "Never";
+
+ const now = new Date();
+ const dateObj = new Date(date);
+
+ // Check if date is valid
+ if (isNaN(dateObj.getTime())) return "Invalid date";
+
+ const diff = now - dateObj;
+ const seconds = Math.floor(diff / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (days > 0) return `${days}d ago`;
+ if (hours > 0) return `${hours}h ago`;
+ if (minutes > 0) return `${minutes}m ago`;
+ return `${seconds}s ago`;
+ };
+
+ // Save sidebar collapsed state to localStorage
+ useEffect(() => {
+ localStorage.setItem("sidebarCollapsed", JSON.stringify(sidebarCollapsed));
+ }, [sidebarCollapsed]);
+
+ // Close user menu when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (userMenuRef.current && !userMenuRef.current.contains(event.target)) {
+ setUserMenuOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ // Fetch GitHub stars on component mount
+ useEffect(() => {
+ fetchGitHubStars();
+ }, []);
+
+ return (
+
+ {/* Mobile sidebar */}
+
+
setSidebarOpen(false)}
+ />
+
+
+ setSidebarOpen(false)}
+ >
+
+
+
+
+
+ {/* Show message for users with very limited permissions */}
+ {navigation.length === 0 && (
+
+
+
Limited access
+
+ Contact your administrator for additional permissions
+
+
+
+ )}
+ {navigation.map((item, index) => {
+ if (item.name) {
+ // Single item (Dashboard)
+ return (
+ setSidebarOpen(false)}
+ >
+
+ {item.name}
+
+ );
+ } else if (item.section) {
+ // Section with items
+ return (
+
+
+ {item.section}
+
+
+ {item.items.map((subItem) => (
+
+ {subItem.name === "Hosts" && canManageHosts() ? (
+ // Special handling for Hosts item with integrated + button (mobile)
+
setSidebarOpen(false)}
+ >
+
+
+ {subItem.name}
+
+
{
+ e.preventDefault();
+ setSidebarOpen(false);
+ handleAddHost();
+ }}
+ className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
+ title="Add Host"
+ >
+
+
+
+ ) : (
+ // Standard navigation item (mobile)
+
e.preventDefault()
+ : () => setSidebarOpen(false)
+ }
+ >
+
+
+ {subItem.name}
+ {subItem.comingSoon && (
+
+ Soon
+
+ )}
+
+
+ )}
+
+ ))}
+
+
+ );
+ }
+ return null;
+ })}
+
+
+
+
+ {/* Desktop sidebar */}
+
+
+
+ {sidebarCollapsed ? (
+
setSidebarCollapsed(!sidebarCollapsed)}
+ className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
+ title="Expand sidebar"
+ >
+
+
+ ) : (
+ <>
+
+
+
+ PatchMon
+
+
+
setSidebarCollapsed(!sidebarCollapsed)}
+ className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
+ title="Collapse sidebar"
+ >
+
+
+ >
+ )}
+
+
+
+ {/* Show message for users with very limited permissions */}
+ {navigation.length === 0 && (
+
+
+
Limited access
+
+ Contact your administrator for additional permissions
+
+
+
+ )}
+ {navigation.map((item, index) => {
+ if (item.name) {
+ // Single item (Dashboard)
+ return (
+
+
+
+ {!sidebarCollapsed && (
+ {item.name}
+ )}
+
+
+ );
+ } else if (item.section) {
+ // Section with items
+ return (
+
+ {!sidebarCollapsed && (
+
+ {item.section}
+
+ )}
+
+
+ );
+ }
+ return null;
+ })}
+
+
+
+ {/* Profile Section - Bottom of Sidebar */}
+
+ {!sidebarCollapsed ? (
+
+ {/* User Info with Sign Out - Username is clickable */}
+
+
+
+
+
+
+ {user?.first_name || user?.username}
+
+ {user?.role === "admin" && (
+
+ Admin
+
+ )}
+
+
+
+
+
+
+
+ {/* Updated info */}
+ {stats && (
+
+
+
+
+ Updated: {formatRelativeTimeShort(stats.lastUpdated)}
+
+ refetch()}
+ disabled={isFetching}
+ className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0 disabled:opacity-50"
+ title="Refresh data"
+ >
+
+
+ {versionInfo && (
+
+ v{versionInfo.version}
+
+ )}
+
+
+ )}
+
+ ) : (
+
+
+
+
+
+
+
+ {/* Updated info for collapsed sidebar */}
+ {stats && (
+
+ refetch()}
+ disabled={isFetching}
+ className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded disabled:opacity-50"
+ title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
+ >
+
+
+ {versionInfo && (
+
+ v{versionInfo.version}
+
+ )}
+
+ )}
+
+ )}
+
+
+
+
+ {/* Main content */}
+
+ {/* Top bar */}
+
+
setSidebarOpen(true)}
+ >
+
+
+
+ {/* Separator */}
+
+
+
+
+
+ {getPageTitle()}
+
+
+
+ {/* External Links */}
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+};
+
+export default Layout;
diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx
index df3fb5c..0257df6 100644
--- a/frontend/src/components/ProtectedRoute.jsx
+++ b/frontend/src/components/ProtectedRoute.jsx
@@ -1,47 +1,59 @@
-import React from 'react'
-import { Navigate } from 'react-router-dom'
-import { useAuth } from '../contexts/AuthContext'
+import React from "react";
+import { Navigate } from "react-router-dom";
+import { useAuth } from "../contexts/AuthContext";
-const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
- const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
+const ProtectedRoute = ({
+ children,
+ requireAdmin = false,
+ requirePermission = null,
+}) => {
+ const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth();
- if (isLoading) {
- return (
-
- )
- }
+ if (isLoading) {
+ return (
+
+ );
+ }
- if (!isAuthenticated()) {
- return
- }
+ if (!isAuthenticated()) {
+ return
;
+ }
- // Check admin requirement
- if (requireAdmin && !isAdmin()) {
- return (
-
-
-
Access Denied
-
You don't have permission to access this page.
-
-
- )
- }
+ // Check admin requirement
+ if (requireAdmin && !isAdmin()) {
+ return (
+
+
+
+ Access Denied
+
+
+ You don't have permission to access this page.
+
+
+
+ );
+ }
- // Check specific permission requirement
- if (requirePermission && !hasPermission(requirePermission)) {
- return (
-
-
-
Access Denied
-
You don't have permission to access this page.
-
-
- )
- }
+ // Check specific permission requirement
+ if (requirePermission && !hasPermission(requirePermission)) {
+ return (
+
+
+
+ Access Denied
+
+
+ You don't have permission to access this page.
+
+
+
+ );
+ }
- return children
-}
+ return children;
+};
-export default ProtectedRoute
+export default ProtectedRoute;
diff --git a/frontend/src/components/UpgradeNotificationIcon.jsx b/frontend/src/components/UpgradeNotificationIcon.jsx
index f605381..3add5e6 100644
--- a/frontend/src/components/UpgradeNotificationIcon.jsx
+++ b/frontend/src/components/UpgradeNotificationIcon.jsx
@@ -1,15 +1,15 @@
-import React from 'react'
-import { ArrowUpCircle } from 'lucide-react'
+import { ArrowUpCircle } from "lucide-react";
+import React from "react";
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
- if (!show) return null
+ if (!show) return null;
- return (
-
- )
-}
+ return (
+
+ );
+};
-export default UpgradeNotificationIcon
+export default UpgradeNotificationIcon;
diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx
index 19c7b60..5794e5a 100644
--- a/frontend/src/contexts/AuthContext.jsx
+++ b/frontend/src/contexts/AuthContext.jsx
@@ -1,298 +1,303 @@
-import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
-const AuthContext = createContext()
+const AuthContext = createContext();
export const useAuth = () => {
- const context = useContext(AuthContext)
- if (!context) {
- throw new Error('useAuth must be used within an AuthProvider')
- }
- return context
-}
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return context;
+};
export const AuthProvider = ({ children }) => {
- const [user, setUser] = useState(null)
- const [token, setToken] = useState(null)
- const [permissions, setPermissions] = useState(null)
- const [isLoading, setIsLoading] = useState(true)
- const [permissionsLoading, setPermissionsLoading] = useState(false)
- const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false)
-
- const [checkingSetup, setCheckingSetup] = useState(true)
+ const [user, setUser] = useState(null);
+ const [token, setToken] = useState(null);
+ const [permissions, setPermissions] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [permissionsLoading, setPermissionsLoading] = useState(false);
+ const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false);
- // Initialize auth state from localStorage
- useEffect(() => {
- const storedToken = localStorage.getItem('token')
- const storedUser = localStorage.getItem('user')
- const storedPermissions = localStorage.getItem('permissions')
+ const [checkingSetup, setCheckingSetup] = useState(true);
- if (storedToken && storedUser) {
- try {
- setToken(storedToken)
- setUser(JSON.parse(storedUser))
- if (storedPermissions) {
- setPermissions(JSON.parse(storedPermissions))
- } else {
- // Fetch permissions if not stored
- fetchPermissions(storedToken)
- }
- } catch (error) {
- console.error('Error parsing stored user data:', error)
- localStorage.removeItem('token')
- localStorage.removeItem('user')
- localStorage.removeItem('permissions')
- }
- }
- setIsLoading(false)
- }, [])
+ // Initialize auth state from localStorage
+ useEffect(() => {
+ const storedToken = localStorage.getItem("token");
+ const storedUser = localStorage.getItem("user");
+ const storedPermissions = localStorage.getItem("permissions");
- // Refresh permissions when user logs in (no automatic refresh)
- useEffect(() => {
- if (token && user) {
- // Only refresh permissions once when user logs in
- refreshPermissions()
- }
- }, [token, user])
+ if (storedToken && storedUser) {
+ try {
+ setToken(storedToken);
+ setUser(JSON.parse(storedUser));
+ if (storedPermissions) {
+ setPermissions(JSON.parse(storedPermissions));
+ } else {
+ // Fetch permissions if not stored
+ fetchPermissions(storedToken);
+ }
+ } catch (error) {
+ console.error("Error parsing stored user data:", error);
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+ localStorage.removeItem("permissions");
+ }
+ }
+ setIsLoading(false);
+ }, []);
- const fetchPermissions = async (authToken) => {
- try {
- setPermissionsLoading(true)
- const response = await fetch('/api/v1/permissions/user-permissions', {
- headers: {
- 'Authorization': `Bearer ${authToken}`,
- },
- })
+ // Refresh permissions when user logs in (no automatic refresh)
+ useEffect(() => {
+ if (token && user) {
+ // Only refresh permissions once when user logs in
+ refreshPermissions();
+ }
+ }, [token, user]);
- if (response.ok) {
- const data = await response.json()
- setPermissions(data)
- localStorage.setItem('permissions', JSON.stringify(data))
- return data
- } else {
- console.error('Failed to fetch permissions')
- return null
- }
- } catch (error) {
- console.error('Error fetching permissions:', error)
- return null
- } finally {
- setPermissionsLoading(false)
- }
- }
+ const fetchPermissions = async (authToken) => {
+ try {
+ setPermissionsLoading(true);
+ const response = await fetch("/api/v1/permissions/user-permissions", {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
- const refreshPermissions = async () => {
- if (token) {
- const updatedPermissions = await fetchPermissions(token)
- return updatedPermissions
- }
- return null
- }
+ if (response.ok) {
+ const data = await response.json();
+ setPermissions(data);
+ localStorage.setItem("permissions", JSON.stringify(data));
+ return data;
+ } else {
+ console.error("Failed to fetch permissions");
+ return null;
+ }
+ } catch (error) {
+ console.error("Error fetching permissions:", error);
+ return null;
+ } finally {
+ setPermissionsLoading(false);
+ }
+ };
- const login = async (username, password) => {
- try {
- const response = await fetch('/api/v1/auth/login', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ username, password }),
- })
+ const refreshPermissions = async () => {
+ if (token) {
+ const updatedPermissions = await fetchPermissions(token);
+ return updatedPermissions;
+ }
+ return null;
+ };
- const data = await response.json()
+ const login = async (username, password) => {
+ try {
+ const response = await fetch("/api/v1/auth/login", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ username, password }),
+ });
- if (response.ok) {
- setToken(data.token)
- setUser(data.user)
- localStorage.setItem('token', data.token)
- localStorage.setItem('user', JSON.stringify(data.user))
-
- // Fetch user permissions after successful login
- const userPermissions = await fetchPermissions(data.token)
- if (userPermissions) {
- setPermissions(userPermissions)
- }
-
- return { success: true }
- } else {
- return { success: false, error: data.error || 'Login failed' }
- }
- } catch (error) {
- return { success: false, error: 'Network error occurred' }
- }
- }
+ const data = await response.json();
- const logout = async () => {
- try {
- if (token) {
- await fetch('/api/v1/auth/logout', {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json',
- },
- })
- }
- } catch (error) {
- console.error('Logout error:', error)
- } finally {
- setToken(null)
- setUser(null)
- setPermissions(null)
- localStorage.removeItem('token')
- localStorage.removeItem('user')
- localStorage.removeItem('permissions')
- }
- }
+ if (response.ok) {
+ setToken(data.token);
+ setUser(data.user);
+ localStorage.setItem("token", data.token);
+ localStorage.setItem("user", JSON.stringify(data.user));
- const updateProfile = async (profileData) => {
- try {
- const response = await fetch('/api/v1/auth/profile', {
- method: 'PUT',
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(profileData),
- })
+ // Fetch user permissions after successful login
+ const userPermissions = await fetchPermissions(data.token);
+ if (userPermissions) {
+ setPermissions(userPermissions);
+ }
- const data = await response.json()
+ return { success: true };
+ } else {
+ return { success: false, error: data.error || "Login failed" };
+ }
+ } catch (error) {
+ return { success: false, error: "Network error occurred" };
+ }
+ };
- if (response.ok) {
- setUser(data.user)
- localStorage.setItem('user', JSON.stringify(data.user))
- return { success: true, user: data.user }
- } else {
- return { success: false, error: data.error || 'Update failed' }
- }
- } catch (error) {
- return { success: false, error: 'Network error occurred' }
- }
- }
+ const logout = async () => {
+ try {
+ if (token) {
+ await fetch("/api/v1/auth/logout", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ });
+ }
+ } catch (error) {
+ console.error("Logout error:", error);
+ } finally {
+ setToken(null);
+ setUser(null);
+ setPermissions(null);
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+ localStorage.removeItem("permissions");
+ }
+ };
- const changePassword = async (currentPassword, newPassword) => {
- try {
- const response = await fetch('/api/v1/auth/change-password', {
- method: 'PUT',
- headers: {
- 'Authorization': `Bearer ${token}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ currentPassword, newPassword }),
- })
+ const updateProfile = async (profileData) => {
+ try {
+ const response = await fetch("/api/v1/auth/profile", {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(profileData),
+ });
- const data = await response.json()
+ const data = await response.json();
- if (response.ok) {
- return { success: true }
- } else {
- return { success: false, error: data.error || 'Password change failed' }
- }
- } catch (error) {
- return { success: false, error: 'Network error occurred' }
- }
- }
+ if (response.ok) {
+ setUser(data.user);
+ localStorage.setItem("user", JSON.stringify(data.user));
+ return { success: true, user: data.user };
+ } else {
+ return { success: false, error: data.error || "Update failed" };
+ }
+ } catch (error) {
+ return { success: false, error: "Network error occurred" };
+ }
+ };
- const isAuthenticated = () => {
- return !!(token && user)
- }
+ const changePassword = async (currentPassword, newPassword) => {
+ try {
+ const response = await fetch("/api/v1/auth/change-password", {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ currentPassword, newPassword }),
+ });
- const isAdmin = () => {
- return user?.role === 'admin'
- }
+ const data = await response.json();
- // Permission checking functions
- const hasPermission = (permission) => {
- // If permissions are still loading, return false to show loading state
- if (permissionsLoading) {
- return false
- }
- return permissions?.[permission] === true
- }
+ if (response.ok) {
+ return { success: true };
+ } else {
+ return {
+ success: false,
+ error: data.error || "Password change failed",
+ };
+ }
+ } catch (error) {
+ return { success: false, error: "Network error occurred" };
+ }
+ };
- const canViewDashboard = () => hasPermission('can_view_dashboard')
- const canViewHosts = () => hasPermission('can_view_hosts')
- const canManageHosts = () => hasPermission('can_manage_hosts')
- const canViewPackages = () => hasPermission('can_view_packages')
- const canManagePackages = () => hasPermission('can_manage_packages')
- const canViewUsers = () => hasPermission('can_view_users')
- const canManageUsers = () => hasPermission('can_manage_users')
- const canViewReports = () => hasPermission('can_view_reports')
- const canExportData = () => hasPermission('can_export_data')
- const canManageSettings = () => hasPermission('can_manage_settings')
+ const isAuthenticated = () => {
+ return !!(token && user);
+ };
- // Check if any admin users exist (for first-time setup)
- const checkAdminUsersExist = useCallback(async () => {
- try {
- const response = await fetch('/api/v1/auth/check-admin-users', {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json'
- }
- })
+ const isAdmin = () => {
+ return user?.role === "admin";
+ };
- if (response.ok) {
- const data = await response.json()
- setNeedsFirstTimeSetup(!data.hasAdminUsers)
- } else {
- // If endpoint doesn't exist or fails, assume setup is needed
- setNeedsFirstTimeSetup(true)
- }
- } catch (error) {
- console.error('Error checking admin users:', error)
- // If there's an error, assume setup is needed
- setNeedsFirstTimeSetup(true)
- } finally {
- setCheckingSetup(false)
- }
- }, [])
+ // Permission checking functions
+ const hasPermission = (permission) => {
+ // If permissions are still loading, return false to show loading state
+ if (permissionsLoading) {
+ return false;
+ }
+ return permissions?.[permission] === true;
+ };
- // Check for admin users on initial load
- useEffect(() => {
- if (!token && !user) {
- checkAdminUsersExist()
- } else {
- setCheckingSetup(false)
- }
- }, [token, user, checkAdminUsersExist])
+ const canViewDashboard = () => hasPermission("can_view_dashboard");
+ const canViewHosts = () => hasPermission("can_view_hosts");
+ const canManageHosts = () => hasPermission("can_manage_hosts");
+ const canViewPackages = () => hasPermission("can_view_packages");
+ const canManagePackages = () => hasPermission("can_manage_packages");
+ const canViewUsers = () => hasPermission("can_view_users");
+ const canManageUsers = () => hasPermission("can_manage_users");
+ const canViewReports = () => hasPermission("can_view_reports");
+ const canExportData = () => hasPermission("can_export_data");
+ const canManageSettings = () => hasPermission("can_manage_settings");
- const setAuthState = (authToken, authUser) => {
- setToken(authToken)
- setUser(authUser)
- localStorage.setItem('token', authToken)
- localStorage.setItem('user', JSON.stringify(authUser))
- }
+ // Check if any admin users exist (for first-time setup)
+ const checkAdminUsersExist = useCallback(async () => {
+ try {
+ const response = await fetch("/api/v1/auth/check-admin-users", {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
- const value = {
- user,
- token,
- permissions,
- isLoading: isLoading || permissionsLoading || checkingSetup,
- needsFirstTimeSetup,
- checkingSetup,
- login,
- logout,
- updateProfile,
- changePassword,
- refreshPermissions,
- setAuthState,
- isAuthenticated,
- isAdmin,
- hasPermission,
- canViewDashboard,
- canViewHosts,
- canManageHosts,
- canViewPackages,
- canManagePackages,
- canViewUsers,
- canManageUsers,
- canViewReports,
- canExportData,
- canManageSettings
- }
+ if (response.ok) {
+ const data = await response.json();
+ setNeedsFirstTimeSetup(!data.hasAdminUsers);
+ } else {
+ // If endpoint doesn't exist or fails, assume setup is needed
+ setNeedsFirstTimeSetup(true);
+ }
+ } catch (error) {
+ console.error("Error checking admin users:", error);
+ // If there's an error, assume setup is needed
+ setNeedsFirstTimeSetup(true);
+ } finally {
+ setCheckingSetup(false);
+ }
+ }, []);
- return (
-
- {children}
-
- )
-}
+ // Check for admin users on initial load
+ useEffect(() => {
+ if (!token && !user) {
+ checkAdminUsersExist();
+ } else {
+ setCheckingSetup(false);
+ }
+ }, [token, user, checkAdminUsersExist]);
+
+ const setAuthState = (authToken, authUser) => {
+ setToken(authToken);
+ setUser(authUser);
+ localStorage.setItem("token", authToken);
+ localStorage.setItem("user", JSON.stringify(authUser));
+ };
+
+ const value = {
+ user,
+ token,
+ permissions,
+ isLoading: isLoading || permissionsLoading || checkingSetup,
+ needsFirstTimeSetup,
+ checkingSetup,
+ login,
+ logout,
+ updateProfile,
+ changePassword,
+ refreshPermissions,
+ setAuthState,
+ isAuthenticated,
+ isAdmin,
+ hasPermission,
+ canViewDashboard,
+ canViewHosts,
+ canManageHosts,
+ canViewPackages,
+ canManagePackages,
+ canViewUsers,
+ canManageUsers,
+ canViewReports,
+ canExportData,
+ canManageSettings,
+ };
+
+ return
{children} ;
+};
diff --git a/frontend/src/contexts/ThemeContext.jsx b/frontend/src/contexts/ThemeContext.jsx
index a289b3c..d70a623 100644
--- a/frontend/src/contexts/ThemeContext.jsx
+++ b/frontend/src/contexts/ThemeContext.jsx
@@ -1,54 +1,52 @@
-import React, { createContext, useContext, useEffect, useState } from 'react'
+import React, { createContext, useContext, useEffect, useState } from "react";
-const ThemeContext = createContext()
+const ThemeContext = createContext();
export const useTheme = () => {
- const context = useContext(ThemeContext)
- if (!context) {
- throw new Error('useTheme must be used within a ThemeProvider')
- }
- return context
-}
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return context;
+};
export const ThemeProvider = ({ children }) => {
- const [theme, setTheme] = useState(() => {
- // Check localStorage first, then system preference
- const savedTheme = localStorage.getItem('theme')
- if (savedTheme) {
- return savedTheme
- }
- // Check system preference
- if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
- return 'dark'
- }
- return 'light'
- })
+ const [theme, setTheme] = useState(() => {
+ // Check localStorage first, then system preference
+ const savedTheme = localStorage.getItem("theme");
+ if (savedTheme) {
+ return savedTheme;
+ }
+ // Check system preference
+ if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
+ return "dark";
+ }
+ return "light";
+ });
- useEffect(() => {
- // Apply theme to document
- if (theme === 'dark') {
- document.documentElement.classList.add('dark')
- } else {
- document.documentElement.classList.remove('dark')
- }
-
- // Save to localStorage
- localStorage.setItem('theme', theme)
- }, [theme])
+ useEffect(() => {
+ // Apply theme to document
+ if (theme === "dark") {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
- const toggleTheme = () => {
- setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
- }
+ // Save to localStorage
+ localStorage.setItem("theme", theme);
+ }, [theme]);
- const value = {
- theme,
- toggleTheme,
- isDark: theme === 'dark'
- }
+ const toggleTheme = () => {
+ setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
+ };
- return (
-
- {children}
-
- )
-}
+ const value = {
+ theme,
+ toggleTheme,
+ isDark: theme === "dark",
+ };
+
+ return (
+
{children}
+ );
+};
diff --git a/frontend/src/contexts/UpdateNotificationContext.jsx b/frontend/src/contexts/UpdateNotificationContext.jsx
index d4ad670..a8e9ac0 100644
--- a/frontend/src/contexts/UpdateNotificationContext.jsx
+++ b/frontend/src/contexts/UpdateNotificationContext.jsx
@@ -1,58 +1,64 @@
-import React, { createContext, useContext, useState } from 'react'
-import { useQuery } from '@tanstack/react-query'
-import { versionAPI, settingsAPI } from '../utils/api'
-import { useAuth } from './AuthContext'
+import { useQuery } from "@tanstack/react-query";
+import React, { createContext, useContext, useState } from "react";
+import { settingsAPI, versionAPI } from "../utils/api";
+import { useAuth } from "./AuthContext";
-const UpdateNotificationContext = createContext()
+const UpdateNotificationContext = createContext();
export const useUpdateNotification = () => {
- const context = useContext(UpdateNotificationContext)
- if (!context) {
- throw new Error('useUpdateNotification must be used within an UpdateNotificationProvider')
- }
- return context
-}
+ const context = useContext(UpdateNotificationContext);
+ if (!context) {
+ throw new Error(
+ "useUpdateNotification must be used within an UpdateNotificationProvider",
+ );
+ }
+ return context;
+};
export const UpdateNotificationProvider = ({ children }) => {
- const [dismissed, setDismissed] = useState(false)
- const { user, token } = useAuth()
+ const [dismissed, setDismissed] = useState(false);
+ const { user, token } = useAuth();
- // Ensure settings are loaded
- const { data: settings, isLoading: settingsLoading } = useQuery({
- queryKey: ['settings'],
- queryFn: () => settingsAPI.get().then(res => res.data),
- enabled: !!(user && token),
- retry: 1
- })
+ // Ensure settings are loaded
+ const { data: settings, isLoading: settingsLoading } = useQuery({
+ queryKey: ["settings"],
+ queryFn: () => settingsAPI.get().then((res) => res.data),
+ enabled: !!(user && token),
+ retry: 1,
+ });
- // Query for update information
- const { data: updateData, isLoading, error } = useQuery({
- queryKey: ['updateCheck'],
- queryFn: () => versionAPI.checkUpdates().then(res => res.data),
- staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
- refetchOnWindowFocus: false, // Don't refetch when window regains focus
- retry: 1,
- enabled: !!(user && token && settings && !settingsLoading) // Only run when authenticated and settings are loaded
- })
+ // Query for update information
+ const {
+ data: updateData,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["updateCheck"],
+ queryFn: () => versionAPI.checkUpdates().then((res) => res.data),
+ staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
+ refetchOnWindowFocus: false, // Don't refetch when window regains focus
+ retry: 1,
+ enabled: !!(user && token && settings && !settingsLoading), // Only run when authenticated and settings are loaded
+ });
- const updateAvailable = updateData?.isUpdateAvailable && !dismissed
- const updateInfo = updateData
+ const updateAvailable = updateData?.isUpdateAvailable && !dismissed;
+ const updateInfo = updateData;
- const dismissNotification = () => {
- setDismissed(true)
- }
+ const dismissNotification = () => {
+ setDismissed(true);
+ };
- const value = {
- updateAvailable,
- updateInfo,
- dismissNotification,
- isLoading,
- error
- }
+ const value = {
+ updateAvailable,
+ updateInfo,
+ dismissNotification,
+ isLoading,
+ error,
+ };
- return (
-
- {children}
-
- )
-}
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 43fafdb..7f46f47 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -3,125 +3,125 @@
@tailwind utilities;
@layer base {
- html {
- font-family: Inter, ui-sans-serif, system-ui;
- }
-
- body {
- @apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
- }
+ html {
+ font-family: Inter, ui-sans-serif, system-ui;
+ }
+
+ body {
+ @apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
+ }
}
@layer components {
- .btn {
- @apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
- }
-
- .btn-primary {
- @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
- }
-
- .btn-secondary {
- @apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
- }
-
- .btn-success {
- @apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
- }
-
- .btn-warning {
- @apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
- }
-
- .btn-danger {
- @apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
- }
-
- .btn-outline {
- @apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
- }
-
- .card {
- @apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
- }
-
- .card-hover {
- @apply card hover:shadow-card-hover transition-shadow duration-150;
- }
-
- .input {
- @apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
- }
-
- .label {
- @apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
- }
-
- .badge {
- @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
- }
-
- .badge-primary {
- @apply badge bg-primary-100 text-primary-800;
- }
-
- .badge-secondary {
- @apply badge bg-secondary-100 text-secondary-800;
- }
-
- .badge-success {
- @apply badge bg-success-100 text-success-800;
- }
-
- .badge-warning {
- @apply badge bg-warning-100 text-warning-800;
- }
-
- .badge-danger {
- @apply badge bg-danger-100 text-danger-800;
- }
+ .btn {
+ @apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
+ }
+
+ .btn-primary {
+ @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
+ }
+
+ .btn-secondary {
+ @apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
+ }
+
+ .btn-success {
+ @apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
+ }
+
+ .btn-warning {
+ @apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
+ }
+
+ .btn-danger {
+ @apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
+ }
+
+ .btn-outline {
+ @apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
+ }
+
+ .card {
+ @apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
+ }
+
+ .card-hover {
+ @apply card hover:shadow-card-hover transition-shadow duration-150;
+ }
+
+ .input {
+ @apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
+ }
+
+ .label {
+ @apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
+ }
+
+ .badge {
+ @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
+ }
+
+ .badge-primary {
+ @apply badge bg-primary-100 text-primary-800;
+ }
+
+ .badge-secondary {
+ @apply badge bg-secondary-100 text-secondary-800;
+ }
+
+ .badge-success {
+ @apply badge bg-success-100 text-success-800;
+ }
+
+ .badge-warning {
+ @apply badge bg-warning-100 text-warning-800;
+ }
+
+ .badge-danger {
+ @apply badge bg-danger-100 text-danger-800;
+ }
}
@layer utilities {
- .text-shadow {
- text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
- }
-
- .scrollbar-thin {
- scrollbar-width: thin;
- scrollbar-color: #cbd5e1 #f1f5f9;
- }
-
- .dark .scrollbar-thin {
- scrollbar-color: #64748b #475569;
- }
-
- .scrollbar-thin::-webkit-scrollbar {
- width: 6px;
- }
-
- .scrollbar-thin::-webkit-scrollbar-track {
- background: #f1f5f9;
- }
-
- .dark .scrollbar-thin::-webkit-scrollbar-track {
- background: #475569;
- }
-
- .scrollbar-thin::-webkit-scrollbar-thumb {
- background-color: #cbd5e1;
- border-radius: 3px;
- }
-
- .dark .scrollbar-thin::-webkit-scrollbar-thumb {
- background-color: #64748b;
- }
-
- .scrollbar-thin::-webkit-scrollbar-thumb:hover {
- background-color: #94a3b8;
- }
-
- .dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
- background-color: #94a3b8;
- }
-}
\ No newline at end of file
+ .text-shadow {
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ }
+
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: #cbd5e1 #f1f5f9;
+ }
+
+ .dark .scrollbar-thin {
+ scrollbar-color: #64748b #475569;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-track {
+ background: #f1f5f9;
+ }
+
+ .dark .scrollbar-thin::-webkit-scrollbar-track {
+ background: #475569;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: #cbd5e1;
+ border-radius: 3px;
+ }
+
+ .dark .scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: #64748b;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background-color: #94a3b8;
+ }
+
+ .dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background-color: #94a3b8;
+ }
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index a1e2515..7666468 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,27 +1,27 @@
-import React from 'react'
-import ReactDOM from 'react-dom/client'
-import { BrowserRouter } from 'react-router-dom'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import App from './App.jsx'
-import './index.css'
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import App from "./App.jsx";
+import "./index.css";
// Create a client for React Query
const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- refetchOnWindowFocus: false,
- retry: 1,
- staleTime: 5 * 60 * 1000, // 5 minutes
- },
- },
-})
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ retry: 1,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ },
+ },
+});
-ReactDOM.createRoot(document.getElementById('root')).render(
-
-
-
-
-
-
- ,
-)
\ No newline at end of file
+ReactDOM.createRoot(document.getElementById("root")).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index a804e68..24aaaf6 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -1,928 +1,1082 @@
-import React, { useState, useEffect } from 'react'
-import { useQuery } from '@tanstack/react-query'
-import { useNavigate } from 'react-router-dom'
-import {
- Server,
- Package,
- AlertTriangle,
- Shield,
- TrendingUp,
- RefreshCw,
- Clock,
- WifiOff,
- Settings,
- Users,
- Folder,
- GitBranch
-} from 'lucide-react'
-import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
-import { Pie, Bar } from 'react-chartjs-2'
-import { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api'
-import DashboardSettingsModal from '../components/DashboardSettingsModal'
-import { useTheme } from '../contexts/ThemeContext'
-import { useAuth } from '../contexts/AuthContext'
+import { useQuery } from "@tanstack/react-query";
+import {
+ ArcElement,
+ BarElement,
+ CategoryScale,
+ Chart as ChartJS,
+ Legend,
+ LinearScale,
+ Title,
+ Tooltip,
+} from "chart.js";
+import {
+ AlertTriangle,
+ Clock,
+ Folder,
+ GitBranch,
+ Package,
+ RefreshCw,
+ Server,
+ Settings,
+ Shield,
+ TrendingUp,
+ Users,
+ WifiOff,
+} from "lucide-react";
+import React, { useEffect, useState } from "react";
+import { Bar, 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, Title)
+ChartJS.register(
+ ArcElement,
+ Tooltip,
+ Legend,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Title,
+);
const Dashboard = () => {
- const [showSettingsModal, setShowSettingsModal] = useState(false)
- const [cardPreferences, setCardPreferences] = useState([])
- const navigate = useNavigate()
- const { isDark } = useTheme()
- const { user } = useAuth()
+ const [showSettingsModal, setShowSettingsModal] = useState(false);
+ const [cardPreferences, setCardPreferences] = useState([]);
+ const navigate = useNavigate();
+ const { isDark } = useTheme();
+ const { user } = useAuth();
- // Navigation handlers
- const handleTotalHostsClick = () => {
- navigate('/hosts', { replace: true })
- }
+ // Navigation handlers
+ const handleTotalHostsClick = () => {
+ navigate("/hosts", { replace: true });
+ };
- const handleHostsNeedingUpdatesClick = () => {
- navigate('/hosts?filter=needsUpdates')
- }
+ const handleHostsNeedingUpdatesClick = () => {
+ navigate("/hosts?filter=needsUpdates");
+ };
- const handleOutdatedPackagesClick = () => {
- navigate('/packages?filter=outdated')
- }
+ const handleOutdatedPackagesClick = () => {
+ navigate("/packages?filter=outdated");
+ };
- const handleSecurityUpdatesClick = () => {
- navigate('/packages?filter=security')
- }
+ const handleSecurityUpdatesClick = () => {
+ navigate("/packages?filter=security");
+ };
- const handleErroredHostsClick = () => {
- navigate('/hosts?filter=inactive')
- }
+ const handleErroredHostsClick = () => {
+ navigate("/hosts?filter=inactive");
+ };
- const handleOfflineHostsClick = () => {
- navigate('/hosts?filter=offline')
- }
+ const handleOfflineHostsClick = () => {
+ navigate("/hosts?filter=offline");
+ };
- // New navigation handlers for top cards
- const handleUsersClick = () => {
- navigate('/users')
- }
+ // New navigation handlers for top cards
+ const handleUsersClick = () => {
+ navigate("/users");
+ };
- const handleHostGroupsClick = () => {
- navigate('/options')
- }
+ const handleHostGroupsClick = () => {
+ navigate("/options");
+ };
- const handleRepositoriesClick = () => {
- navigate('/repositories')
- }
+ const handleRepositoriesClick = () => {
+ navigate("/repositories");
+ };
- const handleOSDistributionClick = () => {
- navigate('/hosts?showFilters=true', { replace: true })
- }
+ const handleOSDistributionClick = () => {
+ navigate("/hosts?showFilters=true", { replace: true });
+ };
- const handleUpdateStatusClick = () => {
- navigate('/hosts?filter=needsUpdates', { replace: true })
- }
+ const handleUpdateStatusClick = () => {
+ navigate("/hosts?filter=needsUpdates", { replace: true });
+ };
- const handlePackagePriorityClick = () => {
- navigate('/packages?filter=security')
- }
+ const handlePackagePriorityClick = () => {
+ navigate("/packages?filter=security");
+ };
- // Chart click handlers
- const handleOSChartClick = (event, 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 })
- }
- }
+ // Chart click handlers
+ const handleOSChartClick = (event, 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 = (event, 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 handleUpdateStatusChartClick = (event, elements) => {
+ if (elements.length > 0) {
+ const elementIndex = elements[0].index;
+ const statusName =
+ stats.charts.updateStatusDistribution[elementIndex].name;
- const handlePackagePriorityChartClick = (event, 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('outdated')) {
- navigate('/packages?filter=outdated', { replace: true })
- }
- }
- }
+ // 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";
+ }
- // 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`
- }
- }
+ if (filter) {
+ navigate(`/hosts?filter=${filter}`, { replace: true });
+ }
+ }
+ };
- 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
- })
+ const handlePackagePriorityChartClick = (event, elements) => {
+ if (elements.length > 0) {
+ const elementIndex = elements[0].index;
+ const priorityName =
+ stats.charts.packageUpdateDistribution[elementIndex].name;
- // Fetch recent users (permission protected server-side)
- const { data: recentUsers } = useQuery({
- queryKey: ['dashboardRecentUsers'],
- queryFn: () => dashboardAPI.getRecentUsers().then(res => res.data),
- staleTime: 60 * 1000,
- })
+ // Map priority names to filter parameters
+ if (priorityName.toLowerCase().includes("security")) {
+ navigate("/packages?filter=security", { replace: true });
+ } else if (priorityName.toLowerCase().includes("outdated")) {
+ navigate("/packages?filter=outdated", { replace: true });
+ }
+ }
+ };
- // Fetch recent collection (permission protected server-side)
- const { data: recentCollection } = useQuery({
- queryKey: ['dashboardRecentCollection'],
- queryFn: () => dashboardAPI.getRecentCollection().then(res => res.data),
- staleTime: 60 * 1000,
- })
+ // Helper function to format the update interval threshold
+ const formatUpdateIntervalThreshold = () => {
+ if (!settings?.updateInterval) return "24 hours";
- // Fetch settings to get the agent update interval
- const { data: settings } = useQuery({
- queryKey: ['settings'],
- queryFn: () => settingsAPI.get().then(res => res.data),
- })
+ const intervalMinutes = settings.updateInterval;
+ const thresholdMinutes = intervalMinutes * 2; // 2x the update interval
- // Fetch user's dashboard preferences
- const { data: preferences, refetch: refetchPreferences } = useQuery({
- queryKey: ['dashboardPreferences'],
- queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
- staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
- })
+ 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`;
+ }
+ };
- // Fetch default card configuration
- const { data: defaultCards } = useQuery({
- queryKey: ['dashboardDefaultCards'],
- queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
- })
+ 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
+ });
- // 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,
- }))
+ // Fetch recent users (permission protected server-side)
+ const { data: recentUsers } = useQuery({
+ queryKey: ["dashboardRecentUsers"],
+ queryFn: () => dashboardAPI.getRecentUsers().then((res) => res.data),
+ staleTime: 60 * 1000,
+ });
- 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)
+ // Fetch recent collection (permission protected server-side)
+ const { data: recentCollection } = useQuery({
+ queryKey: ["dashboardRecentCollection"],
+ queryFn: () => dashboardAPI.getRecentCollection().then((res) => res.data),
+ staleTime: 60 * 1000,
+ });
- setCardPreferences(mergedCards)
- } else if (defaultCards) {
- // If no preferences exist, use defaults
- setCardPreferences(defaultCards.sort((a, b) => a.order - b.order))
- }
- }, [preferences, defaultCards])
+ // Fetch settings to get the agent update interval
+ const { data: settings } = useQuery({
+ queryKey: ["settings"],
+ queryFn: () => settingsAPI.get().then((res) => res.data),
+ });
- // Listen for custom event from Layout component
- useEffect(() => {
- const handleOpenSettings = () => {
- setShowSettingsModal(true);
- };
+ // Fetch user's dashboard preferences
+ const { data: preferences, refetch: refetchPreferences } = useQuery({
+ queryKey: ["dashboardPreferences"],
+ queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
+ staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
+ });
- window.addEventListener('openDashboardSettings', handleOpenSettings);
- return () => {
- window.removeEventListener('openDashboardSettings', handleOpenSettings);
- };
- }, [])
+ // Fetch default card configuration
+ const { data: defaultCards } = useQuery({
+ queryKey: ["dashboardDefaultCards"],
+ queryFn: () =>
+ dashboardPreferencesAPI.getDefaults().then((res) => res.data),
+ });
- // 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
- }
+ // 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,
+ }));
- // 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', 'updateStatus', 'packagePriority', 'recentUsers', 'recentCollection'].includes(cardId)) {
- return 'charts';
- } else if (['erroredHosts', 'quickStats'].includes(cardId)) {
- return 'fullwidth';
- }
- return 'fullwidth'; // Default to full width
- }
+ 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);
- // 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 'fullwidth':
- return 'space-y-6';
- default:
- return 'space-y-6';
- }
- }
+ setCardPreferences(mergedCards);
+ } else if (defaultCards) {
+ // If no preferences exist, use defaults
+ setCardPreferences(defaultCards.sort((a, b) => a.order - b.order));
+ }
+ }, [preferences, defaultCards]);
- // Helper function to render a card by ID
- const renderCard = (cardId) => {
- switch (cardId) {
- case 'upToDateHosts':
- return (
-
-
-
-
-
-
-
Up to date
-
- {stats.cards.upToDateHosts}/{stats.cards.totalHosts}
-
-
-
-
- );
- case 'totalHosts':
- return (
-
-
-
-
-
-
-
Total Hosts
-
- {stats.cards.totalHosts}
-
-
-
-
- );
-
- case 'hostsNeedingUpdates':
- return (
-
-
-
-
-
Needs Updating
-
- {stats.cards.hostsNeedingUpdates}
-
-
-
-
- );
-
- case 'totalOutdatedPackages':
- return (
-
-
-
-
-
Outdated Packages
-
- {stats.cards.totalOutdatedPackages}
-
-
-
-
- );
-
- case 'securityUpdates':
- return (
-
-
-
-
-
-
-
Security Updates
-
- {stats.cards.securityUpdates}
-
-
-
-
- );
+ // Listen for custom event from Layout component
+ useEffect(() => {
+ const handleOpenSettings = () => {
+ setShowSettingsModal(true);
+ };
- case 'totalHostGroups':
- return (
-
-
-
-
-
-
-
Host Groups
-
- {stats.cards.totalHostGroups}
-
-
-
-
- );
+ window.addEventListener("openDashboardSettings", handleOpenSettings);
+ return () => {
+ window.removeEventListener("openDashboardSettings", handleOpenSettings);
+ };
+ }, []);
- case 'totalUsers':
- return (
-
-
-
-
-
-
-
Users
-
- {stats.cards.totalUsers}
-
-
-
-
- );
+ // 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
+ };
- case 'totalRepos':
- return (
-
-
-
-
-
-
-
Repositories
-
- {stats.cards.totalRepos}
-
-
-
-
- );
-
- case 'erroredHosts':
- return (
-
0
- ? 'bg-danger-50 border-danger-200'
- : 'bg-success-50 border-success-200'
- }`}
- onClick={handleErroredHostsClick}
- >
-
-
0 ? 'text-danger-400' : 'text-success-400'
- }`} />
-
- {stats.cards.erroredHosts > 0 ? (
- <>
-
- {stats.cards.erroredHosts} host{stats.cards.erroredHosts > 1 ? 's' : ''} haven't reported in {formatUpdateIntervalThreshold()}+
-
-
- These hosts may be offline or experiencing connectivity issues.
-
- >
- ) : (
- <>
-
- All hosts are reporting normally
-
-
- No hosts have failed to report in the last {formatUpdateIntervalThreshold()}.
-
- >
- )}
-
-
-
- );
-
- case 'offlineHosts':
- return (
-
0
- ? 'bg-warning-50 border-warning-200'
- : 'bg-success-50 border-success-200'
- }`}
- onClick={handleOfflineHostsClick}
- >
-
-
0 ? 'text-warning-400' : 'text-success-400'
- }`} />
-
- {stats.cards.offlineHosts > 0 ? (
- <>
-
- {stats.cards.offlineHosts} host{stats.cards.offlineHosts > 1 ? 's' : ''} offline/stale
-
-
- These hosts haven't reported in {formatUpdateIntervalThreshold() * 3}+ minutes.
-
- >
- ) : (
- <>
-
- All hosts are online
-
-
- No hosts are offline or stale.
-
- >
- )}
-
-
-
- );
-
- case 'osDistribution':
- return (
-
- );
-
- case 'osDistributionBar':
- return (
-
-
OS Distribution
-
-
-
-
- );
-
- case 'updateStatus':
- return (
-
- );
-
- case 'packagePriority':
- return (
-
- );
-
- 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.friendly_name || host.hostname}
-
-
- {host.last_update ? formatRelativeTime(host.last_update) : 'Never'}
-
-
- ))}
- {(!recentCollection || recentCollection.length === 0) && (
-
- No hosts found
-
- )}
-
-
-
- );
-
- default:
- return null;
- }
- }
+ // 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",
+ "updateStatus",
+ "packagePriority",
+ "recentUsers",
+ "recentCollection",
+ ].includes(cardId)
+ ) {
+ return "charts";
+ } else if (["erroredHosts", "quickStats"].includes(cardId)) {
+ return "fullwidth";
+ }
+ return "fullwidth"; // Default to full width
+ };
- if (isLoading) {
- return (
-
-
-
- )
- }
+ // 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 "fullwidth":
+ return "space-y-6";
+ default:
+ return "space-y-6";
+ }
+ };
- if (error) {
- return (
-
-
-
-
-
Error loading dashboard
-
- {error.message || 'Failed to load dashboard statistics'}
-
-
refetch()}
- className="mt-2 btn-danger text-xs"
- >
- Try again
-
-
-
-
- )
- }
+ // Helper function to render a card by ID
+ const renderCard = (cardId) => {
+ switch (cardId) {
+ case "upToDateHosts":
+ return (
+
+
+
+
+
+
+
+ Up to date
+
+
+ {stats.cards.upToDateHosts}/{stats.cards.totalHosts}
+
+
+
+
+ );
+ case "totalHosts":
+ return (
+
+
+
+
+
+
+
+ Total Hosts
+
+
+ {stats.cards.totalHosts}
+
+
+
+
+ );
- const chartOptions = {
- responsive: true,
- plugins: {
- legend: {
- position: 'bottom',
- labels: {
- color: isDark ? '#ffffff' : '#374151',
- font: {
- size: 12
- }
- }
- },
- },
- onClick: handleOSChartClick,
- }
+ case "hostsNeedingUpdates":
+ return (
+
+
+
+
+
+ Needs Updating
+
+
+ {stats.cards.hostsNeedingUpdates}
+
+
+
+
+ );
- const updateStatusChartOptions = {
- responsive: true,
- plugins: {
- legend: {
- position: 'bottom',
- labels: {
- color: isDark ? '#ffffff' : '#374151',
- font: {
- size: 12
- }
- }
- },
- },
- onClick: handleUpdateStatusChartClick,
- }
+ case "totalOutdatedPackages":
+ return (
+
+
+
+
+
+ Outdated Packages
+
+
+ {stats.cards.totalOutdatedPackages}
+
+
+
+
+ );
- const packagePriorityChartOptions = {
- responsive: true,
- plugins: {
- legend: {
- position: 'bottom',
- labels: {
- color: isDark ? '#ffffff' : '#374151',
- font: {
- size: 12
- }
- }
- },
- },
- onClick: handlePackagePriorityChartClick,
- }
+ case "securityUpdates":
+ return (
+
+
+
+
+
+
+
+ Security Updates
+
+
+ {stats.cards.securityUpdates}
+
+
+
+
+ );
- 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'
- }
- }
- }
- }
+ case "totalHostGroups":
+ return (
+
+
+
+
+
+
+
+ Host Groups
+
+
+ {stats.cards.totalHostGroups}
+
+
+
+
+ );
- 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',
- },
- ],
- }
+ case "totalUsers":
+ return (
+
+
+
+
+
+
+
+ Users
+
+
+ {stats.cards.totalUsers}
+
+
+
+
+ );
- 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,
- },
- ],
- }
+ case "totalRepos":
+ return (
+
+
+
+
+
+
+
+ Repositories
+
+
+ {stats.cards.totalRepos}
+
+
+
+
+ );
- 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',
- },
- ],
- }
+ case "erroredHosts":
+ return (
+
0
+ ? "bg-danger-50 border-danger-200"
+ : "bg-success-50 border-success-200"
+ }`}
+ onClick={handleErroredHostsClick}
+ >
+
+
0
+ ? "text-danger-400"
+ : "text-success-400"
+ }`}
+ />
+
+ {stats.cards.erroredHosts > 0 ? (
+ <>
+
+ {stats.cards.erroredHosts} host
+ {stats.cards.erroredHosts > 1 ? "s" : ""} haven't reported
+ in {formatUpdateIntervalThreshold()}+
+
+
+ These hosts may be offline or experiencing connectivity
+ issues.
+
+ >
+ ) : (
+ <>
+
+ All hosts are reporting normally
+
+
+ No hosts have failed to report in the last{" "}
+ {formatUpdateIntervalThreshold()}.
+
+ >
+ )}
+
+
+
+ );
- 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',
- },
- ],
- }
+ case "offlineHosts":
+ return (
+
0
+ ? "bg-warning-50 border-warning-200"
+ : "bg-success-50 border-success-200"
+ }`}
+ onClick={handleOfflineHostsClick}
+ >
+
+
0
+ ? "text-warning-400"
+ : "text-success-400"
+ }`}
+ />
+
+ {stats.cards.offlineHosts > 0 ? (
+ <>
+
+ {stats.cards.offlineHosts} host
+ {stats.cards.offlineHosts > 1 ? "s" : ""} offline/stale
+
+
+ These hosts haven't reported in{" "}
+ {formatUpdateIntervalThreshold() * 3}+ minutes.
+
+ >
+ ) : (
+ <>
+
+ All hosts are online
+
+
+ No hosts are offline or stale.
+
+ >
+ )}
+
+
+
+ );
- return (
-
- {/* Page Header */}
-
-
-
- Welcome back, {user?.first_name || user?.username || 'User'} 👋
-
-
- Overview of your PatchMon infrastructure
-
-
-
- setShowSettingsModal(true)}
- className="btn-outline flex items-center gap-2"
- title="Customize dashboard layout"
- >
-
- Customize Dashboard
-
- refetch()}
- disabled={isFetching}
- className="btn-outline flex items-center gap-2"
- title="Refresh dashboard data"
- >
-
- {isFetching ? 'Refreshing...' : 'Refresh'}
-
-
-
+ case "osDistribution":
+ return (
+
+
+ OS Distribution
+
+
+
+ );
- {/* 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 => (
-
- {renderCard(card.cardId)}
-
- ))}
-
- ))}
- >
- );
- })()}
+ case "osDistributionBar":
+ return (
+
+
+ OS Distribution
+
+
+
+
+
+ );
- {/* Dashboard Settings Modal */}
-
setShowSettingsModal(false)}
- />
-
- )
-}
+ case "updateStatus":
+ return (
+
+ );
-export default Dashboard
\ No newline at end of file
+ case "packagePriority":
+ return (
+
+
+ Package Priority
+
+
+
+ );
+
+ 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.friendly_name || host.hostname}
+
+
+ {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"}
+
+
refetch()}
+ className="mt-2 btn-danger text-xs"
+ >
+ Try again
+
+
+
+
+ );
+ }
+
+ const chartOptions = {
+ responsive: true,
+ plugins: {
+ legend: {
+ position: "bottom",
+ labels: {
+ color: isDark ? "#ffffff" : "#374151",
+ font: {
+ size: 12,
+ },
+ },
+ },
+ },
+ onClick: handleOSChartClick,
+ };
+
+ const updateStatusChartOptions = {
+ responsive: true,
+ plugins: {
+ legend: {
+ position: "bottom",
+ labels: {
+ color: isDark ? "#ffffff" : "#374151",
+ font: {
+ size: 12,
+ },
+ },
+ },
+ },
+ onClick: handleUpdateStatusChartClick,
+ };
+
+ const packagePriorityChartOptions = {
+ responsive: true,
+ plugins: {
+ legend: {
+ position: "bottom",
+ labels: {
+ color: isDark ? "#ffffff" : "#374151",
+ font: {
+ size: 12,
+ },
+ },
+ },
+ },
+ onClick: handlePackagePriorityChartClick,
+ };
+
+ 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",
+ },
+ },
+ },
+ };
+
+ 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
+
+
+
+ setShowSettingsModal(true)}
+ className="btn-outline flex items-center gap-2"
+ title="Customize dashboard layout"
+ >
+
+ Customize Dashboard
+
+ refetch()}
+ disabled={isFetching}
+ className="btn-outline flex items-center gap-2"
+ title="Refresh dashboard data"
+ >
+
+ {isFetching ? "Refreshing..." : "Refresh"}
+
+
+
+
+ {/* 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) => (
+
{renderCard(card.cardId)}
+ ))}
+
+ ))}
+ >
+ );
+ })()}
+
+ {/* Dashboard Settings Modal */}
+
setShowSettingsModal(false)}
+ />
+
+ );
+};
+
+export default Dashboard;
diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx
index b4b1b9b..5b8c1b5 100644
--- a/frontend/src/pages/HostDetail.jsx
+++ b/frontend/src/pages/HostDetail.jsx
@@ -1,837 +1,1043 @@
-import React, { useState } from 'react'
-import { useParams, Link, useNavigate } from 'react-router-dom'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import {
- Server,
- ArrowLeft,
- Package,
- Shield,
- Clock,
- CheckCircle,
- AlertTriangle,
- RefreshCw,
- Calendar,
- Monitor,
- HardDrive,
- Key,
- Trash2,
- X,
- Copy,
- Eye,
- Code,
- EyeOff,
- ToggleLeft,
- ToggleRight,
- Edit,
- Check,
- ChevronDown,
- ChevronUp,
- Cpu,
- MemoryStick,
- Globe,
- Wifi,
- Terminal,
- Activity
-} from 'lucide-react'
-import { dashboardAPI, adminHostsAPI, settingsAPI, formatRelativeTime, formatDate } from '../utils/api'
-import { OSIcon } from '../utils/osIcons.jsx'
-import InlineEdit from '../components/InlineEdit'
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ Activity,
+ AlertTriangle,
+ ArrowLeft,
+ Calendar,
+ Check,
+ CheckCircle,
+ ChevronDown,
+ ChevronUp,
+ Clock,
+ Code,
+ Copy,
+ Cpu,
+ Edit,
+ Eye,
+ EyeOff,
+ Globe,
+ HardDrive,
+ Key,
+ MemoryStick,
+ Monitor,
+ Package,
+ RefreshCw,
+ Server,
+ Shield,
+ Terminal,
+ ToggleLeft,
+ ToggleRight,
+ Trash2,
+ Wifi,
+ X,
+} from "lucide-react";
+import React, { useState } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import InlineEdit from "../components/InlineEdit";
+import {
+ adminHostsAPI,
+ dashboardAPI,
+ formatDate,
+ formatRelativeTime,
+ settingsAPI,
+} from "../utils/api";
+import { OSIcon } from "../utils/osIcons.jsx";
const HostDetail = () => {
- const { hostId } = useParams()
- const navigate = useNavigate()
- const queryClient = useQueryClient()
- const [showCredentialsModal, setShowCredentialsModal] = useState(false)
- const [showDeleteModal, setShowDeleteModal] = useState(false)
- const [isEditingFriendlyName, setIsEditingFriendlyName] = useState(false)
- const [editedFriendlyName, setEditedFriendlyName] = useState('')
- const [showAllUpdates, setShowAllUpdates] = useState(false)
- const [activeTab, setActiveTab] = useState(() => {
- // Restore tab state from localStorage
- const savedTab = localStorage.getItem(`host-detail-tab-${hostId}`)
- return savedTab || 'host'
- })
-
- const { data: host, isLoading, error, refetch, isFetching } = useQuery({
- queryKey: ['host', hostId],
- queryFn: () => dashboardAPI.getHostDetail(hostId).then(res => res.data),
- staleTime: 5 * 60 * 1000, // 5 minutes - data stays fresh longer
- refetchOnWindowFocus: false, // Don't refetch when window regains focus
- })
+ const { hostId } = useParams();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [showCredentialsModal, setShowCredentialsModal] = useState(false);
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [isEditingFriendlyName, setIsEditingFriendlyName] = useState(false);
+ const [editedFriendlyName, setEditedFriendlyName] = useState("");
+ const [showAllUpdates, setShowAllUpdates] = useState(false);
+ const [activeTab, setActiveTab] = useState(() => {
+ // Restore tab state from localStorage
+ const savedTab = localStorage.getItem(`host-detail-tab-${hostId}`);
+ return savedTab || "host";
+ });
- // Save tab state to localStorage when it changes
- const handleTabChange = (tabName) => {
- setActiveTab(tabName)
- localStorage.setItem(`host-detail-tab-${hostId}`, tabName)
- }
+ const {
+ data: host,
+ isLoading,
+ error,
+ refetch,
+ isFetching,
+ } = useQuery({
+ queryKey: ["host", hostId],
+ queryFn: () => dashboardAPI.getHostDetail(hostId).then((res) => res.data),
+ staleTime: 5 * 60 * 1000, // 5 minutes - data stays fresh longer
+ refetchOnWindowFocus: false, // Don't refetch when window regains focus
+ });
- // Auto-show credentials modal for new/pending hosts
- React.useEffect(() => {
- if (host && host.status === 'pending') {
- setShowCredentialsModal(true)
- }
- }, [host])
+ // Save tab state to localStorage when it changes
+ const handleTabChange = (tabName) => {
+ setActiveTab(tabName);
+ localStorage.setItem(`host-detail-tab-${hostId}`, tabName);
+ };
- const deleteHostMutation = useMutation({
- mutationFn: (hostId) => adminHostsAPI.delete(hostId),
- onSuccess: () => {
- queryClient.invalidateQueries(['hosts'])
- navigate('/hosts')
- },
- })
+ // Auto-show credentials modal for new/pending hosts
+ React.useEffect(() => {
+ if (host && host.status === "pending") {
+ setShowCredentialsModal(true);
+ }
+ }, [host]);
- // Toggle auto-update mutation
- const toggleAutoUpdateMutation = useMutation({
- mutationFn: (auto_update) => adminHostsAPI.toggleAutoUpdate(hostId, auto_update).then(res => res.data),
- onSuccess: () => {
- queryClient.invalidateQueries(['host', hostId])
- queryClient.invalidateQueries(['hosts'])
- }
- })
+ const deleteHostMutation = useMutation({
+ mutationFn: (hostId) => adminHostsAPI.delete(hostId),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["hosts"]);
+ navigate("/hosts");
+ },
+ });
- const updateFriendlyNameMutation = useMutation({
- mutationFn: (friendlyName) => adminHostsAPI.updateFriendlyName(hostId, friendlyName).then(res => res.data),
- onSuccess: () => {
- queryClient.invalidateQueries(['host', hostId])
- queryClient.invalidateQueries(['hosts'])
- }
- })
+ // Toggle auto-update mutation
+ const toggleAutoUpdateMutation = useMutation({
+ mutationFn: (auto_update) =>
+ adminHostsAPI
+ .toggleAutoUpdate(hostId, auto_update)
+ .then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["host", hostId]);
+ queryClient.invalidateQueries(["hosts"]);
+ },
+ });
- const handleDeleteHost = async () => {
- if (window.confirm(`Are you sure you want to delete host "${host.friendly_name}"? This action cannot be undone.`)) {
- try {
- await deleteHostMutation.mutateAsync(hostId)
- } catch (error) {
- console.error('Failed to delete host:', error)
- alert('Failed to delete host')
- }
- }
- }
+ const updateFriendlyNameMutation = useMutation({
+ mutationFn: (friendlyName) =>
+ adminHostsAPI
+ .updateFriendlyName(hostId, friendlyName)
+ .then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["host", hostId]);
+ queryClient.invalidateQueries(["hosts"]);
+ },
+ });
- if (isLoading) {
- return (
-
-
-
- )
- }
+ const handleDeleteHost = async () => {
+ if (
+ window.confirm(
+ `Are you sure you want to delete host "${host.friendly_name}"? This action cannot be undone.`,
+ )
+ ) {
+ try {
+ await deleteHostMutation.mutateAsync(hostId);
+ } catch (error) {
+ console.error("Failed to delete host:", error);
+ alert("Failed to delete host");
+ }
+ }
+ };
- if (error) {
- return (
-
-
-
-
-
-
-
-
Error loading host
-
- {error.message || 'Failed to load host details'}
-
-
refetch()}
- className="mt-2 btn-danger text-xs"
- >
- Try again
-
-
-
-
-
- )
- }
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
- if (!host) {
- return (
-
-
-
-
-
-
Host Not Found
-
- The requested host could not be found.
-
-
-
- )
- }
+ if (error) {
+ return (
+
+
- const getStatusColor = (isStale, needsUpdate) => {
- if (isStale) return 'text-danger-600'
- if (needsUpdate) return 'text-warning-600'
- return 'text-success-600'
- }
+
+
+
+
+
+ Error loading host
+
+
+ {error.message || "Failed to load host details"}
+
+
refetch()}
+ className="mt-2 btn-danger text-xs"
+ >
+ Try again
+
+
+
+
+
+ );
+ }
- const getStatusIcon = (isStale, needsUpdate) => {
- if (isStale) return
- if (needsUpdate) return
- return
- }
+ if (!host) {
+ return (
+
+
- const getStatusText = (isStale, needsUpdate) => {
- if (isStale) return 'Stale'
- if (needsUpdate) return 'Needs Updates'
- return 'Up to Date'
- }
+
+
+
+ Host Not Found
+
+
+ The requested host could not be found.
+
+
+
+ );
+ }
- const isStale = new Date() - new Date(host.last_update) > 24 * 60 * 60 * 1000
+ const getStatusColor = (isStale, needsUpdate) => {
+ if (isStale) return "text-danger-600";
+ if (needsUpdate) return "text-warning-600";
+ return "text-success-600";
+ };
- return (
-
- {/* Header */}
-
-
-
-
-
-
{host.friendly_name}
- {host.system_uptime && (
-
-
- Uptime:
- {host.system_uptime}
-
- )}
-
-
- Last updated:
- {formatRelativeTime(host.last_update)}
-
-
0)}`}>
- {getStatusIcon(isStale, host.stats.outdated_packages > 0)}
- {getStatusText(isStale, host.stats.outdated_packages > 0)}
-
-
-
- refetch()}
- disabled={isFetching}
- className="btn-outline flex items-center gap-2 text-sm"
- title="Refresh host data"
- >
-
- {isFetching ? 'Refreshing...' : 'Refresh'}
-
- setShowCredentialsModal(true)}
- className="btn-outline flex items-center gap-2 text-sm"
- >
-
- Deploy Agent
-
- setShowDeleteModal(true)}
- className="btn-danger flex items-center gap-2 text-sm"
- >
-
- Delete
-
-
-
+ const getStatusIcon = (isStale, needsUpdate) => {
+ if (isStale) return
;
+ if (needsUpdate) return
;
+ return
;
+ };
- {/* Main Content Grid */}
-
- {/* Left Column - System Details with Tabs */}
-
- {/* Host Info, Hardware, Network, System Info in Tabs */}
-
-
- handleTabChange('host')}
- className={`px-4 py-2 text-sm font-medium ${
- activeTab === 'host'
- ? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
- : 'text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300'
- }`}
- >
- Host Info
-
- handleTabChange('network')}
- className={`px-4 py-2 text-sm font-medium ${
- activeTab === 'network'
- ? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
- : 'text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300'
- }`}
- >
- Network
-
- handleTabChange('system')}
- className={`px-4 py-2 text-sm font-medium ${
- activeTab === 'system'
- ? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
- : 'text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300'
- }`}
- >
- System
-
- handleTabChange('monitoring')}
- className={`px-4 py-2 text-sm font-medium ${
- activeTab === 'monitoring'
- ? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
- : 'text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300'
- }`}
- >
- Resource
-
- handleTabChange('history')}
- className={`px-4 py-2 text-sm font-medium ${
- activeTab === 'history'
- ? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
- : 'text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300'
- }`}
- >
- Update History
-
-
-
-
- {/* Host Information */}
- {activeTab === 'host' && (
-
-
-
-
Friendly Name
-
updateFriendlyNameMutation.mutate(newName)}
- placeholder="Enter friendly name..."
- maxLength={100}
- validate={(value) => {
- if (!value.trim()) return 'Friendly name is required';
- if (value.trim().length < 1) return 'Friendly name must be at least 1 character';
- if (value.trim().length > 100) return 'Friendly name must be less than 100 characters';
- return null;
- }}
- className="w-full text-sm"
- />
-
-
- {host.hostname && (
-
-
System Hostname
-
{host.hostname}
-
- )}
-
-
-
Host Group
- {host.host_groups ? (
-
- {host.host_groups.name}
-
- ) : (
-
- Ungrouped
-
- )}
-
-
-
-
Operating System
-
-
-
{host.os_type} {host.os_version}
-
-
-
-
- {host.agent_version && (
-
-
-
Agent Version
-
{host.agent_version}
-
-
- Auto-update
- toggleAutoUpdateMutation.mutate(!host.auto_update)}
- disabled={toggleAutoUpdateMutation.isPending}
- className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
- host.auto_update
- ? 'bg-primary-600 dark:bg-primary-500'
- : 'bg-secondary-200 dark:bg-secondary-600'
- }`}
- >
-
-
-
-
- )}
-
-
- )}
+ const getStatusText = (isStale, needsUpdate) => {
+ if (isStale) return "Stale";
+ if (needsUpdate) return "Needs Updates";
+ return "Up to Date";
+ };
+ const isStale = new Date() - new Date(host.last_update) > 24 * 60 * 60 * 1000;
- {/* Network Information */}
- {activeTab === 'network' && (host.gateway_ip || host.dns_servers || host.network_interfaces) && (
-
-
- {host.gateway_ip && (
-
-
Gateway IP
-
{host.gateway_ip}
-
- )}
-
- {host.dns_servers && Array.isArray(host.dns_servers) && host.dns_servers.length > 0 && (
-
-
DNS Servers
-
- {host.dns_servers.map((dns, index) => (
-
{dns}
- ))}
-
-
- )}
-
- {host.network_interfaces && Array.isArray(host.network_interfaces) && host.network_interfaces.length > 0 && (
-
-
Network Interfaces
-
- {host.network_interfaces.map((iface, index) => (
-
{iface.name}
- ))}
-
-
- )}
-
-
- )}
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+ {host.friendly_name}
+
+ {host.system_uptime && (
+
+
+ Uptime:
+ {host.system_uptime}
+
+ )}
+
+
+ Last updated:
+ {formatRelativeTime(host.last_update)}
+
+
0)}`}
+ >
+ {getStatusIcon(isStale, host.stats.outdated_packages > 0)}
+ {getStatusText(isStale, host.stats.outdated_packages > 0)}
+
+
+
+ refetch()}
+ disabled={isFetching}
+ className="btn-outline flex items-center gap-2 text-sm"
+ title="Refresh host data"
+ >
+
+ {isFetching ? "Refreshing..." : "Refresh"}
+
+ setShowCredentialsModal(true)}
+ className="btn-outline flex items-center gap-2 text-sm"
+ >
+
+ Deploy Agent
+
+ setShowDeleteModal(true)}
+ className="btn-danger flex items-center gap-2 text-sm"
+ >
+
+ Delete
+
+
+
- {/* System Information */}
- {activeTab === 'system' && (host.kernel_version || host.selinux_status || host.architecture) && (
-
-
- {host.architecture && (
-
-
Architecture
-
{host.architecture}
-
- )}
-
- {host.kernel_version && (
-
-
Kernel Version
-
{host.kernel_version}
-
- )}
-
- {host.selinux_status && (
-
-
SELinux Status
-
- {host.selinux_status}
-
-
- )}
-
-
-
-
- )}
+ {/* Main Content Grid */}
+
+ {/* Left Column - System Details with Tabs */}
+
+ {/* Host Info, Hardware, Network, System Info in Tabs */}
+
+
+ handleTabChange("host")}
+ className={`px-4 py-2 text-sm font-medium ${
+ activeTab === "host"
+ ? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
+ : "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
+ }`}
+ >
+ Host Info
+
+ handleTabChange("network")}
+ className={`px-4 py-2 text-sm font-medium ${
+ activeTab === "network"
+ ? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
+ : "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
+ }`}
+ >
+ Network
+
+ handleTabChange("system")}
+ className={`px-4 py-2 text-sm font-medium ${
+ activeTab === "system"
+ ? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
+ : "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
+ }`}
+ >
+ System
+
+ handleTabChange("monitoring")}
+ className={`px-4 py-2 text-sm font-medium ${
+ activeTab === "monitoring"
+ ? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
+ : "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
+ }`}
+ >
+ Resource
+
+ handleTabChange("history")}
+ className={`px-4 py-2 text-sm font-medium ${
+ activeTab === "history"
+ ? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500"
+ : "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300"
+ }`}
+ >
+ Update History
+
+
-
- {activeTab === 'network' && !(host.gateway_ip || host.dns_servers || host.network_interfaces) && (
-
-
-
No network information available
-
- )}
-
- {activeTab === 'system' && !(host.kernel_version || host.selinux_status || host.architecture) && (
-
-
-
No system information available
-
- )}
+
+ {/* Host Information */}
+ {activeTab === "host" && (
+
+
+
+
+ Friendly Name
+
+
+ updateFriendlyNameMutation.mutate(newName)
+ }
+ placeholder="Enter friendly name..."
+ maxLength={100}
+ validate={(value) => {
+ if (!value.trim()) return "Friendly name is required";
+ if (value.trim().length < 1)
+ return "Friendly name must be at least 1 character";
+ if (value.trim().length > 100)
+ return "Friendly name must be less than 100 characters";
+ return null;
+ }}
+ className="w-full text-sm"
+ />
+
- {/* System Monitoring */}
- {activeTab === 'monitoring' && (
-
- {/* System Overview */}
-
- {/* System Uptime */}
- {host.system_uptime && (
-
-
-
{host.system_uptime}
-
- )}
-
- {/* CPU Model */}
- {host.cpu_model && (
-
- )}
-
- {/* CPU Cores */}
- {host.cpu_cores && (
-
- )}
-
- {/* RAM Installed */}
- {host.ram_installed && (
-
-
-
{host.ram_installed} GB
-
- )}
-
- {/* Swap Size */}
- {host.swap_size !== undefined && host.swap_size !== null && (
-
-
-
{host.swap_size} GB
-
- )}
-
- {/* Load Average */}
- {host.load_average && Array.isArray(host.load_average) && host.load_average.length > 0 && host.load_average.some(load => load != null) && (
-
-
-
- {host.load_average.filter(load => load != null).map((load, index) => (
-
- {typeof load === 'number' ? load.toFixed(2) : String(load)}
- {index < host.load_average.filter(load => load != null).length - 1 && ', '}
-
- ))}
-
-
- )}
-
-
- {/* Disk Information */}
- {host.disk_details && Array.isArray(host.disk_details) && host.disk_details.length > 0 && (
-
-
-
- Disk Usage
-
-
- {host.disk_details.map((disk, index) => (
-
-
-
- {disk.name || `Disk ${index + 1}`}
-
- {disk.size && (
-
Size: {disk.size}
- )}
- {disk.mountpoint && (
-
Mount: {disk.mountpoint}
- )}
- {disk.usage && typeof disk.usage === 'number' && (
-
-
- Usage
- {disk.usage}%
-
-
-
- )}
-
- ))}
-
-
- )}
-
- {/* No Data State */}
- {!host.system_uptime && !host.cpu_model && !host.cpu_cores && !host.ram_installed && host.swap_size === undefined &&
- (!host.load_average || !Array.isArray(host.load_average) || host.load_average.length === 0 || !host.load_average.some(load => load != null)) &&
- (!host.disk_details || !Array.isArray(host.disk_details) || host.disk_details.length === 0) && (
-
-
-
No monitoring data available
-
- Monitoring data will appear once the agent collects system information
-
-
- )}
-
- )}
+ {host.hostname && (
+
+
+ System Hostname
+
+
+ {host.hostname}
+
+
+ )}
- {/* Update History */}
- {activeTab === 'history' && (
-
- {host.update_history?.length > 0 ? (
- <>
-
-
-
-
- Status
-
-
- Date
-
-
- Packages
-
-
- Security
-
-
-
-
- {(showAllUpdates ? host.update_history : host.update_history.slice(0, 5)).map((update, index) => (
-
-
-
-
-
- {update.status === 'success' ? 'Success' : 'Failed'}
-
-
-
-
- {formatDate(update.timestamp)}
-
-
- {update.packages_count}
-
-
- {update.security_count > 0 ? (
-
-
-
- {update.security_count}
-
-
- ) : (
- -
- )}
-
-
- ))}
-
-
-
- {host.update_history.length > 5 && (
-
- setShowAllUpdates(!showAllUpdates)}
- className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
- >
- {showAllUpdates ? (
- <>
-
- Show Less
- >
- ) : (
- <>
-
- Show All ({host.update_history.length} total)
- >
- )}
-
-
- )}
- >
- ) : (
-
-
-
No update history available
-
- )}
-
- )}
-
-
-
+
+
+ Host Group
+
+ {host.host_groups ? (
+
+ {host.host_groups.name}
+
+ ) : (
+
+ Ungrouped
+
+ )}
+
- {/* Right Column - Package Statistics */}
-
- {/* Package Statistics */}
-
-
-
Package Statistics
-
-
-
-
navigate(`/packages?host=${hostId}`)}
- className="text-center p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors group"
- title="View all packages for this host"
- >
-
- {host.stats.total_packages}
- Total Packages
-
-
-
navigate(`/packages?host=${hostId}`)}
- className="text-center p-4 bg-warning-50 dark:bg-warning-900/20 rounded-lg hover:bg-warning-100 dark:hover:bg-warning-900/30 transition-colors group"
- title="View outdated packages for this host"
- >
-
-
-
- {host.stats.outdated_packages}
- Outdated Packages
-
-
-
navigate(`/packages?host=${hostId}&filter=security`)}
- className="text-center p-4 bg-danger-50 dark:bg-danger-900/20 rounded-lg hover:bg-danger-100 dark:hover:bg-danger-900/30 transition-colors group"
- title="View security packages for this host"
- >
-
-
-
- {host.stats.security_updates}
- Security Updates
-
-
-
-
-
-
+
+
+ Operating System
+
+
+
+
+ {host.os_type} {host.os_version}
+
+
+
+ {host.agent_version && (
+
+
+
+ Agent Version
+
+
+ {host.agent_version}
+
+
+
+
+ Auto-update
+
+
+ toggleAutoUpdateMutation.mutate(!host.auto_update)
+ }
+ disabled={toggleAutoUpdateMutation.isPending}
+ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
+ host.auto_update
+ ? "bg-primary-600 dark:bg-primary-500"
+ : "bg-secondary-200 dark:bg-secondary-600"
+ }`}
+ >
+
+
+
+
+ )}
+
+
+ )}
- {/* Credentials Modal */}
- {showCredentialsModal && (
-
setShowCredentialsModal(false)}
- />
- )}
+ {/* Network Information */}
+ {activeTab === "network" &&
+ (host.gateway_ip ||
+ host.dns_servers ||
+ host.network_interfaces) && (
+
+
+ {host.gateway_ip && (
+
+
+ Gateway IP
+
+
+ {host.gateway_ip}
+
+
+ )}
- {/* Delete Confirmation Modal */}
- {showDeleteModal && (
-
setShowDeleteModal(false)}
- onConfirm={handleDeleteHost}
- isLoading={deleteHostMutation.isPending}
- />
- )}
-
- )
-}
+ {host.dns_servers &&
+ Array.isArray(host.dns_servers) &&
+ host.dns_servers.length > 0 && (
+
+
+ DNS Servers
+
+
+ {host.dns_servers.map((dns, index) => (
+
+ {dns}
+
+ ))}
+
+
+ )}
+
+ {host.network_interfaces &&
+ Array.isArray(host.network_interfaces) &&
+ host.network_interfaces.length > 0 && (
+
+
+ Network Interfaces
+
+
+ {host.network_interfaces.map((iface, index) => (
+
+ {iface.name}
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+ {/* System Information */}
+ {activeTab === "system" &&
+ (host.kernel_version ||
+ host.selinux_status ||
+ host.architecture) && (
+
+
+ {host.architecture && (
+
+
+ Architecture
+
+
+ {host.architecture}
+
+
+ )}
+
+ {host.kernel_version && (
+
+
+ Kernel Version
+
+
+ {host.kernel_version}
+
+
+ )}
+
+ {host.selinux_status && (
+
+
+ SELinux Status
+
+
+ {host.selinux_status}
+
+
+ )}
+
+
+ )}
+
+ {activeTab === "network" &&
+ !(
+ host.gateway_ip ||
+ host.dns_servers ||
+ host.network_interfaces
+ ) && (
+
+
+
+ No network information available
+
+
+ )}
+
+ {activeTab === "system" &&
+ !(
+ host.kernel_version ||
+ host.selinux_status ||
+ host.architecture
+ ) && (
+
+
+
+ No system information available
+
+
+ )}
+
+ {/* System Monitoring */}
+ {activeTab === "monitoring" && (
+
+ {/* System Overview */}
+
+ {/* System Uptime */}
+ {host.system_uptime && (
+
+
+
+ {host.system_uptime}
+
+
+ )}
+
+ {/* CPU Model */}
+ {host.cpu_model && (
+
+
+
+ {host.cpu_model}
+
+
+ )}
+
+ {/* CPU Cores */}
+ {host.cpu_cores && (
+
+
+
+ {host.cpu_cores}
+
+
+ )}
+
+ {/* RAM Installed */}
+ {host.ram_installed && (
+
+
+
+ {host.ram_installed} GB
+
+
+ )}
+
+ {/* Swap Size */}
+ {host.swap_size !== undefined &&
+ host.swap_size !== null && (
+
+
+
+ {host.swap_size} GB
+
+
+ )}
+
+ {/* Load Average */}
+ {host.load_average &&
+ Array.isArray(host.load_average) &&
+ host.load_average.length > 0 &&
+ host.load_average.some((load) => load != null) && (
+
+
+
+ {host.load_average
+ .filter((load) => load != null)
+ .map((load, index) => (
+
+ {typeof load === "number"
+ ? load.toFixed(2)
+ : String(load)}
+ {index <
+ host.load_average.filter(
+ (load) => load != null,
+ ).length -
+ 1 && ", "}
+
+ ))}
+
+
+ )}
+
+
+ {/* Disk Information */}
+ {host.disk_details &&
+ Array.isArray(host.disk_details) &&
+ host.disk_details.length > 0 && (
+
+
+
+ Disk Usage
+
+
+ {host.disk_details.map((disk, index) => (
+
+
+
+
+ {disk.name || `Disk ${index + 1}`}
+
+
+ {disk.size && (
+
+ Size: {disk.size}
+
+ )}
+ {disk.mountpoint && (
+
+ Mount: {disk.mountpoint}
+
+ )}
+ {disk.usage && typeof disk.usage === "number" && (
+
+
+ Usage
+ {disk.usage}%
+
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* No Data State */}
+ {!host.system_uptime &&
+ !host.cpu_model &&
+ !host.cpu_cores &&
+ !host.ram_installed &&
+ host.swap_size === undefined &&
+ (!host.load_average ||
+ !Array.isArray(host.load_average) ||
+ host.load_average.length === 0 ||
+ !host.load_average.some((load) => load != null)) &&
+ (!host.disk_details ||
+ !Array.isArray(host.disk_details) ||
+ host.disk_details.length === 0) && (
+
+
+
+ No monitoring data available
+
+
+ Monitoring data will appear once the agent collects
+ system information
+
+
+ )}
+
+ )}
+
+ {/* Update History */}
+ {activeTab === "history" && (
+
+ {host.update_history?.length > 0 ? (
+ <>
+
+
+
+
+ Status
+
+
+ Date
+
+
+ Packages
+
+
+ Security
+
+
+
+
+ {(showAllUpdates
+ ? host.update_history
+ : host.update_history.slice(0, 5)
+ ).map((update, index) => (
+
+
+
+
+
+ {update.status === "success"
+ ? "Success"
+ : "Failed"}
+
+
+
+
+ {formatDate(update.timestamp)}
+
+
+ {update.packages_count}
+
+
+ {update.security_count > 0 ? (
+
+
+
+ {update.security_count}
+
+
+ ) : (
+
+ -
+
+ )}
+
+
+ ))}
+
+
+
+ {host.update_history.length > 5 && (
+
+ setShowAllUpdates(!showAllUpdates)}
+ className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
+ >
+ {showAllUpdates ? (
+ <>
+
+ Show Less
+ >
+ ) : (
+ <>
+
+ Show All ({host.update_history.length} total)
+ >
+ )}
+
+
+ )}
+ >
+ ) : (
+
+
+
+ No update history available
+
+
+ )}
+
+ )}
+
+
+
+
+ {/* Right Column - Package Statistics */}
+
+ {/* Package Statistics */}
+
+
+
+ Package Statistics
+
+
+
+
+
navigate(`/packages?host=${hostId}`)}
+ className="text-center p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors group"
+ title="View all packages for this host"
+ >
+
+
+ {host.stats.total_packages}
+
+
+ Total Packages
+
+
+
+
navigate(`/packages?host=${hostId}`)}
+ className="text-center p-4 bg-warning-50 dark:bg-warning-900/20 rounded-lg hover:bg-warning-100 dark:hover:bg-warning-900/30 transition-colors group"
+ title="View outdated packages for this host"
+ >
+
+
+
+
+ {host.stats.outdated_packages}
+
+
+ Outdated Packages
+
+
+
+
+ navigate(`/packages?host=${hostId}&filter=security`)
+ }
+ className="text-center p-4 bg-danger-50 dark:bg-danger-900/20 rounded-lg hover:bg-danger-100 dark:hover:bg-danger-900/30 transition-colors group"
+ title="View security packages for this host"
+ >
+
+
+
+
+ {host.stats.security_updates}
+
+
+ Security Updates
+
+
+
+
+
+
+
+
+ {/* Credentials Modal */}
+ {showCredentialsModal && (
+
setShowCredentialsModal(false)}
+ />
+ )}
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteModal && (
+ setShowDeleteModal(false)}
+ onConfirm={handleDeleteHost}
+ isLoading={deleteHostMutation.isPending}
+ />
+ )}
+
+ );
+};
// Credentials Modal Component
const CredentialsModal = ({ host, isOpen, onClose }) => {
- const [showApiKey, setShowApiKey] = useState(false)
- const [activeTab, setActiveTab] = useState('quick-install')
+ const [showApiKey, setShowApiKey] = useState(false);
+ const [activeTab, setActiveTab] = useState("quick-install");
- const { data: serverUrlData } = useQuery({
- queryKey: ['serverUrl'],
- queryFn: () => settingsAPI.getServerUrl().then(res => res.data),
- })
+ const { data: serverUrlData } = useQuery({
+ queryKey: ["serverUrl"],
+ queryFn: () => settingsAPI.getServerUrl().then((res) => res.data),
+ });
- const serverUrl = serverUrlData?.server_url || 'http://localhost:3001'
+ const serverUrl = serverUrlData?.server_url || "http://localhost:3001";
- const copyToClipboard = async (text) => {
- try {
- // Try modern clipboard API first
- if (navigator.clipboard && window.isSecureContext) {
- await navigator.clipboard.writeText(text)
- return
- }
-
- // Fallback for older browsers or non-secure contexts
- const textArea = document.createElement('textarea')
- textArea.value = text
- textArea.style.position = 'fixed'
- textArea.style.left = '-999999px'
- textArea.style.top = '-999999px'
- document.body.appendChild(textArea)
- textArea.focus()
- textArea.select()
-
- try {
- const successful = document.execCommand('copy')
- if (!successful) {
- throw new Error('Copy command failed')
- }
- } catch (err) {
- // If all else fails, show the text in a prompt
- prompt('Copy this command:', text)
- } finally {
- document.body.removeChild(textArea)
- }
- } catch (err) {
- console.error('Failed to copy to clipboard:', err)
- // Show the text in a prompt as last resort
- prompt('Copy this command:', text)
- }
- }
+ const copyToClipboard = async (text) => {
+ try {
+ // Try modern clipboard API first
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
- const getSetupCommands = () => {
- // Get current time for crontab scheduling
- const now = new Date()
- const currentMinute = now.getMinutes()
- const currentHour = now.getHours()
-
- return `# Run this on the target host: ${host?.friendly_name}
+ // Fallback for older browsers or non-secure contexts
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+
+ try {
+ const successful = document.execCommand("copy");
+ if (!successful) {
+ throw new Error("Copy command failed");
+ }
+ } catch (err) {
+ // If all else fails, show the text in a prompt
+ prompt("Copy this command:", text);
+ } finally {
+ document.body.removeChild(textArea);
+ }
+ } catch (err) {
+ console.error("Failed to copy to clipboard:", err);
+ // Show the text in a prompt as last resort
+ prompt("Copy this command:", text);
+ }
+ };
+
+ const getSetupCommands = () => {
+ // Get current time for crontab scheduling
+ const now = new Date();
+ const currentMinute = now.getMinutes();
+ const currentHour = now.getHours();
+
+ return `# Run this on the target host: ${host?.friendly_name}
echo "🔄 Setting up PatchMon agent..."
@@ -855,336 +1061,403 @@ echo "📊 Sending initial package data..."
sudo /usr/local/bin/patchmon-agent.sh update
# Setup crontab starting at current time
-echo "⏰ Setting up hourly crontab starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')}..."
+echo "⏰ Setting up hourly crontab starting at ${currentHour.toString().padStart(2, "0")}:${currentMinute.toString().padStart(2, "0")}..."
echo "${currentMinute} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
echo "✅ PatchMon agent setup complete!"
echo " - Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " - Config directory: /etc/patchmon/"
-echo " - Updates: Every hour via crontab (starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')})"
-echo " - View logs: tail -f /var/log/patchmon-agent.log"`
- }
+echo " - Updates: Every hour via crontab (starting at ${currentHour.toString().padStart(2, "0")}:${currentMinute.toString().padStart(2, "0")})"
+echo " - View logs: tail -f /var/log/patchmon-agent.log"`;
+ };
- if (!isOpen || !host) return null
+ if (!isOpen || !host) return null;
- const commands = getSetupCommands()
+ const commands = getSetupCommands();
- return (
-
-
-
-
Host Setup - {host.friendly_name}
-
-
-
-
+ return (
+
+
+
+
+ Host Setup - {host.friendly_name}
+
+
+
+
+
- {/* Tabs */}
-
-
- setActiveTab('quick-install')}
- className={`py-2 px-1 border-b-2 font-medium text-sm ${
- activeTab === 'quick-install'
- ? 'border-primary-500 text-primary-600 dark:text-primary-400'
- : 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
- }`}
- >
- Quick Install
-
- setActiveTab('credentials')}
- className={`py-2 px-1 border-b-2 font-medium text-sm ${
- activeTab === 'credentials'
- ? 'border-primary-500 text-primary-600 dark:text-primary-400'
- : 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
- }`}
- >
- API Credentials
-
-
-
+ {/* Tabs */}
+
+
+ setActiveTab("quick-install")}
+ className={`py-2 px-1 border-b-2 font-medium text-sm ${
+ activeTab === "quick-install"
+ ? "border-primary-500 text-primary-600 dark:text-primary-400"
+ : "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
+ }`}
+ >
+ Quick Install
+
+ setActiveTab("credentials")}
+ className={`py-2 px-1 border-b-2 font-medium text-sm ${
+ activeTab === "credentials"
+ ? "border-primary-500 text-primary-600 dark:text-primary-400"
+ : "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
+ }`}
+ >
+ API Credentials
+
+
+
- {/* Tab Content */}
- {activeTab === 'quick-install' && (
-
-
-
One-Line Installation
-
- Copy and run this command on the target host to automatically install and configure the PatchMon agent:
-
-
-
- copyToClipboard(`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.api_id}" "${host.api_key}"`)}
- className="btn-primary flex items-center gap-1"
- >
-
- Copy
-
-
-
+ {/* Tab Content */}
+ {activeTab === "quick-install" && (
+
+
+
+ One-Line Installation
+
+
+ Copy and run this command on the target host to automatically
+ install and configure the PatchMon agent:
+
+
+
+
+ copyToClipboard(
+ `curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.api_id}" "${host.api_key}"`,
+ )
+ }
+ className="btn-primary flex items-center gap-1"
+ >
+
+ Copy
+
+
+
-
-
Manual Installation
-
- If you prefer to install manually, follow these steps:
-
-
-
-
1. Download Agent Script
-
-
- copyToClipboard(`curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download`)}
- className="btn-secondary flex items-center gap-1"
- >
-
- Copy
-
-
-
+
+
+ Manual Installation
+
+
+ If you prefer to install manually, follow these steps:
+
+
+
+
+ 1. Download Agent Script
+
+
+
+
+ copyToClipboard(
+ `curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download`,
+ )
+ }
+ className="btn-secondary flex items-center gap-1"
+ >
+
+ Copy
+
+
+
-
-
2. Install Agent
-
-
- copyToClipboard("sudo mkdir -p /etc/patchmon && sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh && sudo chmod +x /usr/local/bin/patchmon-agent.sh")}
- className="btn-secondary flex items-center gap-1"
- >
-
- Copy
-
-
-
+
+
+ 2. Install Agent
+
+
+
+
+ copyToClipboard(
+ "sudo mkdir -p /etc/patchmon && sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh && sudo chmod +x /usr/local/bin/patchmon-agent.sh",
+ )
+ }
+ className="btn-secondary flex items-center gap-1"
+ >
+
+ Copy
+
+
+
-
-
3. Configure Credentials
-
-
- copyToClipboard(`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}"`)}
- className="btn-secondary flex items-center gap-1"
- >
-
- Copy
-
-
-
+
+
+ 3. Configure Credentials
+
+
+
+
+ copyToClipboard(
+ `sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}"`,
+ )
+ }
+ className="btn-secondary flex items-center gap-1"
+ >
+
+ Copy
+
+
+
-
-
4. Test Configuration
-
-
- copyToClipboard("sudo /usr/local/bin/patchmon-agent.sh test")}
- className="btn-secondary flex items-center gap-1"
- >
-
- Copy
-
-
-
+
+
+ 4. Test Configuration
+
+
+
+
+ copyToClipboard(
+ "sudo /usr/local/bin/patchmon-agent.sh test",
+ )
+ }
+ className="btn-secondary flex items-center gap-1"
+ >
+
+ Copy
+
+
+
-
-
5. Send Initial Data
-
-
- copyToClipboard("sudo /usr/local/bin/patchmon-agent.sh update")}
- className="btn-secondary flex items-center gap-1"
- >
-
- Copy
-
-
-
+
+
+ 5. Send Initial Data
+
+
+
+
+ copyToClipboard(
+ "sudo /usr/local/bin/patchmon-agent.sh update",
+ )
+ }
+ className="btn-secondary flex items-center gap-1"
+ >
+
+ Copy
+
+
+
-
-
6. Setup Crontab (Optional)
-
- /dev/null 2>&1" | sudo crontab -`}
- readOnly
- className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
- />
- copyToClipboard(`echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`)}
- className="btn-secondary flex items-center gap-1"
- >
-
- Copy
-
-
-
-
-
-
- )}
+
+
+ 6. Setup Crontab (Optional)
+
+
+ /dev/null 2>&1" | sudo crontab -`}
+ readOnly
+ className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
+ />
+
+ copyToClipboard(
+ `echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`,
+ )
+ }
+ className="btn-secondary flex items-center gap-1"
+ >
+
+ Copy
+
+
+
+
+
+
+ )}
- {activeTab === 'credentials' && (
-
-
-
API Credentials
-
-
-
API ID
-
-
- copyToClipboard(host.api_id)}
- className="btn-outline flex items-center gap-1"
- >
-
- Copy
-
-
-
-
-
-
API Key
-
-
- setShowApiKey(!showApiKey)}
- className="btn-outline flex items-center gap-1"
- >
- {showApiKey ? : }
-
- copyToClipboard(host.api_key)}
- className="btn-outline flex items-center gap-1"
- >
-
- Copy
-
-
-
-
-
+ {activeTab === "credentials" && (
+
+
+
+ API Credentials
+
+
+
+
+ API ID
+
+
+
+ copyToClipboard(host.api_id)}
+ className="btn-outline flex items-center gap-1"
+ >
+
+ Copy
+
+
+
-
-
-
-
-
Security Notice
-
- Keep these credentials secure. They provide full access to this host's monitoring data.
-
-
-
-
-
- )}
+
+
+ API Key
+
+
+
+ setShowApiKey(!showApiKey)}
+ className="btn-outline flex items-center gap-1"
+ >
+ {showApiKey ? (
+
+ ) : (
+
+ )}
+
+ copyToClipboard(host.api_key)}
+ className="btn-outline flex items-center gap-1"
+ >
+
+ Copy
+
+
+
+
+
+
+
+
+
+
+ Security Notice
+
+
+ Keep these credentials secure. They provide full access to
+ this host's monitoring data.
+
+
+
+
+
+ )}
-
-
- Close
-
-
-
-
- )
-}
+
+
+ Close
+
+
+
+
+ );
+};
// Delete Confirmation Modal Component
-const DeleteConfirmationModal = ({ host, isOpen, onClose, onConfirm, isLoading }) => {
- if (!isOpen || !host) return null
+const DeleteConfirmationModal = ({
+ host,
+ isOpen,
+ onClose,
+ onConfirm,
+ isLoading,
+}) => {
+ if (!isOpen || !host) return null;
- return (
-
-
-
-
-
-
- Delete Host
-
-
- This action cannot be undone
-
-
-
-
-
-
- Are you sure you want to delete the host{' '}
- "{host.friendly_name}" ?
-
-
-
- Warning: This will permanently remove the host and all its associated data,
- including package information and update history.
-
-
-
-
-
-
- Cancel
-
-
- {isLoading ? 'Deleting...' : 'Delete Host'}
-
-
-
-
- )
-}
+ return (
+
+
+
+
+
+
+ Delete Host
+
+
+ This action cannot be undone
+
+
+
-export default HostDetail
+
+
+ Are you sure you want to delete the host{" "}
+ "{host.friendly_name}" ?
+
+
+
+ Warning: This will permanently remove the host
+ and all its associated data, including package information and
+ update history.
+
+
+
-
\ No newline at end of file
+
+
+ Cancel
+
+
+ {isLoading ? "Deleting..." : "Delete Host"}
+
+
+
+
+ );
+};
+
+export default HostDetail;
diff --git a/frontend/src/pages/HostGroups.jsx b/frontend/src/pages/HostGroups.jsx
index 1f4568c..8e67211 100644
--- a/frontend/src/pages/HostGroups.jsx
+++ b/frontend/src/pages/HostGroups.jsx
@@ -1,498 +1,501 @@
-import React, { useState } from 'react'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import {
- Plus,
- Edit,
- Trash2,
- Server,
- Users,
- AlertTriangle,
- CheckCircle
-} from 'lucide-react'
-import { hostGroupsAPI } from '../utils/api'
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ CheckCircle,
+ Edit,
+ Plus,
+ Server,
+ Trash2,
+ Users,
+} from "lucide-react";
+import React, { useState } from "react";
+import { hostGroupsAPI } from "../utils/api";
const HostGroups = () => {
- const [showCreateModal, setShowCreateModal] = useState(false)
- const [showEditModal, setShowEditModal] = useState(false)
- const [selectedGroup, setSelectedGroup] = useState(null)
- const [showDeleteModal, setShowDeleteModal] = useState(false)
- const [groupToDelete, setGroupToDelete] = useState(null)
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [selectedGroup, setSelectedGroup] = useState(null);
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [groupToDelete, setGroupToDelete] = useState(null);
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
- // Fetch host groups
- const { data: hostGroups, isLoading, error } = useQuery({
- queryKey: ['hostGroups'],
- queryFn: () => hostGroupsAPI.list().then(res => res.data),
- })
+ // Fetch host groups
+ const {
+ data: hostGroups,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["hostGroups"],
+ queryFn: () => hostGroupsAPI.list().then((res) => res.data),
+ });
- // Create host group mutation
- const createMutation = useMutation({
- mutationFn: (data) => hostGroupsAPI.create(data),
- onSuccess: () => {
- queryClient.invalidateQueries(['hostGroups'])
- setShowCreateModal(false)
- },
- onError: (error) => {
- console.error('Failed to create host group:', error)
- }
- })
+ // Create host group mutation
+ const createMutation = useMutation({
+ mutationFn: (data) => hostGroupsAPI.create(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["hostGroups"]);
+ setShowCreateModal(false);
+ },
+ onError: (error) => {
+ console.error("Failed to create host group:", error);
+ },
+ });
- // Update host group mutation
- const updateMutation = useMutation({
- mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
- onSuccess: () => {
- queryClient.invalidateQueries(['hostGroups'])
- setShowEditModal(false)
- setSelectedGroup(null)
- },
- onError: (error) => {
- console.error('Failed to update host group:', error)
- }
- })
+ // Update host group mutation
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["hostGroups"]);
+ setShowEditModal(false);
+ setSelectedGroup(null);
+ },
+ onError: (error) => {
+ console.error("Failed to update host group:", error);
+ },
+ });
- // Delete host group mutation
- const deleteMutation = useMutation({
- mutationFn: (id) => hostGroupsAPI.delete(id),
- onSuccess: () => {
- queryClient.invalidateQueries(['hostGroups'])
- setShowDeleteModal(false)
- setGroupToDelete(null)
- },
- onError: (error) => {
- console.error('Failed to delete host group:', error)
- }
- })
+ // Delete host group mutation
+ const deleteMutation = useMutation({
+ mutationFn: (id) => hostGroupsAPI.delete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["hostGroups"]);
+ setShowDeleteModal(false);
+ setGroupToDelete(null);
+ },
+ onError: (error) => {
+ console.error("Failed to delete host group:", error);
+ },
+ });
- const handleCreate = (data) => {
- createMutation.mutate(data)
- }
+ const handleCreate = (data) => {
+ createMutation.mutate(data);
+ };
- const handleEdit = (group) => {
- setSelectedGroup(group)
- setShowEditModal(true)
- }
+ const handleEdit = (group) => {
+ setSelectedGroup(group);
+ setShowEditModal(true);
+ };
- const handleUpdate = (data) => {
- updateMutation.mutate({ id: selectedGroup.id, data })
- }
+ const handleUpdate = (data) => {
+ updateMutation.mutate({ id: selectedGroup.id, data });
+ };
- const handleDeleteClick = (group) => {
- setGroupToDelete(group)
- setShowDeleteModal(true)
- }
+ const handleDeleteClick = (group) => {
+ setGroupToDelete(group);
+ setShowDeleteModal(true);
+ };
- const handleDeleteConfirm = () => {
- deleteMutation.mutate(groupToDelete.id)
- }
+ const handleDeleteConfirm = () => {
+ deleteMutation.mutate(groupToDelete.id);
+ };
- if (isLoading) {
- return (
-
- )
- }
+ if (isLoading) {
+ return (
+
+ );
+ }
- if (error) {
- return (
-
-
-
-
-
- Error loading host groups
-
-
- {error.message || 'Failed to load host groups'}
-
-
-
-
- )
- }
+ if (error) {
+ return (
+
+
+
+
+
+ Error loading host groups
+
+
+ {error.message || "Failed to load host groups"}
+
+
+
+
+ );
+ }
- return (
-
- {/* Header */}
-
-
-
- Organize your hosts into logical groups for better management
-
-
-
setShowCreateModal(true)}
- className="btn-primary flex items-center gap-2"
- >
-
- Create Group
-
-
+ return (
+
+ {/* Header */}
+
+
+
+ Organize your hosts into logical groups for better management
+
+
+
setShowCreateModal(true)}
+ className="btn-primary flex items-center gap-2"
+ >
+
+ Create Group
+
+
- {/* Host Groups Grid */}
- {hostGroups && hostGroups.length > 0 ? (
-
- {hostGroups.map((group) => (
-
-
-
-
-
-
- {group.name}
-
- {group.description && (
-
- {group.description}
-
- )}
-
-
-
- handleEdit(group)}
- className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
- title="Edit group"
- >
-
-
- handleDeleteClick(group)}
- className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
- title="Delete group"
- >
-
-
-
-
-
-
-
-
- {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}
-
-
-
- ))}
-
- ) : (
-
-
-
- No host groups yet
-
-
- Create your first host group to organize your hosts
-
-
setShowCreateModal(true)}
- className="btn-primary flex items-center gap-2 mx-auto"
- >
-
- Create Group
-
-
- )}
+ {/* Host Groups Grid */}
+ {hostGroups && hostGroups.length > 0 ? (
+
+ {hostGroups.map((group) => (
+
+
+
+
+
+
+ {group.name}
+
+ {group.description && (
+
+ {group.description}
+
+ )}
+
+
+
+ handleEdit(group)}
+ className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
+ title="Edit group"
+ >
+
+
+ handleDeleteClick(group)}
+ className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
+ title="Delete group"
+ >
+
+
+
+
- {/* Create Modal */}
- {showCreateModal && (
-
setShowCreateModal(false)}
- onSubmit={handleCreate}
- isLoading={createMutation.isPending}
- />
- )}
+
+
+
+
+ {group._count.hosts} host
+ {group._count.hosts !== 1 ? "s" : ""}
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ No host groups yet
+
+
+ Create your first host group to organize your hosts
+
+
setShowCreateModal(true)}
+ className="btn-primary flex items-center gap-2 mx-auto"
+ >
+
+ Create Group
+
+
+ )}
- {/* Edit Modal */}
- {showEditModal && selectedGroup && (
-
{
- setShowEditModal(false)
- setSelectedGroup(null)
- }}
- onSubmit={handleUpdate}
- isLoading={updateMutation.isPending}
- />
- )}
+ {/* Create Modal */}
+ {showCreateModal && (
+ setShowCreateModal(false)}
+ onSubmit={handleCreate}
+ isLoading={createMutation.isPending}
+ />
+ )}
- {/* Delete Confirmation Modal */}
- {showDeleteModal && groupToDelete && (
- {
- setShowDeleteModal(false)
- setGroupToDelete(null)
- }}
- onConfirm={handleDeleteConfirm}
- isLoading={deleteMutation.isPending}
- />
- )}
-
- )
-}
+ {/* Edit Modal */}
+ {showEditModal && selectedGroup && (
+
{
+ setShowEditModal(false);
+ setSelectedGroup(null);
+ }}
+ onSubmit={handleUpdate}
+ isLoading={updateMutation.isPending}
+ />
+ )}
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteModal && groupToDelete && (
+ {
+ setShowDeleteModal(false);
+ setGroupToDelete(null);
+ }}
+ onConfirm={handleDeleteConfirm}
+ isLoading={deleteMutation.isPending}
+ />
+ )}
+
+ );
+};
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
- const [formData, setFormData] = useState({
- name: '',
- description: '',
- color: '#3B82F6'
- })
+ const [formData, setFormData] = useState({
+ name: "",
+ description: "",
+ color: "#3B82F6",
+ });
- const handleSubmit = (e) => {
- e.preventDefault()
- onSubmit(formData)
- }
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSubmit(formData);
+ };
- const handleChange = (e) => {
- setFormData({
- ...formData,
- [e.target.name]: e.target.value
- })
- }
+ const handleChange = (e) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value,
+ });
+ };
- return (
-
-
-
- Create Host Group
-
-
-
-
-
- )
-}
+ return (
+
+
+
+ Create Host Group
+
+
+
+
+
+ Name *
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {isLoading ? "Creating..." : "Create Group"}
+
+
+
+
+
+ );
+};
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
- const [formData, setFormData] = useState({
- name: group.name,
- description: group.description || '',
- color: group.color || '#3B82F6'
- })
+ const [formData, setFormData] = useState({
+ name: group.name,
+ description: group.description || "",
+ color: group.color || "#3B82F6",
+ });
- const handleSubmit = (e) => {
- e.preventDefault()
- onSubmit(formData)
- }
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSubmit(formData);
+ };
- const handleChange = (e) => {
- setFormData({
- ...formData,
- [e.target.name]: e.target.value
- })
- }
+ const handleChange = (e) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value,
+ });
+ };
- return (
-
-
-
- Edit Host Group
-
-
-
-
-
- Name *
-
-
-
-
-
-
- Description
-
-
-
-
-
-
-
-
- Cancel
-
-
- {isLoading ? 'Updating...' : 'Update Group'}
-
-
-
-
-
- )
-}
+ return (
+
+
+
+ Edit Host Group
+
+
+
+
+
+ Name *
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {isLoading ? "Updating..." : "Update Group"}
+
+
+
+
+
+ );
+};
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
- return (
-
-
-
-
-
-
- Delete Host Group
-
-
- This action cannot be undone
-
-
-
-
-
-
- Are you sure you want to delete the host group{' '}
- "{group.name}" ?
-
- {group._count.hosts > 0 && (
-
-
- Warning: This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
- You must move or remove these hosts before deleting the group.
-
-
- )}
-
-
-
-
- Cancel
-
- 0}
- >
- {isLoading ? 'Deleting...' : 'Delete Group'}
-
-
-
-
- )
-}
+ return (
+
+
+
+
+
+
+ Delete Host Group
+
+
+ This action cannot be undone
+
+
+
-export default HostGroups
+
+
+ Are you sure you want to delete the host group{" "}
+ "{group.name}" ?
+
+ {group._count.hosts > 0 && (
+
+
+ Warning: This group contains{" "}
+ {group._count.hosts} host{group._count.hosts !== 1 ? "s" : ""}.
+ You must move or remove these hosts before deleting the group.
+
+
+ )}
+
+
+
+
+ Cancel
+
+ 0}
+ >
+ {isLoading ? "Deleting..." : "Delete Group"}
+
+
+
+
+ );
+};
+
+export default HostGroups;
diff --git a/frontend/src/pages/Hosts.jsx b/frontend/src/pages/Hosts.jsx
index 7a0387b..971464a 100644
--- a/frontend/src/pages/Hosts.jsx
+++ b/frontend/src/pages/Hosts.jsx
@@ -1,295 +1,326 @@
-import React, { useState, useEffect } from 'react'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { Link, useSearchParams, useNavigate } from 'react-router-dom'
-import {
- Server,
- AlertTriangle,
- CheckCircle,
- Clock,
- RefreshCw,
- ExternalLink,
- Plus,
- Key,
- Trash2,
- Copy,
- X,
- Eye,
- EyeOff,
- Users,
- CheckSquare,
- Square,
- Search,
- Filter,
- ArrowUpDown,
- ArrowUp,
- ArrowDown,
- ChevronDown,
- Settings,
- Columns,
- GripVertical,
- Eye as EyeIcon,
- EyeOff as EyeOffIcon
-} from 'lucide-react'
-import { dashboardAPI, adminHostsAPI, settingsAPI, hostGroupsAPI, formatRelativeTime } from '../utils/api'
-import { OSIcon } from '../utils/osIcons.jsx'
-import InlineEdit from '../components/InlineEdit'
-import InlineGroupEdit from '../components/InlineGroupEdit'
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ ArrowDown,
+ ArrowUp,
+ ArrowUpDown,
+ CheckCircle,
+ CheckSquare,
+ ChevronDown,
+ Clock,
+ Columns,
+ Copy,
+ ExternalLink,
+ Eye,
+ Eye as EyeIcon,
+ EyeOff,
+ EyeOff as EyeOffIcon,
+ Filter,
+ GripVertical,
+ Key,
+ Plus,
+ RefreshCw,
+ Search,
+ Server,
+ Settings,
+ Square,
+ Trash2,
+ Users,
+ X,
+} from "lucide-react";
+import React, { useEffect, useState } from "react";
+import { Link, useNavigate, useSearchParams } from "react-router-dom";
+import InlineEdit from "../components/InlineEdit";
+import InlineGroupEdit from "../components/InlineGroupEdit";
+import {
+ adminHostsAPI,
+ dashboardAPI,
+ formatRelativeTime,
+ hostGroupsAPI,
+ settingsAPI,
+} from "../utils/api";
+import { OSIcon } from "../utils/osIcons.jsx";
// Add Host Modal Component
const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
- const [formData, setFormData] = useState({
- friendly_name: '',
- hostGroupId: ''
- })
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [error, setError] = useState('')
+ const [formData, setFormData] = useState({
+ friendly_name: "",
+ hostGroupId: "",
+ });
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [error, setError] = useState("");
- // Fetch host groups for selection
- const { data: hostGroups } = useQuery({
- queryKey: ['hostGroups'],
- queryFn: () => hostGroupsAPI.list().then(res => res.data),
- enabled: isOpen
- })
+ // Fetch host groups for selection
+ const { data: hostGroups } = useQuery({
+ queryKey: ["hostGroups"],
+ queryFn: () => hostGroupsAPI.list().then((res) => res.data),
+ enabled: isOpen,
+ });
- const handleSubmit = async (e) => {
- e.preventDefault()
- setIsSubmitting(true)
- setError('')
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+ setError("");
- console.log('Submitting form data:', formData)
+ console.log("Submitting form data:", formData);
- try {
- const response = await adminHostsAPI.create(formData)
- console.log('Host created successfully:', response.data)
- onSuccess(response.data)
- setFormData({ friendly_name: '', hostGroupId: '' })
- onClose()
- } catch (err) {
- console.error('Full error object:', err)
- console.error('Error response:', err.response)
-
- let errorMessage = 'Failed to create host'
-
- if (err.response?.data?.errors) {
- // Validation errors
- errorMessage = err.response.data.errors.map(e => e.msg).join(', ')
- } else if (err.response?.data?.error) {
- // Single error message
- errorMessage = err.response.data.error
- } else if (err.message) {
- // Network or other error
- errorMessage = err.message
- }
-
- setError(errorMessage)
- } finally {
- setIsSubmitting(false)
- }
- }
+ try {
+ const response = await adminHostsAPI.create(formData);
+ console.log("Host created successfully:", response.data);
+ onSuccess(response.data);
+ setFormData({ friendly_name: "", hostGroupId: "" });
+ onClose();
+ } catch (err) {
+ console.error("Full error object:", err);
+ console.error("Error response:", err.response);
- if (!isOpen) return null
+ let errorMessage = "Failed to create host";
- return (
-
-
-
-
Add New Host
-
-
-
-
+ if (err.response?.data?.errors) {
+ // Validation errors
+ errorMessage = err.response.data.errors.map((e) => e.msg).join(", ");
+ } else if (err.response?.data?.error) {
+ // Single error message
+ errorMessage = err.response.data.error;
+ } else if (err.message) {
+ // Network or other error
+ errorMessage = err.message;
+ }
-
-
-
Friendly Name *
-
setFormData({ ...formData, friendly_name: e.target.value })}
- className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
- placeholder="server.example.com"
- />
-
- System information (OS, IP, architecture) will be automatically detected when the agent connects.
-
-
+ setError(errorMessage);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
-
-
Host Group
-
- {/* No Group Option */}
-
setFormData({ ...formData, host_group_id: '' })}
- className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
- formData.host_group_id === ''
- ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
- : 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
- }`}
- >
- No Group
- Ungrouped
- {formData.host_group_id === '' && (
-
- )}
-
-
- {/* Host Group Options */}
- {hostGroups?.map((group) => (
-
setFormData({ ...formData, host_group_id: group.id })}
- className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
- formData.host_group_id === group.id
- ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
- : 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
- }`}
- >
-
- {group.color && (
-
- )}
-
{group.name}
-
- Group
- {formData.host_group_id === group.id && (
-
- )}
-
- ))}
-
-
- Optional: Assign this host to a group for better organization.
-
-
+ if (!isOpen) return null;
- {error && (
-
- )}
+ return (
+
+
+
+
+ Add New Host
+
+
+
+
+
-
-
- Cancel
-
-
- {isSubmitting ? 'Creating...' : 'Create Host'}
-
-
-
-
-
- )
-}
+
+
+
+ Friendly Name *
+
+
+ setFormData({ ...formData, friendly_name: e.target.value })
+ }
+ className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
+ placeholder="server.example.com"
+ />
+
+ System information (OS, IP, architecture) will be automatically
+ detected when the agent connects.
+
+
+
+
+
+ Host Group
+
+
+ {/* No Group Option */}
+
setFormData({ ...formData, host_group_id: "" })}
+ className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
+ formData.host_group_id === ""
+ ? "border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300"
+ : "border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500"
+ }`}
+ >
+ No Group
+
+ Ungrouped
+
+ {formData.host_group_id === "" && (
+
+ )}
+
+
+ {/* Host Group Options */}
+ {hostGroups?.map((group) => (
+
+ setFormData({ ...formData, host_group_id: group.id })
+ }
+ className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
+ formData.host_group_id === group.id
+ ? "border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300"
+ : "border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500"
+ }`}
+ >
+
+ {group.color && (
+
+ )}
+
+ {group.name}
+
+
+
+ Group
+
+ {formData.host_group_id === group.id && (
+
+ )}
+
+ ))}
+
+
+ Optional: Assign this host to a group for better organization.
+
+
+
+ {error && (
+
+ )}
+
+
+
+ Cancel
+
+
+ {isSubmitting ? "Creating..." : "Create Host"}
+
+
+
+
+
+ );
+};
// Credentials Modal Component
const CredentialsModal = ({ host, isOpen, onClose }) => {
- const [showApiKey, setShowApiKey] = useState(false)
- const [activeTab, setActiveTab] = useState(host?.isNewHost ? 'quick' : 'credentials')
+ const [showApiKey, setShowApiKey] = useState(false);
+ const [activeTab, setActiveTab] = useState(
+ host?.isNewHost ? "quick" : "credentials",
+ );
- // Update active tab when host changes
- React.useEffect(() => {
- if (host?.isNewHost) {
- setActiveTab('quick')
- } else {
- setActiveTab('credentials')
- }
- }, [host?.isNewHost])
+ // Update active tab when host changes
+ React.useEffect(() => {
+ if (host?.isNewHost) {
+ setActiveTab("quick");
+ } else {
+ setActiveTab("credentials");
+ }
+ }, [host?.isNewHost]);
- const copyToClipboard = async (text, label) => {
- try {
- // Try modern clipboard API first
- if (navigator.clipboard && window.isSecureContext) {
- await navigator.clipboard.writeText(text)
- alert(`${label} copied to clipboard!`)
- return
- }
-
- // Fallback for older browsers or non-secure contexts
- const textArea = document.createElement('textarea')
- textArea.value = text
- textArea.style.position = 'fixed'
- textArea.style.left = '-999999px'
- textArea.style.top = '-999999px'
- document.body.appendChild(textArea)
- textArea.focus()
- textArea.select()
-
- try {
- const successful = document.execCommand('copy')
- if (successful) {
- alert(`${label} copied to clipboard!`)
- } else {
- throw new Error('Copy command failed')
- }
- } catch (err) {
- // If all else fails, show the text in a prompt
- prompt(`Copy this ${label.toLowerCase()}:`, text)
- } finally {
- document.body.removeChild(textArea)
- }
- } catch (err) {
- console.error('Failed to copy to clipboard:', err)
- // Show the text in a prompt as last resort
- prompt(`Copy this ${label.toLowerCase()}:`, text)
- }
- }
+ const copyToClipboard = async (text, label) => {
+ try {
+ // Try modern clipboard API first
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ alert(`${label} copied to clipboard!`);
+ return;
+ }
- // Fetch server URL from settings
- const { data: settings } = useQuery({
- queryKey: ['settings'],
- queryFn: () => settingsAPI.get().then(res => res.data),
- enabled: isOpen // Only fetch when modal is open
- })
+ // Fallback for older browsers or non-secure contexts
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
- const serverUrl = settings?.server_url || window.location.origin.replace(':3000', ':3001')
+ try {
+ const successful = document.execCommand("copy");
+ if (successful) {
+ alert(`${label} copied to clipboard!`);
+ } else {
+ throw new Error("Copy command failed");
+ }
+ } catch (err) {
+ // If all else fails, show the text in a prompt
+ prompt(`Copy this ${label.toLowerCase()}:`, text);
+ } finally {
+ document.body.removeChild(textArea);
+ }
+ } catch (err) {
+ console.error("Failed to copy to clipboard:", err);
+ // Show the text in a prompt as last resort
+ prompt(`Copy this ${label.toLowerCase()}:`, text);
+ }
+ };
- const getSetupCommands = () => {
- // Get current time for crontab scheduling
- const now = new Date()
- const currentMinute = now.getMinutes()
- const currentHour = now.getHours()
-
- return {
- oneLine: `curl -sSL ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host?.api_id}" "${host?.api_key}"`,
+ // Fetch server URL from settings
+ const { data: settings } = useQuery({
+ queryKey: ["settings"],
+ queryFn: () => settingsAPI.get().then((res) => res.data),
+ enabled: isOpen, // Only fetch when modal is open
+ });
- download: `# Download and setup PatchMon agent
+ const serverUrl =
+ settings?.server_url || window.location.origin.replace(":3000", ":3001");
+
+ const getSetupCommands = () => {
+ // Get current time for crontab scheduling
+ const now = new Date();
+ const currentMinute = now.getMinutes();
+ const currentHour = now.getHours();
+
+ return {
+ oneLine: `curl -sSL ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host?.api_id}" "${host?.api_key}"`,
+
+ download: `# Download and setup PatchMon agent
curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download
sudo mkdir -p /etc/patchmon
sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh
sudo chmod +x /usr/local/bin/patchmon-agent.sh`,
- configure: `# Configure API credentials
+ configure: `# Configure API credentials
sudo /usr/local/bin/patchmon-agent.sh configure "${host?.api_id}" "${host?.api_key}"`,
- test: `# Test the configuration
+ test: `# Test the configuration
sudo /usr/local/bin/patchmon-agent.sh test`,
- initialUpdate: `# Send initial package data
+ initialUpdate: `# Send initial package data
sudo /usr/local/bin/patchmon-agent.sh update`,
- crontab: `# Add to crontab for hourly updates starting at current time (run as root)
+ crontab: `# Add to crontab for hourly updates starting at current time (run as root)
echo "${currentMinute} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`,
- fullSetup: `#!/bin/bash
+ fullSetup: `#!/bin/bash
# Complete PatchMon Agent Setup Script
# Run this on the target host: ${host?.friendly_name}
@@ -315,1720 +346,1959 @@ echo "📊 Sending initial package data..."
sudo /usr/local/bin/patchmon-agent.sh update
# Setup crontab starting at current time
-echo "⏰ Setting up hourly crontab starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')}..."
+echo "⏰ Setting up hourly crontab starting at ${currentHour.toString().padStart(2, "0")}:${currentMinute.toString().padStart(2, "0")}..."
echo "${currentMinute} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
echo "✅ PatchMon agent setup complete!"
echo " - Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " - Config directory: /etc/patchmon/"
-echo " - Updates: Every hour via crontab (starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')})"
-echo " - View logs: tail -f /var/log/patchmon-agent.log"`
- }
- }
+echo " - Updates: Every hour via crontab (starting at ${currentHour.toString().padStart(2, "0")}:${currentMinute.toString().padStart(2, "0")})"
+echo " - View logs: tail -f /var/log/patchmon-agent.log"`,
+ };
+ };
- if (!isOpen || !host) return null
+ if (!isOpen || !host) return null;
- const commands = getSetupCommands()
+ const commands = getSetupCommands();
- return (
-
-
-
-
Host Setup - {host.friendly_name}
-
-
-
-
+ return (
+
+
+
+
+ Host Setup - {host.friendly_name}
+
+
+
+
+
- {/* Tabs */}
-
- setActiveTab('quick')}
- className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
- activeTab === 'quick'
- ? 'bg-white text-secondary-900 shadow-sm'
- : 'text-secondary-600 hover:text-secondary-900'
- }`}
- >
- Quick Install
-
- setActiveTab('credentials')}
- className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
- activeTab === 'credentials'
- ? 'bg-white text-secondary-900 shadow-sm'
- : 'text-secondary-600 hover:text-secondary-900'
- }`}
- >
- API Credentials
-
- setActiveTab('setup')}
- className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
- activeTab === 'setup'
- ? 'bg-white text-secondary-900 shadow-sm'
- : 'text-secondary-600 hover:text-secondary-900'
- }`}
- >
- Setup Instructions
-
- setActiveTab('script')}
- className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
- activeTab === 'script'
- ? 'bg-white text-secondary-900 shadow-sm'
- : 'text-secondary-600 hover:text-secondary-900'
- }`}
- >
- Auto-Setup Script
-
-
+ {/* Tabs */}
+
+ setActiveTab("quick")}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
+ activeTab === "quick"
+ ? "bg-white text-secondary-900 shadow-sm"
+ : "text-secondary-600 hover:text-secondary-900"
+ }`}
+ >
+ Quick Install
+
+ setActiveTab("credentials")}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
+ activeTab === "credentials"
+ ? "bg-white text-secondary-900 shadow-sm"
+ : "text-secondary-600 hover:text-secondary-900"
+ }`}
+ >
+ API Credentials
+
+ setActiveTab("setup")}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
+ activeTab === "setup"
+ ? "bg-white text-secondary-900 shadow-sm"
+ : "text-secondary-600 hover:text-secondary-900"
+ }`}
+ >
+ Setup Instructions
+
+ setActiveTab("script")}
+ className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
+ activeTab === "script"
+ ? "bg-white text-secondary-900 shadow-sm"
+ : "text-secondary-600 hover:text-secondary-900"
+ }`}
+ >
+ Auto-Setup Script
+
+
- {/* Tab Content */}
- {activeTab === 'quick' && (
-
-
-
🚀 One-Line Installation
-
- Copy and paste this single command on {host.friendly_name} to install and configure the PatchMon agent automatically.
-
-
+ {/* Tab Content */}
+ {activeTab === "quick" && (
+
+
+
+ 🚀 One-Line Installation
+
+
+ Copy and paste this single command on{" "}
+ {host.friendly_name} to install and configure
+ the PatchMon agent automatically.
+
+
-
-
Installation Command
-
-
- {commands.oneLine}
- copyToClipboard(commands.oneLine, 'Installation command')}
- className="ml-2 text-secondary-400 hover:text-secondary-200 flex-shrink-0"
- >
-
-
-
-
-
- This command will download, install, configure, and set up automatic updates for the PatchMon agent.
-
-
+
+
+ Installation Command
+
+
+
+
+ {commands.oneLine}
+
+
+ copyToClipboard(commands.oneLine, "Installation command")
+ }
+ className="ml-2 text-secondary-400 hover:text-secondary-200 flex-shrink-0"
+ >
+
+
+
+
+
+ This command will download, install, configure, and set up
+ automatic updates for the PatchMon agent.
+
+
-
-
📋 What This Command Does
-
- • Downloads the PatchMon installation script
- • Installs the agent to /usr/local/bin/patchmon-agent.sh
- • Configures API credentials for {host.friendly_name}
- • Tests the connection to PatchMon server
- • Sends initial package data
- • Sets up hourly automatic updates via crontab
-
-
+
+
+ 📋 What This Command Does
+
+
+ • Downloads the PatchMon installation script
+
+ • Installs the agent to{" "}
+ /usr/local/bin/patchmon-agent.sh
+
+
+ • Configures API credentials for{" "}
+ {host.friendly_name}
+
+ • Tests the connection to PatchMon server
+ • Sends initial package data
+ • Sets up hourly automatic updates via crontab
+
+
-
-
⚠️ Requirements
-
- • Must be run as root (use sudo)
- • Requires internet connection to download agent
- • Requires curl and bash to be installed
- • Host must be able to reach the PatchMon server
-
-
-
- )}
+
+
+ ⚠️ Requirements
+
+
+
+ • Must be run as root (use sudo)
+
+ • Requires internet connection to download agent
+
+ • Requires curl and bash to be
+ installed
+
+ • Host must be able to reach the PatchMon server
+
+
+
+ )}
- {activeTab === 'credentials' && (
-
-
-
API ID
-
-
- copyToClipboard(host.apiId, 'API ID')}
- className="px-3 py-2 border border-l-0 border-secondary-300 rounded-r-md bg-secondary-50 hover:bg-secondary-100"
- >
-
-
-
-
+ {activeTab === "credentials" && (
+
+
+
+ API ID
+
+
+
+ copyToClipboard(host.apiId, "API ID")}
+ className="px-3 py-2 border border-l-0 border-secondary-300 rounded-r-md bg-secondary-50 hover:bg-secondary-100"
+ >
+
+
+
+
-
-
API Key
-
-
- setShowApiKey(!showApiKey)}
- className="px-3 py-2 border border-l-0 border-r-0 border-secondary-300 bg-secondary-50 hover:bg-secondary-100"
- >
- {showApiKey ? : }
-
- copyToClipboard(host.apiKey, 'API Key')}
- className="px-3 py-2 border border-l-0 border-secondary-300 rounded-r-md bg-secondary-50 hover:bg-secondary-100"
- >
-
-
-
-
+
+
+ API Key
+
+
+
+ setShowApiKey(!showApiKey)}
+ className="px-3 py-2 border border-l-0 border-r-0 border-secondary-300 bg-secondary-50 hover:bg-secondary-100"
+ >
+ {showApiKey ? (
+
+ ) : (
+
+ )}
+
+ copyToClipboard(host.apiKey, "API Key")}
+ className="px-3 py-2 border border-l-0 border-secondary-300 rounded-r-md bg-secondary-50 hover:bg-secondary-100"
+ >
+
+
+
+
-
-
⚠️ Security Note
-
- Keep these credentials secure. They provide access to update package information for {host.friendly_name} only.
-
-
-
- )}
+
+
+ ⚠️ Security Note
+
+
+ Keep these credentials secure. They provide access to update
+ package information for {host.friendly_name} {" "}
+ only.
+
+
+
+ )}
- {activeTab === 'setup' && (
-
-
-
📋 Step-by-Step Setup
-
- Follow these commands on {host.friendly_name} to install and configure the PatchMon agent.
-
-
+ {activeTab === "setup" && (
+
+
+
+ 📋 Step-by-Step Setup
+
+
+ Follow these commands on {host.friendly_name} {" "}
+ to install and configure the PatchMon agent.
+
+
- {/* Step 1: Download & Install */}
-
-
Step 1: Download & Install Agent
-
-
- {commands.download}
- copyToClipboard(commands.download, 'Download commands')}
- className="ml-2 text-secondary-400 hover:text-secondary-200 flex-shrink-0"
- >
-
-
-
-
-
+ {/* Step 1: Download & Install */}
+
+
+ Step 1: Download & Install Agent
+
+
+
+
+ {commands.download}
+
+
+ copyToClipboard(commands.download, "Download commands")
+ }
+ className="ml-2 text-secondary-400 hover:text-secondary-200 flex-shrink-0"
+ >
+
+
+
+
+
- {/* Step 2: Configure */}
-
-
Step 2: Configure API Credentials
-
-
- {commands.configure}
- copyToClipboard(commands.configure, 'Configure command')}
- className="ml-2 text-secondary-400 hover:text-secondary-200"
- >
-
-
-
-
-
+ {/* Step 2: Configure */}
+
+
+ Step 2: Configure API Credentials
+
+
+
+ {commands.configure}
+
+ copyToClipboard(commands.configure, "Configure command")
+ }
+ className="ml-2 text-secondary-400 hover:text-secondary-200"
+ >
+
+
+
+
+
- {/* Step 3: Test */}
-
-
Step 3: Test Configuration
-
-
- {commands.test}
- copyToClipboard(commands.test, 'Test command')}
- className="ml-2 text-secondary-400 hover:text-secondary-200"
- >
-
-
-
-
-
+ {/* Step 3: Test */}
+
+
+ Step 3: Test Configuration
+
+
+
+ {commands.test}
+
+ copyToClipboard(commands.test, "Test command")
+ }
+ className="ml-2 text-secondary-400 hover:text-secondary-200"
+ >
+
+
+
+
+
- {/* Step 4: Initial Update */}
-
-
Step 4: Send Initial Package Data
-
- This will automatically detect and send system information (OS, IP, architecture) along with package data.
-
-
-
- {commands.initialUpdate}
- copyToClipboard(commands.initialUpdate, 'Initial update command')}
- className="ml-2 text-secondary-400 hover:text-secondary-200"
- >
-
-
-
-
-
+ {/* Step 4: Initial Update */}
+
+
+ Step 4: Send Initial Package Data
+
+
+ This will automatically detect and send system information (OS,
+ IP, architecture) along with package data.
+
+
+
+ {commands.initialUpdate}
+
+ copyToClipboard(
+ commands.initialUpdate,
+ "Initial update command",
+ )
+ }
+ className="ml-2 text-secondary-400 hover:text-secondary-200"
+ >
+
+
+
+
+
- {/* Step 5: Crontab */}
-
-
Step 5: Setup Hourly Updates
-
-
- {commands.crontab}
- copyToClipboard(commands.crontab, 'Crontab command')}
- className="ml-2 text-secondary-400 hover:text-secondary-200"
- >
-
-
-
-
-
- This sets up automatic package updates every hour at the top of the hour.
-
-
-
- )}
+ {/* Step 5: Crontab */}
+
+
+ Step 5: Setup Hourly Updates
+
+
+
+ {commands.crontab}
+
+ copyToClipboard(commands.crontab, "Crontab command")
+ }
+ className="ml-2 text-secondary-400 hover:text-secondary-200"
+ >
+
+
+
+
+
+ This sets up automatic package updates every hour at the top of
+ the hour.
+
+
+
+ )}
- {activeTab === 'script' && (
-
-
-
🚀 Automated Setup
-
- Copy this complete setup script to {host.friendly_name} and run it to automatically install and configure everything.
-
-
+ {activeTab === "script" && (
+
+
+
+ 🚀 Automated Setup
+
+
+ Copy this complete setup script to{" "}
+ {host.friendly_name} and run it to
+ automatically install and configure everything.
+
+
-
-
-
Complete Setup Script
- copyToClipboard(commands.fullSetup, 'Complete setup script')}
- className="px-3 py-1 bg-primary-600 text-white rounded text-sm hover:bg-primary-700 flex items-center gap-2"
- >
-
- Copy Script
-
-
-
-
-
Usage:
-
1. Copy the script above
-
2. Save it to a file on {host.friendly_name} (e.g., setup-patchmon.sh)
-
3. Run: chmod +x setup-patchmon.sh && sudo ./setup-patchmon.sh
-
-
-
- )}
+
+
+
+ Complete Setup Script
+
+
+ copyToClipboard(commands.fullSetup, "Complete setup script")
+ }
+ className="px-3 py-1 bg-primary-600 text-white rounded text-sm hover:bg-primary-700 flex items-center gap-2"
+ >
+
+ Copy Script
+
+
+
+
+
+ Usage:
+
+
1. Copy the script above
+
+ 2. Save it to a file on {host.friendly_name} (e.g.,{" "}
+ setup-patchmon.sh)
+
+
+ 3. Run:{" "}
+
+ chmod +x setup-patchmon.sh && sudo ./setup-patchmon.sh
+
+
+
+
+
+ )}
-
-
-
- )
-}
+
+
+
+ );
+};
const Hosts = () => {
- const [showAddModal, setShowAddModal] = useState(false)
- const [selectedHosts, setSelectedHosts] = useState([])
- const [showBulkAssignModal, setShowBulkAssignModal] = useState(false)
- const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
- const [searchParams] = useSearchParams()
- const navigate = useNavigate()
-
- // Table state
- const [searchTerm, setSearchTerm] = useState('')
- const [sortField, setSortField] = useState('hostname')
- const [sortDirection, setSortDirection] = useState('asc')
- const [groupFilter, setGroupFilter] = useState('all')
- const [statusFilter, setStatusFilter] = useState('all')
- const [osFilter, setOsFilter] = useState('all')
- const [showFilters, setShowFilters] = useState(false)
- const [groupBy, setGroupBy] = useState('none')
- const [showColumnSettings, setShowColumnSettings] = useState(false)
- const [hideStale, setHideStale] = useState(false)
+ const [showAddModal, setShowAddModal] = useState(false);
+ const [selectedHosts, setSelectedHosts] = useState([]);
+ const [showBulkAssignModal, setShowBulkAssignModal] = useState(false);
+ const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
- // Handle URL filter parameters
- useEffect(() => {
- const filter = searchParams.get('filter')
- const showFiltersParam = searchParams.get('showFilters')
- const osFilterParam = searchParams.get('osFilter')
-
- if (filter === 'needsUpdates') {
- setShowFilters(true)
- setStatusFilter('all')
- // We'll filter hosts with updates > 0 in the filtering logic
- } else if (filter === 'inactive') {
- setShowFilters(true)
- setStatusFilter('inactive')
- // We'll filter hosts with inactive status in the filtering logic
- } else if (filter === 'upToDate') {
- setShowFilters(true)
- setStatusFilter('active')
- // We'll filter hosts that are up to date in the filtering logic
- } else if (filter === 'stale') {
- setShowFilters(true)
- setStatusFilter('all')
- // We'll filter hosts that are stale in the filtering logic
- } else if (showFiltersParam === 'true') {
- setShowFilters(true)
- }
-
- // Handle OS filter parameter
- if (osFilterParam) {
- setShowFilters(true)
- setOsFilter(osFilterParam)
- }
-
- // Handle add host action from navigation
- const action = searchParams.get('action')
- if (action === 'add') {
- setShowAddModal(true)
- // Remove the action parameter from URL without triggering a page reload
- const newSearchParams = new URLSearchParams(searchParams)
- newSearchParams.delete('action')
- navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true })
- }
+ // Table state
+ const [searchTerm, setSearchTerm] = useState("");
+ const [sortField, setSortField] = useState("hostname");
+ const [sortDirection, setSortDirection] = useState("asc");
+ const [groupFilter, setGroupFilter] = useState("all");
+ const [statusFilter, setStatusFilter] = useState("all");
+ const [osFilter, setOsFilter] = useState("all");
+ const [showFilters, setShowFilters] = useState(false);
+ const [groupBy, setGroupBy] = useState("none");
+ const [showColumnSettings, setShowColumnSettings] = useState(false);
+ const [hideStale, setHideStale] = useState(false);
- // Handle selected hosts from packages page
- const selected = searchParams.get('selected')
- if (selected) {
- const hostIds = selected.split(',').filter(Boolean)
- setSelectedHosts(hostIds)
- // Remove the selected parameter from URL without triggering a page reload
- const newSearchParams = new URLSearchParams(searchParams)
- newSearchParams.delete('selected')
- navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true })
- }
- }, [searchParams, navigate])
+ // Handle URL filter parameters
+ useEffect(() => {
+ const filter = searchParams.get("filter");
+ const showFiltersParam = searchParams.get("showFilters");
+ const osFilterParam = searchParams.get("osFilter");
- // Column configuration
- const [columnConfig, setColumnConfig] = useState(() => {
- const defaultConfig = [
- { id: 'select', label: 'Select', visible: true, order: 0 },
- { id: 'host', label: 'Friendly Name', visible: true, order: 1 },
- { id: 'ip', label: 'IP Address', visible: false, order: 2 },
- { id: 'group', label: 'Group', visible: true, order: 3 },
- { id: 'os', label: 'OS', visible: true, order: 4 },
- { id: 'os_version', label: 'OS Version', visible: false, order: 5 },
- { id: 'agent_version', label: 'Agent Version', visible: true, order: 6 },
- { id: 'auto_update', label: 'Auto-update', visible: true, order: 7 },
- { id: 'status', label: 'Status', visible: true, order: 8 },
- { id: 'updates', label: 'Updates', visible: true, order: 9 },
- { id: 'last_update', label: 'Last Update', visible: true, order: 10 },
- { id: 'actions', label: 'Actions', visible: true, order: 11 }
- ]
+ if (filter === "needsUpdates") {
+ setShowFilters(true);
+ setStatusFilter("all");
+ // We'll filter hosts with updates > 0 in the filtering logic
+ } else if (filter === "inactive") {
+ setShowFilters(true);
+ setStatusFilter("inactive");
+ // We'll filter hosts with inactive status in the filtering logic
+ } else if (filter === "upToDate") {
+ setShowFilters(true);
+ setStatusFilter("active");
+ // We'll filter hosts that are up to date in the filtering logic
+ } else if (filter === "stale") {
+ setShowFilters(true);
+ setStatusFilter("all");
+ // We'll filter hosts that are stale in the filtering logic
+ } else if (showFiltersParam === "true") {
+ setShowFilters(true);
+ }
- const saved = localStorage.getItem('hosts-column-config')
- if (saved) {
- try {
- const savedConfig = JSON.parse(saved)
-
- // Check if we have old camelCase column IDs that need to be migrated
- const hasOldColumns = savedConfig.some(col =>
- col.id === 'agentVersion' || col.id === 'autoUpdate' || col.id === 'osVersion' || col.id === 'lastUpdate'
- )
-
- if (hasOldColumns) {
- // Clear the old configuration and use the default snake_case configuration
- localStorage.removeItem('hosts-column-config')
- return defaultConfig
- } else {
- // Use the existing configuration
- return savedConfig
- }
- } catch (error) {
- // If there's an error parsing the config, clear it and use default
- localStorage.removeItem('hosts-column-config')
- return defaultConfig
- }
- }
-
- return defaultConfig
- })
-
- const queryClient = useQueryClient()
+ // Handle OS filter parameter
+ if (osFilterParam) {
+ setShowFilters(true);
+ setOsFilter(osFilterParam);
+ }
- const { data: hosts, isLoading, error, refetch, isFetching } = useQuery({
- queryKey: ['hosts'],
- queryFn: () => dashboardAPI.getHosts().then(res => res.data),
- staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
- refetchOnWindowFocus: false, // Don't refetch when window regains focus
- })
+ // Handle add host action from navigation
+ const action = searchParams.get("action");
+ if (action === "add") {
+ setShowAddModal(true);
+ // Remove the action parameter from URL without triggering a page reload
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.delete("action");
+ navigate(
+ `/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`,
+ { replace: true },
+ );
+ }
- const { data: hostGroups } = useQuery({
- queryKey: ['hostGroups'],
- queryFn: () => hostGroupsAPI.list().then(res => res.data),
- })
+ // Handle selected hosts from packages page
+ const selected = searchParams.get("selected");
+ if (selected) {
+ const hostIds = selected.split(",").filter(Boolean);
+ setSelectedHosts(hostIds);
+ // Remove the selected parameter from URL without triggering a page reload
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.delete("selected");
+ navigate(
+ `/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ""}`,
+ { replace: true },
+ );
+ }
+ }, [searchParams, navigate]);
- const bulkUpdateGroupMutation = useMutation({
- mutationFn: ({ hostIds, hostGroupId }) => adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId),
- onSuccess: (data) => {
- console.log('bulkUpdateGroupMutation success:', data);
-
- // Update the cache with the new host data
- if (data && data.hosts) {
- queryClient.setQueryData(['hosts'], (oldData) => {
- if (!oldData) return oldData;
- return oldData.map(host => {
- const updatedHost = data.hosts.find(h => h.id === host.id);
- if (updatedHost) {
- // Ensure hostGroupId is set correctly
- return {
- ...updatedHost,
- hostGroupId: updatedHost.host_groups?.id || null
- };
- }
- return host;
- });
- });
- }
-
- // Also invalidate to ensure consistency
- queryClient.invalidateQueries(['hosts'])
- setSelectedHosts([])
- setShowBulkAssignModal(false)
- },
- })
+ // Column configuration
+ const [columnConfig, setColumnConfig] = useState(() => {
+ const defaultConfig = [
+ { id: "select", label: "Select", visible: true, order: 0 },
+ { id: "host", label: "Friendly Name", visible: true, order: 1 },
+ { id: "ip", label: "IP Address", visible: false, order: 2 },
+ { id: "group", label: "Group", visible: true, order: 3 },
+ { id: "os", label: "OS", visible: true, order: 4 },
+ { id: "os_version", label: "OS Version", visible: false, order: 5 },
+ { id: "agent_version", label: "Agent Version", visible: true, order: 6 },
+ { id: "auto_update", label: "Auto-update", visible: true, order: 7 },
+ { id: "status", label: "Status", visible: true, order: 8 },
+ { id: "updates", label: "Updates", visible: true, order: 9 },
+ { id: "last_update", label: "Last Update", visible: true, order: 10 },
+ { id: "actions", label: "Actions", visible: true, order: 11 },
+ ];
- // Toggle auto-update mutation
- const toggleAutoUpdateMutation = useMutation({
- mutationFn: ({ hostId, auto_update }) => adminHostsAPI.toggleAutoUpdate(hostId, auto_update).then(res => res.data),
- onSuccess: () => {
- queryClient.invalidateQueries(['hosts'])
- }
- })
+ const saved = localStorage.getItem("hosts-column-config");
+ if (saved) {
+ try {
+ const savedConfig = JSON.parse(saved);
- const updateFriendlyNameMutation = useMutation({
- mutationFn: ({ hostId, friendlyName }) => adminHostsAPI.updateFriendlyName(hostId, friendlyName).then(res => res.data),
- onSuccess: () => {
- queryClient.invalidateQueries(['hosts'])
- }
- })
+ // Check if we have old camelCase column IDs that need to be migrated
+ const hasOldColumns = savedConfig.some(
+ (col) =>
+ col.id === "agentVersion" ||
+ col.id === "autoUpdate" ||
+ col.id === "osVersion" ||
+ col.id === "lastUpdate",
+ );
- const updateHostGroupMutation = useMutation({
- mutationFn: ({ hostId, hostGroupId }) => {
- console.log('updateHostGroupMutation called with:', { hostId, hostGroupId });
- return adminHostsAPI.updateGroup(hostId, hostGroupId).then(res => {
- console.log('updateGroup API response:', res);
- return res.data;
- });
- },
- onSuccess: (data) => {
-
- // Update the cache with the new host data
- queryClient.setQueryData(['hosts'], (oldData) => {
- console.log('Old cache data before update:', oldData);
- if (!oldData) return oldData;
- const updatedData = oldData.map(host => {
- if (host.id === data.host.id) {
- console.log('Updating host in cache:', host.id, 'with new data:', data.host);
- // Ensure hostGroupId is set correctly
- const updatedHost = {
- ...data.host,
- hostGroupId: data.host.host_groups?.id || null
- };
- console.log('Updated host with hostGroupId:', updatedHost);
- return updatedHost;
- }
- return host;
- });
- console.log('New cache data after update:', updatedData);
- return updatedData;
- });
-
- // Also invalidate to ensure consistency
- queryClient.invalidateQueries(['hosts'])
- },
- onError: (error) => {
- console.error('updateHostGroupMutation error:', error);
- }
- })
+ if (hasOldColumns) {
+ // Clear the old configuration and use the default snake_case configuration
+ localStorage.removeItem("hosts-column-config");
+ return defaultConfig;
+ } else {
+ // Use the existing configuration
+ return savedConfig;
+ }
+ } catch (error) {
+ // If there's an error parsing the config, clear it and use default
+ localStorage.removeItem("hosts-column-config");
+ return defaultConfig;
+ }
+ }
- const bulkDeleteMutation = useMutation({
- mutationFn: (hostIds) => adminHostsAPI.deleteBulk(hostIds),
- onSuccess: (data) => {
- console.log('Bulk delete success:', data);
- queryClient.invalidateQueries(['hosts']);
- setSelectedHosts([]);
- setShowBulkDeleteModal(false);
- },
- onError: (error) => {
- console.error('Bulk delete error:', error);
- }
- });
+ return defaultConfig;
+ });
- // Helper functions for bulk selection
- const handleSelectHost = (hostId) => {
- setSelectedHosts(prev =>
- prev.includes(hostId)
- ? prev.filter(id => id !== hostId)
- : [...prev, hostId]
- )
- }
+ const queryClient = useQueryClient();
- const handleSelectAll = () => {
- if (selectedHosts.length === hosts.length) {
- setSelectedHosts([])
- } else {
- setSelectedHosts(hosts.map(host => host.id))
- }
- }
+ const {
+ data: hosts,
+ isLoading,
+ error,
+ refetch,
+ isFetching,
+ } = useQuery({
+ queryKey: ["hosts"],
+ queryFn: () => dashboardAPI.getHosts().then((res) => res.data),
+ staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
+ refetchOnWindowFocus: false, // Don't refetch when window regains focus
+ });
- const handleBulkAssign = (hostGroupId) => {
- bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, hostGroupId })
- }
+ const { data: hostGroups } = useQuery({
+ queryKey: ["hostGroups"],
+ queryFn: () => hostGroupsAPI.list().then((res) => res.data),
+ });
- const handleBulkDelete = () => {
- bulkDeleteMutation.mutate(selectedHosts)
- }
+ const bulkUpdateGroupMutation = useMutation({
+ mutationFn: ({ hostIds, hostGroupId }) =>
+ adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId),
+ onSuccess: (data) => {
+ console.log("bulkUpdateGroupMutation success:", data);
- // Table filtering and sorting logic
- const filteredAndSortedHosts = React.useMemo(() => {
- if (!hosts) return []
+ // Update the cache with the new host data
+ if (data && data.hosts) {
+ queryClient.setQueryData(["hosts"], (oldData) => {
+ if (!oldData) return oldData;
+ return oldData.map((host) => {
+ const updatedHost = data.hosts.find((h) => h.id === host.id);
+ if (updatedHost) {
+ // Ensure hostGroupId is set correctly
+ return {
+ ...updatedHost,
+ hostGroupId: updatedHost.host_groups?.id || null,
+ };
+ }
+ return host;
+ });
+ });
+ }
- let filtered = hosts.filter(host => {
- // Search filter
- const matchesSearch = searchTerm === '' ||
- host.friendly_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
- host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
- host.os_type?.toLowerCase().includes(searchTerm.toLowerCase())
+ // Also invalidate to ensure consistency
+ queryClient.invalidateQueries(["hosts"]);
+ setSelectedHosts([]);
+ setShowBulkAssignModal(false);
+ },
+ });
- // Group filter
- const matchesGroup = groupFilter === 'all' ||
- (groupFilter === 'ungrouped' && !host.host_groups) ||
- (groupFilter !== 'ungrouped' && host.host_groups?.id === groupFilter)
+ // Toggle auto-update mutation
+ const toggleAutoUpdateMutation = useMutation({
+ mutationFn: ({ hostId, auto_update }) =>
+ adminHostsAPI
+ .toggleAutoUpdate(hostId, auto_update)
+ .then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["hosts"]);
+ },
+ });
- // Status filter
- const matchesStatus = statusFilter === 'all' || (host.effectiveStatus || host.status) === statusFilter
+ const updateFriendlyNameMutation = useMutation({
+ mutationFn: ({ hostId, friendlyName }) =>
+ adminHostsAPI
+ .updateFriendlyName(hostId, friendlyName)
+ .then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["hosts"]);
+ },
+ });
- // OS filter
- const matchesOs = osFilter === 'all' || host.os_type?.toLowerCase() === osFilter.toLowerCase()
+ const updateHostGroupMutation = useMutation({
+ mutationFn: ({ hostId, hostGroupId }) => {
+ console.log("updateHostGroupMutation called with:", {
+ hostId,
+ hostGroupId,
+ });
+ return adminHostsAPI.updateGroup(hostId, hostGroupId).then((res) => {
+ console.log("updateGroup API response:", res);
+ return res.data;
+ });
+ },
+ onSuccess: (data) => {
+ // Update the cache with the new host data
+ queryClient.setQueryData(["hosts"], (oldData) => {
+ console.log("Old cache data before update:", oldData);
+ if (!oldData) return oldData;
+ const updatedData = oldData.map((host) => {
+ if (host.id === data.host.id) {
+ console.log(
+ "Updating host in cache:",
+ host.id,
+ "with new data:",
+ data.host,
+ );
+ // Ensure hostGroupId is set correctly
+ const updatedHost = {
+ ...data.host,
+ hostGroupId: data.host.host_groups?.id || null,
+ };
+ console.log("Updated host with hostGroupId:", updatedHost);
+ return updatedHost;
+ }
+ return host;
+ });
+ console.log("New cache data after update:", updatedData);
+ return updatedData;
+ });
- // URL filter for hosts needing updates, inactive hosts, up-to-date hosts, or stale hosts
- const filter = searchParams.get('filter')
- const matchesUrlFilter =
- (filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)) &&
- (filter !== 'inactive' || (host.effectiveStatus || host.status) === 'inactive') &&
- (filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0)) &&
- (filter !== 'stale' || host.isStale)
+ // Also invalidate to ensure consistency
+ queryClient.invalidateQueries(["hosts"]);
+ },
+ onError: (error) => {
+ console.error("updateHostGroupMutation error:", error);
+ },
+ });
- // Hide stale filter
- const matchesHideStale = !hideStale || !host.isStale
+ const bulkDeleteMutation = useMutation({
+ mutationFn: (hostIds) => adminHostsAPI.deleteBulk(hostIds),
+ onSuccess: (data) => {
+ console.log("Bulk delete success:", data);
+ queryClient.invalidateQueries(["hosts"]);
+ setSelectedHosts([]);
+ setShowBulkDeleteModal(false);
+ },
+ onError: (error) => {
+ console.error("Bulk delete error:", error);
+ },
+ });
- return matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter && matchesHideStale
- })
+ // Helper functions for bulk selection
+ const handleSelectHost = (hostId) => {
+ setSelectedHosts((prev) =>
+ prev.includes(hostId)
+ ? prev.filter((id) => id !== hostId)
+ : [...prev, hostId],
+ );
+ };
- // Sorting
- filtered.sort((a, b) => {
- let aValue, bValue
-
- switch (sortField) {
- case 'friendlyName':
- aValue = a.friendly_name.toLowerCase()
- bValue = b.friendly_name.toLowerCase()
- break
- case 'hostname':
- aValue = a.hostname?.toLowerCase() || 'zzz_no_hostname'
- bValue = b.hostname?.toLowerCase() || 'zzz_no_hostname'
- break
- case 'ip':
- aValue = a.ip?.toLowerCase() || 'zzz_no_ip'
- bValue = b.ip?.toLowerCase() || 'zzz_no_ip'
- break
- case 'group':
- aValue = a.host_groups?.name || 'zzz_ungrouped'
- bValue = b.host_groups?.name || 'zzz_ungrouped'
- break
- case 'os':
- aValue = a.os_type?.toLowerCase() || 'zzz_unknown'
- bValue = b.os_type?.toLowerCase() || 'zzz_unknown'
- break
- case 'os_version':
- aValue = a.os_version?.toLowerCase() || 'zzz_unknown'
- bValue = b.os_version?.toLowerCase() || 'zzz_unknown'
- break
- case 'agent_version':
- aValue = a.agent_version?.toLowerCase() || 'zzz_no_version'
- bValue = b.agent_version?.toLowerCase() || 'zzz_no_version'
- break
- case 'status':
- aValue = a.effectiveStatus || a.status
- bValue = b.effectiveStatus || b.status
- break
- case 'updates':
- aValue = a.updatesCount || 0
- bValue = b.updatesCount || 0
- break
- case 'last_update':
- aValue = new Date(a.last_update)
- bValue = new Date(b.last_update)
- break
- default:
- aValue = a[sortField]
- bValue = b[sortField]
- }
+ const handleSelectAll = () => {
+ if (selectedHosts.length === hosts.length) {
+ setSelectedHosts([]);
+ } else {
+ setSelectedHosts(hosts.map((host) => host.id));
+ }
+ };
- if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1
- if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1
- return 0
- })
+ const handleBulkAssign = (hostGroupId) => {
+ bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, hostGroupId });
+ };
- return filtered
- }, [hosts, searchTerm, groupFilter, statusFilter, osFilter, sortField, sortDirection, searchParams])
+ const handleBulkDelete = () => {
+ bulkDeleteMutation.mutate(selectedHosts);
+ };
- // Group hosts by selected field
- const groupedHosts = React.useMemo(() => {
- if (groupBy === 'none') {
- return { 'All Hosts': filteredAndSortedHosts }
- }
+ // Table filtering and sorting logic
+ const filteredAndSortedHosts = React.useMemo(() => {
+ if (!hosts) return [];
- const groups = {}
- filteredAndSortedHosts.forEach(host => {
- let groupKey
- switch (groupBy) {
- case 'group':
- groupKey = host.host_groups?.name || 'Ungrouped'
- break
- case 'status':
- groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)
- break
- case 'os':
- groupKey = host.os_type || 'Unknown'
- break
- default:
- groupKey = 'All Hosts'
- }
-
- if (!groups[groupKey]) {
- groups[groupKey] = []
- }
- groups[groupKey].push(host)
- })
+ const filtered = hosts.filter((host) => {
+ // Search filter
+ const matchesSearch =
+ searchTerm === "" ||
+ host.friendly_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ host.os_type?.toLowerCase().includes(searchTerm.toLowerCase());
- return groups
- }, [filteredAndSortedHosts, groupBy])
+ // Group filter
+ const matchesGroup =
+ groupFilter === "all" ||
+ (groupFilter === "ungrouped" && !host.host_groups) ||
+ (groupFilter !== "ungrouped" && host.host_groups?.id === groupFilter);
- const handleSort = (field) => {
- if (sortField === field) {
- setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
- } else {
- setSortField(field)
- setSortDirection('asc')
- }
- }
+ // Status filter
+ const matchesStatus =
+ statusFilter === "all" ||
+ (host.effectiveStatus || host.status) === statusFilter;
- const getSortIcon = (field) => {
- if (sortField !== field) return
- return sortDirection === 'asc' ?
:
- }
+ // OS filter
+ const matchesOs =
+ osFilter === "all" ||
+ host.os_type?.toLowerCase() === osFilter.toLowerCase();
- // Column management functions
- const updateColumnConfig = (newConfig) => {
- setColumnConfig(newConfig)
- localStorage.setItem('hosts-column-config', JSON.stringify(newConfig))
- }
+ // URL filter for hosts needing updates, inactive hosts, up-to-date hosts, or stale hosts
+ const filter = searchParams.get("filter");
+ const matchesUrlFilter =
+ (filter !== "needsUpdates" ||
+ (host.updatesCount && host.updatesCount > 0)) &&
+ (filter !== "inactive" ||
+ (host.effectiveStatus || host.status) === "inactive") &&
+ (filter !== "upToDate" || (!host.isStale && host.updatesCount === 0)) &&
+ (filter !== "stale" || host.isStale);
- const toggleColumnVisibility = (columnId) => {
- const newConfig = columnConfig.map(col =>
- col.id === columnId ? { ...col, visible: !col.visible } : col
- )
- updateColumnConfig(newConfig)
- }
+ // Hide stale filter
+ const matchesHideStale = !hideStale || !host.isStale;
- const reorderColumns = (fromIndex, toIndex) => {
- const newConfig = [...columnConfig]
- const [movedColumn] = newConfig.splice(fromIndex, 1)
- newConfig.splice(toIndex, 0, movedColumn)
-
- // Update order values
- const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
- updateColumnConfig(updatedConfig)
- }
+ return (
+ matchesSearch &&
+ matchesGroup &&
+ matchesStatus &&
+ matchesOs &&
+ matchesUrlFilter &&
+ matchesHideStale
+ );
+ });
- const resetColumns = () => {
- const defaultConfig = [
- { id: 'select', label: 'Select', visible: true, order: 0 },
- { id: 'host', label: 'Friendly Name', visible: true, order: 1 },
- { id: 'hostname', label: 'System Hostname', visible: true, order: 2 },
- { id: 'ip', label: 'IP Address', visible: false, order: 3 },
- { id: 'group', label: 'Group', visible: true, order: 4 },
- { id: 'os', label: 'OS', visible: true, order: 5 },
- { id: 'os_version', label: 'OS Version', visible: false, order: 6 },
- { id: 'status', label: 'Status', visible: true, order: 7 },
- { id: 'updates', label: 'Updates', visible: true, order: 8 },
- { id: 'last_update', label: 'Last Update', visible: true, order: 9 },
- { id: 'actions', label: 'Actions', visible: true, order: 10 }
- ]
- updateColumnConfig(defaultConfig)
- }
+ // Sorting
+ filtered.sort((a, b) => {
+ let aValue, bValue;
- // Get visible columns in order
- const visibleColumns = columnConfig
- .filter(col => col.visible)
- .sort((a, b) => a.order - b.order)
+ switch (sortField) {
+ case "friendlyName":
+ aValue = a.friendly_name.toLowerCase();
+ bValue = b.friendly_name.toLowerCase();
+ break;
+ case "hostname":
+ aValue = a.hostname?.toLowerCase() || "zzz_no_hostname";
+ bValue = b.hostname?.toLowerCase() || "zzz_no_hostname";
+ break;
+ case "ip":
+ aValue = a.ip?.toLowerCase() || "zzz_no_ip";
+ bValue = b.ip?.toLowerCase() || "zzz_no_ip";
+ break;
+ case "group":
+ aValue = a.host_groups?.name || "zzz_ungrouped";
+ bValue = b.host_groups?.name || "zzz_ungrouped";
+ break;
+ case "os":
+ aValue = a.os_type?.toLowerCase() || "zzz_unknown";
+ bValue = b.os_type?.toLowerCase() || "zzz_unknown";
+ break;
+ case "os_version":
+ aValue = a.os_version?.toLowerCase() || "zzz_unknown";
+ bValue = b.os_version?.toLowerCase() || "zzz_unknown";
+ break;
+ case "agent_version":
+ aValue = a.agent_version?.toLowerCase() || "zzz_no_version";
+ bValue = b.agent_version?.toLowerCase() || "zzz_no_version";
+ break;
+ case "status":
+ aValue = a.effectiveStatus || a.status;
+ bValue = b.effectiveStatus || b.status;
+ break;
+ case "updates":
+ aValue = a.updatesCount || 0;
+ bValue = b.updatesCount || 0;
+ break;
+ case "last_update":
+ aValue = new Date(a.last_update);
+ bValue = new Date(b.last_update);
+ break;
+ default:
+ aValue = a[sortField];
+ bValue = b[sortField];
+ }
- // Helper function to render table cell content
- const renderCellContent = (column, host) => {
- switch (column.id) {
- case 'select':
- return (
-
handleSelectHost(host.id)}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {selectedHosts.includes(host.id) ? (
-
- ) : (
-
- )}
-
- )
- case 'host':
- return (
-
updateFriendlyNameMutation.mutate({ hostId: host.id, friendlyName: newName })}
- placeholder="Enter friendly name..."
- maxLength={100}
- linkTo={`/hosts/${host.id}`}
- validate={(value) => {
- if (!value.trim()) return 'Friendly name is required';
- if (value.trim().length < 1) return 'Friendly name must be at least 1 character';
- if (value.trim().length > 100) return 'Friendly name must be less than 100 characters';
- return null;
- }}
- className="w-full"
- />
- )
- case 'hostname':
- return (
-
- {host.hostname || 'N/A'}
-
- )
- case 'ip':
- return (
-
- {host.ip || 'N/A'}
-
- )
- case 'group':
- return (
- updateHostGroupMutation.mutate({ hostId: host.id, hostGroupId: newGroupId })}
- options={hostGroups || []}
- placeholder="Select group..."
- className="w-full"
- />
- )
- case 'os':
- return (
-
-
- {host.os_type}
-
- )
- case 'os_version':
- return (
-
- {host.os_version || 'N/A'}
-
- )
- case 'agent_version':
- return (
-
- {host.agent_version || 'N/A'}
-
- )
- case 'auto_update':
- return (
-
- {host.auto_update ? 'Yes' : 'No'}
-
- )
- case 'status':
- return (
-
- {(host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)}
-
- )
- case 'updates':
- return (
- navigate(`/packages?host=${host.id}`)}
- className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium hover:underline"
- title="View packages for this host"
- >
- {host.updatesCount || 0}
-
- )
- case 'last_update':
- return (
-
- {formatRelativeTime(host.last_update)}
-
- )
- case 'actions':
- return (
-
- View
-
-
- )
- default:
- return null
- }
- }
+ if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
+ if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
+ return 0;
+ });
- const handleHostCreated = (newHost) => {
- queryClient.invalidateQueries(['hosts'])
- // Navigate to host detail page to show credentials and setup instructions
- navigate(`/hosts/${newHost.hostId}`)
- }
+ return filtered;
+ }, [
+ hosts,
+ searchTerm,
+ groupFilter,
+ statusFilter,
+ osFilter,
+ sortField,
+ sortDirection,
+ searchParams,
+ ]);
- // Stats card click handlers
- const handleTotalHostsClick = () => {
- // Clear all filters to show all hosts
- setSearchTerm('')
- setGroupFilter('all')
- setStatusFilter('all')
- setOsFilter('all')
- setGroupBy('none')
- setHideStale(false)
- setShowFilters(false)
- // Clear URL parameters to ensure no filters are applied
- navigate('/hosts', { replace: true })
- }
+ // Group hosts by selected field
+ const groupedHosts = React.useMemo(() => {
+ if (groupBy === "none") {
+ return { "All Hosts": filteredAndSortedHosts };
+ }
- const handleUpToDateClick = () => {
- // Filter to show only up-to-date hosts
- setStatusFilter('active')
- setShowFilters(true)
- // Use the upToDate URL filter
- const newSearchParams = new URLSearchParams(window.location.search)
- newSearchParams.set('filter', 'upToDate')
- navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
- }
+ const groups = {};
+ filteredAndSortedHosts.forEach((host) => {
+ let groupKey;
+ switch (groupBy) {
+ case "group":
+ groupKey = host.host_groups?.name || "Ungrouped";
+ break;
+ case "status":
+ groupKey =
+ (host.effectiveStatus || host.status).charAt(0).toUpperCase() +
+ (host.effectiveStatus || host.status).slice(1);
+ break;
+ case "os":
+ groupKey = host.os_type || "Unknown";
+ break;
+ default:
+ groupKey = "All Hosts";
+ }
- const handleNeedsUpdatesClick = () => {
- // Filter to show hosts needing updates (regardless of status)
- setStatusFilter('all')
- setShowFilters(true)
- // We'll use the existing needsUpdates URL filter logic
- const newSearchParams = new URLSearchParams(window.location.search)
- newSearchParams.set('filter', 'needsUpdates')
- navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
- }
+ if (!groups[groupKey]) {
+ groups[groupKey] = [];
+ }
+ groups[groupKey].push(host);
+ });
- const handleStaleClick = () => {
- // Filter to show stale/inactive hosts
- setStatusFilter('inactive')
- setShowFilters(true)
- // We'll use the existing inactive URL filter logic
- const newSearchParams = new URLSearchParams(window.location.search)
- newSearchParams.set('filter', 'inactive')
- navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
- }
+ return groups;
+ }, [filteredAndSortedHosts, groupBy]);
- if (isLoading) {
- return (
-
-
-
- )
- }
+ const handleSort = (field) => {
+ if (sortField === field) {
+ setSortDirection(sortDirection === "asc" ? "desc" : "asc");
+ } else {
+ setSortField(field);
+ setSortDirection("asc");
+ }
+ };
- if (error) {
- return (
-
-
-
-
-
Error loading hosts
-
- {error.message || 'Failed to load hosts'}
-
-
refetch()}
- className="mt-2 btn-danger text-xs"
- >
- Try again
-
-
-
-
- )
- }
+ const getSortIcon = (field) => {
+ if (sortField !== field) return ;
+ return sortDirection === "asc" ? (
+
+ ) : (
+
+ );
+ };
- const getStatusColor = (host) => {
- if (host.isStale) return 'text-danger-600'
- if (host.updatesCount > 0) return 'text-warning-600'
- return 'text-success-600'
- }
+ // Column management functions
+ const updateColumnConfig = (newConfig) => {
+ setColumnConfig(newConfig);
+ localStorage.setItem("hosts-column-config", JSON.stringify(newConfig));
+ };
- const getStatusIcon = (host) => {
- if (host.isStale) return
- if (host.updatesCount > 0) return
- return
- }
+ const toggleColumnVisibility = (columnId) => {
+ const newConfig = columnConfig.map((col) =>
+ col.id === columnId ? { ...col, visible: !col.visible } : col,
+ );
+ updateColumnConfig(newConfig);
+ };
- const getStatusText = (host) => {
- if (host.isStale) return 'Stale'
- if (host.updatesCount > 0) return 'Needs Updates'
- return 'Up to Date'
- }
+ const reorderColumns = (fromIndex, toIndex) => {
+ const newConfig = [...columnConfig];
+ const [movedColumn] = newConfig.splice(fromIndex, 1);
+ newConfig.splice(toIndex, 0, movedColumn);
- return (
-
- {/* Page Header */}
-
-
-
Hosts
-
- Manage and monitor your connected hosts
-
-
-
-
refetch()}
- disabled={isFetching}
- className="btn-outline flex items-center gap-2"
- title="Refresh hosts data"
- >
-
- {isFetching ? 'Refreshing...' : 'Refresh'}
-
-
setShowAddModal(true)}
- className="btn-primary flex items-center gap-2"
- >
-
- Add Host
-
-
-
+ // Update order values
+ const updatedConfig = newConfig.map((col, index) => ({
+ ...col,
+ order: index,
+ }));
+ updateColumnConfig(updatedConfig);
+ };
- {/* Stats Summary */}
-
-
-
-
-
-
Total Hosts
-
{hosts?.length || 0}
-
-
-
-
-
-
-
-
Up to Date
-
- {hosts?.filter(h => !h.isStale && h.updatesCount === 0).length || 0}
-
-
-
-
-
-
-
-
-
Needs Updates
-
- {hosts?.filter(h => h.updatesCount > 0).length || 0}
-
-
-
-
-
-
-
-
-
Stale
-
- {hosts?.filter(h => h.isStale).length || 0}
-
-
-
-
-
+ const resetColumns = () => {
+ const defaultConfig = [
+ { id: "select", label: "Select", visible: true, order: 0 },
+ { id: "host", label: "Friendly Name", visible: true, order: 1 },
+ { id: "hostname", label: "System Hostname", visible: true, order: 2 },
+ { id: "ip", label: "IP Address", visible: false, order: 3 },
+ { id: "group", label: "Group", visible: true, order: 4 },
+ { id: "os", label: "OS", visible: true, order: 5 },
+ { id: "os_version", label: "OS Version", visible: false, order: 6 },
+ { id: "status", label: "Status", visible: true, order: 7 },
+ { id: "updates", label: "Updates", visible: true, order: 8 },
+ { id: "last_update", label: "Last Update", visible: true, order: 9 },
+ { id: "actions", label: "Actions", visible: true, order: 10 },
+ ];
+ updateColumnConfig(defaultConfig);
+ };
- {/* Hosts List */}
-
-
-
- {selectedHosts.length > 0 && (
-
-
- {selectedHosts.length} host{selectedHosts.length !== 1 ? 's' : ''} selected
-
- setShowBulkAssignModal(true)}
- className="btn-outline flex items-center gap-2"
- >
-
- Assign to Group
-
- setShowBulkDeleteModal(true)}
- className="btn-danger flex items-center gap-2"
- >
-
- Delete
-
- setSelectedHosts([])}
- className="text-sm text-secondary-500 hover:text-secondary-700"
- >
- Clear Selection
-
-
- )}
-
-
- {/* Table Controls */}
-
- {/* Search and Filter Bar */}
-
-
-
-
- setSearchTerm(e.target.value)}
- className="pl-10 pr-4 py-2 w-full border border-secondary-300 dark:border-secondary-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
- />
-
-
-
-
setShowFilters(!showFilters)}
- className={`btn-outline flex items-center gap-2 ${showFilters ? 'bg-primary-50 border-primary-300' : ''}`}
- >
-
- Filters
-
-
setShowColumnSettings(true)}
- className="btn-outline flex items-center gap-2"
- >
-
- Columns
-
-
- setGroupBy(e.target.value)}
- className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[120px]"
- >
- No Grouping
- By Group
- By Status
- By OS
-
-
-
-
setHideStale(!hideStale)}
- className={`btn-outline flex items-center gap-2 ${hideStale ? 'bg-primary-50 border-primary-300' : ''}`}
- >
-
- Hide Stale
-
-
setShowAddModal(true)}
- className="btn-primary flex items-center gap-2"
- >
-
- Add Host
-
-
-
+ // Get visible columns in order
+ const visibleColumns = columnConfig
+ .filter((col) => col.visible)
+ .sort((a, b) => a.order - b.order);
- {/* Advanced Filters */}
- {showFilters && (
-
-
-
- Host Group
- setGroupFilter(e.target.value)}
- className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
- >
- All Groups
- Ungrouped
- {hostGroups?.map(group => (
- {group.name}
- ))}
-
-
-
- Status
- setStatusFilter(e.target.value)}
- className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
- >
- All Status
- Active
- Pending
- Inactive
- Error
-
-
-
- Operating System
- setOsFilter(e.target.value)}
- className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
- >
- All OS
- Linux
- Windows
- macOS
-
-
-
- {
- setSearchTerm('')
- setGroupFilter('all')
- setStatusFilter('all')
- setOsFilter('all')
- setGroupBy('none')
- setHideStale(false)
- }}
- className="btn-outline w-full"
- >
- Clear Filters
-
-
-
-
- )}
-
+ // Helper function to render table cell content
+ const renderCellContent = (column, host) => {
+ switch (column.id) {
+ case "select":
+ return (
+
handleSelectHost(host.id)}
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {selectedHosts.includes(host.id) ? (
+
+ ) : (
+
+ )}
+
+ );
+ case "host":
+ return (
+
+ updateFriendlyNameMutation.mutate({
+ hostId: host.id,
+ friendlyName: newName,
+ })
+ }
+ placeholder="Enter friendly name..."
+ maxLength={100}
+ linkTo={`/hosts/${host.id}`}
+ validate={(value) => {
+ if (!value.trim()) return "Friendly name is required";
+ if (value.trim().length < 1)
+ return "Friendly name must be at least 1 character";
+ if (value.trim().length > 100)
+ return "Friendly name must be less than 100 characters";
+ return null;
+ }}
+ className="w-full"
+ />
+ );
+ case "hostname":
+ return (
+
+ {host.hostname || "N/A"}
+
+ );
+ case "ip":
+ return (
+
+ {host.ip || "N/A"}
+
+ );
+ case "group":
+ return (
+
+ updateHostGroupMutation.mutate({
+ hostId: host.id,
+ hostGroupId: newGroupId,
+ })
+ }
+ options={hostGroups || []}
+ placeholder="Select group..."
+ className="w-full"
+ />
+ );
+ case "os":
+ return (
+
+
+ {host.os_type}
+
+ );
+ case "os_version":
+ return (
+
+ {host.os_version || "N/A"}
+
+ );
+ case "agent_version":
+ return (
+
+ {host.agent_version || "N/A"}
+
+ );
+ case "auto_update":
+ return (
+
+ {host.auto_update ? "Yes" : "No"}
+
+ );
+ case "status":
+ return (
+
+ {(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
+ (host.effectiveStatus || host.status).slice(1)}
+
+ );
+ case "updates":
+ return (
+ navigate(`/packages?host=${host.id}`)}
+ className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium hover:underline"
+ title="View packages for this host"
+ >
+ {host.updatesCount || 0}
+
+ );
+ case "last_update":
+ return (
+
+ {formatRelativeTime(host.last_update)}
+
+ );
+ case "actions":
+ return (
+
+ View
+
+
+ );
+ default:
+ return null;
+ }
+ };
-
- {(!hosts || hosts.length === 0) ? (
-
-
-
No hosts registered yet
-
- Click "Add Host" to manually register a new host and get API credentials
-
-
- ) : filteredAndSortedHosts.length === 0 ? (
-
-
-
No hosts match your current filters
-
- Try adjusting your search terms or filters to see more results
-
-
- ) : (
-
-
- {Object.entries(groupedHosts).map(([groupName, groupHosts]) => (
-
- {/* Group Header */}
- {groupBy !== 'none' && (
-
-
- {groupName} ({groupHosts.length})
-
-
- )}
-
- {/* Table for this group */}
-
-
-
-
- {visibleColumns.map((column) => (
-
- {column.id === 'select' ? (
-
- {selectedHosts.length === groupHosts.length ? (
-
- ) : (
-
- )}
-
- ) : column.id === 'host' ? (
- handleSort('friendlyName')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('friendlyName')}
-
- ) : column.id === 'hostname' ? (
- handleSort('hostname')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('hostname')}
-
- ) : column.id === 'ip' ? (
- handleSort('ip')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('ip')}
-
- ) : column.id === 'group' ? (
- handleSort('group')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('group')}
-
- ) : column.id === 'os' ? (
- handleSort('os')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('os')}
-
- ) : column.id === 'os_version' ? (
- handleSort('os_version')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('os_version')}
-
- ) : column.id === 'agent_version' ? (
- handleSort('agent_version')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('agent_version')}
-
- ) : column.id === 'auto_update' ? (
-
- {column.label}
-
- ) : column.id === 'status' ? (
- handleSort('status')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('status')}
-
- ) : column.id === 'updates' ? (
- handleSort('updates')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('updates')}
-
- ) : column.id === 'last_update' ? (
- handleSort('last_update')}
- className="flex items-center gap-2 hover:text-secondary-700"
- >
- {column.label}
- {getSortIcon('last_update')}
-
- ) : (
- column.label
- )}
-
- ))}
-
-
-
- {groupHosts.map((host) => {
- const isInactive = (host.effectiveStatus || host.status) === 'inactive'
- const isSelected = selectedHosts.includes(host.id)
-
- let rowClasses = 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
-
- if (isSelected) {
- rowClasses += ' bg-primary-50 dark:bg-primary-600'
- } else if (isInactive) {
- rowClasses += ' bg-red-50 dark:bg-red-900/20'
- }
-
- return (
-
- {visibleColumns.map((column) => (
-
- {renderCellContent(column, host)}
-
- ))}
-
- )
- })}
-
-
-
-
- ))}
-
-
- )}
-
-
-
+ const handleHostCreated = (newHost) => {
+ queryClient.invalidateQueries(["hosts"]);
+ // Navigate to host detail page to show credentials and setup instructions
+ navigate(`/hosts/${newHost.hostId}`);
+ };
- {/* Modals */}
-
setShowAddModal(false)}
- onSuccess={handleHostCreated}
- />
-
+ // Stats card click handlers
+ const handleTotalHostsClick = () => {
+ // Clear all filters to show all hosts
+ setSearchTerm("");
+ setGroupFilter("all");
+ setStatusFilter("all");
+ setOsFilter("all");
+ setGroupBy("none");
+ setHideStale(false);
+ setShowFilters(false);
+ // Clear URL parameters to ensure no filters are applied
+ navigate("/hosts", { replace: true });
+ };
- {/* Bulk Assign Modal */}
- {showBulkAssignModal && (
- setShowBulkAssignModal(false)}
- onAssign={handleBulkAssign}
- isLoading={bulkUpdateGroupMutation.isPending}
- />
- )}
+ const handleUpToDateClick = () => {
+ // Filter to show only up-to-date hosts
+ setStatusFilter("active");
+ setShowFilters(true);
+ // Use the upToDate URL filter
+ const newSearchParams = new URLSearchParams(window.location.search);
+ newSearchParams.set("filter", "upToDate");
+ navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
+ };
- {/* Bulk Delete Modal */}
- {showBulkDeleteModal && (
- setShowBulkDeleteModal(false)}
- onDelete={handleBulkDelete}
- isLoading={bulkDeleteMutation.isPending}
- />
- )}
+ const handleNeedsUpdatesClick = () => {
+ // Filter to show hosts needing updates (regardless of status)
+ setStatusFilter("all");
+ setShowFilters(true);
+ // We'll use the existing needsUpdates URL filter logic
+ const newSearchParams = new URLSearchParams(window.location.search);
+ newSearchParams.set("filter", "needsUpdates");
+ navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
+ };
- {/* Column Settings Modal */}
- {showColumnSettings && (
- setShowColumnSettings(false)}
- onToggleVisibility={toggleColumnVisibility}
- onReorder={reorderColumns}
- onReset={resetColumns}
- />
- )}
-
- )
-}
+ const handleStaleClick = () => {
+ // Filter to show stale/inactive hosts
+ setStatusFilter("inactive");
+ setShowFilters(true);
+ // We'll use the existing inactive URL filter logic
+ const newSearchParams = new URLSearchParams(window.location.search);
+ newSearchParams.set("filter", "inactive");
+ navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+ Error loading hosts
+
+
+ {error.message || "Failed to load hosts"}
+
+
refetch()}
+ className="mt-2 btn-danger text-xs"
+ >
+ Try again
+
+
+
+
+ );
+ }
+
+ const getStatusColor = (host) => {
+ if (host.isStale) return "text-danger-600";
+ if (host.updatesCount > 0) return "text-warning-600";
+ return "text-success-600";
+ };
+
+ const getStatusIcon = (host) => {
+ if (host.isStale) return ;
+ if (host.updatesCount > 0) return ;
+ return ;
+ };
+
+ const getStatusText = (host) => {
+ if (host.isStale) return "Stale";
+ if (host.updatesCount > 0) return "Needs Updates";
+ return "Up to Date";
+ };
+
+ return (
+
+ {/* Page Header */}
+
+
+
+ Hosts
+
+
+ Manage and monitor your connected hosts
+
+
+
+
refetch()}
+ disabled={isFetching}
+ className="btn-outline flex items-center gap-2"
+ title="Refresh hosts data"
+ >
+
+ {isFetching ? "Refreshing..." : "Refresh"}
+
+
setShowAddModal(true)}
+ className="btn-primary flex items-center gap-2"
+ >
+
+ Add Host
+
+
+
+
+ {/* Stats Summary */}
+
+
+
+
+
+
+ Total Hosts
+
+
+ {hosts?.length || 0}
+
+
+
+
+
+
+
+
+
+ Up to Date
+
+
+ {hosts?.filter((h) => !h.isStale && h.updatesCount === 0)
+ .length || 0}
+
+
+
+
+
+
+
+
+
+ Needs Updates
+
+
+ {hosts?.filter((h) => h.updatesCount > 0).length || 0}
+
+
+
+
+
+
+
+
+
+ Stale
+
+
+ {hosts?.filter((h) => h.isStale).length || 0}
+
+
+
+
+
+
+ {/* Hosts List */}
+
+
+
+ {selectedHosts.length > 0 && (
+
+
+ {selectedHosts.length} host
+ {selectedHosts.length !== 1 ? "s" : ""} selected
+
+ setShowBulkAssignModal(true)}
+ className="btn-outline flex items-center gap-2"
+ >
+
+ Assign to Group
+
+ setShowBulkDeleteModal(true)}
+ className="btn-danger flex items-center gap-2"
+ >
+
+ Delete
+
+ setSelectedHosts([])}
+ className="text-sm text-secondary-500 hover:text-secondary-700"
+ >
+ Clear Selection
+
+
+ )}
+
+
+ {/* Table Controls */}
+
+ {/* Search and Filter Bar */}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ className="pl-10 pr-4 py-2 w-full border border-secondary-300 dark:border-secondary-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
+ />
+
+
+
+
setShowFilters(!showFilters)}
+ className={`btn-outline flex items-center gap-2 ${showFilters ? "bg-primary-50 border-primary-300" : ""}`}
+ >
+
+ Filters
+
+
setShowColumnSettings(true)}
+ className="btn-outline flex items-center gap-2"
+ >
+
+ Columns
+
+
+ setGroupBy(e.target.value)}
+ className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[120px]"
+ >
+ No Grouping
+ By Group
+ By Status
+ By OS
+
+
+
+
setHideStale(!hideStale)}
+ className={`btn-outline flex items-center gap-2 ${hideStale ? "bg-primary-50 border-primary-300" : ""}`}
+ >
+
+ Hide Stale
+
+
setShowAddModal(true)}
+ className="btn-primary flex items-center gap-2"
+ >
+
+ Add Host
+
+
+
+
+ {/* Advanced Filters */}
+ {showFilters && (
+
+
+
+
+ Host Group
+
+ setGroupFilter(e.target.value)}
+ className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
+ >
+ All Groups
+ Ungrouped
+ {hostGroups?.map((group) => (
+
+ {group.name}
+
+ ))}
+
+
+
+
+ Status
+
+ setStatusFilter(e.target.value)}
+ className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
+ >
+ All Status
+ Active
+ Pending
+ Inactive
+ Error
+
+
+
+
+ Operating System
+
+ setOsFilter(e.target.value)}
+ className="w-full border border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
+ >
+ All OS
+ Linux
+ Windows
+ macOS
+
+
+
+ {
+ setSearchTerm("");
+ setGroupFilter("all");
+ setStatusFilter("all");
+ setOsFilter("all");
+ setGroupBy("none");
+ setHideStale(false);
+ }}
+ className="btn-outline w-full"
+ >
+ Clear Filters
+
+
+
+
+ )}
+
+
+
+ {!hosts || hosts.length === 0 ? (
+
+
+
No hosts registered yet
+
+ Click "Add Host" to manually register a new host and get API
+ credentials
+
+
+ ) : filteredAndSortedHosts.length === 0 ? (
+
+
+
+ No hosts match your current filters
+
+
+ Try adjusting your search terms or filters to see more results
+
+
+ ) : (
+
+
+ {Object.entries(groupedHosts).map(
+ ([groupName, groupHosts]) => (
+
+ {/* Group Header */}
+ {groupBy !== "none" && (
+
+
+ {groupName} ({groupHosts.length})
+
+
+ )}
+
+ {/* Table for this group */}
+
+
+
+
+ {visibleColumns.map((column) => (
+
+ {column.id === "select" ? (
+
+ {selectedHosts.length ===
+ groupHosts.length ? (
+
+ ) : (
+
+ )}
+
+ ) : column.id === "host" ? (
+
+ handleSort("friendlyName")
+ }
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("friendlyName")}
+
+ ) : column.id === "hostname" ? (
+ handleSort("hostname")}
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("hostname")}
+
+ ) : column.id === "ip" ? (
+ handleSort("ip")}
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("ip")}
+
+ ) : column.id === "group" ? (
+ handleSort("group")}
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("group")}
+
+ ) : column.id === "os" ? (
+ handleSort("os")}
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("os")}
+
+ ) : column.id === "os_version" ? (
+ handleSort("os_version")}
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("os_version")}
+
+ ) : column.id === "agent_version" ? (
+
+ handleSort("agent_version")
+ }
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("agent_version")}
+
+ ) : column.id === "auto_update" ? (
+
+ {column.label}
+
+ ) : column.id === "status" ? (
+ handleSort("status")}
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("status")}
+
+ ) : column.id === "updates" ? (
+ handleSort("updates")}
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("updates")}
+
+ ) : column.id === "last_update" ? (
+
+ handleSort("last_update")
+ }
+ className="flex items-center gap-2 hover:text-secondary-700"
+ >
+ {column.label}
+ {getSortIcon("last_update")}
+
+ ) : (
+ column.label
+ )}
+
+ ))}
+
+
+
+ {groupHosts.map((host) => {
+ const isInactive =
+ (host.effectiveStatus || host.status) ===
+ "inactive";
+ const isSelected = selectedHosts.includes(
+ host.id,
+ );
+
+ let rowClasses =
+ "hover:bg-secondary-50 dark:hover:bg-secondary-700";
+
+ if (isSelected) {
+ rowClasses +=
+ " bg-primary-50 dark:bg-primary-600";
+ } else if (isInactive) {
+ rowClasses += " bg-red-50 dark:bg-red-900/20";
+ }
+
+ return (
+
+ {visibleColumns.map((column) => (
+
+ {renderCellContent(column, host)}
+
+ ))}
+
+ );
+ })}
+
+
+
+
+ ),
+ )}
+
+
+ )}
+
+
+
+
+ {/* Modals */}
+
setShowAddModal(false)}
+ onSuccess={handleHostCreated}
+ />
+
+ {/* Bulk Assign Modal */}
+ {showBulkAssignModal && (
+ setShowBulkAssignModal(false)}
+ onAssign={handleBulkAssign}
+ isLoading={bulkUpdateGroupMutation.isPending}
+ />
+ )}
+
+ {/* Bulk Delete Modal */}
+ {showBulkDeleteModal && (
+ setShowBulkDeleteModal(false)}
+ onDelete={handleBulkDelete}
+ isLoading={bulkDeleteMutation.isPending}
+ />
+ )}
+
+ {/* Column Settings Modal */}
+ {showColumnSettings && (
+ setShowColumnSettings(false)}
+ onToggleVisibility={toggleColumnVisibility}
+ onReorder={reorderColumns}
+ onReset={resetColumns}
+ />
+ )}
+
+ );
+};
// Bulk Assign Modal Component
-const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading }) => {
- const [selectedGroupId, setSelectedGroupId] = useState('')
+const BulkAssignModal = ({
+ selectedHosts,
+ hosts,
+ onClose,
+ onAssign,
+ isLoading,
+}) => {
+ const [selectedGroupId, setSelectedGroupId] = useState("");
- // Fetch host groups for selection
- const { data: hostGroups } = useQuery({
- queryKey: ['hostGroups'],
- queryFn: () => hostGroupsAPI.list().then(res => res.data),
- })
+ // Fetch host groups for selection
+ const { data: hostGroups } = useQuery({
+ queryKey: ["hostGroups"],
+ queryFn: () => hostGroupsAPI.list().then((res) => res.data),
+ });
- const selectedHostNames = hosts
- .filter(host => selectedHosts.includes(host.id))
- .map(host => host.friendly_name)
+ const selectedHostNames = hosts
+ .filter((host) => selectedHosts.includes(host.id))
+ .map((host) => host.friendly_name);
- const handleSubmit = (e) => {
- e.preventDefault()
- onAssign(selectedGroupId || null)
- }
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onAssign(selectedGroupId || null);
+ };
- return (
-
-
-
-
- Assign to Host Group
-
-
-
-
-
+ return (
+
+
+
+
+ Assign to Host Group
+
+
+
+
+
-
-
- Assigning {selectedHosts.length} host{selectedHosts.length !== 1 ? 's' : ''}:
-
-
- {selectedHostNames.map((friendlyName, index) => (
-
- • {friendlyName}
-
- ))}
-
-
+
+
+ Assigning {selectedHosts.length} host
+ {selectedHosts.length !== 1 ? "s" : ""}:
+
+
+ {selectedHostNames.map((friendlyName, index) => (
+
+ • {friendlyName}
+
+ ))}
+
+
-
-
-
- Host Group
-
-
setSelectedGroupId(e.target.value)}
- className="w-full px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
- >
- No group (ungrouped)
- {hostGroups?.map((group) => (
-
- {group.name}
-
- ))}
-
-
- Select a group to assign these hosts to, or leave ungrouped.
-
-
+
+
+
+ Host Group
+
+
setSelectedGroupId(e.target.value)}
+ className="w-full px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
+ >
+ No group (ungrouped)
+ {hostGroups?.map((group) => (
+
+ {group.name}
+
+ ))}
+
+
+ Select a group to assign these hosts to, or leave ungrouped.
+
+
-
-
- Cancel
-
-
- {isLoading ? 'Assigning...' : 'Assign to Group'}
-
-
-
-
-
- )
-}
+
+
+ Cancel
+
+
+ {isLoading ? "Assigning..." : "Assign to Group"}
+
+
+
+
+
+ );
+};
// Bulk Delete Modal Component
-const BulkDeleteModal = ({ selectedHosts, hosts, onClose, onDelete, isLoading }) => {
- const selectedHostNames = hosts
- .filter(host => selectedHosts.includes(host.id))
- .map(host => host.friendly_name || host.hostname || host.id)
+const BulkDeleteModal = ({
+ selectedHosts,
+ hosts,
+ onClose,
+ onDelete,
+ isLoading,
+}) => {
+ const selectedHostNames = hosts
+ .filter((host) => selectedHosts.includes(host.id))
+ .map((host) => host.friendly_name || host.hostname || host.id);
- const handleSubmit = (e) => {
- e.preventDefault()
- onDelete()
- }
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onDelete();
+ };
- return (
-
-
-
-
-
Delete Hosts
-
-
-
-
-
+ return (
+
+
+
+
+
+ Delete Hosts
+
+
+
+
+
+
-
-
-
-
-
- Warning: This action cannot be undone
-
-
-
- You are about to permanently delete {selectedHosts.length} host{selectedHosts.length !== 1 ? 's' : ''}.
- This will remove all host data, including package information, update history, and API credentials.
-
-
+
+
+
+
+
+ Warning: This action cannot be undone
+
+
+
+ You are about to permanently delete {selectedHosts.length} host
+ {selectedHosts.length !== 1 ? "s" : ""}. This will remove all host
+ data, including package information, update history, and API
+ credentials.
+
+
-
-
- Hosts to be deleted:
-
-
- {selectedHostNames.map((friendlyName, index) => (
-
- • {friendlyName}
-
- ))}
-
-
+
+
+ Hosts to be deleted:
+
+
+ {selectedHostNames.map((friendlyName, index) => (
+
+ • {friendlyName}
+
+ ))}
+
+
-
-
-
- Cancel
-
-
- {isLoading ? 'Deleting...' : `Delete ${selectedHosts.length} Host${selectedHosts.length !== 1 ? 's' : ''}`}
-
-
-
-
-
-
- )
-}
+
+
+
+ Cancel
+
+
+ {isLoading
+ ? "Deleting..."
+ : `Delete ${selectedHosts.length} Host${selectedHosts.length !== 1 ? "s" : ""}`}
+
+
+
+
+
+
+ );
+};
// Column Settings Modal Component
-const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
- const [draggedIndex, setDraggedIndex] = useState(null)
+const ColumnSettingsModal = ({
+ columnConfig,
+ onClose,
+ onToggleVisibility,
+ onReorder,
+ onReset,
+}) => {
+ const [draggedIndex, setDraggedIndex] = useState(null);
- const handleDragStart = (e, index) => {
- setDraggedIndex(index)
- e.dataTransfer.effectAllowed = 'move'
- }
+ const handleDragStart = (e, index) => {
+ setDraggedIndex(index);
+ e.dataTransfer.effectAllowed = "move";
+ };
- const handleDragOver = (e) => {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- }
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ };
- const handleDrop = (e, dropIndex) => {
- e.preventDefault()
- if (draggedIndex !== null && draggedIndex !== dropIndex) {
- onReorder(draggedIndex, dropIndex)
- }
- setDraggedIndex(null)
- }
+ const handleDrop = (e, dropIndex) => {
+ e.preventDefault();
+ if (draggedIndex !== null && draggedIndex !== dropIndex) {
+ onReorder(draggedIndex, dropIndex);
+ }
+ setDraggedIndex(null);
+ };
- return (
-
-
-
-
-
Column Settings
-
-
-
-
-
-
-
-
- Drag to reorder columns or toggle visibility
-
-
-
- {columnConfig.map((column, index) => (
-
handleDragStart(e, index)}
- onDragOver={handleDragOver}
- onDrop={(e) => handleDrop(e, index)}
- className={`flex items-center justify-between p-3 border rounded-lg cursor-move ${
- draggedIndex === index ? 'opacity-50' : 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
- } border-secondary-200 dark:border-secondary-600`}
- >
-
-
-
- {column.label}
-
-
-
onToggleVisibility(column.id)}
- className={`p-1 rounded ${
- column.visible
- ? 'text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300'
- : 'text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300'
- }`}
- >
- {column.visible ? (
-
- ) : (
-
- )}
-
-
- ))}
-
-
-
-
- Reset to Default
-
-
- Done
-
-
-
-
-
- )
-}
+ return (
+
+
+
+
+
+ Column Settings
+
+
+
+
+
+
-export default Hosts
\ No newline at end of file
+
+
+ Drag to reorder columns or toggle visibility
+
+
+
+ {columnConfig.map((column, index) => (
+
handleDragStart(e, index)}
+ onDragOver={handleDragOver}
+ onDrop={(e) => handleDrop(e, index)}
+ className={`flex items-center justify-between p-3 border rounded-lg cursor-move ${
+ draggedIndex === index
+ ? "opacity-50"
+ : "hover:bg-secondary-50 dark:hover:bg-secondary-700"
+ } border-secondary-200 dark:border-secondary-600`}
+ >
+
+
+
+ {column.label}
+
+
+
onToggleVisibility(column.id)}
+ className={`p-1 rounded ${
+ column.visible
+ ? "text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
+ : "text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
+ }`}
+ >
+ {column.visible ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+
+ Reset to Default
+
+
+ Done
+
+
+
+
+
+ );
+};
+
+export default Hosts;
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
index fd557db..c81f6b9 100644
--- a/frontend/src/pages/Login.jsx
+++ b/frontend/src/pages/Login.jsx
@@ -1,450 +1,487 @@
-import React, { useState, useEffect } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft, Mail } from 'lucide-react'
-import { useAuth } from '../contexts/AuthContext'
-import { authAPI } from '../utils/api'
+import {
+ AlertCircle,
+ ArrowLeft,
+ Eye,
+ EyeOff,
+ Lock,
+ Mail,
+ Smartphone,
+ User,
+} from "lucide-react";
+import React, { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { useAuth } from "../contexts/AuthContext";
+import { authAPI } from "../utils/api";
const Login = () => {
- const [isSignupMode, setIsSignupMode] = useState(false)
- const [formData, setFormData] = useState({
- username: '',
- email: '',
- password: '',
- firstName: '',
- lastName: ''
- })
- const [tfaData, setTfaData] = useState({
- token: ''
- })
- const [showPassword, setShowPassword] = useState(false)
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState('')
- const [requiresTfa, setRequiresTfa] = useState(false)
- const [tfaUsername, setTfaUsername] = useState('')
- const [signupEnabled, setSignupEnabled] = useState(false)
+ const [isSignupMode, setIsSignupMode] = useState(false);
+ const [formData, setFormData] = useState({
+ username: "",
+ email: "",
+ password: "",
+ firstName: "",
+ lastName: "",
+ });
+ const [tfaData, setTfaData] = useState({
+ token: "",
+ });
+ const [showPassword, setShowPassword] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [requiresTfa, setRequiresTfa] = useState(false);
+ const [tfaUsername, setTfaUsername] = useState("");
+ const [signupEnabled, setSignupEnabled] = useState(false);
- const navigate = useNavigate()
- const { login, setAuthState } = useAuth()
+ const navigate = useNavigate();
+ const { login, setAuthState } = useAuth();
- // Check if signup is enabled
- useEffect(() => {
- const checkSignupEnabled = async () => {
- try {
- const response = await fetch('/api/v1/auth/signup-enabled')
- if (response.ok) {
- const data = await response.json()
- setSignupEnabled(data.signupEnabled)
- }
- } catch (error) {
- console.error('Failed to check signup status:', error)
- // Default to disabled on error for security
- setSignupEnabled(false)
- }
- }
- checkSignupEnabled()
- }, [])
+ // Check if signup is enabled
+ useEffect(() => {
+ const checkSignupEnabled = async () => {
+ try {
+ const response = await fetch("/api/v1/auth/signup-enabled");
+ if (response.ok) {
+ const data = await response.json();
+ setSignupEnabled(data.signupEnabled);
+ }
+ } catch (error) {
+ console.error("Failed to check signup status:", error);
+ // Default to disabled on error for security
+ setSignupEnabled(false);
+ }
+ };
+ checkSignupEnabled();
+ }, []);
- const handleSubmit = async (e) => {
- e.preventDefault()
- setIsLoading(true)
- setError('')
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError("");
- try {
- const response = await authAPI.login(formData.username, formData.password)
+ try {
+ const response = await authAPI.login(
+ formData.username,
+ formData.password,
+ );
- if (response.data.requiresTfa) {
- setRequiresTfa(true)
- setTfaUsername(formData.username)
- setError('')
- } else {
- // Regular login successful
- const result = await login(formData.username, formData.password)
- if (result.success) {
- navigate('/')
- } else {
- setError(result.error || 'Login failed')
- }
- }
- } catch (err) {
- setError(err.response?.data?.error || 'Login failed')
- } finally {
- setIsLoading(false)
- }
- }
+ if (response.data.requiresTfa) {
+ setRequiresTfa(true);
+ setTfaUsername(formData.username);
+ setError("");
+ } else {
+ // Regular login successful
+ const result = await login(formData.username, formData.password);
+ if (result.success) {
+ navigate("/");
+ } else {
+ setError(result.error || "Login failed");
+ }
+ }
+ } catch (err) {
+ setError(err.response?.data?.error || "Login failed");
+ } finally {
+ setIsLoading(false);
+ }
+ };
- const handleSignupSubmit = async (e) => {
- e.preventDefault()
- setIsLoading(true)
- setError('')
+ const handleSignupSubmit = async (e) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError("");
- try {
- const response = await authAPI.signup(formData.username, formData.email, formData.password, formData.firstName, formData.lastName)
- if (response.data && response.data.token) {
- // Update AuthContext state and localStorage
- setAuthState(response.data.token, response.data.user)
+ try {
+ const response = await authAPI.signup(
+ formData.username,
+ formData.email,
+ formData.password,
+ formData.firstName,
+ formData.lastName,
+ );
+ if (response.data && response.data.token) {
+ // Update AuthContext state and localStorage
+ setAuthState(response.data.token, response.data.user);
- // Redirect to dashboard
- navigate('/')
- } else {
- setError('Signup failed - invalid response')
- }
- } catch (err) {
- console.error('Signup error:', err)
- const errorMessage = err.response?.data?.error ||
- (err.response?.data?.errors && err.response.data.errors.length > 0
- ? err.response.data.errors.map(e => e.msg).join(', ')
- : err.message || 'Signup failed')
- setError(errorMessage)
- } finally {
- setIsLoading(false)
- }
- }
+ // Redirect to dashboard
+ navigate("/");
+ } else {
+ setError("Signup failed - invalid response");
+ }
+ } catch (err) {
+ console.error("Signup error:", err);
+ const errorMessage =
+ err.response?.data?.error ||
+ (err.response?.data?.errors && err.response.data.errors.length > 0
+ ? err.response.data.errors.map((e) => e.msg).join(", ")
+ : err.message || "Signup failed");
+ setError(errorMessage);
+ } finally {
+ setIsLoading(false);
+ }
+ };
- const handleTfaSubmit = async (e) => {
- e.preventDefault()
- setIsLoading(true)
- setError('')
+ const handleTfaSubmit = async (e) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError("");
- try {
- const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
+ try {
+ const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
- if (response.data && response.data.token) {
- // Store token and user data
- localStorage.setItem('token', response.data.token)
- localStorage.setItem('user', JSON.stringify(response.data.user))
+ if (response.data && response.data.token) {
+ // Store token and user data
+ localStorage.setItem("token", response.data.token);
+ localStorage.setItem("user", JSON.stringify(response.data.user));
- // Redirect to dashboard
- navigate('/')
- } else {
- setError('TFA verification failed - invalid response')
- }
- } catch (err) {
- console.error('TFA verification error:', err)
- const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed'
- setError(errorMessage)
- // Clear the token input for security
- setTfaData({ token: '' })
- } finally {
- setIsLoading(false)
- }
- }
+ // Redirect to dashboard
+ navigate("/");
+ } else {
+ setError("TFA verification failed - invalid response");
+ }
+ } catch (err) {
+ console.error("TFA verification error:", err);
+ const errorMessage =
+ err.response?.data?.error || err.message || "TFA verification failed";
+ setError(errorMessage);
+ // Clear the token input for security
+ setTfaData({ token: "" });
+ } finally {
+ setIsLoading(false);
+ }
+ };
- const handleInputChange = (e) => {
- setFormData({
- ...formData,
- [e.target.name]: e.target.value
- })
- }
+ const handleInputChange = (e) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value,
+ });
+ };
- const handleTfaInputChange = (e) => {
- setTfaData({
- ...tfaData,
- [e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6)
- })
- // Clear error when user starts typing
- if (error) {
- setError('')
- }
- }
+ const handleTfaInputChange = (e) => {
+ setTfaData({
+ ...tfaData,
+ [e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
+ });
+ // Clear error when user starts typing
+ if (error) {
+ setError("");
+ }
+ };
- const handleBackToLogin = () => {
- setRequiresTfa(false)
- setTfaData({ token: '' })
- setError('')
- }
+ const handleBackToLogin = () => {
+ setRequiresTfa(false);
+ setTfaData({ token: "" });
+ setError("");
+ };
- const toggleMode = () => {
- // Only allow signup mode if signup is enabled
- if (!signupEnabled && !isSignupMode) {
- return // Don't allow switching to signup if disabled
- }
- setIsSignupMode(!isSignupMode)
- setFormData({
- username: '',
- email: '',
- password: '',
- firstName: '',
- lastName: ''
- })
- setError('')
- }
+ const toggleMode = () => {
+ // Only allow signup mode if signup is enabled
+ if (!signupEnabled && !isSignupMode) {
+ return; // Don't allow switching to signup if disabled
+ }
+ setIsSignupMode(!isSignupMode);
+ setFormData({
+ username: "",
+ email: "",
+ password: "",
+ firstName: "",
+ lastName: "",
+ });
+ setError("");
+ };
- return (
-
-
-
-
-
-
-
- {isSignupMode ? 'Create PatchMon Account' : 'Sign in to PatchMon'}
-
-
- Monitor and manage your Linux package updates
-
-
+ return (
+
+
+
+
+
+
+
+ {isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
+
+
+ Monitor and manage your Linux package updates
+
+
- {!requiresTfa ? (
-
-
-
-
- {isSignupMode ? 'Username' : 'Username or Email'}
-
-
-
+ {!requiresTfa ? (
+
+
+
+
+ {isSignupMode ? "Username" : "Username or Email"}
+
+
+
- {isSignupMode && (
- <>
-
-
- >
- )}
+ {isSignupMode && (
+ <>
+
+
+ >
+ )}
-
-
- Password
-
-
-
-
-
-
-
- setShowPassword(!showPassword)}
- className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
- >
- {showPassword ? (
-
- ) : (
-
- )}
-
-
-
-
-
+
+
+ Password
+
+
+
+
+
+
+
+ setShowPassword(!showPassword)}
+ className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
+ >
+ {showPassword ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
- {error && (
-
- )}
+ {error && (
+
+ )}
-
-
- {isLoading ? (
-
-
- {isSignupMode ? 'Creating account...' : 'Signing in...'}
-
- ) : (
- isSignupMode ? 'Create Account' : 'Sign in'
- )}
-
-
+
+
+ {isLoading ? (
+
+
+ {isSignupMode ? "Creating account..." : "Signing in..."}
+
+ ) : isSignupMode ? (
+ "Create Account"
+ ) : (
+ "Sign in"
+ )}
+
+
- {signupEnabled && (
-
-
- {isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
-
- {isSignupMode ? 'Sign in' : 'Sign up'}
-
-
-
- )}
-
- ) : (
-
-
-
-
-
-
- Two-Factor Authentication
-
-
- Enter the 6-digit code from your authenticator app
-
-
+ {signupEnabled && (
+
+
+ {isSignupMode
+ ? "Already have an account?"
+ : "Don't have an account?"}{" "}
+
+ {isSignupMode ? "Sign in" : "Sign up"}
+
+
+
+ )}
+
+ ) : (
+
+
+
+
+
+
+ Two-Factor Authentication
+
+
+ Enter the 6-digit code from your authenticator app
+
+
-
-
- Verification Code
-
-
-
-
-
+
+
+ Verification Code
+
+
+
+
+
- {error && (
-
- )}
+ {error && (
+
+ )}
-
-
- {isLoading ? (
-
- ) : (
- 'Verify Code'
- )}
-
+
+
+ {isLoading ? (
+
+ ) : (
+ "Verify Code"
+ )}
+
-
-
- Back to Login
-
-
+
+
+ Back to Login
+
+
-
-
- Don't have access to your authenticator? Use a backup code.
-
-
-
- )}
-
-
- )
-}
+
+
+ Don't have access to your authenticator? Use a backup code.
+
+
+
+ )}
+
+
+ );
+};
-export default Login
+export default Login;
diff --git a/frontend/src/pages/Options.jsx b/frontend/src/pages/Options.jsx
index 0f8a9d0..dc0522c 100644
--- a/frontend/src/pages/Options.jsx
+++ b/frontend/src/pages/Options.jsx
@@ -1,571 +1,580 @@
-import React, { useState } from 'react'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import {
- Plus,
- Edit,
- Trash2,
- Server,
- Users,
- AlertTriangle,
- CheckCircle,
- Settings
-} from 'lucide-react'
-import { hostGroupsAPI } from '../utils/api'
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ CheckCircle,
+ Edit,
+ Plus,
+ Server,
+ Settings,
+ Trash2,
+ Users,
+} from "lucide-react";
+import React, { useState } from "react";
+import { hostGroupsAPI } from "../utils/api";
const Options = () => {
- const [activeTab, setActiveTab] = useState('hostgroups')
- const [showCreateModal, setShowCreateModal] = useState(false)
- const [showEditModal, setShowEditModal] = useState(false)
- const [selectedGroup, setSelectedGroup] = useState(null)
- const [showDeleteModal, setShowDeleteModal] = useState(false)
- const [groupToDelete, setGroupToDelete] = useState(null)
+ const [activeTab, setActiveTab] = useState("hostgroups");
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [selectedGroup, setSelectedGroup] = useState(null);
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [groupToDelete, setGroupToDelete] = useState(null);
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
- // Tab configuration
- const tabs = [
- { id: 'hostgroups', name: 'Host Groups', icon: Users },
- { id: 'notifications', name: 'Notifications', icon: AlertTriangle, comingSoon: true }
- ]
+ // Tab configuration
+ const tabs = [
+ { id: "hostgroups", name: "Host Groups", icon: Users },
+ {
+ id: "notifications",
+ name: "Notifications",
+ icon: AlertTriangle,
+ comingSoon: true,
+ },
+ ];
- // Fetch host groups
- const { data: hostGroups, isLoading, error } = useQuery({
- queryKey: ['hostGroups'],
- queryFn: () => hostGroupsAPI.list().then(res => res.data),
- })
+ // Fetch host groups
+ const {
+ data: hostGroups,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["hostGroups"],
+ queryFn: () => hostGroupsAPI.list().then((res) => res.data),
+ });
- // Create host group mutation
- const createMutation = useMutation({
- mutationFn: (data) => hostGroupsAPI.create(data),
- onSuccess: () => {
- queryClient.invalidateQueries(['hostGroups'])
- setShowCreateModal(false)
- },
- onError: (error) => {
- console.error('Failed to create host group:', error)
- }
- })
+ // Create host group mutation
+ const createMutation = useMutation({
+ mutationFn: (data) => hostGroupsAPI.create(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["hostGroups"]);
+ setShowCreateModal(false);
+ },
+ onError: (error) => {
+ console.error("Failed to create host group:", error);
+ },
+ });
- // Update host group mutation
- const updateMutation = useMutation({
- mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
- onSuccess: () => {
- queryClient.invalidateQueries(['hostGroups'])
- setShowEditModal(false)
- setSelectedGroup(null)
- },
- onError: (error) => {
- console.error('Failed to update host group:', error)
- }
- })
+ // Update host group mutation
+ const updateMutation = useMutation({
+ mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["hostGroups"]);
+ setShowEditModal(false);
+ setSelectedGroup(null);
+ },
+ onError: (error) => {
+ console.error("Failed to update host group:", error);
+ },
+ });
- // Delete host group mutation
- const deleteMutation = useMutation({
- mutationFn: (id) => hostGroupsAPI.delete(id),
- onSuccess: () => {
- queryClient.invalidateQueries(['hostGroups'])
- setShowDeleteModal(false)
- setGroupToDelete(null)
- },
- onError: (error) => {
- console.error('Failed to delete host group:', error)
- }
- })
+ // Delete host group mutation
+ const deleteMutation = useMutation({
+ mutationFn: (id) => hostGroupsAPI.delete(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["hostGroups"]);
+ setShowDeleteModal(false);
+ setGroupToDelete(null);
+ },
+ onError: (error) => {
+ console.error("Failed to delete host group:", error);
+ },
+ });
- const handleCreate = (data) => {
- createMutation.mutate(data)
- }
+ const handleCreate = (data) => {
+ createMutation.mutate(data);
+ };
- const handleEdit = (group) => {
- setSelectedGroup(group)
- setShowEditModal(true)
- }
+ const handleEdit = (group) => {
+ setSelectedGroup(group);
+ setShowEditModal(true);
+ };
- const handleUpdate = (data) => {
- updateMutation.mutate({ id: selectedGroup.id, data })
- }
+ const handleUpdate = (data) => {
+ updateMutation.mutate({ id: selectedGroup.id, data });
+ };
- const handleDeleteClick = (group) => {
- setGroupToDelete(group)
- setShowDeleteModal(true)
- }
+ const handleDeleteClick = (group) => {
+ setGroupToDelete(group);
+ setShowDeleteModal(true);
+ };
- const handleDeleteConfirm = () => {
- deleteMutation.mutate(groupToDelete.id)
- }
+ const handleDeleteConfirm = () => {
+ deleteMutation.mutate(groupToDelete.id);
+ };
- const renderHostGroupsTab = () => {
- if (isLoading) {
- return (
-
- )
- }
+ const renderHostGroupsTab = () => {
+ if (isLoading) {
+ return (
+
+ );
+ }
- if (error) {
- return (
-
-
-
-
-
- Error loading host groups
-
-
- {error.message || 'Failed to load host groups'}
-
-
-
-
- )
- }
+ if (error) {
+ return (
+
+
+
+
+
+ Error loading host groups
+
+
+ {error.message || "Failed to load host groups"}
+
+
+
+
+ );
+ }
- return (
-
- {/* Header */}
-
-
-
- Host Groups
-
-
- Organize your hosts into logical groups for better management
-
-
-
setShowCreateModal(true)}
- className="btn-primary flex items-center gap-2"
- >
-
- Create Group
-
-
+ return (
+
+ {/* Header */}
+
+
+
+ Host Groups
+
+
+ Organize your hosts into logical groups for better management
+
+
+
setShowCreateModal(true)}
+ className="btn-primary flex items-center gap-2"
+ >
+
+ Create Group
+
+
- {/* Host Groups Grid */}
- {hostGroups && hostGroups.length > 0 ? (
-
- {hostGroups.map((group) => (
-
-
-
-
-
-
- {group.name}
-
- {group.description && (
-
- {group.description}
-
- )}
-
-
-
- handleEdit(group)}
- className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
- title="Edit group"
- >
-
-
- handleDeleteClick(group)}
- className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
- title="Delete group"
- >
-
-
-
-
-
-
-
-
- {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}
-
-
-
- ))}
-
- ) : (
-
-
-
- No host groups yet
-
-
- Create your first host group to organize your hosts
-
-
setShowCreateModal(true)}
- className="btn-primary flex items-center gap-2 mx-auto"
- >
-
- Create Group
-
-
- )}
-
- )
- }
+ {/* Host Groups Grid */}
+ {hostGroups && hostGroups.length > 0 ? (
+
+ {hostGroups.map((group) => (
+
+
+
+
+
+
+ {group.name}
+
+ {group.description && (
+
+ {group.description}
+
+ )}
+
+
+
+ handleEdit(group)}
+ className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
+ title="Edit group"
+ >
+
+
+ handleDeleteClick(group)}
+ className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
+ title="Delete group"
+ >
+
+
+
+
- const renderComingSoonTab = (tabName) => (
-
-
-
- {tabName} Coming Soon
-
-
- This feature is currently under development and will be available in a future update.
-
-
- )
+
+
+
+
+ {group._count.hosts} host
+ {group._count.hosts !== 1 ? "s" : ""}
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+ No host groups yet
+
+
+ Create your first host group to organize your hosts
+
+
setShowCreateModal(true)}
+ className="btn-primary flex items-center gap-2 mx-auto"
+ >
+
+ Create Group
+
+
+ )}
+
+ );
+ };
- return (
-
- {/* Page Header */}
-
-
- Options
-
-
- Configure PatchMon parameters and user preferences
-
-
+ const renderComingSoonTab = (tabName) => (
+
+
+
+ {tabName} Coming Soon
+
+
+ This feature is currently under development and will be available in a
+ future update.
+
+
+ );
- {/* Tabs */}
-
-
- {tabs.map((tab) => {
- const Icon = tab.icon
- return (
- setActiveTab(tab.id)}
- className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
- activeTab === tab.id
- ? 'border-primary-500 text-primary-600 dark:text-primary-400'
- : 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300'
- }`}
- >
-
- {tab.name}
- {tab.comingSoon && (
-
- Soon
-
- )}
-
- )
- })}
-
-
+ return (
+
+ {/* Page Header */}
+
+
+ Options
+
+
+ Configure PatchMon parameters and user preferences
+
+
- {/* Tab Content */}
-
- {activeTab === 'hostgroups' && renderHostGroupsTab()}
- {activeTab === 'notifications' && renderComingSoonTab('Notifications')}
-
+ {/* Tabs */}
+
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+ return (
+ setActiveTab(tab.id)}
+ className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
+ activeTab === tab.id
+ ? "border-primary-500 text-primary-600 dark:text-primary-400"
+ : "border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300"
+ }`}
+ >
+
+ {tab.name}
+ {tab.comingSoon && (
+
+ Soon
+
+ )}
+
+ );
+ })}
+
+
- {/* Create Modal */}
- {showCreateModal && (
-
setShowCreateModal(false)}
- onSubmit={handleCreate}
- isLoading={createMutation.isPending}
- />
- )}
+ {/* Tab Content */}
+
+ {activeTab === "hostgroups" && renderHostGroupsTab()}
+ {activeTab === "notifications" && renderComingSoonTab("Notifications")}
+
- {/* Edit Modal */}
- {showEditModal && selectedGroup && (
- {
- setShowEditModal(false)
- setSelectedGroup(null)
- }}
- onSubmit={handleUpdate}
- isLoading={updateMutation.isPending}
- />
- )}
+ {/* Create Modal */}
+ {showCreateModal && (
+ setShowCreateModal(false)}
+ onSubmit={handleCreate}
+ isLoading={createMutation.isPending}
+ />
+ )}
- {/* Delete Confirmation Modal */}
- {showDeleteModal && groupToDelete && (
- {
- setShowDeleteModal(false)
- setGroupToDelete(null)
- }}
- onConfirm={handleDeleteConfirm}
- isLoading={deleteMutation.isPending}
- />
- )}
-
- )
-}
+ {/* Edit Modal */}
+ {showEditModal && selectedGroup && (
+
{
+ setShowEditModal(false);
+ setSelectedGroup(null);
+ }}
+ onSubmit={handleUpdate}
+ isLoading={updateMutation.isPending}
+ />
+ )}
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteModal && groupToDelete && (
+ {
+ setShowDeleteModal(false);
+ setGroupToDelete(null);
+ }}
+ onConfirm={handleDeleteConfirm}
+ isLoading={deleteMutation.isPending}
+ />
+ )}
+
+ );
+};
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
- const [formData, setFormData] = useState({
- name: '',
- description: '',
- color: '#3B82F6'
- })
+ const [formData, setFormData] = useState({
+ name: "",
+ description: "",
+ color: "#3B82F6",
+ });
- const handleSubmit = (e) => {
- e.preventDefault()
- onSubmit(formData)
- }
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSubmit(formData);
+ };
- const handleChange = (e) => {
- setFormData({
- ...formData,
- [e.target.name]: e.target.value
- })
- }
+ const handleChange = (e) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value,
+ });
+ };
- return (
-
-
-
- Create Host Group
-
-
-
-
-
- Name *
-
-
-
-
-
-
- Description
-
-
-
-
-
-
-
-
- Cancel
-
-
- {isLoading ? 'Creating...' : 'Create Group'}
-
-
-
-
-
- )
-}
+ return (
+
+
+
+ Create Host Group
+
+
+
+
+
+ Name *
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {isLoading ? "Creating..." : "Create Group"}
+
+
+
+
+
+ );
+};
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
- const [formData, setFormData] = useState({
- name: group.name,
- description: group.description || '',
- color: group.color || '#3B82F6'
- })
+ const [formData, setFormData] = useState({
+ name: group.name,
+ description: group.description || "",
+ color: group.color || "#3B82F6",
+ });
- const handleSubmit = (e) => {
- e.preventDefault()
- onSubmit(formData)
- }
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSubmit(formData);
+ };
- const handleChange = (e) => {
- setFormData({
- ...formData,
- [e.target.name]: e.target.value
- })
- }
+ const handleChange = (e) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value,
+ });
+ };
- return (
-
-
-
- Edit Host Group
-
-
-
-
-
- Name *
-
-
-
-
-
-
- Description
-
-
-
-
-
-
-
-
- Cancel
-
-
- {isLoading ? 'Updating...' : 'Update Group'}
-
-
-
-
-
- )
-}
+ return (
+
+
+
+ Edit Host Group
+
+
+
+
+
+ Name *
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ {isLoading ? "Updating..." : "Update Group"}
+
+
+
+
+
+ );
+};
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
- return (
-
-
-
-
-
-
- Delete Host Group
-
-
- This action cannot be undone
-
-
-
-
-
-
- Are you sure you want to delete the host group{' '}
- "{group.name}" ?
-
- {group._count.hosts > 0 && (
-
-
- Warning: This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
- You must move or remove these hosts before deleting the group.
-
-
- )}
-
-
-
-
- Cancel
-
- 0}
- >
- {isLoading ? 'Deleting...' : 'Delete Group'}
-
-
-
-
- )
-}
+ return (
+
+
+
+
+
+
+ Delete Host Group
+
+
+ This action cannot be undone
+
+
+
-export default Options
+
+
+ Are you sure you want to delete the host group{" "}
+ "{group.name}" ?
+
+ {group._count.hosts > 0 && (
+
+
+ Warning: This group contains{" "}
+ {group._count.hosts} host{group._count.hosts !== 1 ? "s" : ""}.
+ You must move or remove these hosts before deleting the group.
+
+
+ )}
+
+
+
+
+ Cancel
+
+ 0}
+ >
+ {isLoading ? "Deleting..." : "Delete Group"}
+
+
+
+
+ );
+};
+
+export default Options;
diff --git a/frontend/src/pages/PackageDetail.jsx b/frontend/src/pages/PackageDetail.jsx
index 0c3fe29..e5cf916 100644
--- a/frontend/src/pages/PackageDetail.jsx
+++ b/frontend/src/pages/PackageDetail.jsx
@@ -1,25 +1,27 @@
-import React from 'react'
-import { useParams } from 'react-router-dom'
-import { Package } from 'lucide-react'
+import { Package } from "lucide-react";
+import React from "react";
+import { useParams } from "react-router-dom";
const PackageDetail = () => {
- const { packageId } = useParams()
-
- return (
-
-
-
-
-
Package Details
-
- Detailed view for package: {packageId}
-
-
- This page will show package information, affected hosts, version distribution, and more.
-
-
-
- )
-}
+ const { packageId } = useParams();
-export default PackageDetail
\ No newline at end of file
+ return (
+
+
+
+
+ Package Details
+
+
+ Detailed view for package: {packageId}
+
+
+ This page will show package information, affected hosts, version
+ distribution, and more.
+
+
+
+ );
+};
+
+export default PackageDetail;
diff --git a/frontend/src/pages/Packages.jsx b/frontend/src/pages/Packages.jsx
index 6b72265..876d7dc 100644
--- a/frontend/src/pages/Packages.jsx
+++ b/frontend/src/pages/Packages.jsx
@@ -1,629 +1,711 @@
-import React, { useState, useEffect, useMemo } from 'react'
-import { Link, useSearchParams, useNavigate } from 'react-router-dom'
-import { useQuery } from '@tanstack/react-query'
-import {
- Package,
- Server,
- Shield,
- RefreshCw,
- Search,
- AlertTriangle,
- Filter,
- ExternalLink,
- ArrowUpDown,
- ArrowUp,
- ArrowDown,
- ChevronDown,
- Settings,
- Columns,
- GripVertical,
- X,
- Eye as EyeIcon,
- EyeOff as EyeOffIcon
-} from 'lucide-react'
-import { dashboardAPI } from '../utils/api'
+import { useQuery } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ ArrowDown,
+ ArrowUp,
+ ArrowUpDown,
+ ChevronDown,
+ Columns,
+ ExternalLink,
+ Eye as EyeIcon,
+ EyeOff as EyeOffIcon,
+ Filter,
+ GripVertical,
+ Package,
+ RefreshCw,
+ Search,
+ Server,
+ Settings,
+ Shield,
+ X,
+} from "lucide-react";
+import React, { useEffect, useMemo, useState } from "react";
+import { Link, useNavigate, useSearchParams } from "react-router-dom";
+import { dashboardAPI } from "../utils/api";
const Packages = () => {
- const [searchTerm, setSearchTerm] = useState('')
- const [categoryFilter, setCategoryFilter] = useState('all')
- const [securityFilter, setSecurityFilter] = useState('all')
- const [hostFilter, setHostFilter] = useState('all')
- const [sortField, setSortField] = useState('name')
- const [sortDirection, setSortDirection] = useState('asc')
- const [showColumnSettings, setShowColumnSettings] = useState(false)
- const [searchParams] = useSearchParams()
- const navigate = useNavigate()
+ const [searchTerm, setSearchTerm] = useState("");
+ const [categoryFilter, setCategoryFilter] = useState("all");
+ const [securityFilter, setSecurityFilter] = useState("all");
+ const [hostFilter, setHostFilter] = useState("all");
+ const [sortField, setSortField] = useState("name");
+ const [sortDirection, setSortDirection] = useState("asc");
+ const [showColumnSettings, setShowColumnSettings] = useState(false);
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
- // Handle host filter from URL parameter
- useEffect(() => {
- const hostParam = searchParams.get('host')
- if (hostParam) {
- setHostFilter(hostParam)
- }
- }, [searchParams])
+ // Handle host filter from URL parameter
+ useEffect(() => {
+ const hostParam = searchParams.get("host");
+ if (hostParam) {
+ setHostFilter(hostParam);
+ }
+ }, [searchParams]);
- // Column configuration
- const [columnConfig, setColumnConfig] = useState(() => {
- const defaultConfig = [
- { id: 'name', label: 'Package', visible: true, order: 0 },
- { id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
- { id: 'priority', label: 'Priority', visible: true, order: 2 },
- { id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
- ]
+ // Column configuration
+ const [columnConfig, setColumnConfig] = useState(() => {
+ const defaultConfig = [
+ { id: "name", label: "Package", visible: true, order: 0 },
+ { id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
+ { id: "priority", label: "Priority", visible: true, order: 2 },
+ { id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
+ ];
- const saved = localStorage.getItem('packages-column-config')
- if (saved) {
- const savedConfig = JSON.parse(saved)
- // Merge with defaults to handle new columns
- return defaultConfig.map(defaultCol => {
- const savedCol = savedConfig.find(col => col.id === defaultCol.id)
- return savedCol ? { ...defaultCol, ...savedCol } : defaultCol
- })
- }
- return defaultConfig
- })
+ const saved = localStorage.getItem("packages-column-config");
+ if (saved) {
+ const savedConfig = JSON.parse(saved);
+ // Merge with defaults to handle new columns
+ return defaultConfig.map((defaultCol) => {
+ const savedCol = savedConfig.find((col) => col.id === defaultCol.id);
+ return savedCol ? { ...defaultCol, ...savedCol } : defaultCol;
+ });
+ }
+ return defaultConfig;
+ });
- // Update column configuration
- const updateColumnConfig = (newConfig) => {
- setColumnConfig(newConfig)
- localStorage.setItem('packages-column-config', JSON.stringify(newConfig))
- }
+ // Update column configuration
+ const updateColumnConfig = (newConfig) => {
+ setColumnConfig(newConfig);
+ localStorage.setItem("packages-column-config", JSON.stringify(newConfig));
+ };
- // Handle affected hosts click
- const handleAffectedHostsClick = (pkg) => {
- const affectedHosts = pkg.affectedHosts || []
- const hostIds = affectedHosts.map(host => host.hostId)
- const hostNames = affectedHosts.map(host => host.friendlyName)
-
- // Create URL with selected hosts and filter
- const params = new URLSearchParams()
- params.set('selected', hostIds.join(','))
- params.set('filter', 'selected')
-
- // Navigate to hosts page with selected hosts
- navigate(`/hosts?${params.toString()}`)
- }
+ // Handle affected hosts click
+ const handleAffectedHostsClick = (pkg) => {
+ const affectedHosts = pkg.affectedHosts || [];
+ const hostIds = affectedHosts.map((host) => host.hostId);
+ const hostNames = affectedHosts.map((host) => host.friendlyName);
- // Handle URL filter parameters
- useEffect(() => {
- const filter = searchParams.get('filter')
- if (filter === 'outdated') {
- // For outdated packages, we want to show all packages that need updates
- // This is the default behavior, so we don't need to change filters
- setCategoryFilter('all')
- setSecurityFilter('all')
- } else if (filter === 'security') {
- // For security updates, filter to show only security updates
- setSecurityFilter('security')
- setCategoryFilter('all')
- }
- }, [searchParams])
+ // Create URL with selected hosts and filter
+ const params = new URLSearchParams();
+ params.set("selected", hostIds.join(","));
+ params.set("filter", "selected");
- const { data: packages, isLoading, error, refetch, isFetching } = useQuery({
- queryKey: ['packages'],
- queryFn: () => dashboardAPI.getPackages().then(res => res.data),
- staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
- refetchOnWindowFocus: false, // Don't refetch when window regains focus
- })
+ // Navigate to hosts page with selected hosts
+ navigate(`/hosts?${params.toString()}`);
+ };
- // Fetch hosts data to get total packages count
- const { data: hosts } = useQuery({
- queryKey: ['hosts'],
- queryFn: () => dashboardAPI.getHosts().then(res => res.data),
- staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
- refetchOnWindowFocus: false, // Don't refetch when window regains focus
- })
+ // Handle URL filter parameters
+ useEffect(() => {
+ const filter = searchParams.get("filter");
+ if (filter === "outdated") {
+ // For outdated packages, we want to show all packages that need updates
+ // This is the default behavior, so we don't need to change filters
+ setCategoryFilter("all");
+ setSecurityFilter("all");
+ } else if (filter === "security") {
+ // For security updates, filter to show only security updates
+ setSecurityFilter("security");
+ setCategoryFilter("all");
+ }
+ }, [searchParams]);
- // Filter and sort packages
- const filteredAndSortedPackages = useMemo(() => {
- if (!packages) return []
-
- // Filter packages
- const filtered = packages.filter(pkg => {
- const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
- (pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
-
- const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
-
- const matchesSecurity = securityFilter === 'all' ||
- (securityFilter === 'security' && pkg.isSecurityUpdate) ||
- (securityFilter === 'regular' && !pkg.isSecurityUpdate)
-
- const affectedHosts = pkg.affectedHosts || []
- const matchesHost = hostFilter === 'all' ||
- affectedHosts.some(host => host.hostId === hostFilter)
-
- return matchesSearch && matchesCategory && matchesSecurity && matchesHost
- })
+ const {
+ data: packages,
+ isLoading,
+ error,
+ refetch,
+ isFetching,
+ } = useQuery({
+ queryKey: ["packages"],
+ queryFn: () => dashboardAPI.getPackages().then((res) => res.data),
+ staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
+ refetchOnWindowFocus: false, // Don't refetch when window regains focus
+ });
- // Sorting
- filtered.sort((a, b) => {
- let aValue, bValue
-
- switch (sortField) {
- case 'name':
- aValue = a.name?.toLowerCase() || ''
- bValue = b.name?.toLowerCase() || ''
- break
- case 'latestVersion':
- aValue = a.latestVersion?.toLowerCase() || ''
- bValue = b.latestVersion?.toLowerCase() || ''
- break
- case 'affectedHosts':
- aValue = a.affectedHostsCount || a.affectedHosts?.length || 0
- bValue = b.affectedHostsCount || b.affectedHosts?.length || 0
- break
- case 'priority':
- aValue = a.isSecurityUpdate ? 0 : 1 // Security updates first
- bValue = b.isSecurityUpdate ? 0 : 1
- break
- default:
- aValue = a.name?.toLowerCase() || ''
- bValue = b.name?.toLowerCase() || ''
- }
+ // Fetch hosts data to get total packages count
+ const { data: hosts } = useQuery({
+ queryKey: ["hosts"],
+ queryFn: () => dashboardAPI.getHosts().then((res) => res.data),
+ staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
+ refetchOnWindowFocus: false, // Don't refetch when window regains focus
+ });
- if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1
- if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1
- return 0
- })
+ // Filter and sort packages
+ const filteredAndSortedPackages = useMemo(() => {
+ if (!packages) return [];
- return filtered
- }, [packages, searchTerm, categoryFilter, securityFilter, sortField, sortDirection])
+ // Filter packages
+ const filtered = packages.filter((pkg) => {
+ const matchesSearch =
+ pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ (pkg.description &&
+ pkg.description.toLowerCase().includes(searchTerm.toLowerCase()));
- // Get visible columns in order
- const visibleColumns = columnConfig
- .filter(col => col.visible)
- .sort((a, b) => a.order - b.order)
+ const matchesCategory =
+ categoryFilter === "all" || pkg.category === categoryFilter;
- // Sorting functions
- const handleSort = (field) => {
- if (sortField === field) {
- setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
- } else {
- setSortField(field)
- setSortDirection('asc')
- }
- }
+ const matchesSecurity =
+ securityFilter === "all" ||
+ (securityFilter === "security" && pkg.isSecurityUpdate) ||
+ (securityFilter === "regular" && !pkg.isSecurityUpdate);
- const getSortIcon = (field) => {
- if (sortField !== field) return
- return sortDirection === 'asc' ? :
- }
+ const affectedHosts = pkg.affectedHosts || [];
+ const matchesHost =
+ hostFilter === "all" ||
+ affectedHosts.some((host) => host.hostId === hostFilter);
- // Column management functions
- const toggleColumnVisibility = (columnId) => {
- const newConfig = columnConfig.map(col =>
- col.id === columnId ? { ...col, visible: !col.visible } : col
- )
- updateColumnConfig(newConfig)
- }
+ return matchesSearch && matchesCategory && matchesSecurity && matchesHost;
+ });
- const reorderColumns = (fromIndex, toIndex) => {
- const newConfig = [...columnConfig]
- const [movedColumn] = newConfig.splice(fromIndex, 1)
- newConfig.splice(toIndex, 0, movedColumn)
-
- // Update order values
- const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
- updateColumnConfig(updatedConfig)
- }
+ // Sorting
+ filtered.sort((a, b) => {
+ let aValue, bValue;
- const resetColumns = () => {
- const defaultConfig = [
- { id: 'name', label: 'Package', visible: true, order: 0 },
- { id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
- { id: 'priority', label: 'Priority', visible: true, order: 2 },
- { id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
- ]
- updateColumnConfig(defaultConfig)
- }
+ switch (sortField) {
+ case "name":
+ aValue = a.name?.toLowerCase() || "";
+ bValue = b.name?.toLowerCase() || "";
+ break;
+ case "latestVersion":
+ aValue = a.latestVersion?.toLowerCase() || "";
+ bValue = b.latestVersion?.toLowerCase() || "";
+ break;
+ case "affectedHosts":
+ aValue = a.affectedHostsCount || a.affectedHosts?.length || 0;
+ bValue = b.affectedHostsCount || b.affectedHosts?.length || 0;
+ break;
+ case "priority":
+ aValue = a.isSecurityUpdate ? 0 : 1; // Security updates first
+ bValue = b.isSecurityUpdate ? 0 : 1;
+ break;
+ default:
+ aValue = a.name?.toLowerCase() || "";
+ bValue = b.name?.toLowerCase() || "";
+ }
- // Helper function to render table cell content
- const renderCellContent = (column, pkg) => {
- switch (column.id) {
- case 'name':
- return (
-
-
-
-
- {pkg.name}
-
- {pkg.description && (
-
- {pkg.description}
-
- )}
- {pkg.category && (
-
- Category: {pkg.category}
-
- )}
-
-
- )
- case 'affectedHosts':
- const affectedHostsCount = pkg.affectedHostsCount || pkg.affectedHosts?.length || 0
- return (
- handleAffectedHostsClick(pkg)}
- className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
- title={`Click to view all ${affectedHostsCount} affected hosts`}
- >
-
- {affectedHostsCount} host{affectedHostsCount !== 1 ? 's' : ''}
-
-
- )
- case 'priority':
- return pkg.isSecurityUpdate ? (
-
-
- Security Update
-
- ) : (
- Regular Update
- )
- case 'latestVersion':
- return (
-
- {pkg.latestVersion || 'Unknown'}
-
- )
- default:
- return null
- }
- }
+ if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
+ if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
+ return 0;
+ });
- // Get unique categories
- const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
+ return filtered;
+ }, [
+ packages,
+ searchTerm,
+ categoryFilter,
+ securityFilter,
+ sortField,
+ sortDirection,
+ ]);
- // Calculate unique affected hosts
- const uniqueAffectedHosts = new Set()
- packages?.forEach(pkg => {
- const affectedHosts = pkg.affectedHosts || []
- affectedHosts.forEach(host => {
- uniqueAffectedHosts.add(host.hostId)
- })
- })
- const uniqueAffectedHostsCount = uniqueAffectedHosts.size
+ // Get visible columns in order
+ const visibleColumns = columnConfig
+ .filter((col) => col.visible)
+ .sort((a, b) => a.order - b.order);
- // Calculate total packages across all hosts (including up-to-date ones)
- const totalPackagesCount = hosts?.reduce((total, host) => {
- return total + (host.totalPackagesCount || 0)
- }, 0) || 0
+ // Sorting functions
+ const handleSort = (field) => {
+ if (sortField === field) {
+ setSortDirection(sortDirection === "asc" ? "desc" : "asc");
+ } else {
+ setSortField(field);
+ setSortDirection("asc");
+ }
+ };
- // Calculate outdated packages (packages that need updates)
- const outdatedPackagesCount = packages?.length || 0
+ const getSortIcon = (field) => {
+ if (sortField !== field) return ;
+ return sortDirection === "asc" ? (
+
+ ) : (
+
+ );
+ };
- // Calculate security updates
- const securityUpdatesCount = packages?.filter(pkg => pkg.isSecurityUpdate).length || 0
+ // Column management functions
+ const toggleColumnVisibility = (columnId) => {
+ const newConfig = columnConfig.map((col) =>
+ col.id === columnId ? { ...col, visible: !col.visible } : col,
+ );
+ updateColumnConfig(newConfig);
+ };
- if (isLoading) {
- return (
-
-
-
- )
- }
+ const reorderColumns = (fromIndex, toIndex) => {
+ const newConfig = [...columnConfig];
+ const [movedColumn] = newConfig.splice(fromIndex, 1);
+ newConfig.splice(toIndex, 0, movedColumn);
- if (error) {
- return (
-
-
-
-
-
-
-
Error loading packages
-
- {error.message || 'Failed to load packages'}
-
-
refetch()}
- className="mt-2 btn-danger text-xs"
- >
- Try again
-
-
-
-
-
- )
- }
+ // Update order values
+ const updatedConfig = newConfig.map((col, index) => ({
+ ...col,
+ order: index,
+ }));
+ updateColumnConfig(updatedConfig);
+ };
- return (
-
- {/* Page Header */}
-
-
-
Packages
-
- Manage package updates and security patches
-
-
-
- refetch()}
- disabled={isFetching}
- className="btn-outline flex items-center gap-2"
- title="Refresh packages data"
- >
-
- {isFetching ? 'Refreshing...' : 'Refresh'}
-
-
-
+ const resetColumns = () => {
+ const defaultConfig = [
+ { id: "name", label: "Package", visible: true, order: 0 },
+ { id: "affectedHosts", label: "Affected Hosts", visible: true, order: 1 },
+ { id: "priority", label: "Priority", visible: true, order: 2 },
+ { id: "latestVersion", label: "Latest Version", visible: true, order: 3 },
+ ];
+ updateColumnConfig(defaultConfig);
+ };
- {/* Summary Stats */}
-
-
-
-
-
-
Total Packages
-
{totalPackagesCount}
-
-
-
-
-
-
-
-
-
Total Outdated Packages
-
- {outdatedPackagesCount}
-
-
-
-
-
-
-
-
-
-
Hosts Pending Updates
-
- {uniqueAffectedHostsCount}
-
-
-
-
-
-
-
-
-
-
Security Updates Across All Hosts
-
{securityUpdatesCount}
-
-
-
-
+ // Helper function to render table cell content
+ const renderCellContent = (column, pkg) => {
+ switch (column.id) {
+ case "name":
+ return (
+
+
+
+
+ {pkg.name}
+
+ {pkg.description && (
+
+ {pkg.description}
+
+ )}
+ {pkg.category && (
+
+ Category: {pkg.category}
+
+ )}
+
+
+ );
+ case "affectedHosts": {
+ const affectedHostsCount =
+ pkg.affectedHostsCount || pkg.affectedHosts?.length || 0;
+ return (
+
handleAffectedHostsClick(pkg)}
+ className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
+ title={`Click to view all ${affectedHostsCount} affected hosts`}
+ >
+
+ {affectedHostsCount} host{affectedHostsCount !== 1 ? "s" : ""}
+
+
+ );
+ }
+ case "priority":
+ return pkg.isSecurityUpdate ? (
+
+
+ Security Update
+
+ ) : (
+
Regular Update
+ );
+ case "latestVersion":
+ return (
+
+ {pkg.latestVersion || "Unknown"}
+
+ );
+ default:
+ return null;
+ }
+ };
- {/* Packages List */}
-
-
-
- {/* Empty selection controls area to match hosts page spacing */}
-
-
- {/* Table Controls */}
-
-
- {/* Search */}
-
-
-
- setSearchTerm(e.target.value)}
- className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
- />
-
-
-
- {/* Category Filter */}
-
- setCategoryFilter(e.target.value)}
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
- >
- All Categories
- {categories.map(category => (
- {category}
- ))}
-
-
-
- {/* Security Filter */}
-
- setSecurityFilter(e.target.value)}
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
- >
- All Updates
- Security Only
- Regular Only
-
-
-
- {/* Host Filter */}
-
- setHostFilter(e.target.value)}
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
- >
- All Hosts
- {hosts?.map(host => (
- {host.friendly_name}
- ))}
-
-
-
- {/* Columns Button */}
-
- setShowColumnSettings(true)}
- className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"
- >
-
- Columns
-
-
-
-
-
-
- {filteredAndSortedPackages.length === 0 ? (
-
-
-
- {packages?.length === 0 ? 'No packages need updates' : 'No packages match your filters'}
-
- {packages?.length === 0 && (
-
- All packages are up to date across all hosts
-
- )}
-
- ) : (
-
-
-
-
- {visibleColumns.map((column) => (
-
- handleSort(column.id)}
- className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
- >
- {column.label}
- {getSortIcon(column.id)}
-
-
- ))}
-
-
-
- {filteredAndSortedPackages.map((pkg) => (
-
- {visibleColumns.map((column) => (
-
- {renderCellContent(column, pkg)}
-
- ))}
-
- ))}
-
-
-
- )}
-
-
-
+ // Get unique categories
+ const categories =
+ [...new Set(packages?.map((pkg) => pkg.category).filter(Boolean))] || [];
- {/* Column Settings Modal */}
- {showColumnSettings && (
-
setShowColumnSettings(false)}
- onToggleVisibility={toggleColumnVisibility}
- onReorder={reorderColumns}
- onReset={resetColumns}
- />
- )}
-
- )
-}
+ // Calculate unique affected hosts
+ const uniqueAffectedHosts = new Set();
+ packages?.forEach((pkg) => {
+ const affectedHosts = pkg.affectedHosts || [];
+ affectedHosts.forEach((host) => {
+ uniqueAffectedHosts.add(host.hostId);
+ });
+ });
+ const uniqueAffectedHostsCount = uniqueAffectedHosts.size;
+
+ // Calculate total packages across all hosts (including up-to-date ones)
+ const totalPackagesCount =
+ hosts?.reduce((total, host) => {
+ return total + (host.totalPackagesCount || 0);
+ }, 0) || 0;
+
+ // Calculate outdated packages (packages that need updates)
+ const outdatedPackagesCount = packages?.length || 0;
+
+ // Calculate security updates
+ const securityUpdatesCount =
+ packages?.filter((pkg) => pkg.isSecurityUpdate).length || 0;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+
+ Error loading packages
+
+
+ {error.message || "Failed to load packages"}
+
+
refetch()}
+ className="mt-2 btn-danger text-xs"
+ >
+ Try again
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Page Header */}
+
+
+
+ Packages
+
+
+ Manage package updates and security patches
+
+
+
+ refetch()}
+ disabled={isFetching}
+ className="btn-outline flex items-center gap-2"
+ title="Refresh packages data"
+ >
+
+ {isFetching ? "Refreshing..." : "Refresh"}
+
+
+
+
+ {/* Summary Stats */}
+
+
+
+
+
+
+ Total Packages
+
+
+ {totalPackagesCount}
+
+
+
+
+
+
+
+
+
+
+ Total Outdated Packages
+
+
+ {outdatedPackagesCount}
+
+
+
+
+
+
+
+
+
+
+ Hosts Pending Updates
+
+
+ {uniqueAffectedHostsCount}
+
+
+
+
+
+
+
+
+
+
+ Security Updates Across All Hosts
+
+
+ {securityUpdatesCount}
+
+
+
+
+
+
+ {/* Packages List */}
+
+
+
+ {/* Empty selection controls area to match hosts page spacing */}
+
+
+ {/* Table Controls */}
+
+
+ {/* Search */}
+
+
+
+ setSearchTerm(e.target.value)}
+ className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
+ />
+
+
+
+ {/* Category Filter */}
+
+ setCategoryFilter(e.target.value)}
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
+ >
+ All Categories
+ {categories.map((category) => (
+
+ {category}
+
+ ))}
+
+
+
+ {/* Security Filter */}
+
+ setSecurityFilter(e.target.value)}
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
+ >
+ All Updates
+ Security Only
+ Regular Only
+
+
+
+ {/* Host Filter */}
+
+ setHostFilter(e.target.value)}
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
+ >
+ All Hosts
+ {hosts?.map((host) => (
+
+ {host.friendly_name}
+
+ ))}
+
+
+
+ {/* Columns Button */}
+
+ setShowColumnSettings(true)}
+ className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"
+ >
+
+ Columns
+
+
+
+
+
+
+ {filteredAndSortedPackages.length === 0 ? (
+
+
+
+ {packages?.length === 0
+ ? "No packages need updates"
+ : "No packages match your filters"}
+
+ {packages?.length === 0 && (
+
+ All packages are up to date across all hosts
+
+ )}
+
+ ) : (
+
+
+
+
+ {visibleColumns.map((column) => (
+
+ handleSort(column.id)}
+ className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
+ >
+ {column.label}
+ {getSortIcon(column.id)}
+
+
+ ))}
+
+
+
+ {filteredAndSortedPackages.map((pkg) => (
+
+ {visibleColumns.map((column) => (
+
+ {renderCellContent(column, pkg)}
+
+ ))}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ {/* Column Settings Modal */}
+ {showColumnSettings && (
+
setShowColumnSettings(false)}
+ onToggleVisibility={toggleColumnVisibility}
+ onReorder={reorderColumns}
+ onReset={resetColumns}
+ />
+ )}
+
+ );
+};
// Column Settings Modal Component
-const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
- const [draggedIndex, setDraggedIndex] = useState(null)
+const ColumnSettingsModal = ({
+ columnConfig,
+ onClose,
+ onToggleVisibility,
+ onReorder,
+ onReset,
+}) => {
+ const [draggedIndex, setDraggedIndex] = useState(null);
- const handleDragStart = (e, index) => {
- setDraggedIndex(index)
- e.dataTransfer.effectAllowed = 'move'
- }
+ const handleDragStart = (e, index) => {
+ setDraggedIndex(index);
+ e.dataTransfer.effectAllowed = "move";
+ };
- const handleDragOver = (e) => {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- }
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ };
- const handleDrop = (e, dropIndex) => {
- e.preventDefault()
- if (draggedIndex !== null && draggedIndex !== dropIndex) {
- onReorder(draggedIndex, dropIndex)
- }
- setDraggedIndex(null)
- }
+ const handleDrop = (e, dropIndex) => {
+ e.preventDefault();
+ if (draggedIndex !== null && draggedIndex !== dropIndex) {
+ onReorder(draggedIndex, dropIndex);
+ }
+ setDraggedIndex(null);
+ };
- return (
-
-
-
-
Customize Columns
-
-
-
-
-
-
- {columnConfig.map((column, index) => (
-
handleDragStart(e, index)}
- onDragOver={handleDragOver}
- onDrop={(e) => handleDrop(e, index)}
- className={`flex items-center justify-between p-3 border rounded-lg cursor-move ${
- draggedIndex === index ? 'opacity-50' : 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
- } border-secondary-200 dark:border-secondary-600`}
- >
-
-
-
- {column.label}
-
-
-
onToggleVisibility(column.id)}
- className={`p-1 rounded ${
- column.visible
- ? 'text-primary-600 hover:text-primary-700'
- : 'text-secondary-400 hover:text-secondary-600'
- }`}
- >
- {column.visible ? : }
-
-
- ))}
-
-
-
-
- Reset to Default
-
-
- Done
-
-
-
-
- )
-}
+ return (
+
+
+
+
+ Customize Columns
+
+
+
+
+
-export default Packages
\ No newline at end of file
+
+ {columnConfig.map((column, index) => (
+
handleDragStart(e, index)}
+ onDragOver={handleDragOver}
+ onDrop={(e) => handleDrop(e, index)}
+ className={`flex items-center justify-between p-3 border rounded-lg cursor-move ${
+ draggedIndex === index
+ ? "opacity-50"
+ : "hover:bg-secondary-50 dark:hover:bg-secondary-700"
+ } border-secondary-200 dark:border-secondary-600`}
+ >
+
+
+
+ {column.label}
+
+
+
onToggleVisibility(column.id)}
+ className={`p-1 rounded ${
+ column.visible
+ ? "text-primary-600 hover:text-primary-700"
+ : "text-secondary-400 hover:text-secondary-600"
+ }`}
+ >
+ {column.visible ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+
+ Reset to Default
+
+
+ Done
+
+
+
+
+ );
+};
+
+export default Packages;
diff --git a/frontend/src/pages/Permissions.jsx b/frontend/src/pages/Permissions.jsx
index 0529bca..4ca9559 100644
--- a/frontend/src/pages/Permissions.jsx
+++ b/frontend/src/pages/Permissions.jsx
@@ -1,394 +1,479 @@
-import React, { useState, useEffect } from 'react'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import {
- Shield,
- Settings,
- Users,
- Server,
- Package,
- BarChart3,
- Download,
- Eye,
- Edit,
- Trash2,
- Plus,
- Save,
- X,
- AlertTriangle,
- RefreshCw
-} from 'lucide-react'
-import { permissionsAPI } from '../utils/api'
-import { useAuth } from '../contexts/AuthContext'
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ BarChart3,
+ Download,
+ Edit,
+ Eye,
+ Package,
+ Plus,
+ RefreshCw,
+ Save,
+ Server,
+ Settings,
+ Shield,
+ Trash2,
+ Users,
+ X,
+} from "lucide-react";
+import React, { useEffect, useState } from "react";
+import { useAuth } from "../contexts/AuthContext";
+import { permissionsAPI } from "../utils/api";
const Permissions = () => {
- const [editingRole, setEditingRole] = useState(null)
- const [showAddModal, setShowAddModal] = useState(false)
- const queryClient = useQueryClient()
- const { refreshPermissions } = useAuth()
+ const [editingRole, setEditingRole] = useState(null);
+ const [showAddModal, setShowAddModal] = useState(false);
+ const queryClient = useQueryClient();
+ const { refreshPermissions } = useAuth();
- // Fetch all role permissions
- const { data: roles, isLoading, error } = useQuery({
- queryKey: ['rolePermissions'],
- queryFn: () => permissionsAPI.getRoles().then(res => res.data)
- })
+ // Fetch all role permissions
+ const {
+ data: roles,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["rolePermissions"],
+ queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
+ });
- // Update role permissions mutation
- const updateRoleMutation = useMutation({
- mutationFn: ({ role, permissions }) => permissionsAPI.updateRole(role, permissions),
- onSuccess: () => {
- queryClient.invalidateQueries(['rolePermissions'])
- setEditingRole(null)
- // Refresh user permissions to apply changes immediately
- refreshPermissions()
- }
- })
+ // Update role permissions mutation
+ const updateRoleMutation = useMutation({
+ mutationFn: ({ role, permissions }) =>
+ permissionsAPI.updateRole(role, permissions),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["rolePermissions"]);
+ setEditingRole(null);
+ // Refresh user permissions to apply changes immediately
+ refreshPermissions();
+ },
+ });
- // Delete role mutation
- const deleteRoleMutation = useMutation({
- mutationFn: (role) => permissionsAPI.deleteRole(role),
- onSuccess: () => {
- queryClient.invalidateQueries(['rolePermissions'])
- }
- })
+ // Delete role mutation
+ const deleteRoleMutation = useMutation({
+ mutationFn: (role) => permissionsAPI.deleteRole(role),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["rolePermissions"]);
+ },
+ });
- const handleSavePermissions = async (role, permissions) => {
- try {
- await updateRoleMutation.mutateAsync({ role, permissions })
- } catch (error) {
- console.error('Failed to update permissions:', error)
- }
- }
+ const handleSavePermissions = async (role, permissions) => {
+ try {
+ await updateRoleMutation.mutateAsync({ role, permissions });
+ } catch (error) {
+ console.error("Failed to update permissions:", error);
+ }
+ };
- const handleDeleteRole = async (role) => {
- if (window.confirm(`Are you sure you want to delete the "${role}" role? This action cannot be undone.`)) {
- try {
- await deleteRoleMutation.mutateAsync(role)
- } catch (error) {
- console.error('Failed to delete role:', error)
- }
- }
- }
+ const handleDeleteRole = async (role) => {
+ if (
+ window.confirm(
+ `Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
+ )
+ ) {
+ try {
+ await deleteRoleMutation.mutateAsync(role);
+ } catch (error) {
+ console.error("Failed to delete role:", error);
+ }
+ }
+ };
- if (isLoading) {
- return (
-
- )
- }
+ if (isLoading) {
+ return (
+
+ );
+ }
- if (error) {
- return (
-
-
-
-
-
Error loading permissions
-
{error.message}
-
-
-
- )
- }
+ if (error) {
+ return (
+
+
+
+
+
+ Error loading permissions
+
+
{error.message}
+
+
+
+ );
+ }
- return (
-
- {/* Header */}
-
-
-
refreshPermissions()}
- className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
- >
-
- Refresh Permissions
-
-
setShowAddModal(true)}
- className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
- >
-
- Add Role
-
-
-
+ return (
+
+ {/* Header */}
+
+
+
refreshPermissions()}
+ className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
+ >
+
+ Refresh Permissions
+
+
setShowAddModal(true)}
+ className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
+ >
+
+ Add Role
+
+
+
- {/* Roles List */}
-
- {roles && Array.isArray(roles) && roles.map((role) => (
- setEditingRole(role.role)}
- onCancel={() => setEditingRole(null)}
- onSave={handleSavePermissions}
- onDelete={handleDeleteRole}
- />
- ))}
-
+ {/* Roles List */}
+
+ {roles &&
+ Array.isArray(roles) &&
+ roles.map((role) => (
+ setEditingRole(role.role)}
+ onCancel={() => setEditingRole(null)}
+ onSave={handleSavePermissions}
+ onDelete={handleDeleteRole}
+ />
+ ))}
+
- {/* Add Role Modal */}
-
setShowAddModal(false)}
- onSuccess={() => {
- queryClient.invalidateQueries(['rolePermissions'])
- setShowAddModal(false)
- }}
- />
-
- )
-}
+ {/* Add Role Modal */}
+
setShowAddModal(false)}
+ onSuccess={() => {
+ queryClient.invalidateQueries(["rolePermissions"]);
+ setShowAddModal(false);
+ }}
+ />
+
+ );
+};
// Role Permissions Card Component
-const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
- const [permissions, setPermissions] = useState(role)
+const RolePermissionsCard = ({
+ role,
+ isEditing,
+ onEdit,
+ onCancel,
+ onSave,
+ onDelete,
+}) => {
+ const [permissions, setPermissions] = useState(role);
- // Sync permissions state with role prop when it changes
- useEffect(() => {
- setPermissions(role)
- }, [role])
+ // Sync permissions state with role prop when it changes
+ useEffect(() => {
+ setPermissions(role);
+ }, [role]);
- const permissionFields = [
- { key: 'can_view_dashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
- { key: 'can_view_hosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
- { key: 'can_manage_hosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
- { key: 'can_view_packages', label: 'View Packages', icon: Package, description: 'See package information' },
- { key: 'can_manage_packages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
- { key: 'can_view_users', label: 'View Users', icon: Users, description: 'See user list and details' },
- { key: 'can_manage_users', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
- { key: 'can_view_reports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
- { key: 'can_export_data', label: 'Export Data', icon: Download, description: 'Download data and reports' },
- { key: 'can_manage_settings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
- ]
+ const permissionFields = [
+ {
+ key: "can_view_dashboard",
+ label: "View Dashboard",
+ icon: BarChart3,
+ description: "Access to the main dashboard",
+ },
+ {
+ key: "can_view_hosts",
+ label: "View Hosts",
+ icon: Server,
+ description: "See host information and status",
+ },
+ {
+ key: "can_manage_hosts",
+ label: "Manage Hosts",
+ icon: Edit,
+ description: "Add, edit, and delete hosts",
+ },
+ {
+ key: "can_view_packages",
+ label: "View Packages",
+ icon: Package,
+ description: "See package information",
+ },
+ {
+ key: "can_manage_packages",
+ label: "Manage Packages",
+ icon: Settings,
+ description: "Edit package details",
+ },
+ {
+ key: "can_view_users",
+ label: "View Users",
+ icon: Users,
+ description: "See user list and details",
+ },
+ {
+ key: "can_manage_users",
+ label: "Manage Users",
+ icon: Shield,
+ description: "Add, edit, and delete users",
+ },
+ {
+ key: "can_view_reports",
+ label: "View Reports",
+ icon: BarChart3,
+ description: "Access to reports and analytics",
+ },
+ {
+ key: "can_export_data",
+ label: "Export Data",
+ icon: Download,
+ description: "Download data and reports",
+ },
+ {
+ key: "can_manage_settings",
+ label: "Manage Settings",
+ icon: Settings,
+ description: "System configuration access",
+ },
+ ];
- const handlePermissionChange = (key, value) => {
- setPermissions(prev => ({
- ...prev,
- [key]: value
- }))
- }
+ const handlePermissionChange = (key, value) => {
+ setPermissions((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
- const handleSave = () => {
- onSave(role.role, permissions)
- }
+ const handleSave = () => {
+ onSave(role.role, permissions);
+ };
- const isBuiltInRole = role.role === 'admin' || role.role === 'user'
+ const isBuiltInRole = role.role === "admin" || role.role === "user";
- return (
-
-
-
-
-
-
{role.role}
- {isBuiltInRole && (
-
- Built-in Role
-
- )}
-
-
- {isEditing ? (
- <>
-
-
- Save
-
-
-
- Cancel
-
- >
- ) : (
- <>
-
-
- Edit
-
- {!isBuiltInRole && (
- onDelete(role.role)}
- className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
- >
-
- Delete
-
- )}
- >
- )}
-
-
-
+ return (
+
+
+
+
+
+
+ {role.role}
+
+ {isBuiltInRole && (
+
+ Built-in Role
+
+ )}
+
+
+ {isEditing ? (
+ <>
+
+
+ Save
+
+
+
+ Cancel
+
+ >
+ ) : (
+ <>
+
+
+ Edit
+
+ {!isBuiltInRole && (
+ onDelete(role.role)}
+ className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
+ >
+
+ Delete
+
+ )}
+ >
+ )}
+
+
+
-
-
- {permissionFields.map((field) => {
- const Icon = field.icon
- const isChecked = permissions[field.key]
-
- return (
-
-
- handlePermissionChange(field.key, e.target.checked)}
- disabled={!isEditing || (isBuiltInRole && field.key === 'can_manage_users')}
- className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
- />
-
-
-
-
-
- {field.label}
-
-
-
- {field.description}
-
-
-
- )
- })}
-
-
-
- )
-}
+
+
+ {permissionFields.map((field) => {
+ const Icon = field.icon;
+ const isChecked = permissions[field.key];
+
+ return (
+
+
+
+ handlePermissionChange(field.key, e.target.checked)
+ }
+ disabled={
+ !isEditing ||
+ (isBuiltInRole && field.key === "can_manage_users")
+ }
+ className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
+ />
+
+
+
+
+
+ {field.label}
+
+
+
+ {field.description}
+
+
+
+ );
+ })}
+
+
+
+ );
+};
// Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
- const [formData, setFormData] = useState({
- role: '',
- can_view_dashboard: true,
- can_view_hosts: true,
- can_manage_hosts: false,
- can_view_packages: true,
- can_manage_packages: false,
- can_view_users: false,
- can_manage_users: false,
- can_view_reports: true,
- can_export_data: false,
- can_manage_settings: false
- })
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState('')
+ const [formData, setFormData] = useState({
+ role: "",
+ can_view_dashboard: true,
+ can_view_hosts: true,
+ can_manage_hosts: false,
+ can_view_packages: true,
+ can_manage_packages: false,
+ can_view_users: false,
+ can_manage_users: false,
+ can_view_reports: true,
+ can_export_data: false,
+ can_manage_settings: false,
+ });
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
- const handleSubmit = async (e) => {
- e.preventDefault()
- setIsLoading(true)
- setError('')
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError("");
- try {
- await permissionsAPI.updateRole(formData.role, formData)
- onSuccess()
- } catch (err) {
- setError(err.response?.data?.error || 'Failed to create role')
- } finally {
- setIsLoading(false)
- }
- }
+ try {
+ await permissionsAPI.updateRole(formData.role, formData);
+ onSuccess();
+ } catch (err) {
+ setError(err.response?.data?.error || "Failed to create role");
+ } finally {
+ setIsLoading(false);
+ }
+ };
- const handleInputChange = (e) => {
- const { name, value, type, checked } = e.target
- setFormData({
- ...formData,
- [name]: type === 'checkbox' ? checked : value
- })
- }
+ const handleInputChange = (e) => {
+ const { name, value, type, checked } = e.target;
+ setFormData({
+ ...formData,
+ [name]: type === "checkbox" ? checked : value,
+ });
+ };
- if (!isOpen) return null
+ if (!isOpen) return null;
- return (
-
-
-
Add New Role
-
-
-
-
- Role Name
-
-
-
Use lowercase with underscores (e.g., host_manager)
-
+ return (
+
+
+
+ Add New Role
+
-
-
Permissions
- {[
- { key: 'can_view_dashboard', label: 'View Dashboard' },
- { key: 'can_view_hosts', label: 'View Hosts' },
- { key: 'can_manage_hosts', label: 'Manage Hosts' },
- { key: 'can_view_packages', label: 'View Packages' },
- { key: 'can_manage_packages', label: 'Manage Packages' },
- { key: 'can_view_users', label: 'View Users' },
- { key: 'can_manage_users', label: 'Manage Users' },
- { key: 'can_view_reports', label: 'View Reports' },
- { key: 'can_export_data', label: 'Export Data' },
- { key: 'can_manage_settings', label: 'Manage Settings' }
- ].map((permission) => (
-
-
-
- {permission.label}
-
-
- ))}
-
+
+
+
+ Role Name
+
+
+
+ Use lowercase with underscores (e.g., host_manager)
+
+
- {error && (
-
- )}
+
+
+ Permissions
+
+ {[
+ { key: "can_view_dashboard", label: "View Dashboard" },
+ { key: "can_view_hosts", label: "View Hosts" },
+ { key: "can_manage_hosts", label: "Manage Hosts" },
+ { key: "can_view_packages", label: "View Packages" },
+ { key: "can_manage_packages", label: "Manage Packages" },
+ { key: "can_view_users", label: "View Users" },
+ { key: "can_manage_users", label: "Manage Users" },
+ { key: "can_view_reports", label: "View Reports" },
+ { key: "can_export_data", label: "Export Data" },
+ { key: "can_manage_settings", label: "Manage Settings" },
+ ].map((permission) => (
+
+
+
+ {permission.label}
+
+
+ ))}
+
-
-
- Cancel
-
-
- {isLoading ? 'Creating...' : 'Create Role'}
-
-
-
-
-
- )
-}
+ {error && (
+
+ )}
-export default Permissions
+
+
+ Cancel
+
+
+ {isLoading ? "Creating..." : "Create Role"}
+
+
+
+
+
+ );
+};
+
+export default Permissions;
diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx
index 9c30c8e..af2c8cc 100644
--- a/frontend/src/pages/Profile.jsx
+++ b/frontend/src/pages/Profile.jsx
@@ -1,897 +1,1047 @@
-import React, { useState } from 'react'
-import { useAuth } from '../contexts/AuthContext'
-import { useTheme } from '../contexts/ThemeContext'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import {
- User,
- Mail,
- Shield,
- Key,
- Save,
- Eye,
- EyeOff,
- CheckCircle,
- AlertCircle,
- Sun,
- Moon,
- Settings,
- Smartphone,
- QrCode,
- Copy,
- Download,
- Trash2,
- RefreshCw
-} from 'lucide-react'
-import { tfaAPI } from '../utils/api'
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertCircle,
+ CheckCircle,
+ Copy,
+ Download,
+ Eye,
+ EyeOff,
+ Key,
+ Mail,
+ Moon,
+ QrCode,
+ RefreshCw,
+ Save,
+ Settings,
+ Shield,
+ Smartphone,
+ Sun,
+ Trash2,
+ User,
+} from "lucide-react";
+import React, { useState } from "react";
+import { useAuth } from "../contexts/AuthContext";
+import { useTheme } from "../contexts/ThemeContext";
+import { tfaAPI } from "../utils/api";
const Profile = () => {
- const { user, updateProfile, changePassword } = useAuth()
- const { theme, toggleTheme, isDark } = useTheme()
- const [activeTab, setActiveTab] = useState('profile')
- const [isLoading, setIsLoading] = useState(false)
- const [message, setMessage] = useState({ type: '', text: '' })
+ const { user, updateProfile, changePassword } = useAuth();
+ const { theme, toggleTheme, isDark } = useTheme();
+ const [activeTab, setActiveTab] = useState("profile");
+ const [isLoading, setIsLoading] = useState(false);
+ const [message, setMessage] = useState({ type: "", text: "" });
- const [profileData, setProfileData] = useState({
- username: user?.username || '',
- email: user?.email || '',
- first_name: user?.first_name || '',
- last_name: user?.last_name || ''
- })
+ const [profileData, setProfileData] = useState({
+ username: user?.username || "",
+ email: user?.email || "",
+ first_name: user?.first_name || "",
+ last_name: user?.last_name || "",
+ });
- const [passwordData, setPasswordData] = useState({
- currentPassword: '',
- newPassword: '',
- confirmPassword: ''
- })
+ const [passwordData, setPasswordData] = useState({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
- const [showPasswords, setShowPasswords] = useState({
- current: false,
- new: false,
- confirm: false
- })
+ const [showPasswords, setShowPasswords] = useState({
+ current: false,
+ new: false,
+ confirm: false,
+ });
- const handleProfileSubmit = async (e) => {
- e.preventDefault()
- setIsLoading(true)
- setMessage({ type: '', text: '' })
+ const handleProfileSubmit = async (e) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setMessage({ type: "", text: "" });
- try {
- const result = await updateProfile(profileData)
- if (result.success) {
- setMessage({ type: 'success', text: 'Profile updated successfully!' })
- } else {
- setMessage({ type: 'error', text: result.error || 'Failed to update profile' })
- }
- } catch (error) {
- setMessage({ type: 'error', text: 'Network error occurred' })
- } finally {
- setIsLoading(false)
- }
- }
+ try {
+ const result = await updateProfile(profileData);
+ if (result.success) {
+ setMessage({ type: "success", text: "Profile updated successfully!" });
+ } else {
+ setMessage({
+ type: "error",
+ text: result.error || "Failed to update profile",
+ });
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: "Network error occurred" });
+ } finally {
+ setIsLoading(false);
+ }
+ };
- const handlePasswordSubmit = async (e) => {
- e.preventDefault()
- setIsLoading(true)
- setMessage({ type: '', text: '' })
+ const handlePasswordSubmit = async (e) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setMessage({ type: "", text: "" });
- if (passwordData.newPassword !== passwordData.confirmPassword) {
- setMessage({ type: 'error', text: 'New passwords do not match' })
- setIsLoading(false)
- return
- }
+ if (passwordData.newPassword !== passwordData.confirmPassword) {
+ setMessage({ type: "error", text: "New passwords do not match" });
+ setIsLoading(false);
+ return;
+ }
- if (passwordData.newPassword.length < 6) {
- setMessage({ type: 'error', text: 'New password must be at least 6 characters' })
- setIsLoading(false)
- return
- }
+ if (passwordData.newPassword.length < 6) {
+ setMessage({
+ type: "error",
+ text: "New password must be at least 6 characters",
+ });
+ setIsLoading(false);
+ return;
+ }
- try {
- const result = await changePassword(passwordData.currentPassword, passwordData.newPassword)
- if (result.success) {
- setMessage({ type: 'success', text: 'Password changed successfully!' })
- setPasswordData({
- currentPassword: '',
- newPassword: '',
- confirmPassword: ''
- })
- } else {
- setMessage({ type: 'error', text: result.error || 'Failed to change password' })
- }
- } catch (error) {
- setMessage({ type: 'error', text: 'Network error occurred' })
- } finally {
- setIsLoading(false)
- }
- }
+ try {
+ const result = await changePassword(
+ passwordData.currentPassword,
+ passwordData.newPassword,
+ );
+ if (result.success) {
+ setMessage({ type: "success", text: "Password changed successfully!" });
+ setPasswordData({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
+ } else {
+ setMessage({
+ type: "error",
+ text: result.error || "Failed to change password",
+ });
+ }
+ } catch (error) {
+ setMessage({ type: "error", text: "Network error occurred" });
+ } finally {
+ setIsLoading(false);
+ }
+ };
- const handleInputChange = (e) => {
- const { name, value } = e.target
- if (activeTab === 'profile') {
- setProfileData(prev => ({ ...prev, [name]: value }))
- } else {
- setPasswordData(prev => ({ ...prev, [name]: value }))
- }
- }
+ const handleInputChange = (e) => {
+ const { name, value } = e.target;
+ if (activeTab === "profile") {
+ setProfileData((prev) => ({ ...prev, [name]: value }));
+ } else {
+ setPasswordData((prev) => ({ ...prev, [name]: value }));
+ }
+ };
- const togglePasswordVisibility = (field) => {
- setShowPasswords(prev => ({ ...prev, [field]: !prev[field] }))
- }
+ const togglePasswordVisibility = (field) => {
+ setShowPasswords((prev) => ({ ...prev, [field]: !prev[field] }));
+ };
- const tabs = [
- { id: 'profile', name: 'Profile Information', icon: User },
- { id: 'password', name: 'Change Password', icon: Key },
- { id: 'tfa', name: 'Multi-Factor Authentication', icon: Smartphone },
- { id: 'preferences', name: 'Preferences', icon: Settings }
- ]
+ const tabs = [
+ { id: "profile", name: "Profile Information", icon: User },
+ { id: "password", name: "Change Password", icon: Key },
+ { id: "tfa", name: "Multi-Factor Authentication", icon: Smartphone },
+ { id: "preferences", name: "Preferences", icon: Settings },
+ ];
- return (
-
- {/* Header */}
-
-
- Manage your account information and security settings
-
-
+ return (
+
+ {/* Header */}
+
+
+ Manage your account information and security settings
+
+
- {/* User Info Card */}
-
-
-
-
-
- {user?.first_name && user?.last_name
- ? `${user.first_name} ${user.last_name}`
- : user?.first_name || user?.username}
-
-
{user?.email}
-
-
-
- {user?.role?.charAt(0).toUpperCase() + user?.role?.slice(1).replace('_', ' ')}
-
-
-
-
-
+ {/* User Info Card */}
+
+
+
+
+
+ {user?.first_name && user?.last_name
+ ? `${user.first_name} ${user.last_name}`
+ : user?.first_name || user?.username}
+
+
+ {user?.email}
+
+
+
+
+ {user?.role?.charAt(0).toUpperCase() +
+ user?.role?.slice(1).replace("_", " ")}
+
+
+
+
+
- {/* Tabs */}
-
-
-
- {tabs.map((tab) => {
- const Icon = tab.icon
- return (
- setActiveTab(tab.id)}
- className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center ${
- activeTab === tab.id
- ? 'border-primary-500 text-primary-600 dark:text-primary-400'
- : 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
- }`}
- >
-
- {tab.name}
-
- )
- })}
-
-
+ {/* Tabs */}
+
+
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+ return (
+ setActiveTab(tab.id)}
+ className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center ${
+ activeTab === tab.id
+ ? "border-primary-500 text-primary-600 dark:text-primary-400"
+ : "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
+ }`}
+ >
+
+ {tab.name}
+
+ );
+ })}
+
+
-
- {/* Success/Error Message */}
- {message.text && (
-
-
- {message.type === 'success' ? (
-
- ) : (
-
- )}
-
-
-
- )}
+
+ {/* Success/Error Message */}
+ {message.text && (
+
+
+ {message.type === "success" ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
- {/* Profile Information Tab */}
- {activeTab === 'profile' && (
-
-
-
Profile Information
-
-
+ {/* Profile Information Tab */}
+ {activeTab === "profile" && (
+
+
+
+ Profile Information
+
+
+
-
-
- Email Address
-
-
-
-
-
-
+
+
+ Email Address
+
+
+
+
+
+
-
+
-
-
-
+
+
+
-
-
-
- {isLoading ? 'Saving...' : 'Save Changes'}
-
-
-
- )}
+
+
+
+ {isLoading ? "Saving..." : "Save Changes"}
+
+
+
+ )}
- {/* Change Password Tab */}
- {activeTab === 'password' && (
-
-
-
Change Password
-
-
-
- Current Password
-
-
-
-
- togglePasswordVisibility('current')}
- className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
- >
- {showPasswords.current ? : }
-
-
-
+ {/* Change Password Tab */}
+ {activeTab === "password" && (
+
+
+
+ Change Password
+
+
+
+
+ Current Password
+
+
+
+
+ togglePasswordVisibility("current")}
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
+ >
+ {showPasswords.current ? (
+
+ ) : (
+
+ )}
+
+
+
-
-
- New Password
-
-
-
-
- togglePasswordVisibility('new')}
- className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
- >
- {showPasswords.new ? : }
-
-
-
Must be at least 6 characters long
-
+
+
+ New Password
+
+
+
+
+ togglePasswordVisibility("new")}
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
+ >
+ {showPasswords.new ? (
+
+ ) : (
+
+ )}
+
+
+
+ Must be at least 6 characters long
+
+
-
-
- Confirm New Password
-
-
-
-
- togglePasswordVisibility('confirm')}
- className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
- >
- {showPasswords.confirm ? : }
-
-
-
-
-
+
+
+ Confirm New Password
+
+
+
+
+ togglePasswordVisibility("confirm")}
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
+ >
+ {showPasswords.confirm ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
-
-
-
- {isLoading ? 'Changing...' : 'Change Password'}
-
-
-
- )}
+
+
+
+ {isLoading ? "Changing..." : "Change Password"}
+
+
+
+ )}
- {/* Multi-Factor Authentication Tab */}
- {activeTab === 'tfa' && (
-
- )}
+ {/* Multi-Factor Authentication Tab */}
+ {activeTab === "tfa" &&
}
- {/* Preferences Tab */}
- {activeTab === 'preferences' && (
-
-
-
Preferences
-
- {/* Theme Settings */}
-
-
-
Appearance
-
-
-
-
- {isDark ? (
-
- ) : (
-
- )}
-
-
-
- {isDark ? 'Dark Mode' : 'Light Mode'}
-
-
- {isDark ? 'Switch to light mode' : 'Switch to dark mode'}
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
-
-
- )
-}
+ {/* Preferences Tab */}
+ {activeTab === "preferences" && (
+
+
+
+ Preferences
+
+
+ {/* Theme Settings */}
+
+
+
+ Appearance
+
+
+
+
+
+ {isDark ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isDark ? "Dark Mode" : "Light Mode"}
+
+
+ {isDark
+ ? "Switch to light mode"
+ : "Switch to dark mode"}
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+};
// TFA Tab Component
const TfaTab = () => {
- const [setupStep, setSetupStep] = useState('status') // 'status', 'setup', 'verify', 'backup-codes'
- const [verificationToken, setVerificationToken] = useState('')
- const [password, setPassword] = useState('')
- const [backupCodes, setBackupCodes] = useState([])
- const [isLoading, setIsLoading] = useState(false)
- const [message, setMessage] = useState({ type: '', text: '' })
- const queryClient = useQueryClient()
+ const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
+ const [verificationToken, setVerificationToken] = useState("");
+ const [password, setPassword] = useState("");
+ const [backupCodes, setBackupCodes] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [message, setMessage] = useState({ type: "", text: "" });
+ const queryClient = useQueryClient();
- // Fetch TFA status
- const { data: tfaStatus, isLoading: statusLoading } = useQuery({
- queryKey: ['tfaStatus'],
- queryFn: () => tfaAPI.status().then(res => res.data),
- })
+ // Fetch TFA status
+ const { data: tfaStatus, isLoading: statusLoading } = useQuery({
+ queryKey: ["tfaStatus"],
+ queryFn: () => tfaAPI.status().then((res) => res.data),
+ });
- // Setup TFA mutation
- const setupMutation = useMutation({
- mutationFn: () => tfaAPI.setup().then(res => res.data),
- onSuccess: (data) => {
- setSetupStep('setup')
- setMessage({ type: 'info', text: 'Scan the QR code with your authenticator app and enter the verification code below.' })
- },
- onError: (error) => {
- setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to setup TFA' })
- }
- })
+ // Setup TFA mutation
+ const setupMutation = useMutation({
+ mutationFn: () => tfaAPI.setup().then((res) => res.data),
+ onSuccess: (data) => {
+ setSetupStep("setup");
+ setMessage({
+ type: "info",
+ text: "Scan the QR code with your authenticator app and enter the verification code below.",
+ });
+ },
+ onError: (error) => {
+ setMessage({
+ type: "error",
+ text: error.response?.data?.error || "Failed to setup TFA",
+ });
+ },
+ });
- // Verify setup mutation
- const verifyMutation = useMutation({
- mutationFn: (data) => tfaAPI.verifySetup(data).then(res => res.data),
- onSuccess: (data) => {
- setBackupCodes(data.backupCodes)
- setSetupStep('backup-codes')
- setMessage({ type: 'success', text: 'Two-factor authentication has been enabled successfully!' })
- },
- onError: (error) => {
- setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to verify TFA setup' })
- }
- })
+ // Verify setup mutation
+ const verifyMutation = useMutation({
+ mutationFn: (data) => tfaAPI.verifySetup(data).then((res) => res.data),
+ onSuccess: (data) => {
+ setBackupCodes(data.backupCodes);
+ setSetupStep("backup-codes");
+ setMessage({
+ type: "success",
+ text: "Two-factor authentication has been enabled successfully!",
+ });
+ },
+ onError: (error) => {
+ setMessage({
+ type: "error",
+ text: error.response?.data?.error || "Failed to verify TFA setup",
+ });
+ },
+ });
- // Disable TFA mutation
- const disableMutation = useMutation({
- mutationFn: (data) => tfaAPI.disable(data).then(res => res.data),
- onSuccess: () => {
- queryClient.invalidateQueries(['tfaStatus'])
- setSetupStep('status')
- setMessage({ type: 'success', text: 'Two-factor authentication has been disabled successfully!' })
- },
- onError: (error) => {
- setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to disable TFA' })
- }
- })
+ // Disable TFA mutation
+ const disableMutation = useMutation({
+ mutationFn: (data) => tfaAPI.disable(data).then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["tfaStatus"]);
+ setSetupStep("status");
+ setMessage({
+ type: "success",
+ text: "Two-factor authentication has been disabled successfully!",
+ });
+ },
+ onError: (error) => {
+ setMessage({
+ type: "error",
+ text: error.response?.data?.error || "Failed to disable TFA",
+ });
+ },
+ });
- // Regenerate backup codes mutation
- const regenerateBackupCodesMutation = useMutation({
- mutationFn: () => tfaAPI.regenerateBackupCodes().then(res => res.data),
- onSuccess: (data) => {
- setBackupCodes(data.backupCodes)
- setMessage({ type: 'success', text: 'Backup codes have been regenerated successfully!' })
- },
- onError: (error) => {
- setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to regenerate backup codes' })
- }
- })
+ // Regenerate backup codes mutation
+ const regenerateBackupCodesMutation = useMutation({
+ mutationFn: () => tfaAPI.regenerateBackupCodes().then((res) => res.data),
+ onSuccess: (data) => {
+ setBackupCodes(data.backupCodes);
+ setMessage({
+ type: "success",
+ text: "Backup codes have been regenerated successfully!",
+ });
+ },
+ onError: (error) => {
+ setMessage({
+ type: "error",
+ text:
+ error.response?.data?.error || "Failed to regenerate backup codes",
+ });
+ },
+ });
- const handleSetup = () => {
- setupMutation.mutate()
- }
+ const handleSetup = () => {
+ setupMutation.mutate();
+ };
- const handleVerify = (e) => {
- e.preventDefault()
- if (verificationToken.length !== 6) {
- setMessage({ type: 'error', text: 'Please enter a 6-digit verification code' })
- return
- }
- verifyMutation.mutate({ token: verificationToken })
- }
+ const handleVerify = (e) => {
+ e.preventDefault();
+ if (verificationToken.length !== 6) {
+ setMessage({
+ type: "error",
+ text: "Please enter a 6-digit verification code",
+ });
+ return;
+ }
+ verifyMutation.mutate({ token: verificationToken });
+ };
- const handleDisable = (e) => {
- e.preventDefault()
- if (!password) {
- setMessage({ type: 'error', text: 'Please enter your password to disable TFA' })
- return
- }
- disableMutation.mutate({ password })
- }
+ const handleDisable = (e) => {
+ e.preventDefault();
+ if (!password) {
+ setMessage({
+ type: "error",
+ text: "Please enter your password to disable TFA",
+ });
+ return;
+ }
+ disableMutation.mutate({ password });
+ };
- const handleRegenerateBackupCodes = () => {
- regenerateBackupCodesMutation.mutate()
- }
+ const handleRegenerateBackupCodes = () => {
+ regenerateBackupCodesMutation.mutate();
+ };
- const copyToClipboard = async (text) => {
- try {
- // Try modern clipboard API first
- if (navigator.clipboard && window.isSecureContext) {
- await navigator.clipboard.writeText(text)
- setMessage({ type: 'success', text: 'Copied to clipboard!' })
- return
- }
-
- // Fallback for older browsers or non-secure contexts
- const textArea = document.createElement('textarea')
- textArea.value = text
- textArea.style.position = 'fixed'
- textArea.style.left = '-999999px'
- textArea.style.top = '-999999px'
- document.body.appendChild(textArea)
- textArea.focus()
- textArea.select()
-
- try {
- const successful = document.execCommand('copy')
- if (successful) {
- setMessage({ type: 'success', text: 'Copied to clipboard!' })
- } else {
- throw new Error('Copy command failed')
- }
- } catch (err) {
- // If all else fails, show the text in a prompt
- prompt('Copy this text:', text)
- setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
- } finally {
- document.body.removeChild(textArea)
- }
- } catch (err) {
- console.error('Failed to copy to clipboard:', err)
- // Show the text in a prompt as last resort
- prompt('Copy this text:', text)
- setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
- }
- }
+ const copyToClipboard = async (text) => {
+ try {
+ // Try modern clipboard API first
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ setMessage({ type: "success", text: "Copied to clipboard!" });
+ return;
+ }
- const downloadBackupCodes = () => {
- const content = `PatchMon Backup Codes\n\n${backupCodes.map((code, index) => `${index + 1}. ${code}`).join('\n')}\n\nKeep these codes safe! Each code can only be used once.`
- const blob = new Blob([content], { type: 'text/plain' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = 'patchmon-backup-codes.txt'
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- }
+ // Fallback for older browsers or non-secure contexts
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+ textArea.style.position = "fixed";
+ textArea.style.left = "-999999px";
+ textArea.style.top = "-999999px";
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
- if (statusLoading) {
- return (
-
- )
- }
+ try {
+ const successful = document.execCommand("copy");
+ if (successful) {
+ setMessage({ type: "success", text: "Copied to clipboard!" });
+ } else {
+ throw new Error("Copy command failed");
+ }
+ } catch (err) {
+ // If all else fails, show the text in a prompt
+ prompt("Copy this text:", text);
+ setMessage({
+ type: "info",
+ text: "Text shown in prompt for manual copying",
+ });
+ } finally {
+ document.body.removeChild(textArea);
+ }
+ } catch (err) {
+ console.error("Failed to copy to clipboard:", err);
+ // Show the text in a prompt as last resort
+ prompt("Copy this text:", text);
+ setMessage({
+ type: "info",
+ text: "Text shown in prompt for manual copying",
+ });
+ }
+ };
- return (
-
-
-
Multi-Factor Authentication
-
- Add an extra layer of security to your account by enabling two-factor authentication.
-
-
+ const downloadBackupCodes = () => {
+ const content = `PatchMon Backup Codes\n\n${backupCodes.map((code, index) => `${index + 1}. ${code}`).join("\n")}\n\nKeep these codes safe! Each code can only be used once.`;
+ const blob = new Blob([content], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = "patchmon-backup-codes.txt";
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
- {/* Status Message */}
- {message.text && (
-
-
- {message.type === 'success' ? (
-
- ) : message.type === 'error' ? (
-
- ) : (
-
- )}
-
-
-
- )}
+ if (statusLoading) {
+ return (
+
+ );
+ }
- {/* TFA Status */}
- {setupStep === 'status' && (
-
-
-
-
-
-
-
-
-
- {tfaStatus?.enabled ? 'Two-Factor Authentication Enabled' : 'Two-Factor Authentication Disabled'}
-
-
- {tfaStatus?.enabled
- ? 'Your account is protected with two-factor authentication.'
- : 'Add an extra layer of security to your account.'
- }
-
-
-
-
- {tfaStatus?.enabled ? (
- setSetupStep('disable')}
- className="btn-outline text-danger-600 border-danger-300 hover:bg-danger-50"
- >
-
- Disable TFA
-
- ) : (
-
-
- {setupMutation.isPending ? 'Setting up...' : 'Enable TFA'}
-
- )}
-
-
-
+ return (
+
+
+
+ Multi-Factor Authentication
+
+
+ Add an extra layer of security to your account by enabling two-factor
+ authentication.
+
+
- {tfaStatus?.enabled && (
-
-
Backup Codes
-
- Use these backup codes to access your account if you lose your authenticator device.
-
-
-
- {regenerateBackupCodesMutation.isPending ? 'Regenerating...' : 'Regenerate Codes'}
-
-
- )}
-
- )}
+ {/* Status Message */}
+ {message.text && (
+
+
+ {message.type === "success" ? (
+
+ ) : message.type === "error" ? (
+
+ ) : (
+
+ )}
+
+
+
+ )}
- {/* TFA Setup */}
- {setupStep === 'setup' && setupMutation.data && (
-
-
-
Setup Two-Factor Authentication
-
-
-
-
- Scan this QR code with your authenticator app
-
-
-
-
-
Manual Entry Key:
-
-
- {setupMutation.data.manualEntryKey}
-
- copyToClipboard(setupMutation.data.manualEntryKey)}
- className="p-2 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
- title="Copy to clipboard"
- >
-
-
-
-
+ {/* TFA Status */}
+ {setupStep === "status" && (
+
+
+
+
+
+
+
+
+
+ {tfaStatus?.enabled
+ ? "Two-Factor Authentication Enabled"
+ : "Two-Factor Authentication Disabled"}
+
+
+ {tfaStatus?.enabled
+ ? "Your account is protected with two-factor authentication."
+ : "Add an extra layer of security to your account."}
+
+
+
+
+ {tfaStatus?.enabled ? (
+ setSetupStep("disable")}
+ className="btn-outline text-danger-600 border-danger-300 hover:bg-danger-50"
+ >
+
+ Disable TFA
+
+ ) : (
+
+
+ {setupMutation.isPending ? "Setting up..." : "Enable TFA"}
+
+ )}
+
+
+
-
- setSetupStep('verify')}
- className="btn-primary"
- >
- Continue to Verification
-
-
-
-
-
- )}
+ {tfaStatus?.enabled && (
+
+
+ Backup Codes
+
+
+ Use these backup codes to access your account if you lose your
+ authenticator device.
+
+
+
+ {regenerateBackupCodesMutation.isPending
+ ? "Regenerating..."
+ : "Regenerate Codes"}
+
+
+ )}
+
+ )}
- {/* TFA Verification */}
- {setupStep === 'verify' && (
-
-
-
Verify Setup
-
- Enter the 6-digit code from your authenticator app to complete the setup.
-
-
-
-
- Verification Code
-
- setVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))}
- placeholder="000000"
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-center text-lg font-mono tracking-widest"
- maxLength="6"
- required
- />
-
-
-
- {verifyMutation.isPending ? 'Verifying...' : 'Verify & Enable'}
-
- setSetupStep('status')}
- className="btn-outline"
- >
- Cancel
-
-
-
-
-
- )}
+ {/* TFA Setup */}
+ {setupStep === "setup" && setupMutation.data && (
+
+
+
+ Setup Two-Factor Authentication
+
+
+
+
+
+ Scan this QR code with your authenticator app
+
+
- {/* Backup Codes */}
- {setupStep === 'backup-codes' && backupCodes.length > 0 && (
-
-
-
Backup Codes
-
- Save these backup codes in a safe place. Each code can only be used once.
-
-
-
- {backupCodes.map((code, index) => (
-
- {index + 1}.
- {code}
-
- ))}
-
-
-
-
-
- Download Codes
-
- {
- setSetupStep('status')
- queryClient.invalidateQueries(['tfaStatus'])
- }}
- className="btn-primary"
- >
- Done
-
-
-
-
- )}
+
+
+ Manual Entry Key:
+
+
+
+ {setupMutation.data.manualEntryKey}
+
+
+ copyToClipboard(setupMutation.data.manualEntryKey)
+ }
+ className="p-2 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
+ title="Copy to clipboard"
+ >
+
+
+
+
- {/* Disable TFA */}
- {setupStep === 'disable' && (
-
-
-
Disable Two-Factor Authentication
-
- Enter your password to disable two-factor authentication.
-
-
-
-
- Password
-
- setPassword(e.target.value)}
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
- required
- />
-
-
-
- {disableMutation.isPending ? 'Disabling...' : 'Disable TFA'}
-
- setSetupStep('status')}
- className="btn-outline"
- >
- Cancel
-
-
-
-
-
- )}
-
- )
-}
+
+ setSetupStep("verify")}
+ className="btn-primary"
+ >
+ Continue to Verification
+
+
+
+
+
+ )}
-export default Profile
+ {/* TFA Verification */}
+ {setupStep === "verify" && (
+
+
+
+ Verify Setup
+
+
+ Enter the 6-digit code from your authenticator app to complete the
+ setup.
+
+
+
+
+ Verification Code
+
+
+ setVerificationToken(
+ e.target.value.replace(/\D/g, "").slice(0, 6),
+ )
+ }
+ placeholder="000000"
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-center text-lg font-mono tracking-widest"
+ maxLength="6"
+ required
+ />
+
+
+
+ {verifyMutation.isPending
+ ? "Verifying..."
+ : "Verify & Enable"}
+
+ setSetupStep("status")}
+ className="btn-outline"
+ >
+ Cancel
+
+
+
+
+
+ )}
+
+ {/* Backup Codes */}
+ {setupStep === "backup-codes" && backupCodes.length > 0 && (
+
+
+
+ Backup Codes
+
+
+ Save these backup codes in a safe place. Each code can only be
+ used once.
+
+
+
+ {backupCodes.map((code, index) => (
+
+
+ {index + 1}.
+
+
+ {code}
+
+
+ ))}
+
+
+
+
+
+ Download Codes
+
+ {
+ setSetupStep("status");
+ queryClient.invalidateQueries(["tfaStatus"]);
+ }}
+ className="btn-primary"
+ >
+ Done
+
+
+
+
+ )}
+
+ {/* Disable TFA */}
+ {setupStep === "disable" && (
+
+
+
+ Disable Two-Factor Authentication
+
+
+ Enter your password to disable two-factor authentication.
+
+
+
+
+ Password
+
+ setPassword(e.target.value)}
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
+ required
+ />
+
+
+
+ {disableMutation.isPending ? "Disabling..." : "Disable TFA"}
+
+ setSetupStep("status")}
+ className="btn-outline"
+ >
+ Cancel
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default Profile;
diff --git a/frontend/src/pages/Repositories.jsx b/frontend/src/pages/Repositories.jsx
index 4add269..c1c3818 100644
--- a/frontend/src/pages/Repositories.jsx
+++ b/frontend/src/pages/Repositories.jsx
@@ -1,546 +1,626 @@
-import React, { useState, useMemo } from 'react';
-import { useQuery } from '@tanstack/react-query';
-import { Link } from 'react-router-dom';
-import {
- Server,
- Shield,
- ShieldCheck,
- AlertTriangle,
- Users,
- Globe,
- Lock,
- Unlock,
- Database,
- Eye,
- Search,
- Columns,
- ArrowUpDown,
- ArrowUp,
- ArrowDown,
- X,
- GripVertical,
- Check,
- RefreshCw
-} from 'lucide-react';
-import { repositoryAPI } from '../utils/api';
+import { useQuery } from "@tanstack/react-query";
+import {
+ AlertTriangle,
+ ArrowDown,
+ ArrowUp,
+ ArrowUpDown,
+ Check,
+ Columns,
+ Database,
+ Eye,
+ Globe,
+ GripVertical,
+ Lock,
+ RefreshCw,
+ Search,
+ Server,
+ Shield,
+ ShieldCheck,
+ Unlock,
+ Users,
+ X,
+} from "lucide-react";
+import React, { useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+import { repositoryAPI } from "../utils/api";
const Repositories = () => {
- const [searchTerm, setSearchTerm] = useState('');
- const [filterType, setFilterType] = useState('all'); // all, secure, insecure
- const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive
- const [sortField, setSortField] = useState('name');
- const [sortDirection, setSortDirection] = useState('asc');
- const [showColumnSettings, setShowColumnSettings] = useState(false);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [filterType, setFilterType] = useState("all"); // all, secure, insecure
+ const [filterStatus, setFilterStatus] = useState("all"); // all, active, inactive
+ const [sortField, setSortField] = useState("name");
+ const [sortDirection, setSortDirection] = useState("asc");
+ const [showColumnSettings, setShowColumnSettings] = useState(false);
- // Column configuration
- const [columnConfig, setColumnConfig] = useState(() => {
- const defaultConfig = [
- { id: 'name', label: 'Repository', visible: true, order: 0 },
- { id: 'url', label: 'URL', visible: true, order: 1 },
- { id: 'distribution', label: 'Distribution', visible: true, order: 2 },
- { id: 'security', label: 'Security', visible: true, order: 3 },
- { id: 'status', label: 'Status', visible: true, order: 4 },
- { id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
- { id: 'actions', label: 'Actions', visible: true, order: 6 }
- ];
-
- const saved = localStorage.getItem('repositories-column-config');
- if (saved) {
- try {
- return JSON.parse(saved);
- } catch (e) {
- console.error('Failed to parse saved column config:', e);
- }
- }
- return defaultConfig;
- });
+ // Column configuration
+ const [columnConfig, setColumnConfig] = useState(() => {
+ const defaultConfig = [
+ { id: "name", label: "Repository", visible: true, order: 0 },
+ { id: "url", label: "URL", visible: true, order: 1 },
+ { id: "distribution", label: "Distribution", visible: true, order: 2 },
+ { id: "security", label: "Security", visible: true, order: 3 },
+ { id: "status", label: "Status", visible: true, order: 4 },
+ { id: "hostCount", label: "Hosts", visible: true, order: 5 },
+ { id: "actions", label: "Actions", visible: true, order: 6 },
+ ];
- const updateColumnConfig = (newConfig) => {
- setColumnConfig(newConfig);
- localStorage.setItem('repositories-column-config', JSON.stringify(newConfig));
- };
+ const saved = localStorage.getItem("repositories-column-config");
+ if (saved) {
+ try {
+ return JSON.parse(saved);
+ } catch (e) {
+ console.error("Failed to parse saved column config:", e);
+ }
+ }
+ return defaultConfig;
+ });
- // Fetch repositories
- const { data: repositories = [], isLoading, error, refetch, isFetching } = useQuery({
- queryKey: ['repositories'],
- queryFn: () => repositoryAPI.list().then(res => res.data)
- });
+ const updateColumnConfig = (newConfig) => {
+ setColumnConfig(newConfig);
+ localStorage.setItem(
+ "repositories-column-config",
+ JSON.stringify(newConfig),
+ );
+ };
- // Fetch repository statistics
- const { data: stats } = useQuery({
- queryKey: ['repository-stats'],
- queryFn: () => repositoryAPI.getStats().then(res => res.data)
- });
+ // Fetch repositories
+ const {
+ data: repositories = [],
+ isLoading,
+ error,
+ refetch,
+ isFetching,
+ } = useQuery({
+ queryKey: ["repositories"],
+ queryFn: () => repositoryAPI.list().then((res) => res.data),
+ });
- // Get visible columns in order
- const visibleColumns = columnConfig
- .filter(col => col.visible)
- .sort((a, b) => a.order - b.order);
+ // Fetch repository statistics
+ const { data: stats } = useQuery({
+ queryKey: ["repository-stats"],
+ queryFn: () => repositoryAPI.getStats().then((res) => res.data),
+ });
- // Sorting functions
- const handleSort = (field) => {
- if (sortField === field) {
- setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
- } else {
- setSortField(field);
- setSortDirection('asc');
- }
- };
+ // Get visible columns in order
+ const visibleColumns = columnConfig
+ .filter((col) => col.visible)
+ .sort((a, b) => a.order - b.order);
- const getSortIcon = (field) => {
- if (sortField !== field) return
- return sortDirection === 'asc' ? :
- };
+ // Sorting functions
+ const handleSort = (field) => {
+ if (sortField === field) {
+ setSortDirection(sortDirection === "asc" ? "desc" : "asc");
+ } else {
+ setSortField(field);
+ setSortDirection("asc");
+ }
+ };
- // Column management functions
- const toggleColumnVisibility = (columnId) => {
- const newConfig = columnConfig.map(col =>
- col.id === columnId ? { ...col, visible: !col.visible } : col
- )
- updateColumnConfig(newConfig)
- };
+ const getSortIcon = (field) => {
+ if (sortField !== field) return ;
+ return sortDirection === "asc" ? (
+
+ ) : (
+
+ );
+ };
- const reorderColumns = (fromIndex, toIndex) => {
- const newConfig = [...columnConfig]
- const [movedColumn] = newConfig.splice(fromIndex, 1)
- newConfig.splice(toIndex, 0, movedColumn)
-
- // Update order values
- const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
- updateColumnConfig(updatedConfig)
- };
+ // Column management functions
+ const toggleColumnVisibility = (columnId) => {
+ const newConfig = columnConfig.map((col) =>
+ col.id === columnId ? { ...col, visible: !col.visible } : col,
+ );
+ updateColumnConfig(newConfig);
+ };
- const resetColumns = () => {
- const defaultConfig = [
- { id: 'name', label: 'Repository', visible: true, order: 0 },
- { id: 'url', label: 'URL', visible: true, order: 1 },
- { id: 'distribution', label: 'Distribution', visible: true, order: 2 },
- { id: 'security', label: 'Security', visible: true, order: 3 },
- { id: 'status', label: 'Status', visible: true, order: 4 },
- { id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
- { id: 'actions', label: 'Actions', visible: true, order: 6 }
- ]
- updateColumnConfig(defaultConfig)
- };
+ const reorderColumns = (fromIndex, toIndex) => {
+ const newConfig = [...columnConfig];
+ const [movedColumn] = newConfig.splice(fromIndex, 1);
+ newConfig.splice(toIndex, 0, movedColumn);
- // Filter and sort repositories
- const filteredAndSortedRepositories = useMemo(() => {
- if (!repositories) return []
-
- // Filter repositories
- const filtered = repositories.filter(repo => {
- const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
- repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
- repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
-
- // Check security based on URL if isSecure property doesn't exist
- const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
-
- const matchesType = filterType === 'all' ||
- (filterType === 'secure' && isSecure) ||
- (filterType === 'insecure' && !isSecure);
-
- const matchesStatus = filterStatus === 'all' ||
- (filterStatus === 'active' && repo.is_active === true) ||
- (filterStatus === 'inactive' && repo.is_active === false);
-
- return matchesSearch && matchesType && matchesStatus;
- });
+ // Update order values
+ const updatedConfig = newConfig.map((col, index) => ({
+ ...col,
+ order: index,
+ }));
+ updateColumnConfig(updatedConfig);
+ };
- // Sort repositories
- const sorted = filtered.sort((a, b) => {
- let aValue = a[sortField];
- let bValue = b[sortField];
+ const resetColumns = () => {
+ const defaultConfig = [
+ { id: "name", label: "Repository", visible: true, order: 0 },
+ { id: "url", label: "URL", visible: true, order: 1 },
+ { id: "distribution", label: "Distribution", visible: true, order: 2 },
+ { id: "security", label: "Security", visible: true, order: 3 },
+ { id: "status", label: "Status", visible: true, order: 4 },
+ { id: "hostCount", label: "Hosts", visible: true, order: 5 },
+ { id: "actions", label: "Actions", visible: true, order: 6 },
+ ];
+ updateColumnConfig(defaultConfig);
+ };
- // Handle special cases
- if (sortField === 'security') {
- aValue = a.isSecure ? 'Secure' : 'Insecure';
- bValue = b.isSecure ? 'Secure' : 'Insecure';
- } else if (sortField === 'status') {
- aValue = a.is_active ? 'Active' : 'Inactive';
- bValue = b.is_active ? 'Active' : 'Inactive';
- }
+ // Filter and sort repositories
+ const filteredAndSortedRepositories = useMemo(() => {
+ if (!repositories) return [];
- if (typeof aValue === 'string') {
- aValue = aValue.toLowerCase();
- bValue = bValue.toLowerCase();
- }
+ // Filter repositories
+ const filtered = repositories.filter((repo) => {
+ const matchesSearch =
+ repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
- if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
- if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
- return 0;
- });
+ // Check security based on URL if isSecure property doesn't exist
+ const isSecure =
+ repo.isSecure !== undefined
+ ? repo.isSecure
+ : repo.url.startsWith("https://");
- return sorted;
- }, [repositories, searchTerm, filterType, filterStatus, sortField, sortDirection]);
+ const matchesType =
+ filterType === "all" ||
+ (filterType === "secure" && isSecure) ||
+ (filterType === "insecure" && !isSecure);
- if (isLoading) {
- return (
-
- );
- }
+ const matchesStatus =
+ filterStatus === "all" ||
+ (filterStatus === "active" && repo.is_active === true) ||
+ (filterStatus === "inactive" && repo.is_active === false);
- if (error) {
- return (
-
-
-
-
- Failed to load repositories: {error.message}
-
-
-
- );
- }
+ return matchesSearch && matchesType && matchesStatus;
+ });
- return (
-
- {/* Page Header */}
-
-
-
Repositories
-
- Manage and monitor your package repositories
-
-
-
- refetch()}
- disabled={isFetching}
- className="btn-outline flex items-center gap-2"
- title="Refresh repositories data"
- >
-
- {isFetching ? 'Refreshing...' : 'Refresh'}
-
-
-
+ // Sort repositories
+ const sorted = filtered.sort((a, b) => {
+ let aValue = a[sortField];
+ let bValue = b[sortField];
- {/* Summary Stats */}
-
-
-
-
-
-
Total Repositories
-
{stats?.totalRepositories || 0}
-
-
-
-
-
-
-
-
-
Active Repositories
-
{stats?.activeRepositories || 0}
-
-
-
-
-
-
-
-
-
Secure (HTTPS)
-
{stats?.secureRepositories || 0}
-
-
-
-
-
-
-
-
-
Security Score
-
{stats?.securityPercentage || 0}%
-
-
-
-
+ // Handle special cases
+ if (sortField === "security") {
+ aValue = a.isSecure ? "Secure" : "Insecure";
+ bValue = b.isSecure ? "Secure" : "Insecure";
+ } else if (sortField === "status") {
+ aValue = a.is_active ? "Active" : "Inactive";
+ bValue = b.is_active ? "Active" : "Inactive";
+ }
- {/* Repositories List */}
-
-
-
- {/* Empty selection controls area to match packages page spacing */}
-
-
- {/* Table Controls */}
-
-
- {/* Search */}
-
-
-
- setSearchTerm(e.target.value)}
- className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
- />
-
-
-
- {/* Security Filter */}
-
- setFilterType(e.target.value)}
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
- >
- All Security Types
- HTTPS Only
- HTTP Only
-
-
-
- {/* Status Filter */}
-
- setFilterStatus(e.target.value)}
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
- >
- All Statuses
- Active Only
- Inactive Only
-
-
-
- {/* Columns Button */}
-
- setShowColumnSettings(true)}
- className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"
- >
-
- Columns
-
-
-
-
+ if (typeof aValue === "string") {
+ aValue = aValue.toLowerCase();
+ bValue = bValue.toLowerCase();
+ }
-
- {filteredAndSortedRepositories.length === 0 ? (
-
-
-
- {repositories?.length === 0 ? 'No repositories found' : 'No repositories match your filters'}
-
- {repositories?.length === 0 && (
-
- No repositories have been reported by your hosts yet
-
- )}
-
- ) : (
-
-
-
-
- {visibleColumns.map((column) => (
-
- handleSort(column.id)}
- className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
- >
- {column.label}
- {getSortIcon(column.id)}
-
-
- ))}
-
-
-
- {filteredAndSortedRepositories.map((repo) => (
-
- {visibleColumns.map((column) => (
-
- {renderCellContent(column, repo)}
-
- ))}
-
- ))}
-
-
-
- )}
-
-
-
+ if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
+ if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
+ return 0;
+ });
- {/* Column Settings Modal */}
- {showColumnSettings && (
-
setShowColumnSettings(false)}
- onToggleVisibility={toggleColumnVisibility}
- onReorder={reorderColumns}
- onReset={resetColumns}
- />
- )}
-
- );
+ return sorted;
+ }, [
+ repositories,
+ searchTerm,
+ filterType,
+ filterStatus,
+ sortField,
+ sortDirection,
+ ]);
- // Render cell content based on column type
- function renderCellContent(column, repo) {
- switch (column.id) {
- case 'name':
- return (
-
- )
- case 'url':
- return (
-
- {repo.url}
-
- )
- case 'distribution':
- return (
-
- {repo.distribution}
-
- )
- case 'security':
- const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
- return (
-
- {isSecure ? (
-
-
- Secure
-
- ) : (
-
-
- Insecure
-
- )}
-
- )
- case 'status':
- return (
-
- {repo.is_active ? 'Active' : 'Inactive'}
-
- )
- case 'hostCount':
- return (
-
-
- {repo.host_count}
-
- )
- case 'actions':
- return (
-
- View
-
-
- )
- default:
- return null
- }
- }
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Failed to load repositories: {error.message}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Page Header */}
+
+
+
+ Repositories
+
+
+ Manage and monitor your package repositories
+
+
+
+ refetch()}
+ disabled={isFetching}
+ className="btn-outline flex items-center gap-2"
+ title="Refresh repositories data"
+ >
+
+ {isFetching ? "Refreshing..." : "Refresh"}
+
+
+
+
+ {/* Summary Stats */}
+
+
+
+
+
+
+ Total Repositories
+
+
+ {stats?.totalRepositories || 0}
+
+
+
+
+
+
+
+
+
+
+ Active Repositories
+
+
+ {stats?.activeRepositories || 0}
+
+
+
+
+
+
+
+
+
+
+ Secure (HTTPS)
+
+
+ {stats?.secureRepositories || 0}
+
+
+
+
+
+
+
+
+
+
+ Security Score
+
+
+ {stats?.securityPercentage || 0}%
+
+
+
+
+
+
+ {/* Repositories List */}
+
+
+
+ {/* Empty selection controls area to match packages page spacing */}
+
+
+ {/* Table Controls */}
+
+
+ {/* Search */}
+
+
+
+ setSearchTerm(e.target.value)}
+ className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
+ />
+
+
+
+ {/* Security Filter */}
+
+ setFilterType(e.target.value)}
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
+ >
+ All Security Types
+ HTTPS Only
+ HTTP Only
+
+
+
+ {/* Status Filter */}
+
+ setFilterStatus(e.target.value)}
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
+ >
+ All Statuses
+ Active Only
+ Inactive Only
+
+
+
+ {/* Columns Button */}
+
+ setShowColumnSettings(true)}
+ className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"
+ >
+
+ Columns
+
+
+
+
+
+
+ {filteredAndSortedRepositories.length === 0 ? (
+
+
+
+ {repositories?.length === 0
+ ? "No repositories found"
+ : "No repositories match your filters"}
+
+ {repositories?.length === 0 && (
+
+ No repositories have been reported by your hosts yet
+
+ )}
+
+ ) : (
+
+
+
+
+ {visibleColumns.map((column) => (
+
+ handleSort(column.id)}
+ className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
+ >
+ {column.label}
+ {getSortIcon(column.id)}
+
+
+ ))}
+
+
+
+ {filteredAndSortedRepositories.map((repo) => (
+
+ {visibleColumns.map((column) => (
+
+ {renderCellContent(column, repo)}
+
+ ))}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ {/* Column Settings Modal */}
+ {showColumnSettings && (
+
setShowColumnSettings(false)}
+ onToggleVisibility={toggleColumnVisibility}
+ onReorder={reorderColumns}
+ onReset={resetColumns}
+ />
+ )}
+
+ );
+
+ // Render cell content based on column type
+ function renderCellContent(column, repo) {
+ switch (column.id) {
+ case "name":
+ return (
+
+ );
+ case "url":
+ return (
+
+ {repo.url}
+
+ );
+ case "distribution":
+ return (
+
+ {repo.distribution}
+
+ );
+ case "security": {
+ const isSecure =
+ repo.isSecure !== undefined
+ ? repo.isSecure
+ : repo.url.startsWith("https://");
+ return (
+
+ {isSecure ? (
+
+
+ Secure
+
+ ) : (
+
+
+ Insecure
+
+ )}
+
+ );
+ }
+ case "status":
+ return (
+
+ {repo.is_active ? "Active" : "Inactive"}
+
+ );
+ case "hostCount":
+ return (
+
+
+ {repo.host_count}
+
+ );
+ case "actions":
+ return (
+
+ View
+
+
+ );
+ default:
+ return null;
+ }
+ }
};
// Column Settings Modal Component
-const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
- const [draggedIndex, setDraggedIndex] = useState(null)
+const ColumnSettingsModal = ({
+ columnConfig,
+ onClose,
+ onToggleVisibility,
+ onReorder,
+ onReset,
+}) => {
+ const [draggedIndex, setDraggedIndex] = useState(null);
- const handleDragStart = (e, index) => {
- setDraggedIndex(index)
- e.dataTransfer.effectAllowed = 'move'
- }
+ const handleDragStart = (e, index) => {
+ setDraggedIndex(index);
+ e.dataTransfer.effectAllowed = "move";
+ };
- const handleDragOver = (e) => {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- }
+ const handleDragOver = (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ };
- const handleDrop = (e, dropIndex) => {
- e.preventDefault()
- if (draggedIndex !== null && draggedIndex !== dropIndex) {
- onReorder(draggedIndex, dropIndex)
- }
- setDraggedIndex(null)
- }
+ const handleDrop = (e, dropIndex) => {
+ e.preventDefault();
+ if (draggedIndex !== null && draggedIndex !== dropIndex) {
+ onReorder(draggedIndex, dropIndex);
+ }
+ setDraggedIndex(null);
+ };
- return (
-
-
-
-
Column Settings
-
-
-
-
+ return (
+
+
+
+
+ Column Settings
+
+
+
+
+
-
- {columnConfig.map((column, index) => (
-
handleDragStart(e, index)}
- onDragOver={handleDragOver}
- onDrop={(e) => handleDrop(e, index)}
- className="flex items-center justify-between p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg cursor-move hover:bg-secondary-100 dark:hover:bg-secondary-600 transition-colors"
- >
-
-
-
- {column.label}
-
-
-
onToggleVisibility(column.id)}
- className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
- column.visible
- ? 'bg-primary-600 border-primary-600'
- : 'bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600'
- }`}
- >
- {column.visible && }
-
-
- ))}
-
+
+ {columnConfig.map((column, index) => (
+
handleDragStart(e, index)}
+ onDragOver={handleDragOver}
+ onDrop={(e) => handleDrop(e, index)}
+ className="flex items-center justify-between p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg cursor-move hover:bg-secondary-100 dark:hover:bg-secondary-600 transition-colors"
+ >
+
+
+
+ {column.label}
+
+
+
onToggleVisibility(column.id)}
+ className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
+ column.visible
+ ? "bg-primary-600 border-primary-600"
+ : "bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600"
+ }`}
+ >
+ {column.visible && }
+
+
+ ))}
+
-
-
- Reset to Default
-
-
- Done
-
-
-
-
- )
+
+
+ Reset to Default
+
+
+ Done
+
+
+
+
+ );
};
export default Repositories;
diff --git a/frontend/src/pages/RepositoryDetail.jsx b/frontend/src/pages/RepositoryDetail.jsx
index 4a257e2..6e31a1f 100644
--- a/frontend/src/pages/RepositoryDetail.jsx
+++ b/frontend/src/pages/RepositoryDetail.jsx
@@ -1,369 +1,432 @@
-import React, { useState } from 'react';
-import { useParams, Link } from 'react-router-dom';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import {
- ArrowLeft,
- Server,
- Shield,
- ShieldOff,
- AlertTriangle,
- Users,
- Globe,
- Lock,
- Unlock,
- Database,
- Calendar,
- Activity
-} from 'lucide-react';
-import { repositoryAPI } from '../utils/api';
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ Activity,
+ AlertTriangle,
+ ArrowLeft,
+ Calendar,
+ Database,
+ Globe,
+ Lock,
+ Server,
+ Shield,
+ ShieldOff,
+ Unlock,
+ Users,
+} from "lucide-react";
+import React, { useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import { repositoryAPI } from "../utils/api";
const RepositoryDetail = () => {
- const { repositoryId } = useParams();
- const queryClient = useQueryClient();
- const [editMode, setEditMode] = useState(false);
- const [formData, setFormData] = useState({});
+ const { repositoryId } = useParams();
+ const queryClient = useQueryClient();
+ const [editMode, setEditMode] = useState(false);
+ const [formData, setFormData] = useState({});
- // Fetch repository details
- const { data: repository, isLoading, error } = useQuery({
- queryKey: ['repository', repositoryId],
- queryFn: () => repositoryAPI.getById(repositoryId).then(res => res.data),
- enabled: !!repositoryId
- });
+ // Fetch repository details
+ const {
+ data: repository,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["repository", repositoryId],
+ queryFn: () => repositoryAPI.getById(repositoryId).then((res) => res.data),
+ enabled: !!repositoryId,
+ });
- // Update repository mutation
- const updateRepositoryMutation = useMutation({
- mutationFn: (data) => repositoryAPI.update(repositoryId, data),
- onSuccess: () => {
- queryClient.invalidateQueries(['repository', repositoryId]);
- queryClient.invalidateQueries(['repositories']);
- setEditMode(false);
- }
- });
+ // Update repository mutation
+ const updateRepositoryMutation = useMutation({
+ mutationFn: (data) => repositoryAPI.update(repositoryId, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["repository", repositoryId]);
+ queryClient.invalidateQueries(["repositories"]);
+ setEditMode(false);
+ },
+ });
+ const handleEdit = () => {
+ setFormData({
+ name: repository.name,
+ description: repository.description || "",
+ is_active: repository.is_active,
+ priority: repository.priority || "",
+ });
+ setEditMode(true);
+ };
- const handleEdit = () => {
- setFormData({
- name: repository.name,
- description: repository.description || '',
- is_active: repository.is_active,
- priority: repository.priority || ''
- });
- setEditMode(true);
- };
+ const handleSave = () => {
+ updateRepositoryMutation.mutate(formData);
+ };
- const handleSave = () => {
- updateRepositoryMutation.mutate(formData);
- };
+ const handleCancel = () => {
+ setEditMode(false);
+ setFormData({});
+ };
- const handleCancel = () => {
- setEditMode(false);
- setFormData({});
- };
+ if (isLoading) {
+ return (
+
+ );
+ }
+ if (error) {
+ return (
+
+
+
+
+ Back to Repositories
+
+
+
+
+
+
+ Failed to load repository: {error.message}
+
+
+
+
+ );
+ }
- if (isLoading) {
- return (
-
- );
- }
+ if (!repository) {
+ return (
+
+
+
+
+ Back to Repositories
+
+
+
+
+
+ Repository not found
+
+
+ The repository you're looking for doesn't exist.
+
+
+
+ );
+ }
- if (error) {
- return (
-
-
-
-
- Back to Repositories
-
-
-
-
-
-
- Failed to load repository: {error.message}
-
-
-
-
- );
- }
+ return (
+
+ {/* Header */}
+
+
+
+
+ Back
+
+
+
+ {repository.isSecure ? (
+
+ ) : (
+
+ )}
+
+ {repository.name}
+
+
+ {repository.is_active ? "Active" : "Inactive"}
+
+
+
+ Repository configuration and host assignments
+
+
+
+
+ {editMode ? (
+ <>
+
+ Cancel
+
+
+ {updateRepositoryMutation.isPending
+ ? "Saving..."
+ : "Save Changes"}
+
+ >
+ ) : (
+
+ Edit Repository
+
+ )}
+
+
- if (!repository) {
- return (
-
-
-
-
- Back to Repositories
-
-
-
-
-
Repository not found
-
- The repository you're looking for doesn't exist.
-
-
-
- );
- }
+ {/* Repository Information */}
+
+
+
+ Repository Information
+
+
+
+ {editMode ? (
+
+
+
+ Repository Name
+
+
+ setFormData({ ...formData, name: e.target.value })
+ }
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
+ />
+
+
+
+ Priority
+
+
+ setFormData({ ...formData, priority: e.target.value })
+ }
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
+ placeholder="Optional priority"
+ />
+
+
+
+ Description
+
+
+ setFormData({ ...formData, description: e.target.value })
+ }
+ rows="3"
+ className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
+ placeholder="Optional description"
+ />
+
+
+
+ setFormData({ ...formData, is_active: e.target.checked })
+ }
+ className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
+ />
+
+ Repository is active
+
+
+
+ ) : (
+
+
+
+
+ URL
+
+
+
+
+ {repository.url}
+
+
+
+
+
+ Distribution
+
+
+ {repository.distribution}
+
+
+
+
+ Components
+
+
+ {repository.components}
+
+
+
+
+ Repository Type
+
+
+ {repository.repoType}
+
+
+
+
+
+
+ Security
+
+
+ {repository.isSecure ? (
+ <>
+
+ Secure (HTTPS)
+ >
+ ) : (
+ <>
+
+ Insecure (HTTP)
+ >
+ )}
+
+
+ {repository.priority && (
+
+
+ Priority
+
+
+ {repository.priority}
+
+
+ )}
+ {repository.description && (
+
+
+ Description
+
+
+ {repository.description}
+
+
+ )}
+
+
+ Created
+
+
+
+
+ {new Date(repository.created_at).toLocaleDateString()}
+
+
+
+
+
+ )}
+
+
- return (
-
- {/* Header */}
-
-
-
-
- Back
-
-
-
- {repository.isSecure ? (
-
- ) : (
-
- )}
-
- {repository.name}
-
-
- {repository.is_active ? 'Active' : 'Inactive'}
-
-
-
- Repository configuration and host assignments
-
-
-
-
- {editMode ? (
- <>
-
- Cancel
-
-
- {updateRepositoryMutation.isPending ? 'Saving...' : 'Save Changes'}
-
- >
- ) : (
-
- Edit Repository
-
- )}
-
-
-
- {/* Repository Information */}
-
-
-
- Repository Information
-
-
-
- {editMode ? (
-
-
-
- Repository Name
-
- setFormData({ ...formData, name: e.target.value })}
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
- />
-
-
-
- Priority
-
- setFormData({ ...formData, priority: e.target.value })}
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
- placeholder="Optional priority"
- />
-
-
-
- Description
-
- setFormData({ ...formData, description: e.target.value })}
- rows="3"
- className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
- placeholder="Optional description"
- />
-
-
- setFormData({ ...formData, is_active: e.target.checked })}
- className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
- />
-
- Repository is active
-
-
-
- ) : (
-
-
-
-
URL
-
-
- {repository.url}
-
-
-
-
Distribution
-
{repository.distribution}
-
-
-
Components
-
{repository.components}
-
-
-
Repository Type
-
{repository.repoType}
-
-
-
-
-
Security
-
- {repository.isSecure ? (
- <>
-
- Secure (HTTPS)
- >
- ) : (
- <>
-
- Insecure (HTTP)
- >
- )}
-
-
- {repository.priority && (
-
-
Priority
-
{repository.priority}
-
- )}
- {repository.description && (
-
-
Description
-
{repository.description}
-
- )}
-
-
Created
-
-
-
- {new Date(repository.created_at).toLocaleDateString()}
-
-
-
-
-
- )}
-
-
-
- {/* Hosts Using This Repository */}
-
-
-
-
- Hosts Using This Repository ({repository.host_repositories?.length || 0})
-
-
- {!repository.host_repositories || repository.host_repositories.length === 0 ? (
-
-
-
No hosts using this repository
-
- This repository hasn't been reported by any hosts yet.
-
-
- ) : (
-
- {repository.host_repositories.map((hostRepo) => (
-
-
-
-
-
-
- {hostRepo.hosts.friendly_name}
-
-
- IP: {hostRepo.hosts.ip}
- OS: {hostRepo.hosts.os_type} {hostRepo.hosts.os_version}
- Last Update: {new Date(hostRepo.hosts.last_update).toLocaleDateString()}
-
-
-
-
-
-
Last Checked
-
- {new Date(hostRepo.last_checked).toLocaleDateString()}
-
-
-
-
-
- ))}
-
- )}
-
-
- );
+ {/* Hosts Using This Repository */}
+
+
+
+
+ Hosts Using This Repository (
+ {repository.host_repositories?.length || 0})
+
+
+ {!repository.host_repositories ||
+ repository.host_repositories.length === 0 ? (
+
+
+
+ No hosts using this repository
+
+
+ This repository hasn't been reported by any hosts yet.
+
+
+ ) : (
+
+ {repository.host_repositories.map((hostRepo) => (
+
+
+
+
+
+
+ {hostRepo.hosts.friendly_name}
+
+
+ IP: {hostRepo.hosts.ip}
+
+ OS: {hostRepo.hosts.os_type}{" "}
+ {hostRepo.hosts.os_version}
+
+
+ Last Update:{" "}
+ {new Date(
+ hostRepo.hosts.last_update,
+ ).toLocaleDateString()}
+
+
+
+
+
+
+
+ Last Checked
+
+
+ {new Date(hostRepo.last_checked).toLocaleDateString()}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
};
export default RepositoryDetail;
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index bd5a9a0..7d545d6 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1,1241 +1,1486 @@
-import React, { useState, useEffect } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Save, Server, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon, Clock } from 'lucide-react';
-import { settingsAPI, agentVersionAPI, versionAPI, permissionsAPI } from '../utils/api';
-import { useUpdateNotification } from '../contexts/UpdateNotificationContext';
-import UpgradeNotificationIcon from '../components/UpgradeNotificationIcon';
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ AlertCircle,
+ CheckCircle,
+ Clock,
+ Code,
+ Download,
+ Plus,
+ Save,
+ Server,
+ Settings as SettingsIcon,
+ Shield,
+ Star,
+ Trash2,
+ X,
+} from "lucide-react";
+import React, { useEffect, useState } from "react";
+import UpgradeNotificationIcon from "../components/UpgradeNotificationIcon";
+import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
+import {
+ agentVersionAPI,
+ permissionsAPI,
+ settingsAPI,
+ versionAPI,
+} from "../utils/api";
const Settings = () => {
- const [formData, setFormData] = useState({
- serverProtocol: 'http',
- serverHost: 'localhost',
- serverPort: 3001,
- updateInterval: 60,
- autoUpdate: false,
- signupEnabled: false,
- defaultUserRole: 'user',
- githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
- repositoryType: 'public',
- sshKeyPath: '',
- useCustomSshKey: false
- });
- const [errors, setErrors] = useState({});
- const [isDirty, setIsDirty] = useState(false);
-
- // Tab management
- const [activeTab, setActiveTab] = useState('server');
-
- // Get update notification state
- const { updateAvailable } = useUpdateNotification();
-
- // Tab configuration
- const tabs = [
- { id: 'server', name: 'Server Configuration', icon: Server },
- { id: 'agent', name: 'Agent Management', icon: SettingsIcon },
- { id: 'version', name: 'Server Version', icon: Code, showUpgradeIcon: updateAvailable }
- ];
-
- // Agent version management state
- const [showAgentVersionModal, setShowAgentVersionModal] = useState(false);
- const [editingAgentVersion, setEditingAgentVersion] = useState(null);
- const [agentVersionForm, setAgentVersionForm] = useState({
- version: '',
- releaseNotes: '',
- scriptContent: '',
- isDefault: false
- });
-
- // Version checking state
- const [versionInfo, setVersionInfo] = useState({
- currentVersion: null, // Will be loaded from API
- latestVersion: null,
- isUpdateAvailable: false,
- checking: false,
- error: null
- });
-
- const [sshTestResult, setSshTestResult] = useState({
- testing: false,
- success: null,
- message: null,
- error: null
- });
-
- const queryClient = useQueryClient();
-
- // Fetch current settings
- const { data: settings, isLoading, error } = useQuery({
- queryKey: ['settings'],
- queryFn: () => settingsAPI.get().then(res => res.data)
- });
-
- // Fetch available roles for default user role dropdown
- const { data: roles, isLoading: rolesLoading } = useQuery({
- queryKey: ['rolePermissions'],
- queryFn: () => permissionsAPI.getRoles().then(res => res.data)
- });
-
- // Update form data when settings are loaded
- useEffect(() => {
- if (settings) {
- const newFormData = {
- serverProtocol: settings.server_protocol || 'http',
- serverHost: settings.server_host || 'localhost',
- serverPort: settings.server_port || 3001,
- updateInterval: settings.update_interval || 60,
- autoUpdate: settings.auto_update || false,
- signupEnabled: settings.signup_enabled === true ? true : false, // Explicit boolean conversion
- defaultUserRole: settings.default_user_role || 'user',
- githubRepoUrl: settings.github_repo_url || 'git@github.com:9technologygroup/patchmon.net.git',
- repositoryType: settings.repository_type || 'public',
- sshKeyPath: settings.ssh_key_path || '',
- useCustomSshKey: !!settings.ssh_key_path
- };
- setFormData(newFormData);
- setIsDirty(false);
- }
- }, [settings]);
-
- // Update settings mutation
- const updateSettingsMutation = useMutation({
- mutationFn: (data) => {
- return settingsAPI.update(data).then(res => res.data);
- },
- onSuccess: (data) => {
- queryClient.invalidateQueries(['settings']);
- setIsDirty(false);
- setErrors({});
- },
- onError: (error) => {
- if (error.response?.data?.errors) {
- setErrors(error.response.data.errors.reduce((acc, err) => {
- acc[err.path] = err.msg;
- return acc;
- }, {}));
- } else {
- setErrors({ general: error.response?.data?.error || 'Failed to update settings' });
- }
- }
- });
-
- // Agent version queries and mutations
- const { data: agentVersions, isLoading: agentVersionsLoading, error: agentVersionsError } = useQuery({
- queryKey: ['agentVersions'],
- queryFn: () => {
- return agentVersionAPI.list().then(res => {
- return res.data;
- });
- }
- });
-
-
- // Load current version on component mount
- useEffect(() => {
- const loadCurrentVersion = async () => {
- try {
- const response = await versionAPI.getCurrent();
- const data = response.data;
- setVersionInfo(prev => ({
- ...prev,
- currentVersion: data.version
- }));
- } catch (error) {
- console.error('Error loading current version:', error);
- }
- };
-
- loadCurrentVersion();
- }, []);
-
- const createAgentVersionMutation = useMutation({
- mutationFn: (data) => agentVersionAPI.create(data).then(res => res.data),
- onSuccess: () => {
- queryClient.invalidateQueries(['agentVersions']);
- setShowAgentVersionModal(false);
- setAgentVersionForm({ version: '', releaseNotes: '', scriptContent: '', isDefault: false });
- }
- });
-
- const setCurrentAgentVersionMutation = useMutation({
- mutationFn: (id) => agentVersionAPI.setCurrent(id).then(res => res.data),
- onSuccess: () => {
- queryClient.invalidateQueries(['agentVersions']);
- }
- });
-
- const setDefaultAgentVersionMutation = useMutation({
- mutationFn: (id) => agentVersionAPI.setDefault(id).then(res => res.data),
- onSuccess: () => {
- queryClient.invalidateQueries(['agentVersions']);
- }
- });
-
- const deleteAgentVersionMutation = useMutation({
- mutationFn: (id) => agentVersionAPI.delete(id).then(res => res.data),
- onSuccess: () => {
- queryClient.invalidateQueries(['agentVersions']);
- },
- onError: (error) => {
- console.error('Delete agent version error:', error);
-
- // Show user-friendly error message
- if (error.response?.data?.error === 'Agent version not found') {
- alert('Agent version not found. Please refresh the page to get the latest data.');
- // Force refresh the agent versions list
- queryClient.invalidateQueries(['agentVersions']);
- } else if (error.response?.data?.error === 'Cannot delete current agent version') {
- alert('Cannot delete the current agent version. Please set another version as current first.');
- } else {
- alert(`Failed to delete agent version: ${error.response?.data?.error || error.message}`);
- }
- }
- });
-
- // Version checking functions
- const checkForUpdates = async () => {
- setVersionInfo(prev => ({ ...prev, checking: true, error: null }));
-
- try {
- const response = await versionAPI.checkUpdates();
- const data = response.data;
-
- setVersionInfo({
- currentVersion: data.currentVersion,
- latestVersion: data.latestVersion,
- isUpdateAvailable: data.isUpdateAvailable,
- last_update_check: data.last_update_check,
- checking: false,
- error: null
- });
- } catch (error) {
- console.error('Version check error:', error);
- setVersionInfo(prev => ({
- ...prev,
- checking: false,
- error: error.response?.data?.error || 'Failed to check for updates'
- }));
- }
- };
-
- const testSshKey = async () => {
- if (!formData.sshKeyPath || !formData.githubRepoUrl) {
- setSshTestResult({
- testing: false,
- success: false,
- message: null,
- error: 'Please enter both SSH key path and GitHub repository URL'
- });
- return;
- }
-
- setSshTestResult({ testing: true, success: null, message: null, error: null });
-
- try {
- const response = await versionAPI.testSshKey({
- sshKeyPath: formData.sshKeyPath,
- githubRepoUrl: formData.githubRepoUrl
- });
-
- setSshTestResult({
- testing: false,
- success: true,
- message: response.data.message,
- error: null
- });
- } catch (error) {
- console.error('SSH key test error:', error);
- setSshTestResult({
- testing: false,
- success: false,
- message: null,
- error: error.response?.data?.error || 'Failed to test SSH key'
- });
- }
- };
-
- const handleInputChange = (field, value) => {
- setFormData(prev => {
- const newData = { ...prev, [field]: value };
- return newData;
- });
- setIsDirty(true);
- if (errors[field]) {
- setErrors(prev => ({ ...prev, [field]: null }));
- }
- };
-
- const handleSubmit = (e) => {
- e.preventDefault();
-
- // Only include sshKeyPath if the toggle is enabled
- const dataToSubmit = { ...formData };
- if (!dataToSubmit.useCustomSshKey) {
- dataToSubmit.sshKeyPath = '';
- }
- // Remove the frontend-only field
- delete dataToSubmit.useCustomSshKey;
-
- updateSettingsMutation.mutate(dataToSubmit);
- };
-
- const validateForm = () => {
- const newErrors = {};
-
- if (!formData.serverHost.trim()) {
- newErrors.serverHost = 'Server host is required';
- }
-
- if (!formData.serverPort || formData.serverPort < 1 || formData.serverPort > 65535) {
- newErrors.serverPort = 'Port must be between 1 and 65535';
- }
-
- if (!formData.updateInterval || formData.updateInterval < 5 || formData.updateInterval > 1440) {
- newErrors.updateInterval = 'Update interval must be between 5 and 1440 minutes';
- }
-
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
- };
-
- const handleSave = () => {
- if (validateForm()) {
- // Prepare data for submission
- const dataToSubmit = { ...formData };
- if (!dataToSubmit.useCustomSshKey) {
- dataToSubmit.sshKeyPath = '';
- }
- // Remove the frontend-only field
- delete dataToSubmit.useCustomSshKey;
-
- updateSettingsMutation.mutate(dataToSubmit);
- }
- };
-
- if (isLoading) {
- return (
-
- );
- }
-
- if (error) {
- return (
-
-
-
-
-
Error loading settings
-
- {error.response?.data?.error || 'Failed to load settings'}
-
-
-
-
- );
- }
-
- return (
-
-
-
- Configure your PatchMon server settings. These settings will be used in installation scripts and agent communications.
-
-
-
- {errors.general && (
-
- )}
-
- {/* Tab Navigation */}
-
-
-
- {tabs.map((tab) => {
- const Icon = tab.icon;
- return (
- setActiveTab(tab.id)}
- className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
- activeTab === tab.id
- ? 'border-primary-500 text-primary-600 dark:text-primary-400'
- : 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
- }`}
- >
-
- {tab.name}
- {tab.showUpgradeIcon && (
-
- )}
-
- );
- })}
-
-
-
- {/* Tab Content */}
-
- {/* Server Configuration Tab */}
- {activeTab === 'server' && (
-
-
-
-
Server Configuration
-
-
-
-
-
- Protocol
-
- handleInputChange('serverProtocol', e.target.value)}
- className="w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
- >
- HTTP
- HTTPS
-
-
-
-
-
- Host *
-
-
handleInputChange('serverHost', e.target.value)}
- className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
- errors.serverHost ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
- }`}
- placeholder="example.com"
- />
- {errors.serverHost && (
-
{errors.serverHost}
- )}
-
-
-
-
- Port *
-
-
handleInputChange('serverPort', parseInt(e.target.value))}
- className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
- errors.serverPort ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
- }`}
- min="1"
- max="65535"
- />
- {errors.serverPort && (
-
{errors.serverPort}
- )}
-
-
-
-
-
Server URL
-
- {formData.serverProtocol}://{formData.serverHost}:{formData.serverPort}
-
-
- This URL will be used in installation scripts and agent communications.
-
-
-
- {/* Update Interval */}
-
-
- Agent Update Interval (minutes)
-
-
- {/* Numeric input (concise width) */}
-
- {
- const val = parseInt(e.target.value);
- if (!isNaN(val)) {
- handleInputChange('updateInterval', Math.min(1440, Math.max(5, val)));
- } else {
- handleInputChange('updateInterval', 60);
- }
- }}
- className={`w-28 border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
- errors.updateInterval ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
- }`}
- placeholder="60"
- />
-
-
- {/* Quick presets */}
-
- {[15, 30, 60, 120, 360, 720, 1440].map((m) => (
- handleInputChange('updateInterval', m)}
- className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
- formData.updateInterval === m
- ? 'bg-primary-600 text-white border-primary-600'
- : 'bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600'
- }`}
- aria-label={`Set ${m} minutes`}
- >
- {m % 60 === 0 ? `${m / 60}h` : `${m}m`}
-
- ))}
-
-
- {/* Range slider */}
-
- handleInputChange('updateInterval', parseInt(e.target.value))}
- className="w-full accent-primary-600"
- aria-label="Update interval slider"
- />
-
-
- {errors.updateInterval && (
-
{errors.updateInterval}
- )}
-
- {/* Helper text */}
-
- Effective cadence: {' '}
- {(() => {
- const mins = parseInt(formData.updateInterval) || 60;
- if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'}`;
- const hrs = Math.floor(mins / 60);
- const rem = mins % 60;
- return `${hrs} hour${hrs === 1 ? '' : 's'}${rem ? ` ${rem} min` : ''}`;
- })()}
-
-
-
- This affects new installations and will update existing ones when they next reach out.
-
-
-
- {/* Auto-Update Setting */}
-
-
-
- handleInputChange('autoUpdate', e.target.checked)}
- className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
- />
- Enable Automatic Agent Updates
-
-
-
- When enabled, agents will automatically update themselves when a newer version is available during their regular update cycle.
-
-
-
- {/* User Signup Setting */}
-
-
-
- handleInputChange('signupEnabled', e.target.checked)}
- className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
- />
- Enable User Self-Registration
-
-
-
- {/* Default User Role Dropdown */}
- {formData.signupEnabled && (
-
-
- Default Role for New Users
-
-
handleInputChange('defaultUserRole', e.target.value)}
- className="w-full max-w-xs border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
- disabled={rolesLoading}
- >
- {rolesLoading ? (
- Loading roles...
- ) : roles && Array.isArray(roles) ? (
- roles.map((role) => (
-
- {role.role.charAt(0).toUpperCase() + role.role.slice(1)}
-
- ))
- ) : (
- User
- )}
-
-
- New users will be assigned this role when they register.
-
-
- )}
-
-
- When enabled, users can create their own accounts through the signup page. When disabled, only administrators can create user accounts.
-
-
-
- {/* Security Notice */}
-
-
-
-
-
Security Notice
-
- Changing these settings will affect all installation scripts and agent communications.
- Make sure the server URL is accessible from your client networks.
-
-
-
-
-
- {/* Save Button */}
-
-
- {updateSettingsMutation.isPending ? (
- <>
-
- Saving...
- >
- ) : (
- <>
-
- Save Settings
- >
- )}
-
-
-
- {updateSettingsMutation.isSuccess && (
-
-
-
-
-
Settings saved successfully!
-
-
-
- )}
-
- )}
-
- {/* Agent Management Tab */}
- {activeTab === 'agent' && (
-
-
-
-
-
-
Agent Version Management
-
-
- Manage different versions of the PatchMon agent script
-
-
-
setShowAgentVersionModal(true)}
- className="btn-primary flex items-center gap-2"
- >
-
- Add Version
-
-
-
- {/* Version Summary */}
- {agentVersions && agentVersions.length > 0 && (
-
-
-
-
- Current Version:
-
- {agentVersions.find(v => v.is_current)?.version || 'None'}
-
-
-
-
- Default Version:
-
- {agentVersions.find(v => v.is_default)?.version || 'None'}
-
-
-
-
- )}
-
- {agentVersionsLoading ? (
-
- ) : agentVersionsError ? (
-
-
Error loading agent versions: {agentVersionsError.message}
-
- ) : !agentVersions || agentVersions.length === 0 ? (
-
-
No agent versions found
-
- ) : (
-
- {agentVersions.map((version) => (
-
-
-
-
-
-
-
- Version {version.version}
-
- {version.is_default && (
-
-
- Default
-
- )}
- {version.is_current && (
-
- Current
-
- )}
-
- {version.release_notes && (
-
-
- {version.release_notes}
-
-
- )}
-
- Created: {new Date(version.created_at).toLocaleDateString()}
-
-
-
-
- {
- const downloadUrl = `/api/v1/hosts/agent/download?version=${version.version}`;
- window.open(downloadUrl, '_blank');
- }}
- className="btn-outline text-xs flex items-center gap-1"
- >
-
- Download
-
- setCurrentAgentVersionMutation.mutate(version.id)}
- disabled={version.is_current || setCurrentAgentVersionMutation.isPending}
- className="btn-outline text-xs flex items-center gap-1"
- >
-
- Set Current
-
- setDefaultAgentVersionMutation.mutate(version.id)}
- disabled={version.is_default || setDefaultAgentVersionMutation.isPending}
- className="btn-outline text-xs flex items-center gap-1"
- >
-
- Set Default
-
- deleteAgentVersionMutation.mutate(version.id)}
- disabled={version.is_default || version.is_current || deleteAgentVersionMutation.isPending}
- className="btn-danger text-xs flex items-center gap-1"
- >
-
- Delete
-
-
-
-
- ))}
-
- {agentVersions?.length === 0 && (
-
-
-
No agent versions found
-
- Add your first agent version to get started
-
-
- )}
-
- )}
-
- )}
-
- {/* Server Version Tab */}
- {activeTab === 'version' && (
-
-
-
-
Server Version Management
-
-
-
-
Version Check Configuration
-
- Configure automatic version checking against your GitHub repository to notify users of available updates.
-
-
-
-
-
- Repository Type
-
-
-
- Choose whether your repository is public or private to determine the appropriate access method.
-
-
-
-
-
- GitHub Repository URL
-
-
handleInputChange('githubRepoUrl', e.target.value)}
- className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
- placeholder="git@github.com:username/repository.git"
- />
-
- SSH or HTTPS URL to your GitHub repository
-
-
-
- {formData.repositoryType === 'private' && (
-
-
- {
- const checked = e.target.checked;
- handleInputChange('useCustomSshKey', checked);
- if (!checked) {
- handleInputChange('sshKeyPath', '');
- }
- }}
- className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
- />
-
- Set custom SSH key path
-
-
-
- {formData.useCustomSshKey && (
-
-
- SSH Key Path
-
-
handleInputChange('sshKeyPath', e.target.value)}
- className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
- placeholder="/root/.ssh/id_ed25519"
- />
-
- Path to your SSH deploy key. If not set, will auto-detect from common locations.
-
-
-
-
- {sshTestResult.testing ? 'Testing...' : 'Test SSH Key'}
-
-
- {sshTestResult.success && (
-
-
-
-
- {sshTestResult.message}
-
-
-
- )}
-
- {sshTestResult.error && (
-
-
-
-
- {sshTestResult.error}
-
-
-
- )}
-
-
- )}
-
- {!formData.useCustomSshKey && (
-
- Using auto-detection for SSH key location
-
- )}
-
- )}
-
-
-
-
-
- Current Version
-
-
{versionInfo.currentVersion}
-
-
-
-
-
- Latest Version
-
-
- {versionInfo.checking ? (
- Checking...
- ) : versionInfo.latestVersion ? (
-
- {versionInfo.latestVersion}
- {versionInfo.isUpdateAvailable && ' (Update Available!)'}
-
- ) : (
- Not checked
- )}
-
-
-
-
- {/* Last Checked Time */}
- {versionInfo.last_update_check && (
-
-
-
- Last Checked
-
-
- {new Date(versionInfo.last_update_check).toLocaleString()}
-
-
- Updates are checked automatically every 24 hours
-
-
- )}
-
-
-
-
-
- {versionInfo.checking ? 'Checking...' : 'Check for Updates'}
-
-
-
- {/* Save Button for Version Settings */}
-
- {updateSettingsMutation.isPending ? (
- <>
-
- Saving...
- >
- ) : (
- <>
-
- Save Settings
- >
- )}
-
-
-
- {versionInfo.error && (
-
-
-
-
-
Version Check Failed
-
- {versionInfo.error}
-
- {versionInfo.error.includes('private') && (
-
- For private repositories, you may need to configure GitHub authentication or make the repository public.
-
- )}
-
-
-
- )}
-
- {/* Success Message for Version Settings */}
- {updateSettingsMutation.isSuccess && (
-
-
-
-
-
Settings saved successfully!
-
-
-
- )}
-
-
-
-
- )}
-
-
-
- {/* Agent Version Modal */}
- {showAgentVersionModal && (
-
{
- setShowAgentVersionModal(false);
- setAgentVersionForm({ version: '', releaseNotes: '', scriptContent: '', isDefault: false });
- }}
- onSubmit={createAgentVersionMutation.mutate}
- isLoading={createAgentVersionMutation.isPending}
- />
- )}
-
- );
+ const [formData, setFormData] = useState({
+ serverProtocol: "http",
+ serverHost: "localhost",
+ serverPort: 3001,
+ updateInterval: 60,
+ autoUpdate: false,
+ signupEnabled: false,
+ defaultUserRole: "user",
+ githubRepoUrl: "git@github.com:9technologygroup/patchmon.net.git",
+ repositoryType: "public",
+ sshKeyPath: "",
+ useCustomSshKey: false,
+ });
+ const [errors, setErrors] = useState({});
+ const [isDirty, setIsDirty] = useState(false);
+
+ // Tab management
+ const [activeTab, setActiveTab] = useState("server");
+
+ // Get update notification state
+ const { updateAvailable } = useUpdateNotification();
+
+ // Tab configuration
+ const tabs = [
+ { id: "server", name: "Server Configuration", icon: Server },
+ { id: "agent", name: "Agent Management", icon: SettingsIcon },
+ {
+ id: "version",
+ name: "Server Version",
+ icon: Code,
+ showUpgradeIcon: updateAvailable,
+ },
+ ];
+
+ // Agent version management state
+ const [showAgentVersionModal, setShowAgentVersionModal] = useState(false);
+ const [editingAgentVersion, setEditingAgentVersion] = useState(null);
+ const [agentVersionForm, setAgentVersionForm] = useState({
+ version: "",
+ releaseNotes: "",
+ scriptContent: "",
+ isDefault: false,
+ });
+
+ // Version checking state
+ const [versionInfo, setVersionInfo] = useState({
+ currentVersion: null, // Will be loaded from API
+ latestVersion: null,
+ isUpdateAvailable: false,
+ checking: false,
+ error: null,
+ });
+
+ const [sshTestResult, setSshTestResult] = useState({
+ testing: false,
+ success: null,
+ message: null,
+ error: null,
+ });
+
+ const queryClient = useQueryClient();
+
+ // Fetch current settings
+ const {
+ data: settings,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["settings"],
+ queryFn: () => settingsAPI.get().then((res) => res.data),
+ });
+
+ // Fetch available roles for default user role dropdown
+ const { data: roles, isLoading: rolesLoading } = useQuery({
+ queryKey: ["rolePermissions"],
+ queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
+ });
+
+ // Update form data when settings are loaded
+ useEffect(() => {
+ if (settings) {
+ const newFormData = {
+ serverProtocol: settings.server_protocol || "http",
+ serverHost: settings.server_host || "localhost",
+ serverPort: settings.server_port || 3001,
+ updateInterval: settings.update_interval || 60,
+ autoUpdate: settings.auto_update || false,
+ signupEnabled: settings.signup_enabled === true ? true : false, // Explicit boolean conversion
+ defaultUserRole: settings.default_user_role || "user",
+ githubRepoUrl:
+ settings.github_repo_url ||
+ "git@github.com:9technologygroup/patchmon.net.git",
+ repositoryType: settings.repository_type || "public",
+ sshKeyPath: settings.ssh_key_path || "",
+ useCustomSshKey: !!settings.ssh_key_path,
+ };
+ setFormData(newFormData);
+ setIsDirty(false);
+ }
+ }, [settings]);
+
+ // Update settings mutation
+ const updateSettingsMutation = useMutation({
+ mutationFn: (data) => {
+ return settingsAPI.update(data).then((res) => res.data);
+ },
+ onSuccess: (data) => {
+ queryClient.invalidateQueries(["settings"]);
+ setIsDirty(false);
+ setErrors({});
+ },
+ onError: (error) => {
+ if (error.response?.data?.errors) {
+ setErrors(
+ error.response.data.errors.reduce((acc, err) => {
+ acc[err.path] = err.msg;
+ return acc;
+ }, {}),
+ );
+ } else {
+ setErrors({
+ general: error.response?.data?.error || "Failed to update settings",
+ });
+ }
+ },
+ });
+
+ // Agent version queries and mutations
+ const {
+ data: agentVersions,
+ isLoading: agentVersionsLoading,
+ error: agentVersionsError,
+ } = useQuery({
+ queryKey: ["agentVersions"],
+ queryFn: () => {
+ return agentVersionAPI.list().then((res) => {
+ return res.data;
+ });
+ },
+ });
+
+ // Load current version on component mount
+ useEffect(() => {
+ const loadCurrentVersion = async () => {
+ try {
+ const response = await versionAPI.getCurrent();
+ const data = response.data;
+ setVersionInfo((prev) => ({
+ ...prev,
+ currentVersion: data.version,
+ }));
+ } catch (error) {
+ console.error("Error loading current version:", error);
+ }
+ };
+
+ loadCurrentVersion();
+ }, []);
+
+ const createAgentVersionMutation = useMutation({
+ mutationFn: (data) => agentVersionAPI.create(data).then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["agentVersions"]);
+ setShowAgentVersionModal(false);
+ setAgentVersionForm({
+ version: "",
+ releaseNotes: "",
+ scriptContent: "",
+ isDefault: false,
+ });
+ },
+ });
+
+ const setCurrentAgentVersionMutation = useMutation({
+ mutationFn: (id) => agentVersionAPI.setCurrent(id).then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["agentVersions"]);
+ },
+ });
+
+ const setDefaultAgentVersionMutation = useMutation({
+ mutationFn: (id) => agentVersionAPI.setDefault(id).then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["agentVersions"]);
+ },
+ });
+
+ const deleteAgentVersionMutation = useMutation({
+ mutationFn: (id) => agentVersionAPI.delete(id).then((res) => res.data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["agentVersions"]);
+ },
+ onError: (error) => {
+ console.error("Delete agent version error:", error);
+
+ // Show user-friendly error message
+ if (error.response?.data?.error === "Agent version not found") {
+ alert(
+ "Agent version not found. Please refresh the page to get the latest data.",
+ );
+ // Force refresh the agent versions list
+ queryClient.invalidateQueries(["agentVersions"]);
+ } else if (
+ error.response?.data?.error === "Cannot delete current agent version"
+ ) {
+ alert(
+ "Cannot delete the current agent version. Please set another version as current first.",
+ );
+ } else {
+ alert(
+ `Failed to delete agent version: ${error.response?.data?.error || error.message}`,
+ );
+ }
+ },
+ });
+
+ // Version checking functions
+ const checkForUpdates = async () => {
+ setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
+
+ try {
+ const response = await versionAPI.checkUpdates();
+ const data = response.data;
+
+ setVersionInfo({
+ currentVersion: data.currentVersion,
+ latestVersion: data.latestVersion,
+ isUpdateAvailable: data.isUpdateAvailable,
+ last_update_check: data.last_update_check,
+ checking: false,
+ error: null,
+ });
+ } catch (error) {
+ console.error("Version check error:", error);
+ setVersionInfo((prev) => ({
+ ...prev,
+ checking: false,
+ error: error.response?.data?.error || "Failed to check for updates",
+ }));
+ }
+ };
+
+ const testSshKey = async () => {
+ if (!formData.sshKeyPath || !formData.githubRepoUrl) {
+ setSshTestResult({
+ testing: false,
+ success: false,
+ message: null,
+ error: "Please enter both SSH key path and GitHub repository URL",
+ });
+ return;
+ }
+
+ setSshTestResult({
+ testing: true,
+ success: null,
+ message: null,
+ error: null,
+ });
+
+ try {
+ const response = await versionAPI.testSshKey({
+ sshKeyPath: formData.sshKeyPath,
+ githubRepoUrl: formData.githubRepoUrl,
+ });
+
+ setSshTestResult({
+ testing: false,
+ success: true,
+ message: response.data.message,
+ error: null,
+ });
+ } catch (error) {
+ console.error("SSH key test error:", error);
+ setSshTestResult({
+ testing: false,
+ success: false,
+ message: null,
+ error: error.response?.data?.error || "Failed to test SSH key",
+ });
+ }
+ };
+
+ const handleInputChange = (field, value) => {
+ setFormData((prev) => {
+ const newData = { ...prev, [field]: value };
+ return newData;
+ });
+ setIsDirty(true);
+ if (errors[field]) {
+ setErrors((prev) => ({ ...prev, [field]: null }));
+ }
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+
+ // Only include sshKeyPath if the toggle is enabled
+ const dataToSubmit = { ...formData };
+ if (!dataToSubmit.useCustomSshKey) {
+ dataToSubmit.sshKeyPath = "";
+ }
+ // Remove the frontend-only field
+ delete dataToSubmit.useCustomSshKey;
+
+ updateSettingsMutation.mutate(dataToSubmit);
+ };
+
+ const validateForm = () => {
+ const newErrors = {};
+
+ if (!formData.serverHost.trim()) {
+ newErrors.serverHost = "Server host is required";
+ }
+
+ if (
+ !formData.serverPort ||
+ formData.serverPort < 1 ||
+ formData.serverPort > 65535
+ ) {
+ newErrors.serverPort = "Port must be between 1 and 65535";
+ }
+
+ if (
+ !formData.updateInterval ||
+ formData.updateInterval < 5 ||
+ formData.updateInterval > 1440
+ ) {
+ newErrors.updateInterval =
+ "Update interval must be between 5 and 1440 minutes";
+ }
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleSave = () => {
+ if (validateForm()) {
+ // Prepare data for submission
+ const dataToSubmit = { ...formData };
+ if (!dataToSubmit.useCustomSshKey) {
+ dataToSubmit.sshKeyPath = "";
+ }
+ // Remove the frontend-only field
+ delete dataToSubmit.useCustomSshKey;
+
+ updateSettingsMutation.mutate(dataToSubmit);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+ Error loading settings
+
+
+ {error.response?.data?.error || "Failed to load settings"}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Configure your PatchMon server settings. These settings will be used
+ in installation scripts and agent communications.
+
+
+
+ {errors.general && (
+
+ )}
+
+ {/* Tab Navigation */}
+
+
+
+ {tabs.map((tab) => {
+ const Icon = tab.icon;
+ return (
+ setActiveTab(tab.id)}
+ className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
+ activeTab === tab.id
+ ? "border-primary-500 text-primary-600 dark:text-primary-400"
+ : "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
+ }`}
+ >
+
+ {tab.name}
+ {tab.showUpgradeIcon && (
+
+ )}
+
+ );
+ })}
+
+
+
+ {/* Tab Content */}
+
+ {/* Server Configuration Tab */}
+ {activeTab === "server" && (
+
+
+
+
+ Server Configuration
+
+
+
+
+
+
+ Protocol
+
+
+ handleInputChange("serverProtocol", e.target.value)
+ }
+ className="w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
+ >
+ HTTP
+ HTTPS
+
+
+
+
+
+ Host *
+
+
+ handleInputChange("serverHost", e.target.value)
+ }
+ className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
+ errors.serverHost
+ ? "border-red-300 dark:border-red-500"
+ : "border-secondary-300 dark:border-secondary-600"
+ }`}
+ placeholder="example.com"
+ />
+ {errors.serverHost && (
+
+ {errors.serverHost}
+
+ )}
+
+
+
+
+ Port *
+
+
+ handleInputChange("serverPort", parseInt(e.target.value))
+ }
+ className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
+ errors.serverPort
+ ? "border-red-300 dark:border-red-500"
+ : "border-secondary-300 dark:border-secondary-600"
+ }`}
+ min="1"
+ max="65535"
+ />
+ {errors.serverPort && (
+
+ {errors.serverPort}
+
+ )}
+
+
+
+
+
+ Server URL
+
+
+ {formData.serverProtocol}://{formData.serverHost}:
+ {formData.serverPort}
+
+
+ This URL will be used in installation scripts and agent
+ communications.
+
+
+
+ {/* Update Interval */}
+
+
+ Agent Update Interval (minutes)
+
+
+ {/* Numeric input (concise width) */}
+
+ {
+ const val = parseInt(e.target.value);
+ if (!isNaN(val)) {
+ handleInputChange(
+ "updateInterval",
+ Math.min(1440, Math.max(5, val)),
+ );
+ } else {
+ handleInputChange("updateInterval", 60);
+ }
+ }}
+ className={`w-28 border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
+ errors.updateInterval
+ ? "border-red-300 dark:border-red-500"
+ : "border-secondary-300 dark:border-secondary-600"
+ }`}
+ placeholder="60"
+ />
+
+
+ {/* Quick presets */}
+
+ {[15, 30, 60, 120, 360, 720, 1440].map((m) => (
+ handleInputChange("updateInterval", m)}
+ className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
+ formData.updateInterval === m
+ ? "bg-primary-600 text-white border-primary-600"
+ : "bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600"
+ }`}
+ aria-label={`Set ${m} minutes`}
+ >
+ {m % 60 === 0 ? `${m / 60}h` : `${m}m`}
+
+ ))}
+
+
+ {/* Range slider */}
+
+
+ handleInputChange(
+ "updateInterval",
+ parseInt(e.target.value),
+ )
+ }
+ className="w-full accent-primary-600"
+ aria-label="Update interval slider"
+ />
+
+
+ {errors.updateInterval && (
+
+ {errors.updateInterval}
+
+ )}
+
+ {/* Helper text */}
+
+ Effective cadence: {" "}
+ {(() => {
+ const mins = parseInt(formData.updateInterval) || 60;
+ if (mins < 60)
+ return `${mins} minute${mins === 1 ? "" : "s"}`;
+ const hrs = Math.floor(mins / 60);
+ const rem = mins % 60;
+ return `${hrs} hour${hrs === 1 ? "" : "s"}${rem ? ` ${rem} min` : ""}`;
+ })()}
+
+
+
+ This affects new installations and will update existing ones
+ when they next reach out.
+
+
+
+ {/* Auto-Update Setting */}
+
+
+
+
+ handleInputChange("autoUpdate", e.target.checked)
+ }
+ className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
+ />
+ Enable Automatic Agent Updates
+
+
+
+ When enabled, agents will automatically update themselves when
+ a newer version is available during their regular update
+ cycle.
+
+
+
+ {/* User Signup Setting */}
+
+
+
+
+ handleInputChange("signupEnabled", e.target.checked)
+ }
+ className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
+ />
+ Enable User Self-Registration
+
+
+
+ {/* Default User Role Dropdown */}
+ {formData.signupEnabled && (
+
+
+ Default Role for New Users
+
+
+ handleInputChange("defaultUserRole", e.target.value)
+ }
+ className="w-full max-w-xs border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
+ disabled={rolesLoading}
+ >
+ {rolesLoading ? (
+ Loading roles...
+ ) : roles && Array.isArray(roles) ? (
+ roles.map((role) => (
+
+ {role.role.charAt(0).toUpperCase() +
+ role.role.slice(1)}
+
+ ))
+ ) : (
+ User
+ )}
+
+
+ New users will be assigned this role when they register.
+
+
+ )}
+
+
+ When enabled, users can create their own accounts through the
+ signup page. When disabled, only administrators can create
+ user accounts.
+
+
+
+ {/* Security Notice */}
+
+
+
+
+
+ Security Notice
+
+
+ Changing these settings will affect all installation
+ scripts and agent communications. Make sure the server URL
+ is accessible from your client networks.
+
+
+
+
+
+ {/* Save Button */}
+
+
+ {updateSettingsMutation.isPending ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ <>
+
+ Save Settings
+ >
+ )}
+
+
+
+ {updateSettingsMutation.isSuccess && (
+
+
+
+
+
+ Settings saved successfully!
+
+
+
+
+ )}
+
+ )}
+
+ {/* Agent Management Tab */}
+ {activeTab === "agent" && (
+
+
+
+
+
+
+ Agent Version Management
+
+
+
+ Manage different versions of the PatchMon agent script
+
+
+
setShowAgentVersionModal(true)}
+ className="btn-primary flex items-center gap-2"
+ >
+
+ Add Version
+
+
+
+ {/* Version Summary */}
+ {agentVersions && agentVersions.length > 0 && (
+
+
+
+
+
+ Current Version:
+
+
+ {agentVersions.find((v) => v.is_current)?.version ||
+ "None"}
+
+
+
+
+
+ Default Version:
+
+
+ {agentVersions.find((v) => v.is_default)?.version ||
+ "None"}
+
+
+
+
+ )}
+
+ {agentVersionsLoading ? (
+
+ ) : agentVersionsError ? (
+
+
+ Error loading agent versions: {agentVersionsError.message}
+
+
+ ) : !agentVersions || agentVersions.length === 0 ? (
+
+
+ No agent versions found
+
+
+ ) : (
+
+ {agentVersions.map((version) => (
+
+
+
+
+
+
+
+ Version {version.version}
+
+ {version.is_default && (
+
+
+ Default
+
+ )}
+ {version.is_current && (
+
+ Current
+
+ )}
+
+ {version.release_notes && (
+
+
+ {version.release_notes}
+
+
+ )}
+
+ Created:{" "}
+ {new Date(
+ version.created_at,
+ ).toLocaleDateString()}
+
+
+
+
+ {
+ const downloadUrl = `/api/v1/hosts/agent/download?version=${version.version}`;
+ window.open(downloadUrl, "_blank");
+ }}
+ className="btn-outline text-xs flex items-center gap-1"
+ >
+
+ Download
+
+
+ setCurrentAgentVersionMutation.mutate(version.id)
+ }
+ disabled={
+ version.is_current ||
+ setCurrentAgentVersionMutation.isPending
+ }
+ className="btn-outline text-xs flex items-center gap-1"
+ >
+
+ Set Current
+
+
+ setDefaultAgentVersionMutation.mutate(version.id)
+ }
+ disabled={
+ version.is_default ||
+ setDefaultAgentVersionMutation.isPending
+ }
+ className="btn-outline text-xs flex items-center gap-1"
+ >
+
+ Set Default
+
+
+ deleteAgentVersionMutation.mutate(version.id)
+ }
+ disabled={
+ version.is_default ||
+ version.is_current ||
+ deleteAgentVersionMutation.isPending
+ }
+ className="btn-danger text-xs flex items-center gap-1"
+ >
+
+ Delete
+
+
+
+
+ ))}
+
+ {agentVersions?.length === 0 && (
+
+
+
+ No agent versions found
+
+
+ Add your first agent version to get started
+
+
+ )}
+
+ )}
+
+ )}
+
+ {/* Server Version Tab */}
+ {activeTab === "version" && (
+
+
+
+
+ Server Version Management
+
+
+
+
+
+ Version Check Configuration
+
+
+ Configure automatic version checking against your GitHub
+ repository to notify users of available updates.
+
+
+
+
+
+ Repository Type
+
+
+
+ Choose whether your repository is public or private to
+ determine the appropriate access method.
+
+
+
+
+
+ GitHub Repository URL
+
+
+ handleInputChange("githubRepoUrl", e.target.value)
+ }
+ className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
+ placeholder="git@github.com:username/repository.git"
+ />
+
+ SSH or HTTPS URL to your GitHub repository
+
+
+
+ {formData.repositoryType === "private" && (
+
+
+ {
+ const checked = e.target.checked;
+ handleInputChange("useCustomSshKey", checked);
+ if (!checked) {
+ handleInputChange("sshKeyPath", "");
+ }
+ }}
+ className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
+ />
+
+ Set custom SSH key path
+
+
+
+ {formData.useCustomSshKey && (
+
+
+ SSH Key Path
+
+
+ handleInputChange("sshKeyPath", e.target.value)
+ }
+ className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
+ placeholder="/root/.ssh/id_ed25519"
+ />
+
+ Path to your SSH deploy key. If not set, will
+ auto-detect from common locations.
+
+
+
+
+ {sshTestResult.testing
+ ? "Testing..."
+ : "Test SSH Key"}
+
+
+ {sshTestResult.success && (
+
+
+
+
+ {sshTestResult.message}
+
+
+
+ )}
+
+ {sshTestResult.error && (
+
+
+
+
+ {sshTestResult.error}
+
+
+
+ )}
+
+
+ )}
+
+ {!formData.useCustomSshKey && (
+
+ Using auto-detection for SSH key location
+
+ )}
+
+ )}
+
+
+
+
+
+
+ Current Version
+
+
+
+ {versionInfo.currentVersion}
+
+
+
+
+
+
+
+ Latest Version
+
+
+
+ {versionInfo.checking ? (
+
+ Checking...
+
+ ) : versionInfo.latestVersion ? (
+
+ {versionInfo.latestVersion}
+ {versionInfo.isUpdateAvailable &&
+ " (Update Available!)"}
+
+ ) : (
+
+ Not checked
+
+ )}
+
+
+
+
+ {/* Last Checked Time */}
+ {versionInfo.last_update_check && (
+
+
+
+
+ Last Checked
+
+
+
+ {new Date(
+ versionInfo.last_update_check,
+ ).toLocaleString()}
+
+
+ Updates are checked automatically every 24 hours
+
+
+ )}
+
+
+
+
+
+ {versionInfo.checking
+ ? "Checking..."
+ : "Check for Updates"}
+
+
+
+ {/* Save Button for Version Settings */}
+
+ {updateSettingsMutation.isPending ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ <>
+
+ Save Settings
+ >
+ )}
+
+
+
+ {versionInfo.error && (
+
+
+
+
+
+ Version Check Failed
+
+
+ {versionInfo.error}
+
+ {versionInfo.error.includes("private") && (
+
+ For private repositories, you may need to
+ configure GitHub authentication or make the
+ repository public.
+
+ )}
+
+
+
+ )}
+
+ {/* Success Message for Version Settings */}
+ {updateSettingsMutation.isSuccess && (
+
+
+
+
+
+ Settings saved successfully!
+
+
+
+
+ )}
+
+
+
+ )}
+
+
+
+ {/* Agent Version Modal */}
+ {showAgentVersionModal && (
+
{
+ setShowAgentVersionModal(false);
+ setAgentVersionForm({
+ version: "",
+ releaseNotes: "",
+ scriptContent: "",
+ isDefault: false,
+ });
+ }}
+ onSubmit={createAgentVersionMutation.mutate}
+ isLoading={createAgentVersionMutation.isPending}
+ />
+ )}
+
+ );
};
// Agent Version Modal Component
const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
- const [formData, setFormData] = useState({
- version: '',
- releaseNotes: '',
- scriptContent: '',
- isDefault: false
- });
- const [errors, setErrors] = useState({});
+ const [formData, setFormData] = useState({
+ version: "",
+ releaseNotes: "",
+ scriptContent: "",
+ isDefault: false,
+ });
+ const [errors, setErrors] = useState({});
- const handleSubmit = (e) => {
- e.preventDefault();
+ const handleSubmit = (e) => {
+ e.preventDefault();
- // Basic validation
- const newErrors = {};
- if (!formData.version.trim()) newErrors.version = 'Version is required';
- if (!formData.scriptContent.trim()) newErrors.scriptContent = 'Script content is required';
+ // Basic validation
+ const newErrors = {};
+ if (!formData.version.trim()) newErrors.version = "Version is required";
+ if (!formData.scriptContent.trim())
+ newErrors.scriptContent = "Script content is required";
- if (Object.keys(newErrors).length > 0) {
- setErrors(newErrors);
- return;
- }
+ if (Object.keys(newErrors).length > 0) {
+ setErrors(newErrors);
+ return;
+ }
- onSubmit(formData);
- };
+ onSubmit(formData);
+ };
- const handleFileUpload = (e) => {
- const file = e.target.files[0];
- if (file) {
- const reader = new FileReader();
- reader.onload = (event) => {
- setFormData(prev => ({ ...prev, scriptContent: event.target.result }));
- };
- reader.readAsText(file);
- }
- };
+ const handleFileUpload = (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ setFormData((prev) => ({
+ ...prev,
+ scriptContent: event.target.result,
+ }));
+ };
+ reader.readAsText(file);
+ }
+ };
- if (!isOpen) return null;
+ if (!isOpen) return null;
- return (
-
-
-
-
-
Add Agent Version
-
-
-
-
-
+ return (
+
+
+
+
+
+ Add Agent Version
+
+
+
+
+
+
-
-
-
-
- Version *
-
-
setFormData(prev => ({ ...prev, version: e.target.value }))}
- className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
- errors.version ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
- }`}
- placeholder="e.g., 1.0.1"
- />
- {errors.version && (
-
{errors.version}
- )}
-
+
+
+
+
+ Version *
+
+
+ setFormData((prev) => ({ ...prev, version: e.target.value }))
+ }
+ className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
+ errors.version
+ ? "border-red-300 dark:border-red-500"
+ : "border-secondary-300 dark:border-secondary-600"
+ }`}
+ placeholder="e.g., 1.0.1"
+ />
+ {errors.version && (
+
+ {errors.version}
+
+ )}
+
-
-
- Release Notes
-
- setFormData(prev => ({ ...prev, releaseNotes: e.target.value }))}
- rows={3}
- className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
- placeholder="Describe what's new in this version..."
- />
-
+
+
+ Release Notes
+
+
+ setFormData((prev) => ({
+ ...prev,
+ releaseNotes: e.target.value,
+ }))
+ }
+ rows={3}
+ className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
+ placeholder="Describe what's new in this version..."
+ />
+
-
-
- Script Content *
-
-
-
-
setFormData(prev => ({ ...prev, scriptContent: e.target.value }))}
- rows={10}
- className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm ${
- errors.scriptContent ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
- }`}
- placeholder="Paste the agent script content here..."
- />
- {errors.scriptContent && (
- {errors.scriptContent}
- )}
-
-
+
+
+ Script Content *
+
+
+
+
+ setFormData((prev) => ({
+ ...prev,
+ scriptContent: e.target.value,
+ }))
+ }
+ rows={10}
+ className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm ${
+ errors.scriptContent
+ ? "border-red-300 dark:border-red-500"
+ : "border-secondary-300 dark:border-secondary-600"
+ }`}
+ placeholder="Paste the agent script content here..."
+ />
+ {errors.scriptContent && (
+
+ {errors.scriptContent}
+
+ )}
+
+
-
- setFormData(prev => ({ ...prev, isDefault: e.target.checked }))}
- className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-600 rounded"
- />
-
- Set as default version for new installations
-
-
-
+
+
+ setFormData((prev) => ({
+ ...prev,
+ isDefault: e.target.checked,
+ }))
+ }
+ className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-600 rounded"
+ />
+
+ Set as default version for new installations
+
+
+
-
-
- Cancel
-
-
- {isLoading ? 'Creating...' : 'Create Version'}
-
-
-
-
-
- );
+
+
+ Cancel
+
+
+ {isLoading ? "Creating..." : "Create Version"}
+
+
+
+
+
+ );
};
export default Settings;
diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx
index d25f20a..7d0e62f 100644
--- a/frontend/src/pages/Users.jsx
+++ b/frontend/src/pages/Users.jsx
@@ -1,712 +1,775 @@
-import React, { useState } from 'react'
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
-import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle, Key } from 'lucide-react'
-import { adminUsersAPI, permissionsAPI } from '../utils/api'
-import { useAuth } from '../contexts/AuthContext'
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ Calendar,
+ CheckCircle,
+ Edit,
+ Key,
+ Mail,
+ Plus,
+ Shield,
+ Trash2,
+ User,
+ XCircle,
+} from "lucide-react";
+import React, { useState } from "react";
+import { useAuth } from "../contexts/AuthContext";
+import { adminUsersAPI, permissionsAPI } from "../utils/api";
const Users = () => {
- const [showAddModal, setShowAddModal] = useState(false)
- const [editingUser, setEditingUser] = useState(null)
- const [resetPasswordUser, setResetPasswordUser] = useState(null)
- const queryClient = useQueryClient()
- const { user: currentUser } = useAuth()
+ const [showAddModal, setShowAddModal] = useState(false);
+ const [editingUser, setEditingUser] = useState(null);
+ const [resetPasswordUser, setResetPasswordUser] = useState(null);
+ const queryClient = useQueryClient();
+ const { user: currentUser } = useAuth();
- // Fetch users
- const { data: users, isLoading, error } = useQuery({
- queryKey: ['users'],
- queryFn: () => adminUsersAPI.list().then(res => res.data)
- })
+ // Fetch users
+ const {
+ data: users,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ["users"],
+ queryFn: () => adminUsersAPI.list().then((res) => res.data),
+ });
- // Fetch available roles
- const { data: roles } = useQuery({
- queryKey: ['rolePermissions'],
- queryFn: () => permissionsAPI.getRoles().then(res => res.data)
- })
+ // Fetch available roles
+ const { data: roles } = useQuery({
+ queryKey: ["rolePermissions"],
+ queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
+ });
- // Delete user mutation
- const deleteUserMutation = useMutation({
- mutationFn: adminUsersAPI.delete,
- onSuccess: () => {
- queryClient.invalidateQueries(['users'])
- }
- })
+ // Delete user mutation
+ const deleteUserMutation = useMutation({
+ mutationFn: adminUsersAPI.delete,
+ onSuccess: () => {
+ queryClient.invalidateQueries(["users"]);
+ },
+ });
- // Update user mutation
- const updateUserMutation = useMutation({
- mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
- onSuccess: () => {
- queryClient.invalidateQueries(['users'])
- setEditingUser(null)
- }
- })
+ // Update user mutation
+ const updateUserMutation = useMutation({
+ mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["users"]);
+ setEditingUser(null);
+ },
+ });
- // Reset password mutation
- const resetPasswordMutation = useMutation({
- mutationFn: ({ userId, newPassword }) => adminUsersAPI.resetPassword(userId, newPassword),
- onSuccess: () => {
- queryClient.invalidateQueries(['users'])
- setResetPasswordUser(null)
- }
- })
+ // Reset password mutation
+ const resetPasswordMutation = useMutation({
+ mutationFn: ({ userId, newPassword }) =>
+ adminUsersAPI.resetPassword(userId, newPassword),
+ onSuccess: () => {
+ queryClient.invalidateQueries(["users"]);
+ setResetPasswordUser(null);
+ },
+ });
- const handleDeleteUser = async (userId, username) => {
- if (window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
- try {
- await deleteUserMutation.mutateAsync(userId)
- } catch (error) {
- console.error('Failed to delete user:', error)
- }
- }
- }
+ const handleDeleteUser = async (userId, username) => {
+ if (
+ window.confirm(
+ `Are you sure you want to delete user "${username}"? This action cannot be undone.`,
+ )
+ ) {
+ try {
+ await deleteUserMutation.mutateAsync(userId);
+ } catch (error) {
+ console.error("Failed to delete user:", error);
+ }
+ }
+ };
- const handleUserCreated = () => {
- queryClient.invalidateQueries(['users'])
- setShowAddModal(false)
- }
+ const handleUserCreated = () => {
+ queryClient.invalidateQueries(["users"]);
+ setShowAddModal(false);
+ };
- const handleEditUser = (user) => {
- setEditingUser(user)
- }
+ const handleEditUser = (user) => {
+ setEditingUser(user);
+ };
- const handleResetPassword = (user) => {
- setResetPasswordUser(user)
- }
+ const handleResetPassword = (user) => {
+ setResetPasswordUser(user);
+ };
- if (isLoading) {
- return (
-
- )
- }
+ if (isLoading) {
+ return (
+
+ );
+ }
- if (error) {
- return (
-
-
-
-
-
Error loading users
-
{error.message}
-
-
-
- )
- }
+ if (error) {
+ return (
+
+
+
+
+
+ Error loading users
+
+
{error.message}
+
+
+
+ );
+ }
- return (
-
- {/* Header */}
-
-
setShowAddModal(true)}
- className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
- >
-
- Add User
-
-
+ return (
+
+ {/* Header */}
+
+
setShowAddModal(true)}
+ className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
+ >
+
+ Add User
+
+
- {/* Users Table */}
-
-
- {users && Array.isArray(users) && users.length > 0 ? (
- users.map((user) => (
-
-
-
-
-
-
-
{user.username}
- {user.id === currentUser?.id && (
-
- You
-
- )}
-
-
- {user.role.charAt(0).toUpperCase() + user.role.slice(1).replace('_', ' ')}
-
- {user.is_active ? (
-
- ) : (
-
- )}
-
-
-
- {user.email}
-
-
-
- Created: {new Date(user.created_at).toLocaleDateString()}
- {user.last_login && (
- <>
- •
- Last login: {new Date(user.last_login).toLocaleDateString()}
- >
- )}
-
-
-
-
- handleEditUser(user)}
- className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
- title="Edit user"
- >
-
-
- handleResetPassword(user)}
- className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
- title={
- !user.is_active
- ? "Cannot reset password for inactive user"
- : "Reset password"
- }
- disabled={!user.is_active}
- >
-
-
- handleDeleteUser(user.id, user.username)}
- className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
- title={
- user.id === currentUser?.id
- ? "Cannot delete your own account"
- : user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1
- ? "Cannot delete the last admin user"
- : "Delete user"
- }
- disabled={
- user.id === currentUser?.id ||
- (user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1)
- }
- >
-
-
-
-
-
- ))
- ) : (
-
-
-
-
No users found
-
- Click "Add User" to create the first user
-
-
-
- )}
-
-
+ {/* Users Table */}
+
+
+ {users && Array.isArray(users) && users.length > 0 ? (
+ users.map((user) => (
+
+
+
+
+
+
+
+ {user.username}
+
+ {user.id === currentUser?.id && (
+
+ You
+
+ )}
+
+
+ {user.role.charAt(0).toUpperCase() +
+ user.role.slice(1).replace("_", " ")}
+
+ {user.is_active ? (
+
+ ) : (
+
+ )}
+
+
+
+ {user.email}
+
+
+
+ Created:{" "}
+ {new Date(user.created_at).toLocaleDateString()}
+ {user.last_login && (
+ <>
+ •
+ Last login:{" "}
+ {new Date(user.last_login).toLocaleDateString()}
+ >
+ )}
+
+
+
+
+ handleEditUser(user)}
+ className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
+ title="Edit user"
+ >
+
+
+ handleResetPassword(user)}
+ className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
+ title={
+ !user.is_active
+ ? "Cannot reset password for inactive user"
+ : "Reset password"
+ }
+ disabled={!user.is_active}
+ >
+
+
+ handleDeleteUser(user.id, user.username)}
+ className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
+ title={
+ user.id === currentUser?.id
+ ? "Cannot delete your own account"
+ : user.role === "admin" &&
+ users.filter((u) => u.role === "admin").length ===
+ 1
+ ? "Cannot delete the last admin user"
+ : "Delete user"
+ }
+ disabled={
+ user.id === currentUser?.id ||
+ (user.role === "admin" &&
+ users.filter((u) => u.role === "admin").length === 1)
+ }
+ >
+
+
+
+
+
+ ))
+ ) : (
+
+
+
+
+ No users found
+
+
+ Click "Add User" to create the first user
+
+
+
+ )}
+
+
- {/* Add User Modal */}
-
setShowAddModal(false)}
- onUserCreated={handleUserCreated}
- roles={roles}
- />
+ {/* Add User Modal */}
+ setShowAddModal(false)}
+ onUserCreated={handleUserCreated}
+ roles={roles}
+ />
- {/* Edit User Modal */}
- {editingUser && (
- setEditingUser(null)}
- onUserUpdated={() => updateUserMutation.mutate()}
- roles={roles}
- />
- )}
+ {/* Edit User Modal */}
+ {editingUser && (
+ setEditingUser(null)}
+ onUserUpdated={() => updateUserMutation.mutate()}
+ roles={roles}
+ />
+ )}
- {/* Reset Password Modal */}
- {resetPasswordUser && (
- setResetPasswordUser(null)}
- onPasswordReset={resetPasswordMutation.mutate}
- isLoading={resetPasswordMutation.isPending}
- />
- )}
-
- )
-}
+ {/* Reset Password Modal */}
+ {resetPasswordUser && (
+
setResetPasswordUser(null)}
+ onPasswordReset={resetPasswordMutation.mutate}
+ isLoading={resetPasswordMutation.isPending}
+ />
+ )}
+
+ );
+};
// Add User Modal Component
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
- const [formData, setFormData] = useState({
- username: '',
- email: '',
- password: '',
- first_name: '',
- last_name: '',
- role: 'user'
- })
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState('')
+ const [formData, setFormData] = useState({
+ username: "",
+ email: "",
+ password: "",
+ first_name: "",
+ last_name: "",
+ role: "user",
+ });
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
- const handleSubmit = async (e) => {
- e.preventDefault()
- setIsLoading(true)
- setError('')
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError("");
- try {
- // Only send role if roles are available from API
- const payload = { username: formData.username, email: formData.email, password: formData.password }
- if (roles && Array.isArray(roles) && roles.length > 0) {
- payload.role = formData.role
- }
- const response = await adminUsersAPI.create(payload)
- onUserCreated()
- } catch (err) {
- setError(err.response?.data?.error || 'Failed to create user')
- } finally {
- setIsLoading(false)
- }
- }
+ try {
+ // Only send role if roles are available from API
+ const payload = {
+ username: formData.username,
+ email: formData.email,
+ password: formData.password,
+ };
+ if (roles && Array.isArray(roles) && roles.length > 0) {
+ payload.role = formData.role;
+ }
+ const response = await adminUsersAPI.create(payload);
+ onUserCreated();
+ } catch (err) {
+ setError(err.response?.data?.error || "Failed to create user");
+ } finally {
+ setIsLoading(false);
+ }
+ };
- const handleInputChange = (e) => {
- setFormData({
- ...formData,
- [e.target.name]: e.target.value
- })
- }
+ const handleInputChange = (e) => {
+ setFormData({
+ ...formData,
+ [e.target.name]: e.target.value,
+ });
+ };
- if (!isOpen) return null
+ if (!isOpen) return null;
- return (
-
-
-
Add New User
-
-
-
-
- Username
-
-
-
+ return (
+
+
+
+ Add New User
+
-
-
- Email
-
-
-
+
+
+
+ Username
+
+
+
-
+
+
+ Email
+
+
+
-
-
- Password
-
-
-
Minimum 6 characters
-
+
-
-
- Role
-
-
- {roles && Array.isArray(roles) && roles.length > 0 ? (
- roles.map((role) => (
-
- {role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
-
- ))
- ) : (
- <>
- User
- Admin
- >
- )}
-
-
+
+
+ Password
+
+
+
+ Minimum 6 characters
+
+
- {error && (
-
- )}
+
+
+ Role
+
+
+ {roles && Array.isArray(roles) && roles.length > 0 ? (
+ roles.map((role) => (
+
+ {role.role.charAt(0).toUpperCase() +
+ role.role.slice(1).replace("_", " ")}
+
+ ))
+ ) : (
+ <>
+ User
+ Admin
+ >
+ )}
+
+
-
-
- Cancel
-
-
- {isLoading ? 'Creating...' : 'Create User'}
-
-
-
-
-
- )
-}
+ {error && (
+
+ )}
+
+
+
+ Cancel
+
+
+ {isLoading ? "Creating..." : "Create User"}
+
+
+
+
+
+ );
+};
// Edit User Modal Component
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
- const [formData, setFormData] = useState({
- username: user?.username || '',
- email: user?.email || '',
- first_name: user?.first_name || '',
- last_name: user?.last_name || '',
- role: user?.role || 'user',
- is_active: user?.is_active ?? true
- })
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState('')
+ const [formData, setFormData] = useState({
+ username: user?.username || "",
+ email: user?.email || "",
+ first_name: user?.first_name || "",
+ last_name: user?.last_name || "",
+ role: user?.role || "user",
+ is_active: user?.is_active ?? true,
+ });
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
- const handleSubmit = async (e) => {
- e.preventDefault()
- setIsLoading(true)
- setError('')
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError("");
- try {
- await adminUsersAPI.update(user.id, formData)
- onUserUpdated()
- } catch (err) {
- setError(err.response?.data?.error || 'Failed to update user')
- } finally {
- setIsLoading(false)
- }
- }
+ try {
+ await adminUsersAPI.update(user.id, formData);
+ onUserUpdated();
+ } catch (err) {
+ setError(err.response?.data?.error || "Failed to update user");
+ } finally {
+ setIsLoading(false);
+ }
+ };
- const handleInputChange = (e) => {
- const { name, value, type, checked } = e.target
- setFormData({
- ...formData,
- [name]: type === 'checkbox' ? checked : value
- })
- }
+ const handleInputChange = (e) => {
+ const { name, value, type, checked } = e.target;
+ setFormData({
+ ...formData,
+ [name]: type === "checkbox" ? checked : value,
+ });
+ };
- if (!isOpen || !user) return null
+ if (!isOpen || !user) return null;
- return (
-
-
-
Edit User
-
-
-
-
- Username
-
-
-
+ return (
+
- )
-}
+ {error && (
+
+ )}
+
+
+
+ Cancel
+
+
+ {isLoading ? "Updating..." : "Update User"}
+
+
+
+
+
+ );
+};
// Reset Password Modal Component
-const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading }) => {
- const [newPassword, setNewPassword] = useState('')
- const [confirmPassword, setConfirmPassword] = useState('')
- const [error, setError] = useState('')
+const ResetPasswordModal = ({
+ user,
+ isOpen,
+ onClose,
+ onPasswordReset,
+ isLoading,
+}) => {
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [error, setError] = useState("");
- const handleSubmit = async (e) => {
- e.preventDefault()
- setError('')
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setError("");
- // Validate passwords
- if (newPassword.length < 6) {
- setError('Password must be at least 6 characters long')
- return
- }
+ // Validate passwords
+ if (newPassword.length < 6) {
+ setError("Password must be at least 6 characters long");
+ return;
+ }
- if (newPassword !== confirmPassword) {
- setError('Passwords do not match')
- return
- }
+ if (newPassword !== confirmPassword) {
+ setError("Passwords do not match");
+ return;
+ }
- try {
- await onPasswordReset({ userId: user.id, newPassword })
- // Reset form on success
- setNewPassword('')
- setConfirmPassword('')
- } catch (err) {
- setError(err.response?.data?.error || 'Failed to reset password')
- }
- }
+ try {
+ await onPasswordReset({ userId: user.id, newPassword });
+ // Reset form on success
+ setNewPassword("");
+ setConfirmPassword("");
+ } catch (err) {
+ setError(err.response?.data?.error || "Failed to reset password");
+ }
+ };
- const handleClose = () => {
- setNewPassword('')
- setConfirmPassword('')
- setError('')
- onClose()
- }
+ const handleClose = () => {
+ setNewPassword("");
+ setConfirmPassword("");
+ setError("");
+ onClose();
+ };
- if (!isOpen) return null
+ if (!isOpen) return null;
- return (
-
-
-
- Reset Password for {user.username}
-
-
-
-
-
- New Password
-
- setNewPassword(e.target.value)}
- className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
- placeholder="Enter new password (min 6 characters)"
- />
-
+ return (
+
+
+
+ Reset Password for {user.username}
+
-
-
- Confirm Password
-
- setConfirmPassword(e.target.value)}
- className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
- placeholder="Confirm new password"
- />
-
+
+
+
+ New Password
+
+ setNewPassword(e.target.value)}
+ className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
+ placeholder="Enter new password (min 6 characters)"
+ />
+
-
-
-
-
-
-
-
- Password Reset Warning
-
-
-
This will immediately change the user's password. The user will need to use the new password to login.
-
-
-
-
+
+
+ Confirm Password
+
+ setConfirmPassword(e.target.value)}
+ className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
+ placeholder="Confirm new password"
+ />
+
- {error && (
-
- )}
+
+
+
+
+
+
+
+ Password Reset Warning
+
+
+
+ This will immediately change the user's password. The user
+ will need to use the new password to login.
+
+
+
+
+
-
-
- Cancel
-
-
- {isLoading &&
}
- {isLoading ? 'Resetting...' : 'Reset Password'}
-
-
-
-
-
- )
-}
+ {error && (
+
+ )}
-export default Users
+
+
+ Cancel
+
+
+ {isLoading && (
+
+ )}
+ {isLoading ? "Resetting..." : "Reset Password"}
+
+
+
+
+
+ );
+};
+
+export default Users;
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 33130f1..afccb1c 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -1,231 +1,266 @@
-import axios from 'axios'
+import axios from "axios";
-const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
+const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
// Create axios instance with default config
const api = axios.create({
- baseURL: API_BASE_URL,
- timeout: 10000,
- headers: {
- 'Content-Type': 'application/json',
- },
-})
+ baseURL: API_BASE_URL,
+ timeout: 10000,
+ headers: {
+ "Content-Type": "application/json",
+ },
+});
// Request interceptor
api.interceptors.request.use(
- (config) => {
- // Add auth token if available
- const token = localStorage.getItem('token')
- if (token) {
- config.headers.Authorization = `Bearer ${token}`
- }
- return config
- },
- (error) => {
- return Promise.reject(error)
- }
-)
+ (config) => {
+ // Add auth token if available
+ const token = localStorage.getItem("token");
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+ },
+ (error) => {
+ return Promise.reject(error);
+ },
+);
// Response interceptor
api.interceptors.response.use(
- (response) => response,
- (error) => {
- if (error.response?.status === 401) {
- // Don't redirect if we're on the login page or if it's a TFA verification error
- const currentPath = window.location.pathname
- const isTfaError = error.config?.url?.includes('/verify-tfa')
-
- if (currentPath !== '/login' && !isTfaError) {
- // Handle unauthorized
- localStorage.removeItem('token')
- localStorage.removeItem('user')
- localStorage.removeItem('permissions')
- window.location.href = '/login'
- }
- }
- return Promise.reject(error)
- }
-)
+ (response) => response,
+ (error) => {
+ if (error.response?.status === 401) {
+ // Don't redirect if we're on the login page or if it's a TFA verification error
+ const currentPath = window.location.pathname;
+ const isTfaError = error.config?.url?.includes("/verify-tfa");
+
+ if (currentPath !== "/login" && !isTfaError) {
+ // Handle unauthorized
+ localStorage.removeItem("token");
+ localStorage.removeItem("user");
+ localStorage.removeItem("permissions");
+ window.location.href = "/login";
+ }
+ }
+ return Promise.reject(error);
+ },
+);
// Dashboard API
export const dashboardAPI = {
- getStats: () => api.get('/dashboard/stats'),
- getHosts: () => api.get('/dashboard/hosts'),
- getPackages: () => api.get('/dashboard/packages'),
- getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
- getRecentUsers: () => api.get('/dashboard/recent-users'),
- getRecentCollection: () => api.get('/dashboard/recent-collection')
-}
+ getStats: () => api.get("/dashboard/stats"),
+ getHosts: () => api.get("/dashboard/hosts"),
+ getPackages: () => api.get("/dashboard/packages"),
+ getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
+ getRecentUsers: () => api.get("/dashboard/recent-users"),
+ getRecentCollection: () => api.get("/dashboard/recent-collection"),
+};
// Admin Hosts API (for management interface)
export const adminHostsAPI = {
- create: (data) => api.post('/hosts/create', data),
- list: () => api.get('/hosts/admin/list'),
- delete: (hostId) => api.delete(`/hosts/${hostId}`),
- deleteBulk: (hostIds) => api.delete('/hosts/bulk', { data: { hostIds } }),
- regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`),
- updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }),
- bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }),
- toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
- updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendly_name: friendlyName })
-}
+ create: (data) => api.post("/hosts/create", data),
+ list: () => api.get("/hosts/admin/list"),
+ delete: (hostId) => api.delete(`/hosts/${hostId}`),
+ deleteBulk: (hostIds) => api.delete("/hosts/bulk", { data: { hostIds } }),
+ regenerateCredentials: (hostId) =>
+ api.post(`/hosts/${hostId}/regenerate-credentials`),
+ updateGroup: (hostId, hostGroupId) =>
+ api.put(`/hosts/${hostId}/group`, { hostGroupId }),
+ bulkUpdateGroup: (hostIds, hostGroupId) =>
+ api.put("/hosts/bulk/group", { hostIds, hostGroupId }),
+ toggleAutoUpdate: (hostId, autoUpdate) =>
+ api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
+ updateFriendlyName: (hostId, friendlyName) =>
+ api.patch(`/hosts/${hostId}/friendly-name`, {
+ friendly_name: friendlyName,
+ }),
+};
// Host Groups API
export const hostGroupsAPI = {
- list: () => api.get('/host-groups'),
- get: (id) => api.get(`/host-groups/${id}`),
- create: (data) => api.post('/host-groups', data),
- update: (id, data) => api.put(`/host-groups/${id}`, data),
- delete: (id) => api.delete(`/host-groups/${id}`),
- getHosts: (id) => api.get(`/host-groups/${id}/hosts`),
-}
+ list: () => api.get("/host-groups"),
+ get: (id) => api.get(`/host-groups/${id}`),
+ create: (data) => api.post("/host-groups", data),
+ update: (id, data) => api.put(`/host-groups/${id}`, data),
+ delete: (id) => api.delete(`/host-groups/${id}`),
+ getHosts: (id) => api.get(`/host-groups/${id}/hosts`),
+};
// Admin Users API (for user management)
export const adminUsersAPI = {
- list: () => api.get('/auth/admin/users'),
- create: (userData) => api.post('/auth/admin/users', userData),
- update: (userId, userData) => api.put(`/auth/admin/users/${userId}`, userData),
- delete: (userId) => api.delete(`/auth/admin/users/${userId}`),
- resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword })
-}
+ list: () => api.get("/auth/admin/users"),
+ create: (userData) => api.post("/auth/admin/users", userData),
+ update: (userId, userData) =>
+ api.put(`/auth/admin/users/${userId}`, userData),
+ delete: (userId) => api.delete(`/auth/admin/users/${userId}`),
+ resetPassword: (userId, newPassword) =>
+ api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword }),
+};
// Permissions API (for role management)
export const permissionsAPI = {
- getRoles: () => api.get('/permissions/roles'),
- getRole: (role) => api.get(`/permissions/roles/${role}`),
- updateRole: (role, permissions) => api.put(`/permissions/roles/${role}`, permissions),
- deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
- getUserPermissions: () => api.get('/permissions/user-permissions')
-}
+ getRoles: () => api.get("/permissions/roles"),
+ getRole: (role) => api.get(`/permissions/roles/${role}`),
+ updateRole: (role, permissions) =>
+ api.put(`/permissions/roles/${role}`, permissions),
+ deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
+ getUserPermissions: () => api.get("/permissions/user-permissions"),
+};
// Settings API
export const settingsAPI = {
- get: () => api.get('/settings'),
- update: (settings) => api.put('/settings', settings),
- getServerUrl: () => api.get('/settings/server-url')
-}
+ get: () => api.get("/settings"),
+ update: (settings) => api.put("/settings", settings),
+ getServerUrl: () => api.get("/settings/server-url"),
+};
// Agent Version API
export const agentVersionAPI = {
- list: () => api.get('/hosts/agent/versions'),
- create: (data) => api.post('/hosts/agent/versions', data),
- update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data),
- delete: (id) => api.delete(`/hosts/agent/versions/${id}`),
- setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`),
- setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`),
- download: (version) => api.get(`/hosts/agent/download${version ? `?version=${version}` : ''}`, { responseType: 'blob' })
-}
+ list: () => api.get("/hosts/agent/versions"),
+ create: (data) => api.post("/hosts/agent/versions", data),
+ update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data),
+ delete: (id) => api.delete(`/hosts/agent/versions/${id}`),
+ setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`),
+ setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`),
+ download: (version) =>
+ api.get(`/hosts/agent/download${version ? `?version=${version}` : ""}`, {
+ responseType: "blob",
+ }),
+};
// Repository API
export const repositoryAPI = {
- list: () => api.get('/repositories'),
- getById: (repositoryId) => api.get(`/repositories/${repositoryId}`),
- getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
- update: (repositoryId, data) => api.put(`/repositories/${repositoryId}`, data),
- toggleHostRepository: (hostId, repositoryId, isEnabled) =>
- api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled }),
- getStats: () => api.get('/repositories/stats/summary'),
- cleanupOrphaned: () => api.delete('/repositories/cleanup/orphaned')
-}
+ list: () => api.get("/repositories"),
+ getById: (repositoryId) => api.get(`/repositories/${repositoryId}`),
+ getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
+ update: (repositoryId, data) =>
+ api.put(`/repositories/${repositoryId}`, data),
+ toggleHostRepository: (hostId, repositoryId, isEnabled) =>
+ api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, {
+ isEnabled,
+ }),
+ getStats: () => api.get("/repositories/stats/summary"),
+ cleanupOrphaned: () => api.delete("/repositories/cleanup/orphaned"),
+};
// Dashboard Preferences API
export const dashboardPreferencesAPI = {
- get: () => api.get('/dashboard-preferences'),
- update: (preferences) => api.put('/dashboard-preferences', { preferences }),
- getDefaults: () => api.get('/dashboard-preferences/defaults')
-}
+ get: () => api.get("/dashboard-preferences"),
+ update: (preferences) => api.put("/dashboard-preferences", { preferences }),
+ getDefaults: () => api.get("/dashboard-preferences/defaults"),
+};
// Hosts API (for agent communication - kept for compatibility)
export const hostsAPI = {
- // Legacy register endpoint (now deprecated)
- register: (data) => api.post('/hosts/register', data),
-
- // Updated to use API credentials
- update: (apiId, apiKey, data) => api.post('/hosts/update', data, {
- headers: {
- 'X-API-ID': apiId,
- 'X-API-KEY': apiKey
- }
- }),
- getInfo: (apiId, apiKey) => api.get('/hosts/info', {
- headers: {
- 'X-API-ID': apiId,
- 'X-API-KEY': apiKey
- }
- }),
- ping: (apiId, apiKey) => api.post('/hosts/ping', {}, {
- headers: {
- 'X-API-ID': apiId,
- 'X-API-KEY': apiKey
- }
- }),
- toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate })
-}
+ // Legacy register endpoint (now deprecated)
+ register: (data) => api.post("/hosts/register", data),
+
+ // Updated to use API credentials
+ update: (apiId, apiKey, data) =>
+ api.post("/hosts/update", data, {
+ headers: {
+ "X-API-ID": apiId,
+ "X-API-KEY": apiKey,
+ },
+ }),
+ getInfo: (apiId, apiKey) =>
+ api.get("/hosts/info", {
+ headers: {
+ "X-API-ID": apiId,
+ "X-API-KEY": apiKey,
+ },
+ }),
+ ping: (apiId, apiKey) =>
+ api.post(
+ "/hosts/ping",
+ {},
+ {
+ headers: {
+ "X-API-ID": apiId,
+ "X-API-KEY": apiKey,
+ },
+ },
+ ),
+ toggleAutoUpdate: (id, autoUpdate) =>
+ api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate }),
+};
// Packages API
export const packagesAPI = {
- getAll: (params = {}) => api.get('/packages', { params }),
- getById: (packageId) => api.get(`/packages/${packageId}`),
- getCategories: () => api.get('/packages/categories/list'),
- getHosts: (packageId, params = {}) => api.get(`/packages/${packageId}/hosts`, { params }),
- update: (packageId, data) => api.put(`/packages/${packageId}`, data),
- search: (query, params = {}) => api.get(`/packages/search/${query}`, { params }),
-}
+ getAll: (params = {}) => api.get("/packages", { params }),
+ getById: (packageId) => api.get(`/packages/${packageId}`),
+ getCategories: () => api.get("/packages/categories/list"),
+ getHosts: (packageId, params = {}) =>
+ api.get(`/packages/${packageId}/hosts`, { params }),
+ update: (packageId, data) => api.put(`/packages/${packageId}`, data),
+ search: (query, params = {}) =>
+ api.get(`/packages/search/${query}`, { params }),
+};
// Utility functions
export const formatError = (error) => {
- if (error.response?.data?.message) {
- return error.response.data.message
- }
- if (error.response?.data?.error) {
- return error.response.data.error
- }
- if (error.message) {
- return error.message
- }
- return 'An unexpected error occurred'
-}
+ if (error.response?.data?.message) {
+ return error.response.data.message;
+ }
+ if (error.response?.data?.error) {
+ return error.response.data.error;
+ }
+ if (error.message) {
+ return error.message;
+ }
+ return "An unexpected error occurred";
+};
export const formatDate = (date) => {
- return new Date(date).toLocaleString()
-}
+ return new Date(date).toLocaleString();
+};
// Version API
export const versionAPI = {
- getCurrent: () => api.get('/version/current'),
- checkUpdates: () => api.get('/version/check-updates'),
- testSshKey: (data) => api.post('/version/test-ssh-key', data),
-}
+ getCurrent: () => api.get("/version/current"),
+ checkUpdates: () => api.get("/version/check-updates"),
+ testSshKey: (data) => api.post("/version/test-ssh-key", data),
+};
// Auth API
export const authAPI = {
- login: (username, password) => api.post('/auth/login', { username, password }),
- verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }),
- signup: (username, email, password, firstName, lastName) => api.post('/auth/signup', { username, email, password, firstName, lastName }),
-}
+ login: (username, password) =>
+ api.post("/auth/login", { username, password }),
+ verifyTfa: (username, token) =>
+ api.post("/auth/verify-tfa", { username, token }),
+ signup: (username, email, password, firstName, lastName) =>
+ api.post("/auth/signup", {
+ username,
+ email,
+ password,
+ firstName,
+ lastName,
+ }),
+};
// TFA API
export const tfaAPI = {
- setup: () => api.get('/tfa/setup'),
- verifySetup: (data) => api.post('/tfa/verify-setup', data),
- disable: (data) => api.post('/tfa/disable', data),
- status: () => api.get('/tfa/status'),
- regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'),
- verify: (data) => api.post('/tfa/verify', data),
-}
+ setup: () => api.get("/tfa/setup"),
+ verifySetup: (data) => api.post("/tfa/verify-setup", data),
+ disable: (data) => api.post("/tfa/disable", data),
+ status: () => api.get("/tfa/status"),
+ regenerateBackupCodes: () => api.post("/tfa/regenerate-backup-codes"),
+ verify: (data) => api.post("/tfa/verify", data),
+};
export const formatRelativeTime = (date) => {
- const now = new Date()
- const diff = now - new Date(date)
- const seconds = Math.floor(diff / 1000)
- const minutes = Math.floor(seconds / 60)
- const hours = Math.floor(minutes / 60)
- const days = Math.floor(hours / 24)
+ const now = new Date();
+ const diff = now - new Date(date);
+ const seconds = Math.floor(diff / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
- if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
- if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
- if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
- return `${seconds} second${seconds > 1 ? 's' : ''} ago`
-}
+ if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
+ if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
+ if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
+ return `${seconds} second${seconds > 1 ? "s" : ""} ago`;
+};
-export default api
\ No newline at end of file
+export default api;
diff --git a/frontend/src/utils/osIcons.jsx b/frontend/src/utils/osIcons.jsx
index 2f1c0ba..10f541f 100644
--- a/frontend/src/utils/osIcons.jsx
+++ b/frontend/src/utils/osIcons.jsx
@@ -1,65 +1,59 @@
-import {
- Monitor,
- Server,
- HardDrive,
- Cpu,
- Zap,
- Shield,
- Globe,
- Terminal
-} from 'lucide-react';
-
+import {
+ Cpu,
+ Globe,
+ HardDrive,
+ Monitor,
+ Server,
+ Shield,
+ Terminal,
+ Zap,
+} from "lucide-react";
+import { DiDebian, DiLinux, DiUbuntu, DiWindows } from "react-icons/di";
// Import OS icons from react-icons
-import {
- SiUbuntu,
- SiDebian,
- SiCentos,
- SiFedora,
- SiArchlinux,
- SiAlpinelinux,
- SiLinux,
- SiMacos
-} from 'react-icons/si';
-
-import {
- DiUbuntu,
- DiDebian,
- DiLinux,
- DiWindows
-} from 'react-icons/di';
+import {
+ SiAlpinelinux,
+ SiArchlinux,
+ SiCentos,
+ SiDebian,
+ SiFedora,
+ SiLinux,
+ SiMacos,
+ SiUbuntu,
+} from "react-icons/si";
/**
* OS Icon mapping utility
* Maps operating system types to appropriate react-icons components
*/
export const getOSIcon = (osType) => {
- if (!osType) return Monitor;
-
- const os = osType.toLowerCase();
-
- // Linux distributions with authentic react-icons
- if (os.includes('ubuntu')) return SiUbuntu;
- if (os.includes('debian')) return SiDebian;
- if (os.includes('centos') || os.includes('rhel') || os.includes('red hat')) return SiCentos;
- if (os.includes('fedora')) return SiFedora;
- if (os.includes('arch')) return SiArchlinux;
- if (os.includes('alpine')) return SiAlpinelinux;
- if (os.includes('suse') || os.includes('opensuse')) return SiLinux; // SUSE uses generic Linux icon
-
- // Generic Linux
- if (os.includes('linux')) return SiLinux;
-
- // Windows
- if (os.includes('windows')) return DiWindows;
-
- // macOS
- if (os.includes('mac') || os.includes('darwin')) return SiMacos;
-
- // FreeBSD
- if (os.includes('freebsd')) return Server;
-
- // Default fallback
- return Monitor;
+ if (!osType) return Monitor;
+
+ const os = osType.toLowerCase();
+
+ // Linux distributions with authentic react-icons
+ if (os.includes("ubuntu")) return SiUbuntu;
+ if (os.includes("debian")) return SiDebian;
+ if (os.includes("centos") || os.includes("rhel") || os.includes("red hat"))
+ return SiCentos;
+ if (os.includes("fedora")) return SiFedora;
+ if (os.includes("arch")) return SiArchlinux;
+ if (os.includes("alpine")) return SiAlpinelinux;
+ if (os.includes("suse") || os.includes("opensuse")) return SiLinux; // SUSE uses generic Linux icon
+
+ // Generic Linux
+ if (os.includes("linux")) return SiLinux;
+
+ // Windows
+ if (os.includes("windows")) return DiWindows;
+
+ // macOS
+ if (os.includes("mac") || os.includes("darwin")) return SiMacos;
+
+ // FreeBSD
+ if (os.includes("freebsd")) return Server;
+
+ // Default fallback
+ return Monitor;
};
/**
@@ -67,11 +61,11 @@ export const getOSIcon = (osType) => {
* Maps operating system types to appropriate colors (react-icons have built-in brand colors)
*/
export const getOSColor = (osType) => {
- if (!osType) return 'text-gray-500';
-
- // react-icons already have the proper brand colors built-in
- // This function is kept for compatibility but returns neutral colors
- return 'text-gray-600';
+ if (!osType) return "text-gray-500";
+
+ // react-icons already have the proper brand colors built-in
+ // This function is kept for compatibility but returns neutral colors
+ return "text-gray-600";
};
/**
@@ -79,52 +73,53 @@ export const getOSColor = (osType) => {
* Provides clean, formatted OS names for display
*/
export const getOSDisplayName = (osType) => {
- if (!osType) return 'Unknown';
-
- const os = osType.toLowerCase();
-
- // Linux distributions
- if (os.includes('ubuntu')) return 'Ubuntu';
- if (os.includes('debian')) return 'Debian';
- if (os.includes('centos')) return 'CentOS';
- if (os.includes('rhel') || os.includes('red hat')) return 'Red Hat Enterprise Linux';
- if (os.includes('fedora')) return 'Fedora';
- if (os.includes('arch')) return 'Arch Linux';
- if (os.includes('suse')) return 'SUSE Linux';
- if (os.includes('opensuse')) return 'openSUSE';
- if (os.includes('alpine')) return 'Alpine Linux';
-
- // Generic Linux
- if (os.includes('linux')) return 'Linux';
-
- // Windows
- if (os.includes('windows')) return 'Windows';
-
- // macOS
- if (os.includes('mac') || os.includes('darwin')) return 'macOS';
-
- // FreeBSD
- if (os.includes('freebsd')) return 'FreeBSD';
-
- // Return original if no match
- return osType;
+ if (!osType) return "Unknown";
+
+ const os = osType.toLowerCase();
+
+ // Linux distributions
+ if (os.includes("ubuntu")) return "Ubuntu";
+ if (os.includes("debian")) return "Debian";
+ if (os.includes("centos")) return "CentOS";
+ if (os.includes("rhel") || os.includes("red hat"))
+ return "Red Hat Enterprise Linux";
+ if (os.includes("fedora")) return "Fedora";
+ if (os.includes("arch")) return "Arch Linux";
+ if (os.includes("suse")) return "SUSE Linux";
+ if (os.includes("opensuse")) return "openSUSE";
+ if (os.includes("alpine")) return "Alpine Linux";
+
+ // Generic Linux
+ if (os.includes("linux")) return "Linux";
+
+ // Windows
+ if (os.includes("windows")) return "Windows";
+
+ // macOS
+ if (os.includes("mac") || os.includes("darwin")) return "macOS";
+
+ // FreeBSD
+ if (os.includes("freebsd")) return "FreeBSD";
+
+ // Return original if no match
+ return osType;
};
/**
* OS Icon component with proper styling
*/
export const OSIcon = ({ osType, className = "h-4 w-4", showText = false }) => {
- const IconComponent = getOSIcon(osType);
- const displayName = getOSDisplayName(osType);
-
- if (showText) {
- return (
-
-
- {displayName}
-
- );
- }
-
- return ;
+ const IconComponent = getOSIcon(osType);
+ const displayName = getOSDisplayName(osType);
+
+ if (showText) {
+ return (
+
+
+ {displayName}
+
+ );
+ }
+
+ return ;
};
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index 327ad4a..6c5cceb 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -1,85 +1,85 @@
/** @type {import('tailwindcss').Config} */
export default {
- content: [
- "./index.html",
- "./src/**/*.{js,ts,jsx,tsx}",
- ],
- darkMode: 'class',
- theme: {
- extend: {
- colors: {
- primary: {
- 50: '#eff6ff',
- 100: '#dbeafe',
- 200: '#bfdbfe',
- 300: '#93c5fd',
- 400: '#60a5fa',
- 500: '#3b82f6',
- 600: '#2563eb',
- 700: '#1d4ed8',
- 800: '#1e40af',
- 900: '#1e3a8a',
- },
- secondary: {
- 50: '#f8fafc',
- 100: '#f1f5f9',
- 200: '#e2e8f0',
- 300: '#cbd5e1',
- 400: '#94a3b8',
- 500: '#64748b',
- 600: '#475569',
- 700: '#334155',
- 800: '#1e293b',
- 900: '#0f172a',
- },
- success: {
- 50: '#f0fdf4',
- 100: '#dcfce7',
- 200: '#bbf7d0',
- 300: '#86efac',
- 400: '#4ade80',
- 500: '#22c55e',
- 600: '#16a34a',
- 700: '#15803d',
- 800: '#166534',
- 900: '#14532d',
- },
- warning: {
- 50: '#fffbeb',
- 100: '#fef3c7',
- 200: '#fde68a',
- 300: '#fcd34d',
- 400: '#fbbf24',
- 500: '#f59e0b',
- 600: '#d97706',
- 700: '#b45309',
- 800: '#92400e',
- 900: '#78350f',
- },
- danger: {
- 50: '#fef2f2',
- 100: '#fee2e2',
- 200: '#fecaca',
- 300: '#fca5a5',
- 400: '#f87171',
- 500: '#ef4444',
- 600: '#dc2626',
- 700: '#b91c1c',
- 800: '#991b1b',
- 900: '#7f1d1d',
- },
- },
- fontFamily: {
- sans: ['Inter', 'ui-sans-serif', 'system-ui'],
- mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
- },
- boxShadow: {
- 'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
- 'card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
- 'card-dark': '0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06)',
- 'card-hover-dark': '0 4px 6px -1px rgba(255, 255, 255, 0.15), 0 2px 4px -1px rgba(255, 255, 255, 0.1)',
- },
- },
- },
- plugins: [],
-}
\ No newline at end of file
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
+ darkMode: "class",
+ theme: {
+ extend: {
+ colors: {
+ primary: {
+ 50: "#eff6ff",
+ 100: "#dbeafe",
+ 200: "#bfdbfe",
+ 300: "#93c5fd",
+ 400: "#60a5fa",
+ 500: "#3b82f6",
+ 600: "#2563eb",
+ 700: "#1d4ed8",
+ 800: "#1e40af",
+ 900: "#1e3a8a",
+ },
+ secondary: {
+ 50: "#f8fafc",
+ 100: "#f1f5f9",
+ 200: "#e2e8f0",
+ 300: "#cbd5e1",
+ 400: "#94a3b8",
+ 500: "#64748b",
+ 600: "#475569",
+ 700: "#334155",
+ 800: "#1e293b",
+ 900: "#0f172a",
+ },
+ success: {
+ 50: "#f0fdf4",
+ 100: "#dcfce7",
+ 200: "#bbf7d0",
+ 300: "#86efac",
+ 400: "#4ade80",
+ 500: "#22c55e",
+ 600: "#16a34a",
+ 700: "#15803d",
+ 800: "#166534",
+ 900: "#14532d",
+ },
+ warning: {
+ 50: "#fffbeb",
+ 100: "#fef3c7",
+ 200: "#fde68a",
+ 300: "#fcd34d",
+ 400: "#fbbf24",
+ 500: "#f59e0b",
+ 600: "#d97706",
+ 700: "#b45309",
+ 800: "#92400e",
+ 900: "#78350f",
+ },
+ danger: {
+ 50: "#fef2f2",
+ 100: "#fee2e2",
+ 200: "#fecaca",
+ 300: "#fca5a5",
+ 400: "#f87171",
+ 500: "#ef4444",
+ 600: "#dc2626",
+ 700: "#b91c1c",
+ 800: "#991b1b",
+ 900: "#7f1d1d",
+ },
+ },
+ fontFamily: {
+ sans: ["Inter", "ui-sans-serif", "system-ui"],
+ mono: ["JetBrains Mono", "ui-monospace", "monospace"],
+ },
+ boxShadow: {
+ card: "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)",
+ "card-hover":
+ "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
+ "card-dark":
+ "0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06)",
+ "card-hover-dark":
+ "0 4px 6px -1px rgba(255, 255, 255, 0.15), 0 2px 4px -1px rgba(255, 255, 255, 0.1)",
+ },
+ },
+ },
+ plugins: [],
+};
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 760b4fa..bc72ba0 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,35 +1,47 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()],
- server: {
- port: 3000,
- strictPort: true, // Exit if port is already in use
- allowedHosts: ['localhost'],
- proxy: {
- '/api': {
- target: 'http://localhost:3001',
- changeOrigin: true,
- secure: false,
- configure: process.env.VITE_ENABLE_LOGGING === 'true' ? (proxy, options) => {
- proxy.on('error', (err, req, res) => {
- console.log('proxy error', err);
- });
- proxy.on('proxyReq', (proxyReq, req, res) => {
- console.log('Sending Request to the Target:', req.method, req.url);
- });
- proxy.on('proxyRes', (proxyRes, req, res) => {
- console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
- });
- } : undefined,
- },
- },
- },
- build: {
- outDir: 'dist',
- sourcemap: process.env.NODE_ENV !== 'production',
- target: 'es2018',
- },
-})
+ plugins: [react()],
+ server: {
+ port: 3000,
+ host: "0.0.0.0", // Listen on all interfaces
+ strictPort: true, // Exit if port is already in use
+ allowedHosts: true, // Allow all hosts in development
+ proxy: {
+ "/api": {
+ target: `http://${process.env.BACKEND_HOST}:${process.env.BACKEND_PORT}`,
+ changeOrigin: true,
+ secure: false,
+ configure:
+ process.env.VITE_ENABLE_LOGGING === "true"
+ ? (proxy, options) => {
+ proxy.on("error", (err, req, res) => {
+ console.log("proxy error", err);
+ });
+ proxy.on("proxyReq", (proxyReq, req, res) => {
+ console.log(
+ "Sending Request to the Target:",
+ req.method,
+ req.url,
+ );
+ });
+ proxy.on("proxyRes", (proxyRes, req, res) => {
+ console.log(
+ "Received Response from the Target:",
+ proxyRes.statusCode,
+ req.url,
+ );
+ });
+ }
+ : undefined,
+ },
+ },
+ },
+ build: {
+ outDir: "dist",
+ sourcemap: process.env.NODE_ENV !== "production",
+ target: "es2018",
+ },
+});