From b43b20fbe90cb59aa4a580a05b84006fcb4ee263 Mon Sep 17 00:00:00 2001 From: tigattack <10629864+tigattack@users.noreply.github.com> Date: Wed, 24 Sep 2025 22:06:42 +0100 Subject: [PATCH] style(frontend): fmt --- frontend/package.json | 94 +- frontend/postcss.config.js | 10 +- frontend/server.js | 59 +- frontend/src/App.jsx | 312 +- .../src/components/DashboardSettingsModal.jsx | 639 +-- .../src/components/FirstTimeAdminSetup.jsx | 578 +-- frontend/src/components/InlineEdit.jsx | 280 +- frontend/src/components/InlineGroupEdit.jsx | 483 +- frontend/src/components/Layout.jsx | 1499 +++--- frontend/src/components/ProtectedRoute.jsx | 92 +- .../components/UpgradeNotificationIcon.jsx | 22 +- frontend/src/contexts/AuthContext.jsx | 539 +-- frontend/src/contexts/ThemeContext.jsx | 88 +- .../contexts/UpdateNotificationContext.jsx | 100 +- frontend/src/index.css | 232 +- frontend/src/main.jsx | 46 +- frontend/src/pages/Dashboard.jsx | 1906 ++++---- frontend/src/pages/HostDetail.jsx | 2489 +++++----- frontend/src/pages/HostGroups.jsx | 929 ++-- frontend/src/pages/Hosts.jsx | 4006 +++++++++-------- frontend/src/pages/Login.jsx | 875 ++-- frontend/src/pages/Options.jsx | 1071 ++--- frontend/src/pages/PackageDetail.jsx | 46 +- frontend/src/pages/Packages.jsx | 1262 +++--- frontend/src/pages/Permissions.jsx | 807 ++-- frontend/src/pages/Profile.jsx | 1836 ++++---- frontend/src/pages/Repositories.jsx | 1096 ++--- frontend/src/pages/RepositoryDetail.jsx | 769 ++-- frontend/src/pages/Settings.jsx | 2685 ++++++----- frontend/src/pages/Users.jsx | 1367 +++--- frontend/src/utils/api.js | 375 +- frontend/src/utils/osIcons.jsx | 199 +- frontend/tailwind.config.js | 166 +- frontend/vite.config.js | 76 +- 34 files changed, 14405 insertions(+), 12628 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 75dca38..f81806d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,49 +1,49 @@ { - "name": "patchmon-frontend", - "private": true, - "version": "1.2.6", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "lint": "eslint . --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" - }, - "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", - "@tanstack/react-query": "^5.87.4", - "axios": "^1.7.9", - "chart.js": "^4.4.7", - "clsx": "^2.1.1", - "cors": "^2.8.5", - "date-fns": "^4.1.0", - "express": "^4.21.2", - "http-proxy-middleware": "^3.0.3", - "lucide-react": "^0.468.0", - "react": "^18.3.1", - "react-chartjs-2": "^5.2.0", - "react-dom": "^18.3.1", - "react-icons": "^5.5.0", - "react-router-dom": "^6.30.1" - }, - "devDependencies": { - "@eslint/js": "^9.17.0", - "@types/react": "^18.3.14", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.20", - "eslint": "^9.17.0", - "eslint-plugin-react": "^7.37.2", - "eslint-plugin-react-hooks": "^5.1.0", - "eslint-plugin-react-refresh": "^0.4.16", - "globals": "^15.14.0", - "postcss": "^8.5.6", - "tailwindcss": "^3.4.17", - "vite": "^7.1.5" - }, - "overrides": { - "esbuild": "^0.25.10" - } + "name": "patchmon-frontend", + "private": true, + "version": "1.2.6", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.87.4", + "axios": "^1.7.9", + "chart.js": "^4.4.7", + "clsx": "^2.1.1", + "cors": "^2.8.5", + "date-fns": "^4.1.0", + "express": "^4.21.2", + "http-proxy-middleware": "^3.0.3", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", + "react-dom": "^18.3.1", + "react-icons": "^5.5.0", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^18.3.14", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "vite": "^7.1.5" + }, + "overrides": { + "esbuild": "^0.25.10" + } } diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 387612e..7b75c83 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,6 @@ export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} \ No newline at end of file + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/server.js b/frontend/server.js index be0a539..30407cd 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -1,45 +1,50 @@ -import express from 'express'; -import path from 'path'; -import cors from 'cors'; -import { fileURLToPath } from 'url'; -import { createProxyMiddleware } from 'http-proxy-middleware'; +import cors from "cors"; +import express from "express"; +import { createProxyMiddleware } from "http-proxy-middleware"; +import path from "path"; +import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3000; -const BACKEND_URL = process.env.BACKEND_URL || 'http://backend:3001'; +const BACKEND_URL = process.env.BACKEND_URL || "http://backend:3001"; // Enable CORS for API calls -app.use(cors({ - origin: process.env.CORS_ORIGIN || '*', - credentials: true -})); +app.use( + cors({ + origin: process.env.CORS_ORIGIN || "*", + credentials: true, + }), +); // Proxy API requests to backend -app.use('/api', createProxyMiddleware({ - target: BACKEND_URL, - changeOrigin: true, - logLevel: 'info', - onError: (err, req, res) => { - console.error('Proxy error:', err.message); - res.status(500).json({ error: 'Backend service unavailable' }); - }, - onProxyReq: (proxyReq, req, res) => { - console.log(`Proxying ${req.method} ${req.path} to ${BACKEND_URL}`); - } -})); +app.use( + "/api", + createProxyMiddleware({ + target: BACKEND_URL, + changeOrigin: true, + logLevel: "info", + onError: (err, req, res) => { + console.error("Proxy error:", err.message); + res.status(500).json({ error: "Backend service unavailable" }); + }, + onProxyReq: (proxyReq, req, res) => { + console.log(`Proxying ${req.method} ${req.path} to ${BACKEND_URL}`); + }, + }), +); // Serve static files from dist directory -app.use(express.static(path.join(__dirname, 'dist'))); +app.use(express.static(path.join(__dirname, "dist"))); // Handle SPA routing - serve index.html for all routes -app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, 'dist', 'index.html')); +app.get("*", (req, res) => { + res.sendFile(path.join(__dirname, "dist", "index.html")); }); app.listen(PORT, () => { - console.log(`Frontend server running on port ${PORT}`); - console.log(`Serving from: ${path.join(__dirname, 'dist')}`); + console.log(`Frontend server running on port ${PORT}`); + console.log(`Serving from: ${path.join(__dirname, "dist")}`); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d07ed06..e29cbb6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,147 +1,185 @@ -import React from 'react' -import { Routes, Route } from 'react-router-dom' -import { AuthProvider, useAuth } from './contexts/AuthContext' -import { ThemeProvider } from './contexts/ThemeContext' -import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext' -import ProtectedRoute from './components/ProtectedRoute' -import Layout from './components/Layout' -import Login from './pages/Login' -import Dashboard from './pages/Dashboard' -import Hosts from './pages/Hosts' -import Packages from './pages/Packages' -import Repositories from './pages/Repositories' -import RepositoryDetail from './pages/RepositoryDetail' -import Users from './pages/Users' -import Permissions from './pages/Permissions' -import Settings from './pages/Settings' -import Options from './pages/Options' -import Profile from './pages/Profile' -import HostDetail from './pages/HostDetail' -import PackageDetail from './pages/PackageDetail' -import FirstTimeAdminSetup from './components/FirstTimeAdminSetup' +import React from "react"; +import { Route, Routes } from "react-router-dom"; +import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup"; +import Layout from "./components/Layout"; +import ProtectedRoute from "./components/ProtectedRoute"; +import { AuthProvider, useAuth } from "./contexts/AuthContext"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext"; +import Dashboard from "./pages/Dashboard"; +import HostDetail from "./pages/HostDetail"; +import Hosts from "./pages/Hosts"; +import Login from "./pages/Login"; +import Options from "./pages/Options"; +import PackageDetail from "./pages/PackageDetail"; +import Packages from "./pages/Packages"; +import Permissions from "./pages/Permissions"; +import Profile from "./pages/Profile"; +import Repositories from "./pages/Repositories"; +import RepositoryDetail from "./pages/RepositoryDetail"; +import Settings from "./pages/Settings"; +import Users from "./pages/Users"; function AppRoutes() { - const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth() - const isAuth = isAuthenticated() // Call the function to get boolean value + const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth(); + const isAuth = isAuthenticated(); // Call the function to get boolean value - // Show loading while checking if setup is needed - if (checkingSetup) { - return ( -
-
-
-

Checking system status...

-
-
- ) - } + // Show loading while checking if setup is needed + if (checkingSetup) { + return ( +
+
+
+

+ Checking system status... +

+
+
+ ); + } - // Show first-time setup if no admin users exist - if (needsFirstTimeSetup && !isAuth) { - return - } + // Show first-time setup if no admin users exist + if (needsFirstTimeSetup && !isAuth) { + return ; + } - return ( - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - - - - - } /> - - ) + return ( + + } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + ); } function App() { - return ( - - - - - - - - ) + return ( + + + + + + + + ); } -export default App \ No newline at end of file +export default App; diff --git a/frontend/src/components/DashboardSettingsModal.jsx b/frontend/src/components/DashboardSettingsModal.jsx index b3a819f..4623c67 100644 --- a/frontend/src/components/DashboardSettingsModal.jsx +++ b/frontend/src/components/DashboardSettingsModal.jsx @@ -1,336 +1,359 @@ -import React, { useState, useEffect } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; import { - useSortable, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { - X, - GripVertical, - Eye, - EyeOff, - Save, - RotateCcw, - Settings as SettingsIcon -} from 'lucide-react'; -import { dashboardPreferencesAPI } from '../utils/api'; -import { useTheme } from '../contexts/ThemeContext'; + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Eye, + EyeOff, + GripVertical, + RotateCcw, + Save, + Settings as SettingsIcon, + X, +} from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { useTheme } from "../contexts/ThemeContext"; +import { dashboardPreferencesAPI } from "../utils/api"; // Sortable Card Item Component const SortableCardItem = ({ card, onToggle }) => { - const { isDark } = useTheme(); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: card.cardId }); + const { isDark } = useTheme(); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: card.cardId }); - const style = { - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0.5 : 1, - }; + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; - return ( -
-
- -
-
- {card.title} - {card.typeLabel ? ( - ({card.typeLabel}) - ) : null} -
-
-
- - -
- ); + return ( +
+
+ +
+
+ {card.title} + {card.typeLabel ? ( + + ({card.typeLabel}) + + ) : null} +
+
+
+ + +
+ ); }; const DashboardSettingsModal = ({ isOpen, onClose }) => { - const [cards, setCards] = useState([]); - const [hasChanges, setHasChanges] = useState(false); - const queryClient = useQueryClient(); - const { isDark } = useTheme(); + const [cards, setCards] = useState([]); + const [hasChanges, setHasChanges] = useState(false); + const queryClient = useQueryClient(); + const { isDark } = useTheme(); - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }) - ); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); - // Fetch user's dashboard preferences - const { data: preferences, isLoading } = useQuery({ - queryKey: ['dashboardPreferences'], - queryFn: () => dashboardPreferencesAPI.get().then(res => res.data), - enabled: isOpen - }); + // Fetch user's dashboard preferences + const { data: preferences, isLoading } = useQuery({ + queryKey: ["dashboardPreferences"], + queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data), + enabled: isOpen, + }); - // Fetch default card configuration - const { data: defaultCards } = useQuery({ - queryKey: ['dashboardDefaultCards'], - queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data), - enabled: isOpen - }); + // Fetch default card configuration + const { data: defaultCards } = useQuery({ + queryKey: ["dashboardDefaultCards"], + queryFn: () => + dashboardPreferencesAPI.getDefaults().then((res) => res.data), + enabled: isOpen, + }); - // Update preferences mutation - const updatePreferencesMutation = useMutation({ - mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences), - onSuccess: (response) => { - // Optimistically update the query cache with the correct data structure - queryClient.setQueryData(['dashboardPreferences'], response.data.preferences); - // Also invalidate to ensure fresh data - queryClient.invalidateQueries(['dashboardPreferences']); - setHasChanges(false); - onClose(); - }, - onError: (error) => { - console.error('Failed to update dashboard preferences:', error); - } - }); + // Update preferences mutation + const updatePreferencesMutation = useMutation({ + mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences), + onSuccess: (response) => { + // Optimistically update the query cache with the correct data structure + queryClient.setQueryData( + ["dashboardPreferences"], + response.data.preferences, + ); + // Also invalidate to ensure fresh data + queryClient.invalidateQueries(["dashboardPreferences"]); + setHasChanges(false); + onClose(); + }, + onError: (error) => { + console.error("Failed to update dashboard preferences:", error); + }, + }); - // Initialize cards when preferences or defaults are loaded - useEffect(() => { - if (preferences && defaultCards) { - // Normalize server preferences (snake_case -> camelCase) - const normalizedPreferences = preferences.map((p) => ({ - cardId: p.cardId ?? p.card_id, - enabled: p.enabled, - order: p.order, - })); + // Initialize cards when preferences or defaults are loaded + useEffect(() => { + if (preferences && defaultCards) { + // Normalize server preferences (snake_case -> camelCase) + const normalizedPreferences = preferences.map((p) => ({ + cardId: p.cardId ?? p.card_id, + enabled: p.enabled, + order: p.order, + })); - const typeLabelFor = (cardId) => { - if (['totalHosts','hostsNeedingUpdates','totalOutdatedPackages','securityUpdates','upToDateHosts','totalHostGroups','totalUsers','totalRepos'].includes(cardId)) return 'Top card'; - if (cardId === 'osDistribution') return 'Pie chart'; - if (cardId === 'osDistributionBar') return 'Bar chart'; - if (cardId === 'updateStatus') return 'Pie chart'; - if (cardId === 'packagePriority') return 'Pie chart'; - if (cardId === 'recentUsers') return 'Table'; - if (cardId === 'recentCollection') return 'Table'; - if (cardId === 'quickStats') return 'Wide card'; - return undefined; - }; + const typeLabelFor = (cardId) => { + if ( + [ + "totalHosts", + "hostsNeedingUpdates", + "totalOutdatedPackages", + "securityUpdates", + "upToDateHosts", + "totalHostGroups", + "totalUsers", + "totalRepos", + ].includes(cardId) + ) + return "Top card"; + if (cardId === "osDistribution") return "Pie chart"; + if (cardId === "osDistributionBar") return "Bar chart"; + if (cardId === "updateStatus") return "Pie chart"; + if (cardId === "packagePriority") return "Pie chart"; + if (cardId === "recentUsers") return "Table"; + if (cardId === "recentCollection") return "Table"; + if (cardId === "quickStats") return "Wide card"; + return undefined; + }; - // Merge user preferences with default cards - 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, - typeLabel: typeLabelFor(defaultCard.cardId), - }; - }) - .sort((a, b) => a.order - b.order); - - setCards(mergedCards); - } - }, [preferences, defaultCards]); + // Merge user preferences with default cards + 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, + typeLabel: typeLabelFor(defaultCard.cardId), + }; + }) + .sort((a, b) => a.order - b.order); - const handleDragEnd = (event) => { - const { active, over } = event; + setCards(mergedCards); + } + }, [preferences, defaultCards]); - if (active.id !== over.id) { - setCards((items) => { - const oldIndex = items.findIndex(item => item.cardId === active.id); - const newIndex = items.findIndex(item => item.cardId === over.id); - - const newItems = arrayMove(items, oldIndex, newIndex); - - // Update order values - return newItems.map((item, index) => ({ - ...item, - order: index - })); - }); - setHasChanges(true); - } - }; + const handleDragEnd = (event) => { + const { active, over } = event; - const handleToggle = (cardId) => { - setCards(prevCards => - prevCards.map(card => - card.cardId === cardId - ? { ...card, enabled: !card.enabled } - : card - ) - ); - setHasChanges(true); - }; + if (active.id !== over.id) { + setCards((items) => { + const oldIndex = items.findIndex((item) => item.cardId === active.id); + const newIndex = items.findIndex((item) => item.cardId === over.id); - const handleSave = () => { - const preferences = cards.map(card => ({ - cardId: card.cardId, - enabled: card.enabled, - order: card.order - })); - - updatePreferencesMutation.mutate(preferences); - }; + const newItems = arrayMove(items, oldIndex, newIndex); - const handleReset = () => { - if (defaultCards) { - const resetCards = defaultCards.map(card => ({ - ...card, - enabled: true, - order: card.order - })); - setCards(resetCards); - setHasChanges(true); - } - }; + // Update order values + return newItems.map((item, index) => ({ + ...item, + order: index, + })); + }); + setHasChanges(true); + } + }; - if (!isOpen) return null; + const handleToggle = (cardId) => { + setCards((prevCards) => + prevCards.map((card) => + card.cardId === cardId ? { ...card, enabled: !card.enabled } : card, + ), + ); + setHasChanges(true); + }; - return ( -
-
-
- -
-
-
-
- -

- Dashboard Settings -

-
- -
- -

- Customize your dashboard by reordering cards and toggling their visibility. - Drag cards to reorder them, and click the visibility toggle to show/hide cards. -

+ const handleSave = () => { + const preferences = cards.map((card) => ({ + cardId: card.cardId, + enabled: card.enabled, + order: card.order, + })); - {isLoading ? ( -
-
-
- ) : ( - - card.cardId)} strategy={verticalListSortingStrategy}> -
- {cards.map((card) => ( - - ))} -
-
-
- )} -
- -
- - - - - -
-
-
-
- ); + updatePreferencesMutation.mutate(preferences); + }; + + const handleReset = () => { + if (defaultCards) { + const resetCards = defaultCards.map((card) => ({ + ...card, + enabled: true, + order: card.order, + })); + setCards(resetCards); + setHasChanges(true); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+ +
+
+
+
+ +

+ Dashboard Settings +

+
+ +
+ +

+ Customize your dashboard by reordering cards and toggling their + visibility. Drag cards to reorder them, and click the visibility + toggle to show/hide cards. +

+ + {isLoading ? ( +
+
+
+ ) : ( + + card.cardId)} + strategy={verticalListSortingStrategy} + > +
+ {cards.map((card) => ( + + ))} +
+
+
+ )} +
+ +
+ + + + + +
+
+
+
+ ); }; export default DashboardSettingsModal; diff --git a/frontend/src/components/FirstTimeAdminSetup.jsx b/frontend/src/components/FirstTimeAdminSetup.jsx index c0bfa86..006d413 100644 --- a/frontend/src/components/FirstTimeAdminSetup.jsx +++ b/frontend/src/components/FirstTimeAdminSetup.jsx @@ -1,297 +1,321 @@ -import React, { useState } from 'react' -import { useAuth } from '../contexts/AuthContext' -import { UserPlus, Shield, CheckCircle, AlertCircle } from 'lucide-react' +import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react"; +import React, { useState } from "react"; +import { useAuth } from "../contexts/AuthContext"; const FirstTimeAdminSetup = () => { - const { login } = useAuth() - const [formData, setFormData] = useState({ - username: '', - email: '', - password: '', - confirmPassword: '', - firstName: '', - lastName: '' - }) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState('') - const [success, setSuccess] = useState(false) + const { login } = useAuth(); + const [formData, setFormData] = useState({ + username: "", + email: "", + password: "", + confirmPassword: "", + firstName: "", + lastName: "", + }); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); - const handleInputChange = (e) => { - const { name, value } = e.target - setFormData(prev => ({ - ...prev, - [name]: value - })) - // Clear error when user starts typing - if (error) setError('') - } + const handleInputChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + // Clear error when user starts typing + if (error) setError(""); + }; - const validateForm = () => { - if (!formData.firstName.trim()) { - setError('First name is required') - return false - } - if (!formData.lastName.trim()) { - setError('Last name is required') - return false - } - if (!formData.username.trim()) { - setError('Username is required') - return false - } - if (!formData.email.trim()) { - setError('Email address is required') - return false - } - - // Enhanced email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(formData.email.trim())) { - setError('Please enter a valid email address (e.g., user@example.com)') - return false - } - - if (formData.password.length < 8) { - setError('Password must be at least 8 characters for security') - return false - } - if (formData.password !== formData.confirmPassword) { - setError('Passwords do not match') - return false - } - return true - } + const validateForm = () => { + if (!formData.firstName.trim()) { + setError("First name is required"); + return false; + } + if (!formData.lastName.trim()) { + setError("Last name is required"); + return false; + } + if (!formData.username.trim()) { + setError("Username is required"); + return false; + } + if (!formData.email.trim()) { + setError("Email address is required"); + return false; + } - const handleSubmit = async (e) => { - e.preventDefault() - - if (!validateForm()) return + // Enhanced email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(formData.email.trim())) { + setError("Please enter a valid email address (e.g., user@example.com)"); + return false; + } - setIsLoading(true) - setError('') + if (formData.password.length < 8) { + setError("Password must be at least 8 characters for security"); + return false; + } + if (formData.password !== formData.confirmPassword) { + setError("Passwords do not match"); + return false; + } + return true; + }; - try { - const response = await fetch('/api/v1/auth/setup-admin', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - username: formData.username.trim(), - email: formData.email.trim(), - password: formData.password, - firstName: formData.firstName.trim(), - lastName: formData.lastName.trim() - }) - }) + const handleSubmit = async (e) => { + e.preventDefault(); - const data = await response.json() + if (!validateForm()) return; - if (response.ok) { - setSuccess(true) - // Auto-login the user after successful setup - setTimeout(() => { - login(formData.username.trim(), formData.password) - }, 2000) - } else { - setError(data.error || 'Failed to create admin user') - } - } catch (error) { - console.error('Setup error:', error) - setError('Network error. Please try again.') - } finally { - setIsLoading(false) - } - } + setIsLoading(true); + setError(""); - if (success) { - return ( -
-
-
-
-
- -
-
-

- Admin Account Created! -

-

- Your admin account has been successfully created. You will be automatically logged in shortly. -

-
-
-
-
-
-
- ) - } + try { + const response = await fetch("/api/v1/auth/setup-admin", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: formData.username.trim(), + email: formData.email.trim(), + password: formData.password, + firstName: formData.firstName.trim(), + lastName: formData.lastName.trim(), + }), + }); - return ( -
-
-
-
-
-
- -
-
-

- Welcome to PatchMon -

-

- Let's set up your admin account to get started -

-
+ const data = await response.json(); - {error && ( -
-
- - {error} -
-
- )} + if (response.ok) { + setSuccess(true); + // Auto-login the user after successful setup + setTimeout(() => { + login(formData.username.trim(), formData.password); + }, 2000); + } else { + setError(data.error || "Failed to create admin user"); + } + } catch (error) { + console.error("Setup error:", error); + setError("Network error. Please try again."); + } finally { + setIsLoading(false); + } + }; -
-
-
- - -
-
- - -
-
+ if (success) { + return ( +
+
+
+
+
+ +
+
+

+ Admin Account Created! +

+

+ Your admin account has been successfully created. You will be + automatically logged in shortly. +

+
+
+
+
+
+
+ ); + } -
- - -
+ return ( +
+
+
+
+
+
+ +
+
+

+ Welcome to PatchMon +

+

+ Let's set up your admin account to get started +

+
-
- - -
+ {error && ( +
+
+ + + {error} + +
+
+ )} -
- - -
+ +
+
+ + +
+
+ + +
+
-
- - -
+
+ + +
- - +
+ + +
-
-
- -
-

Admin Privileges

-

This account will have full administrative access to manage users, hosts, packages, and system settings.

-
-
-
-
-
-
- ) -} +
+ + +
-export default FirstTimeAdminSetup +
+ + +
+ + + + +
+
+ +
+

Admin Privileges

+

+ This account will have full administrative access to manage + users, hosts, packages, and system settings. +

+
+
+
+
+
+
+ ); +}; + +export default FirstTimeAdminSetup; diff --git a/frontend/src/components/InlineEdit.jsx b/frontend/src/components/InlineEdit.jsx index 05d0f88..5ecfba2 100644 --- a/frontend/src/components/InlineEdit.jsx +++ b/frontend/src/components/InlineEdit.jsx @@ -1,157 +1,159 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { Edit2, Check, X } from 'lucide-react'; -import { Link } from 'react-router-dom'; +import { Check, Edit2, X } from "lucide-react"; +import React, { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; -const InlineEdit = ({ - value, - onSave, - onCancel, - placeholder = "Enter value...", - maxLength = 100, - className = "", - disabled = false, - validate = null, - linkTo = null +const InlineEdit = ({ + value, + onSave, + onCancel, + placeholder = "Enter value...", + maxLength = 100, + className = "", + disabled = false, + validate = null, + linkTo = null, }) => { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(value); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - const inputRef = useRef(null); + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(value); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const inputRef = useRef(null); - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); - useEffect(() => { - setEditValue(value); - }, [value]); + useEffect(() => { + setEditValue(value); + }, [value]); - const handleEdit = () => { - if (disabled) return; - setIsEditing(true); - setEditValue(value); - setError(''); - }; + const handleEdit = () => { + if (disabled) return; + setIsEditing(true); + setEditValue(value); + setError(""); + }; - const handleCancel = () => { - setIsEditing(false); - setEditValue(value); - setError(''); - if (onCancel) onCancel(); - }; + const handleCancel = () => { + setIsEditing(false); + setEditValue(value); + setError(""); + if (onCancel) onCancel(); + }; - const handleSave = async () => { - if (disabled || isLoading) return; + const handleSave = async () => { + if (disabled || isLoading) return; - // Validate if validator function provided - if (validate) { - const validationError = validate(editValue); - if (validationError) { - setError(validationError); - return; - } - } + // Validate if validator function provided + if (validate) { + const validationError = validate(editValue); + if (validationError) { + setError(validationError); + return; + } + } - // Check if value actually changed - if (editValue.trim() === value.trim()) { - setIsEditing(false); - return; - } + // Check if value actually changed + if (editValue.trim() === value.trim()) { + setIsEditing(false); + return; + } - setIsLoading(true); - setError(''); + setIsLoading(true); + setError(""); - try { - await onSave(editValue.trim()); - setIsEditing(false); - } catch (err) { - setError(err.message || 'Failed to save'); - } finally { - setIsLoading(false); - } - }; + try { + await onSave(editValue.trim()); + setIsEditing(false); + } catch (err) { + setError(err.message || "Failed to save"); + } finally { + setIsLoading(false); + } + }; - const handleKeyDown = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSave(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleCancel(); - } - }; + const handleKeyDown = (e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSave(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + }; - if (isEditing) { - return ( -
- setEditValue(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={placeholder} - maxLength={maxLength} - disabled={isLoading} - className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${ - error ? 'border-red-500' : '' - } ${isLoading ? 'opacity-50' : ''}`} - /> - - - {error && ( - {error} - )} -
- ); - } + if (isEditing) { + return ( +
+ setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + maxLength={maxLength} + disabled={isLoading} + className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${ + error ? "border-red-500" : "" + } ${isLoading ? "opacity-50" : ""}`} + /> + + + {error && ( + + {error} + + )} +
+ ); + } - const displayValue = linkTo ? ( - - {value} - - ) : ( - - {value} - - ); + const displayValue = linkTo ? ( + + {value} + + ) : ( + + {value} + + ); - return ( -
- {displayValue} - {!disabled && ( - - )} -
- ); + return ( +
+ {displayValue} + {!disabled && ( + + )} +
+ ); }; export default InlineEdit; diff --git a/frontend/src/components/InlineGroupEdit.jsx b/frontend/src/components/InlineGroupEdit.jsx index fda76bb..48e2e3d 100644 --- a/frontend/src/components/InlineGroupEdit.jsx +++ b/frontend/src/components/InlineGroupEdit.jsx @@ -1,257 +1,270 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; -import { Edit2, Check, X, ChevronDown } from 'lucide-react'; +import { Check, ChevronDown, Edit2, X } from "lucide-react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; -const InlineGroupEdit = ({ - value, - onSave, - onCancel, - options = [], - className = "", - disabled = false, - placeholder = "Select group..." +const InlineGroupEdit = ({ + value, + onSave, + onCancel, + options = [], + className = "", + disabled = false, + placeholder = "Select group...", }) => { - const [isEditing, setIsEditing] = useState(false); - const [selectedValue, setSelectedValue] = useState(value); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - const [isOpen, setIsOpen] = useState(false); - const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); - const dropdownRef = useRef(null); - const buttonRef = useRef(null); + const [isEditing, setIsEditing] = useState(false); + const [selectedValue, setSelectedValue] = useState(value); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [dropdownPosition, setDropdownPosition] = useState({ + top: 0, + left: 0, + width: 0, + }); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); - useEffect(() => { - if (isEditing && dropdownRef.current) { - dropdownRef.current.focus(); - } - }, [isEditing]); + useEffect(() => { + if (isEditing && dropdownRef.current) { + dropdownRef.current.focus(); + } + }, [isEditing]); - useEffect(() => { - setSelectedValue(value); - // Force re-render when value changes - if (!isEditing) { - setIsOpen(false); - } - }, [value, isEditing]); + useEffect(() => { + setSelectedValue(value); + // Force re-render when value changes + if (!isEditing) { + setIsOpen(false); + } + }, [value, isEditing]); - // Calculate dropdown position - const calculateDropdownPosition = () => { - if (buttonRef.current) { - const rect = buttonRef.current.getBoundingClientRect(); - setDropdownPosition({ - top: rect.bottom + window.scrollY + 4, - left: rect.left + window.scrollX, - width: rect.width - }); - } - }; + // Calculate dropdown position + const calculateDropdownPosition = () => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX, + width: rect.width, + }); + } + }; - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { - setIsOpen(false); - } - }; + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false); + } + }; - if (isOpen) { - calculateDropdownPosition(); - document.addEventListener('mousedown', handleClickOutside); - window.addEventListener('resize', calculateDropdownPosition); - window.addEventListener('scroll', calculateDropdownPosition); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - window.removeEventListener('resize', calculateDropdownPosition); - window.removeEventListener('scroll', calculateDropdownPosition); - }; - } - }, [isOpen]); + if (isOpen) { + calculateDropdownPosition(); + document.addEventListener("mousedown", handleClickOutside); + window.addEventListener("resize", calculateDropdownPosition); + window.addEventListener("scroll", calculateDropdownPosition); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + window.removeEventListener("resize", calculateDropdownPosition); + window.removeEventListener("scroll", calculateDropdownPosition); + }; + } + }, [isOpen]); - const handleEdit = () => { - if (disabled) return; - setIsEditing(true); - setSelectedValue(value); - setError(''); - // Automatically open dropdown when editing starts - setTimeout(() => { - setIsOpen(true); - }, 0); - }; + const handleEdit = () => { + if (disabled) return; + setIsEditing(true); + setSelectedValue(value); + setError(""); + // Automatically open dropdown when editing starts + setTimeout(() => { + setIsOpen(true); + }, 0); + }; - const handleCancel = () => { - setIsEditing(false); - setSelectedValue(value); - setError(''); - setIsOpen(false); - if (onCancel) onCancel(); - }; + const handleCancel = () => { + setIsEditing(false); + setSelectedValue(value); + setError(""); + setIsOpen(false); + if (onCancel) onCancel(); + }; - const handleSave = async () => { - if (disabled || isLoading) return; + const handleSave = async () => { + if (disabled || isLoading) return; - // Check if value actually changed - if (selectedValue === value) { - setIsEditing(false); - setIsOpen(false); - return; - } + // Check if value actually changed + if (selectedValue === value) { + setIsEditing(false); + setIsOpen(false); + return; + } - setIsLoading(true); - setError(''); + setIsLoading(true); + setError(""); - try { - await onSave(selectedValue); - // Update the local value to match the saved value - setSelectedValue(selectedValue); - setIsEditing(false); - setIsOpen(false); - } catch (err) { - setError(err.message || 'Failed to save'); - } finally { - setIsLoading(false); - } - }; + try { + await onSave(selectedValue); + // Update the local value to match the saved value + setSelectedValue(selectedValue); + setIsEditing(false); + setIsOpen(false); + } catch (err) { + setError(err.message || "Failed to save"); + } finally { + setIsLoading(false); + } + }; - const handleKeyDown = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSave(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleCancel(); - } - }; + const handleKeyDown = (e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSave(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + }; - const displayValue = useMemo(() => { - if (!value) { - return 'Ungrouped'; - } - const option = options.find(opt => opt.id === value); - return option ? option.name : 'Unknown Group'; - }, [value, options]); + const displayValue = useMemo(() => { + if (!value) { + return "Ungrouped"; + } + const option = options.find((opt) => opt.id === value); + return option ? option.name : "Unknown Group"; + }, [value, options]); - const displayColor = useMemo(() => { - if (!value) return 'bg-secondary-100 text-secondary-800'; - const option = options.find(opt => opt.id === value); - return option ? `text-white` : 'bg-secondary-100 text-secondary-800'; - }, [value, options]); + const displayColor = useMemo(() => { + if (!value) return "bg-secondary-100 text-secondary-800"; + const option = options.find((opt) => opt.id === value); + return option ? `text-white` : "bg-secondary-100 text-secondary-800"; + }, [value, options]); - const selectedOption = useMemo(() => { - return options.find(opt => opt.id === value); - }, [value, options]); + const selectedOption = useMemo(() => { + return options.find((opt) => opt.id === value); + }, [value, options]); - if (isEditing) { - return ( -
-
-
- - - {isOpen && ( -
-
- - {options.map((option) => ( - - ))} -
-
- )} -
- - -
- {error && ( - {error} - )} -
- ); - } + if (isEditing) { + return ( +
+
+
+ - return ( -
- - {displayValue} - - {!disabled && ( - - )} -
- ); + {isOpen && ( +
+
+ + {options.map((option) => ( + + ))} +
+
+ )} +
+ + +
+ {error && ( + + {error} + + )} +
+ ); + } + + return ( +
+ + {displayValue} + + {!disabled && ( + + )} +
+ ); }; export default InlineGroupEdit; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 4aec6c4..6dfc4c0 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,729 +1,822 @@ -import React from 'react' -import { Link, useLocation } from 'react-router-dom' -import { - Home, - Server, - Package, - Shield, - BarChart3, - Menu, - X, - LogOut, - User, - Users, - Settings, - UserCircle, - ChevronLeft, - ChevronRight, - Clock, - RefreshCw, - GitBranch, - Wrench, - Container, - Plus, - Activity, - Cog, - FileText, - Github, - MessageCircle, - Mail, - Star, - Globe -} from 'lucide-react' -import { useState, useEffect, useRef } from 'react' -import { useQuery } from '@tanstack/react-query' -import { useAuth } from '../contexts/AuthContext' -import { useUpdateNotification } from '../contexts/UpdateNotificationContext' -import { dashboardAPI, formatRelativeTime, versionAPI } from '../utils/api' -import UpgradeNotificationIcon from './UpgradeNotificationIcon' +import { useQuery } from "@tanstack/react-query"; +import { + Activity, + BarChart3, + ChevronLeft, + ChevronRight, + Clock, + Cog, + Container, + FileText, + GitBranch, + Github, + Globe, + Home, + LogOut, + Mail, + Menu, + MessageCircle, + Package, + Plus, + RefreshCw, + Server, + Settings, + Shield, + Star, + User, + UserCircle, + Users, + Wrench, + X, +} from "lucide-react"; +import React, { useEffect, useRef, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { useAuth } from "../contexts/AuthContext"; +import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; +import { dashboardAPI, formatRelativeTime, versionAPI } from "../utils/api"; +import UpgradeNotificationIcon from "./UpgradeNotificationIcon"; const Layout = ({ children }) => { - const [sidebarOpen, setSidebarOpen] = useState(false) - const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { - // Load sidebar state from localStorage, default to false - const saved = localStorage.getItem('sidebarCollapsed') - return saved ? JSON.parse(saved) : false - }) - const [userMenuOpen, setUserMenuOpen] = useState(false) - const [githubStars, setGithubStars] = useState(null) - const location = useLocation() - const { user, logout, canViewDashboard, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canViewReports, canExportData, canManageSettings } = useAuth() - const { updateAvailable } = useUpdateNotification() - const userMenuRef = useRef(null) + const [sidebarOpen, setSidebarOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + // Load sidebar state from localStorage, default to false + const saved = localStorage.getItem("sidebarCollapsed"); + return saved ? JSON.parse(saved) : false; + }); + const [userMenuOpen, setUserMenuOpen] = useState(false); + const [githubStars, setGithubStars] = useState(null); + const location = useLocation(); + const { + user, + logout, + canViewDashboard, + canViewHosts, + canManageHosts, + canViewPackages, + canViewUsers, + canManageUsers, + canViewReports, + canExportData, + canManageSettings, + } = useAuth(); + const { updateAvailable } = useUpdateNotification(); + const userMenuRef = useRef(null); - // Fetch dashboard stats for the "Last updated" info - const { data: stats, 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 - }) + // Fetch dashboard stats for the "Last updated" info + const { + data: stats, + 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 + }); - // Fetch version info - const { data: versionInfo } = useQuery({ - queryKey: ['versionInfo'], - queryFn: () => versionAPI.getCurrent().then(res => res.data), - staleTime: 300000, // Consider data stale after 5 minutes - }) + // Fetch version info + const { data: versionInfo } = useQuery({ + queryKey: ["versionInfo"], + queryFn: () => versionAPI.getCurrent().then((res) => res.data), + staleTime: 300000, // Consider data stale after 5 minutes + }); - // Build navigation based on permissions - const buildNavigation = () => { - const nav = [] - - // Dashboard - only show if user can view dashboard - if (canViewDashboard()) { - nav.push({ name: 'Dashboard', href: '/', icon: Home }) - } - - // Inventory section - only show if user has any inventory permissions - if (canViewHosts() || canViewPackages() || canViewReports()) { - const inventoryItems = [] - - if (canViewHosts()) { - inventoryItems.push({ name: 'Hosts', href: '/hosts', icon: Server }) - inventoryItems.push({ name: 'Repos', href: '/repositories', icon: GitBranch }) - } - - if (canViewPackages()) { - inventoryItems.push({ name: 'Packages', href: '/packages', icon: Package }) - } - - if (canViewReports()) { - inventoryItems.push( - { name: 'Services', href: '/services', icon: Activity, comingSoon: true }, - { name: 'Docker', href: '/docker', icon: Container, comingSoon: true }, - { name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true } - ) - } - - if (inventoryItems.length > 0) { - nav.push({ - section: 'Inventory', - items: inventoryItems - }) - } - } - - // PatchMon Users section - only show if user can view/manage users - if (canViewUsers() || canManageUsers()) { - const userItems = [] - - if (canViewUsers()) { - userItems.push({ name: 'Users', href: '/users', icon: Users }) - } - - if (canManageSettings()) { - userItems.push({ name: 'Permissions', href: '/permissions', icon: Shield }) - } - - if (userItems.length > 0) { - nav.push({ - section: 'PatchMon Users', - items: userItems - }) - } - } - - // Settings section - only show if user has any settings permissions - if (canManageSettings() || canViewReports() || canExportData()) { - const settingsItems = [] - - if (canManageSettings()) { - settingsItems.push({ - name: 'PatchMon Options', - href: '/options', - icon: Settings - }) - settingsItems.push({ - name: 'Server Config', - href: '/settings', - icon: Wrench, - showUpgradeIcon: updateAvailable - }) - } - - if (canViewReports() || canExportData()) { - settingsItems.push({ - name: 'Audit Log', - href: '/audit-log', - icon: FileText, - comingSoon: true - }) - } - - if (settingsItems.length > 0) { - nav.push({ - section: 'Settings', - items: settingsItems - }) - } - } - - return nav - } + // Build navigation based on permissions + const buildNavigation = () => { + const nav = []; - const navigation = buildNavigation() + // Dashboard - only show if user can view dashboard + if (canViewDashboard()) { + nav.push({ name: "Dashboard", href: "/", icon: Home }); + } - const isActive = (path) => location.pathname === path + // Inventory section - only show if user has any inventory permissions + if (canViewHosts() || canViewPackages() || canViewReports()) { + const inventoryItems = []; - // Get page title based on current route - const getPageTitle = () => { - const path = location.pathname - - if (path === '/') return 'Dashboard' - if (path === '/hosts') return 'Hosts' - if (path === '/packages') return 'Packages' - if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories' - if (path === '/services') return 'Services' - if (path === '/docker') return 'Docker' - if (path === '/users') return 'Users' - if (path === '/permissions') return 'Permissions' - if (path === '/settings') return 'Settings' - if (path === '/options') return 'PatchMon Options' - if (path === '/audit-log') return 'Audit Log' - if (path === '/profile') return 'My Profile' - if (path.startsWith('/hosts/')) return 'Host Details' - if (path.startsWith('/packages/')) return 'Package Details' - - return 'PatchMon' - } + if (canViewHosts()) { + inventoryItems.push({ name: "Hosts", href: "/hosts", icon: Server }); + inventoryItems.push({ + name: "Repos", + href: "/repositories", + icon: GitBranch, + }); + } - const handleLogout = async () => { - await logout() - setUserMenuOpen(false) - } + if (canViewPackages()) { + inventoryItems.push({ + name: "Packages", + href: "/packages", + icon: Package, + }); + } - const handleAddHost = () => { - // Navigate to hosts page with add modal parameter - window.location.href = '/hosts?action=add' - } + if (canViewReports()) { + inventoryItems.push( + { + name: "Services", + href: "/services", + icon: Activity, + comingSoon: true, + }, + { + name: "Docker", + href: "/docker", + icon: Container, + comingSoon: true, + }, + { + name: "Reporting", + href: "/reporting", + icon: BarChart3, + comingSoon: true, + }, + ); + } + if (inventoryItems.length > 0) { + nav.push({ + section: "Inventory", + items: inventoryItems, + }); + } + } - // 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) - } - } + // PatchMon Users section - only show if user can view/manage users + if (canViewUsers() || canManageUsers()) { + const userItems = []; - // 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 (canViewUsers()) { + userItems.push({ name: "Users", href: "/users", icon: Users }); + } - 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` - } + if (canManageSettings()) { + userItems.push({ + name: "Permissions", + href: "/permissions", + icon: Shield, + }); + } - // Save sidebar collapsed state to localStorage - useEffect(() => { - localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed)) - }, [sidebarCollapsed]) + if (userItems.length > 0) { + nav.push({ + section: "PatchMon Users", + items: userItems, + }); + } + } - // Close user menu when clicking outside - useEffect(() => { - const handleClickOutside = (event) => { - if (userMenuRef.current && !userMenuRef.current.contains(event.target)) { - setUserMenuOpen(false) - } - } + // Settings section - only show if user has any settings permissions + if (canManageSettings() || canViewReports() || canExportData()) { + const settingsItems = []; - document.addEventListener('mousedown', handleClickOutside) - return () => { - document.removeEventListener('mousedown', handleClickOutside) - } - }, []) + if (canManageSettings()) { + settingsItems.push({ + name: "PatchMon Options", + href: "/options", + icon: Settings, + }); + settingsItems.push({ + name: "Server Config", + href: "/settings", + icon: Wrench, + showUpgradeIcon: updateAvailable, + }); + } - // Fetch GitHub stars on component mount - useEffect(() => { - fetchGitHubStars() - }, []) + if (canViewReports() || canExportData()) { + settingsItems.push({ + name: "Audit Log", + href: "/audit-log", + icon: FileText, + comingSoon: true, + }); + } - return ( -
- {/* Mobile sidebar */} -
-
setSidebarOpen(false)} /> -
-
- -
-
-
- -

PatchMon

-
-
- -
-
+ if (settingsItems.length > 0) { + nav.push({ + section: "Settings", + items: settingsItems, + }); + } + } - {/* Desktop sidebar */} -
-
-
- {sidebarCollapsed ? ( - - ) : ( - <> -
- -

PatchMon

-
- - - )} -
- - + return nav; + }; - {/* 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)} - - {versionInfo && ( - - v{versionInfo.version} - - )} -
-
- )} -
- ) : ( -
- - - - - {/* Updated info for collapsed sidebar */} - {stats && ( -
- - {versionInfo && ( - - v{versionInfo.version} - - )} -
- )} -
- )} -
-
-
+ const navigation = buildNavigation(); - {/* Main content */} -
- {/* Top bar */} -
- + const isActive = (path) => location.pathname === path; - {/* Separator */} -
+ // Get page title based on current route + const getPageTitle = () => { + const path = location.pathname; -
-
-

- {getPageTitle()} -

-
- -
-
+ if (path === "/") return "Dashboard"; + if (path === "/hosts") return "Hosts"; + if (path === "/packages") return "Packages"; + if (path === "/repositories" || path.startsWith("/repositories/")) + return "Repositories"; + if (path === "/services") return "Services"; + if (path === "/docker") return "Docker"; + if (path === "/users") return "Users"; + if (path === "/permissions") return "Permissions"; + if (path === "/settings") return "Settings"; + if (path === "/options") return "PatchMon Options"; + if (path === "/audit-log") return "Audit Log"; + if (path === "/profile") return "My Profile"; + if (path.startsWith("/hosts/")) return "Host Details"; + if (path.startsWith("/packages/")) return "Package Details"; -
-
- {children} -
-
-
-
- ) -} + 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)} + /> +
+
+ +
+
+
+ +

+ PatchMon +

+
+
+ +
+
+ + {/* Desktop sidebar */} +
+
+
+ {sidebarCollapsed ? ( + + ) : ( + <> +
+ +

+ PatchMon +

+
+ + + )} +
+ + + {/* 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)} + + + {versionInfo && ( + + v{versionInfo.version} + + )} +
+
+ )} +
+ ) : ( +
+ + + + + {/* Updated info for collapsed sidebar */} + {stats && ( +
+ + {versionInfo && ( + + v{versionInfo.version} + + )} +
+ )} +
+ )} +
+
+
+ + {/* Main content */} +
+ {/* Top bar */} +
+ + + {/* Separator */} +
+ +
+
+

+ {getPageTitle()} +

+
+ +
+
+ +
+
{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 ( -
-

OS Distribution

-
- -
-
- ); - - case 'osDistributionBar': - return ( -
-

OS Distribution

-
- -
-
- ); - - case 'updateStatus': - return ( -
-

Update Status

-
- -
-
- ); - - 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; - } - } + // 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'} -

- -
-
-
- ) - } + // 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 -

-
-
- - -
-
+ 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 ( +
+

+ Update Status +

+
+ +
+
+ ); -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"} +

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

+
+
+ + +
+
+ + {/* 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'} -

- -
-
-
-
- ) - } + 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"} +

+ +
+
+
+
+ ); + } - 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)} -
-
-
- - - -
-
+ 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 */} -
-
- - - - - -
- -
- {/* 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 - -
-
- )} -
-
- )} + 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)} +
+
+
+ + + +
+
- {/* 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 */} +
+
+ + + + + +
- - {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 && ( -
-
- -

System Uptime

-
-

{host.system_uptime}

-
- )} - - {/* CPU Model */} - {host.cpu_model && ( -
-
- -

CPU Model

-
-

{host.cpu_model}

-
- )} - - {/* CPU Cores */} - {host.cpu_cores && ( -
-
- -

CPU Cores

-
-

{host.cpu_cores}

-
- )} - - {/* RAM Installed */} - {host.ram_installed && ( -
-
- -

RAM Installed

-
-

{host.ram_installed} GB

-
- )} - - {/* Swap Size */} - {host.swap_size !== undefined && host.swap_size !== null && ( -
-
- -

Swap Size

-
-

{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) && ( -
-
- -

Load Average

-
-

- {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 ? ( - <> - - - - - - - - - - - {(showAllUpdates ? host.update_history : host.update_history.slice(0, 5)).map((update, index) => ( - - - - - - - ))} - -
- Status - - Date - - Packages - - Security -
-
-
- - {update.status === 'success' ? 'Success' : 'Failed'} - -
-
- {formatDate(update.timestamp)} - - {update.packages_count} - - {update.security_count > 0 ? ( -
- - - {update.security_count} - -
- ) : ( - - - )} -
- - {host.update_history.length > 5 && ( -
- -
- )} - - ) : ( -
- -

No update history available

-
- )} -
- )} -
-
-
+
+

+ Host Group +

+ {host.host_groups ? ( + + {host.host_groups.name} + + ) : ( + + Ungrouped + + )} +
- {/* Right Column - Package Statistics */} -
- {/* Package Statistics */} -
-
-

Package Statistics

-
-
-
- - - - - -
-
-
-
-
+
+

+ Operating System +

+
+ +

+ {host.os_type} {host.os_version} +

+
+
+ {host.agent_version && ( +
+
+

+ Agent Version +

+

+ {host.agent_version} +

+
+
+ + Auto-update + + +
+
+ )} +
+
+ )} - {/* 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 && ( +
+
+ +

+ System Uptime +

+
+

+ {host.system_uptime} +

+
+ )} + + {/* CPU Model */} + {host.cpu_model && ( +
+
+ +

+ CPU Model +

+
+

+ {host.cpu_model} +

+
+ )} + + {/* CPU Cores */} + {host.cpu_cores && ( +
+
+ +

+ CPU Cores +

+
+

+ {host.cpu_cores} +

+
+ )} + + {/* RAM Installed */} + {host.ram_installed && ( +
+
+ +

+ RAM Installed +

+
+

+ {host.ram_installed} GB +

+
+ )} + + {/* Swap Size */} + {host.swap_size !== undefined && + host.swap_size !== null && ( +
+
+ +

+ Swap Size +

+
+

+ {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) && ( +
+
+ +

+ Load Average +

+
+

+ {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 ? ( + <> + + + + + + + + + + + {(showAllUpdates + ? host.update_history + : host.update_history.slice(0, 5) + ).map((update, index) => ( + + + + + + + ))} + +
+ Status + + Date + + Packages + + Security +
+
+
+ + {update.status === "success" + ? "Success" + : "Failed"} + +
+
+ {formatDate(update.timestamp)} + + {update.packages_count} + + {update.security_count > 0 ? ( +
+ + + {update.security_count} + +
+ ) : ( + + - + + )} +
+ + {host.update_history.length > 5 && ( +
+ +
+ )} + + ) : ( +
+ +

+ No update history available +

+
+ )} +
+ )} +
+
+
+ + {/* Right Column - Package Statistics */} +
+ {/* Package Statistics */} +
+
+

+ Package Statistics +

+
+
+
+ + + + + +
+
+
+
+
+ + {/* 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 */} -
- -
+ {/* Tabs */} +
+ +
- {/* 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: -

-
- - -
-
+ {/* 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: +

+
+ + +
+
-
-

Manual Installation

-

- If you prefer to install manually, follow these steps: -

-
-
-
1. Download Agent Script
-
- - -
-
+
+

+ Manual Installation +

+

+ If you prefer to install manually, follow these steps: +

+
+
+
+ 1. Download Agent Script +
+
+ + +
+
-
-
2. Install Agent
-
- - -
-
+
+
+ 2. Install Agent +
+
+ + +
+
-
-
3. Configure Credentials
-
- - -
-
+
+
+ 3. Configure Credentials +
+
+ + +
+
-
-
4. Test Configuration
-
- - -
-
+
+
+ 4. Test Configuration +
+
+ + +
+
-
-
5. Send Initial Data
-
- - -
-
+
+
+ 5. Send Initial Data +
+
+ + +
+
-
-
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" - /> - -
-
-
-
-
- )} +
+
+ 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" + /> + +
+
+
+
+
+ )} - {activeTab === 'credentials' && ( -
-
-

API Credentials

-
-
- -
- - -
-
- -
- -
- - - -
-
-
-
+ {activeTab === "credentials" && ( +
+
+

+ API Credentials +

+
+
+ +
+ + +
+
-
-
- -
-

Security Notice

-

- Keep these credentials secure. They provide full access to this host's monitoring data. -

-
-
-
-
- )} +
+ +
+ + + +
+
+
+
+
+
+ +
+

+ Security Notice +

+

+ Keep these credentials secure. They provide full access to + this host's monitoring data. +

+
+
+
+
+ )} -
- -
-
-
- ) -} +
+ +
+
+
+ ); +}; // 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. -

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

-
- -
+ return ( +
+ {/* Header */} +
+
+

+ Organize your hosts into logical groups for better management +

+
+ +
- {/* Host Groups Grid */} - {hostGroups && hostGroups.length > 0 ? ( -
- {hostGroups.map((group) => ( -
-
-
-
-
-

- {group.name} -

- {group.description && ( -

- {group.description} -

- )} -
-
-
- - -
-
- -
-
- - {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''} -
-
-
- ))} -
- ) : ( -
- -

- No host groups yet -

-

- Create your first host group to organize your hosts -

- -
- )} + {/* Host Groups Grid */} + {hostGroups && hostGroups.length > 0 ? ( +
+ {hostGroups.map((group) => ( +
+
+
+
+
+

+ {group.name} +

+ {group.description && ( +

+ {group.description} +

+ )} +
+
+
+ + +
+
- {/* 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 +

+ +
+ )} - {/* 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 -

- -
-
- - -
- -
- -