import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, CheckCircle, Clock, Code, Download, Plus, Save, Server, Settings as SettingsIcon, Shield, Star, Trash2, X, } from "lucide-react"; import { useEffect, useState } from "react"; import UpgradeNotificationIcon from "../components/UpgradeNotificationIcon"; import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; import { agentVersionAPI, permissionsAPI, settingsAPI, versionAPI, } from "../utils/api"; const Settings = () => { const repoPublicId = useId(); const repoPrivateId = useId(); const useCustomSshKeyId = useId(); const protocolId = useId(); const hostId = useId(); const portId = useId(); const updateIntervalId = useId(); const defaultRoleId = useId(); const githubRepoUrlId = useId(); const sshKeyPathId = useId(); const [formData, setFormData] = useState({ serverProtocol: "http", serverHost: "localhost", serverPort: 3001, updateInterval: 60, autoUpdate: false, signupEnabled: false, defaultUserRole: "user", githubRepoUrl: "git@github.com:9technologygroup/patchmon.net.git", repositoryType: "public", sshKeyPath: "", useCustomSshKey: false, }); const [errors, setErrors] = useState({}); const [isDirty, setIsDirty] = useState(false); // Tab management const [activeTab, setActiveTab] = useState("server"); // Get update notification state const { updateAvailable } = useUpdateNotification(); // Tab configuration const tabs = [ { id: "server", name: "Server Configuration", icon: Server }, { id: "agent", name: "Agent Management", icon: SettingsIcon }, { id: "version", name: "Server Version", icon: Code, showUpgradeIcon: updateAvailable, }, ]; // Agent version management state const [showAgentVersionModal, setShowAgentVersionModal] = useState(false); // Version checking state const [versionInfo, setVersionInfo] = useState({ currentVersion: null, // Will be loaded from API latestVersion: null, isUpdateAvailable: false, checking: false, error: null, }); const [sshTestResult, setSshTestResult] = useState({ testing: false, success: null, message: null, error: null, }); const queryClient = useQueryClient(); // Fetch current settings const { data: settings, isLoading, error, } = useQuery({ queryKey: ["settings"], queryFn: () => settingsAPI.get().then((res) => res.data), }); // Fetch available roles for default user role dropdown const { data: roles, isLoading: rolesLoading } = useQuery({ queryKey: ["rolePermissions"], queryFn: () => permissionsAPI.getRoles().then((res) => res.data), }); // Update form data when settings are loaded useEffect(() => { if (settings) { const newFormData = { serverProtocol: settings.server_protocol || "http", serverHost: settings.server_host || "localhost", serverPort: settings.server_port || 3001, updateInterval: settings.update_interval || 60, autoUpdate: settings.auto_update || false, // biome-ignore lint/complexity/noUselessTernary: Seems to be desired given the comment signupEnabled: settings.signup_enabled === true ? true : false, // Explicit boolean conversion defaultUserRole: settings.default_user_role || "user", githubRepoUrl: settings.github_repo_url || "git@github.com:9technologygroup/patchmon.net.git", repositoryType: settings.repository_type || "public", sshKeyPath: settings.ssh_key_path || "", useCustomSshKey: !!settings.ssh_key_path, }; setFormData(newFormData); setIsDirty(false); } }, [settings]); // Update settings mutation const updateSettingsMutation = useMutation({ mutationFn: (data) => { return settingsAPI.update(data).then((res) => res.data); }, onSuccess: () => { queryClient.invalidateQueries(["settings"]); setIsDirty(false); setErrors({}); }, onError: (error) => { if (error.response?.data?.errors) { setErrors( error.response.data.errors.reduce((acc, err) => { acc[err.path] = err.msg; return acc; }, {}), ); } else { setErrors({ general: error.response?.data?.error || "Failed to update settings", }); } }, }); // Agent version queries and mutations const { data: agentVersions, isLoading: agentVersionsLoading, error: agentVersionsError, } = useQuery({ queryKey: ["agentVersions"], queryFn: () => { return agentVersionAPI.list().then((res) => { return res.data; }); }, }); // Load current version on component mount useEffect(() => { const loadCurrentVersion = async () => { try { const response = await versionAPI.getCurrent(); const data = response.data; setVersionInfo((prev) => ({ ...prev, currentVersion: data.version, })); } catch (error) { console.error("Error loading current version:", error); } }; loadCurrentVersion(); }, []); const createAgentVersionMutation = useMutation({ mutationFn: (data) => agentVersionAPI.create(data).then((res) => res.data), onSuccess: () => { queryClient.invalidateQueries(["agentVersions"]); setShowAgentVersionModal(false); setAgentVersionForm({ version: "", releaseNotes: "", scriptContent: "", isDefault: false, }); }, }); const setCurrentAgentVersionMutation = useMutation({ mutationFn: (id) => agentVersionAPI.setCurrent(id).then((res) => res.data), onSuccess: () => { queryClient.invalidateQueries(["agentVersions"]); }, }); const setDefaultAgentVersionMutation = useMutation({ mutationFn: (id) => agentVersionAPI.setDefault(id).then((res) => res.data), onSuccess: () => { queryClient.invalidateQueries(["agentVersions"]); }, }); const deleteAgentVersionMutation = useMutation({ mutationFn: (id) => agentVersionAPI.delete(id).then((res) => res.data), onSuccess: () => { queryClient.invalidateQueries(["agentVersions"]); }, onError: (error) => { console.error("Delete agent version error:", error); // Show user-friendly error message if (error.response?.data?.error === "Agent version not found") { alert( "Agent version not found. Please refresh the page to get the latest data.", ); // Force refresh the agent versions list queryClient.invalidateQueries(["agentVersions"]); } else if ( error.response?.data?.error === "Cannot delete current agent version" ) { alert( "Cannot delete the current agent version. Please set another version as current first.", ); } else { alert( `Failed to delete agent version: ${error.response?.data?.error || error.message}`, ); } }, }); // Version checking functions const checkForUpdates = async () => { setVersionInfo((prev) => ({ ...prev, checking: true, error: null })); try { const response = await versionAPI.checkUpdates(); const data = response.data; setVersionInfo({ currentVersion: data.currentVersion, latestVersion: data.latestVersion, isUpdateAvailable: data.isUpdateAvailable, last_update_check: data.last_update_check, checking: false, error: null, }); } catch (error) { console.error("Version check error:", error); setVersionInfo((prev) => ({ ...prev, checking: false, error: error.response?.data?.error || "Failed to check for updates", })); } }; const testSshKey = async () => { if (!formData.sshKeyPath || !formData.githubRepoUrl) { setSshTestResult({ testing: false, success: false, message: null, error: "Please enter both SSH key path and GitHub repository URL", }); return; } setSshTestResult({ testing: true, success: null, message: null, error: null, }); try { const response = await versionAPI.testSshKey({ sshKeyPath: formData.sshKeyPath, githubRepoUrl: formData.githubRepoUrl, }); setSshTestResult({ testing: false, success: true, message: response.data.message, error: null, }); } catch (error) { console.error("SSH key test error:", error); setSshTestResult({ testing: false, success: false, message: null, error: error.response?.data?.error || "Failed to test SSH key", }); } }; const handleInputChange = (field, value) => { setFormData((prev) => { const newData = { ...prev, [field]: value }; return newData; }); setIsDirty(true); if (errors[field]) { setErrors((prev) => ({ ...prev, [field]: null })); } }; const handleSubmit = (e) => { e.preventDefault(); // Only include sshKeyPath if the toggle is enabled const dataToSubmit = { ...formData }; if (!dataToSubmit.useCustomSshKey) { dataToSubmit.sshKeyPath = ""; } // Remove the frontend-only field delete dataToSubmit.useCustomSshKey; updateSettingsMutation.mutate(dataToSubmit); }; const validateForm = () => { const newErrors = {}; if (!formData.serverHost.trim()) { newErrors.serverHost = "Server host is required"; } if ( !formData.serverPort || formData.serverPort < 1 || formData.serverPort > 65535 ) { newErrors.serverPort = "Port must be between 1 and 65535"; } if ( !formData.updateInterval || formData.updateInterval < 5 || formData.updateInterval > 1440 ) { newErrors.updateInterval = "Update interval must be between 5 and 1440 minutes"; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSave = () => { if (validateForm()) { // Prepare data for submission const dataToSubmit = { ...formData }; if (!dataToSubmit.useCustomSshKey) { dataToSubmit.sshKeyPath = ""; } // Remove the frontend-only field delete dataToSubmit.useCustomSshKey; updateSettingsMutation.mutate(dataToSubmit); } }; if (isLoading) { return (
{error.response?.data?.error || "Failed to load settings"}
Configure your PatchMon server settings. These settings will be used in installation scripts and agent communications.
{errors.general}
Manage different versions of the PatchMon agent script
Error loading agent versions: {agentVersionsError.message}
No agent versions found
{version.release_notes}
Created:{" "} {new Date( version.created_at, ).toLocaleDateString()}
No agent versions found
Add your first agent version to get started
Configure automatic version checking against your GitHub repository to notify users of available updates.
SSH or HTTPS URL to your GitHub repository
Path to your SSH deploy key. If not set, will auto-detect from common locations.
{sshTestResult.message}
{sshTestResult.error}
Using auto-detection for SSH key location
)}Updates are checked automatically every 24 hours
{versionInfo.error}
{versionInfo.error.includes("private") && (For private repositories, you may need to configure GitHub authentication or make the repository public.
)}Settings saved successfully!