From 056b747c3fc7a21807052c619424c4dd883cae00 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:31:34 +0200 Subject: [PATCH] feat: replace create-app modal with full creation page at /apps/new Full-page creation flow with: - Identity section: name, auto-slug, environment, JAR upload, deploy toggle - Monitoring tab: engine level, payload capture, log levels, metrics, sampling, compress success, replay, route control - Resources tab: memory, CPU, ports, environment variables Environment variables are configurable before first deploy, addressing the need to set app-specific config upfront. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/AppsTab/AppsTab.tsx | 336 +++++++++++++++++++++++-------- ui/src/router.tsx | 1 + 2 files changed, 257 insertions(+), 80 deletions(-) diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index 052694df..91a062bf 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -1,12 +1,11 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; -import { useParams, useNavigate } from 'react-router'; +import { useParams, useNavigate, useLocation } from 'react-router'; import { Badge, Button, ConfirmDialog, DataTable, Input, - Modal, MonoText, SectionHeader, Select, @@ -67,9 +66,11 @@ function slugify(name: string): string { export default function AppsTab() { const { appId } = useParams<{ appId?: string }>(); + const location = useLocation(); const selectedEnv = useEnvironmentStore((s) => s.environment); const { data: environments = [] } = useEnvironments(); + if (location.pathname.endsWith('/apps/new')) return ; if (appId) return ; return ; } @@ -79,12 +80,10 @@ export default function AppsTab() { // ═══════════════════════════════════════════════════════════════════ function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) { - const { toast } = useToast(); const navigate = useNavigate(); const { data: allApps = [], isLoading: allLoading } = useAllApps(); const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]); const { data: envApps = [], isLoading: envLoading } = useApps(envId); - const [createOpen, setCreateOpen] = useState(false); const apps = selectedEnv ? envApps : allApps; const isLoading = selectedEnv ? envLoading : allLoading; @@ -119,60 +118,86 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde return (
- +
navigate(`/apps/${row.id}`)} /> - setCreateOpen(false)} - environments={environments} - defaultEnvId={envId} - onCreated={(appId) => { setCreateOpen(false); navigate(`/apps/${appId}`); }} - />
); } // ═══════════════════════════════════════════════════════════════════ -// CREATE APP MODAL +// CREATE APP PAGE // ═══════════════════════════════════════════════════════════════════ -function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated }: { - open: boolean; - onClose: () => void; - environments: Environment[]; - defaultEnvId: string | undefined; - onCreated: (appId: string) => void; -}) { +function CreateAppView({ environments, selectedEnv }: { environments: Environment[]; selectedEnv: string | undefined }) { const { toast } = useToast(); + const navigate = useNavigate(); const createApp = useCreateApp(); const uploadJar = useUploadJar(); const createDeployment = useCreateDeployment(); + const updateAgentConfig = useUpdateApplicationConfig(); + const updateContainerConfig = useUpdateContainerConfig(); + const defaultEnvId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id ?? (environments.length > 0 ? environments[0].id : ''), [environments, selectedEnv]); + + // Identity const [name, setName] = useState(''); const [slugEdited, setSlugEdited] = useState(false); const [slug, setSlug] = useState(''); - const [envId, setEnvId] = useState(''); + const [envId, setEnvId] = useState(defaultEnvId); const [file, setFile] = useState(null); const [deploy, setDeploy] = useState(true); - const [busy, setBusy] = useState(false); - const [step, setStep] = useState(''); const fileInputRef = useRef(null); - // Reset on open - useEffect(() => { - if (open) { - setName(''); setSlug(''); setSlugEdited(false); setFile(null); - setDeploy(true); setBusy(false); setStep(''); - setEnvId(defaultEnvId || (environments.length > 0 ? environments[0].id : '')); - } - }, [open, defaultEnvId, environments]); + // Monitoring + const [engineLevel, setEngineLevel] = useState('REGULAR'); + const [payloadCapture, setPayloadCapture] = useState('BOTH'); + const [payloadSize, setPayloadSize] = useState('4'); + const [payloadUnit, setPayloadUnit] = useState('KB'); + const [appLogLevel, setAppLogLevel] = useState('INFO'); + const [agentLogLevel, setAgentLogLevel] = useState('INFO'); + const [metricsEnabled, setMetricsEnabled] = useState(true); + const [metricsInterval, setMetricsInterval] = useState('60'); + const [samplingRate, setSamplingRate] = useState('1.0'); + const [compressSuccess, setCompressSuccess] = useState(false); + const [replayEnabled, setReplayEnabled] = useState(true); + const [routeControlEnabled, setRouteControlEnabled] = useState(true); + + // Resources + const env = useMemo(() => environments.find((e) => e.id === envId), [environments, envId]); + const isProd = env?.production ?? false; + const defaults = env?.defaultContainerConfig ?? {}; + const [memoryLimit, setMemoryLimit] = useState(String(defaults.memoryLimitMb ?? 512)); + const [memoryReserve, setMemoryReserve] = useState(String(defaults.memoryReserveMb ?? '')); + const [cpuShares, setCpuShares] = useState(String(defaults.cpuShares ?? 512)); + const [cpuLimit, setCpuLimit] = useState(String(defaults.cpuLimit ?? '')); + const [ports, setPorts] = useState(Array.isArray(defaults.exposedPorts) ? defaults.exposedPorts as number[] : []); + const [newPort, setNewPort] = useState(''); + const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]); + + const [configTab, setConfigTab] = useState<'monitoring' | 'resources'>('monitoring'); + const [busy, setBusy] = useState(false); + const [step, setStep] = useState(''); + + // Reset resource defaults when environment changes + useEffect(() => { + const d = environments.find((e) => e.id === envId)?.defaultContainerConfig ?? {}; + setMemoryLimit(String(d.memoryLimitMb ?? 512)); + setMemoryReserve(String(d.memoryReserveMb ?? '')); + setCpuShares(String(d.cpuShares ?? 512)); + setCpuLimit(String(d.cpuLimit ?? '')); + setPorts(Array.isArray(d.exposedPorts) ? d.exposedPorts as number[] : []); + }, [envId, environments]); - // Auto-compute slug from name useEffect(() => { if (!slugEdited) setSlug(slugify(name)); }, [name, slugEdited]); + function addPort() { + const p = parseInt(newPort); + if (p && !ports.includes(p)) { setPorts([...ports, p]); setNewPort(''); } + } + const canSubmit = name.trim() && slug.trim() && envId && file; async function handleSubmit() { @@ -187,14 +212,44 @@ function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated } setStep('Uploading JAR...'); const version = await uploadJar.mutateAsync({ appId: app.id, file: file! }); - // 3. Deploy (if requested) + // 3. Save container config + setStep('Saving configuration...'); + const containerConfig: Record = { + memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null, + memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null, + cpuShares: cpuShares ? parseInt(cpuShares) : null, + cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null, + exposedPorts: ports, + customEnvVars: Object.fromEntries(envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])), + }; + await updateContainerConfig.mutateAsync({ appId: app.id, config: containerConfig }); + + // 4. Save agent config (will be pushed to agent on first connect) + setStep('Saving monitoring config...'); + await updateAgentConfig.mutateAsync({ + application: slug.trim(), + version: 0, + engineLevel, + payloadCaptureMode: payloadCapture, + applicationLogLevel: appLogLevel, + agentLogLevel, + metricsEnabled, + samplingRate: parseFloat(samplingRate) || 1.0, + compressSuccess, + tracedProcessors: {}, + taps: [], + tapVersion: 0, + routeRecording: {}, + }); + + // 5. Deploy (if requested) if (deploy) { setStep('Starting deployment...'); await createDeployment.mutateAsync({ appId: app.id, appVersionId: version.id, environmentId: envId }); } - toast({ title: 'App created and deployed', description: name.trim(), variant: 'success' }); - onCreated(app.id); + toast({ title: deploy ? 'App created and deployed' : 'App created', description: name.trim(), variant: 'success' }); + navigate(`/apps/${app.id}`); } catch (e) { toast({ title: 'Failed: ' + step, description: e instanceof Error ? e.message : 'Unknown error', variant: 'error', duration: 86_400_000 }); } finally { @@ -204,56 +259,177 @@ function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated } } return ( - -
-
- - setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} /> +
+
+
+

Create Application

+
Configure and deploy a new application
- -
- - { setSlug(e.target.value); setSlugEdited(true); }} disabled={busy} /> -
- -
- - setFile(e.target.files?.[0] ?? null)} /> - - {file && {file.name} ({formatBytes(file.size)})} -
-
- -
-
- setDeploy(!deploy)} disabled={busy} /> - {deploy ? 'Deploy immediately after upload' : 'Upload only (deploy later)'} -
-
- - {step &&
{step}
} - -
- +
+
- + + {step &&
{step}
} + + {/* Identity Section */} + Identity & Artifact +
+ Application Name + setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} /> + + Slug (auto-generated) + { setSlug(e.target.value); setSlugEdited(true); }} disabled={busy} /> + + Environment + setFile(e.target.files?.[0] ?? null)} /> + + {file && {file.name} ({formatBytes(file.size)})} +
+ + Deploy +
+ setDeploy(!deploy)} disabled={busy} /> + + {deploy ? 'Deploy immediately after creation' : 'Create only (deploy later)'} + +
+
+ + {/* Config Tabs */} +
+ + +
+ + {configTab === 'monitoring' && ( +
+ Engine Level + setPayloadCapture(e.target.value)} + options={[{ value: 'NONE', label: 'NONE' }, { value: 'INPUT', label: 'INPUT' }, { value: 'OUTPUT', label: 'OUTPUT' }, { value: 'BOTH', label: 'BOTH' }]} /> + + Max Payload Size +
+ setPayloadSize(e.target.value)} style={{ width: 70 }} /> + setAppLogLevel(e.target.value)} + options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} /> + + Agent Log Level + setMetricsInterval(e.target.value)} style={{ width: 50 }} /> + s +
+ + Sampling Rate + setSamplingRate(e.target.value)} style={{ width: 80 }} /> + + Compress Success +
+ !busy && setCompressSuccess(!compressSuccess)} disabled={busy} /> + {compressSuccess ? 'Enabled' : 'Disabled'} +
+ + Replay +
+ !busy && setReplayEnabled(!replayEnabled)} disabled={busy} /> + {replayEnabled ? 'Enabled' : 'Disabled'} +
+ + Route Control +
+ !busy && setRouteControlEnabled(!routeControlEnabled)} disabled={busy} /> + {routeControlEnabled ? 'Enabled' : 'Disabled'} +
+
+ )} + + {configTab === 'resources' && ( + <> + Container Resources +
+ Memory Limit +
+ setMemoryLimit(e.target.value)} style={{ width: 80 }} /> + MB +
+ + Memory Reserve +
+
+ setMemoryReserve(e.target.value)} placeholder="---" style={{ width: 80 }} /> + MB +
+ {!isProd && Available in production environments only} +
+ + CPU Shares + setCpuShares(e.target.value)} style={{ width: 80 }} /> + + CPU Limit +
+ setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 80 }} /> + cores +
+ + Exposed Ports +
+ {ports.map((p) => ( + + {p} + + + ))} + setNewPort(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addPort(); } }} /> +
+
+ + Environment Variables + {envVars.map((v, i) => ( +
+ { + const next = [...envVars]; next[i] = { ...v, key: e.target.value }; setEnvVars(next); + }} className={styles.envVarKey} placeholder="KEY" /> + { + const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next); + }} className={styles.envVarValue} placeholder="value" /> + +
+ ))} + + + )} +
); } diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 7d6daf15..bb008348 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -68,6 +68,7 @@ export const router = createBrowserRouter([ // Apps tab (OPERATOR+ via UI guard, shows all or single app) { path: 'apps', element: }, + { path: 'apps/new', element: }, { path: 'apps/:appId', element: }, // Admin (ADMIN role required)