feat: per-app resource limits, auto-slug, and polished create dialogs
Add per-app memory limit and CPU shares (stored on AppEntity, used by DeploymentService with fallback to global defaults). JAR upload is now optional at creation time. Both create modals show the computed slug in the dialog title and use consistent Cancel-left/Action-right button layout with inline styles to avoid Modal CSS conflicts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import {
|
||||
Badge,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../api/hooks';
|
||||
import { RequireScope } from '../components/RequireScope';
|
||||
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
|
||||
import { toSlug } from '../utils/slug';
|
||||
import type { AppResponse } from '../types/api';
|
||||
|
||||
interface AppTableRow {
|
||||
@@ -90,19 +91,21 @@ export function EnvironmentDetailPage() {
|
||||
|
||||
// New app modal
|
||||
const [newAppOpen, setNewAppOpen] = useState(false);
|
||||
const [appSlug, setAppSlug] = useState('');
|
||||
const [appDisplayName, setAppDisplayName] = useState('');
|
||||
const [jarFile, setJarFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [memoryLimit, setMemoryLimit] = useState('512m');
|
||||
const [cpuShares, setCpuShares] = useState('512');
|
||||
|
||||
// Delete confirm
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const appSlug = toSlug(appDisplayName);
|
||||
|
||||
function openNewApp() {
|
||||
setAppSlug('');
|
||||
setAppDisplayName('');
|
||||
setJarFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setMemoryLimit('512m');
|
||||
setCpuShares('512');
|
||||
setNewAppOpen(true);
|
||||
}
|
||||
|
||||
@@ -112,12 +115,17 @@ export function EnvironmentDetailPage() {
|
||||
|
||||
async function handleCreateApp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!appSlug.trim() || !appDisplayName.trim()) return;
|
||||
if (!appSlug || !appDisplayName.trim()) return;
|
||||
const metadata = {
|
||||
slug: appSlug,
|
||||
displayName: appDisplayName.trim(),
|
||||
memoryLimit: memoryLimit || null,
|
||||
cpuShares: cpuShares ? parseInt(cpuShares, 10) : null,
|
||||
};
|
||||
const formData = new FormData();
|
||||
formData.append('slug', appSlug.trim());
|
||||
formData.append('displayName', appDisplayName.trim());
|
||||
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||
if (jarFile) {
|
||||
formData.append('jar', jarFile);
|
||||
formData.append('file', jarFile);
|
||||
}
|
||||
try {
|
||||
await createAppMutation.mutateAsync(formData);
|
||||
@@ -253,37 +261,91 @@ export function EnvironmentDetailPage() {
|
||||
)}
|
||||
|
||||
{/* New App Modal */}
|
||||
<Modal open={newAppOpen} onClose={closeNewApp} title="New App" size="sm">
|
||||
<form onSubmit={handleCreateApp} className="space-y-4">
|
||||
<FormField label="Slug" htmlFor="app-slug" required>
|
||||
<Input
|
||||
id="app-slug"
|
||||
value={appSlug}
|
||||
onChange={(e) => setAppSlug(e.target.value)}
|
||||
placeholder="e.g. order-router"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Display Name" htmlFor="app-display-name" required>
|
||||
<Modal open={newAppOpen} onClose={closeNewApp} title={appSlug ? `Deploy ${appSlug}` : 'Deploy New Application'} size="sm">
|
||||
<form onSubmit={handleCreateApp} className="space-y-6">
|
||||
<FormField label="Application Name" htmlFor="app-display-name" required>
|
||||
<Input
|
||||
id="app-display-name"
|
||||
value={appDisplayName}
|
||||
onChange={(e) => setAppDisplayName(e.target.value)}
|
||||
placeholder="e.g. Order Router"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="JAR File" htmlFor="app-jar">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="app-jar"
|
||||
type="file"
|
||||
accept=".jar"
|
||||
className="block w-full text-sm text-white/70 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white hover:file:bg-white/20 cursor-pointer"
|
||||
onChange={(e) => setJarFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FormField label="Memory Limit" htmlFor="app-memory">
|
||||
<Input
|
||||
id="app-memory"
|
||||
value={memoryLimit}
|
||||
onChange={(e) => setMemoryLimit(e.target.value)}
|
||||
placeholder="e.g. 512m, 1g"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<FormField label="CPU Shares" htmlFor="app-cpu">
|
||||
<Input
|
||||
id="app-cpu"
|
||||
value={cpuShares}
|
||||
onChange={(e) => setCpuShares(e.target.value)}
|
||||
placeholder="e.g. 512, 1024"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop zone — drag only, no file picker */}
|
||||
<div
|
||||
style={{
|
||||
border: '2px dashed var(--border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: '2rem 1.5rem',
|
||||
textAlign: 'center',
|
||||
background: jarFile ? 'var(--bg-raised)' : undefined,
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--amber)'; }}
|
||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)'; }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.style.borderColor = 'var(--border)';
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file?.name.endsWith('.jar')) setJarFile(file);
|
||||
}}
|
||||
>
|
||||
{jarFile ? (
|
||||
<>
|
||||
<div style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--text-primary)' }}>
|
||||
{jarFile.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||
{(jarFile.size / 1024 / 1024).toFixed(1)} MB
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
style={{ fontSize: '0.75rem', color: 'var(--amber)', marginTop: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={() => setJarFile(null)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
||||
Drop your <span style={{ fontWeight: 500 }}>.jar</span> file here
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||
You can also upload later from the app detail page
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions — cancel left, create right */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', paddingTop: '1rem', borderTop: '1px solid var(--border-subtle)' }}>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={closeNewApp}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -292,9 +354,9 @@ export function EnvironmentDetailPage() {
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={createAppMutation.isPending}
|
||||
disabled={!appSlug.trim() || !appDisplayName.trim()}
|
||||
disabled={!appSlug || !appDisplayName.trim()}
|
||||
>
|
||||
Create App
|
||||
Create Application
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Column } from '@cameleer/design-system';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
|
||||
import { RequireScope } from '../components/RequireScope';
|
||||
import { toSlug } from '../utils/slug';
|
||||
import type { EnvironmentResponse } from '../types/api';
|
||||
|
||||
interface TableRow {
|
||||
@@ -73,8 +74,8 @@ export function EnvironmentsPage() {
|
||||
const createMutation = useCreateEnvironment(tenantId ?? '');
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [slug, setSlug] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const computedSlug = toSlug(displayName);
|
||||
|
||||
const tableData: TableRow[] = (environments ?? []).map((env) => ({
|
||||
id: env.id,
|
||||
@@ -86,7 +87,6 @@ export function EnvironmentsPage() {
|
||||
}));
|
||||
|
||||
function openModal() {
|
||||
setSlug('');
|
||||
setDisplayName('');
|
||||
setModalOpen(true);
|
||||
}
|
||||
@@ -97,9 +97,9 @@ export function EnvironmentsPage() {
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!slug.trim() || !displayName.trim()) return;
|
||||
if (!computedSlug || !displayName.trim()) return;
|
||||
try {
|
||||
await createMutation.mutateAsync({ slug: slug.trim(), displayName: displayName.trim() });
|
||||
await createMutation.mutateAsync({ slug: computedSlug, displayName: displayName.trim() });
|
||||
toast({ title: 'Environment created', variant: 'success' });
|
||||
closeModal();
|
||||
} catch {
|
||||
@@ -152,27 +152,19 @@ export function EnvironmentsPage() {
|
||||
)}
|
||||
|
||||
{/* Create Environment Modal */}
|
||||
<Modal open={modalOpen} onClose={closeModal} title="Create Environment" size="sm">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<FormField label="Slug" htmlFor="env-slug" required>
|
||||
<Input
|
||||
id="env-slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="e.g. production"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Display Name" htmlFor="env-display-name" required>
|
||||
<Modal open={modalOpen} onClose={closeModal} title={computedSlug ? `Create ${computedSlug}` : 'Create Environment'} size="sm">
|
||||
<form onSubmit={handleCreate} className="space-y-6">
|
||||
<FormField label="Environment Name" htmlFor="env-display-name" required>
|
||||
<Input
|
||||
id="env-display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="e.g. Production"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', paddingTop: '1rem', borderTop: '1px solid var(--border-subtle)' }}>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -181,9 +173,9 @@ export function EnvironmentsPage() {
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={createMutation.isPending}
|
||||
disabled={!slug.trim() || !displayName.trim()}
|
||||
disabled={!computedSlug || !displayName.trim()}
|
||||
>
|
||||
Create
|
||||
Create Environment
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface AppResponse {
|
||||
jarChecksum: string | null;
|
||||
exposedPort: number | null;
|
||||
routeUrl: string | null;
|
||||
memoryLimit: string | null;
|
||||
cpuShares: number | null;
|
||||
currentDeploymentId: string | null;
|
||||
previousDeploymentId: string | null;
|
||||
createdAt: string;
|
||||
|
||||
10
ui/src/utils/slug.ts
Normal file
10
ui/src/utils/slug.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Derive a URL-friendly slug from a display name. */
|
||||
export function toSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/[\s]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
Reference in New Issue
Block a user