Files
cameleer-server/docs/superpowers/plans/2026-04-22-app-deployment-page.md
hsiegeln 1a376eb25f plan(deploy): unified app deployment page implementation plan
13 phases, TDD-oriented: Flyway V3 snapshot column, staged/live config
write flag, dirty-state endpoint, regen OpenAPI, then the new React page
(Identity, Checkpoints, 7 tabs including the live-apply Traces+Taps and
Route Recording with banner), primary Save/Redeploy state machine,
router blocker, old view cleanup, rules docs, and a manual QA walkthrough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:14:11 +02:00

3320 lines
118 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.