mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-10 17:05:48 +00:00
Blame my ADHD brain Sorry - Now we have the server working properly in automation using BullMQ and Redis - It also presents an API endpoint that is used to accept connections for websockets by agents (WS or WSS) - Updated the docker-compose.yml and its documentation
501 lines
16 KiB
JavaScript
501 lines
16 KiB
JavaScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { AlertCircle, CheckCircle, Save, Shield } from "lucide-react";
|
||
import { useEffect, useId, useState } from "react";
|
||
import { permissionsAPI, settingsAPI } from "../../utils/api";
|
||
|
||
const AgentUpdatesTab = () => {
|
||
const updateIntervalId = useId();
|
||
const autoUpdateId = useId();
|
||
const signupEnabledId = useId();
|
||
const defaultRoleId = useId();
|
||
const ignoreSslId = useId();
|
||
const [formData, setFormData] = useState({
|
||
updateInterval: 60,
|
||
autoUpdate: false,
|
||
signupEnabled: false,
|
||
defaultUserRole: "user",
|
||
ignoreSslSelfSigned: false,
|
||
});
|
||
const [errors, setErrors] = useState({});
|
||
const [isDirty, setIsDirty] = useState(false);
|
||
|
||
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 = {
|
||
updateInterval: settings.update_interval || 60,
|
||
autoUpdate: settings.auto_update || false,
|
||
signupEnabled: settings.signup_enabled === true,
|
||
defaultUserRole: settings.default_user_role || "user",
|
||
ignoreSslSelfSigned: settings.ignore_ssl_self_signed === true,
|
||
};
|
||
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",
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
// Normalize update interval to safe presets
|
||
const normalizeInterval = (minutes) => {
|
||
let m = parseInt(minutes, 10);
|
||
if (Number.isNaN(m)) return 60;
|
||
if (m < 5) m = 5;
|
||
if (m > 1440) m = 1440;
|
||
// If less than 60 minutes, keep within 5-59 and step of 5
|
||
if (m < 60) {
|
||
return Math.min(59, Math.max(5, Math.round(m / 5) * 5));
|
||
}
|
||
// 60 or more: only allow exact hour multiples (60, 120, 180, 360, 720, 1440)
|
||
const allowed = [60, 120, 180, 360, 720, 1440];
|
||
// Snap to nearest allowed value
|
||
let nearest = allowed[0];
|
||
let bestDiff = Math.abs(m - nearest);
|
||
for (const a of allowed) {
|
||
const d = Math.abs(m - a);
|
||
if (d < bestDiff) {
|
||
bestDiff = d;
|
||
nearest = a;
|
||
}
|
||
}
|
||
return nearest;
|
||
};
|
||
|
||
const handleInputChange = (field, value) => {
|
||
setFormData((prev) => {
|
||
const newData = {
|
||
...prev,
|
||
[field]: field === "updateInterval" ? normalizeInterval(value) : value,
|
||
};
|
||
return newData;
|
||
});
|
||
setIsDirty(true);
|
||
if (errors[field]) {
|
||
setErrors((prev) => ({ ...prev, [field]: null }));
|
||
}
|
||
};
|
||
|
||
const validateForm = () => {
|
||
const newErrors = {};
|
||
|
||
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()) {
|
||
updateSettingsMutation.mutate(formData);
|
||
}
|
||
};
|
||
|
||
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="space-y-6">
|
||
{errors.general && (
|
||
<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">
|
||
<p className="text-sm text-red-700 dark:text-red-300">
|
||
{errors.general}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<form className="space-y-6">
|
||
{/* 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">
|
||
{[5, 10, 15, 30, 45, 60, 120, 180, 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) => {
|
||
const raw = parseInt(e.target.value, 10);
|
||
handleInputChange("updateInterval", normalizeInterval(raw));
|
||
}}
|
||
className="w-auto accent-primary-600"
|
||
aria-label="Update interval slider"
|
||
style={{ width: "fit-content", minWidth: "500px" }}
|
||
/>
|
||
</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
|
||
id={autoUpdateId}
|
||
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"
|
||
/>
|
||
<label htmlFor={autoUpdateId}>
|
||
Enable Automatic Agent Updates
|
||
</label>
|
||
</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>
|
||
|
||
{/* SSL Certificate 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
|
||
id={ignoreSslId}
|
||
type="checkbox"
|
||
checked={formData.ignoreSslSelfSigned}
|
||
onChange={(e) =>
|
||
handleInputChange("ignoreSslSelfSigned", 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"
|
||
/>
|
||
<label htmlFor={ignoreSslId}>
|
||
Ignore SSL Self-Signed Certificates
|
||
</label>
|
||
</div>
|
||
</label>
|
||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||
When enabled, curl commands in agent scripts will use the -k flag to
|
||
ignore SSL certificate validation errors. Use with caution on
|
||
production systems as this reduces security.
|
||
</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
|
||
id={signupEnabledId}
|
||
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"
|
||
/>
|
||
<label htmlFor={signupEnabledId}>
|
||
Enable User Self-Registration
|
||
</label>
|
||
</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">
|
||
When enabling user self-registration, exercise caution on
|
||
internal networks. Consider restricting access to trusted
|
||
networks only and ensure proper role assignments to prevent
|
||
unauthorized access to sensitive systems.
|
||
</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>
|
||
|
||
{/* Uninstall Instructions */}
|
||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||
<div className="flex">
|
||
<Shield 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">
|
||
Agent Uninstall Command
|
||
</h3>
|
||
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
|
||
<p className="mb-3">To completely remove PatchMon from a host:</p>
|
||
|
||
{/* Go Agent Uninstall */}
|
||
<div className="mb-3">
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
|
||
sudo patchmon-agent uninstall
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(
|
||
"sudo patchmon-agent uninstall",
|
||
);
|
||
}}
|
||
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
|
||
>
|
||
Copy
|
||
</button>
|
||
</div>
|
||
<div className="text-xs text-red-600 dark:text-red-400">
|
||
Options: <code>--remove-config</code>,{" "}
|
||
<code>--remove-logs</code>, <code>--remove-all</code>,{" "}
|
||
<code>--force</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<p className="mt-2 text-xs">
|
||
⚠️ This command will remove all PatchMon files, configuration,
|
||
and crontab entries
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AgentUpdatesTab;
|