diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css b/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css index c8d8cef7..f7869b64 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css +++ b/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css @@ -5,3 +5,51 @@ padding: 16px 24px; min-height: 100%; } + +.section { + border: 1px solid var(--border); + border-radius: 6px; + padding: 16px; + background: var(--bg-surface); +} + +.configGrid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 10px 16px; + align-items: center; + margin-top: 8px; +} + +.configLabel { + color: var(--text-muted); + font-size: 13px; +} + +.readOnlyValue { + color: var(--text-primary); + font-size: 14px; +} + +.fileRow { + display: flex; + align-items: center; + gap: 10px; +} + +.stagedJar { + color: var(--amber); + font-size: 13px; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx new file mode 100644 index 00000000..d4567634 --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx @@ -0,0 +1,107 @@ +import { useRef } from 'react'; +import { SectionHeader, Input, MonoText, Button, Badge } from '@cameleer/design-system'; +import type { App, AppVersion } from '../../../api/queries/admin/apps'; +import type { Environment } from '../../../api/queries/admin/environments'; +import styles from './AppDeploymentPage.module.css'; + +function slugify(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100); +} + +function formatBytes(bytes: number): string { + if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} + +interface IdentitySectionProps { + mode: 'net-new' | 'deployed'; + environment: Environment; + app: App | null; + currentVersion: AppVersion | null; + name: string; + onNameChange: (next: string) => void; + stagedJar: File | null; + onStagedJarChange: (file: File | null) => void; + deploying: boolean; +} + +export function IdentitySection({ + mode, environment, app, currentVersion, + name, onNameChange, stagedJar, onStagedJarChange, deploying, +}: IdentitySectionProps) { + const fileInputRef = useRef(null); + const slug = app?.slug ?? slugify(name); + + const externalUrl = (() => { + const defaults = environment.defaultContainerConfig ?? {}; + const domain = String(defaults.routingDomain ?? ''); + if (defaults.routingMode === 'subdomain' && domain) { + return `https://${slug || '...'}-${environment.slug}.${domain}/`; + } + const base = domain ? `https://${domain}` : window.location.origin; + return `${base}/${environment.slug}/${slug || '...'}/`; + })(); + + return ( +
+ Identity & Artifact +
+ Application Name + {mode === 'deployed' ? ( + {name} + ) : ( + onNameChange(e.target.value)} + placeholder="e.g. Payment Gateway" + disabled={deploying} + /> + )} + + Slug + {slug || '...'} + + Environment + + + External URL + {externalUrl} + + {currentVersion && ( + <> + Current Version + + v{currentVersion.version} · {currentVersion.jarFilename} · {formatBytes(currentVersion.jarSizeBytes)} + + + )} + + Application JAR +
+ onStagedJarChange(e.target.files?.[0] ?? null)} + /> + + {stagedJar && ( + + staged: {stagedJar.name} ({formatBytes(stagedJar.size)}) + + )} +
+
+
+ ); +} diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx index c0a73228..03bbb91b 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx +++ b/ui/src/pages/AppsTab/AppDeploymentPage/index.tsx @@ -1,8 +1,11 @@ +import { useState, useEffect, useRef } from 'react'; import { useParams, useLocation } from 'react-router'; import { useEnvironmentStore } from '../../../api/environment-store'; import { useEnvironments } from '../../../api/queries/admin/environments'; -import { useApps } from '../../../api/queries/admin/apps'; +import { useApps, useAppVersions } from '../../../api/queries/admin/apps'; import { PageLoader } from '../../../components/PageLoader'; +import { IdentitySection } from './IdentitySection'; +import { deriveAppName } from './utils/deriveAppName'; import styles from './AppDeploymentPage.module.css'; export default function AppDeploymentPage() { @@ -15,12 +18,49 @@ export default function AppDeploymentPage() { const isNetNew = location.pathname.endsWith('/apps/new'); const app = isNetNew ? null : apps.find((a) => a.slug === appId) ?? null; + const env = environments.find((e) => e.slug === selectedEnv); + const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug); + const currentVersion = versions.slice().sort((a, b) => b.version - a.version)[0] ?? null; + + // Form state + const [name, setName] = useState(''); + const [stagedJar, setStagedJar] = useState(null); + const lastDerivedRef = useRef(''); + + // Initialize name when app loads + useEffect(() => { + if (app) setName(app.displayName); + }, [app]); + + // Auto-derive from staged JAR (net-new mode only, don't overwrite manual edits) + useEffect(() => { + if (!stagedJar || app) return; + const derived = deriveAppName(stagedJar.name); + if (!name || name === lastDerivedRef.current) { + setName(derived); + lastDerivedRef.current = derived; + } + }, [stagedJar, app, name]); + if (envLoading || appsLoading) return ; + if (!env) return
Select an environment first.
; + + const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new'; return (

{app ? app.displayName : 'Create Application'}

- {/* Identity section, tabs, primary button land in subsequent tasks */} +
); }