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 (
); } if (error) { return (

Error loading settings

{error.response?.data?.error || "Failed to load settings"}

); } return (

Configure your PatchMon server settings. These settings will be used in installation scripts and agent communications.

{errors.general && (

{errors.general}

)} {/* Tab Navigation */}
{/* Tab Content */}
{/* Server Configuration Tab */} {activeTab === "server" && (

Server Configuration

handleInputChange("serverHost", e.target.value) } className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${ errors.serverHost ? "border-red-300 dark:border-red-500" : "border-secondary-300 dark:border-secondary-600" }`} placeholder="example.com" /> {errors.serverHost && (

{errors.serverHost}

)}
handleInputChange( "serverPort", parseInt(e.target.value, 10), ) } className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${ errors.serverPort ? "border-red-300 dark:border-red-500" : "border-secondary-300 dark:border-secondary-600" }`} min="1" max="65535" /> {errors.serverPort && (

{errors.serverPort}

)}

Server URL

{formData.serverProtocol}://{formData.serverHost}: {formData.serverPort}

This URL will be used in installation scripts and agent communications.

{/* Update Interval */}
{/* Numeric input (concise width) */}
{ const val = parseInt(e.target.value, 10); if (!Number.isNaN(val)) { handleInputChange( "updateInterval", Math.min(1440, Math.max(5, val)), ); } else { handleInputChange("updateInterval", 60); } }} className={`w-28 border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${ errors.updateInterval ? "border-red-300 dark:border-red-500" : "border-secondary-300 dark:border-secondary-600" }`} placeholder="60" />
{/* Quick presets */}
{[15, 30, 60, 120, 360, 720, 1440].map((m) => ( ))}
{/* Range slider */}
handleInputChange( "updateInterval", parseInt(e.target.value, 10), ) } className="w-full accent-primary-600" aria-label="Update interval slider" />
{errors.updateInterval && (

{errors.updateInterval}

)} {/* Helper text */}
Effective cadence:{" "} {(() => { const mins = parseInt(formData.updateInterval, 10) || 60; if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"}`; const hrs = Math.floor(mins / 60); const rem = mins % 60; return `${hrs} hour${hrs === 1 ? "" : "s"}${rem ? ` ${rem} min` : ""}`; })()}

This affects new installations and will update existing ones when they next reach out.

{/* Auto-Update Setting */}

When enabled, agents will automatically update themselves when a newer version is available during their regular update cycle.

{/* User Signup Setting */}
{/* Default User Role Dropdown */} {formData.signupEnabled && (

New users will be assigned this role when they register.

)}

When enabled, users can create their own accounts through the signup page. When disabled, only administrators can create user accounts.

{/* Security Notice */}

Security Notice

Changing these settings will affect all installation scripts and agent communications. Make sure the server URL is accessible from your client networks.

{/* Save Button */}
{updateSettingsMutation.isSuccess && (

Settings saved successfully!

)}
)} {/* Agent Management Tab */} {activeTab === "agent" && (

Agent Version Management

Manage different versions of the PatchMon agent script

{/* Version Summary */} {agentVersions && agentVersions.length > 0 && (
Current Version: {agentVersions.find((v) => v.is_current)?.version || "None"}
Default Version: {agentVersions.find((v) => v.is_default)?.version || "None"}
)} {agentVersionsLoading ? (
) : agentVersionsError ? (

Error loading agent versions: {agentVersionsError.message}

) : !agentVersions || agentVersions.length === 0 ? (

No agent versions found

) : (
{agentVersions.map((version) => (

Version {version.version}

{version.is_default && ( Default )} {version.is_current && ( Current )}
{version.release_notes && (

{version.release_notes}

)}

Created:{" "} {new Date( version.created_at, ).toLocaleDateString()}

))} {agentVersions?.length === 0 && (

No agent versions found

Add your first agent version to get started

)}
)}
)} {/* Server Version Tab */} {activeTab === "version" && (

Server Version Management

Version Check Configuration

Configure automatic version checking against your GitHub repository to notify users of available updates.

Repository Type
handleInputChange("repositoryType", e.target.value) } className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300" />
handleInputChange("repositoryType", e.target.value) } className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300" />

Choose whether your repository is public or private to determine the appropriate access method.

handleInputChange("githubRepoUrl", e.target.value) } className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm" placeholder="git@github.com:username/repository.git" />

SSH or HTTPS URL to your GitHub repository

{formData.repositoryType === "private" && (
{ const checked = e.target.checked; handleInputChange("useCustomSshKey", checked); if (!checked) { handleInputChange("sshKeyPath", ""); } }} className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" />
{formData.useCustomSshKey && (
handleInputChange("sshKeyPath", e.target.value) } className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm" placeholder="/root/.ssh/id_ed25519" />

Path to your SSH deploy key. If not set, will auto-detect from common locations.

{sshTestResult.success && (

{sshTestResult.message}

)} {sshTestResult.error && (

{sshTestResult.error}

)}
)} {!formData.useCustomSshKey && (

Using auto-detection for SSH key location

)}
)}
Current Version
{versionInfo.currentVersion}
Latest Version
{versionInfo.checking ? ( Checking... ) : versionInfo.latestVersion ? ( {versionInfo.latestVersion} {versionInfo.isUpdateAvailable && " (Update Available!)"} ) : ( Not checked )}
{/* Last Checked Time */} {versionInfo.last_update_check && (
Last Checked
{new Date( versionInfo.last_update_check, ).toLocaleString()}

Updates are checked automatically every 24 hours

)}
{/* Save Button for Version Settings */}
{versionInfo.error && (

Version Check Failed

{versionInfo.error}

{versionInfo.error.includes("private") && (

For private repositories, you may need to configure GitHub authentication or make the repository public.

)}
)} {/* Success Message for Version Settings */} {updateSettingsMutation.isSuccess && (

Settings saved successfully!

)}
)}
{/* Agent Version Modal */} {showAgentVersionModal && ( { setShowAgentVersionModal(false); setAgentVersionForm({ version: "", releaseNotes: "", scriptContent: "", isDefault: false, }); }} onSubmit={createAgentVersionMutation.mutate} isLoading={createAgentVersionMutation.isPending} /> )}
); }; // Agent Version Modal Component const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => { const [formData, setFormData] = useState({ version: "", releaseNotes: "", scriptContent: "", isDefault: false, }); const [errors, setErrors] = useState({}); const handleSubmit = (e) => { e.preventDefault(); // Basic validation const newErrors = {}; if (!formData.version.trim()) newErrors.version = "Version is required"; if (!formData.scriptContent.trim()) newErrors.scriptContent = "Script content is required"; if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; } onSubmit(formData); }; const handleFileUpload = (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (event) => { setFormData((prev) => ({ ...prev, scriptContent: event.target.result, })); }; reader.readAsText(file); } }; if (!isOpen) return null; return (

Add Agent Version

setFormData((prev) => ({ ...prev, version: e.target.value })) } className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${ errors.version ? "border-red-300 dark:border-red-500" : "border-secondary-300 dark:border-secondary-600" }`} placeholder="e.g., 1.0.1" /> {errors.version && (

{errors.version}

)}