541 lines
20 KiB
TypeScript
541 lines
20 KiB
TypeScript
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<PortMapping[]>([{ container: '', host: '' }])
|
|
const [volumes, setVolumes] = useState<VolumeMapping[]>([])
|
|
const [envVars, setEnvVars] = useState<EnvVar[]>([])
|
|
const [memoryMb, setMemoryMb] = useState('')
|
|
const [cpus, setCpus] = useState('')
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [portConflicts, setPortConflicts] = useState<Array<{ port: number; usedBy: string }>>([])
|
|
const [resourceWarnings, setResourceWarnings] = useState<string[]>([])
|
|
const [blocked, setBlocked] = useState<string[]>([])
|
|
const [forceInstall, setForceInstall] = useState(false)
|
|
const [checkingPreflight, setCheckingPreflight] = useState(false)
|
|
const [suggestedPort, setSuggestedPort] = useState<number | null>(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 (
|
|
<StyledModal
|
|
title={isEdit ? 'Edit App' : 'Add Custom App'}
|
|
open={open}
|
|
onCancel={handleClose}
|
|
cancelText="Cancel"
|
|
onConfirm={handleSubmit}
|
|
confirmVariant='primary'
|
|
confirmText={isEdit ? 'Save & Recreate' : 'Install'}
|
|
confirmIcon="IconBrandDocker"
|
|
confirmLoading={submitting}
|
|
confirmDisabled={!canSubmit}
|
|
large
|
|
>
|
|
<div className="space-y-6 text-sm">
|
|
{/* Image + Name */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input
|
|
name='image'
|
|
label="Docker Image"
|
|
placeholder="e.g. nginx:latest"
|
|
value={image}
|
|
onChange={(e) => setImage(e.target.value)}
|
|
required
|
|
/>
|
|
<Input
|
|
name='friendlyName'
|
|
label="Display Name"
|
|
placeholder="My App"
|
|
value={friendlyName}
|
|
onChange={(e) => setFriendlyName(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Category + Icon */}
|
|
<div className="grid grid-cols-2 gap-4 items-start">
|
|
<Select
|
|
name='category'
|
|
label='Category'
|
|
helpText='Select the most relevant category for this app. This helps with visual organization and filtering.'
|
|
value={category}
|
|
onChange={(newVal) => setCategory(newVal)}
|
|
options={CATEGORY_OPTIONS}
|
|
/>
|
|
<div className="flex items-end gap-2">
|
|
<Select
|
|
name='icon'
|
|
label='Icon'
|
|
helpText='Pick an icon shown on the app card.'
|
|
value={icon}
|
|
onChange={(newVal) => setIcon(newVal)}
|
|
options={ICON_OPTIONS}
|
|
className="flex-1 min-w-0"
|
|
/>
|
|
<div
|
|
className="flex-shrink-0 flex items-center justify-center h-[42px] w-[42px] rounded-md border border-border-default bg-surface-secondary"
|
|
title="Icon preview"
|
|
>
|
|
<DynamicIcon icon={icon as DynamicIconName} className="h-6 w-6 text-desert-green" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Port Mappings */}
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium">Port Mappings</label>
|
|
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addPort}>Add Port</StyledButton>
|
|
</div>
|
|
{ports.length === 0 && (
|
|
<p className="text-xs italic">No port mappings — the app won't be accessible from a browser.</p>
|
|
)}
|
|
<div className="space-y-2">
|
|
{ports.map((p, idx) => (
|
|
<div key={idx} className="flex items-center gap-2 w-full">
|
|
<Input
|
|
name={`containerPort${idx}`}
|
|
label=''
|
|
type="number"
|
|
placeholder="Container port"
|
|
value={p.container}
|
|
onChange={(e) => updatePort(idx, 'container', e.target.value)}
|
|
className='w-full'
|
|
/>
|
|
<span className="text-xs">→</span>
|
|
<Input
|
|
name={`hostPort${idx}`}
|
|
label=''
|
|
type="number"
|
|
placeholder="Host port (8600+)"
|
|
value={p.host}
|
|
onChange={(e) => updatePort(idx, 'host', e.target.value)}
|
|
className='w-full'
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removePort(idx)}
|
|
className="hover:text-desert-red transition-colors cursor-pointer"
|
|
>
|
|
<IconTrash className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs mt-2">Host ports should be in the 8600+ range. Custom apps get ports starting at {suggestedPort ?? 8600}.</p>
|
|
{checkingPreflight && (
|
|
<p className="text-xs mt-1 italic text-text-muted">Checking port availability…</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Volume Mappings */}
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium">Volume Mounts</label>
|
|
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addVolume}>Add Volume</StyledButton>
|
|
</div>
|
|
{volumes.length === 0 && (
|
|
<p className="text-xs italic">No volumes — data won't persist across restarts.</p>
|
|
)}
|
|
<div className="space-y-2">
|
|
{volumes.map((v, idx) => (
|
|
<div key={idx} className="flex items-center gap-2">
|
|
<Input
|
|
name={`hostPath${idx}`}
|
|
label=''
|
|
type="text"
|
|
placeholder="Host path (absolute)"
|
|
value={v.host_path}
|
|
onChange={(e) => updateVolume(idx, 'host_path', e.target.value)}
|
|
className='w-full'
|
|
/>
|
|
<span className="text-xs">:</span>
|
|
<Input
|
|
name={`containerPath${idx}`}
|
|
label=''
|
|
type="text"
|
|
placeholder="Container path"
|
|
value={v.container_path}
|
|
onChange={(e) => updateVolume(idx, 'container_path', e.target.value)}
|
|
className='w-full'
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeVolume(idx)}
|
|
className="hover:text-desert-red transition-colors cursor-pointer"
|
|
>
|
|
<IconTrash className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Environment Variables */}
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium">Environment Variables</label>
|
|
<StyledButton size="sm" variant="ghost" icon="IconPlus" onClick={addEnv}>Add Variable</StyledButton>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{envVars.map((e, idx) => (
|
|
<div key={idx} className="flex items-center gap-2">
|
|
<Input
|
|
name={`envVar${idx}`}
|
|
label=''
|
|
placeholder="KEY=value"
|
|
value={e.value}
|
|
onChange={(ev) => updateEnv(idx, ev.target.value)}
|
|
className='w-full font-mono'
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeEnv(idx)}
|
|
className="hover:text-desert-red transition-colors cursor-pointer"
|
|
>
|
|
<IconTrash className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
{envVars.length === 0 && (
|
|
<p className="text-xs italic">No environment variables provided.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Advanced: resource limits */}
|
|
<div>
|
|
<label className="text-sm font-medium">Resource Limits (optional)</label>
|
|
<div className="grid grid-cols-2 gap-4 mt-1">
|
|
<Input
|
|
name='memoryMb'
|
|
label=''
|
|
type="number"
|
|
placeholder="Memory (MB) — default 1024"
|
|
value={memoryMb}
|
|
onChange={(e) => setMemoryMb(e.target.value)}
|
|
className='w-full'
|
|
/>
|
|
<Input
|
|
name='cpus'
|
|
label=''
|
|
type="number"
|
|
placeholder="CPUs — default 1"
|
|
value={cpus}
|
|
onChange={(e) => setCpus(e.target.value)}
|
|
className='w-full'
|
|
/>
|
|
</div>
|
|
<p className="text-xs mt-1 italic">Caps prevent a runaway container from starving the host. Leave blank to use the defaults (1024 MB / 1 CPU).</p>
|
|
</div>
|
|
|
|
{/* Hard blocks — must be resolved before installing */}
|
|
{hasBlocks && (
|
|
<div className="space-y-2">
|
|
{blocked.map((b, i) => (
|
|
<Alert key={i} type="error" title="Not allowed" message={b} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Warnings */}
|
|
{hasWarnings && (
|
|
<div className="space-y-2">
|
|
{portConflicts.map((c) => (
|
|
<Alert
|
|
key={c.port}
|
|
type="warning"
|
|
title={`Port ${c.port} is already in use`}
|
|
message={`Currently bound by: ${c.usedBy}. Installation may fail.`}
|
|
/>
|
|
))}
|
|
{resourceWarnings.map((w, i) => (
|
|
<Alert key={i} type="warning" title="Resource warning" message={w} />
|
|
))}
|
|
<label className="flex items-center gap-2 cursor-pointer select-none mt-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={forceInstall}
|
|
onChange={(e) => setForceInstall(e.target.checked)}
|
|
className="accent-desert-orange h-4 w-4 rounded"
|
|
/>
|
|
<span className="text-text-muted text-xs">I understand — install anyway</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-sm">
|
|
Containers are created with <code className="font-mono">--restart=unless-stopped</code>. Data is not persisted unless you add volume mounts above.
|
|
</p>
|
|
</div>
|
|
</StyledModal>
|
|
)
|
|
}
|