Compare commits
44 Commits
2835d08418
...
837e5d46f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
837e5d46f5 | ||
|
|
0a71bca7b8 | ||
|
|
b7b6bd2a96 | ||
|
|
d33c039a17 | ||
|
|
6d5ce60608 | ||
|
|
d595746830 | ||
|
|
5a7c0ce4bc | ||
|
|
3a649f40cd | ||
|
|
b1bdb88ea4 | ||
|
|
0e4166bd5f | ||
|
|
42fb6c8b8c | ||
|
|
1579f10a41 | ||
|
|
063a4a5532 | ||
|
|
98a7b7819f | ||
|
|
e96c3cd0cf | ||
|
|
b7c0a225f5 | ||
|
|
f487e6caef | ||
|
|
bb06c4c689 | ||
|
|
5c48b780b2 | ||
|
|
4f5a11f715 | ||
|
|
cc193a1075 | ||
|
|
08efdfa9c5 | ||
|
|
00c7c0cd71 | ||
|
|
d067490f71 | ||
|
|
52ff385b04 | ||
|
|
6052975750 | ||
|
|
0434299d53 | ||
|
|
97f25b4c7e | ||
|
|
6591f2fde3 | ||
|
|
24464c0772 | ||
|
|
e4ccce1e3b | ||
|
|
76352c0d6f | ||
|
|
e716dbf8ca | ||
|
|
76129d407e | ||
|
|
9b1240274d | ||
|
|
a79eafeaf4 | ||
|
|
9b851c4622 | ||
|
|
d3e86b9d77 | ||
|
|
7f9cfc7f18 | ||
|
|
06fa7d832f | ||
|
|
d580b6e90c | ||
|
|
ff95187707 | ||
|
|
1a376eb25f | ||
|
|
58ec67aef9 |
@@ -53,9 +53,9 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
|
||||
### Env-scoped (user-facing data & config)
|
||||
|
||||
- `AppController` — `/api/v1/environments/{envSlug}/apps`. GET list / POST create / GET `{appSlug}` / DELETE `{appSlug}` / GET `{appSlug}/versions` / POST `{appSlug}/versions` (JAR upload) / PUT `{appSlug}/container-config`. App slug uniqueness is per-env (`(env, app_slug)` is the natural key). `CreateAppRequest` body has no env (path), validates slug regex.
|
||||
- `AppController` — `/api/v1/environments/{envSlug}/apps`. GET list / POST create / GET `{appSlug}` / DELETE `{appSlug}` / GET `{appSlug}/versions` / POST `{appSlug}/versions` (JAR upload) / PUT `{appSlug}/container-config` / GET `{appSlug}/dirty-state` (returns `DirtyStateResponse{dirty, lastSuccessfulDeploymentId, differences}` — compares current JAR+config against last RUNNING deployment snapshot; dirty=true when no snapshot exists). App slug uniqueness is per-env (`(env, app_slug)` is the natural key). `CreateAppRequest` body has no env (path), validates slug regex. Injects `DirtyStateCalculator` bean (registered in `RuntimeBeanConfig`, requires `ObjectMapper` with `JavaTimeModule`).
|
||||
- `DeploymentController` — `/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`) / POST `{id}/stop` / POST `{id}/promote` (body `{ targetEnvironment: slug }` — target app slug must exist in target env) / GET `{id}/logs`.
|
||||
- `ApplicationConfigController` — `/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT also pushes `CONFIG_UPDATE` to LIVE agents in this env.
|
||||
- `ApplicationConfigController` — `/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT accepts `?apply=staged|live` (default `live`). `live` saves to DB and pushes `CONFIG_UPDATE` SSE to live agents in this env (existing behavior); `staged` saves to DB only, skipping the SSE push — used by the unified app deployment page. Audit action is `stage_app_config` for staged writes, `update_app_config` for live. Invalid `apply` values return 400.
|
||||
- `AppSettingsController` — `/api/v1/environments/{envSlug}`. GET `/app-settings` (list), GET/PUT/DELETE `/apps/{appSlug}/settings`. ADMIN/OPERATOR only.
|
||||
- `SearchController` — `/api/v1/environments/{envSlug}`. GET `/executions`, POST `/executions/search`, GET `/stats`, `/stats/timeseries`, `/stats/timeseries/by-app`, `/stats/timeseries/by-route`, `/stats/punchcard`, `/attributes/keys`, `/errors/top`.
|
||||
- `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source (multi, comma-split, OR-joined), level (multi, comma-split, OR-joined), application, agentId, exchangeId, logger, q, time range; sort asc/desc). Cursor-paginated, returns `{ data, nextCursor, hasMore, levelCounts }`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"` — same-millisecond tiebreak via the `insert_id` UUID column on `logs`.
|
||||
@@ -133,7 +133,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
|
||||
## storage/ — PostgreSQL repositories (JdbcTemplate)
|
||||
|
||||
- `PostgresAppRepository`, `PostgresAppVersionRepository`, `PostgresEnvironmentRepository`
|
||||
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
|
||||
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId. Also carries `deployed_config_snapshot` JSONB (Flyway V3) populated by `DeploymentExecutor` via `saveDeployedConfigSnapshot(UUID, DeploymentConfigSnapshot)` on successful RUNNING transition. Consumed by `DirtyStateCalculator` for the `/apps/{slug}/dirty-state` endpoint and by the UI for checkpoint restore.
|
||||
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
|
||||
- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`, `PostgresSensitiveKeysRepository`
|
||||
- `PostgresAppSettingsRepository`, `PostgresApplicationConfigRepository`, `PostgresThresholdRepository`. Both `app_settings` and `application_config` are env-scoped (PK `(app_id, environment)` / `(application, environment)`); finders take `(app, env)` — no env-agnostic variants.
|
||||
|
||||
@@ -10,10 +10,14 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
|
||||
- **Exchanges** — route execution search and detail (`ui/src/pages/Exchanges/`)
|
||||
- **Dashboard** — metrics and stats with L1/L2/L3 drill-down (`ui/src/pages/DashboardTab/`)
|
||||
- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`). AgentHealth supports compact view (dense health-tinted cards) and expanded view (full GroupCard+DataTable per app). View mode persisted to localStorage.
|
||||
- **Deployments** — app management, JAR upload, deployment lifecycle (`ui/src/pages/AppsTab/`)
|
||||
- Config sub-tabs: **Monitoring | Resources | Variables | Traces & Taps | Route Recording**
|
||||
- Create app: full page at `/apps/new` (not a modal)
|
||||
- Deployment progress: `ui/src/components/DeploymentProgress.tsx` (7-stage step indicator)
|
||||
- **Deployments** — unified app deployment page (`ui/src/pages/AppsTab/`)
|
||||
- Routes: `/apps` (list, `AppListView` in `AppsTab.tsx`), `/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 (amber LiveBanner + 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 `deployments.deployed_config_snapshot` for Save + Redeploy.
|
||||
- Deployment tab: `StatusCard` + `DeploymentProgress` (during STARTING / FAILED) + flex-grow `StartupLogPanel` (no fixed maxHeight) + `HistoryDisclosure`. Auto-activates when a deploy starts.
|
||||
- Unsaved-change router blocker uses DS `AlertDialog` (not `window.beforeunload`). Env switch intentionally discards edits without warning.
|
||||
|
||||
**Admin pages** (ADMIN-only, under `/admin/`):
|
||||
- **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config. Shows agent built-in defaults as outlined Badge reference, editable Tag pills for custom keys, amber-highlighted push-to-agents toggle. Keys add to (not replace) agent defaults. Per-app sensitive key additions managed via `ApplicationConfigController` API. Note: `AppConfigDetailPage.tsx` exists but is not routed in `router.tsx`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (8893 symbols, 23049 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (9095 symbols, 23495 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **cameleer-server** (8893 symbols, 23049 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **cameleer-server** (9095 symbols, 23495 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.cameleer.server.core.runtime.AppService;
|
||||
import com.cameleer.server.core.runtime.AppVersionRepository;
|
||||
import com.cameleer.server.core.runtime.DeploymentRepository;
|
||||
import com.cameleer.server.core.runtime.DeploymentService;
|
||||
import com.cameleer.server.core.runtime.DirtyStateCalculator;
|
||||
import com.cameleer.server.core.runtime.EnvironmentRepository;
|
||||
import com.cameleer.server.core.runtime.EnvironmentService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -64,6 +65,11 @@ public class RuntimeBeanConfig {
|
||||
return new DeploymentService(deployRepo, appService, envService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DirtyStateCalculator dirtyStateCalculator(ObjectMapper objectMapper) {
|
||||
return new DirtyStateCalculator(objectMapper);
|
||||
}
|
||||
|
||||
@Bean(name = "deploymentTaskExecutor")
|
||||
public Executor deploymentTaskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.cameleer.server.app.dto.DirtyStateResponse;
|
||||
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
|
||||
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
|
||||
import com.cameleer.server.app.web.EnvPath;
|
||||
import com.cameleer.server.core.runtime.App;
|
||||
import com.cameleer.server.core.runtime.AppService;
|
||||
import com.cameleer.server.core.runtime.AppVersion;
|
||||
import com.cameleer.server.core.runtime.AppVersionRepository;
|
||||
import com.cameleer.server.core.runtime.Deployment;
|
||||
import com.cameleer.server.core.runtime.DeploymentConfigSnapshot;
|
||||
import com.cameleer.server.core.runtime.DirtyStateCalculator;
|
||||
import com.cameleer.server.core.runtime.DirtyStateResult;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
import com.cameleer.server.core.runtime.RuntimeType;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -22,8 +32,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
@@ -40,9 +52,21 @@ import java.util.UUID;
|
||||
public class AppController {
|
||||
|
||||
private final AppService appService;
|
||||
private final AppVersionRepository appVersionRepository;
|
||||
private final PostgresApplicationConfigRepository configRepository;
|
||||
private final PostgresDeploymentRepository deploymentRepository;
|
||||
private final DirtyStateCalculator dirtyCalc;
|
||||
|
||||
public AppController(AppService appService) {
|
||||
public AppController(AppService appService,
|
||||
AppVersionRepository appVersionRepository,
|
||||
PostgresApplicationConfigRepository configRepository,
|
||||
PostgresDeploymentRepository deploymentRepository,
|
||||
DirtyStateCalculator dirtyCalc) {
|
||||
this.appService = appService;
|
||||
this.appVersionRepository = appVersionRepository;
|
||||
this.configRepository = configRepository;
|
||||
this.deploymentRepository = deploymentRepository;
|
||||
this.dirtyCalc = dirtyCalc;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -120,6 +144,47 @@ public class AppController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/{appSlug}/dirty-state")
|
||||
@Operation(summary = "Check whether the app's current config differs from the last successful deploy",
|
||||
description = "Returns dirty=true when the desired state (current JAR + agent config + container config) "
|
||||
+ "would produce a changed deployment. When no successful deploy exists yet, dirty=true.")
|
||||
@ApiResponse(responseCode = "200", description = "Dirty-state computed")
|
||||
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||
public ResponseEntity<DirtyStateResponse> getDirtyState(@EnvPath Environment env,
|
||||
@PathVariable String appSlug) {
|
||||
App app;
|
||||
try {
|
||||
app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "App not found");
|
||||
}
|
||||
|
||||
// Latest JAR version (newest first — findByAppId orders by version DESC)
|
||||
List<AppVersion> versions = appVersionRepository.findByAppId(app.id());
|
||||
UUID latestVersionId = versions.isEmpty() ? null
|
||||
: versions.stream().max(Comparator.comparingInt(AppVersion::version))
|
||||
.map(AppVersion::id).orElse(null);
|
||||
|
||||
// Desired agent config
|
||||
ApplicationConfig agentConfig = configRepository
|
||||
.findByApplicationAndEnvironment(appSlug, env.slug())
|
||||
.orElse(null);
|
||||
|
||||
// Container config
|
||||
Map<String, Object> containerConfig = app.containerConfig();
|
||||
|
||||
// Last successful deployment snapshot
|
||||
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()));
|
||||
}
|
||||
|
||||
private static final java.util.regex.Pattern CUSTOM_ARGS_PATTERN =
|
||||
java.util.regex.Pattern.compile("^[-a-zA-Z0-9_.=:/\\s+\"']*$");
|
||||
|
||||
|
||||
@@ -108,13 +108,20 @@ public class ApplicationConfigController {
|
||||
|
||||
@PutMapping("/apps/{appSlug}/config")
|
||||
@Operation(summary = "Update application config for this environment",
|
||||
description = "Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment")
|
||||
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
|
||||
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)")
|
||||
@ApiResponse(responseCode = "400", description = "Unknown apply value (must be 'staged' or '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) {
|
||||
if (!"staged".equalsIgnoreCase(apply) && !"live".equalsIgnoreCase(apply)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
|
||||
}
|
||||
|
||||
String updatedBy = auth != null ? auth.getName() : "system";
|
||||
|
||||
config.setApplication(appSlug);
|
||||
@@ -126,14 +133,24 @@ public class ApplicationConfigController {
|
||||
List<String> perAppKeys = extractSensitiveKeys(saved);
|
||||
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
|
||||
|
||||
CommandGroupResponse 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());
|
||||
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("update_app_config", AuditCategory.CONFIG, appSlug,
|
||||
auditService.log(
|
||||
"staged".equalsIgnoreCase(apply) ? "stage_app_config" : "update_app_config",
|
||||
AuditCategory.CONFIG, appSlug,
|
||||
Map.of("environment", env.slug(), "version", saved.getVersion(),
|
||||
"apply", apply.toLowerCase(),
|
||||
"agentsPushed", pushResult.total(),
|
||||
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
|
||||
"responded", pushResult.responded(),
|
||||
"timedOut", pushResult.timedOut().size()),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
|
||||
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
) {
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.cameleer.server.app.runtime;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.cameleer.server.app.metrics.ServerMetrics;
|
||||
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
|
||||
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
|
||||
import com.cameleer.server.core.runtime.*;
|
||||
import org.slf4j.Logger;
|
||||
@@ -25,6 +27,7 @@ public class DeploymentExecutor {
|
||||
private final EnvironmentService envService;
|
||||
private final DeploymentRepository deploymentRepository;
|
||||
private final PostgresDeploymentRepository pgDeployRepo;
|
||||
private final PostgresApplicationConfigRepository applicationConfigRepository;
|
||||
|
||||
@Autowired(required = false)
|
||||
private DockerNetworkManager networkManager;
|
||||
@@ -75,13 +78,15 @@ public class DeploymentExecutor {
|
||||
DeploymentService deploymentService,
|
||||
AppService appService,
|
||||
EnvironmentService envService,
|
||||
DeploymentRepository deploymentRepository) {
|
||||
DeploymentRepository deploymentRepository,
|
||||
PostgresApplicationConfigRepository applicationConfigRepository) {
|
||||
this.orchestrator = orchestrator;
|
||||
this.deploymentService = deploymentService;
|
||||
this.appService = appService;
|
||||
this.envService = envService;
|
||||
this.deploymentRepository = deploymentRepository;
|
||||
this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository;
|
||||
this.applicationConfigRepository = applicationConfigRepository;
|
||||
}
|
||||
|
||||
@Async("deploymentTaskExecutor")
|
||||
@@ -252,6 +257,19 @@ public class DeploymentExecutor {
|
||||
// === COMPLETE ===
|
||||
updateStage(deployment.id(), DeployStage.COMPLETE);
|
||||
|
||||
// Capture config snapshot before marking RUNNING
|
||||
ApplicationConfig agentConfig = applicationConfigRepository
|
||||
.findByApplicationAndEnvironment(app.slug(), env.slug())
|
||||
.orElse(null);
|
||||
List<String> snapshotSensitiveKeys = agentConfig != null ? agentConfig.getSensitiveKeys() : null;
|
||||
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
|
||||
deployment.appVersionId(),
|
||||
agentConfig,
|
||||
app.containerConfig(),
|
||||
snapshotSensitiveKeys
|
||||
);
|
||||
pgDeployRepo.saveDeployedConfigSnapshot(deployment.id(), snapshot);
|
||||
|
||||
String primaryContainerId = newContainerIds.get(0);
|
||||
DeploymentStatus finalStatus = healthyCount == config.replicas()
|
||||
? DeploymentStatus.RUNNING : DeploymentStatus.DEGRADED;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cameleer.server.app.storage;
|
||||
|
||||
import com.cameleer.server.core.runtime.Deployment;
|
||||
import com.cameleer.server.core.runtime.DeploymentConfigSnapshot;
|
||||
import com.cameleer.server.core.runtime.DeploymentRepository;
|
||||
import com.cameleer.server.core.runtime.DeploymentStatus;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
@@ -21,7 +22,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
||||
private static final String SELECT_COLS =
|
||||
"id, app_id, app_version_id, environment_id, status, target_state, deployment_strategy, " +
|
||||
"replica_states, deploy_stage, container_id, container_name, error_message, " +
|
||||
"resolved_config, deployed_at, stopped_at, created_at";
|
||||
"resolved_config, deployed_config_snapshot, deployed_at, stopped_at, created_at";
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -129,6 +130,25 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public void saveDeployedConfigSnapshot(UUID id, DeploymentConfigSnapshot snapshot) {
|
||||
try {
|
||||
String json = snapshot != null ? objectMapper.writeValueAsString(snapshot) : null;
|
||||
jdbc.update("UPDATE deployments SET deployed_config_snapshot = ?::jsonb WHERE id = ?", json, id);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to serialize deployed_config_snapshot", e);
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Deployment> findLatestSuccessfulByAppAndEnv(UUID appId, UUID envId) {
|
||||
var results = jdbc.query(
|
||||
"SELECT " + SELECT_COLS + " FROM deployments "
|
||||
+ "WHERE app_id = ? AND environment_id = ? "
|
||||
+ "AND status = 'RUNNING' AND deployed_config_snapshot IS NOT NULL "
|
||||
+ "ORDER BY deployed_at DESC NULLS LAST LIMIT 1",
|
||||
(rs, rowNum) -> mapRow(rs), appId, envId);
|
||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||
}
|
||||
|
||||
public Optional<Deployment> findByContainerId(String containerId) {
|
||||
var results = jdbc.query(
|
||||
"SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? " +
|
||||
@@ -158,6 +178,15 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
||||
throw new SQLException("Failed to deserialize resolved_config", e);
|
||||
}
|
||||
}
|
||||
DeploymentConfigSnapshot deployedConfigSnapshot = null;
|
||||
String snapshotJson = rs.getString("deployed_config_snapshot");
|
||||
if (snapshotJson != null) {
|
||||
try {
|
||||
deployedConfigSnapshot = objectMapper.readValue(snapshotJson, DeploymentConfigSnapshot.class);
|
||||
} catch (Exception e) {
|
||||
throw new SQLException("Failed to deserialize deployed_config_snapshot", e);
|
||||
}
|
||||
}
|
||||
return new Deployment(
|
||||
UUID.fromString(rs.getString("id")),
|
||||
UUID.fromString(rs.getString("app_id")),
|
||||
@@ -172,6 +201,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
||||
rs.getString("container_name"),
|
||||
rs.getString("error_message"),
|
||||
resolvedConfig,
|
||||
deployedConfigSnapshot,
|
||||
deployedAt != null ? deployedAt.toInstant() : null,
|
||||
stoppedAt != null ? stoppedAt.toInstant() : null,
|
||||
rs.getTimestamp("created_at").toInstant()
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 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;
|
||||
@@ -48,7 +48,7 @@ class DeploymentStateEvaluatorTest {
|
||||
private Deployment deployment(DeploymentStatus status) {
|
||||
return new Deployment(DEP_ID, APP_ID, UUID.randomUUID(), ENV_ID, status,
|
||||
null, null, List.of(), null, null, "orders-0", null,
|
||||
Map.of(), NOW.minusSeconds(60), null, NOW.minusSeconds(120));
|
||||
Map.of(), null, NOW.minusSeconds(60), null, NOW.minusSeconds(120));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.TestSecurityHelper;
|
||||
import com.cameleer.server.app.dto.DirtyStateResponse;
|
||||
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
|
||||
import com.cameleer.server.core.runtime.ContainerStatus;
|
||||
import com.cameleer.server.core.runtime.Deployment;
|
||||
import com.cameleer.server.core.runtime.DeploymentStatus;
|
||||
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Integration tests for GET /api/v1/environments/{envSlug}/apps/{appSlug}/dirty-state.
|
||||
*
|
||||
* <p>Uses @MockBean RuntimeOrchestrator (same pattern as DeploymentSnapshotIT).
|
||||
* @DirtiesContext prevents context cache conflicts when both IT classes are loaded together.</p>
|
||||
*/
|
||||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
|
||||
class AppDirtyStateIT extends AbstractPostgresIT {
|
||||
|
||||
@MockBean
|
||||
RuntimeOrchestrator runtimeOrchestrator;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
@Autowired
|
||||
private PostgresDeploymentRepository deploymentRepository;
|
||||
|
||||
private String operatorJwt;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
jdbcTemplate.update("DELETE FROM deployments");
|
||||
jdbcTemplate.update("DELETE FROM app_versions");
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: no deployment ever → dirty=true, lastSuccessfulDeploymentId=null
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void dirtyState_noDeployEver_returnsDirtyTrue() throws Exception {
|
||||
String appSlug = "ds-nodeploy-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
post("/api/v1/environments/default/apps",
|
||||
String.format("{\"slug\": \"%s\", \"displayName\": \"DS No Deploy\"}", appSlug),
|
||||
operatorJwt);
|
||||
uploadJar(appSlug, ("fake-jar-" + appSlug).getBytes());
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/config",
|
||||
"{\"samplingRate\": 0.5}", operatorJwt);
|
||||
|
||||
DirtyStateResponse body = getDirtyState("default", appSlug);
|
||||
|
||||
assertThat(body.dirty()).isTrue();
|
||||
assertThat(body.lastSuccessfulDeploymentId()).isNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: after a successful deploy with matching desired state → dirty=false
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void dirtyState_afterSuccessfulDeploy_matchingDesiredState_returnsDirtyFalse() throws Exception {
|
||||
String fakeContainerId = "fake-cid-" + UUID.randomUUID();
|
||||
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
|
||||
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
|
||||
.thenReturn(new ContainerStatus("healthy", true, 0, null));
|
||||
|
||||
String appSlug = "ds-clean-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
post("/api/v1/environments/default/apps",
|
||||
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Clean\"}", appSlug),
|
||||
operatorJwt);
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
|
||||
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
|
||||
String versionId = uploadJar(appSlug, ("fake-jar-clean-" + appSlug).getBytes());
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/config",
|
||||
"{\"samplingRate\": 0.25}", operatorJwt);
|
||||
|
||||
// Deploy and wait for RUNNING
|
||||
JsonNode deploy = post(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
|
||||
String.format("{\"appVersionId\": \"%s\"}", versionId),
|
||||
operatorJwt);
|
||||
String deploymentId = deploy.path("id").asText();
|
||||
|
||||
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
|
||||
.untilAsserted(() -> {
|
||||
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
|
||||
.orElseThrow(() -> new AssertionError("Deployment not found"));
|
||||
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
|
||||
});
|
||||
|
||||
// Desired state matches what was deployed → dirty=false
|
||||
DirtyStateResponse body = getDirtyState("default", appSlug);
|
||||
|
||||
assertThat(body.dirty()).isFalse();
|
||||
assertThat(body.differences()).isEmpty();
|
||||
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 3: after successful deploy, config changed → dirty=true
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void dirtyState_afterSuccessfulDeploy_configChanged_returnsDirtyTrue() throws Exception {
|
||||
String fakeContainerId = "fake-cid2-" + UUID.randomUUID();
|
||||
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
|
||||
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
|
||||
.thenReturn(new ContainerStatus("healthy", true, 0, null));
|
||||
|
||||
String appSlug = "ds-dirty-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
post("/api/v1/environments/default/apps",
|
||||
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Dirty\"}", appSlug),
|
||||
operatorJwt);
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
|
||||
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
|
||||
String versionId = uploadJar(appSlug, ("fake-jar-dirty-" + appSlug).getBytes());
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/config",
|
||||
"{\"samplingRate\": 0.1}", operatorJwt);
|
||||
|
||||
// Deploy and wait for RUNNING
|
||||
JsonNode deploy = post(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
|
||||
String.format("{\"appVersionId\": \"%s\"}", versionId),
|
||||
operatorJwt);
|
||||
String deploymentId = deploy.path("id").asText();
|
||||
|
||||
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
|
||||
.untilAsserted(() -> {
|
||||
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
|
||||
.orElseThrow(() -> new AssertionError("Deployment not found"));
|
||||
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
|
||||
});
|
||||
|
||||
// Change samplingRate after deploy
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/config",
|
||||
"{\"samplingRate\": 0.9}", operatorJwt);
|
||||
|
||||
// Now desired state differs from snapshot → dirty=true
|
||||
DirtyStateResponse body = getDirtyState("default", appSlug);
|
||||
|
||||
assertThat(body.dirty()).isTrue();
|
||||
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
|
||||
assertThat(body.differences()).isNotEmpty();
|
||||
assertThat(body.differences())
|
||||
.anyMatch(d -> d.field().contains("samplingRate"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private DirtyStateResponse getDirtyState(String envSlug, String appSlug) {
|
||||
HttpHeaders headers = securityHelper.authHeaders(operatorJwt);
|
||||
var response = restTemplate.exchange(
|
||||
"/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/dirty-state",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(headers),
|
||||
DirtyStateResponse.class);
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
return response.getBody();
|
||||
}
|
||||
|
||||
private JsonNode post(String path, String json, String jwt) throws Exception {
|
||||
HttpHeaders headers = securityHelper.authHeaders(jwt);
|
||||
var response = restTemplate.exchange(
|
||||
path, HttpMethod.POST,
|
||||
new HttpEntity<>(json, headers),
|
||||
String.class);
|
||||
return objectMapper.readTree(response.getBody());
|
||||
}
|
||||
|
||||
private void put(String path, String json, String jwt) {
|
||||
HttpHeaders headers = securityHelper.authHeaders(jwt);
|
||||
restTemplate.exchange(path, HttpMethod.PUT, new HttpEntity<>(json, headers), String.class);
|
||||
}
|
||||
|
||||
private String uploadJar(String appSlug, byte[] content) throws Exception {
|
||||
ByteArrayResource resource = new ByteArrayResource(content) {
|
||||
@Override
|
||||
public String getFilename() { return "app.jar"; }
|
||||
};
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", resource);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + operatorJwt);
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
var response = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/versions",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers),
|
||||
String.class);
|
||||
|
||||
JsonNode versionNode = objectMapper.readTree(response.getBody());
|
||||
return versionNode.path("id").asText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.TestSecurityHelper;
|
||||
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
|
||||
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer.server.core.agent.CommandType;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.SpyBean;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.annotation.DirtiesContext;
|
||||
import org.springframework.test.annotation.DirtiesContext.ClassMode;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@DirtiesContext(classMode = ClassMode.AFTER_CLASS)
|
||||
class ApplicationConfigControllerIT extends AbstractPostgresIT {
|
||||
|
||||
/**
|
||||
* Spy on the real AgentRegistryService bean so we can verify whether
|
||||
* addGroupCommandWithReplies was invoked (live) or skipped (staged).
|
||||
*/
|
||||
@SpyBean
|
||||
AgentRegistryService registryService;
|
||||
|
||||
@Autowired private TestRestTemplate restTemplate;
|
||||
@Autowired private TestSecurityHelper securityHelper;
|
||||
@Autowired private PostgresApplicationConfigRepository configRepository;
|
||||
|
||||
private String operatorJwt;
|
||||
/** Unique env slug per test to avoid cross-test pollution. */
|
||||
private String envSlug;
|
||||
private UUID envId;
|
||||
/** Unique app slug per test run to avoid cross-test row collisions. */
|
||||
private String appSlug;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
envSlug = "cfg-it-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
envId = UUID.randomUUID();
|
||||
appSlug = "paygw-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING",
|
||||
envId, envSlug, envSlug);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = ?", envSlug);
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void registerLiveAgent(String agentId) {
|
||||
// Use the bootstrap HTTP endpoint — same pattern as AgentCommandControllerIT.
|
||||
String body = """
|
||||
{
|
||||
"instanceId": "%s",
|
||||
"applicationId": "%s",
|
||||
"environmentId": "%s",
|
||||
"version": "1.0.0",
|
||||
"routeIds": ["route-1"],
|
||||
"capabilities": {}
|
||||
}
|
||||
""".formatted(agentId, appSlug, envSlug);
|
||||
restTemplate.postForEntity(
|
||||
"/api/v1/agents/register",
|
||||
new HttpEntity<>(body, securityHelper.bootstrapHeaders()),
|
||||
String.class);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> putConfig(String apply) {
|
||||
String url = "/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/config"
|
||||
+ (apply != null ? "?apply=" + apply : "");
|
||||
String body = """
|
||||
{"samplingRate": 0.1, "metricsEnabled": true}
|
||||
""";
|
||||
return restTemplate.exchange(url, HttpMethod.PUT,
|
||||
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), String.class);
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void putConfig_staged_savesButDoesNotPush() {
|
||||
// Given — one LIVE agent registered for (appSlug, envSlug)
|
||||
String agentId = "staged-agent-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
registerLiveAgent(agentId);
|
||||
|
||||
// When — PUT with apply=staged
|
||||
ResponseEntity<String> response = putConfig("staged");
|
||||
|
||||
// Then — HTTP 200
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// And — DB has the new config
|
||||
ApplicationConfig saved = configRepository
|
||||
.findByApplicationAndEnvironment(appSlug, envSlug)
|
||||
.orElseThrow(() -> new AssertionError("Config not found in DB"));
|
||||
assertThat(saved.getSamplingRate()).isEqualTo(0.1);
|
||||
|
||||
// And — NO CONFIG_UPDATE was pushed to any agent
|
||||
verify(registryService, never())
|
||||
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void putConfig_live_savesAndPushes() {
|
||||
// Given — one LIVE agent registered for (appSlug, envSlug)
|
||||
String agentId = "live-agent-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
registerLiveAgent(agentId);
|
||||
|
||||
// When — PUT without apply param (default is live)
|
||||
ResponseEntity<String> response = putConfig(null);
|
||||
|
||||
// Then — HTTP 200
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// And — DB has the new config
|
||||
ApplicationConfig saved = configRepository
|
||||
.findByApplicationAndEnvironment(appSlug, envSlug)
|
||||
.orElseThrow(() -> new AssertionError("Config not found in DB"));
|
||||
assertThat(saved.getSamplingRate()).isEqualTo(0.1);
|
||||
|
||||
// And — CONFIG_UPDATE was pushed (addGroupCommandWithReplies called once)
|
||||
verify(registryService)
|
||||
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void putConfig_liveExplicit_savesAndPushes() {
|
||||
// Same as above but with explicit apply=live
|
||||
String agentId = "live-explicit-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
registerLiveAgent(agentId);
|
||||
|
||||
ResponseEntity<String> response = putConfig("live");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
verify(registryService)
|
||||
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void putConfig_unknownApplyValue_returns400() {
|
||||
ResponseEntity<String> response = putConfig("BOGUS");
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
|
||||
int auditCount = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM audit_log WHERE target = ?", Integer.class, appSlug);
|
||||
assertThat(auditCount).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void putConfig_staged_auditActionIsStagedAppConfig() {
|
||||
registerLiveAgent("audit-agent-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
ResponseEntity<String> response = putConfig("staged");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
List<String> actions = jdbcTemplate.queryForList(
|
||||
"SELECT action FROM audit_log WHERE target = ? ORDER BY timestamp DESC",
|
||||
String.class, appSlug);
|
||||
assertThat(actions).hasSize(1);
|
||||
assertThat(actions.get(0)).isEqualTo("stage_app_config");
|
||||
}
|
||||
|
||||
@Test
|
||||
void putConfig_live_auditActionIsUpdateAppConfig() {
|
||||
registerLiveAgent("audit-agent-live-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
|
||||
ResponseEntity<String> response = putConfig(null);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
List<String> actions = jdbcTemplate.queryForList(
|
||||
"SELECT action FROM audit_log WHERE target = ? ORDER BY timestamp DESC",
|
||||
String.class, appSlug);
|
||||
assertThat(actions).hasSize(1);
|
||||
assertThat(actions.get(0)).isEqualTo("update_app_config");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.cameleer.server.app.runtime;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.TestSecurityHelper;
|
||||
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
|
||||
import com.cameleer.server.core.runtime.ContainerStatus;
|
||||
import com.cameleer.server.core.runtime.Deployment;
|
||||
import com.cameleer.server.core.runtime.DeploymentStatus;
|
||||
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Verifies that DeploymentExecutor writes DeploymentConfigSnapshot on successful
|
||||
* RUNNING transition and does NOT write it on a FAILED path.
|
||||
*/
|
||||
class DeploymentSnapshotIT extends AbstractPostgresIT {
|
||||
|
||||
@MockBean
|
||||
RuntimeOrchestrator runtimeOrchestrator;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
@Autowired
|
||||
private PostgresDeploymentRepository deploymentRepository;
|
||||
|
||||
private String operatorJwt;
|
||||
private String adminJwt;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
adminJwt = securityHelper.adminToken();
|
||||
|
||||
// Clean up between tests
|
||||
jdbcTemplate.update("DELETE FROM deployments");
|
||||
jdbcTemplate.update("DELETE FROM app_versions");
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 1: snapshot is populated when deployment reaches RUNNING
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void snapshot_isPopulated_whenDeploymentReachesRunning() throws Exception {
|
||||
// --- given: mock orchestrator that simulates a healthy single-replica container ---
|
||||
String fakeContainerId = "fake-container-" + UUID.randomUUID();
|
||||
|
||||
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||
when(runtimeOrchestrator.startContainer(any()))
|
||||
.thenReturn(fakeContainerId);
|
||||
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
|
||||
.thenReturn(new ContainerStatus("healthy", true, 0, null));
|
||||
|
||||
// --- given: create app with explicit runtimeType so auto-detection is not needed ---
|
||||
String appSlug = "snap-success-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String containerConfigJson = """
|
||||
{"runtimeType": "spring-boot", "appPort": 8081}
|
||||
""";
|
||||
String createAppJson = String.format("""
|
||||
{"slug": "%s", "displayName": "Snapshot Success App"}
|
||||
""", appSlug);
|
||||
|
||||
JsonNode createdApp = post("/api/v1/environments/default/apps", createAppJson, operatorJwt);
|
||||
String appId = createdApp.path("id").asText();
|
||||
|
||||
// --- given: update containerConfig to set runtimeType ---
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
|
||||
containerConfigJson, operatorJwt);
|
||||
|
||||
// --- given: upload a JAR (fake bytes; real file written to disk by AppService) ---
|
||||
String versionId = uploadJar(appSlug, ("fake-jar-bytes-" + appSlug).getBytes());
|
||||
|
||||
// --- given: save agentConfig with samplingRate = 0.25 ---
|
||||
String configJson = """
|
||||
{"samplingRate": 0.25}
|
||||
""";
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/config", configJson, operatorJwt);
|
||||
|
||||
// --- when: trigger deploy ---
|
||||
String deployJson = String.format("""
|
||||
{"appVersionId": "%s"}
|
||||
""", versionId);
|
||||
JsonNode deployResponse = post(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
|
||||
deployJson, operatorJwt);
|
||||
String deploymentId = deployResponse.path("id").asText();
|
||||
|
||||
// --- await RUNNING (async executor) ---
|
||||
AtomicReference<Deployment> deploymentRef = new AtomicReference<>();
|
||||
await().atMost(30, TimeUnit.SECONDS)
|
||||
.pollInterval(500, TimeUnit.MILLISECONDS)
|
||||
.untilAsserted(() -> {
|
||||
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
|
||||
.orElseThrow(() -> new AssertionError("Deployment not found: " + deploymentId));
|
||||
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
|
||||
deploymentRef.set(d);
|
||||
});
|
||||
|
||||
// --- then: snapshot is populated ---
|
||||
Deployment deployed = deploymentRef.get();
|
||||
assertThat(deployed.deployedConfigSnapshot()).isNotNull();
|
||||
assertThat(deployed.deployedConfigSnapshot().jarVersionId())
|
||||
.isEqualTo(UUID.fromString(versionId));
|
||||
assertThat(deployed.deployedConfigSnapshot().agentConfig()).isNotNull();
|
||||
assertThat(deployed.deployedConfigSnapshot().agentConfig().getSamplingRate())
|
||||
.isEqualTo(0.25);
|
||||
assertThat(deployed.deployedConfigSnapshot().containerConfig())
|
||||
.containsEntry("runtimeType", "spring-boot")
|
||||
.containsEntry("appPort", 8081);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Test 2: snapshot is NOT populated when deployment fails
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void snapshot_isNotPopulated_whenDeploymentFails() throws Exception {
|
||||
// --- given: mock orchestrator that throws on startContainer ---
|
||||
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||
when(runtimeOrchestrator.startContainer(any()))
|
||||
.thenThrow(new RuntimeException("Simulated container start failure"));
|
||||
|
||||
// --- given: create app with explicit runtimeType ---
|
||||
String appSlug = "snap-fail-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String createAppJson = String.format("""
|
||||
{"slug": "%s", "displayName": "Snapshot Fail App"}
|
||||
""", appSlug);
|
||||
post("/api/v1/environments/default/apps", createAppJson, operatorJwt);
|
||||
|
||||
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
|
||||
"""
|
||||
{"runtimeType": "spring-boot", "appPort": 8081}
|
||||
""", operatorJwt);
|
||||
|
||||
String versionId = uploadJar(appSlug, ("fake-jar-fail-" + appSlug).getBytes());
|
||||
|
||||
// --- when: trigger deploy ---
|
||||
String deployJson = String.format("""
|
||||
{"appVersionId": "%s"}
|
||||
""", versionId);
|
||||
JsonNode deployResponse = post(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
|
||||
deployJson, operatorJwt);
|
||||
String deploymentId = deployResponse.path("id").asText();
|
||||
|
||||
// --- await FAILED (async executor catches exception and marks failed) ---
|
||||
await().atMost(30, TimeUnit.SECONDS)
|
||||
.pollInterval(500, TimeUnit.MILLISECONDS)
|
||||
.untilAsserted(() -> {
|
||||
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
|
||||
.orElseThrow(() -> new AssertionError("Deployment not found: " + deploymentId));
|
||||
assertThat(d.status()).isEqualTo(DeploymentStatus.FAILED);
|
||||
});
|
||||
|
||||
// --- then: snapshot is null ---
|
||||
Deployment failed = deploymentRepository.findById(UUID.fromString(deploymentId)).orElseThrow();
|
||||
assertThat(failed.deployedConfigSnapshot()).isNull();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private JsonNode post(String path, String json, String jwt) throws Exception {
|
||||
HttpHeaders headers = securityHelper.authHeaders(jwt);
|
||||
var response = restTemplate.exchange(
|
||||
path, HttpMethod.POST,
|
||||
new HttpEntity<>(json, headers),
|
||||
String.class);
|
||||
return objectMapper.readTree(response.getBody());
|
||||
}
|
||||
|
||||
private void put(String path, String json, String jwt) {
|
||||
HttpHeaders headers = securityHelper.authHeaders(jwt);
|
||||
restTemplate.exchange(
|
||||
path, HttpMethod.PUT,
|
||||
new HttpEntity<>(json, headers),
|
||||
String.class);
|
||||
}
|
||||
|
||||
private String uploadJar(String appSlug, byte[] content) throws Exception {
|
||||
ByteArrayResource resource = new ByteArrayResource(content) {
|
||||
@Override
|
||||
public String getFilename() { return "app.jar"; }
|
||||
};
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", resource);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + operatorJwt);
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
var response = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/versions",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers),
|
||||
String.class);
|
||||
|
||||
JsonNode versionNode = objectMapper.readTree(response.getBody());
|
||||
return versionNode.path("id").asText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.cameleer.server.app.storage;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.core.runtime.Deployment;
|
||||
import com.cameleer.server.core.runtime.DeploymentConfigSnapshot;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired PostgresDeploymentRepository repository;
|
||||
|
||||
private UUID envId;
|
||||
private UUID appId;
|
||||
private UUID appVersionId;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
envId = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
|
||||
envId, "test-env-" + envId, "Test Env");
|
||||
|
||||
appId = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO apps (id, environment_id, slug, display_name) VALUES (?, ?, ?, ?)",
|
||||
appId, envId, "app-it-" + appId, "App IT");
|
||||
|
||||
appVersionId = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO app_versions (id, app_id, version, jar_path, jar_checksum) VALUES (?, ?, ?, ?, ?)",
|
||||
appVersionId, appId, 1, "/tmp/app.jar", "deadbeef");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
jdbcTemplate.update("DELETE FROM deployments WHERE app_id = ?", appId);
|
||||
jdbcTemplate.update("DELETE FROM app_versions WHERE app_id = ?", appId);
|
||||
jdbcTemplate.update("DELETE FROM apps WHERE id = ?", appId);
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deployedConfigSnapshot_roundtrips() {
|
||||
// given — create a deployment then store a snapshot
|
||||
ApplicationConfig agentConfig = new ApplicationConfig();
|
||||
agentConfig.setApplication("app-it");
|
||||
agentConfig.setEnvironment("staging");
|
||||
agentConfig.setVersion(3);
|
||||
agentConfig.setSamplingRate(0.5);
|
||||
|
||||
UUID jarVersionId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
|
||||
jarVersionId,
|
||||
agentConfig,
|
||||
Map.of("memoryLimitMb", 1024, "replicas", 2),
|
||||
null
|
||||
);
|
||||
|
||||
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container");
|
||||
repository.saveDeployedConfigSnapshot(deploymentId, snapshot);
|
||||
|
||||
// when — load it back
|
||||
Deployment loaded = repository.findById(deploymentId).orElseThrow();
|
||||
|
||||
// then
|
||||
assertThat(loaded.deployedConfigSnapshot().jarVersionId()).isEqualTo(jarVersionId);
|
||||
assertThat(loaded.deployedConfigSnapshot().agentConfig().getSamplingRate()).isEqualTo(0.5);
|
||||
assertThat(loaded.deployedConfigSnapshot().containerConfig()).containsEntry("memoryLimitMb", 1024);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deployedConfigSnapshot_nullByDefault() {
|
||||
// deployments created without a snapshot must return null (not throw)
|
||||
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-null");
|
||||
|
||||
Deployment loaded = repository.findById(deploymentId).orElseThrow();
|
||||
|
||||
assertThat(loaded.deployedConfigSnapshot()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deployedConfigSnapshot_canBeClearedToNull() {
|
||||
UUID jarVersionId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
|
||||
jarVersionId,
|
||||
new ApplicationConfig(),
|
||||
Map.of(),
|
||||
null
|
||||
);
|
||||
|
||||
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-clear");
|
||||
repository.saveDeployedConfigSnapshot(deploymentId, snapshot);
|
||||
repository.saveDeployedConfigSnapshot(deploymentId, null);
|
||||
|
||||
Deployment loaded = repository.findById(deploymentId).orElseThrow();
|
||||
assertThat(loaded.deployedConfigSnapshot()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public record Deployment(
|
||||
String containerName,
|
||||
String errorMessage,
|
||||
Map<String, Object> resolvedConfig,
|
||||
DeploymentConfigSnapshot deployedConfigSnapshot,
|
||||
Instant deployedAt,
|
||||
Instant stoppedAt,
|
||||
Instant createdAt
|
||||
@@ -27,6 +28,13 @@ public record Deployment(
|
||||
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
|
||||
targetState, deploymentStrategy, replicaStates, deployStage,
|
||||
containerId, containerName, errorMessage, resolvedConfig,
|
||||
deployedAt, stoppedAt, createdAt);
|
||||
deployedConfigSnapshot, deployedAt, stoppedAt, createdAt);
|
||||
}
|
||||
|
||||
public Deployment withDeployedConfigSnapshot(DeploymentConfigSnapshot snapshot) {
|
||||
return new Deployment(id, appId, appVersionId, environmentId, status,
|
||||
targetState, deploymentStrategy, replicaStates, deployStage,
|
||||
containerId, containerName, errorMessage, resolvedConfig,
|
||||
snapshot, deployedAt, stoppedAt, createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.cameleer.server.core.runtime;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 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(
|
||||
UUID jarVersionId,
|
||||
ApplicationConfig agentConfig,
|
||||
Map<String, Object> containerConfig,
|
||||
List<String> sensitiveKeys
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Caller must supply an {@link ObjectMapper} configured with {@code JavaTimeModule} so that
|
||||
* {@code ApplicationConfig.updatedAt} (an {@link java.time.Instant}) serialises correctly.</p>
|
||||
*/
|
||||
public class DirtyStateCalculator {
|
||||
|
||||
private static final Set<String> AGENT_CONFIG_IGNORED_KEYS = Set.of(
|
||||
"version", "updatedAt", "updatedBy", "environment", "application"
|
||||
);
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public DirtyStateCalculator(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
private JsonNode scrubAgentConfig(JsonNode node) {
|
||||
if (!(node instanceof ObjectNode obj)) return node;
|
||||
ObjectNode copy = obj.deepCopy();
|
||||
for (String k : AGENT_CONFIG_IGNORED_KEYS) copy.remove(k);
|
||||
return copy;
|
||||
}
|
||||
|
||||
public DirtyStateResult compute(UUID 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",
|
||||
scrubAgentConfig(mapper.valueToTree(desiredAgentConfig)),
|
||||
scrubAgentConfig(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,
|
||||
nodeToString(desired), nodeToString(deployed)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
TreeSet<String> keys = new 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)) continue;
|
||||
if (d instanceof ObjectNode && p instanceof ObjectNode) {
|
||||
compareJson(prefix + "." + key, d, p, diffs);
|
||||
} else {
|
||||
diffs.add(new DirtyStateResult.Difference(prefix + "." + key, nodeToString(d), nodeToString(p)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String nodeToString(JsonNode n) {
|
||||
if (n == null) return "(none)";
|
||||
if (n.isValueNode()) return n.asText();
|
||||
return n.toString(); // arrays/objects: compact JSON
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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) {}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.cameleer.server.core.runtime;
|
||||
|
||||
import com.cameleer.common.model.ApplicationConfig;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DirtyStateCalculatorTest {
|
||||
|
||||
private static final DirtyStateCalculator CALC = new DirtyStateCalculator(
|
||||
new ObjectMapper().registerModule(new JavaTimeModule()));
|
||||
|
||||
@Test
|
||||
void noSnapshot_meansEverythingDirty() {
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig desiredAgent = new ApplicationConfig();
|
||||
desiredAgent.setSamplingRate(1.0);
|
||||
Map<String, Object> desiredContainer = Map.of("memoryLimitMb", 512);
|
||||
|
||||
DirtyStateResult result = calc.compute(UUID.randomUUID(), desiredAgent, desiredContainer, null);
|
||||
|
||||
assertThat(result.dirty()).isTrue();
|
||||
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
|
||||
.contains("snapshot");
|
||||
}
|
||||
|
||||
@Test
|
||||
void identicalSnapshot_isClean() {
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig cfg = new ApplicationConfig();
|
||||
cfg.setSamplingRate(0.5);
|
||||
Map<String, Object> container = Map.of("memoryLimitMb", 512);
|
||||
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, container, null);
|
||||
DirtyStateResult result = calc.compute(jarId, cfg, container, snap);
|
||||
|
||||
assertThat(result.dirty()).isFalse();
|
||||
assertThat(result.differences()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentJar_marksJarField() {
|
||||
DirtyStateCalculator calc = CALC;
|
||||
ApplicationConfig cfg = new ApplicationConfig();
|
||||
Map<String, Object> container = Map.of();
|
||||
UUID v1 = UUID.randomUUID();
|
||||
UUID v2 = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(v1, cfg, container, null);
|
||||
|
||||
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 = CALC;
|
||||
|
||||
ApplicationConfig deployedCfg = new ApplicationConfig();
|
||||
deployedCfg.setSamplingRate(0.5);
|
||||
ApplicationConfig desiredCfg = new ApplicationConfig();
|
||||
desiredCfg.setSamplingRate(1.0);
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployedCfg, Map.of(), null);
|
||||
|
||||
DirtyStateResult result = calc.compute(jarId, desiredCfg, Map.of(), snap);
|
||||
|
||||
assertThat(result.dirty()).isTrue();
|
||||
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
|
||||
.contains("agentConfig.samplingRate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentContainerMemory_marksContainerField() {
|
||||
DirtyStateCalculator calc = CALC;
|
||||
ApplicationConfig cfg = new ApplicationConfig();
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, Map.of("memoryLimitMb", 512), null);
|
||||
|
||||
DirtyStateResult result = calc.compute(jarId, cfg, Map.of("memoryLimitMb", 1024), snap);
|
||||
|
||||
assertThat(result.dirty()).isTrue();
|
||||
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
|
||||
.contains("containerConfig.memoryLimitMb");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullAgentConfigInSnapshot_marksAgentConfigDiff() {
|
||||
DirtyStateCalculator calc = CALC;
|
||||
ApplicationConfig desired = new ApplicationConfig();
|
||||
desired.setSamplingRate(1.0);
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, null, Map.of(), null);
|
||||
|
||||
DirtyStateResult result = calc.compute(jarId, desired, Map.of(), snap);
|
||||
|
||||
assertThat(result.dirty()).isTrue();
|
||||
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
|
||||
.contains("agentConfig");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nestedAgentField_reportsDeepPath() {
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig deployed = new ApplicationConfig();
|
||||
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
|
||||
ApplicationConfig desired = new ApplicationConfig();
|
||||
desired.setTracedProcessors(Map.of("proc-1", "TRACE"));
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployed, Map.of(), null);
|
||||
|
||||
DirtyStateResult result = calc.compute(jarId, desired, Map.of(), snap);
|
||||
|
||||
assertThat(result.dirty()).isTrue();
|
||||
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
|
||||
.contains("agentConfig.tracedProcessors.proc-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stringField_differenceValueIsUnquoted() {
|
||||
DirtyStateCalculator calc = CALC;
|
||||
|
||||
ApplicationConfig deployed = new ApplicationConfig();
|
||||
deployed.setApplicationLogLevel("INFO");
|
||||
ApplicationConfig desired = new ApplicationConfig();
|
||||
desired.setApplicationLogLevel("DEBUG");
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployed, Map.of(), null);
|
||||
|
||||
DirtyStateResult result = calc.compute(jarId, desired, Map.of(), snap);
|
||||
|
||||
DirtyStateResult.Difference diff = result.differences().stream()
|
||||
.filter(d -> d.field().equals("agentConfig.applicationLogLevel"))
|
||||
.findFirst().orElseThrow();
|
||||
assertThat(diff.staged()).isEqualTo("DEBUG");
|
||||
assertThat(diff.deployed()).isEqualTo("INFO");
|
||||
}
|
||||
|
||||
@Test
|
||||
void versionBumpDoesNotMarkDirty() {
|
||||
ApplicationConfig deployedCfg = new ApplicationConfig();
|
||||
deployedCfg.setSamplingRate(0.5);
|
||||
deployedCfg.setVersion(1);
|
||||
ApplicationConfig desiredCfg = new ApplicationConfig();
|
||||
desiredCfg.setSamplingRate(0.5);
|
||||
desiredCfg.setVersion(2); // bumped by save
|
||||
UUID jarId = UUID.randomUUID();
|
||||
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployedCfg, Map.of(), null);
|
||||
|
||||
DirtyStateResult result = CALC.compute(jarId, desiredCfg, Map.of(), snap);
|
||||
assertThat(result.dirty()).isFalse();
|
||||
}
|
||||
}
|
||||
183
docs/handoff/2026-04-23-deployment-page-handoff.md
Normal file
183
docs/handoff/2026-04-23-deployment-page-handoff.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Handoff — Unified App Deployment Page
|
||||
|
||||
**Session:** 2026-04-22 → 2026-04-23
|
||||
**Branch:** `main` (43 commits ahead of `origin/main` before push — all committed directly per explicit user consent)
|
||||
**Base commit (session start):** `1a376eb2`
|
||||
**Head commit (session end):** `0a71bca7`
|
||||
|
||||
## What landed
|
||||
|
||||
Full implementation of the unified app deployment page replacing the old `CreateAppView` / `AppDetailView` split. Key artefacts:
|
||||
|
||||
- **Spec:** `docs/superpowers/specs/2026-04-22-app-deployment-page-design.md`
|
||||
- **Plan:** `docs/superpowers/plans/2026-04-22-app-deployment-page.md`
|
||||
- **Routes:** `/apps` (list, unchanged), `/apps/new` + `/apps/:slug` (both render new `AppDeploymentPage`)
|
||||
|
||||
### Backend delivered (cameleer-server)
|
||||
|
||||
- Flyway V3 adds `deployments.deployed_config_snapshot JSONB`
|
||||
- `DeploymentConfigSnapshot` record: `(UUID jarVersionId, ApplicationConfig agentConfig, Map<String,Object> containerConfig, List<String> sensitiveKeys)`
|
||||
- `DeploymentExecutor` captures snapshot on successful RUNNING transition (not FAILED)
|
||||
- `PostgresDeploymentRepository.saveDeployedConfigSnapshot(UUID, DeploymentConfigSnapshot)` + `findLatestSuccessfulByAppAndEnv(appId, envId)`
|
||||
- `ApplicationConfigController.updateConfig` accepts `?apply=staged|live` (default `live` for back-compat); staged skips SSE push; 400 on unknown
|
||||
- `AppController.getDirtyState` → `GET /api/v1/environments/{envSlug}/apps/{appSlug}/dirty-state` returning `{dirty, lastSuccessfulDeploymentId, differences[]}`
|
||||
- `DirtyStateCalculator` pure service (cameleer-server-core), scrubs volatile fields (`version`, `updatedAt`, `updatedBy`, `environment`, `application`) from agent-config comparison, recurses into nested objects
|
||||
- Integration tests: `PostgresDeploymentRepositoryIT` (3), `DeploymentSnapshotIT` (2), `ApplicationConfigControllerIT` (6), `AppDirtyStateIT` (3), `DirtyStateCalculatorTest` (9)
|
||||
- OpenAPI + `schema.d.ts` regenerated
|
||||
|
||||
### UI delivered (cameleer-server/ui)
|
||||
|
||||
New directory `ui/src/pages/AppsTab/AppDeploymentPage/`:
|
||||
|
||||
```
|
||||
index.tsx # Main composition (524 lines)
|
||||
IdentitySection.tsx # Name + slug + env pill + JAR + Current Version
|
||||
Checkpoints.tsx # Collapsible disclosure of past successful deploys
|
||||
PrimaryActionButton.tsx # Save / Redeploy / Deploying… state machine
|
||||
AppDeploymentPage.module.css # Page-local styles
|
||||
ConfigTabs/
|
||||
MonitoringTab.tsx # Engine, payload, log levels, metrics, sampling, replay, route control
|
||||
ResourcesTab.tsx # CPU / memory / ports / replicas / runtime / networks
|
||||
VariablesTab.tsx # Env vars (Table / Properties / YAML / .env via EnvEditor)
|
||||
SensitiveKeysTab.tsx # Per-app keys + global baseline reference
|
||||
TracesTapsTab.tsx # Live-apply with LiveBanner
|
||||
RouteRecordingTab.tsx # Live-apply with LiveBanner
|
||||
LiveBanner.tsx # Shared amber "changes apply immediately" banner
|
||||
DeploymentTab/
|
||||
DeploymentTab.tsx # Composition: StatusCard + DeploymentProgress + StartupLogPanel + History
|
||||
StatusCard.tsx # RUNNING / STARTING / FAILED indicator + replica count + URL + actions
|
||||
HistoryDisclosure.tsx # Past deployments table with inline log expansion
|
||||
hooks/
|
||||
useDeploymentPageState.ts # Form-state orchestrator (monitoring, resources, variables, sensitiveKeys)
|
||||
useFormDirty.ts # Per-tab dirty computation via JSON.stringify compare
|
||||
useUnsavedChangesBlocker.ts # React Router v6 useBlocker + DS AlertDialog
|
||||
utils/
|
||||
deriveAppName.ts # Filename → app name pure function
|
||||
deriveAppName.test.ts # 9 Vitest cases
|
||||
```
|
||||
|
||||
Touched shared files:
|
||||
- `ui/src/components/StartupLogPanel.tsx` — accepts `className`, flex-grows in container (dropped fixed 300px maxHeight)
|
||||
- `ui/src/api/queries/admin/apps.ts` — added `useDirtyState`, `Deployment.deployedConfigSnapshot` type
|
||||
- `ui/src/api/queries/commands.ts` — `useUpdateApplicationConfig` accepts `apply?: 'staged' | 'live'`
|
||||
- `ui/src/router.tsx` — routes `/apps/new` and `/apps/:appId` to `AppDeploymentPage`
|
||||
- `ui/src/pages/AppsTab/AppsTab.tsx` — shrunk 1387 → 109 lines (list only)
|
||||
|
||||
### Docs delivered
|
||||
|
||||
- `.claude/rules/ui.md` — Deployments bullet rewritten for the unified page
|
||||
- `.claude/rules/app-classes.md` — `ApplicationConfigController` gains `?apply` note; `AppController` gains dirty-state endpoint; `PostgresDeploymentRepository` notes the snapshot column
|
||||
- `docs/superpowers/specs/2026-04-22-app-deployment-page-design.md`
|
||||
- `docs/superpowers/plans/2026-04-22-app-deployment-page.md`
|
||||
|
||||
## Gitea issues opened this session (cameleer/cameleer-server)
|
||||
|
||||
### [#147 — Concurrent-edit protection on app deployment page (optimistic locking)](https://gitea.siegeln.net/cameleer/cameleer-server/issues/147)
|
||||
Deferred during brainstorming. Two browser sessions editing the same app have no last-write-wins protection. Proposed fix is `If-Match` / `ETag` on config + container-config + JAR upload endpoints using `app.updated_at`. Not blocking single-operator use.
|
||||
|
||||
### [#148 — Persist deployment-page monitoring fields end-to-end](https://gitea.siegeln.net/cameleer/cameleer-server/issues/148)
|
||||
**Important.** The Monitoring tab renders five controls that are currently **UI-only**: `payloadSize` + `payloadUnit`, `metricsInterval`, `replayEnabled`, `routeControlEnabled`. They do not persist to the agent because the fields don't exist on `com.cameleer.common.model.ApplicationConfig` and aren't part of the agent protocol. The old `CreateAppView` had the same gap — this is not a new regression, but the user has stated these must actually affect agent behavior. Fix requires cross-repo work (cameleer-common model additions + cameleer-server server wiring + cameleer agent protocol handling + agent-side gating behaviour).
|
||||
|
||||
## Open gaps to tackle next session
|
||||
|
||||
### 1. Task 13.1 — finish manual browser QA
|
||||
|
||||
Partial coverage so far: save/redeploy happy path, ENV pill styling, tab seam, variables view switcher, toast (all landed + verified). Still unverified:
|
||||
|
||||
- Checkpoint restore flow (hydrate form from past snapshot → Save → Redeploy)
|
||||
- Deploy failure path (FAILED status → snapshot stays null → primary button still shows Redeploy)
|
||||
- Unsaved-changes dialog on in-app navigation (sidebar click with dirty form)
|
||||
- Env switch with dirty form (should discard silently)
|
||||
- End-to-end deploy against real Docker daemon — see "Docker deploy setup" below
|
||||
- Per-tab `*` dirty marker visibility across all 4 staged tabs
|
||||
|
||||
### 2. Docker deploy setup (needed to fully exercise E2E)
|
||||
|
||||
Current `docker-compose.yml` sets `CAMELEER_SERVER_RUNTIME_ENABLED: "false"` so `DisabledRuntimeOrchestrator` rejects deploys with `UnsupportedOperationException`. To actually test deploy end-to-end, pick one:
|
||||
|
||||
- **Path A (quick):** `docker compose up -d cameleer-postgres cameleer-clickhouse` only, then `mvn -pl cameleer-server-app spring-boot:run` on the host + `npm run dev` for the UI. Server uses host Docker daemon directly. Runtime enabled by default via `application.yml`.
|
||||
- **Path B (compose-native):** enable runtime in compose by mounting `/var/run/docker.sock`, setting `CAMELEER_SERVER_RUNTIME_ENABLED: "true"` + `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-traefik`, pre-creating the `cameleer-traefik` network, adding `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` for shared JAR storage, and adding a Traefik service for routing. This is a fully separate task — would need its own plan.
|
||||
|
||||
Recommend Path A for finishing QA; Path B only if you want compose to be fully deployable.
|
||||
|
||||
### 3. Deferred code-review items
|
||||
|
||||
All flagged during the final integration review. None are blockers; each is a follow-up.
|
||||
|
||||
- **DEGRADED deployments aren't checkpoints** — `PostgresDeploymentRepository.findLatestSuccessfulByAppAndEnv` filters `status = 'RUNNING'` but the executor writes the snapshot before the status is resolved (so a DEGRADED deployment has a snapshot). Either include `DEGRADED` in the filter, or skip snapshot on DEGRADED. Pick one; document the choice.
|
||||
- **`Checkpoints.tsx` restore on null snapshot is a silent no-op** — should surface a toast like "This checkpoint predates snapshotting and cannot be restored." Currently returns early with no feedback.
|
||||
- **Missing IT: FAILED deploy leaves snapshot NULL** — `DeploymentSnapshotIT` tests the success case and general "snapshot appears on RUNNING" but doesn't explicitly lock in the FAILED → null guarantee. Add a one-line assertion.
|
||||
- **`HistoryDisclosure` expanded log doesn't `scrollIntoView`** — on long histories the startup-log panel opens off-screen. Minor UX rough edge.
|
||||
- **OpenAPI `@Parameter` missing on `apply` query param** — not critical, just improves generated Swagger docs. Add `@Parameter(name = "apply", description = "staged | live (default: live)")` to `ApplicationConfigController.updateConfig`.
|
||||
|
||||
### 4. Minor tech debt introduced this session
|
||||
|
||||
- `samplingRate` normalization hack in `useDeploymentPageState.ts`: `Number.isInteger(x) ? \`${x}.0\` : String(x)` — works around `1.0` parsing back as `1`, but breaks for values like `1.10` (round-trips to `1.1`). A cleaner fix is to compare as numbers, not strings, in `useFormDirty`.
|
||||
- `useDirtyState` defaults to `?? true` during loading (so the button defaults to `Redeploy`, the fail-safe choice). Spurious Redeploy clicks are harmless, but the "Save (disabled)" UX would be more correct during initial load. Consider a loading-aware ternary if it becomes user-visible.
|
||||
- `ApplicationConfigController.updateConfig` returns `ResponseEntity.status(400).build()` (empty body) on unknown `apply` values. Consider a structured error body consistent with other 400s in the codebase.
|
||||
- GitNexus index stats (`AGENTS.md`, `CLAUDE.md`) refreshed several times during the session — these are auto-generated and will refresh again on next `npx gitnexus analyze`.
|
||||
|
||||
### 5. Behavioural caveats to know about
|
||||
|
||||
- **Agent config writes from the Dashboard / Runtime pages** still use `useUpdateApplicationConfig` with default `apply='live'` — they push SSE immediately as before. Only Deployment-page writes use `apply=staged`. This is by design.
|
||||
- **Traces & Taps + Route Recording tabs** on the Deployment page write with `apply='live'` (immediate SSE). They do **not** participate in dirty detection. The LiveBanner explains this to the user.
|
||||
- **Slug is immutable** — enforced both server-side (regex + Jackson drops unknown fields on PUT) and client-side (IdentitySection renders slug as `MonoText`, never `Input`).
|
||||
- **Environment is immutable after create** — the deployment page has no env selector; the environment chip is read-only and colored via `envColorVar` per the env's configured color.
|
||||
- **Dirty detection ignores `version`, `updatedAt`, `updatedBy`, `environment`, `application`** on agent config — these get bumped server-side on every save and would otherwise spuriously mark the page dirty. Scrubbing happens in `DirtyStateCalculator.scrubAgentConfig`.
|
||||
|
||||
## Recommended next-session kickoff
|
||||
|
||||
1. Run `docker compose up -d cameleer-postgres cameleer-clickhouse`, then `mvn -pl cameleer-server-app spring-boot:run` and `npm run dev` in two terminals.
|
||||
2. Walk through the rest of Task 13.1 (checkpoint restore, deploy failure, unsaved dialog, env switch).
|
||||
3. File any new bugs found. Address the deferred review items (section 3) in small PR-sized commits.
|
||||
4. Decide which of #148's cross-repo work to tackle — cleanest path is: (a) extend `ApplicationConfig` in cameleer-common, (b) wire server side, (c) coordinate agent-side behaviour gating.
|
||||
5. If you want compose-native deploy, open a separate ticket or spec for Path B from "Docker deploy setup" above.
|
||||
|
||||
## Commit range summary
|
||||
|
||||
```
|
||||
1a376eb2..0a71bca7 (43 commits)
|
||||
ff951877 db(deploy): add deployments.deployed_config_snapshot column (V3)
|
||||
d580b6e9 core(deploy): add DeploymentConfigSnapshot record
|
||||
06fa7d83 core(deploy): type jarVersionId as UUID (match domain convention)
|
||||
7f9cfc7f core(deploy): add deployedConfigSnapshot field to Deployment model
|
||||
d3e86b9d storage(deploy): persist deployed_config_snapshot as JSONB
|
||||
9b851c46 test(deploy): autowire repository in snapshot IT (JavaTimeModule-safe)
|
||||
a79eafea runtime(deploy): capture config snapshot on RUNNING transition
|
||||
9b124027 test(deploy): assert containerConfig round-trip + strict RUNNING in snapshot IT
|
||||
76129d40 api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config
|
||||
e716dbf8 test(config): verify audit action in staged/live config IT
|
||||
76352c0d test(config): tighten audit assertions + @DirtiesContext on ApplicationConfigControllerIT
|
||||
e4ccce1e core(deploy): add DirtyStateCalculator + DirtyStateResult
|
||||
24464c07 core(deploy): recurse into nested diffs + unquote scalar values in DirtyStateCalculator
|
||||
6591f2fd api(apps): GET /apps/{slug}/dirty-state returns desired-vs-deployed diff
|
||||
97f25b4c test(deploy): register JavaTimeModule in DirtyStateCalculator unit test
|
||||
0434299d api(schema): regenerate OpenAPI + schema.d.ts for deployment page
|
||||
60529757 ui(deploy): scaffold AppDeploymentPage + route /apps/new and /apps/:slug
|
||||
52ff385b ui(api): add useDirtyState + apply=staged|live on useUpdateApplicationConfig
|
||||
d067490f ui(deploy): add deriveAppName pure function + tests
|
||||
00c7c0cd ui(deploy): Identity & Artifact section with filename auto-derive
|
||||
08efdfa9 ui(deploy): Checkpoints disclosure (hides current deployment, flags pruned JARs)
|
||||
cc193a10 ui(deploy): add useDeploymentPageState orchestrator hook
|
||||
4f5a11f7 ui(deploy): extract MonitoringTab component
|
||||
5c48b780 ui(deploy): extract ResourcesTab component
|
||||
bb06c4c6 ui(deploy): extract VariablesTab component
|
||||
f487e6ca ui(deploy): extract SensitiveKeysTab component
|
||||
b7c0a225 ui(deploy): LiveBanner component for live-apply tabs
|
||||
e96c3cd0 ui(deploy): Traces & Taps + Route Recording tabs with live banner
|
||||
98a7b781 ui(deploy): StatusCard for Deployment tab
|
||||
063a4a55 ui(deploy): HistoryDisclosure with inline log expansion
|
||||
1579f10a ui(deploy): DeploymentTab + flex-grow StartupLogPanel
|
||||
42fb6c8b ui(deploy): useFormDirty hook for per-tab dirty markers
|
||||
0e4166bd ui(deploy): PrimaryActionButton + computeMode state-machine helper
|
||||
b1bdb88e ui(deploy): compose page — save/redeploy/checkpoints wired end-to-end
|
||||
3a649f40 ui(deploy): router blocker + DS dialog for unsaved edits
|
||||
5a7c0ce4 ui(deploy): delete CreateAppView + AppDetailView + ConfigSubTab
|
||||
d5957468 docs(rules): update ui.md Deployments bullet for unified deployment page
|
||||
6d5ce606 docs(rules): document ?apply flag + snapshot column in app-classes
|
||||
d33c039a fix(deploy): address final review — sensitiveKeys snapshot, dirty scrubbing, transition race, refetch invalidations
|
||||
b7b6bd2a ui(deploy): port missing agent-config fields, var-view switcher, env pill, tab seam
|
||||
0a71bca7 fix(deploy): redeploy button after save, disable save when clean, success toast
|
||||
```
|
||||
|
||||
Plus this handoff commit + the GitNexus index-stats refresh.
|
||||
3319
docs/superpowers/plans/2026-04-22-app-deployment-page.md
Normal file
3319
docs/superpowers/plans/2026-04-22-app-deployment-page.md
Normal file
File diff suppressed because it is too large
Load Diff
269
docs/superpowers/specs/2026-04-22-app-deployment-page-design.md
Normal file
269
docs/superpowers/specs/2026-04-22-app-deployment-page-design.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Unified App Deployment Page — Design
|
||||
|
||||
**Status:** Design approved, awaiting implementation plan
|
||||
**Date:** 2026-04-22
|
||||
**Related issue:** [cameleer-server#147](https://gitea.siegeln.net/cameleer/cameleer-server/issues/147) (concurrent-edit protection — deferred)
|
||||
|
||||
## Problem
|
||||
|
||||
Today, managing an application is split across two pages:
|
||||
|
||||
- `/apps/new` (`CreateAppView`) — form to create + initially deploy an app. Requires manually entering name and slug, picking an environment from a dropdown, selecting a JAR, and a "deploy immediately" toggle.
|
||||
- `/apps/:slug` (`AppDetailView`) — manages an existing app. Has an `Upload JAR` button in the header that uploads immediately, and an `Overview` / `Configuration` sub-tab split. Config saves are pushed live to agents via SSE the moment Save is clicked.
|
||||
|
||||
Pain points:
|
||||
|
||||
1. Users can't stage a configuration change without immediately applying it (agent config tab is live-push; container config requires a full redeploy). There's no "draft next deploy" concept.
|
||||
2. The primary action doesn't reflect deploy state — `Upload JAR` remains the label even when a new JAR has been uploaded and is waiting to be deployed.
|
||||
3. App name must be typed manually. The JAR filename is the obvious source and isn't used.
|
||||
4. The environment picker on the create page duplicates the environment already chosen in the top-nav switcher, inviting mistakes (create app in wrong env).
|
||||
5. After deploy, the deployment progress bar and startup log disappear from the page lifecycle once the user navigates away or the deploy completes, so users can't revisit "what happened during the last deploy?" without round-tripping through ClickHouse logs.
|
||||
6. The full config of an app is split across two sub-tabs (`Configuration` for monitoring/resources/variables/traces/recording, `Overview` for versions/deployments), which forces context switches for routine checks.
|
||||
|
||||
## Goal
|
||||
|
||||
One unified deployment page that handles the full lifecycle of an app — from initial creation through every subsequent redeploy — with a clear Save-then-Deploy two-step workflow, a dirty-state model that makes "what will change on redeploy" explicit, and persistent access to the last deployment's progress + log.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Real-time collaborative editing, presence awareness, or optimistic-locking protection against concurrent edits (tracked in issue #147).
|
||||
- Restructuring the environment model, slug rules, or any backend orchestration mechanics beyond what's required for staged-vs-live config writes and deployment snapshotting.
|
||||
- Changing the agent SSE protocol.
|
||||
- Pruning or archiving JAR versions (retention is an environment-level setting, already exists).
|
||||
|
||||
## Design
|
||||
|
||||
### Page structure
|
||||
|
||||
Routes:
|
||||
|
||||
- `/apps/new` — unified page in **net-new mode** (no app record exists yet).
|
||||
- `/apps/:slug` — unified page in **existing-app mode**.
|
||||
|
||||
The `CreateAppView` / `AppDetailView` split goes away. A single component (`AppDeploymentPage`) renders both modes; the only differences are which fields are editable and which buttons are enabled.
|
||||
|
||||
Layout top-to-bottom:
|
||||
|
||||
1. **Page header** — title (app display name or "Create Application"), env badge, status badge, Delete App action (existing apps only), and the **primary action button** (Save / Redeploy / Deploying…).
|
||||
2. **Identity & Artifact section** — always visible.
|
||||
3. **Config tabs row** — `Monitoring | Resources | Variables | Sensitive Keys | Deployment | ● Traces & Taps | ● Route Recording`.
|
||||
4. **Active tab content.**
|
||||
|
||||
The old `Overview` sub-tab is removed. Its deployments table becomes the Deployment tab's history disclosure; its version list is rolled into the Identity & Artifact section as a Checkpoints disclosure.
|
||||
|
||||
### Identity & Artifact section
|
||||
|
||||
| Field | Net-new mode | Existing / deployed mode |
|
||||
|---|---|---|
|
||||
| Application Name | `Input`, editable | read-only display text |
|
||||
| Slug | auto-derived from name, displayed for preview only; never directly editable | read-only display (slug is immutable post-create per project conventions) |
|
||||
| Environment | read-only chip showing currently-selected env | read-only chip |
|
||||
| External URL | computed preview (existing formula — `routingMode === 'subdomain'` vs path-style) | same |
|
||||
| Current Version | — | `v5 · payment-gateway-1.2.3.jar · 42 MB · 3 days ago` |
|
||||
| Application JAR | `Select JAR` button; shows filename + size once staged client-side | `Change JAR` button; shows "staged: `<filename>`" badge when a new JAR is pending |
|
||||
| Checkpoints | disclosure; empty when no prior successful deploys | disclosure; lists past successful deployments |
|
||||
|
||||
**Auto-derive rule** — triggered when the user selects a JAR file and the name field is empty OR still matches the previously auto-derived value (never overwrite manual edits):
|
||||
|
||||
1. Take filename, strip `.jar`.
|
||||
2. Truncate at the first character that is a digit (`0-9`) or a `.`.
|
||||
3. Replace `-` and `_` with spaces.
|
||||
4. Strip any resulting 1-char orphan tokens (e.g. trailing `v` from `my-app-v2`).
|
||||
5. Title-case remaining words.
|
||||
|
||||
The derived name is a suggestion — the user can override by typing.
|
||||
|
||||
Examples:
|
||||
|
||||
- `payment-gateway-1.2.0.jar` → `Payment Gateway`
|
||||
- `order-service.jar` → `Order Service`
|
||||
- `my-app-v2.jar` → `My App`
|
||||
- `acme_billing-3.jar` → `Acme Billing`
|
||||
|
||||
**Slug derivation** remains the existing `slugify(name)` logic. The user cannot edit slug directly in net-new mode (auto-tracks name) and cannot edit at all post-create (immutable per existing project conventions).
|
||||
|
||||
### Checkpoints (past deployments as restore points)
|
||||
|
||||
A checkpoint = one past **successful** deployment, carrying the full snapshot `{jarVersionId, agentConfig, containerConfig, sensitiveKeys}` frozen at deploy time. JARs that were uploaded but never successfully deployed do not appear — they are obsolete freight.
|
||||
|
||||
**Restore flow:**
|
||||
|
||||
1. User expands Checkpoints, picks a row.
|
||||
2. Form fields across all four staged tabs reset to that snapshot's values; JAR slot points to the snapshot's JAR version (by checksum reference — no re-upload).
|
||||
3. Dirty evaluation re-runs against the **latest successful deploy snapshot**, as always → the primary button becomes `Redeploy`.
|
||||
4. The user may tweak further before deploying or deploy as-is.
|
||||
|
||||
Restore is pure client-state hydration — it doesn't write to DB until the user clicks Save.
|
||||
|
||||
**Edge cases:**
|
||||
|
||||
- The currently-running deployment is **hidden** from the Checkpoints list (restoring to it is equivalent to Discard).
|
||||
- A checkpoint whose JAR version has been pruned (per the env-level retention policy) shows as "archived, JAR unavailable" with the Restore action disabled and a tooltip explaining why.
|
||||
|
||||
Collapsed by default.
|
||||
|
||||
### Dirty state + primary button
|
||||
|
||||
**What counts as dirty** (any one is sufficient):
|
||||
|
||||
- A new JAR file is staged in client state (not yet uploaded).
|
||||
- A selected past version (via Restore) differs from the currently-deployed version.
|
||||
- Form values on any of the four **staged** tabs (Monitoring, Resources, Variables, Sensitive Keys) differ from the last-saved DB values.
|
||||
- DB-saved config differs from the snapshot captured at the last successful deploy.
|
||||
|
||||
**What does not count:**
|
||||
|
||||
- Changes on Traces & Taps or Route Recording tabs (live-apply — see below).
|
||||
- Changes made via Dashboard / Runtime pages.
|
||||
|
||||
**State machine:**
|
||||
|
||||
| App state | Form has unsaved local edits? | DB matches last deploy? | Button label | Action |
|
||||
|---|---|---|---|---|
|
||||
| Net-new, nothing entered | — | — | `Save` | disabled |
|
||||
| Net-new, form has content | yes | n/a | `Save` | create app + upload JAR + write config; transitions to "exists, no deploy yet" |
|
||||
| Exists, no deploy yet | either | no (never deployed) | `Redeploy` | deploy current DB state |
|
||||
| Exists, form edits pending | yes | either | `Save` | persist local edits; after save, re-evaluates to `Save` (disabled) or `Redeploy` |
|
||||
| Exists, nothing local, DB = deploy | no | yes | `Save` | disabled |
|
||||
| Exists, nothing local, DB ≠ deploy | no | no | `Redeploy` | deploy DB state |
|
||||
| Deploy in progress | — | — | `Deploying…` | disabled, spinner |
|
||||
|
||||
A secondary `Discard` ghost button appears adjacent to the primary button whenever the form has unsaved local edits. It resets form fields to DB-saved values.
|
||||
|
||||
**Net-new first-deploy flow** — clicking Save on a net-new form creates the app record, uploads the JAR as version 1, persists container + agent config, and routes to `/apps/:slug`. It does **not** deploy. The transition lands the user on the same page in existing-app mode with the button showing `Redeploy`. This is the deliberate trade-off for unifying the button label across modes.
|
||||
|
||||
### Traces & Taps + Route Recording — live-apply tabs
|
||||
|
||||
These tabs remain on the Deployment page (single-source-of-truth for the full config) but are visually distinguished:
|
||||
|
||||
- A persistent info banner at the top of each: *"Live controls — changes apply immediately to running agents and do not participate in the Save/Redeploy cycle."*
|
||||
- Tab labels carry a `●` live indicator.
|
||||
- Editors remain fully interactive — user still manages processors and route recording from this page.
|
||||
- These tabs' writes do **not** flip the dirty indicator; the primary button is unaffected.
|
||||
|
||||
### Deployment tab
|
||||
|
||||
Auto-activates when the user clicks Redeploy (and when landing on a page whose app currently has a STARTING deployment).
|
||||
|
||||
Contents top-to-bottom:
|
||||
|
||||
1. **Current deployment card** — status badge + `StatusDot`, version, JAR filename, JAR checksum (short), replica count, external URL (linkified when RUNNING), deployed-at timestamp. Action buttons: `Stop` (RUNNING/STARTING/DEGRADED), `Start` (STOPPED).
|
||||
2. **Progress bar** — only rendered when `status === STARTING`. Existing `DeploymentProgress` 7-stage step indicator, unchanged.
|
||||
3. **Startup log panel** — existing `StartupLogPanel`, uses `useStartupLogs` (3s polling while STARTING).
|
||||
- Flex-grow inside the tab: fills whatever vertical space is left after the status card, progress bar, and history disclosure.
|
||||
- Minimum height ~200px. Internal scroll on overflow.
|
||||
- Does **not** auto-close on success or failure. Remains mounted until the user navigates away or a newer deploy replaces its content.
|
||||
4. **History disclosure** (collapsed by default) — compact table of past deployments: timestamp, version, status, duration, started by. Row click expands its startup log inline (lazy-loaded). This is also the raw JAR-version-history affordance.
|
||||
|
||||
**Empty state** (net-new, no deploys ever): `No deployments yet. Save your configuration and click Redeploy to launch.`
|
||||
|
||||
**Behavior during an active deploy:**
|
||||
|
||||
- Primary button: `Deploying…` (disabled).
|
||||
- Config tabs remain editable — the user can stage the next iteration while the current one runs.
|
||||
- Local edits during deploy cannot be saved until the current deploy completes. Once it does, button re-evaluates normally.
|
||||
|
||||
### Backend changes
|
||||
|
||||
#### 1. Agent config write path gains a staged/live flag
|
||||
|
||||
The existing `ApplicationConfigController` endpoint persists config to DB **and** pushes an SSE `config-update` to live agents in one atomic call.
|
||||
|
||||
**Change:** add a query parameter `?apply=staged|live` (default `live`, preserving existing non-UI callers).
|
||||
|
||||
- `apply=staged` — write to DB only, no SSE push. Used by the deployment page.
|
||||
- `apply=live` — write to DB and push SSE. Used by the existing real-time UI on Dashboard / Runtime pages, and any non-UI caller that relies on current behavior.
|
||||
|
||||
This keeps one endpoint and one DTO. The gating happens in the service layer.
|
||||
|
||||
#### 2. Deployment snapshot column
|
||||
|
||||
Flyway V2 adds `deployed_config_snapshot JSONB` to the `deployments` table:
|
||||
|
||||
```
|
||||
ALTER TABLE deployments
|
||||
ADD COLUMN deployed_config_snapshot JSONB;
|
||||
```
|
||||
|
||||
The snapshot contains `{jarVersionId, agentConfig, containerConfig, sensitiveKeys}` captured at the moment a deployment transitions to a successful `RUNNING` state (not at deploy start — see failure semantics below).
|
||||
|
||||
**No backfill for existing deployments.** The column is `NULL` for historical rows. Dirty detection treats "no snapshot on last successful deployment" the same as "no successful deployment" — everything is dirty, and the first Redeploy after migration will populate the first snapshot. This is acceptable because dirty-state is the only reader of the column.
|
||||
|
||||
Dirty check reads the last successful deployment's snapshot for the `(app, environment)` pair and compares against the current DB state. If no successful deploy exists yet (or the snapshot is NULL), everything is dirty by definition.
|
||||
|
||||
#### 3. Dirty-state endpoint
|
||||
|
||||
```
|
||||
GET /api/v1/environments/{env}/apps/{slug}/dirty-state
|
||||
```
|
||||
|
||||
Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"dirty": true,
|
||||
"lastSuccessfulDeploymentId": "…",
|
||||
"differences": [
|
||||
{ "field": "agentConfig.samplingRate", "staged": "1.0", "deployed": "0.5" },
|
||||
{ "field": "containerConfig.memoryLimitMb", "staged": "1024", "deployed": "512" },
|
||||
{ "field": "jarVersion", "staged": "v6", "deployed": "v5" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The UI uses this to drive the button label and per-tab dirty markers (asterisks on tab labels). Keeping the comparison server-side means the source of truth for "what will change on redeploy" is one service rather than two implementations at risk of drift.
|
||||
|
||||
#### 4. Checkpoint restore — no new endpoint
|
||||
|
||||
Past deployments are already queryable via `GET /deployments`. The restore action is pure client-side: pick a deployment, read its `deployed_config_snapshot`, hydrate form fields. The server sees only the eventual Save + Redeploy calls.
|
||||
|
||||
#### 5. JAR upload staging — no API change
|
||||
|
||||
Client-state only until Save. The existing `POST /apps/{slug}/versions` multipart endpoint is unchanged; it's invoked during the Save handler as part of a sequence (create app if needed → upload JAR → write config with `?apply=staged`).
|
||||
|
||||
### Migration & clean-break
|
||||
|
||||
- `ui/src/pages/AppsTab/AppsTab.tsx` (1387 lines) is split. `AppListView` stays. New directory `ui/src/pages/AppsTab/AppDeploymentPage/` contains the unified page, split into child files for the Identity section, each config tab, the Deployment tab, Checkpoints, and shared hooks (dirty detection, config sync, filename → name derivation).
|
||||
- `CreateAppView`, `AppDetailView`, `OverviewSubTab`, `ConfigSubTab`, `VersionRow` are deleted.
|
||||
- No backwards-compat shims, no legacy flags, no query-string redirects. Removed sub-routes (`/apps/:slug?tab=overview`) simply land on the default tab.
|
||||
- `.claude/rules/ui.md` Deployments bullet is rewritten in the same commit.
|
||||
- `.claude/rules/app-classes.md` (if it documents controllers) notes the new `?apply=staged|live` parameter.
|
||||
- OpenAPI schema is regenerated per the CLAUDE.md procedure. `ui/src/api/openapi.json` and `ui/src/api/schema.d.ts` are regenerated and committed alongside the backend change.
|
||||
|
||||
### Failure modes & edge cases
|
||||
|
||||
- **Save failure (JAR upload timeout, DB error):** button returns to `Save`. Form keeps local edits. Toast with the error (24h duration — matches existing AppsTab pattern). No partial commits — if JAR upload succeeds but config write fails, the orphan JAR version is harmless.
|
||||
- **Deploy failure:** `Deploying…` → `Redeploy` (still dirty, snapshot not written). Progress bar sticks on the failed stage (red). Log stays mounted. User can fix config or upload different JAR, re-Save, click Redeploy again.
|
||||
- **Snapshot-on-success-only:** `deployed_config_snapshot` is populated only when a deployment reaches a successful `RUNNING` state. Failed deployments exist in history but do not participate in "last known good".
|
||||
- **User edits form during active deploy:** config tabs editable, primary button stays `Deploying…`. On completion, button re-evaluates against the new snapshot.
|
||||
- **Concurrent edit (two users, same app):** out of scope for v1 — tracked in [#147](https://gitea.siegeln.net/cameleer/cameleer-server/issues/147). Current behavior: last-write-wins.
|
||||
- **Browser refresh during active deploy:** state is server-side. Progress re-renders from `deployment.deployStage`, log re-fetches from startup logs endpoint. Deployment tab auto-activates on load if any `STARTING` deployment exists; otherwise default is Monitoring.
|
||||
- **Unsaved-change warning on navigation:** router-level blocker using the DS `ConfirmDialog` (same pattern as existing delete-app confirmation). Triggered when form has staged edits and the user navigates away via sidebar, back button, or any in-app route change. Not `window.beforeunload` — DS-themed dialog only.
|
||||
- **Environment switch:** intentionally discards unsaved work. No warning. Page remounts per existing behavior.
|
||||
- **App doesn't exist in selected env:** 404 via `@EnvPath`. Preserve the existing "Unmanaged Application" empty state when the app exists in catalog (discovered via agent) but has no managed record in this env, with the "Create Managed App" CTA.
|
||||
|
||||
### Testing
|
||||
|
||||
**Backend (integration, REST-API-driven per project preference):**
|
||||
|
||||
- Net-new save flow: `POST apps → POST versions → PUT config?apply=staged → PUT container-config` completes without creating any deployment row.
|
||||
- `?apply=staged` write does not emit SSE `config-update` to a connected agent; `?apply=live` write does.
|
||||
- `deployed_config_snapshot` is populated on a deployment that reaches RUNNING; not populated on a deployment that reaches FAILED.
|
||||
- `GET /dirty-state` returns `dirty=true` when desired state differs from the last-successful-deployment snapshot; `dirty=false` when they match.
|
||||
- Checkpoint restore: hydrating form from a past deployment's snapshot and saving produces a new desired state identical to the snapshot.
|
||||
|
||||
**UI (Vitest):**
|
||||
|
||||
- Dirty-detection pure function against a matrix of input combinations.
|
||||
- Filename → name derivation against the examples table above (including orphan stripping and `_` handling).
|
||||
- Router blocker dialog opens on nav-away with dirty form; does not open on clean form.
|
||||
|
||||
**Manual browser verification (per CLAUDE.md):** walk through the 4 visual states (net-new, clean, dirty, deploying) including an end-to-end Save → Redeploy cycle, a checkpoint restore, and a deploy failure path before claiming done.
|
||||
|
||||
## Open questions carried forward
|
||||
|
||||
- Issue [#147](https://gitea.siegeln.net/cameleer/cameleer-server/issues/147) — optimistic locking / concurrent-edit protection. Deferred.
|
||||
|
||||
## Visual reference
|
||||
|
||||
ASCII mockups (State A: net-new, State B: deployed clean, State C: dirty with staged JAR, State D: active deploy on Deployment tab) are preserved in the brainstorming transcript. When implementing, these are the target screens.
|
||||
File diff suppressed because one or more lines are too long
@@ -41,6 +41,12 @@ export interface Deployment {
|
||||
deployedAt: string | null;
|
||||
stoppedAt: string | null;
|
||||
createdAt: string;
|
||||
deployedConfigSnapshot?: {
|
||||
jarVersionId: string;
|
||||
agentConfig: Record<string, unknown> | null;
|
||||
containerConfig: Record<string, unknown>;
|
||||
sensitiveKeys: string[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,3 +209,27 @@ export function usePromoteDeployment() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Dirty State ---
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,9 +83,13 @@ export interface ConfigUpdateResponse {
|
||||
export function useUpdateApplicationConfig() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ config, environment }: { config: ApplicationConfig; environment: string }) => {
|
||||
mutationFn: async ({ config, environment, apply = 'live' }: {
|
||||
config: ApplicationConfig;
|
||||
environment: string;
|
||||
apply?: 'staged' | 'live';
|
||||
}) => {
|
||||
const res = await authFetch(
|
||||
`/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(config.application)}/config`, {
|
||||
`/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(config.application)}/config?apply=${apply}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
|
||||
86
ui/src/api/schema.d.ts
vendored
86
ui/src/api/schema.d.ts
vendored
@@ -54,7 +54,7 @@ export interface paths {
|
||||
get: operations["getConfig"];
|
||||
/**
|
||||
* Update application config for this environment
|
||||
* @description Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given 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.
|
||||
*/
|
||||
put: operations["updateConfig"];
|
||||
post?: never;
|
||||
@@ -1587,6 +1587,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/environments/{envSlug}/apps/{appSlug}/dirty-state": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
get: operations["getDirtyState"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2718,6 +2738,7 @@ export interface components {
|
||||
resolvedConfig?: {
|
||||
[key: string]: Record<string, never>;
|
||||
};
|
||||
deployedConfigSnapshot?: components["schemas"]["DeploymentConfigSnapshot"];
|
||||
/** Format: date-time */
|
||||
deployedAt?: string;
|
||||
/** Format: date-time */
|
||||
@@ -2725,6 +2746,14 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
DeploymentConfigSnapshot: {
|
||||
/** Format: uuid */
|
||||
jarVersionId?: string;
|
||||
agentConfig?: components["schemas"]["ApplicationConfig"];
|
||||
containerConfig?: {
|
||||
[key: string]: Record<string, never>;
|
||||
};
|
||||
};
|
||||
PromoteRequest: {
|
||||
targetEnvironment?: string;
|
||||
};
|
||||
@@ -3294,6 +3323,16 @@ export interface components {
|
||||
height?: number;
|
||||
endpointUri?: string;
|
||||
};
|
||||
Difference: {
|
||||
field?: string;
|
||||
staged?: string;
|
||||
deployed?: string;
|
||||
};
|
||||
DirtyStateResponse: {
|
||||
dirty?: boolean;
|
||||
lastSuccessfulDeploymentId?: string;
|
||||
differences?: components["schemas"]["Difference"][];
|
||||
};
|
||||
AppConfigResponse: {
|
||||
config?: components["schemas"]["ApplicationConfig"];
|
||||
globalSensitiveKeys?: string[];
|
||||
@@ -3833,6 +3872,7 @@ export interface operations {
|
||||
parameters: {
|
||||
query: {
|
||||
env: components["schemas"]["Environment"];
|
||||
apply?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
@@ -3846,7 +3886,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Config saved and pushed */
|
||||
/** @description Config saved (and pushed if apply=live) */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -3855,6 +3895,15 @@ export interface operations {
|
||||
"*/*": components["schemas"]["ConfigUpdateResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unknown apply value (must be 'staged' or 'live') */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ConfigUpdateResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_1: {
|
||||
@@ -7224,6 +7273,39 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDirtyState: {
|
||||
parameters: {
|
||||
query: {
|
||||
env: components["schemas"]["Environment"];
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
appSlug: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Dirty-state computed */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DirtyStateResponse"];
|
||||
};
|
||||
};
|
||||
/** @description App not found in this environment */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DirtyStateResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getDeployment: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
@@ -8,9 +8,10 @@ interface StartupLogPanelProps {
|
||||
deployment: Deployment;
|
||||
appSlug: string;
|
||||
envSlug: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StartupLogPanel({ deployment, appSlug, envSlug }: StartupLogPanelProps) {
|
||||
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
|
||||
const isStarting = deployment.status === 'STARTING';
|
||||
const isFailed = deployment.status === 'FAILED';
|
||||
|
||||
@@ -21,7 +22,7 @@ export function StartupLogPanel({ deployment, appSlug, envSlug }: StartupLogPane
|
||||
if (entries.length === 0 && !isStarting) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={`${styles.panel}${className ? ` ${className}` : ''}`}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerLeft}>
|
||||
<span className={styles.title}>Startup Logs</span>
|
||||
@@ -38,7 +39,7 @@ export function StartupLogPanel({ deployment, appSlug, envSlug }: StartupLogPane
|
||||
<span className={styles.lineCount}>{entries.length} lines</span>
|
||||
</div>
|
||||
{entries.length > 0 ? (
|
||||
<LogViewer entries={entries as unknown as LogEntry[]} maxHeight={300} />
|
||||
<LogViewer entries={entries as unknown as LogEntry[]} />
|
||||
) : (
|
||||
<div className={styles.empty}>Waiting for container output...</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Tabs + content grouped together with no internal gap */
|
||||
.tabGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The tab-content card sits flush against the Tabs strip — no gap */
|
||||
.tabContent {
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
padding: 16px;
|
||||
background: var(--bg-surface);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
background: var(--bg-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-primary);
|
||||
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;
|
||||
}
|
||||
|
||||
.checkpointsRow {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Config tab shared */
|
||||
.configInline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.configHint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cellMeta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toggleEnabled {
|
||||
font-size: 12px;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.toggleDisabled {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Fixed-width inputs */
|
||||
.inputXs {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.inputSm {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.inputMd {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.inputLg {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.inputXl {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
/* Port pills */
|
||||
.portPills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.portPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.portPillDelete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.portPillDelete:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.portPillDelete:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.portAddInput {
|
||||
width: 70px;
|
||||
padding: 3px 6px;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.portAddInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.portAddInput:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.liveBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* StatusCard */
|
||||
.statusCard {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
background: var(--bg-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; }
|
||||
|
||||
/* DeploymentTab */
|
||||
.deploymentTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.logFill { flex: 1 1 auto; min-height: 200px; }
|
||||
|
||||
/* HistoryDisclosure */
|
||||
.historyRow { margin-top: 16px; }
|
||||
|
||||
/* Environment pill (Identity section) */
|
||||
.envPill {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-inverse, white);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
/* Env vars list */
|
||||
.envVarsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.envVarRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
65
ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx
Normal file
65
ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
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 (RUNNING with a deployedAt). 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Select, Input, Toggle } 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;
|
||||
}
|
||||
|
||||
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }));
|
||||
|
||||
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
|
||||
disabled={disabled}
|
||||
value={value.engineLevel}
|
||||
onChange={(e) => update('engineLevel', e.target.value)}
|
||||
options={[
|
||||
{ value: 'NONE', label: 'NONE' },
|
||||
{ value: 'MINIMAL', label: 'MINIMAL' },
|
||||
{ value: 'REGULAR', label: 'REGULAR' },
|
||||
{ value: 'COMPLETE', label: 'COMPLETE' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>Payload Capture</span>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={value.payloadCaptureMode}
|
||||
onChange={(e) => update('payloadCaptureMode', e.target.value)}
|
||||
options={[
|
||||
{ value: 'NONE', label: 'NONE' },
|
||||
{ value: 'INPUT', label: 'INPUT' },
|
||||
{ value: 'OUTPUT', label: 'OUTPUT' },
|
||||
{ value: 'BOTH', label: 'BOTH' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>Max Payload Size</span>
|
||||
<div className={styles.configInline}>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={value.payloadSize}
|
||||
onChange={(e) => update('payloadSize', e.target.value)}
|
||||
className={styles.inputMd}
|
||||
placeholder="e.g. 4"
|
||||
/>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={value.payloadUnit}
|
||||
onChange={(e) => update('payloadUnit', e.target.value)}
|
||||
options={[
|
||||
{ value: 'B', label: 'bytes' },
|
||||
{ value: 'KB', label: 'KB' },
|
||||
{ value: 'MB', label: 'MB' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>App Log Level</span>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={value.applicationLogLevel}
|
||||
onChange={(e) => update('applicationLogLevel', e.target.value)}
|
||||
options={LOG_LEVELS}
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>Agent Log Level</span>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={value.agentLogLevel}
|
||||
onChange={(e) => update('agentLogLevel', e.target.value)}
|
||||
options={LOG_LEVELS}
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>Metrics</span>
|
||||
<div className={styles.configInline}>
|
||||
<Toggle
|
||||
checked={value.metricsEnabled}
|
||||
onChange={() => !disabled && update('metricsEnabled', !value.metricsEnabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={value.metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>
|
||||
{value.metricsEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
<span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={value.metricsInterval}
|
||||
onChange={(e) => update('metricsInterval', e.target.value)}
|
||||
className={styles.inputXs}
|
||||
placeholder="60"
|
||||
/>
|
||||
<span className={styles.cellMeta}>s</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Sampling Rate</span>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={value.samplingRate}
|
||||
onChange={(e) => update('samplingRate', e.target.value)}
|
||||
className={styles.inputLg}
|
||||
placeholder="1.0"
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>Compress Success</span>
|
||||
<div className={styles.configInline}>
|
||||
<Toggle
|
||||
checked={value.compressSuccess}
|
||||
onChange={() => !disabled && update('compressSuccess', !value.compressSuccess)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={value.compressSuccess ? styles.toggleEnabled : styles.toggleDisabled}>
|
||||
{value.compressSuccess ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Replay</span>
|
||||
<div className={styles.configInline}>
|
||||
<Toggle
|
||||
checked={value.replayEnabled}
|
||||
onChange={() => !disabled && update('replayEnabled', !value.replayEnabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={value.replayEnabled ? styles.toggleEnabled : styles.toggleDisabled}>
|
||||
{value.replayEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Route Control</span>
|
||||
<div className={styles.configInline}>
|
||||
<Toggle
|
||||
checked={value.routeControlEnabled}
|
||||
onChange={() => !disabled && update('routeControlEnabled', !value.routeControlEnabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={value.routeControlEnabled ? styles.toggleEnabled : styles.toggleDisabled}>
|
||||
{value.routeControlEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useState } from 'react';
|
||||
import { Select, Input, Toggle } 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;
|
||||
isProd?: boolean;
|
||||
}
|
||||
|
||||
export function ResourcesTab({ value, onChange, disabled, isProd = false }: Props) {
|
||||
const [newPort, setNewPort] = useState('');
|
||||
const [newNetwork, setNewNetwork] = useState('');
|
||||
|
||||
const update = <K extends keyof ResourcesFormState>(key: K, v: ResourcesFormState[K]) =>
|
||||
onChange({ ...value, [key]: v });
|
||||
|
||||
function addPort() {
|
||||
const p = parseInt(newPort);
|
||||
if (p && !value.ports.includes(p)) {
|
||||
onChange({ ...value, ports: [...value.ports, p] });
|
||||
setNewPort('');
|
||||
}
|
||||
}
|
||||
|
||||
function removePort(port: number) {
|
||||
if (!disabled) update('ports', value.ports.filter((x) => x !== port));
|
||||
}
|
||||
|
||||
function addNetwork() {
|
||||
const v = newNetwork.trim();
|
||||
if (v && !value.extraNetworks.includes(v)) {
|
||||
onChange({ ...value, extraNetworks: [...value.extraNetworks, v] });
|
||||
setNewNetwork('');
|
||||
}
|
||||
}
|
||||
|
||||
function removeNetwork(network: string) {
|
||||
if (!disabled) update('extraNetworks', value.extraNetworks.filter((x) => x !== network));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.configGrid}>
|
||||
<span className={styles.configLabel}>Runtime Type</span>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={value.runtimeType}
|
||||
onChange={(e) => update('runtimeType', e.target.value)}
|
||||
options={[
|
||||
{ value: 'auto', label: 'Auto (detect from JAR)' },
|
||||
{ value: 'spring-boot', label: 'Spring Boot' },
|
||||
{ value: 'quarkus', label: 'Quarkus' },
|
||||
{ value: 'plain-java', label: 'Plain Java' },
|
||||
{ value: 'native', label: 'Native' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>Custom Arguments</span>
|
||||
<div>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={value.customArgs}
|
||||
onChange={(e) => update('customArgs', e.target.value)}
|
||||
placeholder="-Xmx256m -Dfoo=bar"
|
||||
className={styles.inputLg}
|
||||
/>
|
||||
<span className={styles.configHint}>
|
||||
{value.runtimeType === 'native'
|
||||
? 'Arguments passed to the native binary'
|
||||
: 'Additional JVM arguments appended to the start command'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Memory Limit</span>
|
||||
<div className={styles.configInline}>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={value.memoryLimit}
|
||||
onChange={(e) => update('memoryLimit', e.target.value)}
|
||||
className={styles.inputLg}
|
||||
placeholder="e.g. 512"
|
||||
/>
|
||||
<span className={styles.cellMeta}>MB</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Memory Reserve</span>
|
||||
<div>
|
||||
<div className={styles.configInline}>
|
||||
<Input
|
||||
disabled={!isProd || disabled}
|
||||
value={value.memoryReserve}
|
||||
onChange={(e) => update('memoryReserve', e.target.value)}
|
||||
placeholder="e.g. 256"
|
||||
className={styles.inputLg}
|
||||
/>
|
||||
<span className={styles.cellMeta}>MB</span>
|
||||
</div>
|
||||
{!isProd && (
|
||||
<span className={styles.configHint}>Available in production environments only</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>CPU Request</span>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={value.cpuRequest}
|
||||
onChange={(e) => update('cpuRequest', e.target.value)}
|
||||
className={styles.inputLg}
|
||||
placeholder="e.g. 500 millicores"
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>CPU Limit</span>
|
||||
<div className={styles.configInline}>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={value.cpuLimit}
|
||||
onChange={(e) => update('cpuLimit', e.target.value)}
|
||||
placeholder="e.g. 1000"
|
||||
className={styles.inputLg}
|
||||
/>
|
||||
<span className={styles.cellMeta}>millicores</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Exposed Ports</span>
|
||||
<div className={styles.portPills}>
|
||||
{value.ports.map((p) => (
|
||||
<span key={p} className={styles.portPill}>
|
||||
{p}
|
||||
<button
|
||||
className={styles.portPillDelete}
|
||||
disabled={disabled}
|
||||
onClick={() => removePort(p)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className={styles.portAddInput}
|
||||
disabled={disabled}
|
||||
placeholder="+ port"
|
||||
value={newPort}
|
||||
onChange={(e) => setNewPort(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addPort();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>App Port</span>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={value.appPort}
|
||||
onChange={(e) => update('appPort', e.target.value)}
|
||||
className={styles.inputLg}
|
||||
placeholder="e.g. 8080"
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>Replicas</span>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
value={value.replicas}
|
||||
onChange={(e) => update('replicas', e.target.value)}
|
||||
className={styles.inputSm}
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>Deploy Strategy</span>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={value.deployStrategy}
|
||||
onChange={(e) => update('deployStrategy', e.target.value)}
|
||||
options={[
|
||||
{ value: 'blue-green', label: 'Blue/Green' },
|
||||
{ value: 'rolling', label: 'Rolling' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<span className={styles.configLabel}>Strip Path Prefix</span>
|
||||
<div className={styles.configInline}>
|
||||
<Toggle
|
||||
checked={value.stripPrefix}
|
||||
onChange={() => !disabled && update('stripPrefix', !value.stripPrefix)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={value.stripPrefix ? styles.toggleEnabled : styles.toggleDisabled}>
|
||||
{value.stripPrefix ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>SSL Offloading</span>
|
||||
<div className={styles.configInline}>
|
||||
<Toggle
|
||||
checked={value.sslOffloading}
|
||||
onChange={() => !disabled && update('sslOffloading', !value.sslOffloading)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className={value.sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>
|
||||
{value.sslOffloading ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Extra Networks</span>
|
||||
<div>
|
||||
<div className={styles.portPills}>
|
||||
{value.extraNetworks.map((n) => (
|
||||
<span key={n} className={styles.portPill}>
|
||||
{n}
|
||||
<button
|
||||
className={styles.portPillDelete}
|
||||
disabled={disabled}
|
||||
onClick={() => removeNetwork(n)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className={styles.portAddInput}
|
||||
disabled={disabled}
|
||||
placeholder="+ network"
|
||||
value={newNetwork}
|
||||
onChange={(e) => setNewNetwork(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNetwork();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.configHint}>
|
||||
Additional Docker networks to join (e.g., monitoring, prometheus)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DataTable, EmptyState, MonoText, SectionHeader, Toggle } from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { LiveBanner } from './LiveBanner';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../../../api/queries/commands';
|
||||
import { useCatalog } from '../../../../api/queries/catalog';
|
||||
import { applyRouteRecordingUpdate } from '../../../../utils/config-draft-utils';
|
||||
import type { CatalogApp, CatalogRoute } from '../../../../api/queries/catalog';
|
||||
import type { App } from '../../../../api/queries/admin/apps';
|
||||
import type { Environment } from '../../../../api/queries/admin/environments';
|
||||
import sectionStyles from '../../../../styles/section-card.module.css';
|
||||
import appsStyles from '../../AppsTab.module.css';
|
||||
|
||||
interface RouteRecordingRow {
|
||||
id: string;
|
||||
routeId: string;
|
||||
recording: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
app: App;
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
export function RouteRecordingTab({ app, environment }: Props) {
|
||||
const envSlug = environment.slug;
|
||||
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
|
||||
const updateAgentConfig = useUpdateApplicationConfig();
|
||||
const { data: catalog } = useCatalog(envSlug);
|
||||
|
||||
// Local draft — each toggle is immediately flushed to live agents
|
||||
const [recordingDraft, setRecordingDraft] = useState<Record<string, boolean> | null>(null);
|
||||
|
||||
// Use draft if in-flight, otherwise reflect server state
|
||||
const effectiveRecording = recordingDraft ?? agentConfig?.routeRecording ?? {};
|
||||
|
||||
const appRoutes: CatalogRoute[] = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
const entry = (catalog as CatalogApp[]).find((e) => e.slug === app.slug);
|
||||
return entry?.routes ?? [];
|
||||
}, [catalog, app.slug]);
|
||||
|
||||
async function updateRouteRecording(routeId: string, recording: boolean) {
|
||||
if (!agentConfig) return;
|
||||
const next = applyRouteRecordingUpdate(effectiveRecording, routeId, recording);
|
||||
setRecordingDraft(next);
|
||||
try {
|
||||
await updateAgentConfig.mutateAsync({
|
||||
config: { ...agentConfig, routeRecording: next },
|
||||
environment: envSlug,
|
||||
apply: 'live',
|
||||
});
|
||||
} finally {
|
||||
setRecordingDraft(null);
|
||||
}
|
||||
}
|
||||
|
||||
const routeRecordingRows: RouteRecordingRow[] = useMemo(
|
||||
() =>
|
||||
appRoutes.map((r) => ({
|
||||
id: r.routeId,
|
||||
routeId: r.routeId,
|
||||
recording: effectiveRecording[r.routeId] !== false,
|
||||
})),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[effectiveRecording, appRoutes],
|
||||
);
|
||||
|
||||
const recordingCount = routeRecordingRows.filter((r) => r.recording).length;
|
||||
|
||||
const routeRecordingColumns: Column<RouteRecordingRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'routeId',
|
||||
header: 'Route',
|
||||
render: (_v: unknown, row: RouteRecordingRow) => (
|
||||
<MonoText size="xs">{row.routeId}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'recording',
|
||||
header: 'Recording',
|
||||
width: '100px',
|
||||
render: (_v: unknown, row: RouteRecordingRow) => (
|
||||
<Toggle
|
||||
checked={row.recording}
|
||||
onChange={() => updateRouteRecording(row.routeId, !row.recording)}
|
||||
disabled={updateAgentConfig.isPending}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[updateAgentConfig.isPending, effectiveRecording],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LiveBanner />
|
||||
<div className={sectionStyles.section}>
|
||||
<SectionHeader>Route Recording</SectionHeader>
|
||||
<span className={appsStyles.sectionSummary}>
|
||||
{recordingCount} of {routeRecordingRows.length} routes recording
|
||||
</span>
|
||||
{routeRecordingRows.length > 0 ? (
|
||||
<DataTable<RouteRecordingRow>
|
||||
columns={routeRecordingColumns}
|
||||
data={routeRecordingRows}
|
||||
pageSize={20}
|
||||
flush
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No routes"
|
||||
description="No routes found for this application. Routes appear once agents report data."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, Input, Tag } from '@cameleer/design-system';
|
||||
import { Shield, 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';
|
||||
|
||||
const AGENT_DEFAULTS = [
|
||||
'Authorization',
|
||||
'Cookie',
|
||||
'Set-Cookie',
|
||||
'X-API-Key',
|
||||
'X-Auth-Token',
|
||||
'Proxy-Authorization',
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: SensitiveKeysFormState;
|
||||
onChange: (next: SensitiveKeysFormState) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SensitiveKeysTab({ value, onChange, disabled }: Props) {
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const { data: globalKeysConfig } = useSensitiveKeys();
|
||||
const globalKeys = globalKeysConfig?.keys ?? [];
|
||||
|
||||
function addKey() {
|
||||
const v = newKey.trim();
|
||||
if (v && !value.sensitiveKeys.some((k) => k.toLowerCase() === v.toLowerCase())) {
|
||||
onChange({ sensitiveKeys: [...value.sensitiveKeys, v] });
|
||||
setNewKey('');
|
||||
}
|
||||
}
|
||||
|
||||
function removeKey(index: number) {
|
||||
onChange({ sensitiveKeys: value.sensitiveKeys.filter((_, i) => i !== index) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={skStyles.sectionTitle}>
|
||||
<Shield size={14} />
|
||||
<span>Agent built-in defaults</span>
|
||||
</div>
|
||||
<div className={skStyles.defaultsList}>
|
||||
{AGENT_DEFAULTS.map((key) => (
|
||||
<Badge key={key} label={key} variant="outlined" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{globalKeys.length > 0 && (
|
||||
<>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border-subtle)', margin: '10px 0' }} />
|
||||
<div className={skStyles.sectionTitle}>
|
||||
<span>Global keys (enforced)</span>
|
||||
<span className={skStyles.keyCount}>{globalKeys.length}</span>
|
||||
</div>
|
||||
<div className={skStyles.defaultsList}>
|
||||
{globalKeys.map((key) => (
|
||||
<Badge key={key} label={key} color="auto" variant="filled" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border-subtle)', margin: '10px 0' }} />
|
||||
<div className={skStyles.sectionTitle}>
|
||||
<span>Application-specific keys</span>
|
||||
{value.sensitiveKeys.length > 0 && (
|
||||
<span className={skStyles.keyCount}>{value.sensitiveKeys.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={skStyles.pillList}>
|
||||
{value.sensitiveKeys.map((k, i) => (
|
||||
<Tag
|
||||
key={`${k}-${i}`}
|
||||
label={k}
|
||||
onRemove={() => !disabled && removeKey(i)}
|
||||
/>
|
||||
))}
|
||||
{value.sensitiveKeys.length === 0 && (
|
||||
<span className={skStyles.emptyState}>
|
||||
No app-specific keys — agents use built-in defaults
|
||||
{globalKeys.length > 0 ? ' and global keys' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={skStyles.inputRow}>
|
||||
<Input
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addKey();
|
||||
}
|
||||
}}
|
||||
placeholder="Add key or glob pattern (e.g. *password*)"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={disabled || !newKey.trim()}
|
||||
onClick={addKey}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={skStyles.hint}>
|
||||
<Info size={12} />
|
||||
<span>
|
||||
The final masking configuration is: agent defaults + global keys + app-specific keys.
|
||||
Supports exact header names and glob patterns.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Badge, DataTable, EmptyState, MonoText, SectionHeader } from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { LiveBanner } from './LiveBanner';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../../../api/queries/commands';
|
||||
import type { TapDefinition } from '../../../../api/queries/commands';
|
||||
import { useCatalog } from '../../../../api/queries/catalog';
|
||||
import { applyTracedProcessorUpdate } from '../../../../utils/config-draft-utils';
|
||||
import type { App } from '../../../../api/queries/admin/apps';
|
||||
import type { Environment } from '../../../../api/queries/admin/environments';
|
||||
import sectionStyles from '../../../../styles/section-card.module.css';
|
||||
import appsStyles from '../../AppsTab.module.css';
|
||||
|
||||
interface TracedTapRow {
|
||||
id: string;
|
||||
processorId: string;
|
||||
captureMode: string | null;
|
||||
taps: TapDefinition[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
app: App;
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
export function TracesTapsTab({ app, environment }: Props) {
|
||||
const envSlug = environment.slug;
|
||||
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
|
||||
const updateAgentConfig = useUpdateApplicationConfig();
|
||||
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug, envSlug);
|
||||
const { data: catalog } = useCatalog(envSlug);
|
||||
|
||||
// Local draft — each change is immediately flushed to live agents
|
||||
const [tracedDraft, setTracedDraft] = useState<Record<string, string> | null>(null);
|
||||
|
||||
// Use draft if in-flight, otherwise reflect server state
|
||||
const effectiveTraced = tracedDraft ?? agentConfig?.tracedProcessors ?? {};
|
||||
|
||||
async function updateTracedProcessor(processorId: string, mode: string) {
|
||||
if (!agentConfig) return;
|
||||
const next = applyTracedProcessorUpdate(effectiveTraced, processorId, mode);
|
||||
setTracedDraft(next);
|
||||
try {
|
||||
await updateAgentConfig.mutateAsync({
|
||||
config: { ...agentConfig, tracedProcessors: next },
|
||||
environment: envSlug,
|
||||
apply: 'live',
|
||||
});
|
||||
} finally {
|
||||
setTracedDraft(null);
|
||||
}
|
||||
}
|
||||
|
||||
const tracedTapRows: TracedTapRow[] = useMemo(() => {
|
||||
const taps = agentConfig?.taps ?? [];
|
||||
const pids = new Set<string>([
|
||||
...Object.keys(effectiveTraced),
|
||||
...taps.map((t) => t.processorId),
|
||||
]);
|
||||
return Array.from(pids)
|
||||
.sort()
|
||||
.map((pid) => ({
|
||||
id: pid,
|
||||
processorId: pid,
|
||||
captureMode: effectiveTraced[pid] ?? null,
|
||||
taps: taps.filter((t) => t.processorId === pid),
|
||||
}));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [effectiveTraced, agentConfig?.taps]);
|
||||
|
||||
const tracedCount = Object.keys(effectiveTraced).length;
|
||||
const tapCount = agentConfig?.taps?.length ?? 0;
|
||||
|
||||
const tracedTapColumns: Column<TracedTapRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'route' as any,
|
||||
header: 'Route',
|
||||
render: (_v: unknown, row: TracedTapRow) => {
|
||||
const routeId = processorToRoute[row.processorId];
|
||||
return routeId ? (
|
||||
<span className={appsStyles.routeLabel}>{routeId}</span>
|
||||
) : (
|
||||
<span className={appsStyles.hint}>—</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'processorId',
|
||||
header: 'Processor',
|
||||
render: (_v: unknown, row: TracedTapRow) => (
|
||||
<MonoText size="xs">{row.processorId}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'captureMode',
|
||||
header: 'Capture',
|
||||
render: (_v: unknown, row: TracedTapRow) => {
|
||||
if (row.captureMode === null) return <span className={appsStyles.hint}>—</span>;
|
||||
return (
|
||||
<select
|
||||
className={appsStyles.nativeSelect}
|
||||
value={row.captureMode}
|
||||
onChange={(e) => updateTracedProcessor(row.processorId, e.target.value)}
|
||||
disabled={updateAgentConfig.isPending}
|
||||
>
|
||||
<option value="NONE">None</option>
|
||||
<option value="INPUT">Input</option>
|
||||
<option value="OUTPUT">Output</option>
|
||||
<option value="BOTH">Both</option>
|
||||
</select>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'taps',
|
||||
header: 'Taps',
|
||||
render: (_v: unknown, row: TracedTapRow) =>
|
||||
row.taps.length === 0 ? (
|
||||
<span className={appsStyles.hint}>—</span>
|
||||
) : (
|
||||
<div className={appsStyles.tapBadges}>
|
||||
{row.taps.map((t) => (
|
||||
<button
|
||||
key={t.tapId}
|
||||
className={appsStyles.tapBadgeLink}
|
||||
title="Manage tap on route page"
|
||||
>
|
||||
<Badge
|
||||
label={t.attributeName}
|
||||
color={t.enabled ? 'success' : 'auto'}
|
||||
variant="filled"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_remove' as const,
|
||||
header: '',
|
||||
width: '36px',
|
||||
render: (_v: unknown, row: TracedTapRow) =>
|
||||
row.captureMode === null ? null : (
|
||||
<button
|
||||
className={appsStyles.removeBtn}
|
||||
title="Remove"
|
||||
onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}
|
||||
disabled={updateAgentConfig.isPending}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
),
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[processorToRoute, updateAgentConfig.isPending, effectiveTraced],
|
||||
);
|
||||
|
||||
// catalog is needed only to satisfy the import (keeps the same data shape as legacy ConfigSubTab)
|
||||
void catalog;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LiveBanner />
|
||||
<div className={sectionStyles.section}>
|
||||
<SectionHeader>Traces & Taps</SectionHeader>
|
||||
<span className={appsStyles.sectionSummary}>
|
||||
{tracedCount} traced · {tapCount} taps
|
||||
</span>
|
||||
{tracedTapRows.length > 0 ? (
|
||||
<DataTable<TracedTapRow>
|
||||
columns={tracedTapColumns}
|
||||
data={tracedTapRows}
|
||||
pageSize={20}
|
||||
flush
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No traces or taps"
|
||||
description="No processor traces or taps configured."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { EnvEditor } from '../../../../components/EnvEditor';
|
||||
import type { VariablesFormState } from '../hooks/useDeploymentPageState';
|
||||
|
||||
interface Props {
|
||||
value: VariablesFormState;
|
||||
onChange: (next: VariablesFormState) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function VariablesTab({ value, onChange, disabled }: Props) {
|
||||
return (
|
||||
<EnvEditor
|
||||
value={value.envVars}
|
||||
onChange={(entries) => onChange({ envVars: entries })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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) => {
|
||||
const v = versionMap.get(d.appVersionId);
|
||||
return v ? `v${v.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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
|
||||
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 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 as keyof typeof DEPLOY_STATUS_DOT] ?? 'dead'} />
|
||||
<Badge label={deployment.status} color={STATUS_COLORS[deployment.status as keyof typeof STATUS_COLORS] ?? '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">{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>
|
||||
);
|
||||
}
|
||||
114
ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx
Normal file
114
ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useRef } from 'react';
|
||||
import { SectionHeader, Input, MonoText, Button } from '@cameleer/design-system';
|
||||
import type { App, AppVersion } from '../../../api/queries/admin/apps';
|
||||
import type { Environment } from '../../../api/queries/admin/environments';
|
||||
import { envColorVar } from '../../../components/env-colors';
|
||||
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>
|
||||
<span
|
||||
className={styles.envPill}
|
||||
style={{ backgroundColor: envColorVar(environment.color) }}
|
||||
title={environment.displayName}
|
||||
>
|
||||
{environment.displayName}
|
||||
</span>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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';
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import type { ApplicationConfig } from '../../../../api/queries/commands';
|
||||
import type { App } from '../../../../api/queries/admin/apps';
|
||||
|
||||
export interface MonitoringFormState {
|
||||
engineLevel: string;
|
||||
payloadCaptureMode: string;
|
||||
payloadSize: string;
|
||||
payloadUnit: string;
|
||||
applicationLogLevel: string;
|
||||
agentLogLevel: string;
|
||||
metricsEnabled: boolean;
|
||||
metricsInterval: string;
|
||||
samplingRate: string;
|
||||
compressSuccess: boolean;
|
||||
replayEnabled: boolean;
|
||||
routeControlEnabled: 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',
|
||||
payloadSize: '4',
|
||||
payloadUnit: 'KB',
|
||||
applicationLogLevel: 'INFO',
|
||||
agentLogLevel: 'INFO',
|
||||
metricsEnabled: true,
|
||||
metricsInterval: '60',
|
||||
samplingRate: '1.0',
|
||||
compressSuccess: false,
|
||||
replayEnabled: true,
|
||||
routeControlEnabled: true,
|
||||
},
|
||||
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;
|
||||
serverState: DeploymentPageFormState;
|
||||
} {
|
||||
const serverState = useMemo<DeploymentPageFormState>(() => {
|
||||
const merged = { ...envDefaults, ...(app?.containerConfig ?? {}) } as Record<string, unknown>;
|
||||
return {
|
||||
monitoring: {
|
||||
engineLevel: (agentConfig?.engineLevel as string) ?? defaultForm.monitoring.engineLevel,
|
||||
payloadCaptureMode: (agentConfig?.payloadCaptureMode as string) ?? defaultForm.monitoring.payloadCaptureMode,
|
||||
payloadSize: defaultForm.monitoring.payloadSize,
|
||||
payloadUnit: defaultForm.monitoring.payloadUnit,
|
||||
applicationLogLevel: (agentConfig?.applicationLogLevel as string) ?? defaultForm.monitoring.applicationLogLevel,
|
||||
agentLogLevel: (agentConfig?.agentLogLevel as string) ?? defaultForm.monitoring.agentLogLevel,
|
||||
metricsEnabled: agentConfig?.metricsEnabled ?? defaultForm.monitoring.metricsEnabled,
|
||||
metricsInterval: defaultForm.monitoring.metricsInterval,
|
||||
samplingRate: agentConfig?.samplingRate !== undefined
|
||||
? (Number.isInteger(agentConfig.samplingRate) ? `${agentConfig.samplingRate}.0` : String(agentConfig.samplingRate))
|
||||
: defaultForm.monitoring.samplingRate,
|
||||
compressSuccess: agentConfig?.compressSuccess ?? defaultForm.monitoring.compressSuccess,
|
||||
replayEnabled: defaultForm.monitoring.replayEnabled,
|
||||
routeControlEnabled: defaultForm.monitoring.routeControlEnabled,
|
||||
},
|
||||
resources: {
|
||||
memoryLimit: String(merged.memoryLimitMb ?? defaultForm.resources.memoryLimit),
|
||||
memoryReserve: merged.memoryReserveMb != null ? String(merged.memoryReserveMb) : defaultForm.resources.memoryReserve,
|
||||
cpuRequest: String(merged.cpuRequest ?? defaultForm.resources.cpuRequest),
|
||||
cpuLimit: merged.cpuLimit != null ? String(merged.cpuLimit) : defaultForm.resources.cpuLimit,
|
||||
ports: Array.isArray(merged.exposedPorts) ? (merged.exposedPorts as number[]) : defaultForm.resources.ports,
|
||||
appPort: String(merged.appPort ?? defaultForm.resources.appPort),
|
||||
replicas: String(merged.replicas ?? defaultForm.resources.replicas),
|
||||
deployStrategy: String(merged.deploymentStrategy ?? defaultForm.resources.deployStrategy),
|
||||
stripPrefix: merged.stripPathPrefix !== false,
|
||||
sslOffloading: merged.sslOffloading !== false,
|
||||
runtimeType: String(merged.runtimeType ?? defaultForm.resources.runtimeType),
|
||||
customArgs: String(merged.customArgs ?? defaultForm.resources.customArgs),
|
||||
extraNetworks: Array.isArray(merged.extraNetworks) ? (merged.extraNetworks as string[]) : defaultForm.resources.extraNetworks,
|
||||
},
|
||||
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]);
|
||||
|
||||
const [form, setForm] = useState<DeploymentPageFormState>(serverState);
|
||||
const prevServerStateRef = useRef<DeploymentPageFormState>(serverState);
|
||||
|
||||
useEffect(() => {
|
||||
// Only overwrite form if the current form value still matches the previous
|
||||
// server state (i.e., the user has no local edits). Otherwise preserve
|
||||
// user edits through background refetches.
|
||||
setForm((current) => {
|
||||
const hadLocalEdits =
|
||||
JSON.stringify(current) !== JSON.stringify(prevServerStateRef.current);
|
||||
prevServerStateRef.current = serverState;
|
||||
return hadLocalEdits ? current : serverState;
|
||||
});
|
||||
}, [serverState]);
|
||||
|
||||
return { form, setForm, reset: () => setForm(serverState), serverState };
|
||||
}
|
||||
25
ui/src/pages/AppsTab/AppDeploymentPage/hooks/useFormDirty.ts
Normal file
25
ui/src/pages/AppsTab/AppDeploymentPage/hooks/useFormDirty.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
558
ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
Normal file
558
ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useLocation, useNavigate } from 'react-router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertDialog, Button, Tabs, 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 type { Deployment } 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 { useDeploymentPageState } from './hooks/useDeploymentPageState';
|
||||
import { useFormDirty } from './hooks/useFormDirty';
|
||||
import { useUnsavedChangesBlocker } from './hooks/useUnsavedChangesBlocker';
|
||||
import { deriveAppName } from './utils/deriveAppName';
|
||||
import styles from './AppDeploymentPage.module.css';
|
||||
|
||||
type TabKey = 'monitoring' | 'resources' | 'variables' | 'sensitive-keys' | 'deployment' | 'traces' | 'recording';
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100);
|
||||
}
|
||||
|
||||
export default function AppDeploymentPage() {
|
||||
const { appId } = useParams<{ appId?: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
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);
|
||||
const env = environments.find((e) => e.slug === selectedEnv);
|
||||
|
||||
const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug);
|
||||
const currentVersion = versions.slice().sort((a, b) => b.version - a.version)[0] ?? null;
|
||||
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
|
||||
const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null;
|
||||
const activeDeployment = deployments.find((d) => d.status === 'STARTING') ?? null;
|
||||
|
||||
const { data: agentConfig = null } = useApplicationConfig(app?.slug, selectedEnv);
|
||||
const { data: dirtyState } = useDirtyState(selectedEnv, app?.slug);
|
||||
|
||||
// Mutations
|
||||
const createApp = useCreateApp();
|
||||
const deleteApp = useDeleteApp();
|
||||
const uploadJar = useUploadJar();
|
||||
const createDeployment = useCreateDeployment();
|
||||
const stopDeployment = useStopDeployment();
|
||||
const updateContainerConfig = useUpdateContainerConfig();
|
||||
const updateAgentConfig = useUpdateApplicationConfig();
|
||||
|
||||
// Form state
|
||||
const { form, setForm, reset, serverState } = useDeploymentPageState(
|
||||
app,
|
||||
agentConfig,
|
||||
env?.defaultContainerConfig ?? {},
|
||||
);
|
||||
|
||||
// Local UI state
|
||||
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>('');
|
||||
|
||||
// Initialize name from app when it loads
|
||||
useEffect(() => {
|
||||
if (app) setName(app.displayName);
|
||||
}, [app?.displayName]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Auto-derive name from staged JAR (net-new only, don't overwrite manual edits)
|
||||
useEffect(() => {
|
||||
if (!stagedJar || app) return;
|
||||
const derived = deriveAppName(stagedJar.name);
|
||||
if (!name || name === lastDerivedRef.current) {
|
||||
setName(derived);
|
||||
lastDerivedRef.current = derived;
|
||||
}
|
||||
}, [stagedJar, app, name]);
|
||||
|
||||
// Auto-switch to Deployment tab when a deployment starts
|
||||
useEffect(() => {
|
||||
if (activeDeployment) setTab('deployment');
|
||||
}, [!!activeDeployment]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Derived
|
||||
const mode = app ? 'deployed' : 'net-new';
|
||||
const dirty = useFormDirty(form, serverState, stagedJar);
|
||||
const { dialogOpen: blockerOpen, confirm: blockerConfirm, cancel: blockerCancel } =
|
||||
useUnsavedChangesBlocker(dirty.anyLocalEdit);
|
||||
const serverDirtyAgainstDeploy = dirtyState?.dirty ?? true;
|
||||
const deploymentInProgress = !!activeDeployment;
|
||||
const primaryMode = computeMode({
|
||||
deploymentInProgress,
|
||||
hasLocalEdits: dirty.anyLocalEdit,
|
||||
serverDirtyAgainstDeploy,
|
||||
});
|
||||
|
||||
// External URL (same formula as IdentitySection)
|
||||
const externalUrl = (() => {
|
||||
if (!env) return '';
|
||||
const slug = app?.slug ?? slugify(name);
|
||||
const defaults = env.defaultContainerConfig ?? {};
|
||||
const domain = String(defaults.routingDomain ?? '');
|
||||
if (defaults.routingMode === 'subdomain' && domain) {
|
||||
return `https://${slug || '...'}-${env.slug}.${domain}/`;
|
||||
}
|
||||
const base = domain ? `https://${domain}` : window.location.origin;
|
||||
return `${base}/${env.slug}/${slug || '...'}/`;
|
||||
})();
|
||||
|
||||
// ── Tabs definition ────────────────────────────────────────────────
|
||||
const tabs: { label: string; value: TabKey }[] = [
|
||||
{ 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' },
|
||||
...(app
|
||||
? ([
|
||||
{ label: '● Traces & Taps', value: 'traces' },
|
||||
{ label: '● Route Recording', value: 'recording' },
|
||||
] as { label: string; value: TabKey }[])
|
||||
: []),
|
||||
];
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSave() {
|
||||
const envSlug = selectedEnv!;
|
||||
try {
|
||||
let targetApp = app;
|
||||
|
||||
// 1. Create app if net-new
|
||||
if (!targetApp) {
|
||||
targetApp = await createApp.mutateAsync({
|
||||
envSlug,
|
||||
slug: slugify(name),
|
||||
displayName: name.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Upload JAR if staged
|
||||
if (stagedJar) {
|
||||
await uploadJar.mutateAsync({ envSlug, appSlug: targetApp.slug, file: stagedJar });
|
||||
}
|
||||
|
||||
// 3. Save container config
|
||||
const r = form.resources;
|
||||
const containerConfig: Record<string, unknown> = {
|
||||
memoryLimitMb: r.memoryLimit ? parseInt(r.memoryLimit) : null,
|
||||
memoryReserveMb: r.memoryReserve ? parseInt(r.memoryReserve) : null,
|
||||
cpuRequest: r.cpuRequest ? parseInt(r.cpuRequest) : null,
|
||||
cpuLimit: r.cpuLimit ? parseInt(r.cpuLimit) : null,
|
||||
exposedPorts: r.ports,
|
||||
customEnvVars: Object.fromEntries(
|
||||
form.variables.envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value]),
|
||||
),
|
||||
appPort: r.appPort ? parseInt(r.appPort) : 8080,
|
||||
replicas: r.replicas ? parseInt(r.replicas) : 1,
|
||||
deploymentStrategy: r.deployStrategy,
|
||||
stripPathPrefix: r.stripPrefix,
|
||||
sslOffloading: r.sslOffloading,
|
||||
runtimeType: r.runtimeType,
|
||||
customArgs: r.customArgs || null,
|
||||
extraNetworks: r.extraNetworks,
|
||||
};
|
||||
await updateContainerConfig.mutateAsync({ envSlug, appSlug: targetApp.slug, config: containerConfig });
|
||||
|
||||
// 4. Save agent config (staged — applied on next deploy)
|
||||
const m = form.monitoring;
|
||||
await updateAgentConfig.mutateAsync({
|
||||
config: {
|
||||
application: targetApp.slug,
|
||||
version: agentConfig?.version ?? 0,
|
||||
engineLevel: m.engineLevel,
|
||||
payloadCaptureMode: m.payloadCaptureMode,
|
||||
applicationLogLevel: m.applicationLogLevel,
|
||||
agentLogLevel: m.agentLogLevel,
|
||||
metricsEnabled: m.metricsEnabled,
|
||||
samplingRate: parseFloat(m.samplingRate) || 1.0,
|
||||
compressSuccess: m.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: envSlug,
|
||||
apply: 'staged',
|
||||
});
|
||||
|
||||
setStagedJar(null);
|
||||
|
||||
toast({ title: 'Configuration saved', variant: 'success' });
|
||||
|
||||
// Invalidate dirty-state so the button reflects the new saved state
|
||||
await queryClient.invalidateQueries({ queryKey: ['apps', envSlug, targetApp.slug, 'dirty-state'] });
|
||||
|
||||
if (!app) {
|
||||
// Transition to the existing-app view — refetch apps first so the new app
|
||||
// is in the cache before the router renders the deployed view (prevents
|
||||
// the transient Save-disabled flash while useApps is loading).
|
||||
await queryClient.refetchQueries({ queryKey: ['apps', envSlug] });
|
||||
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 envSlug = selectedEnv!;
|
||||
setTab('deployment');
|
||||
try {
|
||||
let versionId: string;
|
||||
|
||||
if (stagedJar) {
|
||||
const newVersion = await uploadJar.mutateAsync({ envSlug, appSlug: app.slug, file: stagedJar });
|
||||
versionId = newVersion.id;
|
||||
} else {
|
||||
if (!currentVersion) {
|
||||
toast({
|
||||
title: 'No JAR version available',
|
||||
description: 'Upload a JAR before deploying.',
|
||||
variant: 'error',
|
||||
duration: 86_400_000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
versionId = currentVersion.id;
|
||||
}
|
||||
|
||||
await createDeployment.mutateAsync({ envSlug, appSlug: app.slug, appVersionId: versionId });
|
||||
setStagedJar(null);
|
||||
// Invalidate dirty-state and versions so button recomputes after deploy
|
||||
queryClient.invalidateQueries({ queryKey: ['apps', envSlug, app.slug, 'dirty-state'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['apps', envSlug, app.slug, 'versions'] });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Redeploy failed',
|
||||
description: e instanceof Error ? e.message : 'Unknown error',
|
||||
variant: 'error',
|
||||
duration: 86_400_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(deploymentId: string) {
|
||||
setStopTarget(deploymentId);
|
||||
}
|
||||
|
||||
async function confirmStop() {
|
||||
if (!stopTarget || !app) return;
|
||||
const envSlug = selectedEnv!;
|
||||
try {
|
||||
await stopDeployment.mutateAsync({ envSlug, appSlug: app.slug, deploymentId: stopTarget });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Stop failed',
|
||||
description: e instanceof Error ? e.message : 'Unknown error',
|
||||
variant: 'error',
|
||||
duration: 86_400_000,
|
||||
});
|
||||
} finally {
|
||||
setStopTarget(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!app) return;
|
||||
const envSlug = selectedEnv!;
|
||||
try {
|
||||
await deleteApp.mutateAsync({ envSlug, appSlug: app.slug });
|
||||
navigate('/apps');
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Delete failed',
|
||||
description: e instanceof Error ? e.message : 'Unknown error',
|
||||
variant: 'error',
|
||||
duration: 86_400_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleRestore(deploymentId: string) {
|
||||
const deployment = deployments.find((d) => d.id === deploymentId);
|
||||
if (!deployment) return;
|
||||
const snap = deployment.deployedConfigSnapshot;
|
||||
if (!snap) return;
|
||||
|
||||
setForm((prev) => {
|
||||
const a = snap.agentConfig ?? {};
|
||||
const c = snap.containerConfig ?? {};
|
||||
return {
|
||||
monitoring: {
|
||||
engineLevel: (a.engineLevel as string) ?? prev.monitoring.engineLevel,
|
||||
payloadCaptureMode: (a.payloadCaptureMode as string) ?? prev.monitoring.payloadCaptureMode,
|
||||
payloadSize: prev.monitoring.payloadSize,
|
||||
payloadUnit: prev.monitoring.payloadUnit,
|
||||
applicationLogLevel: (a.applicationLogLevel as string) ?? prev.monitoring.applicationLogLevel,
|
||||
agentLogLevel: (a.agentLogLevel as string) ?? prev.monitoring.agentLogLevel,
|
||||
metricsEnabled: (a.metricsEnabled as boolean) ?? prev.monitoring.metricsEnabled,
|
||||
metricsInterval: prev.monitoring.metricsInterval,
|
||||
samplingRate: a.samplingRate !== undefined ? String(a.samplingRate) : prev.monitoring.samplingRate,
|
||||
compressSuccess: (a.compressSuccess as boolean) ?? prev.monitoring.compressSuccess,
|
||||
replayEnabled: prev.monitoring.replayEnabled,
|
||||
routeControlEnabled: prev.monitoring.routeControlEnabled,
|
||||
},
|
||||
resources: {
|
||||
memoryLimit: c.memoryLimitMb !== undefined ? String(c.memoryLimitMb) : prev.resources.memoryLimit,
|
||||
memoryReserve: c.memoryReserveMb != null ? String(c.memoryReserveMb) : prev.resources.memoryReserve,
|
||||
cpuRequest: c.cpuRequest !== undefined ? String(c.cpuRequest) : prev.resources.cpuRequest,
|
||||
cpuLimit: c.cpuLimit != null ? String(c.cpuLimit) : prev.resources.cpuLimit,
|
||||
ports: Array.isArray(c.exposedPorts) ? (c.exposedPorts as number[]) : prev.resources.ports,
|
||||
appPort: c.appPort !== undefined ? String(c.appPort) : prev.resources.appPort,
|
||||
replicas: c.replicas !== undefined ? String(c.replicas) : prev.resources.replicas,
|
||||
deployStrategy: (c.deploymentStrategy as string) ?? prev.resources.deployStrategy,
|
||||
stripPrefix: c.stripPathPrefix !== undefined ? (c.stripPathPrefix as boolean) : prev.resources.stripPrefix,
|
||||
sslOffloading: c.sslOffloading !== undefined ? (c.sslOffloading as boolean) : prev.resources.sslOffloading,
|
||||
runtimeType: (c.runtimeType as string) ?? prev.resources.runtimeType,
|
||||
customArgs: c.customArgs !== undefined ? String(c.customArgs ?? '') : prev.resources.customArgs,
|
||||
extraNetworks: Array.isArray(c.extraNetworks) ? (c.extraNetworks as string[]) : prev.resources.extraNetworks,
|
||||
},
|
||||
variables: {
|
||||
envVars: c.customEnvVars
|
||||
? Object.entries(c.customEnvVars as Record<string, string>).map(([key, value]) => ({ key, value }))
|
||||
: prev.variables.envVars,
|
||||
},
|
||||
sensitiveKeys: {
|
||||
sensitiveKeys: Array.isArray(snap.sensitiveKeys)
|
||||
? snap.sensitiveKeys
|
||||
: Array.isArray(a.sensitiveKeys)
|
||||
? (a.sensitiveKeys as string[])
|
||||
: prev.sensitiveKeys.sensitiveKeys,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Primary button enabled logic ───────────────────────────────────
|
||||
const primaryEnabled = (() => {
|
||||
if (primaryMode === 'deploying') return false;
|
||||
if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
|
||||
return true; // redeploy always enabled
|
||||
})();
|
||||
|
||||
// ── Loading guard ──────────────────────────────────────────────────
|
||||
if (envLoading || appsLoading) return <PageLoader />;
|
||||
if (!env) return <div>Select an environment first.</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* ── Page header ── */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<h2 style={{ margin: 0 }}>{app ? app.displayName : 'Create Application'}</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{dirty.anyLocalEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setStagedJar(null);
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<PrimaryActionButton
|
||||
mode={primaryMode}
|
||||
enabled={primaryEnabled}
|
||||
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
|
||||
/>
|
||||
{app && (
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>
|
||||
Delete App
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Identity & Artifact ── */}
|
||||
<IdentitySection
|
||||
mode={mode}
|
||||
environment={env}
|
||||
app={app}
|
||||
currentVersion={currentVersion}
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
stagedJar={stagedJar}
|
||||
onStagedJarChange={setStagedJar}
|
||||
deploying={deploymentInProgress}
|
||||
/>
|
||||
|
||||
{/* ── Checkpoints (deployed apps only) ── */}
|
||||
{app && (
|
||||
<Checkpoints
|
||||
deployments={deployments}
|
||||
versions={versions}
|
||||
currentDeploymentId={currentDeployment?.id ?? null}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Config tabs ── */}
|
||||
<div className={styles.tabGroup}>
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
active={tab}
|
||||
onChange={(v) => setTab(v as TabKey)}
|
||||
/>
|
||||
|
||||
<div className={styles.tabContent}>
|
||||
{tab === 'monitoring' && (
|
||||
<MonitoringTab
|
||||
value={form.monitoring}
|
||||
onChange={(next) => setForm((prev) => ({ ...prev, monitoring: next }))}
|
||||
disabled={deploymentInProgress}
|
||||
/>
|
||||
)}
|
||||
{tab === 'resources' && (
|
||||
<ResourcesTab
|
||||
value={form.resources}
|
||||
onChange={(next) => setForm((prev) => ({ ...prev, resources: next }))}
|
||||
disabled={deploymentInProgress}
|
||||
isProd={env.production ?? false}
|
||||
/>
|
||||
)}
|
||||
{tab === 'variables' && (
|
||||
<VariablesTab
|
||||
value={form.variables}
|
||||
onChange={(next) => setForm((prev) => ({ ...prev, variables: next }))}
|
||||
disabled={deploymentInProgress}
|
||||
/>
|
||||
)}
|
||||
{tab === 'sensitive-keys' && (
|
||||
<SensitiveKeysTab
|
||||
value={form.sensitiveKeys}
|
||||
onChange={(next) => setForm((prev) => ({ ...prev, sensitiveKeys: next }))}
|
||||
disabled={deploymentInProgress}
|
||||
/>
|
||||
)}
|
||||
{tab === 'deployment' && app && (
|
||||
<DeploymentTab
|
||||
deployments={deployments}
|
||||
versions={versions}
|
||||
appSlug={app.slug}
|
||||
envSlug={env.slug}
|
||||
externalUrl={externalUrl}
|
||||
onStop={handleStop}
|
||||
onStart={(deploymentId) => {
|
||||
// Re-deploy from a specific historical deployment's version
|
||||
const d = deployments.find((dep) => dep.id === deploymentId);
|
||||
if (d && selectedEnv && app) {
|
||||
setTab('deployment');
|
||||
createDeployment.mutateAsync({
|
||||
envSlug: selectedEnv,
|
||||
appSlug: app.slug,
|
||||
appVersionId: d.appVersionId,
|
||||
}).catch((e: unknown) =>
|
||||
toast({
|
||||
title: 'Start failed',
|
||||
description: e instanceof Error ? e.message : 'Unknown error',
|
||||
variant: 'error',
|
||||
duration: 86_400_000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tab === 'deployment' && !app && (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 14, padding: 16 }}>
|
||||
Save the app first to see deployment status.
|
||||
</div>
|
||||
)}
|
||||
{tab === 'traces' && app && (
|
||||
<TracesTapsTab app={app} environment={env} />
|
||||
)}
|
||||
{tab === 'recording' && app && (
|
||||
<RouteRecordingTab app={app} environment={env} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Stop confirmation dialog ── */}
|
||||
<AlertDialog
|
||||
open={!!stopTarget}
|
||||
onClose={() => setStopTarget(null)}
|
||||
onConfirm={confirmStop}
|
||||
title="Stop deployment?"
|
||||
description="This will stop the running container. The app will be unavailable until redeployed."
|
||||
confirmLabel="Stop"
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* ── Delete confirmation dialog ── */}
|
||||
<AlertDialog
|
||||
open={deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(false)}
|
||||
onConfirm={() => {
|
||||
setDeleteConfirm(false);
|
||||
handleDelete();
|
||||
}}
|
||||
title={`Delete "${app?.displayName ?? ''}"?`}
|
||||
description="This permanently removes the app, all versions, and all deployments. This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* ── Unsaved changes navigation blocker ── */}
|
||||
<AlertDialog
|
||||
open={blockerOpen}
|
||||
onClose={blockerCancel}
|
||||
onConfirm={blockerConfirm}
|
||||
title="Unsaved changes"
|
||||
description="You have unsaved changes on this page. Discard and leave?"
|
||||
confirmLabel="Discard & Leave"
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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(' ');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnect
|
||||
const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnectionEditor'));
|
||||
const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
|
||||
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
|
||||
const AppDeploymentPage = lazy(() => import('./pages/AppsTab/AppDeploymentPage'));
|
||||
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
|
||||
const InboxPage = lazy(() => import('./pages/Alerts/InboxPage'));
|
||||
const RulesListPage = lazy(() => import('./pages/Alerts/RulesListPage'));
|
||||
@@ -76,8 +77,8 @@ export const router = createBrowserRouter([
|
||||
|
||||
// Apps tab (OPERATOR+ via UI guard, shows all or single app)
|
||||
{ path: 'apps', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||
{ path: 'apps/new', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||
{ path: 'apps/:appId', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
|
||||
{ path: 'apps/new', element: <SuspenseWrapper><AppDeploymentPage /></SuspenseWrapper> },
|
||||
{ path: 'apps/:appId', element: <SuspenseWrapper><AppDeploymentPage /></SuspenseWrapper> },
|
||||
|
||||
// Alerts
|
||||
{ path: 'alerts', element: <Navigate to="/alerts/inbox" replace /> },
|
||||
|
||||
Reference in New Issue
Block a user