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;
|
||||
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 { 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<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 (!env) return <div>Select an environment first.</div>;
|
||||
|
||||
const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new';
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user