From 8febdba53328f5271c7c4ed6eb9a67f24c39a658 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:53:57 +0200 Subject: [PATCH] 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) --- .../cameleer/saas/app/AppController.java | 6 +- .../siegeln/cameleer/saas/app/AppEntity.java | 10 ++ .../siegeln/cameleer/saas/app/AppService.java | 25 ++-- .../cameleer/saas/app/dto/AppResponse.java | 2 + .../saas/app/dto/CreateAppRequest.java | 6 +- .../saas/deployment/DeploymentService.java | 21 ++- .../V008__add_app_resource_limits.sql | 2 + .../cameleer/saas/app/AppServiceTest.java | 6 +- ui/src/pages/EnvironmentDetailPage.tsx | 130 +++++++++++++----- ui/src/pages/EnvironmentsPage.tsx | 30 ++-- ui/src/types/api.ts | 2 + ui/src/utils/slug.ts | 10 ++ 12 files changed, 180 insertions(+), 70 deletions(-) create mode 100644 src/main/resources/db/migration/V008__add_app_resource_limits.sql create mode 100644 ui/src/utils/slug.ts diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java index 1c15e79..ccba3b7 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java @@ -52,13 +52,14 @@ public class AppController { public ResponseEntity 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()); } diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java b/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java index cb12277..50c54c3 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppEntity.java @@ -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; } } diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppService.java b/src/main/java/net/siegeln/cameleer/saas/app/AppService.java index 580f61b..4606019 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/AppService.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppService.java @@ -41,8 +41,11 @@ public class AppService { this.runtimeConfig = runtimeConfig; } - public AppEntity create(UUID envId, String slug, String displayName, MultipartFile jarFile, UUID actorId) { - validateJarFile(jarFile); + 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.setJarStoragePath(relativePath); - entity.setJarChecksum(checksum); - entity.setJarOriginalFilename(jarFile.getOriginalFilename()); - entity.setJarSizeBytes(jarFile.getSize()); + 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); diff --git a/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java b/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java index 43e1cc3..b2c4ddc 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java @@ -13,6 +13,8 @@ public record AppResponse( String jarChecksum, Integer exposedPort, String routeUrl, + String memoryLimit, + Integer cpuShares, UUID currentDeploymentId, UUID previousDeploymentId, Instant createdAt, diff --git a/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java b/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java index 69ccbe4..c80b4c4 100644 --- a/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java +++ b/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java @@ -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 ) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java index e4ede03..149988d 100644 --- a/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java +++ b/src/main/java/net/siegeln/cameleer/saas/deployment/DeploymentService.java @@ -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) { diff --git a/src/main/resources/db/migration/V008__add_app_resource_limits.sql b/src/main/resources/db/migration/V008__add_app_resource_limits.sql new file mode 100644 index 0000000..3fb16be --- /dev/null +++ b/src/main/resources/db/migration/V008__add_app_resource_limits.sql @@ -0,0 +1,2 @@ +ALTER TABLE apps ADD COLUMN memory_limit VARCHAR(20); +ALTER TABLE apps ADD COLUMN cpu_shares INTEGER; diff --git a/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java index 7b5a822..6868c74 100644 --- a/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/app/AppServiceTest.java @@ -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"); } diff --git a/ui/src/pages/EnvironmentDetailPage.tsx b/ui/src/pages/EnvironmentDetailPage.tsx index e70f2a7..1316e5b 100644 --- a/ui/src/pages/EnvironmentDetailPage.tsx +++ b/ui/src/pages/EnvironmentDetailPage.tsx @@ -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(null); - const fileInputRef = useRef(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 */} - -
- - setAppSlug(e.target.value)} - placeholder="e.g. order-router" - required - /> - - + + + setAppDisplayName(e.target.value)} placeholder="e.g. Order Router" + autoFocus required /> - - setJarFile(e.target.files?.[0] ?? null)} - /> - -
+ +
+
+ + setMemoryLimit(e.target.value)} + placeholder="e.g. 512m, 1g" + /> + +
+
+ + setCpuShares(e.target.value)} + placeholder="e.g. 512, 1024" + /> + +
+
+ + {/* Drop zone — drag only, no file picker */} +
{ 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 ? ( + <> +
+ {jarFile.name} +
+
+ {(jarFile.size / 1024 / 1024).toFixed(1)} MB +
+ + + ) : ( + <> +
+ Drop your .jar file here +
+
+ You can also upload later from the app detail page +
+ + )} +
+ + {/* Actions — cancel left, create right */} +
@@ -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
diff --git a/ui/src/pages/EnvironmentsPage.tsx b/ui/src/pages/EnvironmentsPage.tsx index 31678e8..24c381f 100644 --- a/ui/src/pages/EnvironmentsPage.tsx +++ b/ui/src/pages/EnvironmentsPage.tsx @@ -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 */} - -
- - setSlug(e.target.value)} - placeholder="e.g. production" - required - /> - - + + + setDisplayName(e.target.value)} placeholder="e.g. Production" + autoFocus required /> -
+
@@ -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
diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index 1e3ff9b..deb4f8e 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -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; diff --git a/ui/src/utils/slug.ts b/ui/src/utils/slug.ts new file mode 100644 index 0000000..bed7ef1 --- /dev/null +++ b/ui/src/utils/slug.ts @@ -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, ''); +}