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