letsbe-hub/src/app/admin/settings/page.tsx

1076 lines
41 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useSettings, useUpdateSettings } from '@/hooks/use-settings'
import {
Settings,
Container,
Key,
Server,
Wrench,
Eye,
EyeOff,
Save,
RotateCcw,
Loader2,
AlertCircle,
CheckCircle,
RefreshCw,
Shield,
AlertTriangle,
X,
Mail,
Bell,
Send,
Database,
HardDrive,
} from 'lucide-react'
import { Switch } from '@/components/ui/switch'
interface SettingFieldProps {
settingKey: string
label: string
description?: string
placeholder?: string
type?: 'text' | 'password' | 'number'
value: string
encrypted?: boolean
maskedValue?: string
onChange: (key: string, value: string) => void
}
function SettingField({
settingKey,
label,
description,
placeholder,
type = 'text',
value,
encrypted,
maskedValue,
onChange,
}: SettingFieldProps) {
const [showPassword, setShowPassword] = useState(false)
const [localValue, setLocalValue] = useState(value)
const [isDirty, setIsDirty] = useState(false)
useEffect(() => {
if (!isDirty) {
setLocalValue(value)
}
}, [value, isDirty])
const handleChange = (newValue: string) => {
setLocalValue(newValue)
setIsDirty(true)
onChange(settingKey, newValue)
}
const isPassword = type === 'password' || encrypted
return (
<div className="space-y-2">
<Label htmlFor={settingKey} className="flex items-center gap-2 text-sm font-medium">
{label}
{encrypted && (
<span className="inline-flex items-center gap-1 text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full font-medium dark:bg-amber-900/30 dark:text-amber-400">
<Shield className="h-3 w-3" />
Encrypted
</span>
)}
{isDirty && (
<span className="inline-flex items-center gap-1 text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium dark:bg-blue-900/30 dark:text-blue-400">
Modified
</span>
)}
</Label>
<div className="relative group">
<Input
id={settingKey}
type={isPassword && !showPassword ? 'password' : 'text'}
placeholder={encrypted && maskedValue ? maskedValue : placeholder}
value={localValue}
onChange={(e) => handleChange(e.target.value)}
className={`h-10 bg-background/50 border-muted-foreground/20 focus:border-primary/50 focus:ring-2 focus:ring-primary/20 transition-all ${
isPassword ? 'pr-12' : ''
} ${isDirty ? 'border-blue-300 dark:border-blue-700' : ''}`}
/>
{isPassword && (
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-muted/80 rounded-md transition-colors"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
)}
</div>
{description && (
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
)}
</div>
)
}
interface SettingsSectionProps {
title: string
description: string
icon: React.ReactNode
iconBgColor: string
iconColor: string
gradientFrom?: string
children: React.ReactNode
}
function SettingsSection({
title,
description,
icon,
iconBgColor,
iconColor,
gradientFrom = 'from-card',
children
}: SettingsSectionProps) {
return (
<div className={`rounded-xl border bg-gradient-to-br ${gradientFrom} to-muted/20 overflow-hidden transition-all hover:shadow-md hover:border-muted-foreground/20`}>
<div className="p-5 md:p-6 border-b border-border/50 bg-gradient-to-r from-muted/30 to-transparent">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl ${iconBgColor} ring-1 ring-inset ring-black/5`}>
<div className={iconColor}>{icon}</div>
</div>
<div>
<h3 className="font-semibold text-lg">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
</div>
<div className="p-5 md:p-6 space-y-5">{children}</div>
</div>
)
}
export default function SettingsPage() {
const { data, isLoading, error, refetch, isFetching } = useSettings()
const updateMutation = useUpdateSettings()
const [formValues, setFormValues] = useState<Record<string, string>>({})
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const [testEmail, setTestEmail] = useState('')
const [testingEmail, setTestingEmail] = useState(false)
const [testEmailResult, setTestEmailResult] = useState<{ success: boolean; message: string } | null>(null)
const [testingStorage, setTestingStorage] = useState(false)
const [testStorageResult, setTestStorageResult] = useState<{ success: boolean; message: string } | null>(null)
// Initialize form values from fetched settings
useEffect(() => {
if (data?.settings) {
const initial: Record<string, string> = {}
for (const setting of data.settings) {
// For encrypted values, don't pre-fill (user must re-enter)
initial[setting.key] = setting.encrypted ? '' : setting.value
}
setFormValues(initial)
}
}, [data])
const handleChange = (key: string, value: string) => {
setFormValues((prev) => ({ ...prev, [key]: value }))
}
const handleSave = async () => {
setSaveMessage(null)
// Convert form values to settings array
const settingsToUpdate = Object.entries(formValues)
.filter(([, value]) => value !== '') // Only include non-empty values
.map(([key, value]) => ({ key, value }))
try {
await updateMutation.mutateAsync({ settings: settingsToUpdate })
setSaveMessage({ type: 'success', message: 'Settings saved successfully!' })
// Reset form values after successful save
setFormValues({})
setTimeout(() => setSaveMessage(null), 5000)
} catch (err) {
setSaveMessage({
type: 'error',
message: err instanceof Error ? err.message : 'Failed to save settings',
})
}
}
const handleReset = () => {
setFormValues({})
if (data?.settings) {
const initial: Record<string, string> = {}
for (const setting of data.settings) {
initial[setting.key] = setting.encrypted ? '' : setting.value
}
setFormValues(initial)
}
setSaveMessage(null)
}
const handleTestEmail = async () => {
setTestingEmail(true)
setTestEmailResult(null)
try {
const response = await fetch('/api/v1/admin/settings/email/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ testEmail: testEmail || undefined }),
})
const result = await response.json()
if (result.success) {
setTestEmailResult({
success: true,
message: result.emailSent
? `Connection successful! Test email sent to ${testEmail}`
: 'SMTP connection successful!',
})
} else {
setTestEmailResult({
success: false,
message: result.error || 'Connection test failed',
})
}
} catch {
setTestEmailResult({
success: false,
message: 'Failed to test email connection',
})
} finally {
setTestingEmail(false)
}
}
const handleTestStorage = async () => {
setTestingStorage(true)
setTestStorageResult(null)
try {
const response = await fetch('/api/v1/admin/settings/storage/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const result = await response.json()
setTestStorageResult({
success: result.success,
message: result.message || (result.success ? 'Connection successful!' : 'Connection failed'),
})
} catch {
setTestStorageResult({
success: false,
message: 'Failed to test storage connection',
})
} finally {
setTestingStorage(false)
}
}
const getSettingValue = (key: string) => formValues[key] ?? ''
const getSettingMasked = (key: string) => {
const setting = data?.settings.find((s) => s.key === key)
return setting?.maskedValue
}
const isEncrypted = (key: string) => {
const setting = data?.settings.find((s) => s.key === key)
return setting?.encrypted ?? false
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl animate-pulse" />
<div className="relative p-4 rounded-full bg-muted">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
</div>
<p className="text-sm text-muted-foreground">Loading settings...</p>
</div>
)
}
if (error) {
return (
<div className="rounded-xl border-2 border-dashed border-destructive/30 bg-destructive/5 py-12 text-center">
<div className="mx-auto w-fit p-4 rounded-full bg-destructive/10 mb-4">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<h3 className="font-semibold text-lg text-destructive">Failed to load settings</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-sm mx-auto">
There was an error loading the system settings. Please try again.
</p>
<Button onClick={() => refetch()} disabled={isFetching} variant="outline" className="mt-6 gap-2">
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Retrying...' : 'Retry'}
</Button>
</div>
)
}
const hasChanges = Object.values(formValues).some((v) => v !== '')
return (
<div className="space-y-8">
{/* Hero Header */}
<div className="relative overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30 p-6 md:p-8">
{/* Background decoration */}
<div className="absolute top-0 right-0 -mt-16 -mr-16 h-64 w-64 rounded-full bg-gradient-to-br from-primary/5 to-primary/10 blur-3xl" />
<div className="absolute bottom-0 left-0 -mb-16 -ml-16 h-48 w-48 rounded-full bg-gradient-to-tr from-violet-500/5 to-transparent blur-2xl" />
<div className="relative">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
{/* Title and description */}
<div className="flex items-center gap-4">
<div className="p-4 rounded-2xl bg-gradient-to-br from-violet-100 to-violet-50 border-2 border-violet-200 dark:from-violet-900/30 dark:to-violet-800/20 dark:border-violet-700">
<Settings className="h-8 w-8 text-violet-600 dark:text-violet-400" />
</div>
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">System Settings</h1>
<p className="text-muted-foreground mt-1">
Configure system-wide settings for provisioning and integrations
</p>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 shrink-0">
<Button
variant="outline"
onClick={handleReset}
disabled={!hasChanges}
className="gap-2 h-10 px-4 border-muted-foreground/20 hover:bg-muted/80"
>
<RotateCcw className="h-4 w-4" />
Reset
</Button>
<Button
onClick={handleSave}
disabled={!hasChanges || updateMutation.isPending}
className="gap-2 h-10 px-5 bg-gradient-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary shadow-lg shadow-primary/20"
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save Changes
</Button>
</div>
</div>
</div>
</div>
{/* Save Message Toast */}
{saveMessage && (
<div
className={`relative flex items-center gap-3 p-4 rounded-xl border shadow-lg transition-all animate-in slide-in-from-top-2 duration-300 ${
saveMessage.type === 'success'
? 'bg-gradient-to-r from-emerald-50 to-emerald-100/50 text-emerald-800 border-emerald-200 dark:from-emerald-900/30 dark:to-emerald-800/20 dark:text-emerald-300 dark:border-emerald-700'
: 'bg-gradient-to-r from-red-50 to-red-100/50 text-red-800 border-red-200 dark:from-red-900/30 dark:to-red-800/20 dark:text-red-300 dark:border-red-700'
}`}
>
<div className={`p-2 rounded-lg ${saveMessage.type === 'success' ? 'bg-emerald-100 dark:bg-emerald-900/50' : 'bg-red-100 dark:bg-red-900/50'}`}>
{saveMessage.type === 'success' ? (
<CheckCircle className="h-5 w-5" />
) : (
<AlertCircle className="h-5 w-5" />
)}
</div>
<span className="font-medium flex-1">{saveMessage.message}</span>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-black/5 dark:hover:bg-white/5"
onClick={() => setSaveMessage(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
{/* Docker Runner Section */}
<SettingsSection
title="Docker Runner"
description="Configure the Docker container that runs provisioning jobs"
icon={<Container className="h-5 w-5" />}
iconBgColor="bg-blue-100 dark:bg-blue-900/30"
iconColor="text-blue-600 dark:text-blue-400"
gradientFrom="from-card"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<SettingField
settingKey="docker.runner.registry"
label="Registry"
description="Container registry URL"
placeholder="code.letsbe.solutions"
value={getSettingValue('docker.runner.registry')}
onChange={handleChange}
/>
<SettingField
settingKey="docker.runner.image"
label="Image Name"
description="Docker image name"
placeholder="letsbe/ansible-runner"
value={getSettingValue('docker.runner.image')}
onChange={handleChange}
/>
<SettingField
settingKey="docker.runner.tag"
label="Image Tag"
description="Docker image tag/version"
placeholder="latest"
value={getSettingValue('docker.runner.tag')}
onChange={handleChange}
/>
<SettingField
settingKey="docker.max_concurrent"
label="Max Concurrent Jobs"
description="Maximum simultaneous provisioning containers"
placeholder="3"
type="number"
value={getSettingValue('docker.max_concurrent')}
onChange={handleChange}
/>
<SettingField
settingKey="docker.network_mode"
label="Network Mode"
description="Docker network mode (host or bridge)"
placeholder="host"
value={getSettingValue('docker.network_mode')}
onChange={handleChange}
/>
</div>
</SettingsSection>
{/* Docker Hub Section */}
<SettingsSection
title="Docker Hub"
description="Docker Hub credentials passed to target servers for pulling images"
icon={<Container className="h-5 w-5" />}
iconBgColor="bg-sky-100 dark:bg-sky-900/30"
iconColor="text-sky-600 dark:text-sky-400"
gradientFrom="from-card"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<SettingField
settingKey="dockerhub.username"
label="Username"
description="Docker Hub account username"
placeholder="your-dockerhub-username"
value={getSettingValue('dockerhub.username')}
onChange={handleChange}
/>
<SettingField
settingKey="dockerhub.token"
label="Access Token"
description="Docker Hub access token (encrypted)"
placeholder="Enter new token to update"
type="password"
encrypted={isEncrypted('dockerhub.token')}
maskedValue={getSettingMasked('dockerhub.token')}
value={getSettingValue('dockerhub.token')}
onChange={handleChange}
/>
<SettingField
settingKey="dockerhub.registry"
label="Custom Registry"
description="Optional custom registry URL (leave empty for hub.docker.com)"
placeholder="hub.docker.com"
value={getSettingValue('dockerhub.registry')}
onChange={handleChange}
/>
</div>
</SettingsSection>
{/* Gitea Registry Section */}
<SettingsSection
title="Gitea Registry"
description="Gitea container registry for LetsBe private images"
icon={<Key className="h-5 w-5" />}
iconBgColor="bg-amber-100 dark:bg-amber-900/30"
iconColor="text-amber-600 dark:text-amber-400"
gradientFrom="from-card"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<SettingField
settingKey="gitea.registry"
label="Registry URL"
description="Gitea registry URL"
placeholder="code.letsbe.solutions"
value={getSettingValue('gitea.registry')}
onChange={handleChange}
/>
<SettingField
settingKey="gitea.username"
label="Username"
description="Gitea registry username"
placeholder="your-gitea-username"
value={getSettingValue('gitea.username')}
onChange={handleChange}
/>
<SettingField
settingKey="gitea.token"
label="Access Token"
description="Gitea registry token (encrypted)"
placeholder="Enter new token to update"
type="password"
encrypted={isEncrypted('gitea.token')}
maskedValue={getSettingMasked('gitea.token')}
value={getSettingValue('gitea.token')}
onChange={handleChange}
/>
</div>
</SettingsSection>
{/* Hub Configuration Section */}
<SettingsSection
title="Hub Configuration"
description="Configuration for this Hub instance"
icon={<Server className="h-5 w-5" />}
iconBgColor="bg-purple-100 dark:bg-purple-900/30"
iconColor="text-purple-600 dark:text-purple-400"
gradientFrom="from-card"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<SettingField
settingKey="hub.url"
label="Public Hub URL"
description="URL where provisioned servers can reach this Hub"
placeholder="https://hub.letsbe.solutions"
value={getSettingValue('hub.url')}
onChange={handleChange}
/>
<SettingField
settingKey="hub.encryption_key"
label="Encryption Key"
description="Master encryption key for sensitive data (change with caution!)"
placeholder="Enter new key to update"
type="password"
encrypted={isEncrypted('hub.encryption_key')}
maskedValue={getSettingMasked('hub.encryption_key')}
value={getSettingValue('hub.encryption_key')}
onChange={handleChange}
/>
</div>
{/* Warning Box */}
<div className="mt-6 rounded-xl border-2 border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 p-5 dark:border-amber-800 dark:from-amber-900/20 dark:to-orange-900/20">
<div className="flex gap-4">
<div className="shrink-0">
<div className="p-2.5 rounded-xl bg-amber-100 dark:bg-amber-900/50">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
</div>
<div>
<h4 className="font-semibold text-amber-800 dark:text-amber-300">Important Warning</h4>
<p className="text-sm text-amber-700 dark:text-amber-400/90 mt-1 leading-relaxed">
Changing the encryption key will prevent decryption of previously encrypted values.
Only change this if you understand the implications and have backed up your data.
</p>
</div>
</div>
</div>
</SettingsSection>
{/* Provisioning Defaults Section */}
<SettingsSection
title="Provisioning Defaults"
description="Default values for new provisioning jobs"
icon={<Wrench className="h-5 w-5" />}
iconBgColor="bg-emerald-100 dark:bg-emerald-900/30"
iconColor="text-emerald-600 dark:text-emerald-400"
gradientFrom="from-card"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<SettingField
settingKey="provisioning.default_ssh_port"
label="Default SSH Port"
description="Default SSH port for new servers"
placeholder="22"
type="number"
value={getSettingValue('provisioning.default_ssh_port')}
onChange={handleChange}
/>
<SettingField
settingKey="provisioning.default_tools"
label="Default Tools"
description="Default tools as JSON array"
placeholder='["orchestrator", "sysadmin-agent"]'
value={getSettingValue('provisioning.default_tools')}
onChange={handleChange}
/>
</div>
</SettingsSection>
{/* Email Configuration Section */}
<SettingsSection
title="Email Configuration"
description="SMTP settings for sending system emails and notifications"
icon={<Mail className="h-5 w-5" />}
iconBgColor="bg-pink-100 dark:bg-pink-900/30"
iconColor="text-pink-600 dark:text-pink-400"
gradientFrom="from-card"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<SettingField
settingKey="email.smtp.host"
label="SMTP Host"
description="SMTP server hostname"
placeholder="smtp.example.com"
value={getSettingValue('email.smtp.host')}
onChange={handleChange}
/>
<SettingField
settingKey="email.smtp.port"
label="SMTP Port"
description="SMTP server port (587 for TLS, 465 for SSL)"
placeholder="587"
type="number"
value={getSettingValue('email.smtp.port')}
onChange={handleChange}
/>
<SettingField
settingKey="email.smtp.username"
label="Username"
description="SMTP authentication username"
placeholder="your-smtp-username"
value={getSettingValue('email.smtp.username')}
onChange={handleChange}
/>
<SettingField
settingKey="email.smtp.password"
label="Password"
description="SMTP authentication password (encrypted)"
placeholder="Enter password to update"
type="password"
encrypted={isEncrypted('email.smtp.password')}
maskedValue={getSettingMasked('email.smtp.password')}
value={getSettingValue('email.smtp.password')}
onChange={handleChange}
/>
<SettingField
settingKey="email.from.address"
label="From Address"
description="Sender email address"
placeholder="noreply@example.com"
value={getSettingValue('email.from.address')}
onChange={handleChange}
/>
<SettingField
settingKey="email.from.name"
label="From Name"
description="Sender display name"
placeholder="LetsBe Hub"
value={getSettingValue('email.from.name')}
onChange={handleChange}
/>
</div>
{/* TLS Toggle */}
<div className="flex items-center justify-between py-3 px-4 rounded-lg bg-muted/30 border mt-4">
<div>
<Label className="text-sm font-medium">Use TLS/SSL</Label>
<p className="text-xs text-muted-foreground">Enable secure connection (recommended)</p>
</div>
<Switch
checked={getSettingValue('email.smtp.secure') === 'true' || getSettingValue('email.smtp.secure') === ''}
onCheckedChange={(checked) => handleChange('email.smtp.secure', checked ? 'true' : 'false')}
/>
</div>
{/* Test Connection */}
<div className="mt-6 p-5 rounded-xl border bg-gradient-to-r from-muted/30 to-transparent">
<h4 className="font-medium mb-3 flex items-center gap-2">
<Send className="h-4 w-4" />
Test Email Connection
</h4>
<div className="flex flex-col sm:flex-row gap-3">
<Input
placeholder="test@example.com (optional)"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
className="flex-1"
/>
<Button
onClick={handleTestEmail}
disabled={testingEmail}
variant="outline"
className="gap-2 shrink-0"
>
{testingEmail ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Mail className="h-4 w-4" />
)}
{testingEmail ? 'Testing...' : 'Test Connection'}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Save your changes first, then test. Leave email empty to just test connection, or enter an email to send a test message.
</p>
{testEmailResult && (
<div
className={`mt-3 p-3 rounded-lg flex items-center gap-2 text-sm ${
testEmailResult.success
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
}`}
>
{testEmailResult.success ? (
<CheckCircle className="h-4 w-4 shrink-0" />
) : (
<AlertCircle className="h-4 w-4 shrink-0" />
)}
{testEmailResult.message}
</div>
)}
</div>
</SettingsSection>
{/* Notifications Section */}
<SettingsSection
title="Notifications"
description="Configure system alerts and email notifications"
icon={<Bell className="h-5 w-5" />}
iconBgColor="bg-orange-100 dark:bg-orange-900/30"
iconColor="text-orange-600 dark:text-orange-400"
gradientFrom="from-card"
>
{/* Master Toggle */}
<div className="flex items-center justify-between py-4 px-5 rounded-xl bg-gradient-to-r from-primary/5 to-primary/10 border border-primary/20">
<div>
<Label className="text-base font-semibold">Enable Notifications</Label>
<p className="text-sm text-muted-foreground">Master switch for all email notifications</p>
</div>
<Switch
checked={getSettingValue('notifications.enabled') === 'true'}
onCheckedChange={(checked) => handleChange('notifications.enabled', checked ? 'true' : 'false')}
/>
</div>
{/* Recipients */}
<div className="mt-5 space-y-2">
<Label className="text-sm font-medium">Notification Recipients</Label>
<Input
placeholder='["admin@example.com", "team@example.com"]'
value={getSettingValue('notifications.recipients')}
onChange={(e) => handleChange('notifications.recipients', e.target.value)}
/>
<p className="text-xs text-muted-foreground">
JSON array of email addresses to receive notifications
</p>
</div>
{/* Cooldown */}
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-5">
<SettingField
settingKey="notifications.cooldown.minutes"
label="Cooldown Period (minutes)"
description="Minimum time between repeated notifications"
placeholder="15"
type="number"
value={getSettingValue('notifications.cooldown.minutes')}
onChange={handleChange}
/>
</div>
{/* Container Alerts */}
<div className="mt-6">
<h4 className="font-medium mb-3 flex items-center gap-2 text-sm">
<Container className="h-4 w-4 text-blue-500" />
Container Alerts
</h4>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-4 rounded-lg bg-muted/30 border">
<div>
<Label className="text-sm">Container Crashes</Label>
<p className="text-xs text-muted-foreground">Alert when containers crash unexpectedly</p>
</div>
<Switch
checked={getSettingValue('notifications.container.crashes.enabled') !== 'false'}
onCheckedChange={(checked) => handleChange('notifications.container.crashes.enabled', checked ? 'true' : 'false')}
/>
</div>
<div className="flex items-center justify-between py-2 px-4 rounded-lg bg-muted/30 border">
<div>
<Label className="text-sm">OOM Kills</Label>
<p className="text-xs text-muted-foreground">Alert when containers are killed due to memory limits</p>
</div>
<Switch
checked={getSettingValue('notifications.container.oom.enabled') !== 'false'}
onCheckedChange={(checked) => handleChange('notifications.container.oom.enabled', checked ? 'true' : 'false')}
/>
</div>
<div className="flex items-center justify-between py-2 px-4 rounded-lg bg-muted/30 border">
<div>
<Label className="text-sm">Unexpected Restarts</Label>
<p className="text-xs text-muted-foreground">Alert when containers restart unexpectedly</p>
</div>
<Switch
checked={getSettingValue('notifications.container.restarts.enabled') !== 'false'}
onCheckedChange={(checked) => handleChange('notifications.container.restarts.enabled', checked ? 'true' : 'false')}
/>
</div>
</div>
</div>
{/* Error Alerts */}
<div className="mt-6">
<h4 className="font-medium mb-3 flex items-center gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-red-500" />
Error Alerts
</h4>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-4 rounded-lg bg-muted/30 border">
<div>
<Label className="text-sm">Critical Errors</Label>
<p className="text-xs text-muted-foreground">Alert on critical-level errors detected in logs</p>
</div>
<Switch
checked={getSettingValue('notifications.errors.critical.enabled') !== 'false'}
onCheckedChange={(checked) => handleChange('notifications.errors.critical.enabled', checked ? 'true' : 'false')}
/>
</div>
<div className="flex items-center justify-between py-2 px-4 rounded-lg bg-muted/30 border">
<div>
<Label className="text-sm">Error-Level Errors</Label>
<p className="text-xs text-muted-foreground">Alert on error-level messages (higher volume)</p>
</div>
<Switch
checked={getSettingValue('notifications.errors.error.enabled') === 'true'}
onCheckedChange={(checked) => handleChange('notifications.errors.error.enabled', checked ? 'true' : 'false')}
/>
</div>
</div>
</div>
{/* Server Stats Alerts */}
<div className="mt-6">
<h4 className="font-medium mb-3 flex items-center gap-2 text-sm">
<Server className="h-4 w-4 text-purple-500" />
Server Stats Alerts
</h4>
<div className="space-y-4">
{/* CPU */}
<div className="py-3 px-4 rounded-lg bg-muted/30 border">
<div className="flex items-center justify-between mb-2">
<div>
<Label className="text-sm">CPU Usage Alert</Label>
<p className="text-xs text-muted-foreground">Alert when CPU exceeds threshold</p>
</div>
<Switch
checked={getSettingValue('notifications.stats.cpu.enabled') !== 'false'}
onCheckedChange={(checked) => handleChange('notifications.stats.cpu.enabled', checked ? 'true' : 'false')}
/>
</div>
<div className="flex items-center gap-3">
<Label className="text-xs text-muted-foreground shrink-0">Threshold:</Label>
<Input
type="number"
min="0"
max="100"
placeholder="90"
value={getSettingValue('notifications.stats.cpu.threshold')}
onChange={(e) => handleChange('notifications.stats.cpu.threshold', e.target.value)}
className="w-20 h-8 text-sm"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
{/* Memory */}
<div className="py-3 px-4 rounded-lg bg-muted/30 border">
<div className="flex items-center justify-between mb-2">
<div>
<Label className="text-sm">Memory Usage Alert</Label>
<p className="text-xs text-muted-foreground">Alert when memory exceeds threshold</p>
</div>
<Switch
checked={getSettingValue('notifications.stats.memory.enabled') !== 'false'}
onCheckedChange={(checked) => handleChange('notifications.stats.memory.enabled', checked ? 'true' : 'false')}
/>
</div>
<div className="flex items-center gap-3">
<Label className="text-xs text-muted-foreground shrink-0">Threshold:</Label>
<Input
type="number"
min="0"
max="100"
placeholder="90"
value={getSettingValue('notifications.stats.memory.threshold')}
onChange={(e) => handleChange('notifications.stats.memory.threshold', e.target.value)}
className="w-20 h-8 text-sm"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
{/* Disk */}
<div className="py-3 px-4 rounded-lg bg-muted/30 border">
<div className="flex items-center justify-between mb-2">
<div>
<Label className="text-sm">Disk Usage Alert</Label>
<p className="text-xs text-muted-foreground">Alert when disk usage exceeds threshold</p>
</div>
<Switch
checked={getSettingValue('notifications.stats.disk.enabled') !== 'false'}
onCheckedChange={(checked) => handleChange('notifications.stats.disk.enabled', checked ? 'true' : 'false')}
/>
</div>
<div className="flex items-center gap-3">
<Label className="text-xs text-muted-foreground shrink-0">Threshold:</Label>
<Input
type="number"
min="0"
max="100"
placeholder="85"
value={getSettingValue('notifications.stats.disk.threshold')}
onChange={(e) => handleChange('notifications.stats.disk.threshold', e.target.value)}
className="w-20 h-8 text-sm"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
</div>
</div>
</SettingsSection>
{/* Object Storage Section */}
<SettingsSection
title="Object Storage"
description="S3-compatible storage configuration (MinIO, AWS S3, etc.) for photos and files"
icon={<HardDrive className="h-5 w-5" />}
iconBgColor="bg-cyan-100 dark:bg-cyan-900/30"
iconColor="text-cyan-600 dark:text-cyan-400"
gradientFrom="from-card"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<SettingField
settingKey="storage.endpoint"
label="Endpoint URL"
description="S3/MinIO server endpoint (without protocol)"
placeholder="minio.example.com:9000"
value={getSettingValue('storage.endpoint')}
onChange={handleChange}
/>
<SettingField
settingKey="storage.bucket"
label="Bucket Name"
description="Storage bucket for files"
placeholder="letsbe-hub"
value={getSettingValue('storage.bucket')}
onChange={handleChange}
/>
<SettingField
settingKey="storage.access_key"
label="Access Key ID"
description="S3/MinIO access key"
placeholder="your-access-key"
value={getSettingValue('storage.access_key')}
onChange={handleChange}
/>
<SettingField
settingKey="storage.secret_key"
label="Secret Access Key"
description="S3/MinIO secret key (encrypted)"
placeholder="Enter secret key to update"
type="password"
encrypted={isEncrypted('storage.secret_key')}
maskedValue={getSettingMasked('storage.secret_key')}
value={getSettingValue('storage.secret_key')}
onChange={handleChange}
/>
<SettingField
settingKey="storage.region"
label="Region"
description="S3 region (use 'us-east-1' for MinIO)"
placeholder="us-east-1"
value={getSettingValue('storage.region')}
onChange={handleChange}
/>
<SettingField
settingKey="storage.public_url"
label="Public URL"
description="Public URL base for accessing files (optional)"
placeholder="https://cdn.example.com"
value={getSettingValue('storage.public_url')}
onChange={handleChange}
/>
</div>
{/* SSL Toggle */}
<div className="flex items-center justify-between py-3 px-4 rounded-lg bg-muted/30 border mt-4">
<div>
<Label className="text-sm font-medium">Use SSL/TLS</Label>
<p className="text-xs text-muted-foreground">Enable secure connection to storage server</p>
</div>
<Switch
checked={getSettingValue('storage.use_ssl') !== 'false'}
onCheckedChange={(checked) => handleChange('storage.use_ssl', checked ? 'true' : 'false')}
/>
</div>
{/* Test Connection */}
<div className="mt-6 p-5 rounded-xl border bg-gradient-to-r from-muted/30 to-transparent">
<h4 className="font-medium mb-3 flex items-center gap-2">
<Database className="h-4 w-4" />
Test Storage Connection
</h4>
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={handleTestStorage}
disabled={testingStorage}
variant="outline"
className="gap-2"
>
{testingStorage ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<HardDrive className="h-4 w-4" />
)}
{testingStorage ? 'Testing...' : 'Test Connection'}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
Save your changes first, then test. This will verify connection to the bucket.
</p>
{testStorageResult && (
<div
className={`mt-3 p-3 rounded-lg flex items-center gap-2 text-sm ${
testStorageResult.success
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'
}`}
>
{testStorageResult.success ? (
<CheckCircle className="h-4 w-4 shrink-0" />
) : (
<AlertCircle className="h-4 w-4 shrink-0" />
)}
{testStorageResult.message}
</div>
)}
</div>
</SettingsSection>
</div>
)
}