Compare commits

...

24 Commits

Author SHA1 Message Date
hsiegeln
e36c82c4db test(deploy): scope schema ITs to current_schema + clear deployments FK in teardown
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m59s
CI / docker (push) Successful in 1m5s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
Surface from the Task 0 testcontainers.reuse enable: when the same Postgres
container is reused across `mvn verify` runs, Flyway migrates both `public`
and `tenant_default` schemas (the app.yml default URL uses
?currentSchema=tenant_default; AbstractPostgresIT overrides to public).
Schema-introspection assertions saw duplicate rows/indexes/enums.

Plus: OutboundConnectionAdminControllerIT's AfterEach couldn't delete its
test users because sibling deployment ITs (Task 4) left deployments.created_by
references — FK blocks the DELETE. Clear referencing deployments first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:06:56 +02:00
hsiegeln
d192f6b57c docs(rules): deployment audit + checkpoints table + SideDrawer + log instanceIds
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:51:22 +02:00
hsiegeln
fe1681e6e8 ui(audit): surface DEPLOYMENT category in admin filter dropdown 2026-04-23 13:49:31 +02:00
hsiegeln
571f85cd0f feat(ui): wire CheckpointsTable + Drawer into IdentitySection (delete old Checkpoints)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:46:31 +02:00
hsiegeln
25d2a3014a refactor(ui): DiffView CSS module + drop duplicate snapshot type 2026-04-23 13:43:15 +02:00
hsiegeln
1a97e2146e feat(ui): ConfigPanel snapshot+diff modes; extract snapshotToForm helper
- Extract inline handleRestore mapping into snapshotToForm(snapshot, defaults) helper
- Export defaultForm from useDeploymentPageState for use in ConfigPanel
- Replace ConfigPanel stub with real read-only snapshot renderer + Snapshot/Diff toggle
- Add fieldDiff deep-equal field-walk helper with nested object + array support
- Forward optional currentForm prop through CheckpointDetailDrawer to ConfigPanel
- 13 new tests across diff.test.ts, snapshotToForm.test.ts, ConfigPanel.test.tsx (all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:38:22 +02:00
hsiegeln
d1150e5dd8 refactor(ui): drawer CSS module + narrow LogsPanel memo deps
Extract 14 inline style blocks from CheckpointDetailDrawer index.tsx and
LogsPanel.tsx into a shared CSS module using DS CSS variables throughout.
Narrow the LogsPanel useMemo dep array from the full deployment object to
deployment.id + deployment.replicaStates to prevent spurious query
invalidation on every TanStack Query poll.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:30:48 +02:00
hsiegeln
b0995d84bc feat(ui): CheckpointDetailDrawer container + LogsPanel
Adds the CheckpointDetailDrawer with Logs/Config tabs. LogsPanel scopes
logs to a deployment's replicas via instanceIds derived from replicaStates
+ generation suffix. Stub ConfigPanel placeholder for Task 11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:25:55 +02:00
hsiegeln
9756a20223 fix(ui): dim archived checkpoint rows + safer outcome class lookup + cleaner cap 2026-04-23 13:19:06 +02:00
hsiegeln
1b4b522233 feat(ui): CheckpointsTable component (replaces row list)
Full-width table with Version / JAR / Deployed-by / Deployed / Strategy /
Outcome columns, pagination cap (jarRetentionCount, default 10), pruned-JAR
archived state, empty state, and row-click onSelect handler. 8/8 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:15:30 +02:00
hsiegeln
48217e0034 test(deploy): contract test — ConfigTabs disabled gates all inputs 2026-04-23 13:10:17 +02:00
hsiegeln
c3ecff9d45 feat(ui): add SideDrawer component (project-local)
Right-sliding panel with portal, ESC + backdrop close, sticky header/footer,
three width sizes (md/lg/xl), transparent click-blocking backdrop, and DS token colors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:05:36 +02:00
hsiegeln
07099357af chore(api): regenerate UI types — Deployment.createdBy + logs instanceIds
- Fetched fresh openapi.json from local backend (Tasks 3-5 changes)
- Regenerated schema.d.ts via openapi-typescript
- Added createdBy: string | null to Deployment interface in apps.ts
- Added instanceIds?: string[] to UseInfiniteApplicationLogsArgs with sort/serialize/queryKey/URLSearchParams wiring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:00:16 +02:00
hsiegeln
ed0e616109 refactor(logs): drop dead null guards on instanceIds filter (record normalizes) 2026-04-23 12:52:18 +02:00
hsiegeln
382e1801a7 feat(logs): add instanceIds multi-value filter to /logs endpoint
Adds List<String> instanceIds to LogSearchRequest (null-normalized to
List.of() in compact ctor) and generates an IN clause in both
ClickHouseLogStore.search() and countLogs(), mirroring the existing
sources pattern. LogQueryController parses ?instanceIds= as a
comma-split list. All existing LogSearchRequest call sites updated.
New ClickHouseLogStoreInstanceIdsIT covers: multi-value filter, empty
filter (all rows), null filter (all rows), single-value filter, and
coexistence with the singular instanceId field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:41:09 +02:00
hsiegeln
2312a7304d fix(deploy): widen promote FAILURE audit detail + clean up test envs 2026-04-23 12:29:46 +02:00
hsiegeln
47d5611462 feat(audit): audit deploy/stop/promote with DEPLOYMENT category
Wires AuditService and AppVersionRepository into DeploymentController.
Replaces null createdBy placeholder with currentUserId() on createDeployment/promote.
Adds audit log entries (SUCCESS + FAILURE) for deploy_app, stop_deployment,
and promote_deployment actions. Fixes FK violations in affected ITs by
seeding the test-operator and alice users into the users table before deploy calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:24:27 +02:00
hsiegeln
9043dc00b0 test(deploy): clean up seeded users + document null createdBy placeholder
Fix Issue 1: Add @AfterEach cleanup for alice/bob users in PostgresDeploymentRepositoryCreatedByIT to prevent test leakage (FK order: deployments -> app_versions -> apps, then users).

Fix Issue 2: Add comment at first create(..., null) call site in PostgresDeploymentRepositoryIT documenting the null placeholder for pre-V4 rows where createdBy is nullable.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-23 12:10:21 +02:00
hsiegeln
a141e99a07 feat(deploy): cascade createdBy through Deployment record + service + repo
Appends String createdBy to the Deployment record (after createdAt), updates
both with-er methods to pass it through, threads the parameter through
DeploymentRepository.create, DeploymentService.createDeployment/promote, and
PostgresDeploymentRepository (INSERT + SELECT_COLS + mapRow). DeploymentController
passes null as placeholder (Task 4 will resolve from SecurityContextHolder).
Covers with PostgresDeploymentRepositoryCreatedByIT verifying round-trip via
both createDeployment and promote.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:04:15 +02:00
hsiegeln
15d00f039c feat(audit): add DEPLOYMENT audit category 2026-04-23 11:51:28 +02:00
hsiegeln
064c302073 docs(plan): V2 → V4 migration filename (V2/V3 already taken) 2026-04-23 11:49:12 +02:00
hsiegeln
35748ea7a1 feat(deploy): V4 migration — add created_by to deployments 2026-04-23 11:44:05 +02:00
hsiegeln
e558494f8d plan(deploy): checkpoints table redesign + audit gap
15 tasks across 5 phases (backend foundation → SideDrawer →
ConfigTabs readOnly → CheckpointsTable + DetailDrawer → polish).
TDD throughout with per-task commits. Backend phase ships
independently to close the audit gap as quickly as possible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:39:11 +02:00
hsiegeln
1f0ab002d6 spec(deploy): checkpoints table redesign + deployment audit gap
Replaces the cramped Checkpoints disclosure with a real DataTable + a
side drawer (Logs / Config with snapshot/diff modes) and closes the
audit-log gap discovered in DeploymentController (deploy/stop/promote
currently make zero auditService.log calls).

Cap visible checkpoints at Environment.jarRetentionCount — beyond that,
JARs are pruned and rows aren't restorable. Logs scoped per-deployment
via instance_id IN (...) computed from replicaStates (no time window
needed). Compare folded into Config as a view-mode toggle. Two-phase
rollout (backend ships first to close the audit gap immediately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:31:50 +02:00
59 changed files with 4748 additions and 188 deletions

View File

@@ -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).

View File

@@ -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)

View File

@@ -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

View File

@@ -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);
});

View File

@@ -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) {}
}

View File

@@ -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);

View File

@@ -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'] = ?)" +

View File

@@ -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")
);
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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");
}
// -----------------------------------------------------------------------

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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')");
}

View File

@@ -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);

View File

@@ -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");
}
// -----------------------------------------------------------------------

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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();
}
}

View File

@@ -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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -47,6 +47,7 @@ export interface Deployment {
containerConfig: Record<string, unknown>;
sensitiveKeys: string[] | null;
} | null;
createdBy: string | null;
}
/**

View File

@@ -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);

View File

@@ -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;

View 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); }
}

View 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();
});
});

View 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
);
}

View File

@@ -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[]) {

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View File

@@ -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:&nbsp;
<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>
);
}

View File

@@ -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' }]);
});
});

View File

@@ -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 }];
}

View File

@@ -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>
);
}

View File

@@ -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([]);
});
});

View File

@@ -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}`
);
}

View File

@@ -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);
});
});

View File

@@ -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,
},
};
}

View File

@@ -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>
);
}

View File

@@ -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();
});
});

View 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>
);
}

View File

@@ -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);
});
});
});

View File

@@ -49,7 +49,7 @@ export interface DeploymentPageFormState {
sensitiveKeys: SensitiveKeysFormState;
}
const defaultForm: DeploymentPageFormState = {
export const defaultForm: DeploymentPageFormState = {
monitoring: {
engineLevel: 'REGULAR',
payloadCaptureMode: 'BOTH',

View File

@@ -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>