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:
@@ -52,13 +52,14 @@ public class AppController {
|
|||||||
public ResponseEntity<AppResponse> create(
|
public ResponseEntity<AppResponse> create(
|
||||||
@PathVariable UUID environmentId,
|
@PathVariable UUID environmentId,
|
||||||
@RequestPart("metadata") String metadataJson,
|
@RequestPart("metadata") String metadataJson,
|
||||||
@RequestPart("file") MultipartFile file,
|
@RequestPart(value = "file", required = false) MultipartFile file,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
|
var request = objectMapper.readValue(metadataJson, CreateAppRequest.class);
|
||||||
UUID actorId = resolveActorId(authentication);
|
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));
|
return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
var msg = e.getMessage();
|
var msg = e.getMessage();
|
||||||
@@ -168,6 +169,7 @@ public class AppController {
|
|||||||
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
|
app.getId(), app.getEnvironmentId(), app.getSlug(), app.getDisplayName(),
|
||||||
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
|
app.getJarOriginalFilename(), app.getJarSizeBytes(), app.getJarChecksum(),
|
||||||
app.getExposedPort(), routeUrl,
|
app.getExposedPort(), routeUrl,
|
||||||
|
app.getMemoryLimit(), app.getCpuShares(),
|
||||||
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
|
app.getCurrentDeploymentId(), app.getPreviousDeploymentId(),
|
||||||
app.getCreatedAt(), app.getUpdatedAt());
|
app.getCreatedAt(), app.getUpdatedAt());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ public class AppEntity {
|
|||||||
@Column(name = "exposed_port")
|
@Column(name = "exposed_port")
|
||||||
private Integer exposedPort;
|
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)
|
@Column(name = "created_at", nullable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -81,6 +87,10 @@ public class AppEntity {
|
|||||||
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
|
public void setPreviousDeploymentId(UUID previousDeploymentId) { this.previousDeploymentId = previousDeploymentId; }
|
||||||
public Integer getExposedPort() { return exposedPort; }
|
public Integer getExposedPort() { return exposedPort; }
|
||||||
public void setExposedPort(Integer exposedPort) { this.exposedPort = 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 getCreatedAt() { return createdAt; }
|
||||||
public Instant getUpdatedAt() { return updatedAt; }
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,11 @@ public class AppService {
|
|||||||
this.runtimeConfig = runtimeConfig;
|
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,
|
||||||
validateJarFile(jarFile);
|
MultipartFile jarFile, String memoryLimit, Integer cpuShares, UUID actorId) {
|
||||||
|
if (jarFile != null && !jarFile.isEmpty()) {
|
||||||
|
validateJarFile(jarFile);
|
||||||
|
}
|
||||||
|
|
||||||
var env = environmentRepository.findById(envId)
|
var env = environmentRepository.findById(envId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + envId));
|
.orElseThrow(() -> new IllegalArgumentException("Environment not found: " + envId));
|
||||||
@@ -54,17 +57,21 @@ public class AppService {
|
|||||||
var tenantId = env.getTenantId();
|
var tenantId = env.getTenantId();
|
||||||
enforceTierLimit(tenantId);
|
enforceTierLimit(tenantId);
|
||||||
|
|
||||||
var relativePath = "tenants/" + tenantId + "/envs/" + env.getSlug() + "/apps/" + slug + "/app.jar";
|
|
||||||
var checksum = storeJar(jarFile, relativePath);
|
|
||||||
|
|
||||||
var entity = new AppEntity();
|
var entity = new AppEntity();
|
||||||
entity.setEnvironmentId(envId);
|
entity.setEnvironmentId(envId);
|
||||||
entity.setSlug(slug);
|
entity.setSlug(slug);
|
||||||
entity.setDisplayName(displayName);
|
entity.setDisplayName(displayName);
|
||||||
entity.setJarStoragePath(relativePath);
|
entity.setMemoryLimit(memoryLimit);
|
||||||
entity.setJarChecksum(checksum);
|
entity.setCpuShares(cpuShares);
|
||||||
entity.setJarOriginalFilename(jarFile.getOriginalFilename());
|
|
||||||
entity.setJarSizeBytes(jarFile.getSize());
|
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);
|
var saved = appRepository.save(entity);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public record AppResponse(
|
|||||||
String jarChecksum,
|
String jarChecksum,
|
||||||
Integer exposedPort,
|
Integer exposedPort,
|
||||||
String routeUrl,
|
String routeUrl,
|
||||||
|
String memoryLimit,
|
||||||
|
Integer cpuShares,
|
||||||
UUID currentDeploymentId,
|
UUID currentDeploymentId,
|
||||||
UUID previousDeploymentId,
|
UUID previousDeploymentId,
|
||||||
Instant createdAt,
|
Instant createdAt,
|
||||||
|
|||||||
@@ -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")
|
@Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens")
|
||||||
String slug,
|
String slug,
|
||||||
@NotBlank @Size(max = 255)
|
@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
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ public class DeploymentService {
|
|||||||
String.valueOf(app.getExposedPort()));
|
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(
|
var containerId = runtimeOrchestrator.startContainer(new StartContainerRequest(
|
||||||
deployment.getImageRef(),
|
deployment.getImageRef(),
|
||||||
containerName,
|
containerName,
|
||||||
@@ -149,8 +156,8 @@ public class DeploymentService {
|
|||||||
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
|
"CAMELEER_ENVIRONMENT_ID", env.getSlug(),
|
||||||
"CAMELEER_DISPLAY_NAME", containerName
|
"CAMELEER_DISPLAY_NAME", containerName
|
||||||
),
|
),
|
||||||
runtimeConfig.parseMemoryLimitBytes(),
|
memoryBytes,
|
||||||
runtimeConfig.getContainerCpuShares(),
|
cpuShares,
|
||||||
runtimeConfig.getAgentHealthPort(),
|
runtimeConfig.getAgentHealthPort(),
|
||||||
labels
|
labels
|
||||||
));
|
));
|
||||||
@@ -232,6 +239,16 @@ public class DeploymentService {
|
|||||||
return deploymentRepository.findById(deploymentId);
|
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) {
|
boolean waitForHealthy(String containerId, int timeoutSeconds) {
|
||||||
var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
|
var deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
|
||||||
while (System.currentTimeMillis() < deadline) {
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE apps ADD COLUMN memory_limit VARCHAR(20);
|
||||||
|
ALTER TABLE apps ADD COLUMN cpu_shares INTEGER;
|
||||||
@@ -84,7 +84,7 @@ class AppServiceTest {
|
|||||||
.thenReturn(Optional.of(license));
|
.thenReturn(Optional.of(license));
|
||||||
when(appRepository.save(any(AppEntity.class))).thenAnswer(inv -> inv.getArgument(0));
|
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.getSlug()).isEqualTo("myapp");
|
||||||
assertThat(result.getDisplayName()).isEqualTo("My App");
|
assertThat(result.getDisplayName()).isEqualTo("My App");
|
||||||
@@ -109,7 +109,7 @@ class AppServiceTest {
|
|||||||
|
|
||||||
var textFile = new MockMultipartFile("file", "readme.txt", "text/plain", "hello".getBytes());
|
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)
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessageContaining(".jar");
|
.hasMessageContaining(".jar");
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ class AppServiceTest {
|
|||||||
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
when(environmentRepository.findById(envId)).thenReturn(Optional.of(env));
|
||||||
when(appRepository.existsByEnvironmentIdAndSlug(envId, "myapp")).thenReturn(true);
|
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)
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
.hasMessageContaining("myapp");
|
.hasMessageContaining("myapp");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from '../api/hooks';
|
} from '../api/hooks';
|
||||||
import { RequireScope } from '../components/RequireScope';
|
import { RequireScope } from '../components/RequireScope';
|
||||||
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
|
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
|
||||||
|
import { toSlug } from '../utils/slug';
|
||||||
import type { AppResponse } from '../types/api';
|
import type { AppResponse } from '../types/api';
|
||||||
|
|
||||||
interface AppTableRow {
|
interface AppTableRow {
|
||||||
@@ -90,19 +91,21 @@ export function EnvironmentDetailPage() {
|
|||||||
|
|
||||||
// New app modal
|
// New app modal
|
||||||
const [newAppOpen, setNewAppOpen] = useState(false);
|
const [newAppOpen, setNewAppOpen] = useState(false);
|
||||||
const [appSlug, setAppSlug] = useState('');
|
|
||||||
const [appDisplayName, setAppDisplayName] = useState('');
|
const [appDisplayName, setAppDisplayName] = useState('');
|
||||||
const [jarFile, setJarFile] = useState<File | null>(null);
|
const [jarFile, setJarFile] = useState<File | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const [memoryLimit, setMemoryLimit] = useState('512m');
|
||||||
|
const [cpuShares, setCpuShares] = useState('512');
|
||||||
|
|
||||||
// Delete confirm
|
// Delete confirm
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
const appSlug = toSlug(appDisplayName);
|
||||||
|
|
||||||
function openNewApp() {
|
function openNewApp() {
|
||||||
setAppSlug('');
|
|
||||||
setAppDisplayName('');
|
setAppDisplayName('');
|
||||||
setJarFile(null);
|
setJarFile(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
setMemoryLimit('512m');
|
||||||
|
setCpuShares('512');
|
||||||
setNewAppOpen(true);
|
setNewAppOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,12 +115,17 @@ export function EnvironmentDetailPage() {
|
|||||||
|
|
||||||
async function handleCreateApp(e: React.FormEvent) {
|
async function handleCreateApp(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
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();
|
const formData = new FormData();
|
||||||
formData.append('slug', appSlug.trim());
|
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||||
formData.append('displayName', appDisplayName.trim());
|
|
||||||
if (jarFile) {
|
if (jarFile) {
|
||||||
formData.append('jar', jarFile);
|
formData.append('file', jarFile);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createAppMutation.mutateAsync(formData);
|
await createAppMutation.mutateAsync(formData);
|
||||||
@@ -253,37 +261,91 @@ export function EnvironmentDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New App Modal */}
|
{/* New App Modal */}
|
||||||
<Modal open={newAppOpen} onClose={closeNewApp} title="New App" size="sm">
|
<Modal open={newAppOpen} onClose={closeNewApp} title={appSlug ? `Deploy ${appSlug}` : 'Deploy New Application'} size="sm">
|
||||||
<form onSubmit={handleCreateApp} className="space-y-4">
|
<form onSubmit={handleCreateApp} className="space-y-6">
|
||||||
<FormField label="Slug" htmlFor="app-slug" required>
|
<FormField label="Application Name" htmlFor="app-display-name" 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>
|
|
||||||
<Input
|
<Input
|
||||||
id="app-display-name"
|
id="app-display-name"
|
||||||
value={appDisplayName}
|
value={appDisplayName}
|
||||||
onChange={(e) => setAppDisplayName(e.target.value)}
|
onChange={(e) => setAppDisplayName(e.target.value)}
|
||||||
placeholder="e.g. Order Router"
|
placeholder="e.g. Order Router"
|
||||||
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="JAR File" htmlFor="app-jar">
|
|
||||||
<input
|
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||||
ref={fileInputRef}
|
<div style={{ flex: 1 }}>
|
||||||
id="app-jar"
|
<FormField label="Memory Limit" htmlFor="app-memory">
|
||||||
type="file"
|
<Input
|
||||||
accept=".jar"
|
id="app-memory"
|
||||||
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"
|
value={memoryLimit}
|
||||||
onChange={(e) => setJarFile(e.target.files?.[0] ?? null)}
|
onChange={(e) => setMemoryLimit(e.target.value)}
|
||||||
/>
|
placeholder="e.g. 512m, 1g"
|
||||||
</FormField>
|
/>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
</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}>
|
<Button type="button" variant="secondary" size="sm" onClick={closeNewApp}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -292,9 +354,9 @@ export function EnvironmentDetailPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={createAppMutation.isPending}
|
loading={createAppMutation.isPending}
|
||||||
disabled={!appSlug.trim() || !appDisplayName.trim()}
|
disabled={!appSlug || !appDisplayName.trim()}
|
||||||
>
|
>
|
||||||
Create App
|
Create Application
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { Column } from '@cameleer/design-system';
|
|||||||
import { useAuth } from '../auth/useAuth';
|
import { useAuth } from '../auth/useAuth';
|
||||||
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
|
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
|
||||||
import { RequireScope } from '../components/RequireScope';
|
import { RequireScope } from '../components/RequireScope';
|
||||||
|
import { toSlug } from '../utils/slug';
|
||||||
import type { EnvironmentResponse } from '../types/api';
|
import type { EnvironmentResponse } from '../types/api';
|
||||||
|
|
||||||
interface TableRow {
|
interface TableRow {
|
||||||
@@ -73,8 +74,8 @@ export function EnvironmentsPage() {
|
|||||||
const createMutation = useCreateEnvironment(tenantId ?? '');
|
const createMutation = useCreateEnvironment(tenantId ?? '');
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [slug, setSlug] = useState('');
|
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const computedSlug = toSlug(displayName);
|
||||||
|
|
||||||
const tableData: TableRow[] = (environments ?? []).map((env) => ({
|
const tableData: TableRow[] = (environments ?? []).map((env) => ({
|
||||||
id: env.id,
|
id: env.id,
|
||||||
@@ -86,7 +87,6 @@ export function EnvironmentsPage() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
setSlug('');
|
|
||||||
setDisplayName('');
|
setDisplayName('');
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
@@ -97,9 +97,9 @@ export function EnvironmentsPage() {
|
|||||||
|
|
||||||
async function handleCreate(e: React.FormEvent) {
|
async function handleCreate(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!slug.trim() || !displayName.trim()) return;
|
if (!computedSlug || !displayName.trim()) return;
|
||||||
try {
|
try {
|
||||||
await createMutation.mutateAsync({ slug: slug.trim(), displayName: displayName.trim() });
|
await createMutation.mutateAsync({ slug: computedSlug, displayName: displayName.trim() });
|
||||||
toast({ title: 'Environment created', variant: 'success' });
|
toast({ title: 'Environment created', variant: 'success' });
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -152,27 +152,19 @@ export function EnvironmentsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Environment Modal */}
|
{/* Create Environment Modal */}
|
||||||
<Modal open={modalOpen} onClose={closeModal} title="Create Environment" size="sm">
|
<Modal open={modalOpen} onClose={closeModal} title={computedSlug ? `Create ${computedSlug}` : 'Create Environment'} size="sm">
|
||||||
<form onSubmit={handleCreate} className="space-y-4">
|
<form onSubmit={handleCreate} className="space-y-6">
|
||||||
<FormField label="Slug" htmlFor="env-slug" required>
|
<FormField label="Environment Name" htmlFor="env-display-name" 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>
|
|
||||||
<Input
|
<Input
|
||||||
id="env-display-name"
|
id="env-display-name"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
placeholder="e.g. Production"
|
placeholder="e.g. Production"
|
||||||
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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}>
|
<Button type="button" variant="secondary" size="sm" onClick={closeModal}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -181,9 +173,9 @@ export function EnvironmentsPage() {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
loading={createMutation.isPending}
|
loading={createMutation.isPending}
|
||||||
disabled={!slug.trim() || !displayName.trim()}
|
disabled={!computedSlug || !displayName.trim()}
|
||||||
>
|
>
|
||||||
Create
|
Create Environment
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface AppResponse {
|
|||||||
jarChecksum: string | null;
|
jarChecksum: string | null;
|
||||||
exposedPort: number | null;
|
exposedPort: number | null;
|
||||||
routeUrl: string | null;
|
routeUrl: string | null;
|
||||||
|
memoryLimit: string | null;
|
||||||
|
cpuShares: number | null;
|
||||||
currentDeploymentId: string | null;
|
currentDeploymentId: string | null;
|
||||||
previousDeploymentId: string | null;
|
previousDeploymentId: string | null;
|
||||||
createdAt: string;
|
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