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:
hsiegeln
2026-04-07 14:53:57 +02:00
parent 3d41d4a3da
commit 8febdba533
12 changed files with 180 additions and 70 deletions

View File

@@ -52,13 +52,14 @@ public class AppController {
public ResponseEntity<AppResponse> create(
@PathVariable UUID environmentId,
@RequestPart("metadata") String metadataJson,
@RequestPart("file") MultipartFile file,
@RequestPart(value = "file", required = false) MultipartFile file,
Authentication authentication) {
try {
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
UUID actorId = resolveActorId(authentication);
var entity = appService.create(environmentId, request.slug(), request.displayName(), file, actorId);
var entity = appService.create(environmentId, request.slug(), request.displayName(),
file, request.memoryLimit(), request.cpuShares(), actorId);
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
} catch (IllegalArgumentException e) {
var msg = e.getMessage();
@@ -168,6 +169,7 @@ public class AppController {
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
app.getExposedPort(), routeUrl,
app.getMemoryLimit(), app.getCpuShares(),
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
app.getCreatedAt(), app.getUpdatedAt());
}

View File

@@ -42,6 +42,12 @@ public class AppEntity {
@Column(name = "exposed_port")
private Integer exposedPort;
@Column(name = "memory_limit", length = 20)
private String memoryLimit;
@Column(name = "cpu_shares")
private Integer cpuShares;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@@ -81,6 +87,10 @@ public class AppEntity {
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
public Integer getExposedPort() { return exposedPort; }
public void setExposedPort(Integer exposedPort) { this.exposedPort = exposedPort; }
public String getMemoryLimit() { return memoryLimit; }
public void setMemoryLimit(String memoryLimit) { this.memoryLimit = memoryLimit; }
public Integer getCpuShares() { return cpuShares; }
public void setCpuShares(Integer cpuShares) { this.cpuShares = cpuShares; }
public Instant getCreatedAt() { return createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
}

View File

@@ -41,8 +41,11 @@ public class AppService {
this.runtimeConfig = runtimeConfig;
}
public AppEntity create(UUID envId, String slug, String displayName, MultipartFile jarFile, UUID actorId) {
public AppEntity create(UUID envId, String slug, String displayName,
MultipartFile jarFile, String memoryLimit, Integer cpuShares, UUID actorId) {
if (jarFile != null && !jarFile.isEmpty()) {
validateJarFile(jarFile);
}
var env = environmentRepository.findById(envId)
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + envId));
@@ -54,17 +57,21 @@ public class AppService {
var tenantId = env.getTenantId();
enforceTierLimit(tenantId);
var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
var checksum = storeJar(jarFile, relativePath);
var entity = new AppEntity();
entity.setEnvironmentId(envId);
entity.setSlug(slug);
entity.setDisplayName(displayName);
entity.setMemoryLimit(memoryLimit);
entity.setCpuShares(cpuShares);
if (jarFile != null && !jarFile.isEmpty()) {
var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
var checksum = storeJar(jarFile, relativePath);
entity.setJarStoragePath(relativePath);
entity.setJarChecksum(checksum);
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
entity.setJarSizeBytes(jarFile.getSize());
}
var saved = appRepository.save(entity);

View File

@@ -13,6 +13,8 @@ public record AppResponse(
String jarChecksum,
Integer exposedPort,
String routeUrl,
String memoryLimit,
Integer cpuShares,
UUID currentDeploymentId,
UUID previousDeploymentId,
Instant createdAt,

View File

@@ -9,5 +9,9 @@ public record CreateAppRequest(
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
String slug,
@NotBlank @Size(max = 255)
String displayName
String displayName,
@Size(max = 20)
@Pattern(regexp = "^(\\d+[mgMG])?$", message = "Memory limit must be like 256m, 512m, 1g")
String memoryLimit,
Integer cpuShares
) {}

View File

@@ -137,6 +137,13 @@ public class DeploymentService {
String.valueOf(app.getExposedPort()));
}
long memoryBytes = app.getMemoryLimit() != null
? parseMemoryBytes(app.getMemoryLimit())
: runtimeConfig.parseMemoryLimitBytes();
int cpuShares = app.getCpuShares() != null
? app.getCpuShares()
: runtimeConfig.getContainerCpuShares();
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
deployment.getImageRef(),
containerName,
@@ -149,8 +156,8 @@ public class DeploymentService {
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
"CAMELEER_DISPLAY_NAME", containerName
),
runtimeConfig.parseMemoryLimitBytes(),
runtimeConfig.getContainerCpuShares(),
memoryBytes,
cpuShares,
runtimeConfig.getAgentHealthPort(),
labels
));
@@ -232,6 +239,16 @@ public class DeploymentService {
return deploymentRepository.findById(deploymentId);
}
static long parseMemoryBytes(String limit) {
var s = limit.trim().toLowerCase();
if (s.endsWith("g")) {
return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024 * 1024;
} else if (s.endsWith("m")) {
return Long.parseLong(s.substring(0, s.length() - 1)) * 1024 * 1024;
}
return Long.parseLong(s);
}
boolean waitForHealthy(String containerId, int timeoutSeconds) {
var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
while (System.currentTimeMillis() < deadline) {

View File

@@ -0,0 +1,2 @@
ALTER TABLE apps ADD COLUMN memory_limit VARCHAR(20);
ALTER TABLE apps ADD COLUMN cpu_shares INTEGER;

View File

@@ -84,7 +84,7 @@ class AppServiceTest {
.thenReturn(Optional.of(license));
when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0));
var result = appService.create(envId, "myapp", "My App", jarFile, actorId);
var result = appService.create(envId, "myapp", "My App", jarFile, null, null, actorId);
assertThat(result.getSlug()).isEqualTo("myapp");
assertThat(result.getDisplayName()).isEqualTo("My App");
@@ -109,7 +109,7 @@ class AppServiceTest {
var textFile = new MockMultipartFile("file", "readme.txt", "text/plain", "hello".getBytes());
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", textFile, actorId))
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", textFile, null, null, actorId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining(".jar");
}
@@ -130,7 +130,7 @@ class AppServiceTest {
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(true);
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", jarFile, actorId))
assertThatThrownBy(() -> appService.create(envId, "myapp", "My App", jarFile, null, null, actorId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("myapp");
}

View File

@@ -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)}
<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 className="flex justify-end gap-2 pt-2">
</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>

View File

@@ -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>

View File

@@ -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
View 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, '');
}