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