import { useEffect, useState } from 'react' import StyledModal from './StyledModal' import StyledButton from './StyledButton' import Alert from './Alert' import api from '~/lib/api' import Input from './inputs/Input' import Select from './inputs/Select' import DynamicIcon, { DynamicIconName } from './DynamicIcon' import { IconTrash } from '@tabler/icons-react' interface PortMapping { container: string host: string } interface VolumeMapping { host_path: string container_path: string } interface EnvVar { value: string } export interface CustomAppInitial { service_name: string friendly_name: string | null image: string category: string icon: string ports: Array<{ container: number; host: number }> volumes: Array<{ host_path: string; container_path: string }> env: string[] memory_mb?: number cpus?: number } interface CustomAppModalProps { open: boolean onClose: () => void onCreated: (serviceName: string) => void showError: (msg: string) => void /** 'edit' reconfigures an existing custom app (prefilled from `initial`); defaults to 'create'. */ mode?: 'create' | 'edit' initial?: CustomAppInitial | null } const CATEGORY_OPTIONS = [ { value: 'custom', label: 'Custom' }, { value: 'productivity', label: 'Productivity' }, { value: 'media', label: 'Media' }, { value: 'security', label: 'Security' }, { value: 'networking', label: 'Networking' }, { value: 'utility', label: 'Utility' }, { value: 'ai', label: 'AI' }, { value: 'education', label: 'Education' }, ] // Curated subset of the DynamicIcon map suitable for custom apps. const ICON_OPTIONS = [ { value: 'IconBrandDocker', label: 'Docker (default)' }, { value: 'IconBox', label: 'Box' }, { value: 'IconServer', label: 'Server' }, { value: 'IconDatabase', label: 'Database' }, { value: 'IconCode', label: 'Code' }, { value: 'IconTool', label: 'Tool' }, { value: 'IconWorld', label: 'Web' }, { value: 'IconShieldLock', label: 'Security' }, { value: 'IconMovie', label: 'Media' }, { value: 'IconBook', label: 'Book' }, { value: 'IconNotes', label: 'Notes' }, { value: 'IconCpu', label: 'Compute' }, { value: 'IconRobot', label: 'AI / Bot' }, { value: 'IconWifi', label: 'Network' }, { value: 'IconHome', label: 'Home' }, ] export default function CustomAppModal({ open, onClose, onCreated, showError, mode = 'create', initial = null, }: CustomAppModalProps) { const isEdit = mode === 'edit' const [friendlyName, setFriendlyName] = useState('') const [image, setImage] = useState('') const [category, setCategory] = useState('custom') const [icon, setIcon] = useState('IconBrandDocker') const [ports, setPorts] = useState([{ container: '', host: '' }]) const [volumes, setVolumes] = useState([]) const [envVars, setEnvVars] = useState([]) const [memoryMb, setMemoryMb] = useState('') const [cpus, setCpus] = useState('') const [submitting, setSubmitting] = useState(false) const [portConflicts, setPortConflicts] = useState>([]) const [resourceWarnings, setResourceWarnings] = useState([]) const [blocked, setBlocked] = useState([]) const [forceInstall, setForceInstall] = useState(false) const [checkingPreflight, setCheckingPreflight] = useState(false) const [suggestedPort, setSuggestedPort] = useState(null) // On open: prefill from the existing app (edit) or fetch a suggested port (create). useEffect(() => { if (!open) return if (isEdit && initial) { setFriendlyName(initial.friendly_name ?? '') setImage(initial.image) setCategory(initial.category) setIcon(initial.icon || 'IconBrandDocker') setPorts( initial.ports.length ? initial.ports.map((p) => ({ container: String(p.container), host: String(p.host) })) : [{ container: '', host: '' }] ) setVolumes(initial.volumes) setEnvVars(initial.env.map((v) => ({ value: v }))) setMemoryMb(initial.memory_mb != null ? String(initial.memory_mb) : '') setCpus(initial.cpus != null ? String(initial.cpus) : '') return } api.suggestCustomPort().then((res) => { if (res?.port) { setSuggestedPort(res.port) setPorts([{ container: '', host: String(res.port) }]) } }) }, [open, isEdit, initial]) // Live preflight: whenever ports, volumes or the image change, debounce a check for port // conflicts, resource/guard warnings and hard blocks so the user gets feedback before submitting. useEffect(() => { if (!open) return const validPorts = ports .map((p) => parseInt(p.host, 10)) .filter((p) => !isNaN(p)) const validVolumes = volumes.filter((v) => v.host_path && v.container_path) if (validPorts.length === 0 && validVolumes.length === 0 && !image.trim()) { setPortConflicts([]) setResourceWarnings([]) setBlocked([]) setCheckingPreflight(false) return } setCheckingPreflight(true) const handle = setTimeout(async () => { const res = await api.preflightCustomApp({ image: image.trim() || undefined, ports: validPorts.length ? validPorts : undefined, volumes: validVolumes.length ? validVolumes : undefined, exclude_service: isEdit && initial ? initial.service_name : undefined, }) if (res) { setPortConflicts(res.portConflicts ?? []) setResourceWarnings(res.resourceWarnings ?? []) setBlocked(res.blocked ?? []) } setCheckingPreflight(false) }, 400) return () => clearTimeout(handle) }, [open, ports, volumes, image]) function resetForm() { setFriendlyName('') setImage('') setCategory('custom') setIcon('IconBrandDocker') setPorts([{ container: '', host: '' }]) setVolumes([]) setEnvVars([]) setMemoryMb('') setCpus('') setPortConflicts([]) setResourceWarnings([]) setBlocked([]) setForceInstall(false) setSuggestedPort(null) } function handleClose() { resetForm() onClose() } // ── Port row helpers ────────────────────────────────────────────────────── function updatePort(idx: number, field: keyof PortMapping, value: string) { setPorts((prev) => prev.map((p, i) => (i === idx ? { ...p, [field]: value } : p))) } function addPort() { const nextHost = suggestedPort ? suggestedPort + ports.length * 10 : 8600 + ports.length * 10 setPorts((prev) => [...prev, { container: '', host: String(nextHost) }]) } function removePort(idx: number) { setPorts((prev) => prev.filter((_, i) => i !== idx)) } // ── Volume row helpers ──────────────────────────────────────────────────── function updateVolume(idx: number, field: keyof VolumeMapping, value: string) { setVolumes((prev) => prev.map((v, i) => (i === idx ? { ...v, [field]: value } : v))) } function addVolume() { setVolumes((prev) => [...prev, { host_path: '', container_path: '' }]) } function removeVolume(idx: number) { setVolumes((prev) => prev.filter((_, i) => i !== idx)) } // ── Env var helpers ─────────────────────────────────────────────────────── function updateEnv(idx: number, value: string) { setEnvVars((prev) => prev.map((e, i) => (i === idx ? { value } : e))) } function addEnv() { setEnvVars((prev) => [...prev, { value: '' }]) } function removeEnv(idx: number) { setEnvVars((prev) => prev.filter((_, i) => i !== idx)) } // ── Submit ──────────────────────────────────────────────────────────────── async function handleSubmit() { if (!friendlyName.trim() || !image.trim()) { showError('Name and image are required.') return } if (blocked.length > 0) { showError('Resolve the blocked issues before installing.') return } const validPorts = ports .filter((p) => p.container && p.host) .map((p) => ({ container: parseInt(p.container, 10), host: parseInt(p.host, 10) })) .filter((p) => !isNaN(p.container) && !isNaN(p.host)) const validVolumes = volumes.filter((v) => v.host_path && v.container_path) const validEnv = envVars.map((e) => e.value).filter(Boolean) const parsedMemory = parseInt(memoryMb, 10) const parsedCpus = parseFloat(cpus) setSubmitting(true) try { const common = { friendly_name: friendlyName.trim(), image: image.trim(), ports: validPorts.length ? validPorts : undefined, volumes: validVolumes.length ? validVolumes : undefined, env: validEnv.length ? validEnv : undefined, category, icon, memory_mb: !isNaN(parsedMemory) ? parsedMemory : undefined, cpus: !isNaN(parsedCpus) ? parsedCpus : undefined, // The user has already acknowledged any conflicts via the "install anyway" checkbox. force: forceInstall, } const result = isEdit && initial ? await api.updateCustomApp({ service_name: initial.service_name, ...common }) : await api.createCustomApp(common) if (result?.success && result.service_name) { resetForm() onCreated(result.service_name) } else { // Check if it's a port conflict error — show warnings and let user force if (result?.message?.toLowerCase().includes('port') || result?.message?.toLowerCase().includes('conflict')) { showError(result.message) } else { showError(result?.message || 'Failed to create custom app.') } } } catch (err: any) { showError(err?.message || 'Unexpected error creating custom app.') } finally { setSubmitting(false) } } const hasWarnings = portConflicts.length > 0 || resourceWarnings.length > 0 const hasBlocks = blocked.length > 0 const canSubmit = friendlyName.trim() && image.trim() && !hasBlocks && (!hasWarnings || forceInstall) return (
{/* Image + Name */}
setImage(e.target.value)} required /> setFriendlyName(e.target.value)} required />
{/* Category + Icon */}
setIcon(newVal)} options={ICON_OPTIONS} className="flex-1 min-w-0" />
{/* Port Mappings */}
Add Port
{ports.length === 0 && (

No port mappings — the app won't be accessible from a browser.

)}
{ports.map((p, idx) => (
updatePort(idx, 'container', e.target.value)} className='w-full' /> updatePort(idx, 'host', e.target.value)} className='w-full' />
))}

Host ports should be in the 8600+ range. Custom apps get ports starting at {suggestedPort ?? 8600}.

{checkingPreflight && (

Checking port availability…

)}
{/* Volume Mappings */}
Add Volume
{volumes.length === 0 && (

No volumes — data won't persist across restarts.

)}
{volumes.map((v, idx) => (
updateVolume(idx, 'host_path', e.target.value)} className='w-full' /> : updateVolume(idx, 'container_path', e.target.value)} className='w-full' />
))}
{/* Environment Variables */}
Add Variable
{envVars.map((e, idx) => (
updateEnv(idx, ev.target.value)} className='w-full font-mono' />
))} {envVars.length === 0 && (

No environment variables provided.

)}
{/* Advanced: resource limits */}
setMemoryMb(e.target.value)} className='w-full' /> setCpus(e.target.value)} className='w-full' />

Caps prevent a runaway container from starving the host. Leave blank to use the defaults (1024 MB / 1 CPU).

{/* Hard blocks — must be resolved before installing */} {hasBlocks && (
{blocked.map((b, i) => ( ))}
)} {/* Warnings */} {hasWarnings && (
{portConflicts.map((c) => ( ))} {resourceWarnings.map((w, i) => ( ))}
)}

Containers are created with --restart=unless-stopped. Data is not persisted unless you add volume mounts above.

) }