Files
cameleer-server/docs/superpowers/plans/2026-04-22-app-deployment-page.md

3320 lines
118 KiB
Markdown
Raw Normal View History

# 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` — adds `deployed_config_snapshot JSONB` on `deployments`.
- **Modify:** `cameleer-server-app/src/main/java/com/cameleer/server/core/runtime/Deployment.java` (or the record defining the `Deployment` model) — add `deployedConfigSnapshot` field.
- **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|live` param (default `live`) on PUT.
- **Modify:** `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppController.java` — add `GET /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` — add `useDirtyState` hook; change `useUpdateApplicationConfig` to accept `apply` param.
- **Modify:** `ui/src/api/queries/commands.ts` — same staged flag plumbing if `useUpdateApplicationConfig` lives here.
- **Modify:** `ui/src/router.tsx` — route `/apps/new` and `/apps/:appId` both to `AppDeploymentPage`.
- **Modify:** `ui/src/pages/AppsTab/AppsTab.tsx` — reduce to only `AppListView`.
- **Modify:** `ui/src/components/StartupLogPanel.tsx` — drop fixed 300px, accept `className` so parent can flex-grow.
- **Delete:** all of `CreateAppView`, `AppDetailView`, `OverviewSubTab`, `ConfigSubTab`, `VersionRow` in `AppsTab.tsx` (keep only `AppListView`).
- **Modify:** `.claude/rules/ui.md` — rewrite Deployments bullet.
- **Modify:** `.claude/rules/app-classes.md` — note `?apply=staged|live` on `ApplicationConfigController` and new `GET /apps/{appSlug}/dirty-state` on `AppController`.
- **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**
```sql
-- 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):
```bash
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**
```bash
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**
```java
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:
```bash
mvn -pl cameleer-server-core -am compile
```
Expected: BUILD SUCCESS.
- [ ] **Step 3: Commit**
```bash
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 in `cameleer-server-app`)
- [ ] **Step 1: Locate the Deployment record**
Run:
```bash
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:
```java
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:
```bash
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:
```bash
mvn -pl cameleer-server-core -am compile
```
Expected: BUILD SUCCESS.
- [ ] **Step 6: Commit**
```bash
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):
```java
@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:
```bash
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:
```java
// 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:
```java
// 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:
```bash
mvn -pl cameleer-server-app test -Dtest=PostgresDeploymentRepositoryIT#deployedConfigSnapshot_roundtrips
```
Expected: PASS.
- [ ] **Step 4: Run full repository IT suite to confirm no regressions**
Run:
```bash
mvn -pl cameleer-server-app test -Dtest=PostgresDeploymentRepositoryIT
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
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:
```bash
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`:
```java
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:
```bash
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:
```java
// 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:
```bash
mvn -pl cameleer-server-app test -Dtest=DeploymentSnapshotIT
```
Expected: both tests PASS.
- [ ] **Step 5: Commit**
```bash
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`:
```java
@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:
```bash
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`:
```java
@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:
```java
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:
```bash
mvn -pl cameleer-server-app test -Dtest=ApplicationConfigControllerIT#putConfig_staged_savesButDoesNotPush+putConfig_live_savesAndPushes
```
Expected: both PASS.
- [ ] **Step 4: Commit**
```bash
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**
```java
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:
```bash
mvn -pl cameleer-server-core test -Dtest=DirtyStateCalculatorTest
```
Expected: FAIL — class doesn't exist.
- [ ] **Step 2: Write the implementation**
```java
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:
```java
// 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:
```bash
mvn -pl cameleer-server-core test -Dtest=DirtyStateCalculatorTest
```
Expected: all PASS.
- [ ] **Step 4: Commit**
```bash
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**
```java
// 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**
```java
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:
```bash
mvn -pl cameleer-server-app test -Dtest=AppDirtyStateIT
```
Expected: FAIL — endpoint does not exist, 404.
- [ ] **Step 3: Add the endpoint**
In `AppController`:
```java
@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`:
```java
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:
```bash
mvn -pl cameleer-server-app test -Dtest=AppDirtyStateIT
```
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
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):
```bash
# 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**
```bash
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**
```bash
git diff --stat ui/src/api/
```
Expected: both files changed.
```bash
grep -n "dirty-state\|deployedConfigSnapshot\|\"apply\"" ui/src/api/openapi.json
```
Expected: hits for all three.
- [ ] **Step 4: Commit**
```bash
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**
```tsx
// 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>
);
}
```
```css
/* 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:
```tsx
// 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:
```bash
cd ui && npm run build
```
Expected: BUILD SUCCESS, no TS errors.
- [ ] **Step 4: Commit**
```bash
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`:
```typescript
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 `useUpdateApplicationConfig` with `apply`**
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'`:
```typescript
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**
```bash
cd ui && npm run build
```
Expected: BUILD SUCCESS.
- [ ] **Step 4: Commit**
```bash
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**
```typescript
// 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:
```bash
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts
```
Expected: FAIL (module doesn't exist).
- [ ] **Step 2: Write the implementation**
```typescript
// 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**
```bash
cd ui && npx vitest run src/pages/AppsTab/AppDeploymentPage/utils/deriveAppName.test.ts
```
Expected: all PASS.
- [ ] **Step 4: Commit**
```bash
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**
```tsx
// 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`:
```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`:
```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**
```bash
cd ui && npm run build
```
Expected: BUILD SUCCESS.
- [ ] **Step 4: Commit**
```bash
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**
```tsx
// 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:
```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`:
```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**
```bash
cd ui && npm run build
```
Expected: BUILD SUCCESS.
- [ ] **Step 4: Commit**
```bash
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**
```typescript
// 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**
```bash
cd ui && npm run build
```
Expected: BUILD SUCCESS.
- [ ] **Step 3: Commit**
```bash
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 ~400600). 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:
```tsx
// 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**
```bash
cd ui && npm run build
```
Expected: BUILD SUCCESS.
- [ ] **Step 3: Commit**
```bash
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`.
```tsx
// 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**
```bash
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**
```tsx
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:
```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**
```bash
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.
```tsx
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**
```bash
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**
```tsx
// 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:
```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**
```bash
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.
```tsx
// 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**
```bash
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**
```tsx
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:
```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**
```bash
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**
```tsx
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:
```css
.historyRow { margin-top: 16px; }
```
- [ ] **Step 2: Build + commit**
```bash
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:
```tsx
// 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**
```tsx
// 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:
```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**
```bash
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**
```typescript
// 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**
```bash
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**
```tsx
// 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**
```bash
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**
```tsx
// 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:
```typescript
// hooks/useDeploymentPageState.ts — refactor return type
return { form, setForm, reset: () => setForm(serverState), serverState };
```
Then in the page:
```typescript
const { form, setForm, reset, serverState } = useDeploymentPageState(app, agentConfig ?? null, envDefaults);
const dirty = useFormDirty(form, serverState, stagedJar);
```
- [ ] **Step 2: Build**
```bash
cd ui && npm run build
```
Expected: BUILD SUCCESS.
- [ ] **Step 3: Commit**
```bash
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**
```typescript
// 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:
```tsx
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**
```bash
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:
```tsx
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:
```bash
cd ui && npm run build
```
Expected: BUILD SUCCESS.
- [ ] **Step 3: Commit**
```bash
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:
```markdown
- **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**
```bash
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 `ApplicationConfigController` bullet**
Find the `ApplicationConfigController` entry. Append:
```markdown
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 `AppController` bullet**
Append:
```markdown
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_snapshot` note under storage**
Find the `PostgresDeploymentRepository` bullet. Append:
```markdown
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**
```bash
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:
```bash
mvn -pl cameleer-server-app spring-boot:run
```
Boot the UI dev server:
```bash
cd ui && npm run dev
```
- [ ] **Step 2: Walk the Net-new path**
1. Navigate to `/apps/new` in the selected env.
2. Verify no env selector.
3. Click `Select JAR`, choose any demo JAR with a name like `payment-gateway-1.2.3.jar`.
4. Confirm name auto-fills as `Payment Gateway`. Type to override, confirm derivation stops.
5. Edit Monitoring (set samplingRate 0.5). Primary button stays `Save` (enabled).
6. Click `Save`. Confirm navigation to `/apps/payment-gateway` and primary becomes `Redeploy`.
7. Click `Redeploy`. Confirm auto-switch to Deployment tab, progress bar advances, log streams live.
8. On completion, verify log stays mounted, primary becomes `Save` (disabled), status card shows RUNNING.
- [ ] **Step 3: Walk the Dirty-edit path**
1. On the same app, open Resources. Change `memoryLimit` to a new value.
2. Primary becomes `Save`, `Discard` ghost appears, Resources tab shows `*`.
3. Click `Discard` — field reverts. `*` and ghost button go away.
4. Change again, click `Save`. Primary becomes `Redeploy` (config is now staged, ≠ last deploy snapshot).
5. Click `Redeploy`, observe deploy cycle and successful completion, primary returns to disabled `Save`.
- [ ] **Step 4: Walk the Checkpoint restore path**
1. Open Checkpoints, verify the currently-running deployment is **not** in the list.
2. Click Restore on a prior checkpoint. Confirm form fields update (samplingRate, memoryLimit) to old values.
3. Primary becomes `Save`. Click Save → Redeploy.
- [ ] **Step 5: Walk the Deploy-failure path**
1. Trigger a deploy that will fail (bad JAR or missing image) — or simulate by stopping Docker mid-deploy.
2. Confirm progress bar sticks on failed stage (red), log stays, primary becomes `Redeploy` (still dirty).
- [ ] **Step 6: Walk the Unsaved-changes blocker**
1. Edit a tab field. Click the sidebar to navigate away.
2. Confirm DS dialog appears asking to discard. Cancel returns you; confirm proceeds.
- [ ] **Step 7: Walk the Env switch**
1. With dirty form, open env switcher, pick a different env.
2. 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:
```bash
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 511 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:**
- `DeploymentConfigSnapshot` record 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.Difference` names are used consistently in Task 3.1 and Task 3.2.
- `?apply=staged|live` consistently defaults to `live`: backend (Task 2.1) + TypeScript client (Task 5.2) + page call site (Task 10.3) all agree.
- `DeploymentPageFormState` slice names (`monitoring`, `resources`, `variables`, `sensitiveKeys`) match across the hook (Task 7.1), per-tab props (Tasks 7.27.5), dirty hook (Task 10.1), and the page (Task 10.3).
- `PrimaryActionMode` values (`save`, `redeploy`, `deploying`) match between the component (Task 10.2) and its caller (Task 10.3).
- The `useDeploymentPageState` return shape was refined in Task 10.3 to also expose `serverState` — Task 10.1's `useFormDirty` signature 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`.