Compare commits
24 Commits
242ef1f0af
...
e36c82c4db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e36c82c4db | ||
|
|
d192f6b57c | ||
|
|
fe1681e6e8 | ||
|
|
571f85cd0f | ||
|
|
25d2a3014a | ||
|
|
1a97e2146e | ||
|
|
d1150e5dd8 | ||
|
|
b0995d84bc | ||
|
|
9756a20223 | ||
|
|
1b4b522233 | ||
|
|
48217e0034 | ||
|
|
c3ecff9d45 | ||
|
|
07099357af | ||
|
|
ed0e616109 | ||
|
|
382e1801a7 | ||
|
|
2312a7304d | ||
|
|
47d5611462 | ||
|
|
9043dc00b0 | ||
|
|
a141e99a07 | ||
|
|
15d00f039c | ||
|
|
064c302073 | ||
|
|
35748ea7a1 | ||
|
|
e558494f8d | ||
|
|
1f0ab002d6 |
@@ -54,11 +54,11 @@ 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` / 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`.
|
||||
- `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`. All lifecycle ops (`POST /` deploy, `POST /{id}/stop`, `POST /{id}/promote`) audited under `AuditCategory.DEPLOYMENT`. Action codes: `deploy_app`, `stop_deployment`, `promote_deployment`. Acting user resolved via the `user:` prefix-strip convention; both SUCCESS and FAILURE branches write audit rows. `created_by` (TEXT, nullable) populated from `SecurityContextHolder` and surfaced on the `Deployment` DTO.
|
||||
- `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`.
|
||||
- `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, instanceIds (multi, comma-split, AND-joined as WHERE instance_id IN (...) — used by the Checkpoint detail drawer to scope logs to a deployment's replicas); 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`.
|
||||
- `RouteCatalogController` — GET `/api/v1/environments/{envSlug}/routes` (merged route catalog from registry + ClickHouse; env filter unconditional).
|
||||
- `RouteMetricsController` — GET `/api/v1/environments/{envSlug}/routes/metrics`, GET `/api/v1/environments/{envSlug}/routes/metrics/processors`.
|
||||
- `AgentListController` — GET `/api/v1/environments/{envSlug}/agents` (registered agents with runtime metrics, filtered to env).
|
||||
|
||||
@@ -28,7 +28,7 @@ paths:
|
||||
- `AppVersion` — record: id, appId, version, jarPath, detectedRuntimeType, detectedMainClass
|
||||
- `Environment` — record: id, slug, displayName, production, enabled, defaultContainerConfig, jarRetentionCount, color, createdAt. `color` is one of the 8 preset palette values validated by `EnvironmentColor.VALUES` and CHECK-constrained in PostgreSQL (V2 migration).
|
||||
- `EnvironmentColor` — constants: `DEFAULT = "slate"`, `VALUES = {slate,red,amber,green,teal,blue,purple,pink}`, `isValid(String)`.
|
||||
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName
|
||||
- `Deployment` — record: id, appId, appVersionId, environmentId, status, targetState, deploymentStrategy, replicaStates (JSONB), deployStage, containerId, containerName, createdBy (String, user_id reference; nullable for pre-V4 historical rows)
|
||||
- `DeploymentStatus` — enum: STOPPED, STARTING, RUNNING, DEGRADED, STOPPING, FAILED. `DEGRADED` is reserved for post-deploy drift (a replica died after RUNNING); `DeploymentExecutor` now marks partial-healthy deploys FAILED, not DEGRADED.
|
||||
- `DeployStage` — enum: PRE_FLIGHT, PULL_IMAGE, CREATE_NETWORK, START_REPLICAS, HEALTH_CHECK, SWAP_TRAFFIC, COMPLETE
|
||||
- `DeploymentStrategy` — enum: BLUE_GREEN, ROLLING. Stored on `ResolvedContainerConfig.deploymentStrategy` as kebab-case string (`"blue-green"` / `"rolling"`). `fromWire(String)` is the only conversion entry point; unknown/null inputs fall back to BLUE_GREEN so the executor dispatch site never null-checks or throws.
|
||||
@@ -80,7 +80,7 @@ paths:
|
||||
- `AppSettings`, `AppSettingsRepository` — per-app-per-env settings config and persistence. Record carries `(applicationId, environment, …)`; repository methods are `findByApplicationAndEnvironment`, `findByEnvironment`, `save`, `delete(appId, env)`. `AppSettings.defaults(appId, env)` produces a default instance scoped to an environment.
|
||||
- `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence
|
||||
- `AuditService` — audit logging facade
|
||||
- `AuditRecord`, `AuditResult`, `AuditCategory` (enum: `INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE`), `AuditRepository` — audit trail records and persistence
|
||||
- `AuditRecord`, `AuditResult`, `AuditCategory` (enum: `INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT, OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE, ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE, DEPLOYMENT`), `AuditRepository` — audit trail records and persistence
|
||||
|
||||
## http/ — Outbound HTTP primitives (cross-cutting)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
|
||||
- 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.
|
||||
- Checkpoints render as a `CheckpointsTable` (DataTable-style) below the Identity section. Columns: Version · JAR (filename) · Deployed by · Deployed (relative + ISO) · Strategy · Outcome · ›. Row click opens `CheckpointDetailDrawer` (project-local `SideDrawer` primitive). Drawer has Logs and Config tabs; Config has Snapshot / Diff vs current view modes. Restore lives in the drawer footer (forces review). Visible row cap = `Environment.jarRetentionCount` (default 10 if 0/null); older rows accessible via "Show older (N)" expander. Currently-running deployment is excluded — represented separately by `StatusCard`. The legacy `Checkpoints.tsx` row-list component is gone.
|
||||
- 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.
|
||||
|
||||
@@ -39,6 +39,7 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
|
||||
- `ui/src/api/queries/agents.ts` — `useAgents` for agent list, `useInfiniteAgentEvents` for cursor-paginated timeline stream
|
||||
- `ui/src/hooks/useInfiniteStream.ts` — tanstack `useInfiniteQuery` wrapper with top-gated auto-refetch, flattened `items[]`, and `refresh()` invalidator
|
||||
- `ui/src/components/InfiniteScrollArea.tsx` — scrollable container with IntersectionObserver top/bottom sentinels. Streaming log/event views use this + `useInfiniteStream`. Bounded views (LogTab, StartupLogPanel) keep `useLogs`/`useStartupLogs`
|
||||
- `ui/src/components/SideDrawer.tsx` — project-local right-slide drawer (DS has Modal but no Drawer). Portal-rendered, ESC + transparent-backdrop click closes, sticky header/footer, sizes md/lg/xl. Currently consumed only by `CheckpointDetailDrawer` — promote to `@cameleer/design-system` once a second consumer appears.
|
||||
|
||||
## Alerts
|
||||
|
||||
|
||||
@@ -61,7 +61,8 @@ public class LogPatternEvaluator implements ConditionEvaluator<LogPatternConditi
|
||||
to,
|
||||
null, // cursor
|
||||
1, // limit (count query; value irrelevant)
|
||||
"desc" // sort
|
||||
"desc", // sort
|
||||
null // instanceIds
|
||||
);
|
||||
return logStore.countLogs(req);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,13 @@ package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.server.app.runtime.DeploymentExecutor;
|
||||
import com.cameleer.server.app.web.EnvPath;
|
||||
import com.cameleer.server.core.admin.AuditCategory;
|
||||
import com.cameleer.server.core.admin.AuditResult;
|
||||
import com.cameleer.server.core.admin.AuditService;
|
||||
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.DeploymentService;
|
||||
import com.cameleer.server.core.runtime.Environment;
|
||||
@@ -12,14 +17,18 @@ import com.cameleer.server.core.runtime.RuntimeOrchestrator;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -42,17 +51,23 @@ public class DeploymentController {
|
||||
private final RuntimeOrchestrator orchestrator;
|
||||
private final AppService appService;
|
||||
private final EnvironmentService environmentService;
|
||||
private final AuditService auditService;
|
||||
private final AppVersionRepository appVersionRepository;
|
||||
|
||||
public DeploymentController(DeploymentService deploymentService,
|
||||
DeploymentExecutor deploymentExecutor,
|
||||
RuntimeOrchestrator orchestrator,
|
||||
AppService appService,
|
||||
EnvironmentService environmentService) {
|
||||
EnvironmentService environmentService,
|
||||
AuditService auditService,
|
||||
AppVersionRepository appVersionRepository) {
|
||||
this.deploymentService = deploymentService;
|
||||
this.deploymentExecutor = deploymentExecutor;
|
||||
this.orchestrator = orchestrator;
|
||||
this.appService = appService;
|
||||
this.environmentService = environmentService;
|
||||
this.auditService = auditService;
|
||||
this.appVersionRepository = appVersionRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -86,13 +101,25 @@ public class DeploymentController {
|
||||
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
|
||||
public ResponseEntity<Deployment> deploy(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@RequestBody DeployRequest request) {
|
||||
@RequestBody DeployRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
try {
|
||||
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id());
|
||||
AppVersion appVersion = appVersionRepository.findById(request.appVersionId())
|
||||
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + request.appVersionId()));
|
||||
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id(), currentUserId());
|
||||
deploymentExecutor.executeAsync(deployment);
|
||||
auditService.log("deploy_app", AuditCategory.DEPLOYMENT, deployment.id().toString(),
|
||||
Map.of("appSlug", appSlug, "envSlug", env.slug(),
|
||||
"appVersionId", request.appVersionId().toString(),
|
||||
"jarFilename", appVersion.jarFilename() != null ? appVersion.jarFilename() : "",
|
||||
"version", appVersion.version()),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.accepted().body(deployment);
|
||||
} catch (IllegalArgumentException e) {
|
||||
auditService.log("deploy_app", AuditCategory.DEPLOYMENT, null,
|
||||
Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()),
|
||||
AuditResult.FAILURE, httpRequest);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
@@ -103,12 +130,19 @@ public class DeploymentController {
|
||||
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
||||
public ResponseEntity<Deployment> stop(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@PathVariable UUID deploymentId) {
|
||||
@PathVariable UUID deploymentId,
|
||||
HttpServletRequest httpRequest) {
|
||||
try {
|
||||
Deployment deployment = deploymentService.getById(deploymentId);
|
||||
deploymentExecutor.stopDeployment(deployment);
|
||||
auditService.log("stop_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
|
||||
Map.of("appSlug", appSlug, "envSlug", env.slug()),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.ok(deploymentService.getById(deploymentId));
|
||||
} catch (IllegalArgumentException e) {
|
||||
auditService.log("stop_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
|
||||
Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()),
|
||||
AuditResult.FAILURE, httpRequest);
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
@@ -122,18 +156,26 @@ public class DeploymentController {
|
||||
public ResponseEntity<?> promote(@EnvPath Environment env,
|
||||
@PathVariable String appSlug,
|
||||
@PathVariable UUID deploymentId,
|
||||
@RequestBody PromoteRequest request) {
|
||||
@RequestBody PromoteRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
try {
|
||||
App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||
Deployment source = deploymentService.getById(deploymentId);
|
||||
Environment targetEnv = environmentService.getBySlug(request.targetEnvironment());
|
||||
// Target must also have the app with the same slug
|
||||
App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug);
|
||||
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id());
|
||||
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id(), currentUserId());
|
||||
deploymentExecutor.executeAsync(promoted);
|
||||
auditService.log("promote_deployment", AuditCategory.DEPLOYMENT, promoted.id().toString(),
|
||||
Map.of("sourceEnv", env.slug(), "targetEnv", request.targetEnvironment(),
|
||||
"appSlug", appSlug, "appVersionId", source.appVersionId().toString()),
|
||||
AuditResult.SUCCESS, httpRequest);
|
||||
return ResponseEntity.accepted().body(promoted);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND)
|
||||
auditService.log("promote_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
|
||||
Map.of("sourceEnv", env.slug(), "targetEnv", request.targetEnvironment(),
|
||||
"appSlug", appSlug, "error", e.getMessage()),
|
||||
AuditResult.FAILURE, httpRequest);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -157,6 +199,15 @@ public class DeploymentController {
|
||||
}
|
||||
}
|
||||
|
||||
private String currentUserId() {
|
||||
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getName() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authentication");
|
||||
}
|
||||
String name = auth.getName();
|
||||
return name.startsWith("user:") ? name.substring(5) : name;
|
||||
}
|
||||
|
||||
public record DeployRequest(UUID appVersionId) {}
|
||||
public record PromoteRequest(String targetEnvironment) {}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public class LogQueryController {
|
||||
@RequestParam(required = false) String exchangeId,
|
||||
@RequestParam(required = false) String logger,
|
||||
@RequestParam(required = false) String source,
|
||||
@RequestParam(required = false) String instanceIds,
|
||||
@RequestParam(required = false) String from,
|
||||
@RequestParam(required = false) String to,
|
||||
@RequestParam(required = false) String cursor,
|
||||
@@ -69,12 +70,21 @@ public class LogQueryController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<String> instanceIdList = List.of();
|
||||
if (instanceIds != null && !instanceIds.isEmpty()) {
|
||||
instanceIdList = Arrays.stream(instanceIds.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.toList();
|
||||
}
|
||||
|
||||
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||
Instant toInstant = to != null ? Instant.parse(to) : null;
|
||||
|
||||
LogSearchRequest request = new LogSearchRequest(
|
||||
searchText, levels, application, instanceId, exchangeId,
|
||||
logger, env.slug(), sources, fromInstant, toInstant, cursor, limit, sort);
|
||||
logger, env.slug(), sources, fromInstant, toInstant, cursor, limit, sort,
|
||||
instanceIdList);
|
||||
|
||||
LogSearchResponse result = logIndex.search(request);
|
||||
|
||||
|
||||
@@ -122,6 +122,14 @@ public class ClickHouseLogStore implements LogIndex {
|
||||
baseParams.add(request.instanceId());
|
||||
}
|
||||
|
||||
if (!request.instanceIds().isEmpty()) {
|
||||
String placeholders = String.join(", ", Collections.nCopies(request.instanceIds().size(), "?"));
|
||||
baseConditions.add("instance_id IN (" + placeholders + ")");
|
||||
for (String id : request.instanceIds()) {
|
||||
baseParams.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.exchangeId() != null && !request.exchangeId().isEmpty()) {
|
||||
baseConditions.add("(exchange_id = ?" +
|
||||
" OR (mapContains(mdc, 'cameleer.exchangeId') AND mdc['cameleer.exchangeId'] = ?)" +
|
||||
@@ -281,6 +289,14 @@ public class ClickHouseLogStore implements LogIndex {
|
||||
params.add(request.instanceId());
|
||||
}
|
||||
|
||||
if (!request.instanceIds().isEmpty()) {
|
||||
String placeholders = String.join(", ", Collections.nCopies(request.instanceIds().size(), "?"));
|
||||
conditions.add("instance_id IN (" + placeholders + ")");
|
||||
for (String id : request.instanceIds()) {
|
||||
params.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.exchangeId() != null && !request.exchangeId().isEmpty()) {
|
||||
conditions.add("(exchange_id = ?" +
|
||||
" OR (mapContains(mdc, 'cameleer.exchangeId') AND mdc['cameleer.exchangeId'] = ?)" +
|
||||
|
||||
@@ -22,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_config_snapshot, deployed_at, stopped_at, created_at";
|
||||
"resolved_config, deployed_config_snapshot, deployed_at, stopped_at, created_at, created_by";
|
||||
|
||||
private final JdbcTemplate jdbc;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -81,10 +81,10 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName) {
|
||||
public UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName, String createdBy) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbc.update("INSERT INTO deployments (id, app_id, app_version_id, environment_id, container_name) VALUES (?, ?, ?, ?, ?)",
|
||||
id, appId, appVersionId, environmentId, containerName);
|
||||
jdbc.update("INSERT INTO deployments (id, app_id, app_version_id, environment_id, container_name, created_by) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, appId, appVersionId, environmentId, containerName, createdBy);
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -216,7 +216,8 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
||||
deployedConfigSnapshot,
|
||||
deployedAt != null ? deployedAt.toInstant() : null,
|
||||
stoppedAt != null ? stoppedAt.toInstant() : null,
|
||||
rs.getTimestamp("created_at").toInstant()
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
rs.getString("created_by")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- V4: add created_by column to deployments for audit trail
|
||||
-- Captures which user initiated a deployment. Nullable for backwards compatibility;
|
||||
-- pre-V4 historical deployments will have NULL.
|
||||
|
||||
ALTER TABLE deployments
|
||||
ADD COLUMN created_by TEXT REFERENCES users(user_id);
|
||||
|
||||
CREATE INDEX idx_deployments_created_by ON deployments (created_by);
|
||||
@@ -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(), null, NOW.minusSeconds(60), null, NOW.minusSeconds(120));
|
||||
Map.of(), null, NOW.minusSeconds(60), null, NOW.minusSeconds(120), "test-user");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -52,10 +52,14 @@ class SchemaBootstrapIT extends AbstractPostgresIT {
|
||||
|
||||
@Test
|
||||
void alerting_enums_exist() {
|
||||
// Scope to current schema's namespace — Testcontainers reuse can otherwise
|
||||
// expose enums from a previous run's tenant_default schema alongside public.
|
||||
var enums = jdbcTemplate.queryForList("""
|
||||
SELECT typname FROM pg_type
|
||||
WHERE typname IN ('severity_enum','condition_kind_enum','alert_state_enum',
|
||||
'target_kind_enum','notification_status_enum')
|
||||
SELECT t.typname FROM pg_type t
|
||||
JOIN pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname IN ('severity_enum','condition_kind_enum','alert_state_enum',
|
||||
'target_kind_enum','notification_status_enum')
|
||||
AND n.nspname = current_schema()
|
||||
""", String.class);
|
||||
assertThat(enums).containsExactlyInAnyOrder(
|
||||
"severity_enum", "condition_kind_enum", "alert_state_enum",
|
||||
@@ -86,6 +90,7 @@ class SchemaBootstrapIT extends AbstractPostgresIT {
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'alert_instances'
|
||||
AND column_name IN ('read_at','deleted_at')
|
||||
AND table_schema = current_schema()
|
||||
""", String.class);
|
||||
assertThat(cols).containsExactlyInAnyOrder("read_at", "deleted_at");
|
||||
}
|
||||
@@ -96,13 +101,16 @@ class SchemaBootstrapIT extends AbstractPostgresIT {
|
||||
SELECT COUNT(*)::int FROM pg_indexes
|
||||
WHERE indexname = 'alert_instances_open_rule_uq'
|
||||
AND tablename = 'alert_instances'
|
||||
AND schemaname = current_schema()
|
||||
""", Integer.class);
|
||||
assertThat(count).isEqualTo(1);
|
||||
|
||||
Boolean isUnique = jdbcTemplate.queryForObject("""
|
||||
SELECT indisunique FROM pg_index
|
||||
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
|
||||
WHERE pg_class.relname = 'alert_instances_open_rule_uq'
|
||||
JOIN pg_class c ON c.oid = pg_index.indexrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relname = 'alert_instances_open_rule_uq'
|
||||
AND n.nspname = current_schema()
|
||||
""", Boolean.class);
|
||||
assertThat(isUnique).isTrue();
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ class AppDirtyStateIT extends AbstractPostgresIT {
|
||||
jdbcTemplate.update("DELETE FROM app_versions");
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
||||
|
||||
// Ensure test-operator exists in users table (required for deployments.created_by FK)
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.cameleer.server.app.controller;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.TestSecurityHelper;
|
||||
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.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.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DeploymentControllerAuditIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
private String aliceJwt;
|
||||
private String adminJwt;
|
||||
private String appSlug;
|
||||
private String versionId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Mint JWT for alice (OPERATOR) — subject must start with "user:" for JwtAuthenticationFilter
|
||||
aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR"));
|
||||
adminJwt = securityHelper.adminToken();
|
||||
|
||||
// Clean up deployment-related tables and test-created environments
|
||||
jdbcTemplate.update("DELETE FROM deployments");
|
||||
jdbcTemplate.update("DELETE FROM app_versions");
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE slug LIKE 'promote-target-%'");
|
||||
jdbcTemplate.update("DELETE FROM audit_log");
|
||||
|
||||
// Ensure alice exists in the users table (required for deployments.created_by FK)
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, display_name) VALUES ('alice', 'local', 'Alice Test') ON CONFLICT (user_id) DO NOTHING");
|
||||
|
||||
// Create app in the seeded "default" environment
|
||||
appSlug = "audit-test-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String appJson = String.format("""
|
||||
{"slug": "%s", "displayName": "Audit Test App"}
|
||||
""", appSlug);
|
||||
ResponseEntity<String> appResponse = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps", HttpMethod.POST,
|
||||
new HttpEntity<>(appJson, authHeaders(aliceJwt)),
|
||||
String.class);
|
||||
assertThat(appResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
|
||||
// Upload a JAR version
|
||||
byte[] jarContent = "fake-jar-for-audit-test".getBytes();
|
||||
ByteArrayResource resource = new ByteArrayResource(jarContent) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "audit-test.jar";
|
||||
}
|
||||
};
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", resource);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + aliceJwt);
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
ResponseEntity<String> versionResponse = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/versions", HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers),
|
||||
String.class);
|
||||
assertThat(versionResponse.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception {
|
||||
String json = String.format("""
|
||||
{"appVersionId": "%s"}
|
||||
""", versionId);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
|
||||
new HttpEntity<>(json, authHeaders(aliceJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
|
||||
Map<String, Object> row = queryAuditRow("deploy_app");
|
||||
assertThat(row).isNotNull();
|
||||
assertThat(row.get("username")).isEqualTo("alice");
|
||||
assertThat(row.get("action")).isEqualTo("deploy_app");
|
||||
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
|
||||
assertThat(row.get("result")).isEqualTo("SUCCESS");
|
||||
assertThat(row.get("target")).isNotNull();
|
||||
assertThat(row.get("target").toString()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void stop_writes_audit_row() throws Exception {
|
||||
// First deploy
|
||||
String deployJson = String.format("""
|
||||
{"appVersionId": "%s"}
|
||||
""", versionId);
|
||||
ResponseEntity<String> deployResponse = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
|
||||
new HttpEntity<>(deployJson, authHeaders(aliceJwt)),
|
||||
String.class);
|
||||
assertThat(deployResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
String deploymentId = objectMapper.readTree(deployResponse.getBody()).path("id").asText();
|
||||
|
||||
// Clear audit log to isolate stop audit row
|
||||
jdbcTemplate.update("DELETE FROM audit_log");
|
||||
|
||||
// Stop the deployment
|
||||
ResponseEntity<String> stopResponse = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments/" + deploymentId + "/stop",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(authHeadersNoBody(aliceJwt)),
|
||||
String.class);
|
||||
assertThat(stopResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
Map<String, Object> row = queryAuditRow("stop_deployment");
|
||||
assertThat(row).isNotNull();
|
||||
assertThat(row.get("username")).isEqualTo("alice");
|
||||
assertThat(row.get("action")).isEqualTo("stop_deployment");
|
||||
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
|
||||
assertThat(row.get("result")).isEqualTo("SUCCESS");
|
||||
assertThat(row.get("target").toString()).isEqualTo(deploymentId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void promote_writes_audit_row() throws Exception {
|
||||
// Create a second environment for promotion target
|
||||
String targetEnvSlug = "promote-target-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
String envJson = String.format("""
|
||||
{"slug": "%s", "displayName": "Promote Target Env"}
|
||||
""", targetEnvSlug);
|
||||
ResponseEntity<String> envResponse = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>(envJson, authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(envResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
|
||||
// Create the same app slug in the target environment
|
||||
String appJson = String.format("""
|
||||
{"slug": "%s", "displayName": "Audit Test App (target)"}
|
||||
""", appSlug);
|
||||
ResponseEntity<String> targetAppResponse = restTemplate.exchange(
|
||||
"/api/v1/environments/" + targetEnvSlug + "/apps", HttpMethod.POST,
|
||||
new HttpEntity<>(appJson, authHeaders(aliceJwt)),
|
||||
String.class);
|
||||
assertThat(targetAppResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
|
||||
// Deploy in source (default) env
|
||||
String deployJson = String.format("""
|
||||
{"appVersionId": "%s"}
|
||||
""", versionId);
|
||||
ResponseEntity<String> deployResponse = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
|
||||
new HttpEntity<>(deployJson, authHeaders(aliceJwt)),
|
||||
String.class);
|
||||
assertThat(deployResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
String deploymentId = objectMapper.readTree(deployResponse.getBody()).path("id").asText();
|
||||
|
||||
// Clear audit log to isolate promote audit row
|
||||
jdbcTemplate.update("DELETE FROM audit_log");
|
||||
|
||||
// Promote to target env
|
||||
String promoteJson = String.format("""
|
||||
{"targetEnvironment": "%s"}
|
||||
""", targetEnvSlug);
|
||||
ResponseEntity<String> promoteResponse = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments/" + deploymentId + "/promote",
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(promoteJson, authHeaders(aliceJwt)),
|
||||
String.class);
|
||||
assertThat(promoteResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
|
||||
Map<String, Object> row = queryAuditRow("promote_deployment");
|
||||
assertThat(row).isNotNull();
|
||||
assertThat(row.get("username")).isEqualTo("alice");
|
||||
assertThat(row.get("action")).isEqualTo("promote_deployment");
|
||||
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
|
||||
assertThat(row.get("result")).isEqualTo("SUCCESS");
|
||||
assertThat(row.get("target")).isNotNull();
|
||||
assertThat(row.get("target").toString()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploy_with_unknown_appVersion_writes_FAILURE_audit_row() throws Exception {
|
||||
String unknownVersionId = UUID.randomUUID().toString();
|
||||
String json = String.format("""
|
||||
{"appVersionId": "%s"}
|
||||
""", unknownVersionId);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
|
||||
new HttpEntity<>(json, authHeaders(aliceJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
|
||||
Map<String, Object> row = queryAuditRow("deploy_app");
|
||||
assertThat(row).isNotNull();
|
||||
assertThat(row.get("username")).isEqualTo("alice");
|
||||
assertThat(row.get("action")).isEqualTo("deploy_app");
|
||||
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
|
||||
assertThat(row.get("result")).isEqualTo("FAILURE");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private HttpHeaders authHeaders(String jwt) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + jwt);
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private HttpHeaders authHeadersNoBody(String jwt) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + jwt);
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Query the most recent audit_log row for the given action. Returns null if not found. */
|
||||
private Map<String, Object> queryAuditRow(String action) {
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT username, action, category, target, result FROM audit_log WHERE action = ? ORDER BY timestamp DESC LIMIT 1",
|
||||
action);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,10 @@ class DeploymentControllerIT extends AbstractPostgresIT {
|
||||
jdbcTemplate.update("DELETE FROM app_versions");
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
|
||||
// Ensure test-operator exists in users table (required for deployments.created_by FK)
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
|
||||
|
||||
// Get default environment ID
|
||||
ResponseEntity<String> envResponse = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.GET,
|
||||
|
||||
@@ -34,6 +34,10 @@ class OutboundConnectionAdminControllerIT extends AbstractPostgresIT {
|
||||
@org.junit.jupiter.api.AfterEach
|
||||
void cleanupRows() {
|
||||
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
|
||||
// Clear deployments.created_by for our test users — sibling ITs
|
||||
// (DeploymentControllerIT etc.) may have left rows that FK-block user deletion.
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM deployments WHERE created_by IN ('test-admin','test-operator','test-viewer')");
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-admin','test-operator','test-viewer')");
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ class BlueGreenStrategyIT extends AbstractPostgresIT {
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
||||
|
||||
// Ensure test-operator exists in users table (required for deployments.created_by FK)
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
|
||||
|
||||
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||
|
||||
appSlug = "bg-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
@@ -69,6 +69,10 @@ class DeploymentSnapshotIT extends AbstractPostgresIT {
|
||||
jdbcTemplate.update("DELETE FROM app_versions");
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
||||
|
||||
// Ensure test-operator exists in users table (required for deployments.created_by FK)
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -65,6 +65,10 @@ class RollingStrategyIT extends AbstractPostgresIT {
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
||||
|
||||
// Ensure test-operator exists in users table (required for deployments.created_by FK)
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
|
||||
|
||||
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||
|
||||
appSlug = "roll-" + UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
@@ -79,7 +79,8 @@ class ClickHouseLogStoreCountIT {
|
||||
base.plusSeconds(30),
|
||||
null,
|
||||
100,
|
||||
"desc"));
|
||||
"desc",
|
||||
null));
|
||||
|
||||
assertThat(count).isEqualTo(3);
|
||||
}
|
||||
@@ -102,7 +103,8 @@ class ClickHouseLogStoreCountIT {
|
||||
base.plusSeconds(30),
|
||||
null,
|
||||
100,
|
||||
"desc"));
|
||||
"desc",
|
||||
null));
|
||||
|
||||
assertThat(count).isZero();
|
||||
}
|
||||
@@ -120,7 +122,7 @@ class ClickHouseLogStoreCountIT {
|
||||
null, List.of("ERROR"), "orders", null, null, null,
|
||||
"dev", List.of(),
|
||||
base.minusSeconds(1), base.plusSeconds(60),
|
||||
null, 100, "desc"));
|
||||
null, 100, "desc", null));
|
||||
|
||||
assertThat(devCount).isEqualTo(2);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class ClickHouseLogStoreIT {
|
||||
}
|
||||
|
||||
private LogSearchRequest req(String application) {
|
||||
return new LogSearchRequest(null, null, application, null, null, null, null, null, null, null, null, 100, "desc");
|
||||
return new LogSearchRequest(null, null, application, null, null, null, null, null, null, null, null, 100, "desc", null);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
@@ -99,7 +99,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc"));
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).level()).isEqualTo("ERROR");
|
||||
@@ -116,7 +116,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc"));
|
||||
null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(2);
|
||||
}
|
||||
@@ -130,7 +130,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
"order #12345", null, "my-app", null, null, null, null, null, null, null, null, 100, "desc"));
|
||||
"order #12345", null, "my-app", null, null, null, null, null, null, null, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).contains("order #12345");
|
||||
@@ -147,7 +147,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, "exchange-abc", null, null, null, null, null, null, 100, "desc"));
|
||||
null, null, "my-app", null, "exchange-abc", null, null, null, null, null, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("msg with exchange");
|
||||
@@ -170,7 +170,7 @@ class ClickHouseLogStoreIT {
|
||||
Instant to = Instant.parse("2026-03-31T13:00:00Z");
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, from, to, null, 100, "desc"));
|
||||
null, null, "my-app", null, null, null, null, null, from, to, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("noon");
|
||||
@@ -188,7 +188,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// No application filter — should return both
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, null, null, null, null, null, null, null, null, null, 100, "desc"));
|
||||
null, null, null, null, null, null, null, null, null, null, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(2);
|
||||
}
|
||||
@@ -202,7 +202,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, "OrderProcessor", null, null, null, null, null, 100, "desc"));
|
||||
null, null, "my-app", null, null, "OrderProcessor", null, null, null, null, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).loggerName()).contains("OrderProcessor");
|
||||
@@ -221,7 +221,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// Page 1: limit 2
|
||||
LogSearchResponse page1 = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, null, null, null, 2, "desc"));
|
||||
null, null, "my-app", null, null, null, null, null, null, null, null, 2, "desc", null));
|
||||
|
||||
assertThat(page1.data()).hasSize(2);
|
||||
assertThat(page1.hasMore()).isTrue();
|
||||
@@ -230,7 +230,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// Page 2: use cursor
|
||||
LogSearchResponse page2 = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, null, null, page1.nextCursor(), 2, "desc"));
|
||||
null, null, "my-app", null, null, null, null, null, null, null, page1.nextCursor(), 2, "desc", null));
|
||||
|
||||
assertThat(page2.data()).hasSize(2);
|
||||
assertThat(page2.hasMore()).isTrue();
|
||||
@@ -238,7 +238,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// Page 3: last page
|
||||
LogSearchResponse page3 = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, null, null, page2.nextCursor(), 2, "desc"));
|
||||
null, null, "my-app", null, null, null, null, null, null, null, page2.nextCursor(), 2, "desc", null));
|
||||
|
||||
assertThat(page3.data()).hasSize(1);
|
||||
assertThat(page3.hasMore()).isFalse();
|
||||
@@ -257,7 +257,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
// Filter for ERROR only, but counts should include all levels
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc"));
|
||||
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.levelCounts()).containsEntry("INFO", 2L);
|
||||
@@ -275,7 +275,7 @@ class ClickHouseLogStoreIT {
|
||||
));
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null, null, null, null, 100, "asc"));
|
||||
null, null, "my-app", null, null, null, null, null, null, null, null, 100, "asc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(3);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("msg-1");
|
||||
@@ -340,7 +340,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null,
|
||||
List.of("container"), null, null, null, 100, "desc"));
|
||||
List.of("container"), null, null, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("container msg");
|
||||
@@ -365,7 +365,7 @@ class ClickHouseLogStoreIT {
|
||||
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null,
|
||||
List.of("app", "container"), null, null, null, 100, "desc"));
|
||||
List.of("app", "container"), null, null, null, 100, "desc", null));
|
||||
|
||||
assertThat(result.data()).hasSize(2);
|
||||
assertThat(result.data()).extracting(LogEntryResult::message)
|
||||
@@ -388,7 +388,7 @@ class ClickHouseLogStoreIT {
|
||||
for (int page = 0; page < 10; page++) {
|
||||
LogSearchResponse resp = store.search(new LogSearchRequest(
|
||||
null, null, "my-app", null, null, null, null, null,
|
||||
null, null, cursor, 2, "desc"));
|
||||
null, null, cursor, 2, "desc", null));
|
||||
for (LogEntryResult r : resp.data()) {
|
||||
assertThat(seen.add(r.message())).as("duplicate row returned: " + r.message()).isTrue();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.cameleer.server.app.search;
|
||||
|
||||
import com.cameleer.server.core.ingestion.BufferedLogEntry;
|
||||
import com.cameleer.server.core.search.LogSearchRequest;
|
||||
import com.cameleer.server.core.search.LogSearchResponse;
|
||||
import com.cameleer.common.model.LogEntry;
|
||||
import com.cameleer.server.app.ClickHouseTestHelper;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.testcontainers.clickhouse.ClickHouseContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test for the {@code instanceIds} multi-value filter on
|
||||
* {@link ClickHouseLogStore#search(LogSearchRequest)}.
|
||||
*
|
||||
* <p>Three rows are seeded with distinct {@code instance_id} values:
|
||||
* <ul>
|
||||
* <li>{@code prod-app1-0-aaa11111} — included in filter</li>
|
||||
* <li>{@code prod-app1-1-aaa11111} — included in filter</li>
|
||||
* <li>{@code prod-app1-0-bbb22222} — excluded from filter</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Testcontainers
|
||||
class ClickHouseLogStoreInstanceIdsIT {
|
||||
|
||||
@Container
|
||||
static final ClickHouseContainer clickhouse =
|
||||
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
|
||||
|
||||
private JdbcTemplate jdbc;
|
||||
private ClickHouseLogStore store;
|
||||
|
||||
private static final String TENANT = "default";
|
||||
private static final String ENV = "prod";
|
||||
private static final String APP = "app1";
|
||||
private static final String INST_A = "prod-app1-0-aaa11111";
|
||||
private static final String INST_B = "prod-app1-1-aaa11111";
|
||||
private static final String INST_C = "prod-app1-0-bbb22222";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
HikariDataSource ds = new HikariDataSource();
|
||||
ds.setJdbcUrl(clickhouse.getJdbcUrl());
|
||||
ds.setUsername(clickhouse.getUsername());
|
||||
ds.setPassword(clickhouse.getPassword());
|
||||
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
ClickHouseTestHelper.executeInitSql(jdbc);
|
||||
jdbc.execute("TRUNCATE TABLE logs");
|
||||
|
||||
store = new ClickHouseLogStore(TENANT, jdbc);
|
||||
|
||||
Instant base = Instant.parse("2026-04-23T09:00:00Z");
|
||||
seedLog(INST_A, base, "msg-from-replica-0-gen-aaa");
|
||||
seedLog(INST_B, base.plusSeconds(1), "msg-from-replica-1-gen-aaa");
|
||||
seedLog(INST_C, base.plusSeconds(2), "msg-from-replica-0-gen-bbb");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
jdbc.execute("TRUNCATE TABLE logs");
|
||||
}
|
||||
|
||||
private void seedLog(String instanceId, Instant ts, String message) {
|
||||
LogEntry entry = new LogEntry(ts, "INFO", "com.example.Svc", message, "main", null, null);
|
||||
store.insertBufferedBatch(List.of(
|
||||
new BufferedLogEntry(TENANT, ENV, instanceId, APP, entry)));
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void search_instanceIds_returnsOnlyMatchingInstances() {
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null,
|
||||
List.of(),
|
||||
APP,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ENV,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
100,
|
||||
"desc",
|
||||
List.of(INST_A, INST_B)));
|
||||
|
||||
assertThat(result.data()).hasSize(2);
|
||||
assertThat(result.data())
|
||||
.extracting(r -> r.instanceId())
|
||||
.containsExactlyInAnyOrder(INST_A, INST_B);
|
||||
assertThat(result.data())
|
||||
.extracting(r -> r.instanceId())
|
||||
.doesNotContain(INST_C);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_emptyInstanceIds_returnsAllRows() {
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null,
|
||||
List.of(),
|
||||
APP,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ENV,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
100,
|
||||
"desc",
|
||||
List.of()));
|
||||
|
||||
assertThat(result.data()).hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_nullInstanceIds_returnsAllRows() {
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null,
|
||||
List.of(),
|
||||
APP,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ENV,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
100,
|
||||
"desc",
|
||||
null));
|
||||
|
||||
assertThat(result.data()).hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_instanceIds_singleValue_filtersToOneReplica() {
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null,
|
||||
List.of(),
|
||||
APP,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
ENV,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
100,
|
||||
"desc",
|
||||
List.of(INST_C)));
|
||||
|
||||
assertThat(result.data()).hasSize(1);
|
||||
assertThat(result.data().get(0).instanceId()).isEqualTo(INST_C);
|
||||
assertThat(result.data().get(0).message()).isEqualTo("msg-from-replica-0-gen-bbb");
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_instanceIds_doesNotConflictWithSingularInstanceId() {
|
||||
// Singular instanceId=INST_A AND instanceIds=[INST_B] → intersection = empty
|
||||
// (both conditions apply: instance_id = A AND instance_id IN (B))
|
||||
LogSearchResponse result = store.search(new LogSearchRequest(
|
||||
null,
|
||||
List.of(),
|
||||
APP,
|
||||
INST_A, // singular
|
||||
null,
|
||||
null,
|
||||
ENV,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
100,
|
||||
"desc",
|
||||
List.of(INST_B))); // plural — no overlap
|
||||
|
||||
assertThat(result.data()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.cameleer.server.app.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.core.runtime.Deployment;
|
||||
import com.cameleer.server.core.runtime.DeploymentService;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PostgresDeploymentRepositoryCreatedByIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired DeploymentService deploymentService;
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
|
||||
private UUID appId;
|
||||
private UUID envId;
|
||||
private UUID versionId;
|
||||
|
||||
@BeforeEach
|
||||
void seedAppAndVersion() {
|
||||
// Clean up to avoid conflicts across test runs
|
||||
jdbc.update("DELETE FROM deployments");
|
||||
jdbc.update("DELETE FROM app_versions");
|
||||
jdbc.update("DELETE FROM apps");
|
||||
jdbc.update("DELETE FROM users WHERE user_id IN ('alice', 'bob')");
|
||||
|
||||
envId = jdbc.queryForObject(
|
||||
"SELECT id FROM environments WHERE slug = 'default'", UUID.class);
|
||||
|
||||
// Seed users (alice, bob) — use the bare user_id convention; provider is NOT NULL
|
||||
jdbc.update("INSERT INTO users (user_id, provider) VALUES (?, 'LOCAL') " +
|
||||
"ON CONFLICT (user_id) DO NOTHING", "alice");
|
||||
jdbc.update("INSERT INTO users (user_id, provider) VALUES (?, 'LOCAL') " +
|
||||
"ON CONFLICT (user_id) DO NOTHING", "bob");
|
||||
|
||||
// Seed app
|
||||
appId = UUID.randomUUID();
|
||||
jdbc.update("INSERT INTO apps (id, environment_id, slug, display_name) " +
|
||||
"VALUES (?, ?, 'test-app', 'Test App')",
|
||||
appId, envId);
|
||||
|
||||
// Seed version
|
||||
versionId = UUID.randomUUID();
|
||||
jdbc.update("INSERT INTO app_versions (id, app_id, version, jar_path, jar_checksum) " +
|
||||
"VALUES (?, ?, 1, '/tmp/x.jar', 'abc')",
|
||||
versionId, appId);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
jdbc.update("DELETE FROM deployments");
|
||||
jdbc.update("DELETE FROM app_versions");
|
||||
jdbc.update("DELETE FROM apps");
|
||||
jdbc.update("DELETE FROM users WHERE user_id IN ('alice', 'bob')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createDeployment_persists_createdBy_and_returns_it() {
|
||||
Deployment d = deploymentService.createDeployment(appId, versionId, envId, "alice");
|
||||
assertThat(d.createdBy()).isEqualTo("alice");
|
||||
String fromDb = jdbc.queryForObject(
|
||||
"SELECT created_by FROM deployments WHERE id = ?", String.class, d.id());
|
||||
assertThat(fromDb).isEqualTo("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void promote_persists_createdBy() {
|
||||
Deployment promoted = deploymentService.promote(appId, versionId, envId, "bob");
|
||||
assertThat(promoted.createdBy()).isEqualTo("bob");
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,8 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
|
||||
null
|
||||
);
|
||||
|
||||
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container");
|
||||
// pre-V4 rows: no creator (createdBy is nullable)
|
||||
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container", null);
|
||||
repository.saveDeployedConfigSnapshot(deploymentId, snapshot);
|
||||
|
||||
// when — load it back
|
||||
@@ -80,7 +81,7 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
|
||||
@Test
|
||||
void deployedConfigSnapshot_nullByDefault() {
|
||||
// deployments created without a snapshot must return null (not throw)
|
||||
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-null");
|
||||
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-null", null);
|
||||
|
||||
Deployment loaded = repository.findById(deploymentId).orElseThrow();
|
||||
|
||||
@@ -90,13 +91,13 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
|
||||
@Test
|
||||
void deleteFailedByAppAndEnvironment_keepsStoppedAndActive() {
|
||||
// given: one STOPPED (checkpoint), one FAILED, one RUNNING
|
||||
UUID stoppedId = repository.create(appId, appVersionId, envId, "stopped");
|
||||
UUID stoppedId = repository.create(appId, appVersionId, envId, "stopped", null);
|
||||
repository.updateStatus(stoppedId, com.cameleer.server.core.runtime.DeploymentStatus.STOPPED, null, null);
|
||||
|
||||
UUID failedId = repository.create(appId, appVersionId, envId, "failed");
|
||||
UUID failedId = repository.create(appId, appVersionId, envId, "failed", null);
|
||||
repository.updateStatus(failedId, com.cameleer.server.core.runtime.DeploymentStatus.FAILED, null, "boom");
|
||||
|
||||
UUID runningId = repository.create(appId, appVersionId, envId, "running");
|
||||
UUID runningId = repository.create(appId, appVersionId, envId, "running", null);
|
||||
repository.updateStatus(runningId, com.cameleer.server.core.runtime.DeploymentStatus.RUNNING, "c1", null);
|
||||
|
||||
// when
|
||||
@@ -118,7 +119,7 @@ class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
|
||||
null
|
||||
);
|
||||
|
||||
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-clear");
|
||||
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-clear", null);
|
||||
repository.saveDeployedConfigSnapshot(deploymentId, snapshot);
|
||||
repository.saveDeployedConfigSnapshot(deploymentId, null);
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.cameleer.server.app.storage;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class V4DeploymentCreatedByMigrationIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
|
||||
@Test
|
||||
void created_by_column_exists_with_correct_type_and_nullable() {
|
||||
// Scope to current schema — Testcontainers reuse can otherwise leave
|
||||
// a previous run's tenant_default schema visible alongside public.
|
||||
List<Map<String, Object>> cols = jdbc.queryForList(
|
||||
"SELECT column_name, data_type, is_nullable " +
|
||||
"FROM information_schema.columns " +
|
||||
"WHERE table_name = 'deployments' AND column_name = 'created_by' " +
|
||||
" AND table_schema = current_schema()"
|
||||
);
|
||||
assertThat(cols).hasSize(1);
|
||||
assertThat(cols.get(0)).containsEntry("data_type", "text");
|
||||
assertThat(cols.get(0)).containsEntry("is_nullable", "YES");
|
||||
}
|
||||
|
||||
@Test
|
||||
void created_by_index_exists() {
|
||||
Integer count = jdbc.queryForObject(
|
||||
"SELECT count(*)::int FROM pg_indexes " +
|
||||
"WHERE tablename = 'deployments' AND indexname = 'idx_deployments_created_by' " +
|
||||
" AND schemaname = current_schema()",
|
||||
Integer.class
|
||||
);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void created_by_has_fk_to_users() {
|
||||
Integer count = jdbc.queryForObject(
|
||||
"SELECT count(*)::int FROM information_schema.table_constraints tc " +
|
||||
"JOIN information_schema.constraint_column_usage ccu " +
|
||||
" ON tc.constraint_name = ccu.constraint_name " +
|
||||
"WHERE tc.table_name = 'deployments' " +
|
||||
" AND tc.constraint_type = 'FOREIGN KEY' " +
|
||||
" AND ccu.table_name = 'users' " +
|
||||
" AND ccu.column_name = 'user_id' " +
|
||||
" AND tc.table_schema = current_schema()",
|
||||
Integer.class
|
||||
);
|
||||
assertThat(count).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ package com.cameleer.server.core.admin;
|
||||
public enum AuditCategory {
|
||||
INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT,
|
||||
OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE,
|
||||
ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE
|
||||
ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE,
|
||||
DEPLOYMENT
|
||||
}
|
||||
|
||||
@@ -22,19 +22,20 @@ public record Deployment(
|
||||
DeploymentConfigSnapshot deployedConfigSnapshot,
|
||||
Instant deployedAt,
|
||||
Instant stoppedAt,
|
||||
Instant createdAt
|
||||
Instant createdAt,
|
||||
String createdBy
|
||||
) {
|
||||
public Deployment withStatus(DeploymentStatus newStatus) {
|
||||
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
|
||||
targetState, deploymentStrategy, replicaStates, deployStage,
|
||||
containerId, containerName, errorMessage, resolvedConfig,
|
||||
deployedConfigSnapshot, deployedAt, stoppedAt, createdAt);
|
||||
deployedConfigSnapshot, deployedAt, stoppedAt, createdAt, createdBy);
|
||||
}
|
||||
|
||||
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);
|
||||
snapshot, deployedAt, stoppedAt, createdAt, createdBy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public interface DeploymentRepository {
|
||||
Optional<Deployment> findById(UUID id);
|
||||
Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId);
|
||||
Optional<Deployment> findActiveByAppIdAndEnvironmentIdExcluding(UUID appId, UUID environmentId, UUID excludeDeploymentId);
|
||||
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName);
|
||||
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName, String createdBy);
|
||||
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
|
||||
void markDeployed(UUID id);
|
||||
void markStopped(UUID id);
|
||||
|
||||
@@ -23,19 +23,19 @@ public class DeploymentService {
|
||||
public Deployment getById(UUID id) { return deployRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("Deployment not found: " + id)); }
|
||||
|
||||
/** Create a deployment record. Actual container start is handled by DeploymentExecutor (async). */
|
||||
public Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId) {
|
||||
public Deployment createDeployment(UUID appId, UUID appVersionId, UUID environmentId, String createdBy) {
|
||||
App app = appService.getById(appId);
|
||||
Environment env = envService.getById(environmentId);
|
||||
String containerName = env.slug() + "-" + app.slug();
|
||||
|
||||
deployRepo.deleteFailedByAppAndEnvironment(appId, environmentId);
|
||||
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName);
|
||||
UUID deploymentId = deployRepo.create(appId, appVersionId, environmentId, containerName, createdBy);
|
||||
return deployRepo.findById(deploymentId).orElseThrow();
|
||||
}
|
||||
|
||||
/** Promote: deploy the same app version to a different environment. */
|
||||
public Deployment promote(UUID appId, UUID appVersionId, UUID targetEnvironmentId) {
|
||||
return createDeployment(appId, appVersionId, targetEnvironmentId);
|
||||
public Deployment promote(UUID appId, UUID appVersionId, UUID targetEnvironmentId, String createdBy) {
|
||||
return createDeployment(appId, appVersionId, targetEnvironmentId, createdBy);
|
||||
}
|
||||
|
||||
public void markRunning(UUID deploymentId, String containerId) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.util.List;
|
||||
* @param q free-text search across message and stack trace
|
||||
* @param levels log level filter (e.g. ["WARN","ERROR"]), OR-joined
|
||||
* @param application application ID filter (nullable = all apps)
|
||||
* @param instanceId agent instance ID filter
|
||||
* @param instanceId agent instance ID filter (single value; coexists with instanceIds)
|
||||
* @param exchangeId Camel exchange ID filter
|
||||
* @param logger logger name substring filter
|
||||
* @param environment optional environment filter (e.g. "dev", "staging", "prod")
|
||||
@@ -19,6 +19,9 @@ import java.util.List;
|
||||
* @param cursor ISO timestamp cursor for keyset pagination
|
||||
* @param limit page size (1-500, default 100)
|
||||
* @param sort sort direction: "asc" or "desc" (default "desc")
|
||||
* @param instanceIds multi-value instance ID filter (IN clause); scopes logs to one deployment's
|
||||
* replicas when provided. Both instanceId and instanceIds may coexist — both
|
||||
* conditions apply (AND). Empty/null means no additional filtering.
|
||||
*/
|
||||
public record LogSearchRequest(
|
||||
String q,
|
||||
@@ -33,7 +36,8 @@ public record LogSearchRequest(
|
||||
Instant to,
|
||||
String cursor,
|
||||
int limit,
|
||||
String sort
|
||||
String sort,
|
||||
List<String> instanceIds
|
||||
) {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 100;
|
||||
@@ -45,5 +49,6 @@ public record LogSearchRequest(
|
||||
if (sort == null || !"asc".equalsIgnoreCase(sort)) sort = "desc";
|
||||
if (levels == null) levels = List.of();
|
||||
if (sources == null) sources = List.of();
|
||||
if (instanceIds == null) instanceIds = List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,10 @@ class AuditCategoryTest {
|
||||
assertThat(AuditCategory.valueOf("ALERT_RULE_CHANGE")).isNotNull();
|
||||
assertThat(AuditCategory.valueOf("ALERT_SILENCE_CHANGE")).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploymentCategoryPresent() {
|
||||
assertThat(AuditCategory.valueOf("DEPLOYMENT"))
|
||||
.isEqualTo(AuditCategory.DEPLOYMENT);
|
||||
}
|
||||
}
|
||||
|
||||
2177
docs/superpowers/plans/2026-04-23-checkpoints-table-redesign.md
Normal file
2177
docs/superpowers/plans/2026-04-23-checkpoints-table-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
# Checkpoints table redesign + deployment audit gap closure
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Status:** Spec — pending implementation
|
||||
**Affects:** App deployment page, deployments backend, audit log
|
||||
|
||||
## Context
|
||||
|
||||
The Checkpoints disclosure on the unified app deployment page (`ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx`) currently renders past deployments as a cramped row list — a Badge, a "12m ago" label, and a Restore button. It hides the operator information that matters most when reasoning about a checkpoint: who deployed it, the JAR filename (not just the version number), the deployment outcome, and access to the logs and config snapshot the deployment ran with.
|
||||
|
||||
Investigating this also surfaced a **gap in the audit log**: `DeploymentController.deploy / stop / promote` make zero `auditService.log(...)` calls. Container deployments — the most consequential operations the server performs — leave no audit trail today. Closing this gap is in scope because it's prerequisite to the "Deployed by" column.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Replace the cramped checkpoints list with a real table (DS `DataTable`) showing version, JAR filename, deployer, time, strategy, and outcome.
|
||||
2. Capture and display "who deployed" — backend gains a `created_by` column on `deployments`, populated from `SecurityContextHolder`.
|
||||
3. Audit deploy / stop / promote operations under a new `AuditCategory.DEPLOYMENT` value.
|
||||
4. Provide an in-page detail view (side drawer) where the operator can review the deployment's logs and config snapshot before deciding to restore, with an optional diff against the current live config.
|
||||
5. Cap the visible checkpoint list at the environment's JAR retention count, since older entries cannot be restored.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Sortable column headers (default newest-first is enough)
|
||||
- Deep-linking via `?checkpoint=<id>` query param
|
||||
- "Remember last drawer tab" preference
|
||||
- Bulk actions on checkpoints
|
||||
- Promoting `SideDrawer` into `@cameleer/design-system` (wait for a second consumer)
|
||||
|
||||
## Backend changes
|
||||
|
||||
### Audit category
|
||||
|
||||
Add `DEPLOYMENT` to `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java`:
|
||||
|
||||
```java
|
||||
public enum AuditCategory {
|
||||
INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT,
|
||||
OUTBOUND_CONNECTION_CHANGE, OUTBOUND_HTTP_TRUST_CHANGE,
|
||||
ALERT_RULE_CHANGE, ALERT_SILENCE_CHANGE,
|
||||
DEPLOYMENT
|
||||
}
|
||||
```
|
||||
|
||||
The `AuditCategory.valueOf(...)` lookup in `AuditLogController` picks this up automatically. The Admin → Audit page filter dropdown gets one new option in `ui/src/pages/Admin/AuditLogPage.tsx`.
|
||||
|
||||
### Audit calls in `DeploymentController`
|
||||
|
||||
Add `AuditService` injection and write audit rows on every successful and failed lifecycle operation. Action codes:
|
||||
|
||||
| Method | Action | Target | Details |
|
||||
|---|---|---|---|
|
||||
| `deploy` | `deploy_app` | `deployment.id().toString()` | `{ appSlug, envSlug, appVersionId, jarFilename, version }` |
|
||||
| `stop` | `stop_deployment` | `deploymentId.toString()` | `{ appSlug, envSlug }` |
|
||||
| `promote` | `promote_deployment` | `deploymentId.toString()` | `{ sourceEnv, targetEnv, appSlug, appVersionId }` |
|
||||
|
||||
Each `try` branch writes `AuditResult.SUCCESS`; `catch (IllegalArgumentException)` writes `AuditResult.FAILURE` with the exception message in details before returning the existing 404. Pattern matches `OutboundConnectionAdminController`.
|
||||
|
||||
### Flyway migration `V2__add_deployment_created_by.sql`
|
||||
|
||||
```sql
|
||||
ALTER TABLE deployments ADD COLUMN created_by TEXT REFERENCES users(user_id);
|
||||
CREATE INDEX idx_deployments_created_by ON deployments (created_by);
|
||||
```
|
||||
|
||||
Nullable — existing rows stay `NULL` (rendered as `—` in UI). New rows always populated. No backfill: pre-V2 history is unrecoverable, and the column starts paying off from the next deploy onward.
|
||||
|
||||
### Service signature change
|
||||
|
||||
`DeploymentService.createDeployment(appId, appVersionId, envId, createdBy)` and `promote(targetAppId, sourceVersionId, targetEnvId, createdBy)` both gain a trailing `String createdBy` parameter. `PostgresDeploymentRepository` writes it to the new column.
|
||||
|
||||
`DeploymentController` resolves `createdBy` via the existing user-id convention: strip `"user:"` prefix from `SecurityContextHolder.getContext().getAuthentication().getName()`. Same helper pattern as `AlertRuleController` / `OutboundConnectionAdminController`.
|
||||
|
||||
### DTO change
|
||||
|
||||
`com.cameleer.server.core.runtime.Deployment` record gains `createdBy: String`. UI `Deployment` interface in `ui/src/api/queries/admin/apps.ts` gains `createdBy: string | null`.
|
||||
|
||||
### Log filter for the drawer
|
||||
|
||||
`LogQueryController.GET /api/v1/environments/{envSlug}/logs` accepts a new multi-value query param `instanceIds` (comma-split, OR-joined). Translates to `WHERE instance_id IN (...)` against the existing `LowCardinality(String)` index on `logs.instance_id` (already part of the `ORDER BY` key).
|
||||
|
||||
`LogSearchRequest` gains `instanceIds: List<String>` (null-normalized). Service layer adds the `IN (...)` clause when non-null and non-empty.
|
||||
|
||||
The drawer client computes the instance_id list from `Deployment.replicaStates`: for each replica, `instance_id = "{envSlug}-{appSlug}-{replicaIndex}-{generation}"` where generation is the first 8 chars of `deployment.id`. This is the documented format from `.claude/rules/docker-orchestration.md` — pure client-side derivation, no extra server endpoint.
|
||||
|
||||
## Drawer infrastructure
|
||||
|
||||
The design system provides `Modal` but no drawer. Building a project-local component is preferred over submitting to DS first (single consumer; easier to iterate locally).
|
||||
|
||||
**File:** `ui/src/components/SideDrawer.tsx` + `SideDrawer.module.css` (~120 LOC total).
|
||||
|
||||
**API:**
|
||||
|
||||
```tsx
|
||||
<SideDrawer
|
||||
open={!!selectedCheckpoint}
|
||||
onClose={() => setSelectedCheckpoint(null)}
|
||||
title={`Deployment v${version} · ${jarFilename}`}
|
||||
size="lg" // 'md'=560px, 'lg'=720px, 'xl'=900px
|
||||
footer={<Button onClick={handleRestore}>Restore this checkpoint</Button>}
|
||||
>
|
||||
{/* scrollable body */}
|
||||
</SideDrawer>
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- React portal to `document.body` (mirrors DS `Modal`).
|
||||
- Slides in from right via `transform: translateX(100% → 0)` over 240ms ease-out.
|
||||
- Click-blocking transparent backdrop (no dim — the parent table stays readable). Clicking outside closes.
|
||||
- ESC closes.
|
||||
- Focus trap on open; focus restored to trigger on close.
|
||||
- Sticky header (title + close ×) and optional sticky footer.
|
||||
- Body uses `overflow-y: auto`.
|
||||
- All colors via DS CSS variables (`--bg`, `--border`, `--shadow-lg`).
|
||||
|
||||
**Unsaved-changes interaction:** Opening the drawer is unrestricted. The drawer is read-only — only Restore mutates form state, and Restore already triggers the existing unsaved-changes guard via `useUnsavedChangesBlocker`.
|
||||
|
||||
## Checkpoints table
|
||||
|
||||
**File:** `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx` — replaces `Checkpoints.tsx`.
|
||||
|
||||
**Columns** (left to right):
|
||||
|
||||
| Column | Source | Notes |
|
||||
|---|---|---|
|
||||
| Version | `versionMap.get(d.appVersionId).version` | Badge "v6" with auto-color (matches existing pattern) |
|
||||
| JAR | `versionMap.get(d.appVersionId).jarFilename` | Monospace; truncate with tooltip on overflow |
|
||||
| Deployed by | `d.createdBy` | Bare username; OIDC users show `oidc:<sub>` truncated with tooltip; null shows `—` muted |
|
||||
| Deployed | `d.deployedAt` | Relative ("12m ago") + ISO subline |
|
||||
| Strategy | `d.deploymentStrategy` | Small pill: "blue/green" or "rolling" |
|
||||
| Outcome | `d.status` | Tinted pill: STOPPED (slate), DEGRADED (amber) |
|
||||
| (chevron) | — | Visual affordance for "row click opens drawer" |
|
||||
|
||||
**Interaction:**
|
||||
- Row click opens `CheckpointDetailDrawer` (no separate "View" button).
|
||||
- No per-row Restore button — Restore lives inside the drawer to force review before action.
|
||||
- Pruned-JAR rows (`!versionMap.has(d.appVersionId)`) render at 55% opacity with a strikethrough on the filename and an amber "archived — JAR pruned" hint. Row stays clickable; Restore inside the drawer is disabled with tooltip.
|
||||
- Currently-running deployment is excluded (already represented by `StatusCard` above).
|
||||
|
||||
**Empty state:** When zero checkpoints, render a single full-width muted row: "No past deployments yet."
|
||||
|
||||
## Pagination
|
||||
|
||||
Visible cap = `Environment.jarRetentionCount` rows (newest first). Anything older has likely been pruned and is not restorable, so it's hidden by default.
|
||||
|
||||
- `total ≤ jarRetentionCount` → render all, no expander.
|
||||
- `total > jarRetentionCount` → render newest `jarRetentionCount` rows + an expander row: **"Show older (N) — archived, postmortem only"**. Expanding renders the full list (older rows already styled as archived).
|
||||
- `jarRetentionCount === 0` (unlimited or unconfigured) → fall back to a default cap of 10.
|
||||
|
||||
`jarRetentionCount` comes from `useEnvironments()` (already in the env-store).
|
||||
|
||||
## Drawer detail view
|
||||
|
||||
**File:** `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx` plus three panel files: `LogsPanel.tsx`, `ConfigPanel.tsx`, `ComparePanel.tsx`.
|
||||
|
||||
**Header:**
|
||||
- Version badge + JAR filename + outcome pill.
|
||||
- Meta line: "Deployed by **{createdBy}** · {relative} ({ISO}) · Strategy: {strategy} · {N} replicas · ran for {duration}".
|
||||
- Close × top-right.
|
||||
|
||||
**Tabs** (DS `Tabs`):
|
||||
- **Logs** — default on open
|
||||
- **Config** — read-only render of the live config sub-tabs, with a view-mode toggle for "Snapshot" vs "Diff vs current"
|
||||
|
||||
### Logs panel
|
||||
|
||||
Reuses `useInfiniteApplicationLogs` with the new `instanceIds` filter. The hook signature gets an optional `instanceIds: string[]` parameter that flows through to the `LogQueryController` query string.
|
||||
|
||||
**Filters** (in addition to `instanceIds`):
|
||||
- Existing source/level multi-select pills
|
||||
- New replica filter dropdown: "all (N)" / "0" / "1" / ... / "N-1" — narrows to a single replica when troubleshooting blue-green or rolling deploys.
|
||||
|
||||
**Default sort:** newest first (matches operator mental model when investigating a stopped deployment).
|
||||
|
||||
**Total line count** displayed in the filter bar.
|
||||
|
||||
### Config panel
|
||||
|
||||
Renders the five existing live config sub-tabs (`Monitoring`, `Resources`, `Variables`, `SensitiveKeys`, `Deployment`) **read-only**, hydrated from `deployedConfigSnapshot`.
|
||||
|
||||
Each sub-tab component (`ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/*`) gains an optional `readOnly?: boolean` prop. When `readOnly` is set:
|
||||
- All inputs disabled (`disabled` attribute + visual styling)
|
||||
- Save / edit buttons hidden
|
||||
- Live banners (`LiveBanner`) hidden — these are not applicable to a frozen snapshot
|
||||
|
||||
If a sub-tab currently mixes derived state with form state in a way that makes a clean `readOnly` toggle awkward, refactor that sub-tab as part of this work. Don't proceed with leaky read-only behavior.
|
||||
|
||||
**View-mode toggle:** "Snapshot" / "Diff vs current". Default = Snapshot (full read-only render). Diff mode shows differences only — both old and new values per changed field, with red/green left borders, grouped by sub-tab. Each sub-tab pill shows a change-count badge (e.g. "Resources (2)"); sub-tabs with zero differences are dimmed and render a muted "No differences in this section" message when clicked.
|
||||
|
||||
Diff base = current live config, pulled via the existing `useApplicationConfig` hook the live form already uses. Algorithm: deep-equal field-level walk between snapshot and current.
|
||||
|
||||
The toggle is hidden entirely when JAR is pruned (the missing JAR makes "current vs snapshot" comparison incomplete and misleading).
|
||||
|
||||
**Footer:** Sticky. Single primary button "Restore this checkpoint" + helper text "Restoring hydrates the form — you'll still need to Redeploy."
|
||||
|
||||
When JAR is pruned: button disabled with tooltip "JAR was pruned by the environment retention policy".
|
||||
|
||||
Restore behavior is unchanged from today: closes the drawer + hydrates the form via the existing `onRestore(deploymentId)` callback. No backend call; the eventual Redeploy generates the next `deploy_app` audit row.
|
||||
|
||||
## Authorization
|
||||
|
||||
`DeploymentController` and `AppController` are already class-level `@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")`, so the deployment page is operator-gated. The new `instanceIds` filter on `LogQueryController` (which is VIEWER+) widens nothing — viewers can already query the same logs by `application + environment`; the filter just narrows.
|
||||
|
||||
## Real-time updates
|
||||
|
||||
When a new deployment lands, the previous "current" becomes a checkpoint. TanStack Query already polls deployments via the existing `useDeployments(appSlug, envSlug)` hook; the new table consumes the same data — auto-refresh comes for free.
|
||||
|
||||
## Tests
|
||||
|
||||
**Backend integration tests:**
|
||||
|
||||
| Test | What it asserts |
|
||||
|---|---|
|
||||
| `V2MigrationIT` | `created_by` column exists, FK valid, index exists |
|
||||
| `DeploymentServiceCreatedByIT` | `createDeployment(...createdBy)` persists the value |
|
||||
| `DeploymentControllerAuditIT` | All three lifecycle actions write the expected audit row (action, category, target, details, actor, result) including FAILURE branches |
|
||||
| `LogQueryControllerInstanceIdsFilterIT` | `?instanceIds=a,b,c` returns only matching rows; empty/missing param preserves prior behavior |
|
||||
|
||||
**UI component tests:**
|
||||
|
||||
| Test | What it asserts |
|
||||
|---|---|
|
||||
| `SideDrawer.test.tsx` | open/close, ESC closes, backdrop click closes, focus trap |
|
||||
| `CheckpointsTable.test.tsx` | row click opens drawer; pruned-JAR row dimmed + clickable; empty state |
|
||||
| `CheckpointDetailDrawer.test.tsx` | renders correct logs (mocked instance_id list); Restore disabled when JAR pruned |
|
||||
| `ConfigPanel.test.tsx` | snapshot mode renders all fields read-only; diff mode counts differences correctly per sub-tab; "no differences" message when section unchanged; toggle hidden when JAR pruned |
|
||||
|
||||
## Files touched
|
||||
|
||||
**Backend:**
|
||||
- New: `cameleer-server-app/src/main/resources/db/migration/V2__add_deployment_created_by.sql`
|
||||
- Modified: `cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AuditCategory.java` (add `DEPLOYMENT`)
|
||||
- Modified: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/Deployment.java` (record field)
|
||||
- Modified: `cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/DeploymentService.java` (signature + impl)
|
||||
- Modified: `cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresDeploymentRepository.java` (insert + map)
|
||||
- Modified: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java` (audit calls + createdBy resolution)
|
||||
- Modified: `cameleer-server-app/src/main/java/com/cameleer/server/app/controller/LogQueryController.java` (instanceIds param)
|
||||
- Modified: `cameleer-server-core/src/main/java/com/cameleer/server/core/search/LogSearchRequest.java` (instanceIds field)
|
||||
- Regenerate: `cameleer-server-app/src/main/resources/openapi.json` (controller change → SPA types)
|
||||
|
||||
**UI:**
|
||||
- New: `ui/src/components/SideDrawer.tsx` + `SideDrawer.module.css`
|
||||
- New: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx`
|
||||
- New: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/{index,LogsPanel,ConfigPanel}.tsx` (Compare is a view-mode inside ConfigPanel, not a separate file)
|
||||
- Modified: `ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx` (swap Checkpoints → CheckpointsTable)
|
||||
- Deleted: `ui/src/pages/AppsTab/AppDeploymentPage/Checkpoints.tsx`
|
||||
- Modified: `ui/src/pages/AppsTab/AppDeploymentPage/ConfigTabs/{Monitoring,Resources,Variables,SensitiveKeys,Deployment}Tab.tsx` (add `readOnly?` prop)
|
||||
- Modified: `ui/src/api/queries/logs.ts` (`useInfiniteApplicationLogs` accepts `instanceIds`)
|
||||
- Modified: `ui/src/api/queries/admin/apps.ts` (`Deployment.createdBy` field)
|
||||
- Modified: `ui/src/api/schema.d.ts` + `ui/src/api/openapi.json` (regenerated)
|
||||
- Modified: `ui/src/pages/Admin/AuditLogPage.tsx` (one new category in filter dropdown)
|
||||
|
||||
**Docs / rules:**
|
||||
- Modified: `.claude/rules/app-classes.md` (DeploymentController audit calls + LogQueryController instanceIds param)
|
||||
- Modified: `.claude/rules/ui.md` (CheckpointsTable + SideDrawer pattern)
|
||||
- Modified: `.claude/rules/core-classes.md` (`AuditCategory.DEPLOYMENT`, `Deployment.createdBy`)
|
||||
|
||||
## Rollout
|
||||
|
||||
Two phases, ideally two PRs:
|
||||
|
||||
1. **Backend phase** — V2 migration, `AuditCategory.DEPLOYMENT`, audit calls in `DeploymentController`, `created_by` plumbing through `DeploymentService` / record / repository, `LogQueryController` `instanceIds` param. Ships independently because the column is nullable, the audit category is picked up automatically, and the new log filter is opt-in.
|
||||
2. **UI phase** — `SideDrawer`, `CheckpointsTable`, `CheckpointDetailDrawer`, `readOnly?` props on the five config sub-tabs, audit-page dropdown entry. Depends on the backend PR being merged + the OpenAPI schema regenerated.
|
||||
|
||||
Splitting in this order means production gets the audit trail and `created_by` capture immediately, even before the new UI lands, so the audit gap is closed as quickly as possible.
|
||||
File diff suppressed because one or more lines are too long
@@ -47,6 +47,7 @@ export interface Deployment {
|
||||
containerConfig: Record<string, unknown>;
|
||||
sensitiveKeys: string[] | null;
|
||||
} | null;
|
||||
createdBy: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -162,8 +162,9 @@ export function useStartupLogs(
|
||||
export interface UseInfiniteApplicationLogsArgs {
|
||||
application?: string;
|
||||
agentId?: string;
|
||||
sources?: string[]; // multi-select, server-side OR
|
||||
levels?: string[]; // multi-select, server-side OR
|
||||
sources?: string[]; // multi-select, server-side OR
|
||||
levels?: string[]; // multi-select, server-side OR
|
||||
instanceIds?: string[]; // multi-select instance_id filter, server-side OR (e.g. drawer scopes to one deployment's replicas)
|
||||
exchangeId?: string;
|
||||
sort?: 'asc' | 'desc';
|
||||
isAtTop: boolean;
|
||||
@@ -191,8 +192,10 @@ export function useInfiniteApplicationLogs(
|
||||
|
||||
const sortedSources = (args.sources ?? []).slice().sort();
|
||||
const sortedLevels = (args.levels ?? []).slice().sort();
|
||||
const sortedInstanceIds = (args.instanceIds ?? []).slice().sort();
|
||||
const sourcesParam = sortedSources.join(',');
|
||||
const levelsParam = sortedLevels.join(',');
|
||||
const instanceIdsParam = sortedInstanceIds.join(',');
|
||||
const pageSize = args.pageSize ?? 100;
|
||||
const sort = args.sort ?? 'desc';
|
||||
|
||||
@@ -204,6 +207,7 @@ export function useInfiniteApplicationLogs(
|
||||
args.agentId ?? '',
|
||||
args.exchangeId ?? '',
|
||||
sourcesParam,
|
||||
instanceIdsParam,
|
||||
levelsParam,
|
||||
fromIso ?? '',
|
||||
toIso ?? '',
|
||||
@@ -220,6 +224,7 @@ export function useInfiniteApplicationLogs(
|
||||
if (args.exchangeId) qp.set('exchangeId', args.exchangeId);
|
||||
if (sourcesParam) qp.set('source', sourcesParam);
|
||||
if (levelsParam) qp.set('level', levelsParam);
|
||||
if (instanceIdsParam) qp.set('instanceIds', instanceIdsParam);
|
||||
if (fromIso) qp.set('from', fromIso);
|
||||
const effectiveTo = isLiveRange ? new Date().toISOString() : toIso;
|
||||
if (effectiveTo) qp.set('to', effectiveTo);
|
||||
|
||||
6
ui/src/api/schema.d.ts
vendored
6
ui/src/api/schema.d.ts
vendored
@@ -2745,6 +2745,7 @@ export interface components {
|
||||
stoppedAt?: string;
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
createdBy?: string;
|
||||
};
|
||||
DeploymentConfigSnapshot: {
|
||||
/** Format: uuid */
|
||||
@@ -2753,6 +2754,7 @@ export interface components {
|
||||
containerConfig?: {
|
||||
[key: string]: Record<string, never>;
|
||||
};
|
||||
sensitiveKeys?: string[];
|
||||
};
|
||||
PromoteRequest: {
|
||||
targetEnvironment?: string;
|
||||
@@ -3703,7 +3705,7 @@ export interface components {
|
||||
username?: string;
|
||||
action?: string;
|
||||
/** @enum {string} */
|
||||
category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG" | "RBAC" | "AGENT" | "OUTBOUND_CONNECTION_CHANGE" | "OUTBOUND_HTTP_TRUST_CHANGE" | "ALERT_RULE_CHANGE" | "ALERT_SILENCE_CHANGE";
|
||||
category?: "INFRA" | "AUTH" | "USER_MGMT" | "CONFIG" | "RBAC" | "AGENT" | "OUTBOUND_CONNECTION_CHANGE" | "OUTBOUND_HTTP_TRUST_CHANGE" | "ALERT_RULE_CHANGE" | "ALERT_SILENCE_CHANGE" | "DEPLOYMENT";
|
||||
target?: string;
|
||||
detail?: {
|
||||
[key: string]: Record<string, never>;
|
||||
@@ -3872,6 +3874,7 @@ export interface operations {
|
||||
parameters: {
|
||||
query: {
|
||||
env: components["schemas"]["Environment"];
|
||||
/** @description When to apply: 'live' (default) saves and pushes CONFIG_UPDATE to live agents immediately; 'staged' saves without pushing — the next successful deploy applies it. */
|
||||
apply?: string;
|
||||
};
|
||||
header?: never;
|
||||
@@ -7028,6 +7031,7 @@ export interface operations {
|
||||
exchangeId?: string;
|
||||
logger?: string;
|
||||
source?: string;
|
||||
instanceIds?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
cursor?: string;
|
||||
|
||||
77
ui/src/components/SideDrawer.module.css
Normal file
77
ui/src/components/SideDrawer.module.css
Normal file
@@ -0,0 +1,77 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 950;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
/* transparent — no dim */
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
animation: slideIn 240ms ease-out;
|
||||
}
|
||||
|
||||
.size-md { width: 560px; max-width: 100vw; }
|
||||
.size-lg { width: 720px; max-width: 100vw; }
|
||||
.size-xl { width: 900px; max-width: 100vw; }
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.closeBtn:hover { color: var(--text-primary); }
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 18px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
padding: 14px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
53
ui/src/components/SideDrawer.test.tsx
Normal file
53
ui/src/components/SideDrawer.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@cameleer/design-system';
|
||||
import type { ReactNode } from 'react';
|
||||
import { SideDrawer } from './SideDrawer';
|
||||
|
||||
function wrap(ui: ReactNode) {
|
||||
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
}
|
||||
|
||||
describe('SideDrawer', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
wrap(<SideDrawer open={false} onClose={() => {}} title="X">body</SideDrawer>);
|
||||
expect(screen.queryByText('body')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders title, body, and close button when open', () => {
|
||||
wrap(<SideDrawer open onClose={() => {}} title="My Title">body content</SideDrawer>);
|
||||
expect(screen.getByText('My Title')).toBeInTheDocument();
|
||||
expect(screen.getByText('body content')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClose when close button clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
wrap(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when ESC pressed', () => {
|
||||
const onClose = vi.fn();
|
||||
wrap(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when backdrop clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
wrap(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
|
||||
fireEvent.click(screen.getByTestId('side-drawer-backdrop'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders footer when provided', () => {
|
||||
wrap(
|
||||
<SideDrawer open onClose={() => {}} title="X" footer={<button>Save</button>}>
|
||||
body
|
||||
</SideDrawer>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
55
ui/src/components/SideDrawer.tsx
Normal file
55
ui/src/components/SideDrawer.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import styles from './SideDrawer.module.css';
|
||||
|
||||
interface SideDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
footer?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SideDrawer({
|
||||
open, onClose, title, size = 'lg', footer, children,
|
||||
}: SideDrawerProps): React.JSX.Element | null {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
data-testid="side-drawer-backdrop"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
className={`${styles.drawer} ${styles[`size-${size}`]}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close drawer"
|
||||
className={styles.closeBtn}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.body}>{children}</div>
|
||||
{footer && <footer className={styles.footer}>{footer}</footer>}
|
||||
</aside>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const CATEGORIES = [
|
||||
{ value: 'CONFIG', label: 'CONFIG' },
|
||||
{ value: 'RBAC', label: 'RBAC' },
|
||||
{ value: 'AGENT', label: 'AGENT' },
|
||||
{ value: 'DEPLOYMENT', label: 'DEPLOYMENT' },
|
||||
];
|
||||
|
||||
function exportCsv(events: AuditEvent[]) {
|
||||
|
||||
@@ -109,6 +109,10 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.checkpointsTable tr.checkpointArchived {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.checkpointEmpty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
@@ -309,3 +313,69 @@
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* CheckpointsTable */
|
||||
.checkpointsTable {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.checkpointsTable table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.checkpointsTable th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-inset);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.checkpointsTable td {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.checkpointsTable tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkpointsTable tbody tr:hover {
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.jarCell { font-family: monospace; font-size: 12px; }
|
||||
.jarName { font-family: monospace; }
|
||||
.jarStrike { text-decoration: line-through; }
|
||||
.archivedHint { font-size: 11px; color: var(--amber, #f59e0b); }
|
||||
.isoSubline { font-size: 11px; color: var(--text-muted); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.strategyPill,
|
||||
.outcomePill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
/* outcome status colors */
|
||||
.outcome-STOPPED { color: var(--text-muted); }
|
||||
.outcome-DEGRADED {
|
||||
background: var(--amber-bg, rgba(245, 158, 11, 0.18));
|
||||
color: var(--amber, #f59e0b);
|
||||
}
|
||||
.chevron { color: var(--text-muted); font-size: 14px; text-align: right; }
|
||||
.showOlderBtn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.showOlderBtn:hover {
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/* index.tsx */
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.titleJar {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.statusPill {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
|
||||
.metaLine {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.footerRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footerHint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* LogsPanel.tsx */
|
||||
.filterBar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.logRow {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.logTimestamp {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ConfigPanel.tsx / DiffView */
|
||||
.diffList {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diffEntry {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.diffPath {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.diffRemoved {
|
||||
background: var(--red-bg, rgba(239, 68, 68, 0.15));
|
||||
border-left: 2px solid var(--red, #ef4444);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.diffAdded {
|
||||
background: var(--green-bg, rgba(34, 197, 94, 0.15));
|
||||
border-left: 2px solid var(--green, #22c55e);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@cameleer/design-system';
|
||||
import { CheckpointDetailDrawer } from './index';
|
||||
|
||||
// Mock the logs hook so the test doesn't try to fetch
|
||||
vi.mock('../../../../api/queries/logs', () => ({
|
||||
useInfiniteApplicationLogs: () => ({ items: [], isLoading: false, hasNextPage: false, fetchNextPage: vi.fn(), isFetchingNextPage: false, refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
const baseDep: any = {
|
||||
id: 'aaa11111-2222-3333-4444-555555555555',
|
||||
appId: 'a', appVersionId: 'v6id', environmentId: 'e',
|
||||
status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
|
||||
replicaStates: [{ index: 0, containerId: 'c', containerName: 'n', status: 'STOPPED' }],
|
||||
deployStage: null, containerId: null, containerName: null, errorMessage: null,
|
||||
deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
|
||||
createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
|
||||
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
|
||||
};
|
||||
|
||||
const v: any = {
|
||||
id: 'v6id', appId: 'a', version: 6,
|
||||
jarPath: '/j', jarChecksum: 'c', jarFilename: 'my-app-1.2.3.jar',
|
||||
jarSizeBytes: 1, detectedRuntimeType: null, detectedMainClass: null,
|
||||
uploadedAt: '2026-04-23T10:00:00Z',
|
||||
};
|
||||
|
||||
function renderDrawer(propOverrides: Partial<Parameters<typeof CheckpointDetailDrawer>[0]> = {}) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ThemeProvider>
|
||||
<CheckpointDetailDrawer
|
||||
open
|
||||
onClose={() => {}}
|
||||
deployment={baseDep}
|
||||
version={v}
|
||||
appSlug="my-app"
|
||||
envSlug="prod"
|
||||
onRestore={() => {}}
|
||||
{...propOverrides}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CheckpointDetailDrawer', () => {
|
||||
it('renders header with version + jar + status', () => {
|
||||
renderDrawer();
|
||||
expect(screen.getByText('v6')).toBeInTheDocument();
|
||||
expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
|
||||
expect(screen.getByText('STOPPED')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders meta line with createdBy', () => {
|
||||
renderDrawer();
|
||||
expect(screen.getByText(/alice/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Logs tab is selected by default', () => {
|
||||
renderDrawer();
|
||||
// Tabs from DS may render as buttons or tabs role — be lenient on the query
|
||||
const logsTab = screen.getByText(/^logs$/i);
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Restore when JAR is pruned', () => {
|
||||
renderDrawer({ version: undefined });
|
||||
expect(screen.getByRole('button', { name: /restore/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@cameleer/design-system';
|
||||
import { ConfigPanel } from './ConfigPanel';
|
||||
import type { DeploymentPageFormState } from '../hooks/useDeploymentPageState';
|
||||
|
||||
const baseDep: any = {
|
||||
id: 'd1', appId: 'a', appVersionId: 'v', environmentId: 'e',
|
||||
status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
|
||||
replicaStates: [], deployStage: null, containerId: null, containerName: null,
|
||||
errorMessage: null, deployedAt: '2026-04-23T10:35:00Z', stoppedAt: null,
|
||||
createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
|
||||
deployedConfigSnapshot: {
|
||||
jarVersionId: 'v', agentConfig: { engineLevel: 'COMPLETE' },
|
||||
containerConfig: { memoryLimitMb: 512, replicas: 3 }, sensitiveKeys: ['SECRET'],
|
||||
},
|
||||
};
|
||||
|
||||
function wrap(ui: React.ReactNode) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ThemeProvider>{ui}</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ConfigPanel', () => {
|
||||
it('renders sub-tabs in Snapshot mode', () => {
|
||||
wrap(<ConfigPanel deployment={baseDep} appSlug="a" envSlug="e" archived={false} />);
|
||||
// Use role=tab to avoid matching text inside rendered tab content (e.g. hint text)
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
const tabLabels = tabs.map((t) => t.textContent ?? '');
|
||||
expect(tabLabels.some((l) => /resources/i.test(l))).toBe(true);
|
||||
expect(tabLabels.some((l) => /monitoring/i.test(l))).toBe(true);
|
||||
expect(tabLabels.some((l) => /variables/i.test(l))).toBe(true);
|
||||
expect(tabLabels.some((l) => /sensitive keys/i.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the Snapshot/Diff toggle when archived', () => {
|
||||
wrap(<ConfigPanel deployment={baseDep} appSlug="a" envSlug="e" archived />);
|
||||
expect(screen.queryByText(/diff vs current/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the toggle when no currentForm provided', () => {
|
||||
wrap(<ConfigPanel deployment={baseDep} appSlug="a" envSlug="e" archived={false} />);
|
||||
expect(screen.queryByText(/diff vs current/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the toggle when currentForm is provided and not archived', () => {
|
||||
const currentForm: 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: '256', memoryReserve: '', cpuRequest: '500', cpuLimit: '',
|
||||
ports: [], appPort: '8080', replicas: '1', deployStrategy: 'blue-green',
|
||||
stripPrefix: true, sslOffloading: true, runtimeType: 'auto', customArgs: '',
|
||||
extraNetworks: [],
|
||||
},
|
||||
variables: { envVars: [] },
|
||||
sensitiveKeys: { sensitiveKeys: [] },
|
||||
};
|
||||
wrap(<ConfigPanel deployment={baseDep} appSlug="a" envSlug="e" archived={false} currentForm={currentForm} />);
|
||||
expect(screen.getByText(/snapshot/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/diff vs current/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty-state when no snapshot', () => {
|
||||
const noSnap = { ...baseDep, deployedConfigSnapshot: null };
|
||||
wrap(<ConfigPanel deployment={noSnap} appSlug="a" envSlug="e" archived={false} />);
|
||||
expect(screen.getByText(/no config snapshot/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { SegmentedTabs, Tabs } from '@cameleer/design-system';
|
||||
import type { Deployment } from '../../../../api/queries/admin/apps';
|
||||
import { MonitoringTab } from '../ConfigTabs/MonitoringTab';
|
||||
import { ResourcesTab } from '../ConfigTabs/ResourcesTab';
|
||||
import { VariablesTab } from '../ConfigTabs/VariablesTab';
|
||||
import { SensitiveKeysTab } from '../ConfigTabs/SensitiveKeysTab';
|
||||
import { defaultForm, type DeploymentPageFormState } from '../hooks/useDeploymentPageState';
|
||||
import { snapshotToForm } from './snapshotToForm';
|
||||
import { fieldDiff, type FieldDiff } from './diff';
|
||||
import styles from './CheckpointDetailDrawer.module.css';
|
||||
|
||||
interface Props {
|
||||
deployment: Deployment;
|
||||
appSlug: string;
|
||||
envSlug: string;
|
||||
archived: boolean;
|
||||
currentForm?: DeploymentPageFormState;
|
||||
}
|
||||
|
||||
type Mode = 'snapshot' | 'diff';
|
||||
type SubTab = 'monitoring' | 'resources' | 'variables' | 'sensitive';
|
||||
|
||||
export function ConfigPanel({ deployment, archived, currentForm }: Props) {
|
||||
const [mode, setMode] = useState<Mode>('snapshot');
|
||||
const [subTab, setSubTab] = useState<SubTab>('resources');
|
||||
|
||||
const snapshot = deployment.deployedConfigSnapshot;
|
||||
const snapshotForm = useMemo(
|
||||
() => (snapshot ? snapshotToForm(snapshot, defaultForm) : defaultForm),
|
||||
[snapshot],
|
||||
);
|
||||
|
||||
const diff: FieldDiff[] = useMemo(
|
||||
() => (mode === 'diff' && currentForm ? fieldDiff(snapshotForm, currentForm) : []),
|
||||
[mode, snapshotForm, currentForm],
|
||||
);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<SubTab, number> = { monitoring: 0, resources: 0, variables: 0, sensitive: 0 };
|
||||
for (const d of diff) {
|
||||
const root = d.path.split('.')[0].split('[')[0];
|
||||
if (root === 'monitoring') c.monitoring++;
|
||||
else if (root === 'resources') c.resources++;
|
||||
else if (root === 'variables') c.variables++;
|
||||
else if (root === 'sensitiveKeys') c.sensitive++;
|
||||
}
|
||||
return c;
|
||||
}, [diff]);
|
||||
|
||||
if (!snapshot) {
|
||||
return <div className={styles.emptyState}>This deployment has no config snapshot.</div>;
|
||||
}
|
||||
|
||||
const showToggle = !archived && !!currentForm;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showToggle && (
|
||||
<SegmentedTabs
|
||||
tabs={[
|
||||
{ value: 'snapshot', label: 'Snapshot' },
|
||||
{ value: 'diff', label: `Diff vs current${diff.length ? ` (${diff.length})` : ''}` },
|
||||
]}
|
||||
active={mode}
|
||||
onChange={(m) => setMode(m as Mode)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
active={subTab}
|
||||
onChange={(t) => setSubTab(t as SubTab)}
|
||||
tabs={[
|
||||
{ value: 'resources', label: `Resources${counts.resources ? ` (${counts.resources})` : ''}` },
|
||||
{ value: 'monitoring', label: `Monitoring${counts.monitoring ? ` (${counts.monitoring})` : ''}` },
|
||||
{ value: 'variables', label: `Variables${counts.variables ? ` (${counts.variables})` : ''}` },
|
||||
{ value: 'sensitive', label: `Sensitive Keys${counts.sensitive ? ` (${counts.sensitive})` : ''}` },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{mode === 'snapshot' && (
|
||||
<>
|
||||
{subTab === 'resources' && (
|
||||
<ResourcesTab value={snapshotForm.resources} onChange={() => {}} disabled isProd={false} />
|
||||
)}
|
||||
{subTab === 'monitoring' && (
|
||||
<MonitoringTab value={snapshotForm.monitoring} onChange={() => {}} disabled />
|
||||
)}
|
||||
{subTab === 'variables' && (
|
||||
<VariablesTab value={snapshotForm.variables} onChange={() => {}} disabled />
|
||||
)}
|
||||
{subTab === 'sensitive' && (
|
||||
<SensitiveKeysTab value={snapshotForm.sensitiveKeys} onChange={() => {}} disabled />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'diff' && (
|
||||
<DiffView diffs={diff.filter((d) => filterTab(d.path, subTab))} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function filterTab(path: string, tab: SubTab): boolean {
|
||||
const root = path.split('.')[0].split('[')[0];
|
||||
if (tab === 'sensitive') return root === 'sensitiveKeys';
|
||||
return root === tab;
|
||||
}
|
||||
|
||||
function DiffView({ diffs }: { diffs: FieldDiff[] }) {
|
||||
if (diffs.length === 0) {
|
||||
return <div className={styles.emptyState}>No differences in this section.</div>;
|
||||
}
|
||||
return (
|
||||
<div className={styles.diffList}>
|
||||
{diffs.map((d) => (
|
||||
<div key={d.path} className={styles.diffEntry}>
|
||||
<div className={styles.diffPath}>{d.path}</div>
|
||||
<div className={styles.diffRemoved}>- {JSON.stringify(d.oldValue)}</div>
|
||||
<div className={styles.diffAdded}>+ {JSON.stringify(d.newValue)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useInfiniteApplicationLogs } from '../../../../api/queries/logs';
|
||||
import type { Deployment } from '../../../../api/queries/admin/apps';
|
||||
import { instanceIdsFor } from './instance-id';
|
||||
import styles from './CheckpointDetailDrawer.module.css';
|
||||
|
||||
interface Props {
|
||||
deployment: Deployment;
|
||||
appSlug: string;
|
||||
envSlug: string;
|
||||
}
|
||||
|
||||
export function LogsPanel({ deployment, appSlug, envSlug }: Props) {
|
||||
const allInstanceIds = useMemo(
|
||||
() => instanceIdsFor(deployment, envSlug, appSlug),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[deployment.id, deployment.replicaStates, envSlug, appSlug]
|
||||
);
|
||||
|
||||
const [replicaFilter, setReplicaFilter] = useState<'all' | number>('all');
|
||||
const filteredInstanceIds = replicaFilter === 'all'
|
||||
? allInstanceIds
|
||||
: allInstanceIds.filter((_, i) => i === replicaFilter);
|
||||
|
||||
const logs = useInfiniteApplicationLogs({
|
||||
application: appSlug,
|
||||
instanceIds: filteredInstanceIds,
|
||||
isAtTop: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.filterBar}>
|
||||
<label>
|
||||
Replica:
|
||||
<select
|
||||
value={String(replicaFilter)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setReplicaFilter(v === 'all' ? 'all' : Number(v));
|
||||
}}
|
||||
>
|
||||
<option value="all">all ({deployment.replicaStates.length})</option>
|
||||
{deployment.replicaStates.map((_, i) => (
|
||||
<option key={i} value={i}>{i}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{logs.items.length === 0 && !logs.isLoading && (
|
||||
<div className={styles.emptyState}>No logs for this deployment.</div>
|
||||
)}
|
||||
{logs.items.map((entry, i) => (
|
||||
<div key={i} className={styles.logRow}>
|
||||
<span className={styles.logTimestamp}>{entry.timestamp}</span>{' '}
|
||||
<span>[{entry.level}]</span>{' '}
|
||||
<span>{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fieldDiff } from './diff';
|
||||
|
||||
describe('fieldDiff', () => {
|
||||
it('returns empty list for equal objects', () => {
|
||||
expect(fieldDiff({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects changed values', () => {
|
||||
expect(fieldDiff({ a: 1 }, { a: 2 })).toEqual([{ path: 'a', oldValue: 1, newValue: 2 }]);
|
||||
});
|
||||
|
||||
it('detects added keys', () => {
|
||||
expect(fieldDiff({}, { a: 1 })).toEqual([{ path: 'a', oldValue: undefined, newValue: 1 }]);
|
||||
});
|
||||
|
||||
it('detects removed keys', () => {
|
||||
expect(fieldDiff({ a: 1 }, {})).toEqual([{ path: 'a', oldValue: 1, newValue: undefined }]);
|
||||
});
|
||||
|
||||
it('walks nested objects', () => {
|
||||
const diff = fieldDiff({ resources: { mem: 512 } }, { resources: { mem: 1024 } });
|
||||
expect(diff).toEqual([{ path: 'resources.mem', oldValue: 512, newValue: 1024 }]);
|
||||
});
|
||||
|
||||
it('compares arrays by position', () => {
|
||||
const diff = fieldDiff({ keys: ['a', 'b'] }, { keys: ['a', 'c'] });
|
||||
expect(diff).toEqual([{ path: 'keys[1]', oldValue: 'b', newValue: 'c' }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface FieldDiff {
|
||||
path: string;
|
||||
oldValue: unknown;
|
||||
newValue: unknown;
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
export function fieldDiff(a: unknown, b: unknown, path = ''): FieldDiff[] {
|
||||
if (Object.is(a, b)) return [];
|
||||
|
||||
if (isPlainObject(a) && isPlainObject(b)) {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
const out: FieldDiff[] = [];
|
||||
for (const k of keys) {
|
||||
const sub = path ? `${path}.${k}` : k;
|
||||
out.push(...fieldDiff(a[k], b[k], sub));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
const len = Math.max(a.length, b.length);
|
||||
const out: FieldDiff[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
out.push(...fieldDiff(a[i], b[i], `${path}[${i}]`));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return [{ path: path || '(root)', oldValue: a, newValue: b }];
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { SideDrawer } from '../../../../components/SideDrawer';
|
||||
import { Tabs, Button, Badge } from '@cameleer/design-system';
|
||||
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
|
||||
import type { DeploymentPageFormState } from '../hooks/useDeploymentPageState';
|
||||
import { LogsPanel } from './LogsPanel';
|
||||
import { ConfigPanel } from './ConfigPanel';
|
||||
import { timeAgo } from '../../../../utils/format-utils';
|
||||
import styles from './CheckpointDetailDrawer.module.css';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
deployment: Deployment;
|
||||
version?: AppVersion;
|
||||
appSlug: string;
|
||||
envSlug: string;
|
||||
onRestore: (deploymentId: string) => void;
|
||||
currentForm?: DeploymentPageFormState;
|
||||
}
|
||||
|
||||
type TabId = 'logs' | 'config';
|
||||
|
||||
export function CheckpointDetailDrawer({
|
||||
open, onClose, deployment, version, appSlug, envSlug, onRestore, currentForm,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState<TabId>('logs');
|
||||
const archived = !version;
|
||||
|
||||
const title = (
|
||||
<div className={styles.titleRow}>
|
||||
<Badge label={version ? `v${version.version}` : '?'} color="auto" />
|
||||
<span className={styles.titleJar}>
|
||||
{version?.jarFilename ?? 'JAR pruned'}
|
||||
</span>
|
||||
<span className={styles.statusPill}>
|
||||
{deployment.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<div className={styles.footerRow}>
|
||||
<span className={styles.footerHint}>
|
||||
Restoring hydrates the form — you'll still need to Redeploy.
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={archived}
|
||||
title={archived ? 'JAR was pruned by the environment retention policy' : undefined}
|
||||
onClick={() => { onRestore(deployment.id); onClose(); }}
|
||||
>
|
||||
Restore this checkpoint
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SideDrawer open={open} onClose={onClose} title={title} size="lg" footer={footer}>
|
||||
<div className={styles.metaLine}>
|
||||
Deployed by <b>{deployment.createdBy ?? '—'}</b>
|
||||
{deployment.deployedAt && <> · {timeAgo(deployment.deployedAt)} ({deployment.deployedAt})</>}
|
||||
{' · '}Strategy: {deployment.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling'}
|
||||
{' · '}{deployment.replicaStates.length} replicas
|
||||
</div>
|
||||
<Tabs
|
||||
active={tab}
|
||||
onChange={(t) => setTab(t as TabId)}
|
||||
tabs={[
|
||||
{ value: 'logs', label: 'Logs' },
|
||||
{ value: 'config', label: 'Config' },
|
||||
]}
|
||||
/>
|
||||
<div className={styles.tabContent}>
|
||||
{tab === 'logs' && (
|
||||
<LogsPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug} />
|
||||
)}
|
||||
{tab === 'config' && (
|
||||
<ConfigPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug} archived={archived} currentForm={currentForm} />
|
||||
)}
|
||||
</div>
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { instanceIdsFor } from './instance-id';
|
||||
|
||||
describe('instanceIdsFor', () => {
|
||||
it('derives N instance_ids from replicaStates + deployment id', () => {
|
||||
expect(instanceIdsFor({
|
||||
id: 'aaa11111-2222-3333-4444-555555555555',
|
||||
replicaStates: [{ index: 0 }, { index: 1 }, { index: 2 }],
|
||||
} as any, 'prod', 'my-app')).toEqual([
|
||||
'prod-my-app-0-aaa11111',
|
||||
'prod-my-app-1-aaa11111',
|
||||
'prod-my-app-2-aaa11111',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when no replicas', () => {
|
||||
expect(instanceIdsFor({ id: 'x', replicaStates: [] } as any, 'e', 'a')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Deployment } from '../../../../api/queries/admin/apps';
|
||||
|
||||
export function instanceIdsFor(
|
||||
deployment: Pick<Deployment, 'id' | 'replicaStates'>,
|
||||
envSlug: string,
|
||||
appSlug: string,
|
||||
): string[] {
|
||||
const generation = deployment.id.replace(/-/g, '').slice(0, 8);
|
||||
return deployment.replicaStates.map((r) =>
|
||||
`${envSlug}-${appSlug}-${r.index}-${generation}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { snapshotToForm } from './snapshotToForm';
|
||||
import { defaultForm } from '../hooks/useDeploymentPageState';
|
||||
|
||||
describe('snapshotToForm', () => {
|
||||
it('overrides monitoring fields from agentConfig', () => {
|
||||
const snapshot = {
|
||||
jarVersionId: 'v1',
|
||||
agentConfig: { engineLevel: 'COMPLETE', samplingRate: 0.5, compressSuccess: true },
|
||||
containerConfig: {},
|
||||
sensitiveKeys: null,
|
||||
};
|
||||
const result = snapshotToForm(snapshot, defaultForm);
|
||||
expect(result.monitoring.engineLevel).toBe('COMPLETE');
|
||||
expect(result.monitoring.samplingRate).toBe('0.5');
|
||||
expect(result.monitoring.compressSuccess).toBe(true);
|
||||
// fields not in snapshot fall back to defaults
|
||||
expect(result.monitoring.payloadSize).toBe(defaultForm.monitoring.payloadSize);
|
||||
expect(result.monitoring.replayEnabled).toBe(defaultForm.monitoring.replayEnabled);
|
||||
});
|
||||
|
||||
it('overrides resources fields from containerConfig', () => {
|
||||
const snapshot = {
|
||||
jarVersionId: 'v1',
|
||||
agentConfig: null,
|
||||
containerConfig: {
|
||||
memoryLimitMb: 1024,
|
||||
replicas: 3,
|
||||
deploymentStrategy: 'rolling',
|
||||
customEnvVars: { FOO: 'bar', BAZ: 'qux' },
|
||||
exposedPorts: [8080, 9090],
|
||||
},
|
||||
sensitiveKeys: ['SECRET_KEY'],
|
||||
};
|
||||
const result = snapshotToForm(snapshot, defaultForm);
|
||||
expect(result.resources.memoryLimit).toBe('1024');
|
||||
expect(result.resources.replicas).toBe('3');
|
||||
expect(result.resources.deployStrategy).toBe('rolling');
|
||||
expect(result.resources.ports).toEqual([8080, 9090]);
|
||||
expect(result.variables.envVars).toEqual([
|
||||
{ key: 'FOO', value: 'bar' },
|
||||
{ key: 'BAZ', value: 'qux' },
|
||||
]);
|
||||
expect(result.sensitiveKeys.sensitiveKeys).toEqual(['SECRET_KEY']);
|
||||
});
|
||||
|
||||
it('falls back to defaults for missing fields', () => {
|
||||
const snapshot = {
|
||||
jarVersionId: 'v1',
|
||||
agentConfig: null,
|
||||
containerConfig: {},
|
||||
sensitiveKeys: null,
|
||||
};
|
||||
const result = snapshotToForm(snapshot, defaultForm);
|
||||
expect(result.resources.memoryLimit).toBe(defaultForm.resources.memoryLimit);
|
||||
expect(result.resources.cpuRequest).toBe(defaultForm.resources.cpuRequest);
|
||||
expect(result.variables.envVars).toEqual(defaultForm.variables.envVars);
|
||||
expect(result.sensitiveKeys.sensitiveKeys).toEqual(defaultForm.sensitiveKeys.sensitiveKeys);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Deployment } from '../../../../api/queries/admin/apps';
|
||||
import type { DeploymentPageFormState } from '../hooks/useDeploymentPageState';
|
||||
|
||||
type DeployedConfigSnapshot = NonNullable<Deployment['deployedConfigSnapshot']>;
|
||||
|
||||
/**
|
||||
* Maps a deployment snapshot to the page's form-state shape.
|
||||
* Used by both the live page's "restore" action and the read-only ConfigPanel
|
||||
* in the checkpoint detail drawer.
|
||||
*
|
||||
* Fields not represented in the snapshot fall back to `defaults`.
|
||||
*/
|
||||
export function snapshotToForm(
|
||||
snapshot: DeployedConfigSnapshot,
|
||||
defaults: DeploymentPageFormState,
|
||||
): DeploymentPageFormState {
|
||||
const a = snapshot.agentConfig ?? {};
|
||||
const c = snapshot.containerConfig ?? {};
|
||||
return {
|
||||
monitoring: {
|
||||
engineLevel: (a.engineLevel as string) ?? defaults.monitoring.engineLevel,
|
||||
payloadCaptureMode: (a.payloadCaptureMode as string) ?? defaults.monitoring.payloadCaptureMode,
|
||||
payloadSize: defaults.monitoring.payloadSize,
|
||||
payloadUnit: defaults.monitoring.payloadUnit,
|
||||
applicationLogLevel: (a.applicationLogLevel as string) ?? defaults.monitoring.applicationLogLevel,
|
||||
agentLogLevel: (a.agentLogLevel as string) ?? defaults.monitoring.agentLogLevel,
|
||||
metricsEnabled: (a.metricsEnabled as boolean) ?? defaults.monitoring.metricsEnabled,
|
||||
metricsInterval: defaults.monitoring.metricsInterval,
|
||||
samplingRate: a.samplingRate !== undefined ? String(a.samplingRate) : defaults.monitoring.samplingRate,
|
||||
compressSuccess: (a.compressSuccess as boolean) ?? defaults.monitoring.compressSuccess,
|
||||
replayEnabled: defaults.monitoring.replayEnabled,
|
||||
routeControlEnabled: defaults.monitoring.routeControlEnabled,
|
||||
},
|
||||
resources: {
|
||||
memoryLimit: c.memoryLimitMb !== undefined ? String(c.memoryLimitMb) : defaults.resources.memoryLimit,
|
||||
memoryReserve: c.memoryReserveMb != null ? String(c.memoryReserveMb) : defaults.resources.memoryReserve,
|
||||
cpuRequest: c.cpuRequest !== undefined ? String(c.cpuRequest) : defaults.resources.cpuRequest,
|
||||
cpuLimit: c.cpuLimit != null ? String(c.cpuLimit) : defaults.resources.cpuLimit,
|
||||
ports: Array.isArray(c.exposedPorts) ? (c.exposedPorts as number[]) : defaults.resources.ports,
|
||||
appPort: c.appPort !== undefined ? String(c.appPort) : defaults.resources.appPort,
|
||||
replicas: c.replicas !== undefined ? String(c.replicas) : defaults.resources.replicas,
|
||||
deployStrategy: (c.deploymentStrategy as string) ?? defaults.resources.deployStrategy,
|
||||
stripPrefix: c.stripPathPrefix !== undefined ? (c.stripPathPrefix as boolean) : defaults.resources.stripPrefix,
|
||||
sslOffloading: c.sslOffloading !== undefined ? (c.sslOffloading as boolean) : defaults.resources.sslOffloading,
|
||||
runtimeType: (c.runtimeType as string) ?? defaults.resources.runtimeType,
|
||||
customArgs: c.customArgs !== undefined ? String(c.customArgs ?? '') : defaults.resources.customArgs,
|
||||
extraNetworks: Array.isArray(c.extraNetworks) ? (c.extraNetworks as string[]) : defaults.resources.extraNetworks,
|
||||
},
|
||||
variables: {
|
||||
envVars: c.customEnvVars
|
||||
? Object.entries(c.customEnvVars as Record<string, string>).map(([key, value]) => ({ key, value }))
|
||||
: defaults.variables.envVars,
|
||||
},
|
||||
sensitiveKeys: {
|
||||
sensitiveKeys: Array.isArray(snapshot.sensitiveKeys)
|
||||
? snapshot.sensitiveKeys
|
||||
: Array.isArray(a.sensitiveKeys)
|
||||
? (a.sensitiveKeys as string[])
|
||||
: defaults.sensitiveKeys.sensitiveKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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]));
|
||||
|
||||
// Any deployment that captured a snapshot is restorable — that covers RUNNING,
|
||||
// DEGRADED, and STOPPED (blue/green swap previous, user-stopped). Exclude the
|
||||
// currently-running one and anything without a snapshot (FAILED, STARTING).
|
||||
const checkpoints = deployments
|
||||
.filter((d) => d.deployedConfigSnapshot && 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,92 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '@cameleer/design-system';
|
||||
import type { ReactNode } from 'react';
|
||||
import { CheckpointsTable } from './CheckpointsTable';
|
||||
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
|
||||
|
||||
function wrap(ui: ReactNode) {
|
||||
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
}
|
||||
|
||||
const v6: AppVersion = {
|
||||
id: 'v6id', appId: 'a', version: 6, jarPath: '/j', jarChecksum: 'c',
|
||||
jarFilename: 'my-app-1.2.3.jar', jarSizeBytes: 1, detectedRuntimeType: null,
|
||||
detectedMainClass: null, uploadedAt: '2026-04-23T10:00:00Z',
|
||||
};
|
||||
|
||||
const stoppedDep: Deployment = {
|
||||
id: 'd1', appId: 'a', appVersionId: 'v6id', environmentId: 'e',
|
||||
status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
|
||||
replicaStates: [{ index: 0, containerId: 'c', containerName: 'n', status: 'STOPPED' }],
|
||||
deployStage: null, containerId: null, containerName: null, errorMessage: null,
|
||||
deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
|
||||
createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
|
||||
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
|
||||
};
|
||||
|
||||
describe('CheckpointsTable', () => {
|
||||
it('renders a row per checkpoint with version, jar, deployer', () => {
|
||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||
expect(screen.getByText('v6')).toBeInTheDocument();
|
||||
expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('row click invokes onSelect with deploymentId', () => {
|
||||
const onSelect = vi.fn();
|
||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
|
||||
// Use the version text as the row anchor (most stable selector)
|
||||
fireEvent.click(screen.getByText('v6').closest('tr')!);
|
||||
expect(onSelect).toHaveBeenCalledWith('d1');
|
||||
});
|
||||
|
||||
it('renders em-dash for null createdBy', () => {
|
||||
const noActor = { ...stoppedDep, createdBy: null };
|
||||
wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks pruned-JAR rows as archived', () => {
|
||||
const pruned = { ...stoppedDep, appVersionId: 'unknown' };
|
||||
wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
|
||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||
expect(screen.getByText(/archived/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes the currently-running deployment', () => {
|
||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
|
||||
expect(screen.queryByText('v6')).toBeNull();
|
||||
expect(screen.getByText(/no past deployments/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('caps visible rows at jarRetentionCount and shows expander', () => {
|
||||
const many: Deployment[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
|
||||
}));
|
||||
wrap(<CheckpointsTable deployments={many} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
|
||||
// 3 visible rows + 1 header row = 4 rows max in the table
|
||||
expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4);
|
||||
expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all rows when jarRetentionCount >= total', () => {
|
||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
|
||||
expect(screen.queryByText(/show older/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to default cap of 10 when jarRetentionCount is 0 or null', () => {
|
||||
const fifteen: Deployment[] = Array.from({ length: 15 }, (_, i) => ({
|
||||
...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
|
||||
}));
|
||||
wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
|
||||
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
112
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx
Normal file
112
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { 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';
|
||||
|
||||
const FALLBACK_CAP = 10;
|
||||
|
||||
interface CheckpointsTableProps {
|
||||
deployments: Deployment[];
|
||||
versions: AppVersion[];
|
||||
currentDeploymentId: string | null;
|
||||
jarRetentionCount: number | null;
|
||||
onSelect: (deploymentId: string) => void;
|
||||
}
|
||||
|
||||
export function CheckpointsTable({
|
||||
deployments,
|
||||
versions,
|
||||
currentDeploymentId,
|
||||
jarRetentionCount,
|
||||
onSelect,
|
||||
}: CheckpointsTableProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
||||
|
||||
const checkpoints = deployments
|
||||
.filter((d) => d.deployedConfigSnapshot && d.id !== currentDeploymentId)
|
||||
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
|
||||
|
||||
if (checkpoints.length === 0) {
|
||||
return <div className={styles.checkpointEmpty}>No past deployments yet.</div>;
|
||||
}
|
||||
|
||||
const cap = jarRetentionCount && jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
|
||||
const visible = expanded ? checkpoints : checkpoints.slice(0, cap);
|
||||
const hidden = checkpoints.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className={styles.checkpointsTable}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>JAR</th>
|
||||
<th>Deployed by</th>
|
||||
<th>Deployed</th>
|
||||
<th>Strategy</th>
|
||||
<th>Outcome</th>
|
||||
<th aria-label="open"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((d) => {
|
||||
const v = versionMap.get(d.appVersionId);
|
||||
const archived = !v;
|
||||
const strategyLabel =
|
||||
d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
|
||||
return (
|
||||
<tr
|
||||
key={d.id}
|
||||
className={archived ? styles.checkpointArchived : undefined}
|
||||
onClick={() => onSelect(d.id)}
|
||||
>
|
||||
<td>
|
||||
<Badge label={v ? `v${v.version}` : '?'} color="auto" />
|
||||
</td>
|
||||
<td className={styles.jarCell}>
|
||||
{v ? (
|
||||
<span className={styles.jarName}>{v.jarFilename}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.jarStrike}>JAR pruned</span>
|
||||
<div className={styles.archivedHint}>archived — JAR pruned</div>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{d.createdBy ?? <span className={styles.muted}>—</span>}
|
||||
</td>
|
||||
<td>
|
||||
{d.deployedAt && timeAgo(d.deployedAt)}
|
||||
<div className={styles.isoSubline}>{d.deployedAt}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.strategyPill}>{strategyLabel}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`${styles.outcomePill} ${styles[`outcome-${d.status}` as keyof typeof styles] || ''}`}
|
||||
>
|
||||
{d.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className={styles.chevron}>›</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{hidden > 0 && !expanded && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showOlderBtn}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
Show older ({hidden}) — archived, postmortem only
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@cameleer/design-system';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MonitoringTab } from './MonitoringTab';
|
||||
import { ResourcesTab } from './ResourcesTab';
|
||||
import { VariablesTab } from './VariablesTab';
|
||||
import { SensitiveKeysTab } from './SensitiveKeysTab';
|
||||
import type {
|
||||
MonitoringFormState,
|
||||
ResourcesFormState,
|
||||
VariablesFormState,
|
||||
SensitiveKeysFormState,
|
||||
} from '../hooks/useDeploymentPageState';
|
||||
|
||||
function wrap(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>{ui}</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultMonitoring: MonitoringFormState = {
|
||||
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,
|
||||
};
|
||||
|
||||
const defaultResources: ResourcesFormState = {
|
||||
memoryLimit: '512',
|
||||
memoryReserve: '',
|
||||
cpuRequest: '500',
|
||||
cpuLimit: '',
|
||||
ports: [],
|
||||
appPort: '8080',
|
||||
replicas: '1',
|
||||
deployStrategy: 'blue-green',
|
||||
stripPrefix: true,
|
||||
sslOffloading: true,
|
||||
runtimeType: 'auto',
|
||||
customArgs: '',
|
||||
extraNetworks: [],
|
||||
};
|
||||
|
||||
const defaultVariables: VariablesFormState = {
|
||||
envVars: [],
|
||||
};
|
||||
|
||||
const defaultSensitiveKeys: SensitiveKeysFormState = {
|
||||
sensitiveKeys: [],
|
||||
};
|
||||
|
||||
describe('ConfigTabs disabled contract', () => {
|
||||
describe('MonitoringTab', () => {
|
||||
it('disables all inputs and selects when disabled=true', () => {
|
||||
wrap(
|
||||
<MonitoringTab
|
||||
value={defaultMonitoring}
|
||||
onChange={vi.fn()}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textboxes = screen.queryAllByRole('textbox');
|
||||
const comboboxes = screen.queryAllByRole('combobox');
|
||||
|
||||
expect(textboxes.length).toBeGreaterThan(0);
|
||||
expect(comboboxes.length).toBeGreaterThan(0);
|
||||
|
||||
textboxes.forEach((box) => {
|
||||
expect(box).toBeDisabled();
|
||||
});
|
||||
comboboxes.forEach((cb) => {
|
||||
expect(cb).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables inputs when disabled=false', () => {
|
||||
wrap(
|
||||
<MonitoringTab
|
||||
value={defaultMonitoring}
|
||||
onChange={vi.fn()}
|
||||
disabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textboxes = screen.queryAllByRole('textbox');
|
||||
expect(textboxes.length).toBeGreaterThan(0);
|
||||
textboxes.forEach((box) => {
|
||||
expect(box).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ResourcesTab', () => {
|
||||
it('disables all inputs and selects when disabled=true', () => {
|
||||
wrap(
|
||||
<ResourcesTab
|
||||
value={defaultResources}
|
||||
onChange={vi.fn()}
|
||||
disabled={true}
|
||||
isProd={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textboxes = screen.queryAllByRole('textbox');
|
||||
const comboboxes = screen.queryAllByRole('combobox');
|
||||
|
||||
expect(textboxes.length).toBeGreaterThan(0);
|
||||
expect(comboboxes.length).toBeGreaterThan(0);
|
||||
|
||||
textboxes.forEach((box) => {
|
||||
expect(box).toBeDisabled();
|
||||
});
|
||||
comboboxes.forEach((cb) => {
|
||||
expect(cb).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('enables inputs when disabled=false', () => {
|
||||
wrap(
|
||||
<ResourcesTab
|
||||
value={defaultResources}
|
||||
onChange={vi.fn()}
|
||||
disabled={false}
|
||||
isProd={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textboxes = screen.queryAllByRole('textbox');
|
||||
expect(textboxes.length).toBeGreaterThan(0);
|
||||
textboxes.forEach((box) => {
|
||||
expect(box).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('VariablesTab', () => {
|
||||
it('disables add and import buttons when disabled=true', () => {
|
||||
wrap(
|
||||
<VariablesTab
|
||||
value={defaultVariables}
|
||||
onChange={vi.fn()}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
// When disabled, import and clear/add buttons should be disabled
|
||||
// Copy button is always enabled by design
|
||||
const importBtn = buttons.find((btn) => btn.title?.includes('Import'));
|
||||
const clearBtn = buttons.find((btn) => btn.title?.includes('Clear'));
|
||||
expect(importBtn).toBeDisabled();
|
||||
expect(clearBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables inputs when disabled=false', () => {
|
||||
wrap(
|
||||
<VariablesTab
|
||||
value={defaultVariables}
|
||||
onChange={vi.fn()}
|
||||
disabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
const importBtn = buttons.find((btn) => btn.title?.includes('Import'));
|
||||
const clearBtn = buttons.find((btn) => btn.title?.includes('Clear'));
|
||||
expect(importBtn).not.toBeDisabled();
|
||||
expect(clearBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('with envVars populated, disables textboxes when disabled=true', () => {
|
||||
const varState: VariablesFormState = {
|
||||
envVars: [
|
||||
{ key: 'TEST_VAR', value: 'test-value' },
|
||||
{ key: 'ANOTHER', value: 'value' },
|
||||
],
|
||||
};
|
||||
|
||||
wrap(
|
||||
<VariablesTab
|
||||
value={varState}
|
||||
onChange={vi.fn()}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textboxes = screen.queryAllByRole('textbox');
|
||||
expect(textboxes.length).toBeGreaterThan(0);
|
||||
textboxes.forEach((box) => {
|
||||
expect(box).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SensitiveKeysTab', () => {
|
||||
it('disables input and add button when disabled=true', () => {
|
||||
wrap(
|
||||
<SensitiveKeysTab
|
||||
value={defaultSensitiveKeys}
|
||||
onChange={vi.fn()}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textboxes = screen.queryAllByRole('textbox');
|
||||
expect(textboxes.length).toBeGreaterThan(0);
|
||||
textboxes.forEach((box) => {
|
||||
expect(box).toBeDisabled();
|
||||
});
|
||||
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
const addButton = buttons.find((btn) => btn.textContent?.includes('Add'));
|
||||
expect(addButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables input when disabled=false', () => {
|
||||
wrap(
|
||||
<SensitiveKeysTab
|
||||
value={defaultSensitiveKeys}
|
||||
onChange={vi.fn()}
|
||||
disabled={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textboxes = screen.queryAllByRole('textbox');
|
||||
expect(textboxes.length).toBeGreaterThan(0);
|
||||
textboxes.forEach((box) => {
|
||||
expect(box).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('with sensitive keys, disables remove tag buttons when disabled=true', () => {
|
||||
const skState: SensitiveKeysFormState = {
|
||||
sensitiveKeys: ['Authorization', 'X-API-Key', '*password*'],
|
||||
};
|
||||
|
||||
wrap(
|
||||
<SensitiveKeysTab
|
||||
value={skState}
|
||||
onChange={vi.fn()}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Tags have remove buttons that should be disabled
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
// Check that at least some buttons exist (the tag removers)
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ export interface DeploymentPageFormState {
|
||||
sensitiveKeys: SensitiveKeysFormState;
|
||||
}
|
||||
|
||||
const defaultForm: DeploymentPageFormState = {
|
||||
export const defaultForm: DeploymentPageFormState = {
|
||||
monitoring: {
|
||||
engineLevel: 'REGULAR',
|
||||
payloadCaptureMode: 'BOTH',
|
||||
|
||||
@@ -20,7 +20,8 @@ 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 { CheckpointsTable } from './CheckpointsTable';
|
||||
import { CheckpointDetailDrawer } from './CheckpointDetailDrawer';
|
||||
import { MonitoringTab } from './ConfigTabs/MonitoringTab';
|
||||
import { ResourcesTab } from './ConfigTabs/ResourcesTab';
|
||||
import { VariablesTab } from './ConfigTabs/VariablesTab';
|
||||
@@ -31,6 +32,7 @@ import { DeploymentTab } from './DeploymentTab/DeploymentTab';
|
||||
import { PrimaryActionButton, computeMode } from './PrimaryActionButton';
|
||||
import { useDeploymentPageState } from './hooks/useDeploymentPageState';
|
||||
import { useFormDirty } from './hooks/useFormDirty';
|
||||
import { snapshotToForm } from './CheckpointDetailDrawer/snapshotToForm';
|
||||
import { useUnsavedChangesBlocker } from './hooks/useUnsavedChangesBlocker';
|
||||
import { deriveAppName } from './utils/deriveAppName';
|
||||
import styles from './AppDeploymentPage.module.css';
|
||||
@@ -89,6 +91,7 @@ export default function AppDeploymentPage() {
|
||||
const [tab, setTab] = useState<TabKey>('monitoring');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [stopTarget, setStopTarget] = useState<string | null>(null);
|
||||
const [selectedCheckpointId, setSelectedCheckpointId] = useState<string | null>(null);
|
||||
const lastDerivedRef = useRef<string>('');
|
||||
|
||||
// Initialize name from app when it loads
|
||||
@@ -337,53 +340,7 @@ export default function AppDeploymentPage() {
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
setForm((prev) => snapshotToForm(snap, prev));
|
||||
}
|
||||
|
||||
// ── Primary button enabled logic ───────────────────────────────────
|
||||
@@ -393,6 +350,15 @@ export default function AppDeploymentPage() {
|
||||
return true; // redeploy always enabled
|
||||
})();
|
||||
|
||||
// Checkpoint drawer derivations
|
||||
const jarRetentionCount = env?.jarRetentionCount ?? null;
|
||||
const selectedDep = selectedCheckpointId
|
||||
? deployments.find((d) => d.id === selectedCheckpointId) ?? null
|
||||
: null;
|
||||
const selectedDepVersion = selectedDep
|
||||
? versions.find((v) => v.id === selectedDep.appVersionId)
|
||||
: undefined;
|
||||
|
||||
// ── Loading guard ──────────────────────────────────────────────────
|
||||
if (envLoading || appsLoading) return <PageLoader />;
|
||||
if (!env) return <div>Select an environment first.</div>;
|
||||
@@ -480,12 +446,30 @@ export default function AppDeploymentPage() {
|
||||
deploying={deploymentInProgress}
|
||||
>
|
||||
{app && (
|
||||
<Checkpoints
|
||||
deployments={deployments}
|
||||
versions={versions}
|
||||
currentDeploymentId={currentDeployment?.id ?? null}
|
||||
onRestore={handleRestore}
|
||||
/>
|
||||
<>
|
||||
<CheckpointsTable
|
||||
deployments={deployments}
|
||||
versions={versions}
|
||||
currentDeploymentId={currentDeployment?.id ?? null}
|
||||
jarRetentionCount={jarRetentionCount}
|
||||
onSelect={setSelectedCheckpointId}
|
||||
/>
|
||||
{selectedDep && (
|
||||
<CheckpointDetailDrawer
|
||||
open
|
||||
onClose={() => setSelectedCheckpointId(null)}
|
||||
deployment={selectedDep}
|
||||
version={selectedDepVersion}
|
||||
appSlug={app.slug}
|
||||
envSlug={selectedEnv ?? ''}
|
||||
currentForm={form}
|
||||
onRestore={(deploymentId) => {
|
||||
handleRestore(deploymentId);
|
||||
setSelectedCheckpointId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</IdentitySection>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user