ui(deploy): Identity & Artifact section with filename auto-derive
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,3 +5,51 @@
|
|||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
min-height: 100%;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
107
ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx
Normal file
107
ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx
Normal file
@@ -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<HTMLInputElement>(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 (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<SectionHeader>Identity & Artifact</SectionHeader>
|
||||||
|
<div className={styles.configGrid}>
|
||||||
|
<span className={styles.configLabel}>Application Name</span>
|
||||||
|
{mode === 'deployed' ? (
|
||||||
|
<span className={styles.readOnlyValue}>{name}</span>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
placeholder="e.g. Payment Gateway"
|
||||||
|
disabled={deploying}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Slug</span>
|
||||||
|
<MonoText size="sm">{slug || '...'}</MonoText>
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Environment</span>
|
||||||
|
<Badge label={environment.displayName} color="auto" />
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>External URL</span>
|
||||||
|
<MonoText size="sm">{externalUrl}</MonoText>
|
||||||
|
|
||||||
|
{currentVersion && (
|
||||||
|
<>
|
||||||
|
<span className={styles.configLabel}>Current Version</span>
|
||||||
|
<span className={styles.readOnlyValue}>
|
||||||
|
v{currentVersion.version} · {currentVersion.jarFilename} · {formatBytes(currentVersion.jarSizeBytes)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Application JAR</span>
|
||||||
|
<div className={styles.fileRow}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".jar"
|
||||||
|
className={styles.visuallyHidden}
|
||||||
|
onChange={(e) => onStagedJarChange(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={deploying}
|
||||||
|
>
|
||||||
|
{currentVersion ? 'Change JAR' : 'Select JAR'}
|
||||||
|
</Button>
|
||||||
|
{stagedJar && (
|
||||||
|
<span className={styles.stagedJar}>
|
||||||
|
staged: {stagedJar.name} ({formatBytes(stagedJar.size)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, useLocation } from 'react-router';
|
import { useParams, useLocation } from 'react-router';
|
||||||
import { useEnvironmentStore } from '../../../api/environment-store';
|
import { useEnvironmentStore } from '../../../api/environment-store';
|
||||||
import { useEnvironments } from '../../../api/queries/admin/environments';
|
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 { PageLoader } from '../../../components/PageLoader';
|
||||||
|
import { IdentitySection } from './IdentitySection';
|
||||||
|
import { deriveAppName } from './utils/deriveAppName';
|
||||||
import styles from './AppDeploymentPage.module.css';
|
import styles from './AppDeploymentPage.module.css';
|
||||||
|
|
||||||
export default function AppDeploymentPage() {
|
export default function AppDeploymentPage() {
|
||||||
@@ -15,12 +18,49 @@ export default function AppDeploymentPage() {
|
|||||||
const isNetNew = location.pathname.endsWith('/apps/new');
|
const isNetNew = location.pathname.endsWith('/apps/new');
|
||||||
const app = isNetNew ? null : apps.find((a) => a.slug === appId) ?? null;
|
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<File | null>(null);
|
||||||
|
const lastDerivedRef = useRef<string>('');
|
||||||
|
|
||||||
|
// 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 <PageLoader />;
|
if (envLoading || appsLoading) return <PageLoader />;
|
||||||
|
if (!env) return <div>Select an environment first.</div>;
|
||||||
|
|
||||||
|
const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h2>{app ? app.displayName : 'Create Application'}</h2>
|
<h2>{app ? app.displayName : 'Create Application'}</h2>
|
||||||
{/* Identity section, tabs, primary button land in subsequent tasks */}
|
<IdentitySection
|
||||||
|
mode={mode}
|
||||||
|
environment={env}
|
||||||
|
app={app}
|
||||||
|
currentVersion={currentVersion}
|
||||||
|
name={name}
|
||||||
|
onNameChange={setName}
|
||||||
|
stagedJar={stagedJar}
|
||||||
|
onStagedJarChange={setStagedJar}
|
||||||
|
deploying={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user