refactor: CPU config to millicores, fix replica health, reorder tabs
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m18s
CI / docker (push) Successful in 1m5s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Has been cancelled

- Rename cpuShares to cpuRequest (millicores), cpuLimit from cores to
  millicores. ResolvedContainerConfig translates to Docker-native units
  via dockerCpuShares() and dockerCpuQuota() helpers. Future K8s
  orchestrator can pass millicores through directly.
- Fix waitForAnyHealthy to wait for ALL replicas instead of returning
  on first healthy one. Prevents false DEGRADED status with 2+ replicas.
- Default app detail to Configuration tab (was Overview)
- Reorder config sub-tabs: Monitoring, Resources, Variables, Traces &
  Taps, Route Recording

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 07:38:23 +02:00
parent eb7cd9ba62
commit e88db56f79
5 changed files with 57 additions and 46 deletions

View File

@@ -37,8 +37,8 @@ public class DeploymentExecutor {
@Value("${cameleer.runtime.container-memory-limit:512m}")
private String globalMemoryLimit;
@Value("${cameleer.runtime.container-cpu-shares:512}")
private int globalCpuShares;
@Value("${cameleer.runtime.container-cpu-request:500}")
private int globalCpuRequest;
@Value("${cameleer.runtime.health-check-timeout:60}")
private int healthCheckTimeout;
@@ -86,7 +86,7 @@ public class DeploymentExecutor {
var globalDefaults = new ConfigMerger.GlobalRuntimeDefaults(
parseMemoryLimitMb(globalMemoryLimit),
globalCpuShares,
globalCpuRequest,
globalRoutingMode,
globalRoutingDomain,
globalServerUrl.isBlank() ? "http://cameleer3-server:8081" : globalServerUrl
@@ -126,7 +126,6 @@ public class DeploymentExecutor {
for (int i = 0; i < config.replicas(); i++) {
String containerName = env.slug() + "-" + app.slug() + "-" + i;
Long cpuQuota = config.cpuLimit() != null ? (long) (config.cpuLimit() * 100_000) : null;
String volumeName = jarDockerVolume != null && !jarDockerVolume.isBlank() ? jarDockerVolume : null;
ContainerRequest request = new ContainerRequest(
@@ -136,7 +135,7 @@ public class DeploymentExecutor {
envNet != null ? List.of(envNet) : List.of(),
baseEnvVars, labels,
config.memoryLimitBytes(), config.memoryReserveBytes(),
config.cpuShares(), cpuQuota,
config.dockerCpuShares(), config.dockerCpuQuota(),
config.exposedPorts(), agentHealthPort,
"on-failure", 3
);
@@ -273,19 +272,21 @@ public class DeploymentExecutor {
private int waitForAnyHealthy(List<String> containerIds, int timeoutSeconds) {
long deadline = System.currentTimeMillis() + (timeoutSeconds * 1000L);
int lastHealthy = 0;
while (System.currentTimeMillis() < deadline) {
int healthy = 0;
for (String cid : containerIds) {
ContainerStatus status = orchestrator.getContainerStatus(cid);
if ("healthy".equals(status.state())) healthy++;
}
if (healthy > 0) return healthy;
lastHealthy = healthy;
if (healthy == containerIds.size()) return healthy;
try { Thread.sleep(2000); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
return 0;
return lastHealthy;
}
}
return 0;
return lastHealthy;
}
private List<Map<String, Object>> updateReplicaHealth(List<Map<String, Object>> replicas,

View File

@@ -17,8 +17,8 @@ public final class ConfigMerger {
return new ResolvedContainerConfig(
intVal(appConfig, envConfig, "memoryLimitMb", global.memoryLimitMb()),
intOrNull(appConfig, envConfig, "memoryReserveMb"),
intVal(appConfig, envConfig, "cpuShares", global.cpuShares()),
doubleOrNull(appConfig, envConfig, "cpuLimit"),
intVal(appConfig, envConfig, "cpuRequest", global.cpuRequest()),
intOrNull(appConfig, envConfig, "cpuLimit"),
intVal(appConfig, envConfig, "appPort", 8080),
intList(appConfig, envConfig, "exposedPorts"),
stringMap(appConfig, envConfig, "customEnvVars"),
@@ -88,7 +88,7 @@ public final class ConfigMerger {
/** Global defaults extracted from application.yml @Value fields */
public record GlobalRuntimeDefaults(
int memoryLimitMb,
int cpuShares,
int cpuRequest,
String routingMode,
String routingDomain,
String serverUrl

View File

@@ -6,8 +6,8 @@ import java.util.Map;
public record ResolvedContainerConfig(
int memoryLimitMb,
Integer memoryReserveMb,
int cpuShares,
Double cpuLimit,
int cpuRequest,
Integer cpuLimit,
int appPort,
List<Integer> exposedPorts,
Map<String, String> customEnvVars,
@@ -26,4 +26,14 @@ public record ResolvedContainerConfig(
public Long memoryReserveBytes() {
return memoryReserveMb != null ? (long) memoryReserveMb * 1024 * 1024 : null;
}
/** Convert cpuRequest (millicores) to Docker CPU shares (proportional to 1024 = 1 core). */
public int dockerCpuShares() {
return cpuRequest * 1024 / 1000;
}
/** Convert cpuLimit (millicores) to Docker CPU quota (microseconds per 100ms period). */
public Long dockerCpuQuota() {
return cpuLimit != null ? (long) cpuLimit * 100 : null;
}
}

View File

@@ -331,7 +331,7 @@ function DefaultResourcesSection({ environment, onSave, saving }: {
const [editing, setEditing] = useState(false);
const [memoryLimit, setMemoryLimit] = useState('');
const [memoryReserve, setMemoryReserve] = useState('');
const [cpuShares, setCpuShares] = useState('');
const [cpuRequest, setCpuRequest] = useState('');
const [cpuLimit, setCpuLimit] = useState('');
const [routingMode, setRoutingMode] = useState(String(defaults.routingMode ?? 'path'));
const [routingDomain, setRoutingDomain] = useState(String(defaults.routingDomain ?? ''));
@@ -341,7 +341,7 @@ function DefaultResourcesSection({ environment, onSave, saving }: {
useEffect(() => {
setMemoryLimit(String(defaults.memoryLimitMb ?? ''));
setMemoryReserve(String(defaults.memoryReserveMb ?? ''));
setCpuShares(String(defaults.cpuShares ?? ''));
setCpuRequest(String(defaults.cpuRequest ?? ''));
setCpuLimit(String(defaults.cpuLimit ?? ''));
setRoutingMode(String(environment.defaultContainerConfig.routingMode ?? 'path'));
setRoutingDomain(String(environment.defaultContainerConfig.routingDomain ?? ''));
@@ -353,7 +353,7 @@ function DefaultResourcesSection({ environment, onSave, saving }: {
function handleCancel() {
setMemoryLimit(String(defaults.memoryLimitMb ?? ''));
setMemoryReserve(String(defaults.memoryReserveMb ?? ''));
setCpuShares(String(defaults.cpuShares ?? ''));
setCpuRequest(String(defaults.cpuRequest ?? ''));
setCpuLimit(String(defaults.cpuLimit ?? ''));
setRoutingMode(String(defaults.routingMode ?? 'path'));
setRoutingDomain(String(defaults.routingDomain ?? ''));
@@ -366,8 +366,8 @@ function DefaultResourcesSection({ environment, onSave, saving }: {
await onSave({
memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null,
memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null,
cpuShares: cpuShares ? parseInt(cpuShares) : null,
cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null,
cpuRequest: cpuRequest ? parseInt(cpuRequest) : null,
cpuLimit: cpuLimit ? parseInt(cpuLimit) : null,
routingMode,
routingDomain: routingDomain || null,
serverUrl: serverUrl || null,
@@ -393,15 +393,15 @@ function DefaultResourcesSection({ environment, onSave, saving }: {
? <Input value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="e.g. 256" style={{ width: 100 }} />
: <span className={styles.metaValue}>{defaults.memoryReserveMb ? `${defaults.memoryReserveMb} MB` : '—'}</span>}
<span className={styles.metaLabel}>CPU Shares</span>
<span className={styles.metaLabel}>CPU Request</span>
{editing
? <Input value={cpuShares} onChange={(e) => setCpuShares(e.target.value)} placeholder="e.g. 512" style={{ width: 100 }} />
: <span className={styles.metaValue}>{String(defaults.cpuShares ?? '—')}</span>}
? <Input value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} placeholder="e.g. 500" style={{ width: 100 }} />
: <span className={styles.metaValue}>{defaults.cpuRequest ? `${defaults.cpuRequest}m` : '—'}</span>}
<span className={styles.metaLabel}>CPU Limit</span>
{editing
? <Input value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 100 }} />
: <span className={styles.metaValue}>{defaults.cpuLimit ? `${defaults.cpuLimit} cores` : '—'}</span>}
? <Input value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1000" style={{ width: 100 }} />
: <span className={styles.metaValue}>{defaults.cpuLimit ? `${defaults.cpuLimit}m` : '—'}</span>}
<span className={styles.metaLabel}>Routing Mode</span>
{editing

View File

@@ -171,7 +171,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
const defaults = env?.defaultContainerConfig ?? {};
const [memoryLimit, setMemoryLimit] = useState(String(defaults.memoryLimitMb ?? 512));
const [memoryReserve, setMemoryReserve] = useState(String(defaults.memoryReserveMb ?? ''));
const [cpuShares, setCpuShares] = useState(String(defaults.cpuShares ?? 512));
const [cpuRequest, setCpuRequest] = useState(String(defaults.cpuRequest ?? 500));
const [cpuLimit, setCpuLimit] = useState(String(defaults.cpuLimit ?? ''));
const [ports, setPorts] = useState<number[]>(Array.isArray(defaults.exposedPorts) ? defaults.exposedPorts as number[] : []);
const [newPort, setNewPort] = useState('');
@@ -182,7 +182,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
const [stripPrefix, setStripPrefix] = useState(true);
const [sslOffloading, setSslOffloading] = useState(true);
const [configTab, setConfigTab] = useState<'variables' | 'monitoring' | 'resources'>('variables');
const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables'>('monitoring');
const [busy, setBusy] = useState(false);
const [step, setStep] = useState('');
@@ -191,7 +191,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
const d = environments.find((e) => e.id === envId)?.defaultContainerConfig ?? {};
setMemoryLimit(String(d.memoryLimitMb ?? 512));
setMemoryReserve(String(d.memoryReserveMb ?? ''));
setCpuShares(String(d.cpuShares ?? 512));
setCpuRequest(String(d.cpuRequest ?? 500));
setCpuLimit(String(d.cpuLimit ?? ''));
setPorts(Array.isArray(d.exposedPorts) ? d.exposedPorts as number[] : []);
}, [envId, environments]);
@@ -224,8 +224,8 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
const containerConfig: Record<string, unknown> = {
memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null,
memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null,
cpuShares: cpuShares ? parseInt(cpuShares) : null,
cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null,
cpuRequest: cpuRequest ? parseInt(cpuRequest) : null,
cpuLimit: cpuLimit ? parseInt(cpuLimit) : null,
exposedPorts: ports,
customEnvVars: Object.fromEntries(envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])),
appPort: appPort ? parseInt(appPort) : 8080,
@@ -322,9 +322,9 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
{/* Config Tabs */}
<div className={styles.subTabs}>
<button className={`${styles.subTab} ${configTab === 'variables' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('variables')}>Variables</button>
<button className={`${styles.subTab} ${configTab === 'monitoring' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('monitoring')}>Monitoring</button>
<button className={`${styles.subTab} ${configTab === 'resources' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('resources')}>Resources</button>
<button className={`${styles.subTab} ${configTab === 'variables' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('variables')}>Variables</button>
</div>
{configTab === 'variables' && (
@@ -421,13 +421,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
{!isProd && <span className={styles.configHint}>Available in production environments only</span>}
</div>
<span className={styles.configLabel}>CPU Shares</span>
<Input disabled={busy} value={cpuShares} onChange={(e) => setCpuShares(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>CPU Request</span>
<Input disabled={busy} value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>CPU Limit</span>
<div className={styles.configInline}>
<Input disabled={busy} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 80 }} />
<span className={styles.cellMeta}>cores</span>
<Input disabled={busy} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1000" style={{ width: 80 }} />
<span className={styles.cellMeta}>millicores</span>
</div>
<span className={styles.configLabel}>Exposed Ports</span>
@@ -488,7 +488,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
const stopDeployment = useStopDeployment();
const deleteApp = useDeleteApp();
const fileInputRef = useRef<HTMLInputElement>(null);
const [subTab, setSubTab] = useState<'overview' | 'config'>('overview');
const [subTab, setSubTab] = useState<'overview' | 'config'>('config');
const [deleteConfirm, setDeleteConfirm] = useState(false);
const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
@@ -549,8 +549,8 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
</div>
<div className={styles.subTabs}>
<button className={`${styles.subTab} ${subTab === 'overview' ? styles.subTabActive : ''}`} onClick={() => setSubTab('overview')}>Overview</button>
<button className={`${styles.subTab} ${subTab === 'config' ? styles.subTabActive : ''}`} onClick={() => setSubTab('config')}>Configuration</button>
<button className={`${styles.subTab} ${subTab === 'overview' ? styles.subTabActive : ''}`} onClick={() => setSubTab('overview')}>Overview</button>
</div>
{subTab === 'overview' && (
@@ -702,7 +702,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug);
const isProd = environment?.production ?? false;
const [editing, setEditing] = useState(false);
const [configTab, setConfigTab] = useState<'variables' | 'monitoring' | 'traces' | 'recording' | 'resources'>('variables');
const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables' | 'traces' | 'recording'>('monitoring');
const appRoutes: CatalogRoute[] = useMemo(() => {
if (!catalog) return [];
@@ -731,7 +731,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
const merged = useMemo(() => ({ ...defaults, ...app.containerConfig }), [defaults, app.containerConfig]);
const [memoryLimit, setMemoryLimit] = useState('512');
const [memoryReserve, setMemoryReserve] = useState('');
const [cpuShares, setCpuShares] = useState('512');
const [cpuRequest, setCpuRequest] = useState('500');
const [cpuLimit, setCpuLimit] = useState('');
const [ports, setPorts] = useState<number[]>([]);
const [newPort, setNewPort] = useState('');
@@ -758,7 +758,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
}
setMemoryLimit(String(merged.memoryLimitMb ?? 512));
setMemoryReserve(String(merged.memoryReserveMb ?? ''));
setCpuShares(String(merged.cpuShares ?? 512));
setCpuRequest(String(merged.cpuRequest ?? 500));
setCpuLimit(String(merged.cpuLimit ?? ''));
setPorts(Array.isArray(merged.exposedPorts) ? merged.exposedPorts as number[] : []);
const vars = merged.customEnvVars as Record<string, string> | undefined;
@@ -808,8 +808,8 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
const containerConfig: Record<string, unknown> = {
memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null,
memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null,
cpuShares: cpuShares ? parseInt(cpuShares) : null,
cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null,
cpuRequest: cpuRequest ? parseInt(cpuRequest) : null,
cpuLimit: cpuLimit ? parseInt(cpuLimit) : null,
exposedPorts: ports,
customEnvVars: Object.fromEntries(envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])),
appPort: appPort ? parseInt(appPort) : 8080,
@@ -910,11 +910,11 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
)}
<div className={styles.subTabs}>
<button className={`${styles.subTab} ${configTab === 'variables' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('variables')}>Variables</button>
<button className={`${styles.subTab} ${configTab === 'monitoring' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('monitoring')}>Monitoring</button>
<button className={`${styles.subTab} ${configTab === 'resources' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('resources')}>Resources</button>
<button className={`${styles.subTab} ${configTab === 'variables' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('variables')}>Variables</button>
<button className={`${styles.subTab} ${configTab === 'traces' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('traces')}>Traces & Taps</button>
<button className={`${styles.subTab} ${configTab === 'recording' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('recording')}>Route Recording</button>
<button className={`${styles.subTab} ${configTab === 'resources' ? styles.subTabActive : ''}`} onClick={() => setConfigTab('resources')}>Resources</button>
</div>
{configTab === 'variables' && (
@@ -1033,13 +1033,13 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
{!isProd && <span className={styles.configHint}>Available in production environments only</span>}
</div>
<span className={styles.configLabel}>CPU Shares</span>
<Input disabled={!editing} value={cpuShares} onChange={(e) => setCpuShares(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>CPU Request</span>
<Input disabled={!editing} value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} style={{ width: 80 }} />
<span className={styles.configLabel}>CPU Limit</span>
<div className={styles.configInline}>
<Input disabled={!editing} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 80 }} />
<span className={styles.cellMeta}>cores</span>
<Input disabled={!editing} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1000" style={{ width: 80 }} />
<span className={styles.cellMeta}>millicores</span>
</div>
<span className={styles.configLabel}>Exposed Ports</span>