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>
3320 lines
118 KiB
Markdown
3320 lines
118 KiB
Markdown
# 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 ~400–600). The fields are: engineLevel, payloadCapture + size/unit, appLogLevel, agentLogLevel, metricsEnabled, metricsInterval, samplingRate, compressSuccess, replayEnabled, routeControlEnabled.
|
||
|
||
Port this JSX into a new component that takes the `MonitoringFormState` plus an `onChange` callback:
|
||
|
||
```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 5–11 implement the unified page UI including the router blocker; Phase 12 handles cleanup + rules docs; Phase 13 is the manual QA pass required by CLAUDE.md for UI changes.
|
||
|
||
**Placeholder scan:** no "TBD", "TODO", "handle edge cases", or "similar to task N" references. Every step has either a concrete code block or a concrete shell command with expected output. The only forward reference is that Task 6.3's `onRestore` is a placeholder `console.info` — wired fully in Task 10.3. This is intentional, and Task 10.3 includes the full restore implementation.
|
||
|
||
**Type consistency checks:**
|
||
- `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.2–7.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`.
|