Files
patchmon.net/frontend/src/pages/Settings.jsx
2025-09-25 23:54:24 +01:00

1543 lines
51 KiB
JavaScript

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 (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error loading settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || "Failed to load settings"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-8">
<p className="text-secondary-600 dark:text-secondary-300">
Configure your PatchMon server settings. These settings will be used
in installation scripts and agent communications.
</p>
</div>
{errors.general && (
<div className="mb-6 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">
{errors.general}
</p>
</div>
</div>
</div>
)}
{/* Tab Navigation */}
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8 px-6">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
type="button"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? "border-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500"
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
{tab.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3" />
)}
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="p-6">
{/* Server Configuration Tab */}
{activeTab === "server" && (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex items-center mb-6">
<Server className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Server Configuration
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label
htmlFor={protocolId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Protocol
</label>
<select
id={protocolId}
value={formData.serverProtocol}
onChange={(e) =>
handleInputChange("serverProtocol", e.target.value)
}
className="w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div>
<label
htmlFor={hostId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Host *
</label>
<input
id={hostId}
type="text"
value={formData.serverHost}
onChange={(e) =>
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 && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.serverHost}
</p>
)}
</div>
<div>
<label
htmlFor={portId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Port *
</label>
<input
id={portId}
type="number"
value={formData.serverPort}
onChange={(e) =>
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 && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.serverPort}
</p>
)}
</div>
</div>
<div className="mt-4 p-4 bg-secondary-50 dark:bg-secondary-700 rounded-md">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
Server URL
</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 font-mono">
{formData.serverProtocol}://{formData.serverHost}:
{formData.serverPort}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
This URL will be used in installation scripts and agent
communications.
</p>
</div>
{/* Update Interval */}
<div>
<label
htmlFor={updateIntervalId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Agent Update Interval (minutes)
</label>
{/* Numeric input (concise width) */}
<div className="flex items-center gap-2">
<input
id={updateIntervalId}
type="number"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => {
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"
/>
</div>
{/* Quick presets */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{[15, 30, 60, 120, 360, 720, 1440].map((m) => (
<button
key={m}
type="button"
onClick={() => handleInputChange("updateInterval", m)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
formData.updateInterval === m
? "bg-primary-600 text-white border-primary-600"
: "bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600"
}`}
aria-label={`Set ${m} minutes`}
>
{m % 60 === 0 ? `${m / 60}h` : `${m}m`}
</button>
))}
</div>
{/* Range slider */}
<div className="mt-4">
<input
type="range"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) =>
handleInputChange(
"updateInterval",
parseInt(e.target.value, 10),
)
}
className="w-full accent-primary-600"
aria-label="Update interval slider"
/>
</div>
{errors.updateInterval && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.updateInterval}
</p>
)}
{/* Helper text */}
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<span className="font-medium">Effective cadence:</span>{" "}
{(() => {
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` : ""}`;
})()}
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
This affects new installations and will update existing ones
when they next reach out.
</p>
</div>
{/* Auto-Update Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.autoUpdate}
onChange={(e) =>
handleInputChange("autoUpdate", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
Enable Automatic Agent Updates
</div>
</label>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, agents will automatically update themselves when
a newer version is available during their regular update
cycle.
</p>
</div>
{/* User Signup Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.signupEnabled}
onChange={(e) =>
handleInputChange("signupEnabled", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
Enable User Self-Registration
</div>
</label>
{/* Default User Role Dropdown */}
{formData.signupEnabled && (
<div className="mt-3 ml-6">
<label
htmlFor={defaultRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Default Role for New Users
</label>
<select
id={defaultRoleId}
value={formData.defaultUserRole}
onChange={(e) =>
handleInputChange("defaultUserRole", e.target.value)
}
className="w-full max-w-xs border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
disabled={rolesLoading}
>
{rolesLoading ? (
<option>Loading roles...</option>
) : roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() +
role.role.slice(1)}
</option>
))
) : (
<option value="user">User</option>
)}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
New users will be assigned this role when they register.
</p>
</div>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, users can create their own accounts through the
signup page. When disabled, only administrators can create
user accounts.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Security Notice
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Changing these settings will affect all installation
scripts and agent communications. Make sure the server URL
is accessible from your client networks.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
</div>
</div>
</div>
)}
</form>
)}
{/* Agent Management Tab */}
{activeTab === "agent" && (
<div className="space-y-6">
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center mb-2">
<SettingsIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Agent Version Management
</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">
Manage different versions of the PatchMon agent script
</p>
</div>
<button
type="button"
onClick={() => setShowAgentVersionModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Version
</button>
</div>
{/* Version Summary */}
{agentVersions && agentVersions.length > 0 && (
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Current Version:
</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentVersions.find((v) => v.is_current)?.version ||
"None"}
</span>
</div>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Default Version:
</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentVersions.find((v) => v.is_default)?.version ||
"None"}
</span>
</div>
</div>
</div>
)}
{agentVersionsLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : agentVersionsError ? (
<div className="text-center py-8">
<p className="text-red-600 dark:text-red-400">
Error loading agent versions: {agentVersionsError.message}
</p>
</div>
) : !agentVersions || agentVersions.length === 0 ? (
<div className="text-center py-8">
<p className="text-secondary-500 dark:text-secondary-400">
No agent versions found
</p>
</div>
) : (
<div className="space-y-4">
{agentVersions.map((version) => (
<div
key={version.id}
className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Code className="h-5 w-5 text-secondary-400 dark:text-secondary-500" />
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Version {version.version}
</h3>
{version.is_default && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
<Star className="h-3 w-3 mr-1" />
Default
</span>
)}
{version.is_current && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Current
</span>
)}
</div>
{version.release_notes && (
<div className="text-sm text-secondary-500 dark:text-secondary-300 mt-1">
<p className="line-clamp-3 whitespace-pre-line">
{version.release_notes}
</p>
</div>
)}
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
Created:{" "}
{new Date(
version.created_at,
).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const downloadUrl = `/api/v1/hosts/agent/download?version=${version.version}`;
window.open(downloadUrl, "_blank");
}}
className="btn-outline text-xs flex items-center gap-1"
>
<Download className="h-3 w-3" />
Download
</button>
<button
type="button"
onClick={() =>
setCurrentAgentVersionMutation.mutate(version.id)
}
disabled={
version.is_current ||
setCurrentAgentVersionMutation.isPending
}
className="btn-outline text-xs flex items-center gap-1"
>
<CheckCircle className="h-3 w-3" />
Set Current
</button>
<button
type="button"
onClick={() =>
setDefaultAgentVersionMutation.mutate(version.id)
}
disabled={
version.is_default ||
setDefaultAgentVersionMutation.isPending
}
className="btn-outline text-xs flex items-center gap-1"
>
<Star className="h-3 w-3" />
Set Default
</button>
<button
type="button"
onClick={() =>
deleteAgentVersionMutation.mutate(version.id)
}
disabled={
version.is_default ||
version.is_current ||
deleteAgentVersionMutation.isPending
}
className="btn-danger text-xs flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
</div>
</div>
</div>
))}
{agentVersions?.length === 0 && (
<div className="text-center py-8">
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No agent versions found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Add your first agent version to get started
</p>
</div>
)}
</div>
)}
</div>
)}
{/* Server Version Tab */}
{activeTab === "version" && (
<div className="space-y-6">
<div className="flex items-center mb-6">
<Code className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Server Version Management
</h2>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Version Check Configuration
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
Configure automatic version checking against your GitHub
repository to notify users of available updates.
</p>
<div className="space-y-4">
<fieldset>
<legend className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Repository Type
</legend>
<div className="space-y-2">
<div className="flex items-center">
<input
type="radio"
id={repoPublicId}
name="repositoryType"
value="public"
checked={formData.repositoryType === "public"}
onChange={(e) =>
handleInputChange("repositoryType", e.target.value)
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label
htmlFor={repoPublicId}
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
>
Public Repository (uses GitHub API - no authentication
required)
</label>
</div>
<div className="flex items-center">
<input
type="radio"
id={repoPrivateId}
name="repositoryType"
value="private"
checked={formData.repositoryType === "private"}
onChange={(e) =>
handleInputChange("repositoryType", e.target.value)
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label
htmlFor={repoPrivateId}
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
>
Private Repository (uses SSH with deploy key)
</label>
</div>
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Choose whether your repository is public or private to
determine the appropriate access method.
</p>
</fieldset>
<div>
<label
htmlFor={githubRepoUrlId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
GitHub Repository URL
</label>
<input
id={githubRepoUrlId}
type="text"
value={formData.githubRepoUrl || ""}
onChange={(e) =>
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"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
SSH or HTTPS URL to your GitHub repository
</p>
</div>
{formData.repositoryType === "private" && (
<div>
<div className="flex items-center gap-3 mb-3">
<input
type="checkbox"
id={useCustomSshKeyId}
checked={formData.useCustomSshKey}
onChange={(e) => {
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"
/>
<label
htmlFor={useCustomSshKeyId}
className="text-sm font-medium text-secondary-700 dark:text-secondary-200"
>
Set custom SSH key path
</label>
</div>
{formData.useCustomSshKey && (
<div>
<label
htmlFor={sshKeyPathId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
SSH Key Path
</label>
<input
id={sshKeyPathId}
type="text"
value={formData.sshKeyPath || ""}
onChange={(e) =>
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"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path to your SSH deploy key. If not set, will
auto-detect from common locations.
</p>
<div className="mt-3">
<button
type="button"
onClick={testSshKey}
disabled={
sshTestResult.testing ||
!formData.sshKeyPath ||
!formData.githubRepoUrl
}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{sshTestResult.testing
? "Testing..."
: "Test SSH Key"}
</button>
{sshTestResult.success && (
<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
<div className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
<p className="text-sm text-green-800 dark:text-green-200">
{sshTestResult.message}
</p>
</div>
</div>
)}
{sshTestResult.error && (
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="flex items-center">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mr-2" />
<p className="text-sm text-red-800 dark:text-red-200">
{sshTestResult.error}
</p>
</div>
</div>
)}
</div>
</div>
)}
{!formData.useCustomSshKey && (
<p className="text-xs text-secondary-500 dark:text-secondary-400">
Using auto-detection for SSH key location
</p>
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Current Version
</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.currentVersion}
</span>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Latest Version
</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.checking ? (
<span className="text-blue-600 dark:text-blue-400">
Checking...
</span>
) : versionInfo.latestVersion ? (
<span
className={
versionInfo.isUpdateAvailable
? "text-orange-600 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}
>
{versionInfo.latestVersion}
{versionInfo.isUpdateAvailable &&
" (Update Available!)"}
</span>
) : (
<span className="text-secondary-500 dark:text-secondary-400">
Not checked
</span>
)}
</span>
</div>
</div>
{/* Last Checked Time */}
{versionInfo.last_update_check && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Last Checked
</span>
</div>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{new Date(
versionInfo.last_update_check,
).toLocaleString()}
</span>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
Updates are checked automatically every 24 hours
</p>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={checkForUpdates}
disabled={versionInfo.checking}
className="btn-primary flex items-center gap-2"
>
<Download className="h-4 w-4" />
{versionInfo.checking
? "Checking..."
: "Check for Updates"}
</button>
</div>
{/* Save Button for Version Settings */}
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{versionInfo.error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Version Check Failed
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{versionInfo.error}
</p>
{versionInfo.error.includes("private") && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
For private repositories, you may need to
configure GitHub authentication or make the
repository public.
</p>
)}
</div>
</div>
</div>
)}
{/* Success Message for Version Settings */}
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Agent Version Modal */}
{showAgentVersionModal && (
<AgentVersionModal
isOpen={showAgentVersionModal}
onClose={() => {
setShowAgentVersionModal(false);
setAgentVersionForm({
version: "",
releaseNotes: "",
scriptContent: "",
isDefault: false,
});
}}
onSubmit={createAgentVersionMutation.mutate}
isLoading={createAgentVersionMutation.isPending}
/>
)}
</div>
);
};
// 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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Add Agent Version
</h3>
<button
type="button"
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4">
<div className="space-y-4">
<div>
<label
htmlFor={versionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Version *
</label>
<input
id={versionId}
type="text"
value={formData.version}
onChange={(e) =>
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 && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.version}
</p>
)}
</div>
<div>
<label
htmlFor={releaseNotesId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Release Notes
</label>
<textarea
id={releaseNotesId}
value={formData.releaseNotes}
onChange={(e) =>
setFormData((prev) => ({
...prev,
releaseNotes: e.target.value,
}))
}
rows={3}
className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
placeholder="Describe what's new in this version..."
/>
</div>
<div>
<label
htmlFor={scriptContentId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Script Content *
</label>
<div className="space-y-2">
<input
type="file"
accept=".sh"
onChange={handleFileUpload}
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
/>
<textarea
id={scriptContentId}
value={formData.scriptContent}
onChange={(e) =>
setFormData((prev) => ({
...prev,
scriptContent: e.target.value,
}))
}
rows={10}
className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm ${
errors.scriptContent
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
placeholder="Paste the agent script content here..."
/>
{errors.scriptContent && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.scriptContent}
</p>
)}
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
id={isDefaultId}
checked={formData.isDefault}
onChange={(e) =>
setFormData((prev) => ({
...prev,
isDefault: e.target.checked,
}))
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-600 rounded"
/>
<label
htmlFor={isDefaultId}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
>
Set as default version for new installations
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={onClose} className="btn-outline">
Cancel
</button>
<button type="submit" disabled={isLoading} className="btn-primary">
{isLoading ? "Creating..." : "Create Version"}
</button>
</div>
</form>
</div>
</div>
);
};
export default Settings;