1076 lines
41 KiB
TypeScript
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>
|
|
)
|
|
}
|