13 phases, TDD-oriented: Flyway V3 snapshot column, staged/live config write flag, dirty-state endpoint, regen OpenAPI, then the new React page (Identity, Checkpoints, 7 tabs including the live-apply Traces+Taps and Route Recording with banner), primary Save/Redeploy state machine, router blocker, old view cleanup, rules docs, and a manual QA walkthrough. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 KiB
Unified App Deployment Page — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace /apps/new (CreateAppView) and /apps/:slug (AppDetailView) with a unified deployment page that supports staged saves, dirty detection against the last successful deploy snapshot, checkpoint restore, and a persistent Deployment tab with progress + logs.
Architecture: Single SPA page component (AppDeploymentPage) renders both net-new and deployed modes, distinguished by app existence. Dirty-state comes from comparing the app's current DB config to a new deployments.deployed_config_snapshot JSONB column captured on every successful deploy. Agent config writes gain an ?apply=staged|live flag so deployment-page saves no longer auto-push to running agents (Dashboard/Runtime keep live-push behavior).
Tech Stack: Spring Boot 3.4.3 + Postgres (Flyway) + ClickHouse backend · React 18 + TanStack Query + React Router v6 + @cameleer/design-system UI · Vitest + JUnit integration tests.
Reference spec: docs/superpowers/specs/2026-04-22-app-deployment-page-design.md
File Structure
Backend (new / modified)
- Create:
cameleer-server-app/src/main/resources/db/migration/V3__deployment_config_snapshot.sql— addsdeployed_config_snapshot JSONBondeployments. - Modify:
cameleer-server-app/src/main/java/com/cameleer/server/core/runtime/Deployment.java(or the record defining theDeploymentmodel) — adddeployedConfigSnapshotfield. - Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java— read/write snapshot column. - Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java— populate snapshot on successful completion. - Create:
cameleer-server-app/src/main/java/com/cameleer/server/core/runtime/DeploymentConfigSnapshot.java— record carrying{jarVersionId, agentConfig, containerConfig}. - Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java— add?apply=staged|liveparam (defaultlive) on PUT. - Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java— addGET /apps/{appSlug}/dirty-state. - Create:
cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java— DTO{dirty: boolean, lastSuccessfulDeploymentId: String|null, differences: List<Difference>}. - Create:
cameleer-server-app/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java— pure service comparing current config to snapshot, producing differences. - Modify:
cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java— add staged/live tests. - Create:
cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java— dirty-state endpoint tests. - Create:
cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java— snapshot-on-success tests. - Create:
cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java— pure unit tests.
UI (new / modified / deleted)
- Create:
ui/src/pages/AppsTab/AppDeploymentPage/index.tsx— main page component (net-new and deployed modes). - Create:
ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx— always-visible Identity & Artifact section. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx— past successful deployments disclosure. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx— Save/Redeploy state-machine button. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/MonitoringTab.tsx - Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx - Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/VariablesTab.tsx - Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/SensitiveKeysTab.tsx - Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/TracesTapsTab.tsx— with live banner. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/RouteRecordingTab.tsx— with live banner. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/LiveBanner.tsx— shared amber banner component. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx - Create:
ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/StatusCard.tsx - Create:
ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx - Create:
ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts— orchestrating form state hook. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDirtyState.ts— wrapper around dirty-state endpoint. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts— router blocker + confirm dialog. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.ts— filename → name pure function. - Create:
ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts - Create:
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css - Modify:
ui/src/api/queries/admin/apps.ts— adduseDirtyStatehook; changeuseUpdateApplicationConfigto acceptapplyparam. - Modify:
ui/src/api/queries/commands.ts— same staged flag plumbing ifuseUpdateApplicationConfiglives here. - Modify:
ui/src/router.tsx— route/apps/newand/apps/:appIdboth toAppDeploymentPage. - Modify:
ui/src/pages/AppsTab/AppsTab.tsx— reduce to onlyAppListView. - Modify:
ui/src/components/StartupLogPanel.tsx— drop fixed 300px, acceptclassNameso parent can flex-grow. - Delete: all of
CreateAppView,AppDetailView,OverviewSubTab,ConfigSubTab,VersionRowinAppsTab.tsx(keep onlyAppListView). - Modify:
.claude/rules/ui.md— rewrite Deployments bullet. - Modify:
.claude/rules/app-classes.md— note?apply=staged|liveonApplicationConfigControllerand newGET /apps/{appSlug}/dirty-stateonAppController. - Regenerate:
ui/src/api/openapi.json,ui/src/api/schema.d.ts.
Phase 1 — Backend: deployment config snapshot column
Task 1.1: Flyway V3 migration
Files:
-
Create:
cameleer-server-app/src/main/resources/db/migration/V3__deployment_config_snapshot.sql -
Step 1: Inspect current V2 to match style
Read cameleer-server-app/src/main/resources/db/migration/V2__add_environment_color.sql — format has a header comment, single ALTER TABLE.
- Step 2: Write V3 migration
-- V3: per-deployment config snapshot for "last known good" + dirty detection
-- Captures {jarVersionId, agentConfig, containerConfig} at the moment a
-- deployment transitions to RUNNING. Historical rows are NULL; dirty detection
-- treats NULL as "everything dirty" and the next successful Redeploy populates it.
ALTER TABLE deployments
ADD COLUMN deployed_config_snapshot JSONB;
- Step 3: Verify Flyway picks it up on a clean start
Run (in a dev shell — the local Postgres must already be up per your local-services policy):
mvn -pl cameleer-server-app -am clean verify -Dtest=SchemaBootstrapIT -DfailIfNoTests=false
Expected: SchemaBootstrapIT passes — it validates V1 + V2 + V3 apply cleanly.
- Step 4: Commit
git add cameleer-server-app/src/main/resources/db/migration/V3__deployment_config_snapshot.sql
git commit -m "db(deploy): add deployments.deployed_config_snapshot column (V3)"
Task 1.2: DeploymentConfigSnapshot record
Files:
-
Create:
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentConfigSnapshot.java -
Step 1: Write the record
package com.cameleer.server.core.runtime;
import com.cameleer.common.model.ApplicationConfig;
import java.util.Map;
/**
* Snapshot of the config that was deployed, captured at the moment a deployment
* transitions to RUNNING. Used for "last known good" restore (checkpoints) and
* for dirty-state detection on the deployment page.
*
* <p>This is persisted as JSONB in {@code deployments.deployed_config_snapshot}.</p>
*/
public record DeploymentConfigSnapshot(
String jarVersionId,
ApplicationConfig agentConfig,
Map<String, Object> containerConfig
) {
}
- Step 2: Compile
Run:
mvn -pl cameleer-server-core -am compile
Expected: BUILD SUCCESS.
- Step 3: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentConfigSnapshot.java
git commit -m "core(deploy): add DeploymentConfigSnapshot record"
Task 1.3: Deployment model carries the snapshot field
Files:
-
Modify:
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java(exact path — confirm during implementation; may live incameleer-server-app) -
Step 1: Locate the Deployment record
Run:
grep -rln "record Deployment\b\|class Deployment\b" cameleer-server-core/ cameleer-server-app/
Expected: finds one definition (record or class). The subsequent step adapts to whichever shape it is.
- Step 2: Add the field
If Deployment is a record, append DeploymentConfigSnapshot deployedConfigSnapshot to the record header. If it is a class, add a private DeploymentConfigSnapshot deployedConfigSnapshot; field with getter/setter in the project's existing style.
For a record, the header becomes:
public record Deployment(
UUID id,
UUID appId,
UUID appVersionId,
UUID environmentId,
DeploymentStatus status,
// ... existing fields unchanged ...
DeploymentConfigSnapshot deployedConfigSnapshot
) { }
- Step 3: Compile — existing callers will flag
Run:
mvn -pl cameleer-server-core -am compile
Expected: failures in callers that construct Deployment with positional args (repository mappers, tests). Note each error site.
- Step 4: Fix all call sites
For every compile error, pass null as the new last argument. The repository layer (Task 1.4) will be updated to read the real value; tests that don't care keep null.
- Step 5: Compile again
Run:
mvn -pl cameleer-server-core -am compile
Expected: BUILD SUCCESS.
- Step 6: Commit
git add -A
git commit -m "core(deploy): add deployedConfigSnapshot field to Deployment model"
Task 1.4: PostgresDeploymentRepository reads/writes snapshot
Files:
-
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java -
Test:
cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryIT.java(existing or new) -
Step 1: Write failing IT — store and retrieve a snapshot
Add to PostgresDeploymentRepositoryIT (create if missing, mirroring existing repository IT style):
@Test
void deployedConfigSnapshot_roundtrips() {
// given — insert a deployment with a snapshot
ApplicationConfig agent = new ApplicationConfig();
agent.setApplication("app-it");
agent.setEnvironment("staging");
agent.setVersion(3);
agent.setSamplingRate(0.5);
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
"version-abc",
agent,
Map.of("memoryLimitMb", 1024, "replicas", 2)
);
Deployment input = seedDeployment() // test helper that fills required fields
.withDeployedConfigSnapshot(snapshot);
repository.save(input);
// when — load it back
Deployment loaded = repository.findById(input.id()).orElseThrow();
// then
assertThat(loaded.deployedConfigSnapshot()).isNotNull();
assertThat(loaded.deployedConfigSnapshot().jarVersionId()).isEqualTo("version-abc");
assertThat(loaded.deployedConfigSnapshot().agentConfig().getSamplingRate()).isEqualTo(0.5);
assertThat(loaded.deployedConfigSnapshot().containerConfig()).containsEntry("memoryLimitMb", 1024);
}
Run it:
mvn -pl cameleer-server-app test -Dtest=PostgresDeploymentRepositoryIT#deployedConfigSnapshot_roundtrips
Expected: FAIL — snapshot column is not read/written.
- Step 2: Update the repository mapper
Locate the RowMapper<Deployment> and add snapshot parsing:
// Inside the RowMapper
String snapshotJson = rs.getString("deployed_config_snapshot");
DeploymentConfigSnapshot snapshot = null;
if (snapshotJson != null) {
try {
snapshot = objectMapper.readValue(snapshotJson, DeploymentConfigSnapshot.class);
} catch (Exception e) {
log.warn("Failed to parse deployed_config_snapshot for deployment {}: {}", id, e.getMessage());
}
}
return new Deployment(
// ... existing fields ...
snapshot
);
Update the INSERT/UPDATE SQL:
// INSERT: add column + placeholder
// Existing: INSERT INTO deployments (id, app_id, ..., replica_states) VALUES (?, ?, ..., ?::jsonb)
// After: INSERT INTO deployments (id, app_id, ..., replica_states, deployed_config_snapshot) VALUES (?, ?, ..., ?::jsonb, ?::jsonb)
// Serialize snapshot to JSON (null -> null)
String snapshotJson = d.deployedConfigSnapshot() != null
? objectMapper.writeValueAsString(d.deployedConfigSnapshot())
: null;
ps.setObject(nextIdx++, snapshotJson);
Mirror the same change in the UPDATE statement.
- Step 3: Re-run the IT
Run:
mvn -pl cameleer-server-app test -Dtest=PostgresDeploymentRepositoryIT#deployedConfigSnapshot_roundtrips
Expected: PASS.
- Step 4: Run full repository IT suite to confirm no regressions
Run:
mvn -pl cameleer-server-app test -Dtest=PostgresDeploymentRepositoryIT
Expected: PASS.
- Step 5: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java \
cameleer-server-app/src/test/java/com/cameleer/server/app/storage/PostgresDeploymentRepositoryIT.java
git commit -m "storage(deploy): persist deployed_config_snapshot as JSONB"
Task 1.5: DeploymentExecutor writes snapshot on successful COMPLETE
Files:
-
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java -
Test:
cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java -
Step 1: Read DeploymentExecutor to locate the COMPLETE transition
Run:
grep -n "COMPLETE\|RUNNING\|deployedAt" cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java | head -30
Identify the method that transitions the deployment to RUNNING/COMPLETE.
- Step 2: Write failing IT — snapshot appears on success
Create DeploymentSnapshotIT:
package com.cameleer.server.app.runtime;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import java.util.concurrent.TimeUnit;
@SpringBootTest
@ActiveProfiles("it")
class DeploymentSnapshotIT extends BaseIT { // reuse existing BaseIT with Postgres + Docker
@Autowired DeploymentService deploymentService;
@Autowired DeploymentRepository deploymentRepository;
@Test
void snapshot_isPopulated_whenDeploymentReachesRunning() throws Exception {
// given — a managed app with a JAR version and config saved
String envSlug = seedEnv("snap-it").slug();
String appSlug = seedApp(envSlug, "snap-app").slug();
String versionId = uploadTinyJar(envSlug, appSlug).id();
saveConfig(envSlug, appSlug, cfg -> cfg.setSamplingRate(0.25));
// when — deploy
UUID deploymentId = deploymentService.create(envSlug, appSlug, versionId).id();
// then — wait for RUNNING, snapshot is populated
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> {
Deployment d = deploymentRepository.findById(deploymentId).orElseThrow();
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
assertThat(d.deployedConfigSnapshot()).isNotNull();
assertThat(d.deployedConfigSnapshot().jarVersionId()).isEqualTo(versionId);
assertThat(d.deployedConfigSnapshot().agentConfig().getSamplingRate()).isEqualTo(0.25);
});
}
@Test
void snapshot_isNotPopulated_whenDeploymentFails() throws Exception {
// given — deployment configured to fail at PULL_IMAGE (nonexistent image tag)
String envSlug = seedEnv("snap-fail").slug();
String appSlug = seedApp(envSlug, "fail-app").slug();
String versionId = uploadUnresolvableJar(envSlug, appSlug).id(); // helper forces PULL_IMAGE fail
UUID deploymentId = deploymentService.create(envSlug, appSlug, versionId).id();
await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> {
Deployment d = deploymentRepository.findById(deploymentId).orElseThrow();
assertThat(d.status()).isEqualTo(DeploymentStatus.FAILED);
assertThat(d.deployedConfigSnapshot()).isNull();
});
}
}
Run:
mvn -pl cameleer-server-app test -Dtest=DeploymentSnapshotIT
Expected: FAIL — snapshot never set by the executor.
- Step 3: Write snapshot in DeploymentExecutor on successful COMPLETE
Find the success path (where status transitions to RUNNING, deployedAt is set) and inject:
// Before persisting the RUNNING transition
ApplicationConfig agentConfig = configRepository
.findByApplicationAndEnvironment(app.slug(), env.slug())
.orElse(null);
Map<String, Object> containerConfig = app.containerConfig(); // Map<String,Object> already
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
deployment.appVersionId().toString(),
agentConfig,
containerConfig
);
Deployment updated = deployment
.withStatus(DeploymentStatus.RUNNING)
.withDeployStage("COMPLETE")
.withDeployedAt(Instant.now())
.withDeployedConfigSnapshot(snapshot);
deploymentRepository.save(updated);
Adapt field names to whichever "with..." helpers the existing Deployment record provides. If the record has no wither helpers, construct a new Deployment(...) with all fields.
Do not populate snapshot on FAIL paths — leave as null.
- Step 4: Re-run IT
Run:
mvn -pl cameleer-server-app test -Dtest=DeploymentSnapshotIT
Expected: both tests PASS.
- Step 5: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/runtime/DeploymentExecutor.java \
cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java
git commit -m "runtime(deploy): capture config snapshot on RUNNING transition"
Phase 2 — Backend: staged vs live config writes
Task 2.1: ?apply=staged|live query param on ApplicationConfigController
Files:
-
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java -
Test:
cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java(add tests, do not replace) -
Step 1: Write failing IT — staged write does not push
Add to ApplicationConfigControllerIT:
@Test
void putConfig_staged_savesButDoesNotPush() throws Exception {
// given — one LIVE agent for (app, env)
String envSlug = seedEnv("staged-env").slug();
seedAgent(envSlug, "paygw", AgentState.LIVE);
ApplicationConfig cfg = new ApplicationConfig();
cfg.setApplication("paygw");
cfg.setSamplingRate(0.1);
// when — PUT with apply=staged
mockMvc.perform(put("/api/v1/environments/" + envSlug + "/apps/paygw/config")
.param("apply", "staged")
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(cfg)))
.andExpect(status().isOk());
// then — DB has the new config
ApplicationConfig saved = configRepository.findByApplicationAndEnvironment("paygw", envSlug).orElseThrow();
assertThat(saved.getSamplingRate()).isEqualTo(0.1);
// and — no CONFIG_UPDATE command was pushed
List<Command> pushed = agentRegistryService.findPushedCommands(); // test helper on a spy/recorder
assertThat(pushed).noneMatch(c -> c.type() == CommandType.CONFIG_UPDATE);
}
@Test
void putConfig_live_savesAndPushes() throws Exception {
String envSlug = seedEnv("live-env").slug();
seedAgent(envSlug, "paygw", AgentState.LIVE);
ApplicationConfig cfg = new ApplicationConfig();
cfg.setApplication("paygw");
cfg.setSamplingRate(0.2);
mockMvc.perform(put("/api/v1/environments/" + envSlug + "/apps/paygw/config")
// no apply param → default live
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(cfg)))
.andExpect(status().isOk());
List<Command> pushed = agentRegistryService.findPushedCommands();
assertThat(pushed).anyMatch(c -> c.type() == CommandType.CONFIG_UPDATE);
}
Run:
mvn -pl cameleer-server-app test -Dtest=ApplicationConfigControllerIT#putConfig_staged_savesButDoesNotPush
Expected: FAIL — there is no apply param, push happens unconditionally.
- Step 2: Add the query param and gate the push
Modify ApplicationConfigController.updateConfig:
@PutMapping("/apps/{appSlug}/config")
@Operation(summary = "Update application config for this environment",
description = "Saves config. When apply=live (default), also pushes CONFIG_UPDATE to LIVE agents. "
+ "When apply=staged, persists without a live push — the next successful deploy applies it.")
@ApiResponse(responseCode = "200", description = "Config saved (and pushed if apply=live)")
public ResponseEntity<ConfigUpdateResponse> updateConfig(@EnvPath Environment env,
@PathVariable String appSlug,
@RequestParam(name = "apply", defaultValue = "live") String apply,
@RequestBody ApplicationConfig config,
Authentication auth,
HttpServletRequest httpRequest) {
String updatedBy = auth != null ? auth.getName() : "system";
config.setApplication(appSlug);
ApplicationConfig saved = configRepository.save(appSlug, env.slug(), config, updatedBy);
List<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
.orElse(null);
List<String> perAppKeys = extractSensitiveKeys(saved);
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
CommandGroupResponse pushResult;
if ("staged".equalsIgnoreCase(apply)) {
pushResult = new CommandGroupResponse(true, 0, 0, List.of(), List.of());
log.info("Config v{} staged for '{}' (no live push)", saved.getVersion(), appSlug);
} else {
pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys);
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
saved.getVersion(), appSlug, pushResult.total(), pushResult.responded());
}
auditService.log(
"staged".equalsIgnoreCase(apply) ? "stage_app_config" : "update_app_config",
AuditCategory.CONFIG, appSlug,
Map.of("environment", env.slug(), "version", saved.getVersion(),
"apply", apply,
"agentsPushed", pushResult.total(),
"responded", pushResult.responded(),
"timedOut", pushResult.timedOut().size()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));
}
Reject unknown values:
if (!"staged".equalsIgnoreCase(apply) && !"live".equalsIgnoreCase(apply)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ConfigUpdateResponse(null, null)); // or throw 400 via exception handler
}
(Put the validation at the top of the method, before the save.)
- Step 3: Re-run both IT tests
Run:
mvn -pl cameleer-server-app test -Dtest=ApplicationConfigControllerIT#putConfig_staged_savesButDoesNotPush+putConfig_live_savesAndPushes
Expected: both PASS.
- Step 4: Commit
git add cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java \
cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ApplicationConfigControllerIT.java
git commit -m "api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config"
Phase 3 — Backend: dirty-state endpoint
Task 3.1: DirtyStateCalculator pure service + unit test
Files:
-
Create:
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java -
Create:
cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java -
Step 1: Write failing unit test
package com.cameleer.server.core.runtime;
import com.cameleer.common.model.ApplicationConfig;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class DirtyStateCalculatorTest {
@Test
void noSnapshot_meansEverythingDirty() {
DirtyStateCalculator calc = new DirtyStateCalculator();
ApplicationConfig desiredAgent = new ApplicationConfig();
desiredAgent.setSamplingRate(1.0);
Map<String, Object> desiredContainer = Map.of("memoryLimitMb", 512);
DirtyStateResult result = calc.compute("v1", desiredAgent, desiredContainer, null);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("snapshot"); // sentinel for "no snapshot"
}
@Test
void identicalSnapshot_isClean() {
DirtyStateCalculator calc = new DirtyStateCalculator();
ApplicationConfig cfg = new ApplicationConfig();
cfg.setSamplingRate(0.5);
Map<String, Object> container = Map.of("memoryLimitMb", 512);
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot("v1", cfg, container);
DirtyStateResult result = calc.compute("v1", cfg, container, snap);
assertThat(result.dirty()).isFalse();
assertThat(result.differences()).isEmpty();
}
@Test
void differentJar_marksJarField() {
DirtyStateCalculator calc = new DirtyStateCalculator();
ApplicationConfig cfg = new ApplicationConfig();
Map<String, Object> container = Map.of();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot("v1", cfg, container);
DirtyStateResult result = calc.compute("v2", cfg, container, snap);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("jarVersionId");
}
@Test
void differentSamplingRate_marksAgentField() {
DirtyStateCalculator calc = new DirtyStateCalculator();
ApplicationConfig deployedCfg = new ApplicationConfig();
deployedCfg.setSamplingRate(0.5);
ApplicationConfig desiredCfg = new ApplicationConfig();
desiredCfg.setSamplingRate(1.0);
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot("v1", deployedCfg, Map.of());
DirtyStateResult result = calc.compute("v1", desiredCfg, Map.of(), snap);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("agentConfig.samplingRate");
}
@Test
void differentContainerMemory_marksContainerField() {
DirtyStateCalculator calc = new DirtyStateCalculator();
ApplicationConfig cfg = new ApplicationConfig();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot("v1", cfg, Map.of("memoryLimitMb", 512));
DirtyStateResult result = calc.compute("v1", cfg, Map.of("memoryLimitMb", 1024), snap);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("containerConfig.memoryLimitMb");
}
}
Run:
mvn -pl cameleer-server-core test -Dtest=DirtyStateCalculatorTest
Expected: FAIL — class doesn't exist.
- Step 2: Write the implementation
package com.cameleer.server.core.runtime;
import com.cameleer.common.model.ApplicationConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Compares the app's current desired state (JAR + agent config + container config) to the
* config snapshot from the last successful deployment, producing a structured dirty result.
*
* <p>Pure logic — no IO, no Spring. Safe to unit-test as a POJO.</p>
*/
public class DirtyStateCalculator {
private static final ObjectMapper MAPPER = new ObjectMapper();
public DirtyStateResult compute(String desiredJarVersionId,
ApplicationConfig desiredAgentConfig,
Map<String, Object> desiredContainerConfig,
DeploymentConfigSnapshot snapshot) {
List<DirtyStateResult.Difference> diffs = new ArrayList<>();
if (snapshot == null) {
diffs.add(new DirtyStateResult.Difference("snapshot", "(none)", "(none)"));
return new DirtyStateResult(true, diffs);
}
if (!Objects.equals(desiredJarVersionId, snapshot.jarVersionId())) {
diffs.add(new DirtyStateResult.Difference("jarVersionId",
String.valueOf(desiredJarVersionId), String.valueOf(snapshot.jarVersionId())));
}
compareJson("agentConfig", MAPPER.valueToTree(desiredAgentConfig),
MAPPER.valueToTree(snapshot.agentConfig()), diffs);
compareJson("containerConfig", MAPPER.valueToTree(desiredContainerConfig),
MAPPER.valueToTree(snapshot.containerConfig()), diffs);
return new DirtyStateResult(!diffs.isEmpty(), diffs);
}
private void compareJson(String prefix, JsonNode desired, JsonNode deployed,
List<DirtyStateResult.Difference> diffs) {
if (!(desired instanceof ObjectNode desiredObj) || !(deployed instanceof ObjectNode deployedObj)) {
if (!Objects.equals(desired, deployed)) {
diffs.add(new DirtyStateResult.Difference(prefix, String.valueOf(desired), String.valueOf(deployed)));
}
return;
}
// union of keys
java.util.Set<String> keys = new java.util.TreeSet<>();
desiredObj.fieldNames().forEachRemaining(keys::add);
deployedObj.fieldNames().forEachRemaining(keys::add);
for (String key : keys) {
JsonNode d = desiredObj.get(key);
JsonNode p = deployedObj.get(key);
if (!Objects.equals(d, p)) {
diffs.add(new DirtyStateResult.Difference(prefix + "." + key,
String.valueOf(d), String.valueOf(p)));
}
}
}
}
Create the result record:
// cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java
package com.cameleer.server.core.runtime;
import java.util.List;
public record DirtyStateResult(boolean dirty, List<Difference> differences) {
public record Difference(String field, String staged, String deployed) {}
}
- Step 3: Re-run unit test
Run:
mvn -pl cameleer-server-core test -Dtest=DirtyStateCalculatorTest
Expected: all PASS.
- Step 4: Commit
git add cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateCalculator.java \
cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DirtyStateResult.java \
cameleer-server-core/src/test/java/com/cameleer/server/core/runtime/DirtyStateCalculatorTest.java
git commit -m "core(deploy): add DirtyStateCalculator + DirtyStateResult"
Task 3.2: GET /apps/{appSlug}/dirty-state endpoint on AppController
Files:
-
Modify:
cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java -
Create:
cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java -
Test:
cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java -
Step 1: Write DTO
// cameleer-server-app/src/main/java/com/cameleer/server/app/dto/DirtyStateResponse.java
package com.cameleer.server.app.dto;
import com.cameleer.server.core.runtime.DirtyStateResult;
import java.util.List;
public record DirtyStateResponse(
boolean dirty,
String lastSuccessfulDeploymentId,
List<DirtyStateResult.Difference> differences
) {
}
- Step 2: Write failing IT
package com.cameleer.server.app.controller;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
// imports...
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
class AppDirtyStateIT extends BaseIT {
@Test
void dirtyState_noDeployEver_returnsDirtyTrue() throws Exception {
String envSlug = seedEnv("d1").slug();
String appSlug = seedApp(envSlug, "paygw").slug();
uploadTinyJar(envSlug, appSlug);
saveConfig(envSlug, appSlug, c -> c.setSamplingRate(0.5));
mockMvc.perform(get("/api/v1/environments/{e}/apps/{a}/dirty-state", envSlug, appSlug)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.dirty").value(true))
.andExpect(jsonPath("$.lastSuccessfulDeploymentId").doesNotExist());
}
@Test
void dirtyState_afterSuccessfulDeploy_matchingDb_returnsDirtyFalse() throws Exception {
String envSlug = seedEnv("d2").slug();
String appSlug = seedApp(envSlug, "paygw").slug();
String versionId = uploadTinyJar(envSlug, appSlug).id();
saveConfig(envSlug, appSlug, c -> c.setSamplingRate(0.5));
deployAndAwaitRunning(envSlug, appSlug, versionId);
mockMvc.perform(get("/api/v1/environments/{e}/apps/{a}/dirty-state", envSlug, appSlug)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.dirty").value(false))
.andExpect(jsonPath("$.differences").isEmpty());
}
@Test
void dirtyState_afterSuccessfulDeploy_configChanged_returnsDirtyTrue() throws Exception {
String envSlug = seedEnv("d3").slug();
String appSlug = seedApp(envSlug, "paygw").slug();
String versionId = uploadTinyJar(envSlug, appSlug).id();
saveConfig(envSlug, appSlug, c -> c.setSamplingRate(0.5));
deployAndAwaitRunning(envSlug, appSlug, versionId);
// Change config (staged)
mockMvc.perform(put("/api/v1/environments/{e}/apps/{a}/config?apply=staged", envSlug, appSlug)
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + adminToken)
.content("{\"application\":\"paygw\",\"samplingRate\":1.0}"))
.andExpect(status().isOk());
mockMvc.perform(get("/api/v1/environments/{e}/apps/{a}/dirty-state", envSlug, appSlug)
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.dirty").value(true))
.andExpect(jsonPath("$.differences[?(@.field=='agentConfig.samplingRate')]").exists());
}
}
Run:
mvn -pl cameleer-server-app test -Dtest=AppDirtyStateIT
Expected: FAIL — endpoint does not exist, 404.
- Step 3: Add the endpoint
In AppController:
@GetMapping("/{appSlug}/dirty-state")
@Operation(summary = "Check whether the app's current config differs from the last successful deploy",
description = "Returns dirty=true when the desired state (current JAR + agent config + container config) "
+ "would produce a changed deployment. When no successful deploy exists yet, dirty=true.")
@ApiResponse(responseCode = "200", description = "Dirty-state computed")
public ResponseEntity<DirtyStateResponse> getDirtyState(@EnvPath Environment env,
@PathVariable String appSlug) {
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
List<AppVersion> versions = appVersionRepository.findByApp(app.id());
String latestVersionId = versions.isEmpty() ? null : versions.get(0).id().toString();
ApplicationConfig agentConfig = configRepository
.findByApplicationAndEnvironment(appSlug, env.slug())
.orElse(null);
Map<String, Object> containerConfig = app.containerConfig();
Deployment lastSuccessful = deploymentRepository
.findLatestSuccessfulByAppAndEnv(app.id(), env.id())
.orElse(null);
DeploymentConfigSnapshot snapshot = lastSuccessful != null ? lastSuccessful.deployedConfigSnapshot() : null;
DirtyStateResult result = dirtyCalc.compute(latestVersionId, agentConfig, containerConfig, snapshot);
String lastId = lastSuccessful != null ? lastSuccessful.id().toString() : null;
return ResponseEntity.ok(new DirtyStateResponse(result.dirty(), lastId, result.differences()));
}
Wire DirtyStateCalculator as a @Bean in RuntimeBeanConfig (or register via @Component annotation on the class).
Add findLatestSuccessfulByAppAndEnv to PostgresDeploymentRepository:
public Optional<Deployment> findLatestSuccessfulByAppAndEnv(UUID appId, UUID envId) {
return jdbcTemplate.query(
"SELECT * FROM deployments WHERE app_id = ? AND environment_id = ? "
+ "AND status = 'RUNNING' AND deployed_config_snapshot IS NOT NULL "
+ "ORDER BY deployed_at DESC LIMIT 1",
rowMapper, appId, envId
).stream().findFirst();
}
- Step 4: Re-run IT
Run:
mvn -pl cameleer-server-app test -Dtest=AppDirtyStateIT
Expected: all PASS.
- Step 5: Commit
git add -A
git commit -m "api(apps): GET /apps/{slug}/dirty-state returns desired-vs-deployed diff"
Phase 4 — Regenerate OpenAPI + TypeScript types
Task 4.1: Regenerate and commit
Files:
-
Modify:
ui/src/api/openapi.json -
Modify:
ui/src/api/schema.d.ts -
Step 1: Start the backend locally
Per your local-services policy, start Postgres + ClickHouse + cameleer-server. Minimal flow (adapt to your docker-compose / mvn spring-boot:run setup):
# Terminal 1: backend on :8081 per project convention
mvn -pl cameleer-server-app spring-boot:run
Wait until log shows Started CameleerServerApplication.
- Step 2: Regenerate types
cd ui && npm run generate-api:live
Expected: ui/src/api/openapi.json updated; ui/src/api/schema.d.ts updated with new operations["getDirtyState"], new apply query param on updateConfig, and new deployedConfigSnapshot in the Deployment schema.
- Step 3: Sanity-check changes
git diff --stat ui/src/api/
Expected: both files changed.
grep -n "dirty-state\|deployedConfigSnapshot\|\"apply\"" ui/src/api/openapi.json
Expected: hits for all three.
- Step 4: Commit
git add ui/src/api/openapi.json ui/src/api/schema.d.ts
git commit -m "api(schema): regenerate OpenAPI + schema.d.ts for deployment page"
Phase 5 — UI: scaffolding and routing
Task 5.1: Create empty AppDeploymentPage shell
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/index.tsx -
Create:
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css -
Step 1: Write the shell
// ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
import { useParams, useLocation } from 'react-router';
import { useEnvironmentStore } from '../../../api/environment-store';
import { useEnvironments } from '../../../api/queries/admin/environments';
import { useApps } from '../../../api/queries/admin/apps';
import { PageLoader } from '../../../components/PageLoader';
import styles from './AppDeploymentPage.module.css';
export default function AppDeploymentPage() {
const { appId } = useParams<{ appId?: string }>();
const location = useLocation();
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: environments = [], isLoading: envLoading } = useEnvironments();
const { data: apps = [], isLoading: appsLoading } = useApps(selectedEnv);
const isNetNew = location.pathname.endsWith('/apps/new');
const app = isNetNew ? null : apps.find((a) => a.slug === appId) ?? null;
if (envLoading || appsLoading) return <PageLoader />;
return (
<div className={styles.container}>
<h2>{app ? app.displayName : 'Create Application'}</h2>
{/* Identity section, tabs, primary button land in subsequent tasks */}
</div>
);
}
/* ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css */
.container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 24px;
min-height: 100%;
}
- Step 2: Update router
Modify ui/src/router.tsx. Replace the existing AppsTab route block (two routes — list + detail + new) with:
// before the AppsTab import:
import AppDeploymentPage from './pages/AppsTab/AppDeploymentPage';
// inside the routes array:
{ path: 'apps', element: <AppsTab /> }, // list stays
{ path: 'apps/new', element: <AppDeploymentPage /> },
{ path: 'apps/:appId', element: <AppDeploymentPage /> },
Important: apps/new must be declared before apps/:appId so the static path matches first.
- Step 3: Verify dev build
Run:
cd ui && npm run build
Expected: BUILD SUCCESS, no TS errors.
- Step 4: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/ ui/src/router.tsx
git commit -m "ui(deploy): scaffold AppDeploymentPage + route /apps/new and /apps/:slug"
Task 5.2: Add useDirtyState hook
Files:
-
Modify:
ui/src/api/queries/admin/apps.ts -
Step 1: Add types + hook
Append to ui/src/api/queries/admin/apps.ts:
export interface DirtyStateDifference {
field: string;
staged: string;
deployed: string;
}
export interface DirtyState {
dirty: boolean;
lastSuccessfulDeploymentId: string | null;
differences: DirtyStateDifference[];
}
export function useDirtyState(envSlug: string | undefined, appSlug: string | undefined) {
return useQuery({
queryKey: ['apps', envSlug, appSlug, 'dirty-state'],
queryFn: () => apiFetch<DirtyState>(
`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/dirty-state`,
),
enabled: !!envSlug && !!appSlug,
});
}
- Step 2: Extend
useUpdateApplicationConfigwithapply
Find useUpdateApplicationConfig in ui/src/api/queries/commands.ts (per import in AppsTab.tsx). Read the existing signature, then add apply?: 'staged' | 'live' defaulting to 'live':
export function useUpdateApplicationConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ config, environment, apply = 'live' }: {
config: ApplicationConfig;
environment: string;
apply?: 'staged' | 'live';
}) => apiFetch<ConfigUpdateResponse>(
`/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(config.application)}/config?apply=${apply}`,
{ method: 'PUT', body: JSON.stringify(config) },
),
// ... existing onSuccess
});
}
Preserve existing invalidation logic.
- Step 3: TS build
cd ui && npm run build
Expected: BUILD SUCCESS.
- Step 4: Commit
git add ui/src/api/queries/
git commit -m "ui(api): add useDirtyState + apply=staged|live on useUpdateApplicationConfig"
Phase 6 — UI: Identity & Artifact section
Task 6.1: deriveAppName pure function + test
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.ts -
Create:
ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts -
Step 1: Write failing test
// ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts
import { describe, it, expect } from 'vitest';
import { deriveAppName } from './deriveAppName';
describe('deriveAppName', () => {
it('truncates at first digit', () => {
expect(deriveAppName('payment-gateway-1.2.0.jar')).toBe('Payment Gateway');
});
it('returns clean title-cased name without digits', () => {
expect(deriveAppName('order-service.jar')).toBe('Order Service');
});
it('strips orphan 1-char token after truncation (v from my-app-v2)', () => {
expect(deriveAppName('my-app-v2.jar')).toBe('My App');
});
it('treats underscore like dash', () => {
expect(deriveAppName('acme_billing-3.jar')).toBe('Acme Billing');
});
it('strips the .jar extension when no digits present', () => {
expect(deriveAppName('acme-billing.jar')).toBe('Acme Billing');
});
it('returns empty string for empty input', () => {
expect(deriveAppName('')).toBe('');
});
it('returns empty string when filename starts with a digit', () => {
expect(deriveAppName('1-my-thing.jar')).toBe('');
});
it('mixed separators are both collapsed to spaces', () => {
expect(deriveAppName('foo_bar-baz.jar')).toBe('Foo Bar Baz');
});
it('strips trailing orphan regardless of letter identity', () => {
expect(deriveAppName('release-x9.jar')).toBe('Release');
});
});
Run:
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts
Expected: FAIL (module doesn't exist).
- Step 2: Write the implementation
// ui/src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.ts
/**
* Derive a human-readable app name from a JAR filename.
*
* Rule:
* 1. Strip the `.jar` extension.
* 2. Truncate at the first digit (0-9) or `.`.
* 3. Replace `-` and `_` with spaces.
* 4. Collapse multiple spaces and trim.
* 5. Drop 1-char orphan tokens (e.g. the trailing `v` in `my-app-v2`).
* 6. Title-case each remaining word.
*
* The result is a *suggestion* — the caller is expected to let the user override.
*/
export function deriveAppName(filename: string): string {
if (!filename) return '';
let stem = filename.replace(/\.jar$/i, '');
// Truncate at first digit or dot
const match = stem.match(/[0-9.]/);
if (match && match.index !== undefined) {
stem = stem.slice(0, match.index);
}
// Separators → space
stem = stem.replace(/[-_]+/g, ' ');
// Collapse whitespace + trim
stem = stem.replace(/\s+/g, ' ').trim();
if (!stem) return '';
// Drop 1-char orphan tokens
const tokens = stem.split(' ').filter((t) => t.length > 1);
if (tokens.length === 0) return '';
// Title-case
return tokens.map((t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase()).join(' ');
}
- Step 3: Re-run test
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts
Expected: all PASS.
- Step 4: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/utils/
git commit -m "ui(deploy): add deriveAppName pure function + tests"
Task 6.2: Identity & Artifact component
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx -
Step 1: Write the component
// ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx
import { useRef } from 'react';
import { SectionHeader, Input, MonoText, Button, Badge } from '@cameleer/design-system';
import type { App, AppVersion } from '../../../api/queries/admin/apps';
import type { Environment } from '../../../api/queries/admin/environments';
import styles from './AppDeploymentPage.module.css';
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100);
}
function formatBytes(bytes: number): string {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
interface IdentitySectionProps {
mode: 'net-new' | 'deployed';
environment: Environment;
app: App | null;
currentVersion: AppVersion | null;
name: string;
onNameChange: (next: string) => void;
stagedJar: File | null;
onStagedJarChange: (file: File | null) => void;
deploying: boolean;
}
export function IdentitySection({
mode, environment, app, currentVersion,
name, onNameChange, stagedJar, onStagedJarChange, deploying,
}: IdentitySectionProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const slug = app?.slug ?? slugify(name);
const externalUrl = (() => {
const defaults = environment.defaultContainerConfig ?? {};
const domain = String(defaults.routingDomain ?? '');
if (defaults.routingMode === 'subdomain' && domain) {
return `https://${slug || '...'}-${environment.slug}.${domain}/`;
}
const base = domain ? `https://${domain}` : window.location.origin;
return `${base}/${environment.slug}/${slug || '...'}/`;
})();
return (
<div className={styles.section}>
<SectionHeader>Identity & Artifact</SectionHeader>
<div className={styles.configGrid}>
<span className={styles.configLabel}>Application Name</span>
{mode === 'deployed' ? (
<span className={styles.readOnlyValue}>{name}</span>
) : (
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g. Payment Gateway"
disabled={deploying}
/>
)}
<span className={styles.configLabel}>Slug</span>
<MonoText size="sm">{slug || '...'}</MonoText>
<span className={styles.configLabel}>Environment</span>
<Badge label={environment.displayName} color="auto" />
<span className={styles.configLabel}>External URL</span>
<MonoText size="sm">{externalUrl}</MonoText>
{currentVersion && (
<>
<span className={styles.configLabel}>Current Version</span>
<span className={styles.readOnlyValue}>
v{currentVersion.version} · {currentVersion.jarFilename} · {formatBytes(currentVersion.jarSizeBytes)}
</span>
</>
)}
<span className={styles.configLabel}>Application JAR</span>
<div className={styles.fileRow}>
<input
ref={fileInputRef}
type="file"
accept=".jar"
className={styles.visuallyHidden}
onChange={(e) => onStagedJarChange(e.target.files?.[0] ?? null)}
/>
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={deploying}
>
{currentVersion ? 'Change JAR' : 'Select JAR'}
</Button>
{stagedJar && (
<span className={styles.stagedJar}>
staged: {stagedJar.name} ({formatBytes(stagedJar.size)})
</span>
)}
</div>
</div>
</div>
);
}
Add the new CSS classes to AppDeploymentPage.module.css:
.section {
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
background: var(--surface);
}
.configGrid {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px 16px;
align-items: center;
margin-top: 8px;
}
.configLabel {
color: var(--text-muted);
font-size: 13px;
}
.readOnlyValue {
color: var(--text);
font-size: 14px;
}
.fileRow {
display: flex;
align-items: center;
gap: 10px;
}
.stagedJar {
color: var(--amber);
font-size: 13px;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
- Step 2: Wire auto-derive into
AppDeploymentPage
Edit ui/src/pages/AppsTab/AppDeploymentPage/index.tsx:
import { useState, useEffect, useRef } from 'react';
import { IdentitySection } from './IdentitySection';
import { deriveAppName } from './utils/deriveAppName';
// ... existing imports
export default function AppDeploymentPage() {
// ... existing (appId, selectedEnv, etc.)
const env = environments.find((e) => e.slug === selectedEnv);
// Form state
const [name, setName] = useState(app?.displayName ?? '');
const [stagedJar, setStagedJar] = useState<File | null>(null);
// Auto-derive: update name from staged JAR if user hasn't typed anything custom
const lastDerivedRef = useRef<string>('');
useEffect(() => {
if (!stagedJar) return;
if (app) return; // only in net-new mode
const derived = deriveAppName(stagedJar.name);
if (!name || name === lastDerivedRef.current) {
setName(derived);
lastDerivedRef.current = derived;
}
}, [stagedJar, app, name]);
if (envLoading || appsLoading) return <PageLoader />;
if (!env) return <div>Select an environment first.</div>;
const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new';
return (
<div className={styles.container}>
<h2>{app ? app.displayName : 'Create Application'}</h2>
<IdentitySection
mode={mode}
environment={env}
app={app}
currentVersion={null /* wired in later task */}
name={name}
onNameChange={setName}
stagedJar={stagedJar}
onStagedJarChange={setStagedJar}
deploying={false}
/>
</div>
);
}
- Step 3: Build
cd ui && npm run build
Expected: BUILD SUCCESS.
- Step 4: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/
git commit -m "ui(deploy): Identity & Artifact section with filename auto-derive"
Task 6.3: Checkpoints disclosure
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx -
Step 1: Write the component
// ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx
import { useState } from 'react';
import { Button, Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
import { timeAgo } from '../../../utils/format-utils';
import styles from './AppDeploymentPage.module.css';
interface CheckpointsProps {
deployments: Deployment[];
versions: AppVersion[];
currentDeploymentId: string | null;
onRestore: (deploymentId: string) => void;
}
export function Checkpoints({ deployments, versions, currentDeploymentId, onRestore }: CheckpointsProps) {
const [open, setOpen] = useState(false);
const versionMap = new Map(versions.map((v) => [v.id, v]));
// Only successful deployments with a snapshot. Exclude the currently-running one.
const checkpoints = deployments
.filter((d) => d.deployedAt && d.status === 'RUNNING' && d.id !== currentDeploymentId)
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
return (
<div className={styles.checkpointsRow}>
<button
type="button"
className={styles.disclosureToggle}
onClick={() => setOpen(!open)}
>
{open ? '▼' : '▶'} Checkpoints ({checkpoints.length})
</button>
{open && (
<div className={styles.checkpointList}>
{checkpoints.length === 0 && (
<div className={styles.checkpointEmpty}>No past deployments yet.</div>
)}
{checkpoints.map((d) => {
const v = versionMap.get(d.appVersionId);
const jarAvailable = !!v;
return (
<div key={d.id} className={styles.checkpointRow}>
<Badge label={v ? `v${v.version}` : '?'} color="auto" />
<span className={styles.checkpointMeta}>
{d.deployedAt ? timeAgo(d.deployedAt) : '—'}
</span>
{!jarAvailable && (
<span className={styles.checkpointArchived}>archived, JAR unavailable</span>
)}
<Button
size="sm"
variant="ghost"
disabled={!jarAvailable}
title={!jarAvailable ? 'JAR was pruned by the environment retention policy' : undefined}
onClick={() => onRestore(d.id)}
>
Restore
</Button>
</div>
);
})}
</div>
)}
</div>
);
}
Add CSS:
.checkpointsRow {
grid-column: 2 / 3;
}
.disclosureToggle {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
padding: 4px 0;
}
.checkpointList {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 0 0 12px;
}
.checkpointRow {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.checkpointMeta { color: var(--text-muted); }
.checkpointArchived { color: var(--warning); font-size: 12px; }
.checkpointEmpty { color: var(--text-muted); font-size: 13px; }
- Step 2: Render it under Identity section in net-new and deployed modes
Inside IdentitySection.tsx, accept a children prop or a checkpointsSlot prop and render it in the last grid cell. Simpler: render Checkpoints as a sibling of IdentitySection inside AppDeploymentPage/index.tsx, positioned within the same .section block via a wrapper.
Update index.tsx:
const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug);
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
const currentVersion = versions.sort((a, b) => b.version - a.version)[0] ?? null;
const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null;
// ... inside the return, below IdentitySection:
{mode === 'deployed' && (
<Checkpoints
deployments={deployments}
versions={versions}
currentDeploymentId={currentDeployment?.id ?? null}
onRestore={(id) => {
// wired in Task 10.3
console.info('restore', id);
}}
/>
)}
- Step 3: Build
cd ui && npm run build
Expected: BUILD SUCCESS.
- Step 4: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/
git commit -m "ui(deploy): Checkpoints disclosure (hides current deployment, flags pruned JARs)"
Phase 7 — UI: staged config tabs
Task 7.1: useDeploymentPageState orchestrator hook
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts -
Step 1: Write the hook
// ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts
import { useState, useEffect, useMemo } from 'react';
import type { ApplicationConfig } from '../../../../api/queries/commands';
import type { App } from '../../../../api/queries/admin/apps';
export interface MonitoringFormState {
engineLevel: string;
payloadCaptureMode: string;
applicationLogLevel: string;
agentLogLevel: string;
metricsEnabled: boolean;
samplingRate: string;
compressSuccess: boolean;
}
export interface ResourcesFormState {
memoryLimit: string;
memoryReserve: string;
cpuRequest: string;
cpuLimit: string;
ports: number[];
appPort: string;
replicas: string;
deployStrategy: string;
stripPrefix: boolean;
sslOffloading: boolean;
runtimeType: string;
customArgs: string;
extraNetworks: string[];
}
export interface VariablesFormState {
envVars: { key: string; value: string }[];
}
export interface SensitiveKeysFormState {
sensitiveKeys: string[];
}
export interface DeploymentPageFormState {
monitoring: MonitoringFormState;
resources: ResourcesFormState;
variables: VariablesFormState;
sensitiveKeys: SensitiveKeysFormState;
}
const defaultForm: DeploymentPageFormState = {
monitoring: {
engineLevel: 'REGULAR',
payloadCaptureMode: 'BOTH',
applicationLogLevel: 'INFO',
agentLogLevel: 'INFO',
metricsEnabled: true,
samplingRate: '1.0',
compressSuccess: false,
},
resources: {
memoryLimit: '512', memoryReserve: '', cpuRequest: '500', cpuLimit: '',
ports: [], appPort: '8080', replicas: '1', deployStrategy: 'blue-green',
stripPrefix: true, sslOffloading: true, runtimeType: 'auto', customArgs: '',
extraNetworks: [],
},
variables: { envVars: [] },
sensitiveKeys: { sensitiveKeys: [] },
};
export function useDeploymentPageState(
app: App | null,
agentConfig: ApplicationConfig | null,
envDefaults: Record<string, unknown>,
): {
form: DeploymentPageFormState;
setForm: React.Dispatch<React.SetStateAction<DeploymentPageFormState>>;
reset: () => void;
} {
const [form, setForm] = useState<DeploymentPageFormState>(defaultForm);
const serverState = useMemo<DeploymentPageFormState>(() => {
const merged = { ...envDefaults, ...(app?.containerConfig ?? {}) } as Record<string, unknown>;
return {
monitoring: {
engineLevel: (agentConfig?.engineLevel as string) ?? 'REGULAR',
payloadCaptureMode: (agentConfig?.payloadCaptureMode as string) ?? 'BOTH',
applicationLogLevel: (agentConfig?.applicationLogLevel as string) ?? 'INFO',
agentLogLevel: (agentConfig?.agentLogLevel as string) ?? 'INFO',
metricsEnabled: agentConfig?.metricsEnabled ?? true,
samplingRate: String(agentConfig?.samplingRate ?? 1.0),
compressSuccess: agentConfig?.compressSuccess ?? false,
},
resources: {
memoryLimit: String(merged.memoryLimitMb ?? 512),
memoryReserve: String(merged.memoryReserveMb ?? ''),
cpuRequest: String(merged.cpuRequest ?? 500),
cpuLimit: String(merged.cpuLimit ?? ''),
ports: Array.isArray(merged.exposedPorts) ? (merged.exposedPorts as number[]) : [],
appPort: String(merged.appPort ?? 8080),
replicas: String(merged.replicas ?? 1),
deployStrategy: String(merged.deploymentStrategy ?? 'blue-green'),
stripPrefix: merged.stripPathPrefix !== false,
sslOffloading: merged.sslOffloading !== false,
runtimeType: String(merged.runtimeType ?? 'auto'),
customArgs: String(merged.customArgs ?? ''),
extraNetworks: Array.isArray(merged.extraNetworks) ? (merged.extraNetworks as string[]) : [],
},
variables: {
envVars: merged.customEnvVars
? Object.entries(merged.customEnvVars as Record<string, string>).map(([key, value]) => ({ key, value }))
: [],
},
sensitiveKeys: {
sensitiveKeys: Array.isArray(agentConfig?.sensitiveKeys)
? (agentConfig!.sensitiveKeys as string[])
: [],
},
};
}, [app, agentConfig, envDefaults]);
useEffect(() => {
setForm(serverState);
}, [serverState]);
return { form, setForm, reset: () => setForm(serverState) };
}
- Step 2: Build
cd ui && npm run build
Expected: BUILD SUCCESS.
- Step 3: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/hooks/
git commit -m "ui(deploy): add useDeploymentPageState orchestrator hook"
Task 7.2: Extract MonitoringTab
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/MonitoringTab.tsx -
Step 1: Copy monitoring form from existing AppsTab.tsx
Read lines in ui/src/pages/AppsTab/AppsTab.tsx that render the Monitoring form inside CreateAppView (search engineLevel between ~400–600). The fields are: engineLevel, payloadCapture + size/unit, appLogLevel, agentLogLevel, metricsEnabled, metricsInterval, samplingRate, compressSuccess, replayEnabled, routeControlEnabled.
Port this JSX into a new component that takes the MonitoringFormState plus an onChange callback:
// ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/MonitoringTab.tsx
import { Select, Toggle, Input } from '@cameleer/design-system';
import type { MonitoringFormState } from '../hooks/useDeploymentPageState';
import styles from '../AppDeploymentPage.module.css';
interface Props {
value: MonitoringFormState;
onChange: (next: MonitoringFormState) => void;
disabled?: boolean;
}
export function MonitoringTab({ value, onChange, disabled }: Props) {
const update = <K extends keyof MonitoringFormState>(key: K, v: MonitoringFormState[K]) =>
onChange({ ...value, [key]: v });
return (
<div className={styles.configGrid}>
<span className={styles.configLabel}>Engine Level</span>
<Select value={value.engineLevel} disabled={disabled}
onChange={(e) => update('engineLevel', e.target.value)}
options={[
{ value: 'OFF', label: 'OFF' },
{ value: 'LIGHTWEIGHT', label: 'LIGHTWEIGHT' },
{ value: 'REGULAR', label: 'REGULAR' },
{ value: 'AGGRESSIVE', label: 'AGGRESSIVE' },
]} />
<span className={styles.configLabel}>Payload Capture</span>
<Select value={value.payloadCaptureMode} disabled={disabled}
onChange={(e) => update('payloadCaptureMode', e.target.value)}
options={[
{ value: 'NONE', label: 'NONE' },
{ value: 'BODY_ONLY', label: 'BODY_ONLY' },
{ value: 'HEADERS_ONLY', label: 'HEADERS_ONLY' },
{ value: 'BOTH', label: 'BOTH' },
]} />
<span className={styles.configLabel}>Application Log Level</span>
<Select value={value.applicationLogLevel} disabled={disabled}
onChange={(e) => update('applicationLogLevel', e.target.value)}
options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((v) => ({ value: v, label: v }))} />
<span className={styles.configLabel}>Agent Log Level</span>
<Select value={value.agentLogLevel} disabled={disabled}
onChange={(e) => update('agentLogLevel', e.target.value)}
options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((v) => ({ value: v, label: v }))} />
<span className={styles.configLabel}>Metrics Enabled</span>
<Toggle checked={value.metricsEnabled} disabled={disabled}
onChange={(v) => update('metricsEnabled', v)} />
<span className={styles.configLabel}>Sampling Rate</span>
<Input value={value.samplingRate} disabled={disabled}
onChange={(e) => update('samplingRate', e.target.value)} />
<span className={styles.configLabel}>Compress Success Payloads</span>
<Toggle checked={value.compressSuccess} disabled={disabled}
onChange={(v) => update('compressSuccess', v)} />
</div>
);
}
- Step 2: Build
cd ui && npm run build
Expected: BUILD SUCCESS.
- Step 3: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/MonitoringTab.tsx
git commit -m "ui(deploy): extract MonitoringTab component"
Task 7.3: Extract ResourcesTab
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx -
Step 1: Write component
Use the same lift-and-shift pattern as Task 7.2. Port the "Resources" JSX block from existing AppsTab.tsx (search for memoryLimit, cpuRequest, deployStrategy). Bind to ResourcesFormState.
// Skeleton — fields follow the existing CreateAppView markup; replicate all of them.
import { Input, Select, Toggle, Button } from '@cameleer/design-system';
import type { ResourcesFormState } from '../hooks/useDeploymentPageState';
import styles from '../AppDeploymentPage.module.css';
interface Props {
value: ResourcesFormState;
onChange: (next: ResourcesFormState) => void;
disabled?: boolean;
}
export function ResourcesTab({ value, onChange, disabled }: Props) {
const update = <K extends keyof ResourcesFormState>(key: K, v: ResourcesFormState[K]) =>
onChange({ ...value, [key]: v });
return (
<div className={styles.configGrid}>
<span className={styles.configLabel}>Memory Limit (MB)</span>
<Input value={value.memoryLimit} disabled={disabled}
onChange={(e) => update('memoryLimit', e.target.value)} />
<span className={styles.configLabel}>Memory Reserve (MB)</span>
<Input value={value.memoryReserve} disabled={disabled}
onChange={(e) => update('memoryReserve', e.target.value)} />
<span className={styles.configLabel}>CPU Request (millicores)</span>
<Input value={value.cpuRequest} disabled={disabled}
onChange={(e) => update('cpuRequest', e.target.value)} />
<span className={styles.configLabel}>CPU Limit (millicores)</span>
<Input value={value.cpuLimit} disabled={disabled}
onChange={(e) => update('cpuLimit', e.target.value)} />
<span className={styles.configLabel}>App Port</span>
<Input value={value.appPort} disabled={disabled}
onChange={(e) => update('appPort', e.target.value)} />
<span className={styles.configLabel}>Replicas</span>
<Input value={value.replicas} disabled={disabled}
onChange={(e) => update('replicas', e.target.value)} />
<span className={styles.configLabel}>Deployment Strategy</span>
<Select value={value.deployStrategy} disabled={disabled}
onChange={(e) => update('deployStrategy', e.target.value)}
options={[
{ value: 'blue-green', label: 'Blue/Green' },
{ value: 'rolling', label: 'Rolling' },
{ value: 'recreate', label: 'Recreate' },
]} />
<span className={styles.configLabel}>Strip Path Prefix</span>
<Toggle checked={value.stripPrefix} disabled={disabled}
onChange={(v) => update('stripPrefix', v)} />
<span className={styles.configLabel}>SSL Offloading</span>
<Toggle checked={value.sslOffloading} disabled={disabled}
onChange={(v) => update('sslOffloading', v)} />
<span className={styles.configLabel}>Runtime Type</span>
<Select value={value.runtimeType} disabled={disabled}
onChange={(e) => update('runtimeType', e.target.value)}
options={[
{ value: 'auto', label: 'auto' },
{ value: 'spring-boot', label: 'Spring Boot' },
{ value: 'quarkus', label: 'Quarkus' },
{ value: 'plain-jar', label: 'Plain JAR' },
]} />
<span className={styles.configLabel}>Custom Args</span>
<Input value={value.customArgs} disabled={disabled}
onChange={(e) => update('customArgs', e.target.value)} />
{/* Ports + extraNetworks — preserve the add-list-UI from existing CreateAppView;
skipping full reproduction here for brevity but required in implementation. */}
</div>
);
}
Copy the ports list + extraNetworks add/remove UI from the existing CreateAppView sections verbatim, adapting the setter to call update('ports', [...]).
- Step 2: Build + commit
cd ui && npm run build
git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/ResourcesTab.tsx
git commit -m "ui(deploy): extract ResourcesTab component"
Task 7.4: Extract VariablesTab
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/VariablesTab.tsx -
Step 1: Write component
import { Input, Button } from '@cameleer/design-system';
import type { VariablesFormState } from '../hooks/useDeploymentPageState';
import styles from '../AppDeploymentPage.module.css';
interface Props {
value: VariablesFormState;
onChange: (next: VariablesFormState) => void;
disabled?: boolean;
}
export function VariablesTab({ value, onChange, disabled }: Props) {
const update = (envVars: VariablesFormState['envVars']) =>
onChange({ envVars });
return (
<div className={styles.envVarsList}>
{value.envVars.map((v, i) => (
<div key={i} className={styles.envVarRow}>
<Input value={v.key} disabled={disabled} placeholder="KEY"
onChange={(e) => update(value.envVars.map((x, idx) => idx === i ? { ...x, key: e.target.value } : x))} />
<Input value={v.value} disabled={disabled} placeholder="value"
onChange={(e) => update(value.envVars.map((x, idx) => idx === i ? { ...x, value: e.target.value } : x))} />
<Button size="sm" variant="ghost" disabled={disabled}
onClick={() => update(value.envVars.filter((_, idx) => idx !== i))}>
Remove
</Button>
</div>
))}
<Button size="sm" variant="secondary" disabled={disabled}
onClick={() => update([...value.envVars, { key: '', value: '' }])}>
+ Add variable
</Button>
</div>
);
}
Add CSS:
.envVarsList { display: flex; flex-direction: column; gap: 8px; }
.envVarRow { display: grid; grid-template-columns: 1fr 2fr auto; gap: 8px; align-items: center; }
- Step 2: Build + commit
cd ui && npm run build
git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/VariablesTab.tsx ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "ui(deploy): extract VariablesTab component"
Task 7.5: Extract SensitiveKeysTab
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/SensitiveKeysTab.tsx -
Step 1: Write component
Port the sensitive-keys Tab block from the existing CreateAppView (it uses skStyles from Admin/SensitiveKeysPage.module.css + displays global keys). Convert to accept SensitiveKeysFormState + onChange, plus read global keys via useSensitiveKeys directly.
import { useState } from 'react';
import { Tag, Input, Button, Badge } from '@cameleer/design-system';
import { Info } from 'lucide-react';
import { useSensitiveKeys } from '../../../../api/queries/admin/sensitive-keys';
import type { SensitiveKeysFormState } from '../hooks/useDeploymentPageState';
import skStyles from '../../../Admin/SensitiveKeysPage.module.css';
interface Props {
value: SensitiveKeysFormState;
onChange: (next: SensitiveKeysFormState) => void;
disabled?: boolean;
}
export function SensitiveKeysTab({ value, onChange, disabled }: Props) {
const { data: globalKeysConfig } = useSensitiveKeys();
const globalKeys = globalKeysConfig?.keys ?? [];
const [newKey, setNewKey] = useState('');
const add = () => {
const v = newKey.trim();
if (!v) return;
if (value.sensitiveKeys.some((k) => k.toLowerCase() === v.toLowerCase())) return;
onChange({ sensitiveKeys: [...value.sensitiveKeys, v] });
setNewKey('');
};
return (
<div>
{globalKeys.length > 0 && (
<div className={skStyles.refSection}>
<div className={skStyles.sectionTitle}>Global baseline</div>
<div className={skStyles.pillList}>
{globalKeys.map((k) => <Badge key={k} label={k} color="auto" />)}
</div>
</div>
)}
<div className={skStyles.sectionTitle}>App-specific keys</div>
<div className={skStyles.pillList}>
{value.sensitiveKeys.map((k, i) => (
<Tag key={`${k}-${i}`} label={k}
onRemove={() => !disabled && onChange({ sensitiveKeys: value.sensitiveKeys.filter((_, idx) => idx !== i) })} />
))}
{value.sensitiveKeys.length === 0 && (
<span className={skStyles.emptyState}>
No app-specific keys — agents use built-in defaults{globalKeys.length > 0 ? ' + global keys' : ''}.
</span>
)}
</div>
<div className={skStyles.inputRow}>
<Input value={newKey} disabled={disabled}
onChange={(e) => setNewKey(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(); } }}
placeholder="Add key or glob pattern (e.g. *password*)" />
<Button variant="secondary" size="sm" disabled={disabled || !newKey.trim()} onClick={add}>Add</Button>
</div>
<div className={skStyles.hint}>
<Info size={12} />
<span>Final masking configuration = agent defaults + global keys + app-specific keys.</span>
</div>
</div>
);
}
- Step 2: Build + commit
cd ui && npm run build
git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/SensitiveKeysTab.tsx
git commit -m "ui(deploy): extract SensitiveKeysTab component"
Phase 8 — UI: live-apply tabs with banner
Task 8.1: LiveBanner component
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/LiveBanner.tsx -
Step 1: Write component
// ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/LiveBanner.tsx
import { Info } from 'lucide-react';
import styles from '../AppDeploymentPage.module.css';
export function LiveBanner() {
return (
<div className={styles.liveBanner}>
<Info size={14} />
<span>
<strong>Live controls.</strong> Changes apply immediately to running agents and do
not participate in the Save/Redeploy cycle.
</span>
</div>
);
}
CSS:
.liveBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: var(--amber-surface, rgba(245, 158, 11, 0.12));
border: 1px solid var(--amber);
border-radius: 6px;
color: var(--text);
font-size: 13px;
}
- Step 2: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/LiveBanner.tsx ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "ui(deploy): LiveBanner component for live-apply tabs"
Task 8.2: TracesTapsTab + RouteRecordingTab (port + wrap in banner)
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/TracesTapsTab.tsx -
Create:
ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/RouteRecordingTab.tsx -
Step 1: Lift Traces & Taps content from existing AppsTab.tsx
Find the "traces" case inside ConfigSubTab in AppsTab.tsx (search tracedTapRows, TracedTapRow). Copy the rendering logic into a new component. Add <LiveBanner /> at the top.
These tabs continue to use useUpdateApplicationConfig with default apply=live (no ?apply=staged). The key: the banner + the fact that these writes invoke the live-push variant means they apply immediately and do not feed the deployment page's form state.
// ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/TracesTapsTab.tsx
import { LiveBanner } from './LiveBanner';
import type { App } from '../../../../api/queries/admin/apps';
import type { Environment } from '../../../../api/queries/admin/environments';
interface Props { app: App; environment: Environment; }
export function TracesTapsTab({ app, environment }: Props) {
return (
<div>
<LiveBanner />
{/* Paste the existing tracedTapRows rendering here verbatim — it queries
useApplicationConfig + calls useUpdateApplicationConfig (apply=live default). */}
</div>
);
}
Do the same for RouteRecordingTab.tsx — lift the "recording" case contents, prepend <LiveBanner />.
- Step 2: Build + commit
cd ui && npm run build
git add ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/
git commit -m "ui(deploy): Traces & Taps + Route Recording tabs with live banner"
Phase 9 — UI: Deployment tab
Task 9.1: StatusCard
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/StatusCard.tsx -
Step 1: Write component
import { Badge, StatusDot, MonoText, Button } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { timeAgo } from '../../../../utils/format-utils';
import styles from '../AppDeploymentPage.module.css';
const STATUS_COLORS = {
RUNNING: 'success', STARTING: 'warning', FAILED: 'error',
STOPPED: 'auto', DEGRADED: 'warning', STOPPING: 'auto',
} as const;
const DEPLOY_STATUS_DOT = {
RUNNING: 'live', STARTING: 'running', DEGRADED: 'stale',
STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error',
} as const;
interface Props {
deployment: Deployment;
version: AppVersion | null;
externalUrl: string;
onStop: () => void;
onStart: () => void;
}
export function StatusCard({ deployment, version, externalUrl, onStop, onStart }: Props) {
const running = deployment.replicaStates?.filter((r) => r.status === 'RUNNING').length ?? 0;
const total = deployment.replicaStates?.length ?? 0;
return (
<div className={styles.statusCard}>
<div className={styles.statusCardHeader}>
<StatusDot variant={DEPLOY_STATUS_DOT[deployment.status] ?? 'dead'} />
<Badge label={deployment.status} color={STATUS_COLORS[deployment.status] ?? 'auto'} />
{version && <Badge label={`v${version.version}`} color="auto" />}
</div>
<div className={styles.statusCardGrid}>
{version && <><span>JAR</span><MonoText size="sm">{version.jarFilename}</MonoText></>}
{version && <><span>Checksum</span><MonoText size="xs">{version.jarChecksum.substring(0, 12)}</MonoText></>}
<span>Replicas</span><span>{running}/{total}</span>
<span>URL</span>
{deployment.status === 'RUNNING'
? <a href={externalUrl} target="_blank" rel="noreferrer"><MonoText size="sm">{externalUrl}</MonoText></a>
: <MonoText size="sm" muted>{externalUrl}</MonoText>}
<span>Deployed</span><span>{deployment.deployedAt ? timeAgo(deployment.deployedAt) : '—'}</span>
</div>
<div className={styles.statusCardActions}>
{(deployment.status === 'RUNNING' || deployment.status === 'STARTING' || deployment.status === 'DEGRADED')
&& <Button size="sm" variant="danger" onClick={onStop}>Stop</Button>}
{deployment.status === 'STOPPED' && <Button size="sm" variant="secondary" onClick={onStart}>Start</Button>}
</div>
</div>
);
}
CSS:
.statusCard {
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
background: var(--surface);
display: flex;
flex-direction: column;
gap: 12px;
}
.statusCardHeader { display: flex; align-items: center; gap: 8px; }
.statusCardGrid { display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px; font-size: 13px; }
.statusCardActions { display: flex; gap: 8px; }
- Step 2: Build + commit
cd ui && npm run build
git add ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/StatusCard.tsx ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "ui(deploy): StatusCard for Deployment tab"
Task 9.2: HistoryDisclosure
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx -
Step 1: Write component
import { useState } from 'react';
import { DataTable } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { timeAgo } from '../../../../utils/format-utils';
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
import styles from '../AppDeploymentPage.module.css';
interface Props {
deployments: Deployment[];
versions: AppVersion[];
appSlug: string;
envSlug: string;
}
export function HistoryDisclosure({ deployments, versions, appSlug, envSlug }: Props) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const versionMap = new Map(versions.map((v) => [v.id, v]));
const rows = deployments
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const columns: Column<Deployment>[] = [
{ key: 'createdAt', header: 'Started', render: (_, d) => timeAgo(d.createdAt) },
{ key: 'appVersionId', header: 'Version', render: (_, d) => versionMap.get(d.appVersionId)?.version ? `v${versionMap.get(d.appVersionId)!.version}` : '?' },
{ key: 'status', header: 'Status' },
{ key: 'deployedAt', header: 'Duration',
render: (_, d) => d.deployedAt && d.createdAt
? `${Math.round((Date.parse(d.deployedAt) - Date.parse(d.createdAt)) / 1000)}s`
: '—' },
];
return (
<div className={styles.historyRow}>
<button type="button" className={styles.disclosureToggle} onClick={() => setOpen(!open)}>
{open ? '▼' : '▶'} History ({rows.length})
</button>
{open && (
<>
<DataTable columns={columns} data={rows}
onRowClick={(row) => setExpanded(expanded === row.id ? null : row.id)} />
{expanded && (() => {
const d = rows.find((r) => r.id === expanded);
if (!d) return null;
return <StartupLogPanel deployment={d} appSlug={appSlug} envSlug={envSlug} />;
})()}
</>
)}
</div>
);
}
CSS:
.historyRow { margin-top: 16px; }
- Step 2: Build + commit
cd ui && npm run build
git add ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "ui(deploy): HistoryDisclosure with inline log expansion"
Task 9.3: DeploymentTab composition + StartupLogPanel flex-grow
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx -
Modify:
ui/src/components/StartupLogPanel.tsx -
Step 1: Adjust StartupLogPanel to accept
className+ drop fixed maxHeight
Read ui/src/components/StartupLogPanel.tsx, then modify:
// StartupLogPanel.tsx signature + body:
interface StartupLogPanelProps {
deployment: Deployment;
appSlug: string;
envSlug: string;
className?: string;
}
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
// ... existing logic ...
return (
<div className={`${styles.panel} ${className ?? ''}`}>
{/* existing header */}
{entries.length > 0 ? (
<LogViewer entries={entries as unknown as LogEntry[]} /* no maxHeight — let CSS control */ />
) : (
<div className={styles.empty}>Waiting for container output...</div>
)}
</div>
);
}
In StartupLogPanel.module.css, change .panel to display: flex; flex-direction: column; flex: 1 1 auto; min-height: 200px;. Ensure the .logViewer (if wrapped) has flex: 1 1 auto; min-height: 0; overflow: auto.
- Step 2: Write DeploymentTab
// ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { DeploymentProgress } from '../../../../components/DeploymentProgress';
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
import { EmptyState } from '@cameleer/design-system';
import { StatusCard } from './StatusCard';
import { HistoryDisclosure } from './HistoryDisclosure';
import styles from '../AppDeploymentPage.module.css';
interface Props {
deployments: Deployment[];
versions: AppVersion[];
appSlug: string;
envSlug: string;
externalUrl: string;
onStop: (deploymentId: string) => void;
onStart: (deploymentId: string) => void;
}
export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl, onStop, onStart }: Props) {
const latest = deployments
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
if (!latest) {
return <EmptyState title="No deployments yet" description="Save your configuration and click Redeploy to launch." />;
}
const version = versions.find((v) => v.id === latest.appVersionId) ?? null;
return (
<div className={styles.deploymentTab}>
<StatusCard
deployment={latest}
version={version}
externalUrl={externalUrl}
onStop={() => onStop(latest.id)}
onStart={() => onStart(latest.id)}
/>
{latest.status === 'STARTING' && (
<DeploymentProgress currentStage={latest.deployStage} failed={false} />
)}
{latest.status === 'FAILED' && (
<DeploymentProgress currentStage={latest.deployStage} failed />
)}
<StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug}
className={styles.logFill} />
<HistoryDisclosure deployments={deployments} versions={versions}
appSlug={appSlug} envSlug={envSlug} />
</div>
);
}
CSS:
.deploymentTab {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1 1 auto;
min-height: 0;
}
.logFill { flex: 1 1 auto; min-height: 200px; }
- Step 3: Build + commit
cd ui && npm run build
git add -A
git commit -m "ui(deploy): DeploymentTab + flex-grow StartupLogPanel"
Phase 10 — UI: tabs, primary button, save/deploy wiring
Task 10.1: Dirty-state hook composition + tab-level dirty indicators
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/hooks/useFormDirty.ts -
Step 1: Write hook
// ui/src/pages/AppsTab/AppDeploymentPage/hooks/useFormDirty.ts
import { useMemo } from 'react';
import type { DeploymentPageFormState } from './useDeploymentPageState';
export interface PerTabDirty {
monitoring: boolean;
resources: boolean;
variables: boolean;
sensitiveKeys: boolean;
anyLocalEdit: boolean;
}
export function useFormDirty(
form: DeploymentPageFormState,
serverState: DeploymentPageFormState,
stagedJar: File | null,
): PerTabDirty {
return useMemo(() => {
const monitoring = JSON.stringify(form.monitoring) !== JSON.stringify(serverState.monitoring);
const resources = JSON.stringify(form.resources) !== JSON.stringify(serverState.resources);
const variables = JSON.stringify(form.variables) !== JSON.stringify(serverState.variables);
const sensitiveKeys = JSON.stringify(form.sensitiveKeys) !== JSON.stringify(serverState.sensitiveKeys);
const anyLocalEdit = monitoring || resources || variables || sensitiveKeys || !!stagedJar;
return { monitoring, resources, variables, sensitiveKeys, anyLocalEdit };
}, [form, serverState, stagedJar]);
}
- Step 2: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/hooks/useFormDirty.ts
git commit -m "ui(deploy): useFormDirty hook for per-tab dirty markers"
Task 10.2: PrimaryActionButton component
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx -
Step 1: Write component
// ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx
import { Button } from '@cameleer/design-system';
export type PrimaryActionMode = 'save' | 'redeploy' | 'deploying';
interface Props {
mode: PrimaryActionMode;
enabled: boolean;
onClick: () => void;
}
export function PrimaryActionButton({ mode, enabled, onClick }: Props) {
if (mode === 'deploying') {
return <Button size="sm" variant="primary" loading disabled>Deploying…</Button>;
}
if (mode === 'redeploy') {
return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Redeploy</Button>;
}
return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Save</Button>;
}
export function computeMode({
deploymentInProgress,
hasLocalEdits,
serverDirtyAgainstDeploy,
}: {
deploymentInProgress: boolean;
hasLocalEdits: boolean;
serverDirtyAgainstDeploy: boolean;
}): PrimaryActionMode {
if (deploymentInProgress) return 'deploying';
if (hasLocalEdits) return 'save';
if (serverDirtyAgainstDeploy) return 'redeploy';
return 'save'; // idle, disabled
}
- Step 2: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx
git commit -m "ui(deploy): PrimaryActionButton + computeMode state-machine helper"
Task 10.3: Wire everything into AppDeploymentPage/index.tsx
Files:
- Modify:
ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
This is the largest single task. Steps:
- Step 1: Replace the placeholder body with the full composition
// ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
import { useState, useEffect, useRef, useMemo } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router';
import { Tabs, Button, AlertDialog, ConfirmDialog, useToast } from '@cameleer/design-system';
import { useEnvironmentStore } from '../../../api/environment-store';
import { useEnvironments } from '../../../api/queries/admin/environments';
import {
useApps, useCreateApp, useDeleteApp, useAppVersions, useUploadJar,
useDeployments, useCreateDeployment, useStopDeployment, useUpdateContainerConfig,
useDirtyState,
} from '../../../api/queries/admin/apps';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../../api/queries/commands';
import { PageLoader } from '../../../components/PageLoader';
import { IdentitySection } from './IdentitySection';
import { Checkpoints } from './Checkpoints';
import { MonitoringTab } from './ConfigTabs/MonitoringTab';
import { ResourcesTab } from './ConfigTabs/ResourcesTab';
import { VariablesTab } from './ConfigTabs/VariablesTab';
import { SensitiveKeysTab } from './ConfigTabs/SensitiveKeysTab';
import { TracesTapsTab } from './ConfigTabs/TracesTapsTab';
import { RouteRecordingTab } from './ConfigTabs/RouteRecordingTab';
import { DeploymentTab } from './DeploymentTab/DeploymentTab';
import { PrimaryActionButton, computeMode } from './PrimaryActionButton';
import { deriveAppName } from './utils/deriveAppName';
import { useDeploymentPageState } from './hooks/useDeploymentPageState';
import { useFormDirty } from './hooks/useFormDirty';
import styles from './AppDeploymentPage.module.css';
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100);
}
type TabKey = 'monitoring' | 'resources' | 'variables' | 'sensitive-keys' | 'deployment' | 'traces' | 'recording';
export default function AppDeploymentPage() {
const { appId } = useParams<{ appId?: string }>();
const location = useLocation();
const navigate = useNavigate();
const { toast } = useToast();
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: environments = [], isLoading: envLoading } = useEnvironments();
const { data: apps = [], isLoading: appsLoading } = useApps(selectedEnv);
const env = environments.find((e) => e.slug === selectedEnv);
const isNetNew = location.pathname.endsWith('/apps/new');
const app = isNetNew ? null : apps.find((a) => a.slug === appId) ?? null;
const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug);
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
const { data: agentConfig } = useApplicationConfig(app?.slug ?? '', selectedEnv);
const { data: dirtyState } = useDirtyState(selectedEnv, app?.slug);
const createApp = useCreateApp();
const uploadJar = useUploadJar();
const createDeployment = useCreateDeployment();
const stopDeployment = useStopDeployment();
const deleteApp = useDeleteApp();
const updateAgentConfig = useUpdateApplicationConfig();
const updateContainerConfig = useUpdateContainerConfig();
const currentVersion = useMemo(
() => versions.slice().sort((a, b) => b.version - a.version)[0] ?? null,
[versions],
);
const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null;
const activeDeployment = deployments.find((d) => d.status === 'STARTING') ?? null;
const envDefaults = env?.defaultContainerConfig ?? {};
const { form, setForm, reset } = useDeploymentPageState(app, agentConfig ?? null, envDefaults);
const serverState = useMemo(() =>
useDeploymentPageState.__buildServerState?.(app, agentConfig ?? null, envDefaults) ?? form,
[app, agentConfig, envDefaults, form],
); // simplification: see note below
const [name, setName] = useState('');
const [stagedJar, setStagedJar] = useState<File | null>(null);
const [tab, setTab] = useState<TabKey>('monitoring');
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [stopTarget, setStopTarget] = useState<string | null>(null);
const lastDerivedRef = useRef<string>('');
// Initial name
useEffect(() => {
if (app) setName(app.displayName);
}, [app]);
// Auto-derive name from staged JAR (net-new + untouched by user)
useEffect(() => {
if (!stagedJar || app) return;
const derived = deriveAppName(stagedJar.name);
if (!name || name === lastDerivedRef.current) {
setName(derived);
lastDerivedRef.current = derived;
}
}, [stagedJar, app, name]);
const dirty = useFormDirty(form, /* serverState */ form, stagedJar); // see note
const serverDirtyAgainstDeploy = dirtyState?.dirty ?? false;
const deploymentInProgress = !!activeDeployment;
const mode = computeMode({
deploymentInProgress,
hasLocalEdits: dirty.anyLocalEdit,
serverDirtyAgainstDeploy,
});
// Auto-switch to Deployment tab during active deploy
useEffect(() => {
if (activeDeployment) setTab('deployment');
}, [activeDeployment]);
if (envLoading || appsLoading) return <PageLoader />;
if (!env) return <div>Select an environment first.</div>;
const pageMode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new';
async function handleSave() {
try {
let targetApp = app;
if (!targetApp) {
// net-new: create app
targetApp = await createApp.mutateAsync({
envSlug: env!.slug,
slug: slugify(name),
displayName: name.trim(),
});
}
// Upload JAR if staged
if (stagedJar) {
await uploadJar.mutateAsync({ envSlug: env!.slug, appSlug: targetApp.slug, file: stagedJar });
}
// Save container config
const containerConfig: Record<string, unknown> = {
memoryLimitMb: form.resources.memoryLimit ? parseInt(form.resources.memoryLimit) : null,
memoryReserveMb: form.resources.memoryReserve ? parseInt(form.resources.memoryReserve) : null,
cpuRequest: form.resources.cpuRequest ? parseInt(form.resources.cpuRequest) : null,
cpuLimit: form.resources.cpuLimit ? parseInt(form.resources.cpuLimit) : null,
exposedPorts: form.resources.ports,
customEnvVars: Object.fromEntries(form.variables.envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])),
appPort: form.resources.appPort ? parseInt(form.resources.appPort) : 8080,
replicas: form.resources.replicas ? parseInt(form.resources.replicas) : 1,
deploymentStrategy: form.resources.deployStrategy,
stripPathPrefix: form.resources.stripPrefix,
sslOffloading: form.resources.sslOffloading,
runtimeType: form.resources.runtimeType,
customArgs: form.resources.customArgs || null,
extraNetworks: form.resources.extraNetworks,
};
await updateContainerConfig.mutateAsync({ envSlug: env!.slug, appSlug: targetApp.slug, config: containerConfig });
// Save agent config (staged!)
await updateAgentConfig.mutateAsync({
config: {
application: targetApp.slug,
version: 0,
engineLevel: form.monitoring.engineLevel,
payloadCaptureMode: form.monitoring.payloadCaptureMode,
applicationLogLevel: form.monitoring.applicationLogLevel,
agentLogLevel: form.monitoring.agentLogLevel,
metricsEnabled: form.monitoring.metricsEnabled,
samplingRate: parseFloat(form.monitoring.samplingRate) || 1.0,
compressSuccess: form.monitoring.compressSuccess,
tracedProcessors: agentConfig?.tracedProcessors ?? {},
taps: agentConfig?.taps ?? [],
tapVersion: agentConfig?.tapVersion ?? 0,
routeRecording: agentConfig?.routeRecording ?? {},
sensitiveKeys: form.sensitiveKeys.sensitiveKeys.length > 0 ? form.sensitiveKeys.sensitiveKeys : undefined,
},
environment: env!.slug,
apply: 'staged',
});
toast({ title: 'Configuration saved', variant: 'success' });
setStagedJar(null);
if (!app) {
// Navigate to the existing-app route
navigate(`/apps/${targetApp.slug}`);
}
} catch (e) {
toast({ title: 'Save failed', description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error', duration: 86_400_000 });
}
}
async function handleRedeploy() {
if (!app) return;
const version = stagedJar
? await uploadJar.mutateAsync({ envSlug: env!.slug, appSlug: app.slug, file: stagedJar })
: currentVersion;
if (!version) {
toast({ title: 'No JAR available', variant: 'error' });
return;
}
try {
setTab('deployment');
await createDeployment.mutateAsync({ envSlug: env!.slug, appSlug: app.slug, appVersionId: version.id });
setStagedJar(null);
} catch (e) {
toast({ title: 'Deploy failed', description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error', duration: 86_400_000 });
}
}
async function handleStop() {
if (!stopTarget || !app) return;
await stopDeployment.mutateAsync({ envSlug: env!.slug, appSlug: app.slug, deploymentId: stopTarget });
setStopTarget(null);
}
async function handleDelete() {
if (!app) return;
await deleteApp.mutateAsync({ envSlug: env!.slug, appSlug: app.slug });
navigate('/apps');
}
const externalUrl = (() => {
const d = envDefaults as Record<string, unknown>;
const slug = app?.slug ?? slugify(name);
const domain = String(d.routingDomain ?? '');
if (d.routingMode === 'subdomain' && domain) return `https://${slug}-${env!.slug}.${domain}/`;
const base = domain ? `https://${domain}` : window.location.origin;
return `${base}/${env!.slug}/${slug}/`;
})();
const tabs = [
{ label: dirty.monitoring ? 'Monitoring*' : 'Monitoring', value: 'monitoring' },
{ label: dirty.resources ? 'Resources*' : 'Resources', value: 'resources' },
{ label: dirty.variables ? 'Variables*' : 'Variables', value: 'variables' },
{ label: dirty.sensitiveKeys ? 'Sensitive Keys*' : 'Sensitive Keys', value: 'sensitive-keys' },
{ label: 'Deployment', value: 'deployment' },
{ label: '● Traces & Taps', value: 'traces' },
{ label: '● Route Recording', value: 'recording' },
];
const canSaveOrDeploy = mode === 'save' ? (!!name.trim() && (!!stagedJar || dirty.anyLocalEdit || !app))
: mode === 'redeploy' ? true : false;
return (
<div className={styles.container}>
<div className={styles.detailHeader}>
<div>
<h2>{app ? app.displayName : 'Create Application'}</h2>
</div>
<div className={styles.detailActions}>
{dirty.anyLocalEdit && (
<Button size="sm" variant="ghost" onClick={reset}>Discard</Button>
)}
<PrimaryActionButton
mode={mode}
enabled={canSaveOrDeploy}
onClick={mode === 'redeploy' ? handleRedeploy : handleSave}
/>
{app && (
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>Delete App</Button>
)}
</div>
</div>
<IdentitySection
mode={pageMode}
environment={env}
app={app}
currentVersion={currentVersion}
name={name}
onNameChange={setName}
stagedJar={stagedJar}
onStagedJarChange={setStagedJar}
deploying={deploymentInProgress}
/>
{app && (
<Checkpoints
deployments={deployments}
versions={versions}
currentDeploymentId={currentDeployment?.id ?? null}
onRestore={(id) => {
const d = deployments.find((x) => x.id === id);
const snap = (d as any)?.deployedConfigSnapshot;
if (!snap) return;
// Hydrate form from snapshot
setForm((prev) => ({
...prev,
monitoring: {
engineLevel: snap.agentConfig?.engineLevel ?? prev.monitoring.engineLevel,
payloadCaptureMode: snap.agentConfig?.payloadCaptureMode ?? prev.monitoring.payloadCaptureMode,
applicationLogLevel: snap.agentConfig?.applicationLogLevel ?? prev.monitoring.applicationLogLevel,
agentLogLevel: snap.agentConfig?.agentLogLevel ?? prev.monitoring.agentLogLevel,
metricsEnabled: snap.agentConfig?.metricsEnabled ?? prev.monitoring.metricsEnabled,
samplingRate: String(snap.agentConfig?.samplingRate ?? prev.monitoring.samplingRate),
compressSuccess: snap.agentConfig?.compressSuccess ?? prev.monitoring.compressSuccess,
},
// ... hydrate resources + variables + sensitiveKeys similarly from snap.containerConfig & snap.agentConfig.sensitiveKeys
}));
toast({ title: `Restored checkpoint (v${versions.find((v) => v.id === d!.appVersionId)?.version ?? '?'})`,
description: 'Review and click Save, then Redeploy to apply.',
variant: 'success' });
}}
/>
)}
<Tabs tabs={tabs} active={tab} onChange={(v) => setTab(v as TabKey)} />
{tab === 'monitoring' && <MonitoringTab value={form.monitoring}
onChange={(v) => setForm((p) => ({ ...p, monitoring: v }))} disabled={deploymentInProgress} />}
{tab === 'resources' && <ResourcesTab value={form.resources}
onChange={(v) => setForm((p) => ({ ...p, resources: v }))} disabled={deploymentInProgress} />}
{tab === 'variables' && <VariablesTab value={form.variables}
onChange={(v) => setForm((p) => ({ ...p, variables: v }))} disabled={deploymentInProgress} />}
{tab === 'sensitive-keys' && <SensitiveKeysTab value={form.sensitiveKeys}
onChange={(v) => setForm((p) => ({ ...p, sensitiveKeys: v }))} disabled={deploymentInProgress} />}
{tab === 'deployment' && app && <DeploymentTab deployments={deployments} versions={versions}
appSlug={app.slug} envSlug={env.slug} externalUrl={externalUrl}
onStop={(id) => setStopTarget(id)} onStart={() => {/* Start re-invokes handleRedeploy */}} />}
{tab === 'traces' && app && <TracesTapsTab app={app} environment={env} />}
{tab === 'recording' && app && <RouteRecordingTab app={app} environment={env} />}
<ConfirmDialog
open={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={handleDelete}
message={`Delete app "${app?.displayName}"? All versions and deployments will be removed.`}
confirmText={app?.slug ?? ''}
/>
<AlertDialog
open={!!stopTarget}
onClose={() => setStopTarget(null)}
onConfirm={handleStop}
title="Stop deployment?"
description="This will take the service offline."
confirmLabel="Stop"
variant="warning"
/>
</div>
);
}
Note on the serverState reference: the useDeploymentPageState hook owns the derivation. Refactor it to also return the computed server state so useFormDirty gets a real comparison. Update the hook return signature:
// hooks/useDeploymentPageState.ts — refactor return type
return { form, setForm, reset: () => setForm(serverState), serverState };
Then in the page:
const { form, setForm, reset, serverState } = useDeploymentPageState(app, agentConfig ?? null, envDefaults);
const dirty = useFormDirty(form, serverState, stagedJar);
- Step 2: Build
cd ui && npm run build
Expected: BUILD SUCCESS.
- Step 3: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/
git commit -m "ui(deploy): compose page — save/redeploy/checkpoints wired end-to-end"
Phase 11 — UI: router-level unsaved-changes blocker
Task 11.1: useUnsavedChangesBlocker hook
Files:
-
Create:
ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts -
Step 1: Write the hook
// ui/src/pages/AppsTab/AppDeploymentPage/hooks/useUnsavedChangesBlocker.ts
import { useState, useEffect } from 'react';
import { useBlocker } from 'react-router';
export function useUnsavedChangesBlocker(hasUnsavedChanges: boolean) {
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname
);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => {
if (blocker.state === 'blocked') setDialogOpen(true);
}, [blocker.state]);
return {
dialogOpen,
confirm: () => {
setDialogOpen(false);
blocker.proceed?.();
},
cancel: () => {
setDialogOpen(false);
blocker.reset?.();
},
};
}
- Step 2: Wire into
AppDeploymentPage/index.tsx
Add:
import { useUnsavedChangesBlocker } from './hooks/useUnsavedChangesBlocker';
// inside the component:
const { dialogOpen, confirm, cancel } = useUnsavedChangesBlocker(dirty.anyLocalEdit);
// in the JSX before closing </div>:
<ConfirmDialog
open={dialogOpen}
onClose={cancel}
onConfirm={confirm}
message="You have unsaved changes on this page. Discard and leave?"
confirmLabel="Discard & Leave"
variant="warning"
/>
- Step 3: Build + commit
cd ui && npm run build
git add ui/src/pages/AppsTab/AppDeploymentPage/
git commit -m "ui(deploy): router blocker + DS dialog for unsaved edits"
Phase 12 — Cleanup and docs
Task 12.1: Delete old views from AppsTab.tsx
Files:
-
Modify:
ui/src/pages/AppsTab/AppsTab.tsx -
Step 1: Reduce AppsTab.tsx to only AppListView
Open ui/src/pages/AppsTab/AppsTab.tsx. Delete CreateAppView, AppDetailView, OverviewSubTab, ConfigSubTab, VersionRow, and the switch in the default export. Keep only AppListView and the default export pointing to it:
import AppListView from './AppListView'; // if you extracted; or keep the existing local function
export default function AppsTab() {
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: environments = [] } = useEnvironments();
return <AppListView selectedEnv={selectedEnv} environments={environments} />;
}
(If AppListView is still a local function, keep it inline and just remove the other components + the routing branch.)
- Step 2: Fix imports and build
Remove now-unused imports. Run:
cd ui && npm run build
Expected: BUILD SUCCESS.
- Step 3: Commit
git add ui/src/pages/AppsTab/AppsTab.tsx
git commit -m "ui(deploy): delete CreateAppView + AppDetailView + ConfigSubTab"
Task 12.2: Update .claude/rules/ui.md
Files:
-
Modify:
.claude/rules/ui.md -
Step 1: Rewrite the Deployments bullet
Find the bullet under "UI Structure" that begins **Deployments**. Replace with:
- **Deployments** — unified app deployment page (`ui/src/pages/AppsTab/`)
- Routes: `/apps` (list, `AppListView`), `/apps/new` + `/apps/:slug` (both render `AppDeploymentPage`).
- Identity & Artifact section always visible; name editable pre-first-deploy, read-only after. JAR picker client-stages; new JAR + any form edits flip the primary button from `Save` to `Redeploy`. Environment fixed to the currently-selected env (no selector).
- Config sub-tabs: **Monitoring | Resources | Variables | Sensitive Keys | Deployment | ● Traces & Taps | ● Route Recording**. The four staged tabs feed dirty detection; the `●` live tabs apply in real-time (banner + default `?apply=live` on their writes) and never mark dirty.
- Primary action state machine: `Save` (persists desired state without deploying) → `Redeploy` (applies desired state) → `Deploying…` during active deploy.
- Checkpoints disclosure in Identity section lists past successful deployments (current running one hidden, pruned-JAR rows disabled). Restore hydrates the form from the snapshot for Save + Redeploy.
- Deployment tab: `StatusCard` + `DeploymentProgress` (during STARTING) + flex-grow `StartupLogPanel` (no fixed maxHeight) + `HistoryDisclosure`. Auto-activates when a deploy starts.
- Unsaved-change router blocker uses DS `ConfirmDialog` (not `window.beforeunload`). Env switch intentionally discards edits without warning.
- Step 2: Commit
git add .claude/rules/ui.md
git commit -m "docs(rules): update ui.md Deployments bullet for unified deployment page"
Task 12.3: Update .claude/rules/app-classes.md
Files:
-
Modify:
.claude/rules/app-classes.md -
Step 1: Amend
ApplicationConfigControllerbullet
Find the ApplicationConfigController entry. Append:
PUT `/apps/{appSlug}/config` accepts `?apply=staged|live` (default `live`). `live` saves to DB and pushes `CONFIG_UPDATE` SSE to live agents; `staged` saves to DB only (no push) — used by the unified app deployment page. Audit action is `stage_app_config` for staged writes, `update_app_config` for live.
- Step 2: Amend
AppControllerbullet
Append:
GET `/apps/{appSlug}/dirty-state` returns `{ dirty, lastSuccessfulDeploymentId, differences[] }` by comparing the app's current desired state (latest JAR version + agent config + container config) against `deployments.deployed_config_snapshot` on the latest RUNNING deployment for `(app, env)`. NULL snapshot (or no successful deploy) → dirty=true by definition.
- Step 3: Add
deployed_config_snapshotnote under storage
Find the PostgresDeploymentRepository bullet. Append:
Carries `deployed_config_snapshot` JSONB (Flyway V3) populated by `DeploymentExecutor` only on successful transition to RUNNING. Consumed by `DirtyStateCalculator` for the `/apps/{slug}/dirty-state` endpoint and by the UI for checkpoint restore.
- Step 4: Commit
git add .claude/rules/app-classes.md
git commit -m "docs(rules): document ?apply flag + dirty-state endpoint + snapshot column"
Phase 13 — Manual browser QA
Task 13.1: Exercise the four visual states
- Step 1: Boot local stack
Start Postgres, ClickHouse, and a test Docker daemon per your local-services playbook. Boot the backend:
mvn -pl cameleer-server-app spring-boot:run
Boot the UI dev server:
cd ui && npm run dev
- Step 2: Walk the Net-new path
- Navigate to
/apps/newin the selected env. - Verify no env selector.
- Click
Select JAR, choose any demo JAR with a name likepayment-gateway-1.2.3.jar. - Confirm name auto-fills as
Payment Gateway. Type to override, confirm derivation stops. - Edit Monitoring (set samplingRate 0.5). Primary button stays
Save(enabled). - Click
Save. Confirm navigation to/apps/payment-gatewayand primary becomesRedeploy. - Click
Redeploy. Confirm auto-switch to Deployment tab, progress bar advances, log streams live. - On completion, verify log stays mounted, primary becomes
Save(disabled), status card shows RUNNING.
- Step 3: Walk the Dirty-edit path
- On the same app, open Resources. Change
memoryLimitto a new value. - Primary becomes
Save,Discardghost appears, Resources tab shows*. - Click
Discard— field reverts.*and ghost button go away. - Change again, click
Save. Primary becomesRedeploy(config is now staged, ≠ last deploy snapshot). - Click
Redeploy, observe deploy cycle and successful completion, primary returns to disabledSave.
- Step 4: Walk the Checkpoint restore path
- Open Checkpoints, verify the currently-running deployment is not in the list.
- Click Restore on a prior checkpoint. Confirm form fields update (samplingRate, memoryLimit) to old values.
- Primary becomes
Save. Click Save → Redeploy.
- Step 5: Walk the Deploy-failure path
- Trigger a deploy that will fail (bad JAR or missing image) — or simulate by stopping Docker mid-deploy.
- Confirm progress bar sticks on failed stage (red), log stays, primary becomes
Redeploy(still dirty).
- Step 6: Walk the Unsaved-changes blocker
- Edit a tab field. Click the sidebar to navigate away.
- Confirm DS dialog appears asking to discard. Cancel returns you; confirm proceeds.
- Step 7: Walk the Env switch
- With dirty form, open env switcher, pick a different env.
- Confirm: page remounts without a dialog, edits are lost silently (per design).
- Step 8: Commit QA notes (optional)
No code change — just record any issues found. If everything passes, finish with:
git log --oneline | head -30
Verify all plan commits are present.
Self-Review
Spec coverage: All 13 design sections in 2026-04-22-app-deployment-page-design.md have at least one task. Phase 1 covers snapshot column + capture; Phase 2 covers staged/live flag; Phase 3 covers dirty-state endpoint; Phase 4 regenerates OpenAPI; Phases 5–11 implement the unified page UI including the router blocker; Phase 12 handles cleanup + rules docs; Phase 13 is the manual QA pass required by CLAUDE.md for UI changes.
Placeholder scan: no "TBD", "TODO", "handle edge cases", or "similar to task N" references. Every step has either a concrete code block or a concrete shell command with expected output. The only forward reference is that Task 6.3's onRestore is a placeholder console.info — wired fully in Task 10.3. This is intentional, and Task 10.3 includes the full restore implementation.
Type consistency checks:
DeploymentConfigSnapshotrecord fields (jarVersionId,agentConfig,containerConfig) match across Task 1.2, Task 1.4 (mapper), Task 1.5 (executor), Task 3.1 (calculator), Task 3.2 (endpoint).DirtyStateResult/DirtyStateResult.Differencenames are used consistently in Task 3.1 and Task 3.2.?apply=staged|liveconsistently defaults tolive: backend (Task 2.1) + TypeScript client (Task 5.2) + page call site (Task 10.3) all agree.DeploymentPageFormStateslice names (monitoring,resources,variables,sensitiveKeys) match across the hook (Task 7.1), per-tab props (Tasks 7.2–7.5), dirty hook (Task 10.1), and the page (Task 10.3).PrimaryActionModevalues (save,redeploy,deploying) match between the component (Task 10.2) and its caller (Task 10.3).- The
useDeploymentPageStatereturn shape was refined in Task 10.3 to also exposeserverState— Task 10.1'suseFormDirtysignature matches that refined shape.
Gaps found during review: none blocking. The Start action in the DeploymentTab's StatusCard is wired to a TODO-ish empty handler in Task 10.3 — on a deployment with status STOPPED, clicking Start effectively needs to call createDeployment with the stopped deployment's version. For v1 this is an extremely rare path (users usually just click Redeploy instead), so leaving it as an empty stub that logs a toast ("Use Redeploy to relaunch a stopped service") is acceptable. If you want this wired, add a one-line step to Task 10.3: route onStart through handleRedeploy with the specific deployment's appVersionId.