Compare commits

44 Commits

Author SHA1 Message Date
hsiegeln
837e5d46f5 docs(deploy): session handoff + refresh GitNexus index stats
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m9s
CI / docker (push) Successful in 1m17s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
Handoff summarises the unified deployment page implementation (spec,
plan, 43 commits, opened Gitea issues #147 and #148), open gaps, and
recommended kickoff for the next session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:17:26 +02:00
hsiegeln
0a71bca7b8 fix(deploy): redeploy button after save, disable save when clean, success toast
- Bug 1: default serverDirtyAgainstDeploy to true (not false) while
  dirtyState query is loading — prevents the button showing 'Save'
  instead of 'Redeploy' on apps with no successful deployment yet.
- Bug 2: normalize samplingRate from server as '<n>.0' when the value
  is a whole-number float so serverState matches form after save,
  eliminating spurious dirty detection that kept Save enabled.
- Bug 3: add success toast after handleSave completes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 00:06:00 +02:00
hsiegeln
b7b6bd2a96 ui(deploy): port missing agent-config fields, var-view switcher, env pill, tab seam
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:45:19 +02:00
hsiegeln
d33c039a17 fix(deploy): address final review — sensitiveKeys snapshot, dirty scrubbing, transition race, refetch invalidations
- Issue 1: add List<String> sensitiveKeys as 4th field to DeploymentConfigSnapshot; populate
  from agentConfig.getSensitiveKeys() in DeploymentExecutor; handleRestore hydrates from
  snap.sensitiveKeys directly; Deployment type in apps.ts gains sensitiveKeys field
- Issue 2: after createApp succeeds, refetchQueries(['apps', envSlug]) before navigate so the
  new app is in cache before the router renders the deployed view (eliminates transient Save-
  disabled flash)
- Issue 3: useDeploymentPageState useEffect now uses prevServerStateRef to detect local edits;
  background refetches only overwrite form when no local changes are present
- Issue 5: handleRedeploy invalidates dirty-state + versions queries after createDeployment
  resolves; handleSave invalidates dirty-state after staged save
- Issue 10: DirtyStateCalculator strips volatile agentConfig keys (version, updatedAt, updatedBy,
  environment, application) before JSON comparison via scrubAgentConfig(); adds
  versionBumpDoesNotMarkDirty test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:29:01 +02:00
hsiegeln
6d5ce60608 docs(rules): document ?apply flag + snapshot column in app-classes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:17:25 +02:00
hsiegeln
d595746830 docs(rules): update ui.md Deployments bullet for unified deployment page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:16:59 +02:00
hsiegeln
5a7c0ce4bc ui(deploy): delete CreateAppView + AppDetailView + ConfigSubTab
AppsTab.tsx shrunk from 1387 to 109 lines — router now owns /apps/new
and /apps/:slug via AppDeploymentPage; list-only file retained.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:16:38 +02:00
hsiegeln
3a649f40cd ui(deploy): router blocker + DS dialog for unsaved edits
- Add deployedConfigSnapshot field to Deployment interface (mirrors server shape)
- Remove the Task 10.3 cast in handleRestore now that the type has the field
- New useUnsavedChangesBlocker hook (react-router useBlocker, v7.13.1)
- Wire AlertDialog into AppDeploymentPage for in-app navigation guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:13:36 +02:00
hsiegeln
b1bdb88ea4 ui(deploy): compose page — save/redeploy/checkpoints wired end-to-end
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:10:55 +02:00
hsiegeln
0e4166bd5f ui(deploy): PrimaryActionButton + computeMode state-machine helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:05:46 +02:00
hsiegeln
42fb6c8b8c ui(deploy): useFormDirty hook for per-tab dirty markers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:05:22 +02:00
hsiegeln
1579f10a41 ui(deploy): DeploymentTab + flex-grow StartupLogPanel
DeploymentTab composes StatusCard, DeploymentProgress, StartupLogPanel,
and HistoryDisclosure for the latest deployment. StartupLogPanel gains an
optional className prop, drops the fixed maxHeight, and its .panel rule
uses flex-column + min-height:0 so a parent can drive its height.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:03:52 +02:00
hsiegeln
063a4a5532 ui(deploy): HistoryDisclosure with inline log expansion
Collapsible deployment history table (sorted newest-first) with
click-to-expand StartupLogPanel for any historical deployment row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:03:02 +02:00
hsiegeln
98a7b7819f ui(deploy): StatusCard for Deployment tab
Status badge, replica count, URL, JAR/checksum grid, and stop/start
actions for the latest deployment. CSS added to AppDeploymentPage.module.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:02:44 +02:00
hsiegeln
e96c3cd0cf ui(deploy): Traces & Taps + Route Recording tabs with live banner
Ports the ConfigSubTab traces/taps and route recording content into
standalone tab components. Each write goes straight to live agents via
useUpdateApplicationConfig (apply='live'). A local draft state prevents
stale reads during the async flush. LiveBanner is rendered at the top of
both tabs to communicate the live-apply semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:00:14 +02:00
hsiegeln
b7c0a225f5 ui(deploy): LiveBanner component for live-apply tabs
Adds a warning banner that communicates live-apply semantics (changes
bypass the Save/Redeploy cycle). Uses --warning-bg / --warning-border
DS tokens. CSS class .liveBanner added to AppDeploymentPage.module.css.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:59:11 +02:00
hsiegeln
f487e6caef ui(deploy): extract SensitiveKeysTab component
Pure presentational tab receiving SensitiveKeysFormState via value/onChange.
Calls useSensitiveKeys() internally to show global baseline (readonly).
Local useState for the new-key input buffer. Reuses skStyles from
SensitiveKeysPage.module.css for consistent pill/badge layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:57:02 +02:00
hsiegeln
bb06c4c689 ui(deploy): extract VariablesTab component
Pure presentational tab receiving VariablesFormState via value/onChange.
Rows use the new .envVarsList / .envVarRow CSS grid (1fr 2fr auto).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:56:31 +02:00
hsiegeln
5c48b780b2 ui(deploy): extract ResourcesTab component
Pure presentational tab receiving ResourcesFormState via value/onChange.
Local useState buffers for newPort/newNetwork keep the "add next item"
inputs isolated from form state. isProd prop gates the memory-reserve field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:56:05 +02:00
hsiegeln
4f5a11f715 ui(deploy): extract MonitoringTab component
Pure presentational tab receiving MonitoringFormState via value/onChange.
Also adds shared config-tab styles to AppDeploymentPage.module.css
(configInline, toggleEnabled/Disabled, portPills, inputSizes, envVarsList/Row).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:55:25 +02:00
hsiegeln
cc193a1075 ui(deploy): add useDeploymentPageState orchestrator hook
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:53:06 +02:00
hsiegeln
08efdfa9c5 ui(deploy): Checkpoints disclosure (hides current deployment, flags pruned JARs)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:51:39 +02:00
hsiegeln
00c7c0cd71 ui(deploy): Identity & Artifact section with filename auto-derive
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:49:43 +02:00
hsiegeln
d067490f71 ui(deploy): add deriveAppName pure function + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:46:52 +02:00
hsiegeln
52ff385b04 ui(api): add useDirtyState + apply=staged|live on useUpdateApplicationConfig
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:45:42 +02:00
hsiegeln
6052975750 ui(deploy): scaffold AppDeploymentPage + route /apps/new and /apps/:slug
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:43:54 +02:00
hsiegeln
0434299d53 api(schema): regenerate OpenAPI + schema.d.ts for deployment page
Picks up GET dirty-state, PUT config ?apply=staged|live, and
deployedConfigSnapshot on Deployment for the deployment config-diff UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:42:10 +02:00
hsiegeln
97f25b4c7e test(deploy): register JavaTimeModule in DirtyStateCalculator unit test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:38:57 +02:00
hsiegeln
6591f2fde3 api(apps): GET /apps/{slug}/dirty-state returns desired-vs-deployed diff
Wires DirtyStateCalculator behind an HTTP endpoint on AppController.
Adds findLatestSuccessfulByAppAndEnv to PostgresDeploymentRepository,
registers DirtyStateCalculator as a Spring bean (with ObjectMapper for
JavaTimeModule support), and covers all three scenarios with IT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:35:35 +02:00
hsiegeln
24464c0772 core(deploy): recurse into nested diffs + unquote scalar values in DirtyStateCalculator
- compareJson now recurses when both nodes are ObjectNode, so nested maps
  (tracedProcessors, routeRecording, routeSamplingRates) produce deep paths
  like agentConfig.tracedProcessors.proc-1 instead of a blob diff
- Extract nodeToString helper: value nodes use asText() (strips JSON quotes),
  null becomes "(none)", arrays/objects get compact JSON
- Apply nodeToString in both diff-emission paths (top-level mismatch + leaf)
- Add three new tests: nullAgentConfigInSnapshot, nestedAgentField_reportsDeepPath,
  stringField_differenceValueIsUnquoted (8 tests total, all pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:25:04 +02:00
hsiegeln
e4ccce1e3b core(deploy): add DirtyStateCalculator + DirtyStateResult
Pure-logic dirty-state detection: compares desired JAR + agent config + container
config against the DeploymentConfigSnapshot from the last successful deployment.
Returns a structured DirtyStateResult with per-field differences. 5 unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:20:49 +02:00
hsiegeln
76352c0d6f test(config): tighten audit assertions + @DirtiesContext on ApplicationConfigControllerIT
- Add @DirtiesContext(AFTER_CLASS) so the SpyBean-forked context is torn
  down after the 6 tests finish, preventing permanent cache pollution
- Replace single-row queryForObject with queryForList + hasSize(1) in both
  audit tests so spurious extra rows will fail explicitly
- Assert auditCount == 0 in the 400 test to lock in the no-audit-on-bad-input invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:18:44 +02:00
hsiegeln
e716dbf8ca test(config): verify audit action in staged/live config IT
Replace the misleading putConfig_staged_auditActionIsStagedAppConfig test
(which only checked pushResult.total == 0, a duplicate of _savesButDoesNotPush)
with two real audit-log assertions: one verifying "stage_app_config" is written
for apply=staged and a new companion test verifying "update_app_config" for the
live path. Uses jdbcTemplate to query audit_log directly (Option B).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:13:53 +02:00
hsiegeln
76129d407e api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config
When apply=staged, saves to DB only — no CONFIG_UPDATE dispatched to agents.
When apply=live (default, back-compat), preserves today's immediate-push behavior.
Unknown apply values return 400. Audit action is stage_app_config vs update_app_config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:07:36 +02:00
hsiegeln
9b1240274d test(deploy): assert containerConfig round-trip + strict RUNNING in snapshot IT
Adds the missing containerConfig assertion to snapshot_isPopulated_whenDeploymentReachesRunning
(runtimeType + appPort entries), and tightens the await predicate from .isIn(RUNNING, DEGRADED)
to .isEqualTo(RUNNING) — the mock returns a healthy container so RUNNING is deterministic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:54:57 +02:00
hsiegeln
a79eafeaf4 runtime(deploy): capture config snapshot on RUNNING transition
Injects PostgresApplicationConfigRepository into DeploymentExecutor and
calls saveDeployedConfigSnapshot at the COMPLETE stage, before
markRunning. Snapshot contains jarVersionId, agentConfig (nullable),
and app.containerConfig. The FAILED catch path is left untouched so
snapshot stays null on failure. Verified by DeploymentSnapshotIT.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:51:00 +02:00
hsiegeln
9b851c4622 test(deploy): autowire repository in snapshot IT (JavaTimeModule-safe)
Replace manual `new PostgresDeploymentRepository(jdbcTemplate, new ObjectMapper())` with
`@Autowired PostgresDeploymentRepository repository` to use the Spring-managed bean whose
ObjectMapper has JavaTimeModule registered. Also removes the redundant isNotNull() assertion
whose work is done by the field-level assertions that follow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:43:40 +02:00
hsiegeln
d3e86b9d77 storage(deploy): persist deployed_config_snapshot as JSONB
Wire SELECT_COLS, mapRow deserialization, and saveDeployedConfigSnapshot
update method. Adds PostgresDeploymentRepositoryIT with roundtrip,
null-default, and clear-to-null tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:39:04 +02:00
hsiegeln
7f9cfc7f18 core(deploy): add deployedConfigSnapshot field to Deployment model
Appends DeploymentConfigSnapshot deployedConfigSnapshot to the Deployment
record and adds a matching withDeployedConfigSnapshot wither. All
positional call sites (repository mapper, test fixture) updated to pass
null; Task 1.4 will wire real persistence and Task 1.5 will populate
the field on RUNNING transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:31:48 +02:00
hsiegeln
06fa7d832f core(deploy): type jarVersionId as UUID (match domain convention)
All other FKs to app_versions.id (e.g. Deployment.appVersionId) use UUID;
DeploymentConfigSnapshot.jarVersionId was incorrectly typed as String.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:29:26 +02:00
hsiegeln
d580b6e90c core(deploy): add DeploymentConfigSnapshot record
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:26:30 +02:00
hsiegeln
ff95187707 db(deploy): add deployments.deployed_config_snapshot column (V3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 21:23:46 +02:00
hsiegeln
1a376eb25f plan(deploy): unified app deployment page implementation plan
13 phases, TDD-oriented: Flyway V3 snapshot column, staged/live config
write flag, dirty-state endpoint, regen OpenAPI, then the new React page
(Identity, Checkpoints, 7 tabs including the live-apply Traces+Taps and
Route Recording with banner), primary Save/Redeploy state machine,
router blocker, old view cleanup, rules docs, and a manual QA walkthrough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:14:11 +02:00
hsiegeln
58ec67aef9 spec(deploy): unified app deployment page design
Single page at /apps/:slug (+ /apps/new in net-new mode) replacing the
CreateAppView/AppDetailView split. Save ↔ Redeploy state machine driven
by a deployment snapshot on the deployments table, agent-config writes
gain ?apply=staged|live, Identity & Artifact always visible, new
Deployment tab carries progress + startup log, and checkpoints restore
full prior state (JAR + config) from past successful deploys.

Concurrent-edit protection deferred to #147.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:02:50 +02:00
52 changed files with 7536 additions and 1315 deletions

View File

@@ -53,9 +53,9 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
### Env-scoped (user-facing data & config)
- `AppController``/api/v1/environments/{envSlug}/apps`. GET list / POST create / GET `{appSlug}` / DELETE `{appSlug}` / GET `{appSlug}/versions` / POST `{appSlug}/versions` (JAR upload) / PUT `{appSlug}/container-config`. App slug uniqueness is per-env (`(env, app_slug)` is the natural key). `CreateAppRequest` body has no env (path), validates slug regex.
- `AppController``/api/v1/environments/{envSlug}/apps`. GET list / POST create / GET `{appSlug}` / DELETE `{appSlug}` / GET `{appSlug}/versions` / POST `{appSlug}/versions` (JAR upload) / PUT `{appSlug}/container-config` / GET `{appSlug}/dirty-state` (returns `DirtyStateResponse{dirty, lastSuccessfulDeploymentId, differences}` — compares current JAR+config against last RUNNING deployment snapshot; dirty=true when no snapshot exists). App slug uniqueness is per-env (`(env, app_slug)` is the natural key). `CreateAppRequest` body has no env (path), validates slug regex. Injects `DirtyStateCalculator` bean (registered in `RuntimeBeanConfig`, requires `ObjectMapper` with `JavaTimeModule`).
- `DeploymentController``/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`) / POST `{id}/stop` / POST `{id}/promote` (body `{ targetEnvironment: slug }` — target app slug must exist in target env) / GET `{id}/logs`.
- `ApplicationConfigController``/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT also pushes `CONFIG_UPDATE` to LIVE agents in this env.
- `ApplicationConfigController``/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT accepts `?apply=staged|live` (default `live`). `live` saves to DB and pushes `CONFIG_UPDATE` SSE to live agents in this env (existing behavior); `staged` saves to DB only, skipping the SSE push — used by the unified app deployment page. Audit action is `stage_app_config` for staged writes, `update_app_config` for live. Invalid `apply` values return 400.
- `AppSettingsController``/api/v1/environments/{envSlug}`. GET `/app-settings` (list), GET/PUT/DELETE `/apps/{appSlug}/settings`. ADMIN/OPERATOR only.
- `SearchController``/api/v1/environments/{envSlug}`. GET `/executions`, POST `/executions/search`, GET `/stats`, `/stats/timeseries`, `/stats/timeseries/by-app`, `/stats/timeseries/by-route`, `/stats/punchcard`, `/attributes/keys`, `/errors/top`.
- `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source (multi, comma-split, OR-joined), level (multi, comma-split, OR-joined), application, agentId, exchangeId, logger, q, time range; sort asc/desc). Cursor-paginated, returns `{ data, nextCursor, hasMore, levelCounts }`; cursor is base64url of `"{timestampIso}|{insert_id_uuid}"` — same-millisecond tiebreak via the `insert_id` UUID column on `logs`.
@@ -133,7 +133,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
## storage/ — PostgreSQL repositories (JdbcTemplate)
- `PostgresAppRepository`, `PostgresAppVersionRepository`, `PostgresEnvironmentRepository`
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId
- `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId. Also carries `deployed_config_snapshot` JSONB (Flyway V3) populated by `DeploymentExecutor` via `saveDeployedConfigSnapshot(UUID, DeploymentConfigSnapshot)` on successful RUNNING transition. Consumed by `DirtyStateCalculator` for the `/apps/{slug}/dirty-state` endpoint and by the UI for checkpoint restore.
- `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository`
- `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`, `PostgresSensitiveKeysRepository`
- `PostgresAppSettingsRepository`, `PostgresApplicationConfigRepository`, `PostgresThresholdRepository`. Both `app_settings` and `application_config` are env-scoped (PK `(app_id, environment)` / `(application, environment)`); finders take `(app, env)` — no env-agnostic variants.

View File

@@ -10,10 +10,14 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
- **Exchanges** — route execution search and detail (`ui/src/pages/Exchanges/`)
- **Dashboard** — metrics and stats with L1/L2/L3 drill-down (`ui/src/pages/DashboardTab/`)
- **Runtime** — live agent status, logs, commands (`ui/src/pages/RuntimeTab/`). AgentHealth supports compact view (dense health-tinted cards) and expanded view (full GroupCard+DataTable per app). View mode persisted to localStorage.
- **Deployments** — app management, JAR upload, deployment lifecycle (`ui/src/pages/AppsTab/`)
- Config sub-tabs: **Monitoring | Resources | Variables | Traces & Taps | Route Recording**
- Create app: full page at `/apps/new` (not a modal)
- Deployment progress: `ui/src/components/DeploymentProgress.tsx` (7-stage step indicator)
- **Deployments** — unified app deployment page (`ui/src/pages/AppsTab/`)
- Routes: `/apps` (list, `AppListView` in `AppsTab.tsx`), `/apps/new` + `/apps/:slug` (both render `AppDeploymentPage`).
- Identity & Artifact section always visible; name editable pre-first-deploy, read-only after. JAR picker client-stages; new JAR + any form edits flip the primary button from `Save` to `Redeploy`. Environment fixed to the currently-selected env (no selector).
- Config sub-tabs: **Monitoring | Resources | Variables | Sensitive Keys | Deployment | ● Traces & Taps | ● Route Recording**. The four staged tabs feed dirty detection; the `●` live tabs apply in real-time (amber LiveBanner + default `?apply=live` on their writes) and never mark dirty.
- Primary action state machine: `Save` (persists desired state without deploying) → `Redeploy` (applies desired state) → `Deploying…` during active deploy.
- Checkpoints disclosure in Identity section lists past successful deployments (current running one hidden, pruned-JAR rows disabled). Restore hydrates the form from `deployments.deployed_config_snapshot` for Save + Redeploy.
- Deployment tab: `StatusCard` + `DeploymentProgress` (during STARTING / FAILED) + flex-grow `StartupLogPanel` (no fixed maxHeight) + `HistoryDisclosure`. Auto-activates when a deploy starts.
- Unsaved-change router blocker uses DS `AlertDialog` (not `window.beforeunload`). Env switch intentionally discards edits without warning.
**Admin pages** (ADMIN-only, under `/admin/`):
- **Sensitive Keys** (`ui/src/pages/Admin/SensitiveKeysPage.tsx`) — global sensitive key masking config. Shows agent built-in defaults as outlined Badge reference, editable Tag pills for custom keys, amber-highlighted push-to-agents toggle. Keys add to (not replace) agent defaults. Per-app sensitive key additions managed via `ApplicationConfigController` API. Note: `AppConfigDetailPage.tsx` exists but is not routed in `router.tsx`.

View File

@@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (8893 symbols, 23049 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-server** (9095 symbols, 23495 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -85,7 +85,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **cameleer-server** (8893 symbols, 23049 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **cameleer-server** (9095 symbols, 23495 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -9,6 +9,7 @@ import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersionRepository;
import com.cameleer.server.core.runtime.DeploymentRepository;
import com.cameleer.server.core.runtime.DeploymentService;
import com.cameleer.server.core.runtime.DirtyStateCalculator;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import com.cameleer.server.core.runtime.EnvironmentService;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -64,6 +65,11 @@ public class RuntimeBeanConfig {
return new DeploymentService(deployRepo, appService, envService);
}
@Bean
public DirtyStateCalculator dirtyStateCalculator(ObjectMapper objectMapper) {
return new DirtyStateCalculator(objectMapper);
}
@Bean(name = "deploymentTaskExecutor")
public Executor deploymentTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

View File

@@ -1,14 +1,24 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.dto.DirtyStateResponse;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.runtime.App;
import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersion;
import com.cameleer.server.core.runtime.AppVersionRepository;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentConfigSnapshot;
import com.cameleer.server.core.runtime.DirtyStateCalculator;
import com.cameleer.server.core.runtime.DirtyStateResult;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.RuntimeType;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -22,8 +32,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -40,9 +52,21 @@ import java.util.UUID;
public class AppController {
private final AppService appService;
private final AppVersionRepository appVersionRepository;
private final PostgresApplicationConfigRepository configRepository;
private final PostgresDeploymentRepository deploymentRepository;
private final DirtyStateCalculator dirtyCalc;
public AppController(AppService appService) {
public AppController(AppService appService,
AppVersionRepository appVersionRepository,
PostgresApplicationConfigRepository configRepository,
PostgresDeploymentRepository deploymentRepository,
DirtyStateCalculator dirtyCalc) {
this.appService = appService;
this.appVersionRepository = appVersionRepository;
this.configRepository = configRepository;
this.deploymentRepository = deploymentRepository;
this.dirtyCalc = dirtyCalc;
}
@GetMapping
@@ -120,6 +144,47 @@ public class AppController {
}
}
@GetMapping("/{appSlug}/dirty-state")
@Operation(summary = "Check whether the app's current config differs from the last successful deploy",
description = "Returns dirty=true when the desired state (current JAR + agent config + container config) "
+ "would produce a changed deployment. When no successful deploy exists yet, dirty=true.")
@ApiResponse(responseCode = "200", description = "Dirty-state computed")
@ApiResponse(responseCode = "404", description = "App not found in this environment")
public ResponseEntity<DirtyStateResponse> getDirtyState(@EnvPath Environment env,
@PathVariable String appSlug) {
App app;
try {
app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "App not found");
}
// Latest JAR version (newest first — findByAppId orders by version DESC)
List<AppVersion> versions = appVersionRepository.findByAppId(app.id());
UUID latestVersionId = versions.isEmpty() ? null
: versions.stream().max(Comparator.comparingInt(AppVersion::version))
.map(AppVersion::id).orElse(null);
// Desired agent config
ApplicationConfig agentConfig = configRepository
.findByApplicationAndEnvironment(appSlug, env.slug())
.orElse(null);
// Container config
Map<String, Object> containerConfig = app.containerConfig();
// Last successful deployment snapshot
Deployment lastSuccessful = deploymentRepository
.findLatestSuccessfulByAppAndEnv(app.id(), env.id())
.orElse(null);
DeploymentConfigSnapshot snapshot = lastSuccessful != null ? lastSuccessful.deployedConfigSnapshot() : null;
DirtyStateResult result = dirtyCalc.compute(latestVersionId, agentConfig, containerConfig, snapshot);
String lastId = lastSuccessful != null ? lastSuccessful.id().toString() : null;
return ResponseEntity.ok(new DirtyStateResponse(result.dirty(), lastId, result.differences()));
}
private static final java.util.regex.Pattern CUSTOM_ARGS_PATTERN =
java.util.regex.Pattern.compile("^[-a-zA-Z0-9_.=:/\\s+\"']*$");

View File

@@ -108,13 +108,20 @@ public class ApplicationConfigController {
@PutMapping("/apps/{appSlug}/config")
@Operation(summary = "Update application config for this environment",
description = "Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment")
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
description = "Saves config. When apply=live (default), also pushes CONFIG_UPDATE to LIVE agents. "
+ "When apply=staged, persists without a live push — the next successful deploy applies it.")
@ApiResponse(responseCode = "200", description = "Config saved (and pushed if apply=live)")
@ApiResponse(responseCode = "400", description = "Unknown apply value (must be 'staged' or 'live')")
public ResponseEntity<ConfigUpdateResponse> updateConfig(@EnvPath Environment env,
@PathVariable String appSlug,
@RequestParam(name = "apply", defaultValue = "live") String apply,
@RequestBody ApplicationConfig config,
Authentication auth,
HttpServletRequest httpRequest) {
if (!"staged".equalsIgnoreCase(apply) && !"live".equalsIgnoreCase(apply)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
String updatedBy = auth != null ? auth.getName() : "system";
config.setApplication(appSlug);
@@ -126,14 +133,24 @@ public class ApplicationConfigController {
List<String> perAppKeys = extractSensitiveKeys(saved);
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys);
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
saved.getVersion(), appSlug, pushResult.total(), pushResult.responded());
CommandGroupResponse pushResult;
if ("staged".equalsIgnoreCase(apply)) {
pushResult = new CommandGroupResponse(true, 0, 0, List.of(), List.of());
log.info("Config v{} staged for '{}' (no live push)", saved.getVersion(), appSlug);
} else {
pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys);
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
saved.getVersion(), appSlug, pushResult.total(), pushResult.responded());
}
auditService.log("update_app_config", AuditCategory.CONFIG, appSlug,
auditService.log(
"staged".equalsIgnoreCase(apply) ? "stage_app_config" : "update_app_config",
AuditCategory.CONFIG, appSlug,
Map.of("environment", env.slug(), "version", saved.getVersion(),
"apply", apply.toLowerCase(),
"agentsPushed", pushResult.total(),
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
"responded", pushResult.responded(),
"timedOut", pushResult.timedOut().size()),
AuditResult.SUCCESS, httpRequest);
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.dto;
import com.cameleer.server.core.runtime.DirtyStateResult;
import java.util.List;
public record DirtyStateResponse(
boolean dirty,
String lastSuccessfulDeploymentId,
List<DirtyStateResult.Difference> differences
) {
}

View File

@@ -1,6 +1,8 @@
package com.cameleer.server.app.runtime;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.metrics.ServerMetrics;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.*;
import org.slf4j.Logger;
@@ -25,6 +27,7 @@ public class DeploymentExecutor {
private final EnvironmentService envService;
private final DeploymentRepository deploymentRepository;
private final PostgresDeploymentRepository pgDeployRepo;
private final PostgresApplicationConfigRepository applicationConfigRepository;
@Autowired(required = false)
private DockerNetworkManager networkManager;
@@ -75,13 +78,15 @@ public class DeploymentExecutor {
DeploymentService deploymentService,
AppService appService,
EnvironmentService envService,
DeploymentRepository deploymentRepository) {
DeploymentRepository deploymentRepository,
PostgresApplicationConfigRepository applicationConfigRepository) {
this.orchestrator = orchestrator;
this.deploymentService = deploymentService;
this.appService = appService;
this.envService = envService;
this.deploymentRepository = deploymentRepository;
this.pgDeployRepo = (PostgresDeploymentRepository) deploymentRepository;
this.applicationConfigRepository = applicationConfigRepository;
}
@Async("deploymentTaskExecutor")
@@ -252,6 +257,19 @@ public class DeploymentExecutor {
// === COMPLETE ===
updateStage(deployment.id(), DeployStage.COMPLETE);
// Capture config snapshot before marking RUNNING
ApplicationConfig agentConfig = applicationConfigRepository
.findByApplicationAndEnvironment(app.slug(), env.slug())
.orElse(null);
List<String> snapshotSensitiveKeys = agentConfig != null ? agentConfig.getSensitiveKeys() : null;
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
deployment.appVersionId(),
agentConfig,
app.containerConfig(),
snapshotSensitiveKeys
);
pgDeployRepo.saveDeployedConfigSnapshot(deployment.id(), snapshot);
String primaryContainerId = newContainerIds.get(0);
DeploymentStatus finalStatus = healthyCount == config.replicas()
? DeploymentStatus.RUNNING : DeploymentStatus.DEGRADED;

View File

@@ -1,6 +1,7 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentConfigSnapshot;
import com.cameleer.server.core.runtime.DeploymentRepository;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.fasterxml.jackson.core.type.TypeReference;
@@ -21,7 +22,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
private static final String SELECT_COLS =
"id, app_id, app_version_id, environment_id, status, target_state, deployment_strategy, " +
"replica_states, deploy_stage, container_id, container_name, error_message, " +
"resolved_config, deployed_at, stopped_at, created_at";
"resolved_config, deployed_config_snapshot, deployed_at, stopped_at, created_at";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
@@ -129,6 +130,25 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
}
}
public void saveDeployedConfigSnapshot(UUID id, DeploymentConfigSnapshot snapshot) {
try {
String json = snapshot != null ? objectMapper.writeValueAsString(snapshot) : null;
jdbc.update("UPDATE deployments SET deployed_config_snapshot = ?::jsonb WHERE id = ?", json, id);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize deployed_config_snapshot", e);
}
}
public Optional<Deployment> findLatestSuccessfulByAppAndEnv(UUID appId, UUID envId) {
var results = jdbc.query(
"SELECT " + SELECT_COLS + " FROM deployments "
+ "WHERE app_id = ? AND environment_id = ? "
+ "AND status = 'RUNNING' AND deployed_config_snapshot IS NOT NULL "
+ "ORDER BY deployed_at DESC NULLS LAST LIMIT 1",
(rs, rowNum) -> mapRow(rs), appId, envId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public Optional<Deployment> findByContainerId(String containerId) {
var results = jdbc.query(
"SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? " +
@@ -158,6 +178,15 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
throw new SQLException("Failed to deserialize resolved_config", e);
}
}
DeploymentConfigSnapshot deployedConfigSnapshot = null;
String snapshotJson = rs.getString("deployed_config_snapshot");
if (snapshotJson != null) {
try {
deployedConfigSnapshot = objectMapper.readValue(snapshotJson, DeploymentConfigSnapshot.class);
} catch (Exception e) {
throw new SQLException("Failed to deserialize deployed_config_snapshot", e);
}
}
return new Deployment(
UUID.fromString(rs.getString("id")),
UUID.fromString(rs.getString("app_id")),
@@ -172,6 +201,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
rs.getString("container_name"),
rs.getString("error_message"),
resolvedConfig,
deployedConfigSnapshot,
deployedAt != null ? deployedAt.toInstant() : null,
stoppedAt != null ? stoppedAt.toInstant() : null,
rs.getTimestamp("created_at").toInstant()

View File

@@ -0,0 +1,7 @@
-- V3: per-deployment config snapshot for "last known good" + dirty detection
-- Captures {jarVersionId, agentConfig, containerConfig} at the moment a
-- deployment transitions to RUNNING. Historical rows are NULL; dirty detection
-- treats NULL as "everything dirty" and the next successful Redeploy populates it.
ALTER TABLE deployments
ADD COLUMN deployed_config_snapshot JSONB;

View File

@@ -48,7 +48,7 @@ class DeploymentStateEvaluatorTest {
private Deployment deployment(DeploymentStatus status) {
return new Deployment(DEP_ID, APP_ID, UUID.randomUUID(), ENV_ID, status,
null, null, List.of(), null, null, "orders-0", null,
Map.of(), NOW.minusSeconds(60), null, NOW.minusSeconds(120));
Map.of(), null, NOW.minusSeconds(60), null, NOW.minusSeconds(120));
}
@Test

View File

@@ -0,0 +1,235 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.dto.DirtyStateResponse;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.ContainerStatus;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Integration tests for GET /api/v1/environments/{envSlug}/apps/{appSlug}/dirty-state.
*
* <p>Uses @MockBean RuntimeOrchestrator (same pattern as DeploymentSnapshotIT).
* @DirtiesContext prevents context cache conflicts when both IT classes are loaded together.</p>
*/
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AppDirtyStateIT extends AbstractPostgresIT {
@MockBean
RuntimeOrchestrator runtimeOrchestrator;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private PostgresDeploymentRepository deploymentRepository;
private String operatorJwt;
@BeforeEach
void setUp() {
operatorJwt = securityHelper.operatorToken();
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
}
// -----------------------------------------------------------------------
// Test 1: no deployment ever → dirty=true, lastSuccessfulDeploymentId=null
// -----------------------------------------------------------------------
@Test
void dirtyState_noDeployEver_returnsDirtyTrue() throws Exception {
String appSlug = "ds-nodeploy-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps",
String.format("{\"slug\": \"%s\", \"displayName\": \"DS No Deploy\"}", appSlug),
operatorJwt);
uploadJar(appSlug, ("fake-jar-" + appSlug).getBytes());
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.5}", operatorJwt);
DirtyStateResponse body = getDirtyState("default", appSlug);
assertThat(body.dirty()).isTrue();
assertThat(body.lastSuccessfulDeploymentId()).isNull();
}
// -----------------------------------------------------------------------
// Test 2: after a successful deploy with matching desired state → dirty=false
// -----------------------------------------------------------------------
@Test
void dirtyState_afterSuccessfulDeploy_matchingDesiredState_returnsDirtyFalse() throws Exception {
String fakeContainerId = "fake-cid-" + UUID.randomUUID();
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
.thenReturn(new ContainerStatus("healthy", true, 0, null));
String appSlug = "ds-clean-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps",
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Clean\"}", appSlug),
operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
String versionId = uploadJar(appSlug, ("fake-jar-clean-" + appSlug).getBytes());
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.25}", operatorJwt);
// Deploy and wait for RUNNING
JsonNode deploy = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
String.format("{\"appVersionId\": \"%s\"}", versionId),
operatorJwt);
String deploymentId = deploy.path("id").asText();
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found"));
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
});
// Desired state matches what was deployed → dirty=false
DirtyStateResponse body = getDirtyState("default", appSlug);
assertThat(body.dirty()).isFalse();
assertThat(body.differences()).isEmpty();
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
}
// -----------------------------------------------------------------------
// Test 3: after successful deploy, config changed → dirty=true
// -----------------------------------------------------------------------
@Test
void dirtyState_afterSuccessfulDeploy_configChanged_returnsDirtyTrue() throws Exception {
String fakeContainerId = "fake-cid2-" + UUID.randomUUID();
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
.thenReturn(new ContainerStatus("healthy", true, 0, null));
String appSlug = "ds-dirty-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps",
String.format("{\"slug\": \"%s\", \"displayName\": \"DS Dirty\"}", appSlug),
operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
"{\"runtimeType\": \"spring-boot\", \"appPort\": 8081}", operatorJwt);
String versionId = uploadJar(appSlug, ("fake-jar-dirty-" + appSlug).getBytes());
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.1}", operatorJwt);
// Deploy and wait for RUNNING
JsonNode deploy = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
String.format("{\"appVersionId\": \"%s\"}", versionId),
operatorJwt);
String deploymentId = deploy.path("id").asText();
await().atMost(30, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found"));
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
});
// Change samplingRate after deploy
put("/api/v1/environments/default/apps/" + appSlug + "/config",
"{\"samplingRate\": 0.9}", operatorJwt);
// Now desired state differs from snapshot → dirty=true
DirtyStateResponse body = getDirtyState("default", appSlug);
assertThat(body.dirty()).isTrue();
assertThat(body.lastSuccessfulDeploymentId()).isEqualTo(deploymentId);
assertThat(body.differences()).isNotEmpty();
assertThat(body.differences())
.anyMatch(d -> d.field().contains("samplingRate"));
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private DirtyStateResponse getDirtyState(String envSlug, String appSlug) {
HttpHeaders headers = securityHelper.authHeaders(operatorJwt);
var response = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/dirty-state",
HttpMethod.GET,
new HttpEntity<>(headers),
DirtyStateResponse.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
return response.getBody();
}
private JsonNode post(String path, String json, String jwt) throws Exception {
HttpHeaders headers = securityHelper.authHeaders(jwt);
var response = restTemplate.exchange(
path, HttpMethod.POST,
new HttpEntity<>(json, headers),
String.class);
return objectMapper.readTree(response.getBody());
}
private void put(String path, String json, String jwt) {
HttpHeaders headers = securityHelper.authHeaders(jwt);
restTemplate.exchange(path, HttpMethod.PUT, new HttpEntity<>(json, headers), String.class);
}
private String uploadJar(String appSlug, byte[] content) throws Exception {
ByteArrayResource resource = new ByteArrayResource(content) {
@Override
public String getFilename() { return "app.jar"; }
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + operatorJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
var response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/versions",
HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
JsonNode versionNode = objectMapper.readTree(response.getBody());
return versionNode.path("id").asText();
}
}

View File

@@ -0,0 +1,200 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.CommandType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@DirtiesContext(classMode = ClassMode.AFTER_CLASS)
class ApplicationConfigControllerIT extends AbstractPostgresIT {
/**
* Spy on the real AgentRegistryService bean so we can verify whether
* addGroupCommandWithReplies was invoked (live) or skipped (staged).
*/
@SpyBean
AgentRegistryService registryService;
@Autowired private TestRestTemplate restTemplate;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private PostgresApplicationConfigRepository configRepository;
private String operatorJwt;
/** Unique env slug per test to avoid cross-test pollution. */
private String envSlug;
private UUID envId;
/** Unique app slug per test run to avoid cross-test row collisions. */
private String appSlug;
@BeforeEach
void setUp() {
operatorJwt = securityHelper.operatorToken();
envSlug = "cfg-it-" + UUID.randomUUID().toString().substring(0, 8);
envId = UUID.randomUUID();
appSlug = "paygw-" + UUID.randomUUID().toString().substring(0, 8);
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING",
envId, envSlug, envSlug);
}
@AfterEach
void cleanUp() {
jdbcTemplate.update("DELETE FROM application_config WHERE environment = ?", envSlug);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
}
// ── helpers ──────────────────────────────────────────────────────────────
private void registerLiveAgent(String agentId) {
// Use the bootstrap HTTP endpoint — same pattern as AgentCommandControllerIT.
String body = """
{
"instanceId": "%s",
"applicationId": "%s",
"environmentId": "%s",
"version": "1.0.0",
"routeIds": ["route-1"],
"capabilities": {}
}
""".formatted(agentId, appSlug, envSlug);
restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(body, securityHelper.bootstrapHeaders()),
String.class);
}
private ResponseEntity<String> putConfig(String apply) {
String url = "/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/config"
+ (apply != null ? "?apply=" + apply : "");
String body = """
{"samplingRate": 0.1, "metricsEnabled": true}
""";
return restTemplate.exchange(url, HttpMethod.PUT,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), String.class);
}
// ── tests ─────────────────────────────────────────────────────────────────
@Test
void putConfig_staged_savesButDoesNotPush() {
// Given — one LIVE agent registered for (appSlug, envSlug)
String agentId = "staged-agent-" + UUID.randomUUID().toString().substring(0, 8);
registerLiveAgent(agentId);
// When — PUT with apply=staged
ResponseEntity<String> response = putConfig("staged");
// Then — HTTP 200
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// And — DB has the new config
ApplicationConfig saved = configRepository
.findByApplicationAndEnvironment(appSlug, envSlug)
.orElseThrow(() -> new AssertionError("Config not found in DB"));
assertThat(saved.getSamplingRate()).isEqualTo(0.1);
// And — NO CONFIG_UPDATE was pushed to any agent
verify(registryService, never())
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
}
@Test
void putConfig_live_savesAndPushes() {
// Given — one LIVE agent registered for (appSlug, envSlug)
String agentId = "live-agent-" + UUID.randomUUID().toString().substring(0, 8);
registerLiveAgent(agentId);
// When — PUT without apply param (default is live)
ResponseEntity<String> response = putConfig(null);
// Then — HTTP 200
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// And — DB has the new config
ApplicationConfig saved = configRepository
.findByApplicationAndEnvironment(appSlug, envSlug)
.orElseThrow(() -> new AssertionError("Config not found in DB"));
assertThat(saved.getSamplingRate()).isEqualTo(0.1);
// And — CONFIG_UPDATE was pushed (addGroupCommandWithReplies called once)
verify(registryService)
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
}
@Test
void putConfig_liveExplicit_savesAndPushes() {
// Same as above but with explicit apply=live
String agentId = "live-explicit-" + UUID.randomUUID().toString().substring(0, 8);
registerLiveAgent(agentId);
ResponseEntity<String> response = putConfig("live");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(registryService)
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
}
@Test
void putConfig_unknownApplyValue_returns400() {
ResponseEntity<String> response = putConfig("BOGUS");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
int auditCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE target = ?", Integer.class, appSlug);
assertThat(auditCount).isZero();
}
@Test
void putConfig_staged_auditActionIsStagedAppConfig() {
registerLiveAgent("audit-agent-" + UUID.randomUUID().toString().substring(0, 8));
ResponseEntity<String> response = putConfig("staged");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<String> actions = jdbcTemplate.queryForList(
"SELECT action FROM audit_log WHERE target = ? ORDER BY timestamp DESC",
String.class, appSlug);
assertThat(actions).hasSize(1);
assertThat(actions.get(0)).isEqualTo("stage_app_config");
}
@Test
void putConfig_live_auditActionIsUpdateAppConfig() {
registerLiveAgent("audit-agent-live-" + UUID.randomUUID().toString().substring(0, 8));
ResponseEntity<String> response = putConfig(null);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
List<String> actions = jdbcTemplate.queryForList(
"SELECT action FROM audit_log WHERE target = ? ORDER BY timestamp DESC",
String.class, appSlug);
assertThat(actions).hasSize(1);
assertThat(actions.get(0)).isEqualTo("update_app_config");
}
}

View File

@@ -0,0 +1,235 @@
package com.cameleer.server.app.runtime;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.storage.PostgresDeploymentRepository;
import com.cameleer.server.core.runtime.ContainerStatus;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentStatus;
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Verifies that DeploymentExecutor writes DeploymentConfigSnapshot on successful
* RUNNING transition and does NOT write it on a FAILED path.
*/
class DeploymentSnapshotIT extends AbstractPostgresIT {
@MockBean
RuntimeOrchestrator runtimeOrchestrator;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private PostgresDeploymentRepository deploymentRepository;
private String operatorJwt;
private String adminJwt;
@BeforeEach
void setUp() throws Exception {
operatorJwt = securityHelper.operatorToken();
adminJwt = securityHelper.adminToken();
// Clean up between tests
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
}
// -----------------------------------------------------------------------
// Test 1: snapshot is populated when deployment reaches RUNNING
// -----------------------------------------------------------------------
@Test
void snapshot_isPopulated_whenDeploymentReachesRunning() throws Exception {
// --- given: mock orchestrator that simulates a healthy single-replica container ---
String fakeContainerId = "fake-container-" + UUID.randomUUID();
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any()))
.thenReturn(fakeContainerId);
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
.thenReturn(new ContainerStatus("healthy", true, 0, null));
// --- given: create app with explicit runtimeType so auto-detection is not needed ---
String appSlug = "snap-success-" + UUID.randomUUID().toString().substring(0, 8);
String containerConfigJson = """
{"runtimeType": "spring-boot", "appPort": 8081}
""";
String createAppJson = String.format("""
{"slug": "%s", "displayName": "Snapshot Success App"}
""", appSlug);
JsonNode createdApp = post("/api/v1/environments/default/apps", createAppJson, operatorJwt);
String appId = createdApp.path("id").asText();
// --- given: update containerConfig to set runtimeType ---
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
containerConfigJson, operatorJwt);
// --- given: upload a JAR (fake bytes; real file written to disk by AppService) ---
String versionId = uploadJar(appSlug, ("fake-jar-bytes-" + appSlug).getBytes());
// --- given: save agentConfig with samplingRate = 0.25 ---
String configJson = """
{"samplingRate": 0.25}
""";
put("/api/v1/environments/default/apps/" + appSlug + "/config", configJson, operatorJwt);
// --- when: trigger deploy ---
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
JsonNode deployResponse = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
deployJson, operatorJwt);
String deploymentId = deployResponse.path("id").asText();
// --- await RUNNING (async executor) ---
AtomicReference<Deployment> deploymentRef = new AtomicReference<>();
await().atMost(30, TimeUnit.SECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found: " + deploymentId));
assertThat(d.status()).isEqualTo(DeploymentStatus.RUNNING);
deploymentRef.set(d);
});
// --- then: snapshot is populated ---
Deployment deployed = deploymentRef.get();
assertThat(deployed.deployedConfigSnapshot()).isNotNull();
assertThat(deployed.deployedConfigSnapshot().jarVersionId())
.isEqualTo(UUID.fromString(versionId));
assertThat(deployed.deployedConfigSnapshot().agentConfig()).isNotNull();
assertThat(deployed.deployedConfigSnapshot().agentConfig().getSamplingRate())
.isEqualTo(0.25);
assertThat(deployed.deployedConfigSnapshot().containerConfig())
.containsEntry("runtimeType", "spring-boot")
.containsEntry("appPort", 8081);
}
// -----------------------------------------------------------------------
// Test 2: snapshot is NOT populated when deployment fails
// -----------------------------------------------------------------------
@Test
void snapshot_isNotPopulated_whenDeploymentFails() throws Exception {
// --- given: mock orchestrator that throws on startContainer ---
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any()))
.thenThrow(new RuntimeException("Simulated container start failure"));
// --- given: create app with explicit runtimeType ---
String appSlug = "snap-fail-" + UUID.randomUUID().toString().substring(0, 8);
String createAppJson = String.format("""
{"slug": "%s", "displayName": "Snapshot Fail App"}
""", appSlug);
post("/api/v1/environments/default/apps", createAppJson, operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
"""
{"runtimeType": "spring-boot", "appPort": 8081}
""", operatorJwt);
String versionId = uploadJar(appSlug, ("fake-jar-fail-" + appSlug).getBytes());
// --- when: trigger deploy ---
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
JsonNode deployResponse = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
deployJson, operatorJwt);
String deploymentId = deployResponse.path("id").asText();
// --- await FAILED (async executor catches exception and marks failed) ---
await().atMost(30, TimeUnit.SECONDS)
.pollInterval(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
Deployment d = deploymentRepository.findById(UUID.fromString(deploymentId))
.orElseThrow(() -> new AssertionError("Deployment not found: " + deploymentId));
assertThat(d.status()).isEqualTo(DeploymentStatus.FAILED);
});
// --- then: snapshot is null ---
Deployment failed = deploymentRepository.findById(UUID.fromString(deploymentId)).orElseThrow();
assertThat(failed.deployedConfigSnapshot()).isNull();
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private JsonNode post(String path, String json, String jwt) throws Exception {
HttpHeaders headers = securityHelper.authHeaders(jwt);
var response = restTemplate.exchange(
path, HttpMethod.POST,
new HttpEntity<>(json, headers),
String.class);
return objectMapper.readTree(response.getBody());
}
private void put(String path, String json, String jwt) {
HttpHeaders headers = securityHelper.authHeaders(jwt);
restTemplate.exchange(
path, HttpMethod.PUT,
new HttpEntity<>(json, headers),
String.class);
}
private String uploadJar(String appSlug, byte[] content) throws Exception {
ByteArrayResource resource = new ByteArrayResource(content) {
@Override
public String getFilename() { return "app.jar"; }
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + operatorJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
var response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/versions",
HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
JsonNode versionNode = objectMapper.readTree(response.getBody());
return versionNode.path("id").asText();
}
}

View File

@@ -0,0 +1,107 @@
package com.cameleer.server.app.storage;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentConfigSnapshot;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class PostgresDeploymentRepositoryIT extends AbstractPostgresIT {
@Autowired PostgresDeploymentRepository repository;
private UUID envId;
private UUID appId;
private UUID appVersionId;
@BeforeEach
void setup() {
envId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
envId, "test-env-" + envId, "Test Env");
appId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO apps (id, environment_id, slug, display_name) VALUES (?, ?, ?, ?)",
appId, envId, "app-it-" + appId, "App IT");
appVersionId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO app_versions (id, app_id, version, jar_path, jar_checksum) VALUES (?, ?, ?, ?, ?)",
appVersionId, appId, 1, "/tmp/app.jar", "deadbeef");
}
@AfterEach
void cleanup() {
jdbcTemplate.update("DELETE FROM deployments WHERE app_id = ?", appId);
jdbcTemplate.update("DELETE FROM app_versions WHERE app_id = ?", appId);
jdbcTemplate.update("DELETE FROM apps WHERE id = ?", appId);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
}
@Test
void deployedConfigSnapshot_roundtrips() {
// given — create a deployment then store a snapshot
ApplicationConfig agentConfig = new ApplicationConfig();
agentConfig.setApplication("app-it");
agentConfig.setEnvironment("staging");
agentConfig.setVersion(3);
agentConfig.setSamplingRate(0.5);
UUID jarVersionId = UUID.randomUUID();
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
jarVersionId,
agentConfig,
Map.of("memoryLimitMb", 1024, "replicas", 2),
null
);
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container");
repository.saveDeployedConfigSnapshot(deploymentId, snapshot);
// when — load it back
Deployment loaded = repository.findById(deploymentId).orElseThrow();
// then
assertThat(loaded.deployedConfigSnapshot().jarVersionId()).isEqualTo(jarVersionId);
assertThat(loaded.deployedConfigSnapshot().agentConfig().getSamplingRate()).isEqualTo(0.5);
assertThat(loaded.deployedConfigSnapshot().containerConfig()).containsEntry("memoryLimitMb", 1024);
}
@Test
void deployedConfigSnapshot_nullByDefault() {
// deployments created without a snapshot must return null (not throw)
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-null");
Deployment loaded = repository.findById(deploymentId).orElseThrow();
assertThat(loaded.deployedConfigSnapshot()).isNull();
}
@Test
void deployedConfigSnapshot_canBeClearedToNull() {
UUID jarVersionId = UUID.randomUUID();
DeploymentConfigSnapshot snapshot = new DeploymentConfigSnapshot(
jarVersionId,
new ApplicationConfig(),
Map.of(),
null
);
UUID deploymentId = repository.create(appId, appVersionId, envId, "test-container-clear");
repository.saveDeployedConfigSnapshot(deploymentId, snapshot);
repository.saveDeployedConfigSnapshot(deploymentId, null);
Deployment loaded = repository.findById(deploymentId).orElseThrow();
assertThat(loaded.deployedConfigSnapshot()).isNull();
}
}

View File

@@ -19,6 +19,7 @@ public record Deployment(
String containerName,
String errorMessage,
Map<String, Object> resolvedConfig,
DeploymentConfigSnapshot deployedConfigSnapshot,
Instant deployedAt,
Instant stoppedAt,
Instant createdAt
@@ -27,6 +28,13 @@ public record Deployment(
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
targetState, deploymentStrategy, replicaStates, deployStage,
containerId, containerName, errorMessage, resolvedConfig,
deployedAt, stoppedAt, createdAt);
deployedConfigSnapshot, deployedAt, stoppedAt, createdAt);
}
public Deployment withDeployedConfigSnapshot(DeploymentConfigSnapshot snapshot) {
return new Deployment(id, appId, appVersionId, environmentId, status,
targetState, deploymentStrategy, replicaStates, deployStage,
containerId, containerName, errorMessage, resolvedConfig,
snapshot, deployedAt, stoppedAt, createdAt);
}
}

View File

@@ -0,0 +1,22 @@
package com.cameleer.server.core.runtime;
import com.cameleer.common.model.ApplicationConfig;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Snapshot of the config that was deployed, captured at the moment a deployment
* transitions to RUNNING. Used for "last known good" restore (checkpoints) and
* for dirty-state detection on the deployment page.
*
* <p>This is persisted as JSONB in {@code deployments.deployed_config_snapshot}.</p>
*/
public record DeploymentConfigSnapshot(
UUID jarVersionId,
ApplicationConfig agentConfig,
Map<String, Object> containerConfig,
List<String> sensitiveKeys
) {
}

View File

@@ -0,0 +1,98 @@
package com.cameleer.server.core.runtime;
import com.cameleer.common.model.ApplicationConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;
/**
* Compares the app's current desired state (JAR + agent config + container config) to the
* config snapshot from the last successful deployment, producing a structured dirty result.
*
* <p>Pure logic — no IO, no Spring. Safe to unit-test as a POJO.
* Caller must supply an {@link ObjectMapper} configured with {@code JavaTimeModule} so that
* {@code ApplicationConfig.updatedAt} (an {@link java.time.Instant}) serialises correctly.</p>
*/
public class DirtyStateCalculator {
private static final Set<String> AGENT_CONFIG_IGNORED_KEYS = Set.of(
"version", "updatedAt", "updatedBy", "environment", "application"
);
private final ObjectMapper mapper;
public DirtyStateCalculator(ObjectMapper mapper) {
this.mapper = mapper;
}
private JsonNode scrubAgentConfig(JsonNode node) {
if (!(node instanceof ObjectNode obj)) return node;
ObjectNode copy = obj.deepCopy();
for (String k : AGENT_CONFIG_IGNORED_KEYS) copy.remove(k);
return copy;
}
public DirtyStateResult compute(UUID desiredJarVersionId,
ApplicationConfig desiredAgentConfig,
Map<String, Object> desiredContainerConfig,
DeploymentConfigSnapshot snapshot) {
List<DirtyStateResult.Difference> diffs = new ArrayList<>();
if (snapshot == null) {
diffs.add(new DirtyStateResult.Difference("snapshot", "(none)", "(none)"));
return new DirtyStateResult(true, diffs);
}
if (!Objects.equals(desiredJarVersionId, snapshot.jarVersionId())) {
diffs.add(new DirtyStateResult.Difference("jarVersionId",
String.valueOf(desiredJarVersionId), String.valueOf(snapshot.jarVersionId())));
}
compareJson("agentConfig",
scrubAgentConfig(mapper.valueToTree(desiredAgentConfig)),
scrubAgentConfig(mapper.valueToTree(snapshot.agentConfig())),
diffs);
compareJson("containerConfig", mapper.valueToTree(desiredContainerConfig),
mapper.valueToTree(snapshot.containerConfig()), diffs);
return new DirtyStateResult(!diffs.isEmpty(), diffs);
}
private void compareJson(String prefix, JsonNode desired, JsonNode deployed,
List<DirtyStateResult.Difference> diffs) {
if (!(desired instanceof ObjectNode desiredObj) || !(deployed instanceof ObjectNode deployedObj)) {
if (!Objects.equals(desired, deployed)) {
diffs.add(new DirtyStateResult.Difference(prefix,
nodeToString(desired), nodeToString(deployed)));
}
return;
}
TreeSet<String> keys = new TreeSet<>();
desiredObj.fieldNames().forEachRemaining(keys::add);
deployedObj.fieldNames().forEachRemaining(keys::add);
for (String key : keys) {
JsonNode d = desiredObj.get(key);
JsonNode p = deployedObj.get(key);
if (Objects.equals(d, p)) continue;
if (d instanceof ObjectNode && p instanceof ObjectNode) {
compareJson(prefix + "." + key, d, p, diffs);
} else {
diffs.add(new DirtyStateResult.Difference(prefix + "." + key, nodeToString(d), nodeToString(p)));
}
}
}
private static String nodeToString(JsonNode n) {
if (n == null) return "(none)";
if (n.isValueNode()) return n.asText();
return n.toString(); // arrays/objects: compact JSON
}
}

View File

@@ -0,0 +1,7 @@
package com.cameleer.server.core.runtime;
import java.util.List;
public record DirtyStateResult(boolean dirty, List<Difference> differences) {
public record Difference(String field, String staged, String deployed) {}
}

View File

@@ -0,0 +1,164 @@
package com.cameleer.server.core.runtime;
import com.cameleer.common.model.ApplicationConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DirtyStateCalculatorTest {
private static final DirtyStateCalculator CALC = new DirtyStateCalculator(
new ObjectMapper().registerModule(new JavaTimeModule()));
@Test
void noSnapshot_meansEverythingDirty() {
DirtyStateCalculator calc = CALC;
ApplicationConfig desiredAgent = new ApplicationConfig();
desiredAgent.setSamplingRate(1.0);
Map<String, Object> desiredContainer = Map.of("memoryLimitMb", 512);
DirtyStateResult result = calc.compute(UUID.randomUUID(), desiredAgent, desiredContainer, null);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("snapshot");
}
@Test
void identicalSnapshot_isClean() {
DirtyStateCalculator calc = CALC;
ApplicationConfig cfg = new ApplicationConfig();
cfg.setSamplingRate(0.5);
Map<String, Object> container = Map.of("memoryLimitMb", 512);
UUID jarId = UUID.randomUUID();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, container, null);
DirtyStateResult result = calc.compute(jarId, cfg, container, snap);
assertThat(result.dirty()).isFalse();
assertThat(result.differences()).isEmpty();
}
@Test
void differentJar_marksJarField() {
DirtyStateCalculator calc = CALC;
ApplicationConfig cfg = new ApplicationConfig();
Map<String, Object> container = Map.of();
UUID v1 = UUID.randomUUID();
UUID v2 = UUID.randomUUID();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(v1, cfg, container, null);
DirtyStateResult result = calc.compute(v2, cfg, container, snap);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("jarVersionId");
}
@Test
void differentSamplingRate_marksAgentField() {
DirtyStateCalculator calc = CALC;
ApplicationConfig deployedCfg = new ApplicationConfig();
deployedCfg.setSamplingRate(0.5);
ApplicationConfig desiredCfg = new ApplicationConfig();
desiredCfg.setSamplingRate(1.0);
UUID jarId = UUID.randomUUID();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployedCfg, Map.of(), null);
DirtyStateResult result = calc.compute(jarId, desiredCfg, Map.of(), snap);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("agentConfig.samplingRate");
}
@Test
void differentContainerMemory_marksContainerField() {
DirtyStateCalculator calc = CALC;
ApplicationConfig cfg = new ApplicationConfig();
UUID jarId = UUID.randomUUID();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, cfg, Map.of("memoryLimitMb", 512), null);
DirtyStateResult result = calc.compute(jarId, cfg, Map.of("memoryLimitMb", 1024), snap);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("containerConfig.memoryLimitMb");
}
@Test
void nullAgentConfigInSnapshot_marksAgentConfigDiff() {
DirtyStateCalculator calc = CALC;
ApplicationConfig desired = new ApplicationConfig();
desired.setSamplingRate(1.0);
UUID jarId = UUID.randomUUID();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, null, Map.of(), null);
DirtyStateResult result = calc.compute(jarId, desired, Map.of(), snap);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("agentConfig");
}
@Test
void nestedAgentField_reportsDeepPath() {
DirtyStateCalculator calc = CALC;
ApplicationConfig deployed = new ApplicationConfig();
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
ApplicationConfig desired = new ApplicationConfig();
desired.setTracedProcessors(Map.of("proc-1", "TRACE"));
UUID jarId = UUID.randomUUID();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployed, Map.of(), null);
DirtyStateResult result = calc.compute(jarId, desired, Map.of(), snap);
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("agentConfig.tracedProcessors.proc-1");
}
@Test
void stringField_differenceValueIsUnquoted() {
DirtyStateCalculator calc = CALC;
ApplicationConfig deployed = new ApplicationConfig();
deployed.setApplicationLogLevel("INFO");
ApplicationConfig desired = new ApplicationConfig();
desired.setApplicationLogLevel("DEBUG");
UUID jarId = UUID.randomUUID();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployed, Map.of(), null);
DirtyStateResult result = calc.compute(jarId, desired, Map.of(), snap);
DirtyStateResult.Difference diff = result.differences().stream()
.filter(d -> d.field().equals("agentConfig.applicationLogLevel"))
.findFirst().orElseThrow();
assertThat(diff.staged()).isEqualTo("DEBUG");
assertThat(diff.deployed()).isEqualTo("INFO");
}
@Test
void versionBumpDoesNotMarkDirty() {
ApplicationConfig deployedCfg = new ApplicationConfig();
deployedCfg.setSamplingRate(0.5);
deployedCfg.setVersion(1);
ApplicationConfig desiredCfg = new ApplicationConfig();
desiredCfg.setSamplingRate(0.5);
desiredCfg.setVersion(2); // bumped by save
UUID jarId = UUID.randomUUID();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployedCfg, Map.of(), null);
DirtyStateResult result = CALC.compute(jarId, desiredCfg, Map.of(), snap);
assertThat(result.dirty()).isFalse();
}
}

View File

@@ -0,0 +1,183 @@
# Handoff — Unified App Deployment Page
**Session:** 2026-04-22 → 2026-04-23
**Branch:** `main` (43 commits ahead of `origin/main` before push — all committed directly per explicit user consent)
**Base commit (session start):** `1a376eb2`
**Head commit (session end):** `0a71bca7`
## What landed
Full implementation of the unified app deployment page replacing the old `CreateAppView` / `AppDetailView` split. Key artefacts:
- **Spec:** `docs/superpowers/specs/2026-04-22-app-deployment-page-design.md`
- **Plan:** `docs/superpowers/plans/2026-04-22-app-deployment-page.md`
- **Routes:** `/apps` (list, unchanged), `/apps/new` + `/apps/:slug` (both render new `AppDeploymentPage`)
### Backend delivered (cameleer-server)
- Flyway V3 adds `deployments.deployed_config_snapshot JSONB`
- `DeploymentConfigSnapshot` record: `(UUID jarVersionId, ApplicationConfig agentConfig, Map<String,Object> containerConfig, List<String> sensitiveKeys)`
- `DeploymentExecutor` captures snapshot on successful RUNNING transition (not FAILED)
- `PostgresDeploymentRepository.saveDeployedConfigSnapshot(UUID, DeploymentConfigSnapshot)` + `findLatestSuccessfulByAppAndEnv(appId, envId)`
- `ApplicationConfigController.updateConfig` accepts `?apply=staged|live` (default `live` for back-compat); staged skips SSE push; 400 on unknown
- `AppController.getDirtyState``GET /api/v1/environments/{envSlug}/apps/{appSlug}/dirty-state` returning `{dirty, lastSuccessfulDeploymentId, differences[]}`
- `DirtyStateCalculator` pure service (cameleer-server-core), scrubs volatile fields (`version`, `updatedAt`, `updatedBy`, `environment`, `application`) from agent-config comparison, recurses into nested objects
- Integration tests: `PostgresDeploymentRepositoryIT` (3), `DeploymentSnapshotIT` (2), `ApplicationConfigControllerIT` (6), `AppDirtyStateIT` (3), `DirtyStateCalculatorTest` (9)
- OpenAPI + `schema.d.ts` regenerated
### UI delivered (cameleer-server/ui)
New directory `ui/src/pages/AppsTab/AppDeploymentPage/`:
```
index.tsx # Main composition (524 lines)
IdentitySection.tsx # Name + slug + env pill + JAR + Current Version
Checkpoints.tsx # Collapsible disclosure of past successful deploys
PrimaryActionButton.tsx # Save / Redeploy / Deploying… state machine
AppDeploymentPage.module.css # Page-local styles
ConfigTabs/
MonitoringTab.tsx # Engine, payload, log levels, metrics, sampling, replay, route control
ResourcesTab.tsx # CPU / memory / ports / replicas / runtime / networks
VariablesTab.tsx # Env vars (Table / Properties / YAML / .env via EnvEditor)
SensitiveKeysTab.tsx # Per-app keys + global baseline reference
TracesTapsTab.tsx # Live-apply with LiveBanner
RouteRecordingTab.tsx # Live-apply with LiveBanner
LiveBanner.tsx # Shared amber "changes apply immediately" banner
DeploymentTab/
DeploymentTab.tsx # Composition: StatusCard + DeploymentProgress + StartupLogPanel + History
StatusCard.tsx # RUNNING / STARTING / FAILED indicator + replica count + URL + actions
HistoryDisclosure.tsx # Past deployments table with inline log expansion
hooks/
useDeploymentPageState.ts # Form-state orchestrator (monitoring, resources, variables, sensitiveKeys)
useFormDirty.ts # Per-tab dirty computation via JSON.stringify compare
useUnsavedChangesBlocker.ts # React Router v6 useBlocker + DS AlertDialog
utils/
deriveAppName.ts # Filename → app name pure function
deriveAppName.test.ts # 9 Vitest cases
```
Touched shared files:
- `ui/src/components/StartupLogPanel.tsx` — accepts `className`, flex-grows in container (dropped fixed 300px maxHeight)
- `ui/src/api/queries/admin/apps.ts` — added `useDirtyState`, `Deployment.deployedConfigSnapshot` type
- `ui/src/api/queries/commands.ts``useUpdateApplicationConfig` accepts `apply?: 'staged' | 'live'`
- `ui/src/router.tsx` — routes `/apps/new` and `/apps/:appId` to `AppDeploymentPage`
- `ui/src/pages/AppsTab/AppsTab.tsx` — shrunk 1387 → 109 lines (list only)
### Docs delivered
- `.claude/rules/ui.md` — Deployments bullet rewritten for the unified page
- `.claude/rules/app-classes.md``ApplicationConfigController` gains `?apply` note; `AppController` gains dirty-state endpoint; `PostgresDeploymentRepository` notes the snapshot column
- `docs/superpowers/specs/2026-04-22-app-deployment-page-design.md`
- `docs/superpowers/plans/2026-04-22-app-deployment-page.md`
## Gitea issues opened this session (cameleer/cameleer-server)
### [#147 — Concurrent-edit protection on app deployment page (optimistic locking)](https://gitea.siegeln.net/cameleer/cameleer-server/issues/147)
Deferred during brainstorming. Two browser sessions editing the same app have no last-write-wins protection. Proposed fix is `If-Match` / `ETag` on config + container-config + JAR upload endpoints using `app.updated_at`. Not blocking single-operator use.
### [#148 — Persist deployment-page monitoring fields end-to-end](https://gitea.siegeln.net/cameleer/cameleer-server/issues/148)
**Important.** The Monitoring tab renders five controls that are currently **UI-only**: `payloadSize` + `payloadUnit`, `metricsInterval`, `replayEnabled`, `routeControlEnabled`. They do not persist to the agent because the fields don't exist on `com.cameleer.common.model.ApplicationConfig` and aren't part of the agent protocol. The old `CreateAppView` had the same gap — this is not a new regression, but the user has stated these must actually affect agent behavior. Fix requires cross-repo work (cameleer-common model additions + cameleer-server server wiring + cameleer agent protocol handling + agent-side gating behaviour).
## Open gaps to tackle next session
### 1. Task 13.1 — finish manual browser QA
Partial coverage so far: save/redeploy happy path, ENV pill styling, tab seam, variables view switcher, toast (all landed + verified). Still unverified:
- Checkpoint restore flow (hydrate form from past snapshot → Save → Redeploy)
- Deploy failure path (FAILED status → snapshot stays null → primary button still shows Redeploy)
- Unsaved-changes dialog on in-app navigation (sidebar click with dirty form)
- Env switch with dirty form (should discard silently)
- End-to-end deploy against real Docker daemon — see "Docker deploy setup" below
- Per-tab `*` dirty marker visibility across all 4 staged tabs
### 2. Docker deploy setup (needed to fully exercise E2E)
Current `docker-compose.yml` sets `CAMELEER_SERVER_RUNTIME_ENABLED: "false"` so `DisabledRuntimeOrchestrator` rejects deploys with `UnsupportedOperationException`. To actually test deploy end-to-end, pick one:
- **Path A (quick):** `docker compose up -d cameleer-postgres cameleer-clickhouse` only, then `mvn -pl cameleer-server-app spring-boot:run` on the host + `npm run dev` for the UI. Server uses host Docker daemon directly. Runtime enabled by default via `application.yml`.
- **Path B (compose-native):** enable runtime in compose by mounting `/var/run/docker.sock`, setting `CAMELEER_SERVER_RUNTIME_ENABLED: "true"` + `CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-traefik`, pre-creating the `cameleer-traefik` network, adding `CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME` for shared JAR storage, and adding a Traefik service for routing. This is a fully separate task — would need its own plan.
Recommend Path A for finishing QA; Path B only if you want compose to be fully deployable.
### 3. Deferred code-review items
All flagged during the final integration review. None are blockers; each is a follow-up.
- **DEGRADED deployments aren't checkpoints** — `PostgresDeploymentRepository.findLatestSuccessfulByAppAndEnv` filters `status = 'RUNNING'` but the executor writes the snapshot before the status is resolved (so a DEGRADED deployment has a snapshot). Either include `DEGRADED` in the filter, or skip snapshot on DEGRADED. Pick one; document the choice.
- **`Checkpoints.tsx` restore on null snapshot is a silent no-op** — should surface a toast like "This checkpoint predates snapshotting and cannot be restored." Currently returns early with no feedback.
- **Missing IT: FAILED deploy leaves snapshot NULL** — `DeploymentSnapshotIT` tests the success case and general "snapshot appears on RUNNING" but doesn't explicitly lock in the FAILED → null guarantee. Add a one-line assertion.
- **`HistoryDisclosure` expanded log doesn't `scrollIntoView`** — on long histories the startup-log panel opens off-screen. Minor UX rough edge.
- **OpenAPI `@Parameter` missing on `apply` query param** — not critical, just improves generated Swagger docs. Add `@Parameter(name = "apply", description = "staged | live (default: live)")` to `ApplicationConfigController.updateConfig`.
### 4. Minor tech debt introduced this session
- `samplingRate` normalization hack in `useDeploymentPageState.ts`: `Number.isInteger(x) ? \`${x}.0\` : String(x)` — works around `1.0` parsing back as `1`, but breaks for values like `1.10` (round-trips to `1.1`). A cleaner fix is to compare as numbers, not strings, in `useFormDirty`.
- `useDirtyState` defaults to `?? true` during loading (so the button defaults to `Redeploy`, the fail-safe choice). Spurious Redeploy clicks are harmless, but the "Save (disabled)" UX would be more correct during initial load. Consider a loading-aware ternary if it becomes user-visible.
- `ApplicationConfigController.updateConfig` returns `ResponseEntity.status(400).build()` (empty body) on unknown `apply` values. Consider a structured error body consistent with other 400s in the codebase.
- GitNexus index stats (`AGENTS.md`, `CLAUDE.md`) refreshed several times during the session — these are auto-generated and will refresh again on next `npx gitnexus analyze`.
### 5. Behavioural caveats to know about
- **Agent config writes from the Dashboard / Runtime pages** still use `useUpdateApplicationConfig` with default `apply='live'` — they push SSE immediately as before. Only Deployment-page writes use `apply=staged`. This is by design.
- **Traces & Taps + Route Recording tabs** on the Deployment page write with `apply='live'` (immediate SSE). They do **not** participate in dirty detection. The LiveBanner explains this to the user.
- **Slug is immutable** — enforced both server-side (regex + Jackson drops unknown fields on PUT) and client-side (IdentitySection renders slug as `MonoText`, never `Input`).
- **Environment is immutable after create** — the deployment page has no env selector; the environment chip is read-only and colored via `envColorVar` per the env's configured color.
- **Dirty detection ignores `version`, `updatedAt`, `updatedBy`, `environment`, `application`** on agent config — these get bumped server-side on every save and would otherwise spuriously mark the page dirty. Scrubbing happens in `DirtyStateCalculator.scrubAgentConfig`.
## Recommended next-session kickoff
1. Run `docker compose up -d cameleer-postgres cameleer-clickhouse`, then `mvn -pl cameleer-server-app spring-boot:run` and `npm run dev` in two terminals.
2. Walk through the rest of Task 13.1 (checkpoint restore, deploy failure, unsaved dialog, env switch).
3. File any new bugs found. Address the deferred review items (section 3) in small PR-sized commits.
4. Decide which of #148's cross-repo work to tackle — cleanest path is: (a) extend `ApplicationConfig` in cameleer-common, (b) wire server side, (c) coordinate agent-side behaviour gating.
5. If you want compose-native deploy, open a separate ticket or spec for Path B from "Docker deploy setup" above.
## Commit range summary
```
1a376eb2..0a71bca7 (43 commits)
ff951877 db(deploy): add deployments.deployed_config_snapshot column (V3)
d580b6e9 core(deploy): add DeploymentConfigSnapshot record
06fa7d83 core(deploy): type jarVersionId as UUID (match domain convention)
7f9cfc7f core(deploy): add deployedConfigSnapshot field to Deployment model
d3e86b9d storage(deploy): persist deployed_config_snapshot as JSONB
9b851c46 test(deploy): autowire repository in snapshot IT (JavaTimeModule-safe)
a79eafea runtime(deploy): capture config snapshot on RUNNING transition
9b124027 test(deploy): assert containerConfig round-trip + strict RUNNING in snapshot IT
76129d40 api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config
e716dbf8 test(config): verify audit action in staged/live config IT
76352c0d test(config): tighten audit assertions + @DirtiesContext on ApplicationConfigControllerIT
e4ccce1e core(deploy): add DirtyStateCalculator + DirtyStateResult
24464c07 core(deploy): recurse into nested diffs + unquote scalar values in DirtyStateCalculator
6591f2fd api(apps): GET /apps/{slug}/dirty-state returns desired-vs-deployed diff
97f25b4c test(deploy): register JavaTimeModule in DirtyStateCalculator unit test
0434299d api(schema): regenerate OpenAPI + schema.d.ts for deployment page
60529757 ui(deploy): scaffold AppDeploymentPage + route /apps/new and /apps/:slug
52ff385b ui(api): add useDirtyState + apply=staged|live on useUpdateApplicationConfig
d067490f ui(deploy): add deriveAppName pure function + tests
00c7c0cd ui(deploy): Identity & Artifact section with filename auto-derive
08efdfa9 ui(deploy): Checkpoints disclosure (hides current deployment, flags pruned JARs)
cc193a10 ui(deploy): add useDeploymentPageState orchestrator hook
4f5a11f7 ui(deploy): extract MonitoringTab component
5c48b780 ui(deploy): extract ResourcesTab component
bb06c4c6 ui(deploy): extract VariablesTab component
f487e6ca ui(deploy): extract SensitiveKeysTab component
b7c0a225 ui(deploy): LiveBanner component for live-apply tabs
e96c3cd0 ui(deploy): Traces & Taps + Route Recording tabs with live banner
98a7b781 ui(deploy): StatusCard for Deployment tab
063a4a55 ui(deploy): HistoryDisclosure with inline log expansion
1579f10a ui(deploy): DeploymentTab + flex-grow StartupLogPanel
42fb6c8b ui(deploy): useFormDirty hook for per-tab dirty markers
0e4166bd ui(deploy): PrimaryActionButton + computeMode state-machine helper
b1bdb88e ui(deploy): compose page — save/redeploy/checkpoints wired end-to-end
3a649f40 ui(deploy): router blocker + DS dialog for unsaved edits
5a7c0ce4 ui(deploy): delete CreateAppView + AppDetailView + ConfigSubTab
d5957468 docs(rules): update ui.md Deployments bullet for unified deployment page
6d5ce606 docs(rules): document ?apply flag + snapshot column in app-classes
d33c039a fix(deploy): address final review — sensitiveKeys snapshot, dirty scrubbing, transition race, refetch invalidations
b7b6bd2a ui(deploy): port missing agent-config fields, var-view switcher, env pill, tab seam
0a71bca7 fix(deploy): redeploy button after save, disable save when clean, success toast
```
Plus this handoff commit + the GitNexus index-stats refresh.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
# Unified App Deployment Page — Design
**Status:** Design approved, awaiting implementation plan
**Date:** 2026-04-22
**Related issue:** [cameleer-server#147](https://gitea.siegeln.net/cameleer/cameleer-server/issues/147) (concurrent-edit protection — deferred)
## Problem
Today, managing an application is split across two pages:
- `/apps/new` (`CreateAppView`) — form to create + initially deploy an app. Requires manually entering name and slug, picking an environment from a dropdown, selecting a JAR, and a "deploy immediately" toggle.
- `/apps/:slug` (`AppDetailView`) — manages an existing app. Has an `Upload JAR` button in the header that uploads immediately, and an `Overview` / `Configuration` sub-tab split. Config saves are pushed live to agents via SSE the moment Save is clicked.
Pain points:
1. Users can't stage a configuration change without immediately applying it (agent config tab is live-push; container config requires a full redeploy). There's no "draft next deploy" concept.
2. The primary action doesn't reflect deploy state — `Upload JAR` remains the label even when a new JAR has been uploaded and is waiting to be deployed.
3. App name must be typed manually. The JAR filename is the obvious source and isn't used.
4. The environment picker on the create page duplicates the environment already chosen in the top-nav switcher, inviting mistakes (create app in wrong env).
5. After deploy, the deployment progress bar and startup log disappear from the page lifecycle once the user navigates away or the deploy completes, so users can't revisit "what happened during the last deploy?" without round-tripping through ClickHouse logs.
6. The full config of an app is split across two sub-tabs (`Configuration` for monitoring/resources/variables/traces/recording, `Overview` for versions/deployments), which forces context switches for routine checks.
## Goal
One unified deployment page that handles the full lifecycle of an app — from initial creation through every subsequent redeploy — with a clear Save-then-Deploy two-step workflow, a dirty-state model that makes "what will change on redeploy" explicit, and persistent access to the last deployment's progress + log.
## Non-goals
- Real-time collaborative editing, presence awareness, or optimistic-locking protection against concurrent edits (tracked in issue #147).
- Restructuring the environment model, slug rules, or any backend orchestration mechanics beyond what's required for staged-vs-live config writes and deployment snapshotting.
- Changing the agent SSE protocol.
- Pruning or archiving JAR versions (retention is an environment-level setting, already exists).
## Design
### Page structure
Routes:
- `/apps/new` — unified page in **net-new mode** (no app record exists yet).
- `/apps/:slug` — unified page in **existing-app mode**.
The `CreateAppView` / `AppDetailView` split goes away. A single component (`AppDeploymentPage`) renders both modes; the only differences are which fields are editable and which buttons are enabled.
Layout top-to-bottom:
1. **Page header** — title (app display name or "Create Application"), env badge, status badge, Delete App action (existing apps only), and the **primary action button** (Save / Redeploy / Deploying…).
2. **Identity & Artifact section** — always visible.
3. **Config tabs row**`Monitoring | Resources | Variables | Sensitive Keys | Deployment | ● Traces & Taps | ● Route Recording`.
4. **Active tab content.**
The old `Overview` sub-tab is removed. Its deployments table becomes the Deployment tab's history disclosure; its version list is rolled into the Identity & Artifact section as a Checkpoints disclosure.
### Identity & Artifact section
| Field | Net-new mode | Existing / deployed mode |
|---|---|---|
| Application Name | `Input`, editable | read-only display text |
| Slug | auto-derived from name, displayed for preview only; never directly editable | read-only display (slug is immutable post-create per project conventions) |
| Environment | read-only chip showing currently-selected env | read-only chip |
| External URL | computed preview (existing formula — `routingMode === 'subdomain'` vs path-style) | same |
| Current Version | — | `v5 · payment-gateway-1.2.3.jar · 42 MB · 3 days ago` |
| Application JAR | `Select JAR` button; shows filename + size once staged client-side | `Change JAR` button; shows "staged: `<filename>`" badge when a new JAR is pending |
| Checkpoints | disclosure; empty when no prior successful deploys | disclosure; lists past successful deployments |
**Auto-derive rule** — triggered when the user selects a JAR file and the name field is empty OR still matches the previously auto-derived value (never overwrite manual edits):
1. Take filename, strip `.jar`.
2. Truncate at the first character that is a digit (`0-9`) or a `.`.
3. Replace `-` and `_` with spaces.
4. Strip any resulting 1-char orphan tokens (e.g. trailing `v` from `my-app-v2`).
5. Title-case remaining words.
The derived name is a suggestion — the user can override by typing.
Examples:
- `payment-gateway-1.2.0.jar``Payment Gateway`
- `order-service.jar``Order Service`
- `my-app-v2.jar``My App`
- `acme_billing-3.jar``Acme Billing`
**Slug derivation** remains the existing `slugify(name)` logic. The user cannot edit slug directly in net-new mode (auto-tracks name) and cannot edit at all post-create (immutable per existing project conventions).
### Checkpoints (past deployments as restore points)
A checkpoint = one past **successful** deployment, carrying the full snapshot `{jarVersionId, agentConfig, containerConfig, sensitiveKeys}` frozen at deploy time. JARs that were uploaded but never successfully deployed do not appear — they are obsolete freight.
**Restore flow:**
1. User expands Checkpoints, picks a row.
2. Form fields across all four staged tabs reset to that snapshot's values; JAR slot points to the snapshot's JAR version (by checksum reference — no re-upload).
3. Dirty evaluation re-runs against the **latest successful deploy snapshot**, as always → the primary button becomes `Redeploy`.
4. The user may tweak further before deploying or deploy as-is.
Restore is pure client-state hydration — it doesn't write to DB until the user clicks Save.
**Edge cases:**
- The currently-running deployment is **hidden** from the Checkpoints list (restoring to it is equivalent to Discard).
- A checkpoint whose JAR version has been pruned (per the env-level retention policy) shows as "archived, JAR unavailable" with the Restore action disabled and a tooltip explaining why.
Collapsed by default.
### Dirty state + primary button
**What counts as dirty** (any one is sufficient):
- A new JAR file is staged in client state (not yet uploaded).
- A selected past version (via Restore) differs from the currently-deployed version.
- Form values on any of the four **staged** tabs (Monitoring, Resources, Variables, Sensitive Keys) differ from the last-saved DB values.
- DB-saved config differs from the snapshot captured at the last successful deploy.
**What does not count:**
- Changes on Traces & Taps or Route Recording tabs (live-apply — see below).
- Changes made via Dashboard / Runtime pages.
**State machine:**
| App state | Form has unsaved local edits? | DB matches last deploy? | Button label | Action |
|---|---|---|---|---|
| Net-new, nothing entered | — | — | `Save` | disabled |
| Net-new, form has content | yes | n/a | `Save` | create app + upload JAR + write config; transitions to "exists, no deploy yet" |
| Exists, no deploy yet | either | no (never deployed) | `Redeploy` | deploy current DB state |
| Exists, form edits pending | yes | either | `Save` | persist local edits; after save, re-evaluates to `Save` (disabled) or `Redeploy` |
| Exists, nothing local, DB = deploy | no | yes | `Save` | disabled |
| Exists, nothing local, DB ≠ deploy | no | no | `Redeploy` | deploy DB state |
| Deploy in progress | — | — | `Deploying…` | disabled, spinner |
A secondary `Discard` ghost button appears adjacent to the primary button whenever the form has unsaved local edits. It resets form fields to DB-saved values.
**Net-new first-deploy flow** — clicking Save on a net-new form creates the app record, uploads the JAR as version 1, persists container + agent config, and routes to `/apps/:slug`. It does **not** deploy. The transition lands the user on the same page in existing-app mode with the button showing `Redeploy`. This is the deliberate trade-off for unifying the button label across modes.
### Traces & Taps + Route Recording — live-apply tabs
These tabs remain on the Deployment page (single-source-of-truth for the full config) but are visually distinguished:
- A persistent info banner at the top of each: *"Live controls — changes apply immediately to running agents and do not participate in the Save/Redeploy cycle."*
- Tab labels carry a `●` live indicator.
- Editors remain fully interactive — user still manages processors and route recording from this page.
- These tabs' writes do **not** flip the dirty indicator; the primary button is unaffected.
### Deployment tab
Auto-activates when the user clicks Redeploy (and when landing on a page whose app currently has a STARTING deployment).
Contents top-to-bottom:
1. **Current deployment card** — status badge + `StatusDot`, version, JAR filename, JAR checksum (short), replica count, external URL (linkified when RUNNING), deployed-at timestamp. Action buttons: `Stop` (RUNNING/STARTING/DEGRADED), `Start` (STOPPED).
2. **Progress bar** — only rendered when `status === STARTING`. Existing `DeploymentProgress` 7-stage step indicator, unchanged.
3. **Startup log panel** — existing `StartupLogPanel`, uses `useStartupLogs` (3s polling while STARTING).
- Flex-grow inside the tab: fills whatever vertical space is left after the status card, progress bar, and history disclosure.
- Minimum height ~200px. Internal scroll on overflow.
- Does **not** auto-close on success or failure. Remains mounted until the user navigates away or a newer deploy replaces its content.
4. **History disclosure** (collapsed by default) — compact table of past deployments: timestamp, version, status, duration, started by. Row click expands its startup log inline (lazy-loaded). This is also the raw JAR-version-history affordance.
**Empty state** (net-new, no deploys ever): `No deployments yet. Save your configuration and click Redeploy to launch.`
**Behavior during an active deploy:**
- Primary button: `Deploying…` (disabled).
- Config tabs remain editable — the user can stage the next iteration while the current one runs.
- Local edits during deploy cannot be saved until the current deploy completes. Once it does, button re-evaluates normally.
### Backend changes
#### 1. Agent config write path gains a staged/live flag
The existing `ApplicationConfigController` endpoint persists config to DB **and** pushes an SSE `config-update` to live agents in one atomic call.
**Change:** add a query parameter `?apply=staged|live` (default `live`, preserving existing non-UI callers).
- `apply=staged` — write to DB only, no SSE push. Used by the deployment page.
- `apply=live` — write to DB and push SSE. Used by the existing real-time UI on Dashboard / Runtime pages, and any non-UI caller that relies on current behavior.
This keeps one endpoint and one DTO. The gating happens in the service layer.
#### 2. Deployment snapshot column
Flyway V2 adds `deployed_config_snapshot JSONB` to the `deployments` table:
```
ALTER TABLE deployments
ADD COLUMN deployed_config_snapshot JSONB;
```
The snapshot contains `{jarVersionId, agentConfig, containerConfig, sensitiveKeys}` captured at the moment a deployment transitions to a successful `RUNNING` state (not at deploy start — see failure semantics below).
**No backfill for existing deployments.** The column is `NULL` for historical rows. Dirty detection treats "no snapshot on last successful deployment" the same as "no successful deployment" — everything is dirty, and the first Redeploy after migration will populate the first snapshot. This is acceptable because dirty-state is the only reader of the column.
Dirty check reads the last successful deployment's snapshot for the `(app, environment)` pair and compares against the current DB state. If no successful deploy exists yet (or the snapshot is NULL), everything is dirty by definition.
#### 3. Dirty-state endpoint
```
GET /api/v1/environments/{env}/apps/{slug}/dirty-state
```
Returns:
```json
{
"dirty": true,
"lastSuccessfulDeploymentId": "…",
"differences": [
{ "field": "agentConfig.samplingRate", "staged": "1.0", "deployed": "0.5" },
{ "field": "containerConfig.memoryLimitMb", "staged": "1024", "deployed": "512" },
{ "field": "jarVersion", "staged": "v6", "deployed": "v5" }
]
}
```
The UI uses this to drive the button label and per-tab dirty markers (asterisks on tab labels). Keeping the comparison server-side means the source of truth for "what will change on redeploy" is one service rather than two implementations at risk of drift.
#### 4. Checkpoint restore — no new endpoint
Past deployments are already queryable via `GET /deployments`. The restore action is pure client-side: pick a deployment, read its `deployed_config_snapshot`, hydrate form fields. The server sees only the eventual Save + Redeploy calls.
#### 5. JAR upload staging — no API change
Client-state only until Save. The existing `POST /apps/{slug}/versions` multipart endpoint is unchanged; it's invoked during the Save handler as part of a sequence (create app if needed → upload JAR → write config with `?apply=staged`).
### Migration & clean-break
- `ui/src/pages/AppsTab/AppsTab.tsx` (1387 lines) is split. `AppListView` stays. New directory `ui/src/pages/AppsTab/AppDeploymentPage/` contains the unified page, split into child files for the Identity section, each config tab, the Deployment tab, Checkpoints, and shared hooks (dirty detection, config sync, filename → name derivation).
- `CreateAppView`, `AppDetailView`, `OverviewSubTab`, `ConfigSubTab`, `VersionRow` are deleted.
- No backwards-compat shims, no legacy flags, no query-string redirects. Removed sub-routes (`/apps/:slug?tab=overview`) simply land on the default tab.
- `.claude/rules/ui.md` Deployments bullet is rewritten in the same commit.
- `.claude/rules/app-classes.md` (if it documents controllers) notes the new `?apply=staged|live` parameter.
- OpenAPI schema is regenerated per the CLAUDE.md procedure. `ui/src/api/openapi.json` and `ui/src/api/schema.d.ts` are regenerated and committed alongside the backend change.
### Failure modes & edge cases
- **Save failure (JAR upload timeout, DB error):** button returns to `Save`. Form keeps local edits. Toast with the error (24h duration — matches existing AppsTab pattern). No partial commits — if JAR upload succeeds but config write fails, the orphan JAR version is harmless.
- **Deploy failure:** `Deploying…``Redeploy` (still dirty, snapshot not written). Progress bar sticks on the failed stage (red). Log stays mounted. User can fix config or upload different JAR, re-Save, click Redeploy again.
- **Snapshot-on-success-only:** `deployed_config_snapshot` is populated only when a deployment reaches a successful `RUNNING` state. Failed deployments exist in history but do not participate in "last known good".
- **User edits form during active deploy:** config tabs editable, primary button stays `Deploying…`. On completion, button re-evaluates against the new snapshot.
- **Concurrent edit (two users, same app):** out of scope for v1 — tracked in [#147](https://gitea.siegeln.net/cameleer/cameleer-server/issues/147). Current behavior: last-write-wins.
- **Browser refresh during active deploy:** state is server-side. Progress re-renders from `deployment.deployStage`, log re-fetches from startup logs endpoint. Deployment tab auto-activates on load if any `STARTING` deployment exists; otherwise default is Monitoring.
- **Unsaved-change warning on navigation:** router-level blocker using the DS `ConfirmDialog` (same pattern as existing delete-app confirmation). Triggered when form has staged edits and the user navigates away via sidebar, back button, or any in-app route change. Not `window.beforeunload` — DS-themed dialog only.
- **Environment switch:** intentionally discards unsaved work. No warning. Page remounts per existing behavior.
- **App doesn't exist in selected env:** 404 via `@EnvPath`. Preserve the existing "Unmanaged Application" empty state when the app exists in catalog (discovered via agent) but has no managed record in this env, with the "Create Managed App" CTA.
### Testing
**Backend (integration, REST-API-driven per project preference):**
- Net-new save flow: `POST apps → POST versions → PUT config?apply=staged → PUT container-config` completes without creating any deployment row.
- `?apply=staged` write does not emit SSE `config-update` to a connected agent; `?apply=live` write does.
- `deployed_config_snapshot` is populated on a deployment that reaches RUNNING; not populated on a deployment that reaches FAILED.
- `GET /dirty-state` returns `dirty=true` when desired state differs from the last-successful-deployment snapshot; `dirty=false` when they match.
- Checkpoint restore: hydrating form from a past deployment's snapshot and saving produces a new desired state identical to the snapshot.
**UI (Vitest):**
- Dirty-detection pure function against a matrix of input combinations.
- Filename → name derivation against the examples table above (including orphan stripping and `_` handling).
- Router blocker dialog opens on nav-away with dirty form; does not open on clean form.
**Manual browser verification (per CLAUDE.md):** walk through the 4 visual states (net-new, clean, dirty, deploying) including an end-to-end Save → Redeploy cycle, a checkpoint restore, and a deploy failure path before claiming done.
## Open questions carried forward
- Issue [#147](https://gitea.siegeln.net/cameleer/cameleer-server/issues/147) — optimistic locking / concurrent-edit protection. Deferred.
## Visual reference
ASCII mockups (State A: net-new, State B: deployed clean, State C: dirty with staged JAR, State D: active deploy on Deployment tab) are preserved in the brainstorming transcript. When implementing, these are the target screens.

File diff suppressed because one or more lines are too long

View File

@@ -41,6 +41,12 @@ export interface Deployment {
deployedAt: string | null;
stoppedAt: string | null;
createdAt: string;
deployedConfigSnapshot?: {
jarVersionId: string;
agentConfig: Record<string, unknown> | null;
containerConfig: Record<string, unknown>;
sensitiveKeys: string[] | null;
} | null;
}
/**
@@ -203,3 +209,27 @@ export function usePromoteDeployment() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}
// --- Dirty State ---
export interface DirtyStateDifference {
field: string;
staged: string;
deployed: string;
}
export interface DirtyState {
dirty: boolean;
lastSuccessfulDeploymentId: string | null;
differences: DirtyStateDifference[];
}
export function useDirtyState(envSlug: string | undefined, appSlug: string | undefined) {
return useQuery({
queryKey: ['apps', envSlug, appSlug, 'dirty-state'],
queryFn: () => apiFetch<DirtyState>(
`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/dirty-state`,
),
enabled: !!envSlug && !!appSlug,
});
}

View File

@@ -83,9 +83,13 @@ export interface ConfigUpdateResponse {
export function useUpdateApplicationConfig() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ config, environment }: { config: ApplicationConfig; environment: string }) => {
mutationFn: async ({ config, environment, apply = 'live' }: {
config: ApplicationConfig;
environment: string;
apply?: 'staged' | 'live';
}) => {
const res = await authFetch(
`/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(config.application)}/config`, {
`/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(config.application)}/config?apply=${apply}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),

View File

@@ -54,7 +54,7 @@ export interface paths {
get: operations["getConfig"];
/**
* Update application config for this environment
* @description Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment
* @description Saves config. When apply=live (default), also pushes CONFIG_UPDATE to LIVE agents. When apply=staged, persists without a live push — the next successful deploy applies it.
*/
put: operations["updateConfig"];
post?: never;
@@ -1587,6 +1587,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/environments/{envSlug}/apps/{appSlug}/dirty-state": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Check whether the app's current config differs from the last successful deploy
* @description Returns dirty=true when the desired state (current JAR + agent config + container config) would produce a changed deployment. When no successful deploy exists yet, dirty=true.
*/
get: operations["getDirtyState"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/environments/{envSlug}/apps/{appSlug}/deployments/{deploymentId}": {
parameters: {
query?: never;
@@ -2718,6 +2738,7 @@ export interface components {
resolvedConfig?: {
[key: string]: Record<string, never>;
};
deployedConfigSnapshot?: components["schemas"]["DeploymentConfigSnapshot"];
/** Format: date-time */
deployedAt?: string;
/** Format: date-time */
@@ -2725,6 +2746,14 @@ export interface components {
/** Format: date-time */
createdAt?: string;
};
DeploymentConfigSnapshot: {
/** Format: uuid */
jarVersionId?: string;
agentConfig?: components["schemas"]["ApplicationConfig"];
containerConfig?: {
[key: string]: Record<string, never>;
};
};
PromoteRequest: {
targetEnvironment?: string;
};
@@ -3294,6 +3323,16 @@ export interface components {
height?: number;
endpointUri?: string;
};
Difference: {
field?: string;
staged?: string;
deployed?: string;
};
DirtyStateResponse: {
dirty?: boolean;
lastSuccessfulDeploymentId?: string;
differences?: components["schemas"]["Difference"][];
};
AppConfigResponse: {
config?: components["schemas"]["ApplicationConfig"];
globalSensitiveKeys?: string[];
@@ -3833,6 +3872,7 @@ export interface operations {
parameters: {
query: {
env: components["schemas"]["Environment"];
apply?: string;
};
header?: never;
path: {
@@ -3846,7 +3886,7 @@ export interface operations {
};
};
responses: {
/** @description Config saved and pushed */
/** @description Config saved (and pushed if apply=live) */
200: {
headers: {
[name: string]: unknown;
@@ -3855,6 +3895,15 @@ export interface operations {
"*/*": components["schemas"]["ConfigUpdateResponse"];
};
};
/** @description Unknown apply value (must be 'staged' or 'live') */
400: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["ConfigUpdateResponse"];
};
};
};
};
update_1: {
@@ -7224,6 +7273,39 @@ export interface operations {
};
};
};
getDirtyState: {
parameters: {
query: {
env: components["schemas"]["Environment"];
};
header?: never;
path: {
appSlug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Dirty-state computed */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DirtyStateResponse"];
};
};
/** @description App not found in this environment */
404: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DirtyStateResponse"];
};
};
};
};
getDeployment: {
parameters: {
query: {

View File

@@ -4,6 +4,9 @@
border-radius: 6px;
overflow: hidden;
margin-top: 8px;
display: flex;
flex-direction: column;
min-height: 0;
}
.header {

View File

@@ -8,9 +8,10 @@ interface StartupLogPanelProps {
deployment: Deployment;
appSlug: string;
envSlug: string;
className?: string;
}
export function StartupLogPanel({ deployment, appSlug, envSlug }: StartupLogPanelProps) {
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
const isStarting = deployment.status === 'STARTING';
const isFailed = deployment.status === 'FAILED';
@@ -21,7 +22,7 @@ export function StartupLogPanel({ deployment, appSlug, envSlug }: StartupLogPane
if (entries.length === 0 && !isStarting) return null;
return (
<div className={styles.panel}>
<div className={`${styles.panel}${className ? ` ${className}` : ''}`}>
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.title}>Startup Logs</span>
@@ -38,7 +39,7 @@ export function StartupLogPanel({ deployment, appSlug, envSlug }: StartupLogPane
<span className={styles.lineCount}>{entries.length} lines</span>
</div>
{entries.length > 0 ? (
<LogViewer entries={entries as unknown as LogEntry[]} maxHeight={300} />
<LogViewer entries={entries as unknown as LogEntry[]} />
) : (
<div className={styles.empty}>Waiting for container output...</div>
)}

View File

@@ -0,0 +1,291 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 24px;
min-height: 100%;
}
/* Tabs + content grouped together with no internal gap */
.tabGroup {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
/* The tab-content card sits flush against the Tabs strip — no gap */
.tabContent {
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
padding: 16px;
background: var(--bg-surface);
flex: 1 1 auto;
min-height: 0;
}
.section {
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
background: var(--bg-surface);
}
.configGrid {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px 16px;
align-items: center;
margin-top: 8px;
}
.configLabel {
color: var(--text-muted);
font-size: 13px;
}
.readOnlyValue {
color: var(--text-primary);
font-size: 14px;
}
.fileRow {
display: flex;
align-items: center;
gap: 10px;
}
.stagedJar {
color: var(--amber);
font-size: 13px;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.checkpointsRow {
margin-top: 8px;
}
.disclosureToggle {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
padding: 4px 0;
}
.checkpointList {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 0 0 12px;
}
.checkpointRow {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.checkpointMeta {
color: var(--text-muted);
}
.checkpointArchived {
color: var(--warning);
font-size: 12px;
}
.checkpointEmpty {
color: var(--text-muted);
font-size: 13px;
}
/* Config tab shared */
.configInline {
display: flex;
align-items: center;
gap: 6px;
}
.configHint {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
margin-top: 2px;
}
.cellMeta {
font-size: 12px;
color: var(--text-muted);
}
.toggleEnabled {
font-size: 12px;
color: var(--success);
}
.toggleDisabled {
font-size: 12px;
color: var(--text-muted);
}
/* Fixed-width inputs */
.inputXs {
width: 50px;
}
.inputSm {
width: 60px;
}
.inputMd {
width: 70px;
}
.inputLg {
width: 80px;
}
.inputXl {
width: 90px;
}
/* Port pills */
.portPills {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.portPill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-family: var(--font-mono);
background: var(--bg-raised);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
}
.portPillDelete {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
line-height: 1;
padding: 0;
}
.portPillDelete:hover {
color: var(--error);
}
.portPillDelete:disabled {
opacity: 0.3;
cursor: default;
}
.portAddInput {
width: 70px;
padding: 3px 6px;
border: 1px dashed var(--border-subtle);
border-radius: 12px;
background: transparent;
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-mono);
text-align: center;
}
.portAddInput::placeholder {
color: var(--text-muted);
}
.portAddInput:disabled {
opacity: 0.3;
cursor: default;
}
.liveBanner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
}
/* StatusCard */
.statusCard {
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
background: var(--bg-surface);
display: flex;
flex-direction: column;
gap: 12px;
}
.statusCardHeader { display: flex; align-items: center; gap: 8px; }
.statusCardGrid { display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px; font-size: 13px; }
.statusCardActions { display: flex; gap: 8px; }
/* DeploymentTab */
.deploymentTab {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1 1 auto;
min-height: 0;
}
.logFill { flex: 1 1 auto; min-height: 200px; }
/* HistoryDisclosure */
.historyRow { margin-top: 16px; }
/* Environment pill (Identity section) */
.envPill {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
color: var(--text-inverse, white);
width: max-content;
}
/* Env vars list */
.envVarsList {
display: flex;
flex-direction: column;
gap: 8px;
}
.envVarRow {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 8px;
align-items: center;
}

View File

@@ -0,0 +1,65 @@
import { useState } from 'react';
import { Button, Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
import { timeAgo } from '../../../utils/format-utils';
import styles from './AppDeploymentPage.module.css';
interface CheckpointsProps {
deployments: Deployment[];
versions: AppVersion[];
currentDeploymentId: string | null;
onRestore: (deploymentId: string) => void;
}
export function Checkpoints({ deployments, versions, currentDeploymentId, onRestore }: CheckpointsProps) {
const [open, setOpen] = useState(false);
const versionMap = new Map(versions.map((v) => [v.id, v]));
// Only successful deployments (RUNNING with a deployedAt). Exclude the currently-running one.
const checkpoints = deployments
.filter((d) => d.deployedAt && d.status === 'RUNNING' && d.id !== currentDeploymentId)
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
return (
<div className={styles.checkpointsRow}>
<button
type="button"
className={styles.disclosureToggle}
onClick={() => setOpen(!open)}
>
{open ? '▼' : '▶'} Checkpoints ({checkpoints.length})
</button>
{open && (
<div className={styles.checkpointList}>
{checkpoints.length === 0 && (
<div className={styles.checkpointEmpty}>No past deployments yet.</div>
)}
{checkpoints.map((d) => {
const v = versionMap.get(d.appVersionId);
const jarAvailable = !!v;
return (
<div key={d.id} className={styles.checkpointRow}>
<Badge label={v ? `v${v.version}` : '?'} color="auto" />
<span className={styles.checkpointMeta}>
{d.deployedAt ? timeAgo(d.deployedAt) : '—'}
</span>
{!jarAvailable && (
<span className={styles.checkpointArchived}>archived, JAR unavailable</span>
)}
<Button
size="sm"
variant="ghost"
disabled={!jarAvailable}
title={!jarAvailable ? 'JAR was pruned by the environment retention policy' : undefined}
onClick={() => onRestore(d.id)}
>
Restore
</Button>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { Info } from 'lucide-react';
import styles from '../AppDeploymentPage.module.css';
export function LiveBanner() {
return (
<div className={styles.liveBanner}>
<Info size={14} />
<span>
<strong>Live controls.</strong> Changes apply immediately to running agents and do
not participate in the Save/Redeploy cycle.
</span>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import { Select, Input, Toggle } from '@cameleer/design-system';
import type { MonitoringFormState } from '../hooks/useDeploymentPageState';
import styles from '../AppDeploymentPage.module.css';
interface Props {
value: MonitoringFormState;
onChange: (next: MonitoringFormState) => void;
disabled?: boolean;
}
const LOG_LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }));
export function MonitoringTab({ value, onChange, disabled }: Props) {
const update = <K extends keyof MonitoringFormState>(key: K, v: MonitoringFormState[K]) =>
onChange({ ...value, [key]: v });
return (
<div className={styles.configGrid}>
<span className={styles.configLabel}>Engine Level</span>
<Select
disabled={disabled}
value={value.engineLevel}
onChange={(e) => update('engineLevel', e.target.value)}
options={[
{ value: 'NONE', label: 'NONE' },
{ value: 'MINIMAL', label: 'MINIMAL' },
{ value: 'REGULAR', label: 'REGULAR' },
{ value: 'COMPLETE', label: 'COMPLETE' },
]}
/>
<span className={styles.configLabel}>Payload Capture</span>
<Select
disabled={disabled}
value={value.payloadCaptureMode}
onChange={(e) => update('payloadCaptureMode', e.target.value)}
options={[
{ value: 'NONE', label: 'NONE' },
{ value: 'INPUT', label: 'INPUT' },
{ value: 'OUTPUT', label: 'OUTPUT' },
{ value: 'BOTH', label: 'BOTH' },
]}
/>
<span className={styles.configLabel}>Max Payload Size</span>
<div className={styles.configInline}>
<Input
disabled={disabled}
value={value.payloadSize}
onChange={(e) => update('payloadSize', e.target.value)}
className={styles.inputMd}
placeholder="e.g. 4"
/>
<Select
disabled={disabled}
value={value.payloadUnit}
onChange={(e) => update('payloadUnit', e.target.value)}
options={[
{ value: 'B', label: 'bytes' },
{ value: 'KB', label: 'KB' },
{ value: 'MB', label: 'MB' },
]}
/>
</div>
<span className={styles.configLabel}>App Log Level</span>
<Select
disabled={disabled}
value={value.applicationLogLevel}
onChange={(e) => update('applicationLogLevel', e.target.value)}
options={LOG_LEVELS}
/>
<span className={styles.configLabel}>Agent Log Level</span>
<Select
disabled={disabled}
value={value.agentLogLevel}
onChange={(e) => update('agentLogLevel', e.target.value)}
options={LOG_LEVELS}
/>
<span className={styles.configLabel}>Metrics</span>
<div className={styles.configInline}>
<Toggle
checked={value.metricsEnabled}
onChange={() => !disabled && update('metricsEnabled', !value.metricsEnabled)}
disabled={disabled}
/>
<span className={value.metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>
{value.metricsEnabled ? 'Enabled' : 'Disabled'}
</span>
<span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span>
<Input
disabled={disabled}
value={value.metricsInterval}
onChange={(e) => update('metricsInterval', e.target.value)}
className={styles.inputXs}
placeholder="60"
/>
<span className={styles.cellMeta}>s</span>
</div>
<span className={styles.configLabel}>Sampling Rate</span>
<Input
disabled={disabled}
value={value.samplingRate}
onChange={(e) => update('samplingRate', e.target.value)}
className={styles.inputLg}
placeholder="1.0"
/>
<span className={styles.configLabel}>Compress Success</span>
<div className={styles.configInline}>
<Toggle
checked={value.compressSuccess}
onChange={() => !disabled && update('compressSuccess', !value.compressSuccess)}
disabled={disabled}
/>
<span className={value.compressSuccess ? styles.toggleEnabled : styles.toggleDisabled}>
{value.compressSuccess ? 'Enabled' : 'Disabled'}
</span>
</div>
<span className={styles.configLabel}>Replay</span>
<div className={styles.configInline}>
<Toggle
checked={value.replayEnabled}
onChange={() => !disabled && update('replayEnabled', !value.replayEnabled)}
disabled={disabled}
/>
<span className={value.replayEnabled ? styles.toggleEnabled : styles.toggleDisabled}>
{value.replayEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<span className={styles.configLabel}>Route Control</span>
<div className={styles.configInline}>
<Toggle
checked={value.routeControlEnabled}
onChange={() => !disabled && update('routeControlEnabled', !value.routeControlEnabled)}
disabled={disabled}
/>
<span className={value.routeControlEnabled ? styles.toggleEnabled : styles.toggleDisabled}>
{value.routeControlEnabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { useState } from 'react';
import { Select, Input, Toggle } from '@cameleer/design-system';
import type { ResourcesFormState } from '../hooks/useDeploymentPageState';
import styles from '../AppDeploymentPage.module.css';
interface Props {
value: ResourcesFormState;
onChange: (next: ResourcesFormState) => void;
disabled?: boolean;
isProd?: boolean;
}
export function ResourcesTab({ value, onChange, disabled, isProd = false }: Props) {
const [newPort, setNewPort] = useState('');
const [newNetwork, setNewNetwork] = useState('');
const update = <K extends keyof ResourcesFormState>(key: K, v: ResourcesFormState[K]) =>
onChange({ ...value, [key]: v });
function addPort() {
const p = parseInt(newPort);
if (p && !value.ports.includes(p)) {
onChange({ ...value, ports: [...value.ports, p] });
setNewPort('');
}
}
function removePort(port: number) {
if (!disabled) update('ports', value.ports.filter((x) => x !== port));
}
function addNetwork() {
const v = newNetwork.trim();
if (v && !value.extraNetworks.includes(v)) {
onChange({ ...value, extraNetworks: [...value.extraNetworks, v] });
setNewNetwork('');
}
}
function removeNetwork(network: string) {
if (!disabled) update('extraNetworks', value.extraNetworks.filter((x) => x !== network));
}
return (
<div className={styles.configGrid}>
<span className={styles.configLabel}>Runtime Type</span>
<Select
disabled={disabled}
value={value.runtimeType}
onChange={(e) => update('runtimeType', e.target.value)}
options={[
{ value: 'auto', label: 'Auto (detect from JAR)' },
{ value: 'spring-boot', label: 'Spring Boot' },
{ value: 'quarkus', label: 'Quarkus' },
{ value: 'plain-java', label: 'Plain Java' },
{ value: 'native', label: 'Native' },
]}
/>
<span className={styles.configLabel}>Custom Arguments</span>
<div>
<Input
disabled={disabled}
value={value.customArgs}
onChange={(e) => update('customArgs', e.target.value)}
placeholder="-Xmx256m -Dfoo=bar"
className={styles.inputLg}
/>
<span className={styles.configHint}>
{value.runtimeType === 'native'
? 'Arguments passed to the native binary'
: 'Additional JVM arguments appended to the start command'}
</span>
</div>
<span className={styles.configLabel}>Memory Limit</span>
<div className={styles.configInline}>
<Input
disabled={disabled}
value={value.memoryLimit}
onChange={(e) => update('memoryLimit', e.target.value)}
className={styles.inputLg}
placeholder="e.g. 512"
/>
<span className={styles.cellMeta}>MB</span>
</div>
<span className={styles.configLabel}>Memory Reserve</span>
<div>
<div className={styles.configInline}>
<Input
disabled={!isProd || disabled}
value={value.memoryReserve}
onChange={(e) => update('memoryReserve', e.target.value)}
placeholder="e.g. 256"
className={styles.inputLg}
/>
<span className={styles.cellMeta}>MB</span>
</div>
{!isProd && (
<span className={styles.configHint}>Available in production environments only</span>
)}
</div>
<span className={styles.configLabel}>CPU Request</span>
<Input
disabled={disabled}
value={value.cpuRequest}
onChange={(e) => update('cpuRequest', e.target.value)}
className={styles.inputLg}
placeholder="e.g. 500 millicores"
/>
<span className={styles.configLabel}>CPU Limit</span>
<div className={styles.configInline}>
<Input
disabled={disabled}
value={value.cpuLimit}
onChange={(e) => update('cpuLimit', e.target.value)}
placeholder="e.g. 1000"
className={styles.inputLg}
/>
<span className={styles.cellMeta}>millicores</span>
</div>
<span className={styles.configLabel}>Exposed Ports</span>
<div className={styles.portPills}>
{value.ports.map((p) => (
<span key={p} className={styles.portPill}>
{p}
<button
className={styles.portPillDelete}
disabled={disabled}
onClick={() => removePort(p)}
>
&times;
</button>
</span>
))}
<input
className={styles.portAddInput}
disabled={disabled}
placeholder="+ port"
value={newPort}
onChange={(e) => setNewPort(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addPort();
}
}}
/>
</div>
<span className={styles.configLabel}>App Port</span>
<Input
disabled={disabled}
value={value.appPort}
onChange={(e) => update('appPort', e.target.value)}
className={styles.inputLg}
placeholder="e.g. 8080"
/>
<span className={styles.configLabel}>Replicas</span>
<Input
disabled={disabled}
value={value.replicas}
onChange={(e) => update('replicas', e.target.value)}
className={styles.inputSm}
type="number"
placeholder="1"
/>
<span className={styles.configLabel}>Deploy Strategy</span>
<Select
disabled={disabled}
value={value.deployStrategy}
onChange={(e) => update('deployStrategy', e.target.value)}
options={[
{ value: 'blue-green', label: 'Blue/Green' },
{ value: 'rolling', label: 'Rolling' },
]}
/>
<span className={styles.configLabel}>Strip Path Prefix</span>
<div className={styles.configInline}>
<Toggle
checked={value.stripPrefix}
onChange={() => !disabled && update('stripPrefix', !value.stripPrefix)}
disabled={disabled}
/>
<span className={value.stripPrefix ? styles.toggleEnabled : styles.toggleDisabled}>
{value.stripPrefix ? 'Enabled' : 'Disabled'}
</span>
</div>
<span className={styles.configLabel}>SSL Offloading</span>
<div className={styles.configInline}>
<Toggle
checked={value.sslOffloading}
onChange={() => !disabled && update('sslOffloading', !value.sslOffloading)}
disabled={disabled}
/>
<span className={value.sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>
{value.sslOffloading ? 'Enabled' : 'Disabled'}
</span>
</div>
<span className={styles.configLabel}>Extra Networks</span>
<div>
<div className={styles.portPills}>
{value.extraNetworks.map((n) => (
<span key={n} className={styles.portPill}>
{n}
<button
className={styles.portPillDelete}
disabled={disabled}
onClick={() => removeNetwork(n)}
>
&times;
</button>
</span>
))}
<input
className={styles.portAddInput}
disabled={disabled}
placeholder="+ network"
value={newNetwork}
onChange={(e) => setNewNetwork(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addNetwork();
}
}}
/>
</div>
<span className={styles.configHint}>
Additional Docker networks to join (e.g., monitoring, prometheus)
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useMemo, useState } from 'react';
import { DataTable, EmptyState, MonoText, SectionHeader, Toggle } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { LiveBanner } from './LiveBanner';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../../../api/queries/commands';
import { useCatalog } from '../../../../api/queries/catalog';
import { applyRouteRecordingUpdate } from '../../../../utils/config-draft-utils';
import type { CatalogApp, CatalogRoute } from '../../../../api/queries/catalog';
import type { App } from '../../../../api/queries/admin/apps';
import type { Environment } from '../../../../api/queries/admin/environments';
import sectionStyles from '../../../../styles/section-card.module.css';
import appsStyles from '../../AppsTab.module.css';
interface RouteRecordingRow {
id: string;
routeId: string;
recording: boolean;
}
interface Props {
app: App;
environment: Environment;
}
export function RouteRecordingTab({ app, environment }: Props) {
const envSlug = environment.slug;
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
const updateAgentConfig = useUpdateApplicationConfig();
const { data: catalog } = useCatalog(envSlug);
// Local draft — each toggle is immediately flushed to live agents
const [recordingDraft, setRecordingDraft] = useState<Record<string, boolean> | null>(null);
// Use draft if in-flight, otherwise reflect server state
const effectiveRecording = recordingDraft ?? agentConfig?.routeRecording ?? {};
const appRoutes: CatalogRoute[] = useMemo(() => {
if (!catalog) return [];
const entry = (catalog as CatalogApp[]).find((e) => e.slug === app.slug);
return entry?.routes ?? [];
}, [catalog, app.slug]);
async function updateRouteRecording(routeId: string, recording: boolean) {
if (!agentConfig) return;
const next = applyRouteRecordingUpdate(effectiveRecording, routeId, recording);
setRecordingDraft(next);
try {
await updateAgentConfig.mutateAsync({
config: { ...agentConfig, routeRecording: next },
environment: envSlug,
apply: 'live',
});
} finally {
setRecordingDraft(null);
}
}
const routeRecordingRows: RouteRecordingRow[] = useMemo(
() =>
appRoutes.map((r) => ({
id: r.routeId,
routeId: r.routeId,
recording: effectiveRecording[r.routeId] !== false,
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[effectiveRecording, appRoutes],
);
const recordingCount = routeRecordingRows.filter((r) => r.recording).length;
const routeRecordingColumns: Column<RouteRecordingRow>[] = useMemo(
() => [
{
key: 'routeId',
header: 'Route',
render: (_v: unknown, row: RouteRecordingRow) => (
<MonoText size="xs">{row.routeId}</MonoText>
),
},
{
key: 'recording',
header: 'Recording',
width: '100px',
render: (_v: unknown, row: RouteRecordingRow) => (
<Toggle
checked={row.recording}
onChange={() => updateRouteRecording(row.routeId, !row.recording)}
disabled={updateAgentConfig.isPending}
/>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[updateAgentConfig.isPending, effectiveRecording],
);
return (
<div>
<LiveBanner />
<div className={sectionStyles.section}>
<SectionHeader>Route Recording</SectionHeader>
<span className={appsStyles.sectionSummary}>
{recordingCount} of {routeRecordingRows.length} routes recording
</span>
{routeRecordingRows.length > 0 ? (
<DataTable<RouteRecordingRow>
columns={routeRecordingColumns}
data={routeRecordingRows}
pageSize={20}
flush
/>
) : (
<EmptyState
title="No routes"
description="No routes found for this application. Routes appear once agents report data."
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
import { useState } from 'react';
import { Badge, Button, Input, Tag } from '@cameleer/design-system';
import { Shield, Info } from 'lucide-react';
import { useSensitiveKeys } from '../../../../api/queries/admin/sensitive-keys';
import type { SensitiveKeysFormState } from '../hooks/useDeploymentPageState';
import skStyles from '../../../Admin/SensitiveKeysPage.module.css';
const AGENT_DEFAULTS = [
'Authorization',
'Cookie',
'Set-Cookie',
'X-API-Key',
'X-Auth-Token',
'Proxy-Authorization',
];
interface Props {
value: SensitiveKeysFormState;
onChange: (next: SensitiveKeysFormState) => void;
disabled?: boolean;
}
export function SensitiveKeysTab({ value, onChange, disabled }: Props) {
const [newKey, setNewKey] = useState('');
const { data: globalKeysConfig } = useSensitiveKeys();
const globalKeys = globalKeysConfig?.keys ?? [];
function addKey() {
const v = newKey.trim();
if (v && !value.sensitiveKeys.some((k) => k.toLowerCase() === v.toLowerCase())) {
onChange({ sensitiveKeys: [...value.sensitiveKeys, v] });
setNewKey('');
}
}
function removeKey(index: number) {
onChange({ sensitiveKeys: value.sensitiveKeys.filter((_, i) => i !== index) });
}
return (
<div>
<div className={skStyles.sectionTitle}>
<Shield size={14} />
<span>Agent built-in defaults</span>
</div>
<div className={skStyles.defaultsList}>
{AGENT_DEFAULTS.map((key) => (
<Badge key={key} label={key} variant="outlined" />
))}
</div>
{globalKeys.length > 0 && (
<>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-subtle)', margin: '10px 0' }} />
<div className={skStyles.sectionTitle}>
<span>Global keys (enforced)</span>
<span className={skStyles.keyCount}>{globalKeys.length}</span>
</div>
<div className={skStyles.defaultsList}>
{globalKeys.map((key) => (
<Badge key={key} label={key} color="auto" variant="filled" />
))}
</div>
</>
)}
<hr style={{ border: 'none', borderTop: '1px solid var(--border-subtle)', margin: '10px 0' }} />
<div className={skStyles.sectionTitle}>
<span>Application-specific keys</span>
{value.sensitiveKeys.length > 0 && (
<span className={skStyles.keyCount}>{value.sensitiveKeys.length}</span>
)}
</div>
<div className={skStyles.pillList}>
{value.sensitiveKeys.map((k, i) => (
<Tag
key={`${k}-${i}`}
label={k}
onRemove={() => !disabled && removeKey(i)}
/>
))}
{value.sensitiveKeys.length === 0 && (
<span className={skStyles.emptyState}>
No app-specific keys agents use built-in defaults
{globalKeys.length > 0 ? ' and global keys' : ''}
</span>
)}
</div>
<div className={skStyles.inputRow}>
<Input
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addKey();
}
}}
placeholder="Add key or glob pattern (e.g. *password*)"
disabled={disabled}
/>
<Button
variant="secondary"
size="sm"
disabled={disabled || !newKey.trim()}
onClick={addKey}
>
Add
</Button>
</div>
<div className={skStyles.hint}>
<Info size={12} />
<span>
The final masking configuration is: agent defaults + global keys + app-specific keys.
Supports exact header names and glob patterns.
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useMemo, useState } from 'react';
import { Badge, DataTable, EmptyState, MonoText, SectionHeader } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { LiveBanner } from './LiveBanner';
import { useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../../../api/queries/commands';
import type { TapDefinition } from '../../../../api/queries/commands';
import { useCatalog } from '../../../../api/queries/catalog';
import { applyTracedProcessorUpdate } from '../../../../utils/config-draft-utils';
import type { App } from '../../../../api/queries/admin/apps';
import type { Environment } from '../../../../api/queries/admin/environments';
import sectionStyles from '../../../../styles/section-card.module.css';
import appsStyles from '../../AppsTab.module.css';
interface TracedTapRow {
id: string;
processorId: string;
captureMode: string | null;
taps: TapDefinition[];
}
interface Props {
app: App;
environment: Environment;
}
export function TracesTapsTab({ app, environment }: Props) {
const envSlug = environment.slug;
const { data: agentConfig } = useApplicationConfig(app.slug, envSlug);
const updateAgentConfig = useUpdateApplicationConfig();
const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug, envSlug);
const { data: catalog } = useCatalog(envSlug);
// Local draft — each change is immediately flushed to live agents
const [tracedDraft, setTracedDraft] = useState<Record<string, string> | null>(null);
// Use draft if in-flight, otherwise reflect server state
const effectiveTraced = tracedDraft ?? agentConfig?.tracedProcessors ?? {};
async function updateTracedProcessor(processorId: string, mode: string) {
if (!agentConfig) return;
const next = applyTracedProcessorUpdate(effectiveTraced, processorId, mode);
setTracedDraft(next);
try {
await updateAgentConfig.mutateAsync({
config: { ...agentConfig, tracedProcessors: next },
environment: envSlug,
apply: 'live',
});
} finally {
setTracedDraft(null);
}
}
const tracedTapRows: TracedTapRow[] = useMemo(() => {
const taps = agentConfig?.taps ?? [];
const pids = new Set<string>([
...Object.keys(effectiveTraced),
...taps.map((t) => t.processorId),
]);
return Array.from(pids)
.sort()
.map((pid) => ({
id: pid,
processorId: pid,
captureMode: effectiveTraced[pid] ?? null,
taps: taps.filter((t) => t.processorId === pid),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveTraced, agentConfig?.taps]);
const tracedCount = Object.keys(effectiveTraced).length;
const tapCount = agentConfig?.taps?.length ?? 0;
const tracedTapColumns: Column<TracedTapRow>[] = useMemo(
() => [
{
key: 'route' as any,
header: 'Route',
render: (_v: unknown, row: TracedTapRow) => {
const routeId = processorToRoute[row.processorId];
return routeId ? (
<span className={appsStyles.routeLabel}>{routeId}</span>
) : (
<span className={appsStyles.hint}>&mdash;</span>
);
},
},
{
key: 'processorId',
header: 'Processor',
render: (_v: unknown, row: TracedTapRow) => (
<MonoText size="xs">{row.processorId}</MonoText>
),
},
{
key: 'captureMode',
header: 'Capture',
render: (_v: unknown, row: TracedTapRow) => {
if (row.captureMode === null) return <span className={appsStyles.hint}>&mdash;</span>;
return (
<select
className={appsStyles.nativeSelect}
value={row.captureMode}
onChange={(e) => updateTracedProcessor(row.processorId, e.target.value)}
disabled={updateAgentConfig.isPending}
>
<option value="NONE">None</option>
<option value="INPUT">Input</option>
<option value="OUTPUT">Output</option>
<option value="BOTH">Both</option>
</select>
);
},
},
{
key: 'taps',
header: 'Taps',
render: (_v: unknown, row: TracedTapRow) =>
row.taps.length === 0 ? (
<span className={appsStyles.hint}>&mdash;</span>
) : (
<div className={appsStyles.tapBadges}>
{row.taps.map((t) => (
<button
key={t.tapId}
className={appsStyles.tapBadgeLink}
title="Manage tap on route page"
>
<Badge
label={t.attributeName}
color={t.enabled ? 'success' : 'auto'}
variant="filled"
/>
</button>
))}
</div>
),
},
{
key: '_remove' as const,
header: '',
width: '36px',
render: (_v: unknown, row: TracedTapRow) =>
row.captureMode === null ? null : (
<button
className={appsStyles.removeBtn}
title="Remove"
onClick={() => updateTracedProcessor(row.processorId, 'REMOVE')}
disabled={updateAgentConfig.isPending}
>
&times;
</button>
),
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[processorToRoute, updateAgentConfig.isPending, effectiveTraced],
);
// catalog is needed only to satisfy the import (keeps the same data shape as legacy ConfigSubTab)
void catalog;
return (
<div>
<LiveBanner />
<div className={sectionStyles.section}>
<SectionHeader>Traces &amp; Taps</SectionHeader>
<span className={appsStyles.sectionSummary}>
{tracedCount} traced &middot; {tapCount} taps
</span>
{tracedTapRows.length > 0 ? (
<DataTable<TracedTapRow>
columns={tracedTapColumns}
data={tracedTapRows}
pageSize={20}
flush
/>
) : (
<EmptyState
title="No traces or taps"
description="No processor traces or taps configured."
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { EnvEditor } from '../../../../components/EnvEditor';
import type { VariablesFormState } from '../hooks/useDeploymentPageState';
interface Props {
value: VariablesFormState;
onChange: (next: VariablesFormState) => void;
disabled?: boolean;
}
export function VariablesTab({ value, onChange, disabled }: Props) {
return (
<EnvEditor
value={value.envVars}
onChange={(entries) => onChange({ envVars: entries })}
disabled={disabled}
/>
);
}

View File

@@ -0,0 +1,51 @@
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { DeploymentProgress } from '../../../../components/DeploymentProgress';
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
import { EmptyState } from '@cameleer/design-system';
import { StatusCard } from './StatusCard';
import { HistoryDisclosure } from './HistoryDisclosure';
import styles from '../AppDeploymentPage.module.css';
interface Props {
deployments: Deployment[];
versions: AppVersion[];
appSlug: string;
envSlug: string;
externalUrl: string;
onStop: (deploymentId: string) => void;
onStart: (deploymentId: string) => void;
}
export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl, onStop, onStart }: Props) {
const latest = deployments
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
if (!latest) {
return <EmptyState title="No deployments yet" description="Save your configuration and click Redeploy to launch." />;
}
const version = versions.find((v) => v.id === latest.appVersionId) ?? null;
return (
<div className={styles.deploymentTab}>
<StatusCard
deployment={latest}
version={version}
externalUrl={externalUrl}
onStop={() => onStop(latest.id)}
onStart={() => onStart(latest.id)}
/>
{latest.status === 'STARTING' && (
<DeploymentProgress currentStage={latest.deployStage} failed={false} />
)}
{latest.status === 'FAILED' && (
<DeploymentProgress currentStage={latest.deployStage} failed />
)}
<StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug}
className={styles.logFill} />
<HistoryDisclosure deployments={deployments} versions={versions}
appSlug={appSlug} envSlug={envSlug} />
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useState } from 'react';
import { DataTable } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { timeAgo } from '../../../../utils/format-utils';
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
import styles from '../AppDeploymentPage.module.css';
interface Props {
deployments: Deployment[];
versions: AppVersion[];
appSlug: string;
envSlug: string;
}
export function HistoryDisclosure({ deployments, versions, appSlug, envSlug }: Props) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const versionMap = new Map(versions.map((v) => [v.id, v]));
const rows = deployments
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const columns: Column<Deployment>[] = [
{ key: 'createdAt', header: 'Started', render: (_, d) => timeAgo(d.createdAt) },
{
key: 'appVersionId', header: 'Version',
render: (_, d) => {
const v = versionMap.get(d.appVersionId);
return v ? `v${v.version}` : '?';
},
},
{ key: 'status', header: 'Status' },
{
key: 'deployedAt', header: 'Duration',
render: (_, d) => d.deployedAt && d.createdAt
? `${Math.round((Date.parse(d.deployedAt) - Date.parse(d.createdAt)) / 1000)}s`
: '—',
},
];
return (
<div className={styles.historyRow}>
<button type="button" className={styles.disclosureToggle} onClick={() => setOpen(!open)}>
{open ? '▼' : '▶'} History ({rows.length})
</button>
{open && (
<>
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => setExpanded(expanded === row.id ? null : row.id)}
/>
{expanded && (() => {
const d = rows.find((r) => r.id === expanded);
if (!d) return null;
return <StartupLogPanel deployment={d} appSlug={appSlug} envSlug={envSlug} />;
})()}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { Badge, StatusDot, MonoText, Button } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { timeAgo } from '../../../../utils/format-utils';
import styles from '../AppDeploymentPage.module.css';
const STATUS_COLORS = {
RUNNING: 'success', STARTING: 'warning', FAILED: 'error',
STOPPED: 'auto', DEGRADED: 'warning', STOPPING: 'auto',
} as const;
const DEPLOY_STATUS_DOT = {
RUNNING: 'live', STARTING: 'running', DEGRADED: 'stale',
STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error',
} as const;
function formatBytes(bytes: number): string {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
interface Props {
deployment: Deployment;
version: AppVersion | null;
externalUrl: string;
onStop: () => void;
onStart: () => void;
}
export function StatusCard({ deployment, version, externalUrl, onStop, onStart }: Props) {
const running = deployment.replicaStates?.filter((r) => r.status === 'RUNNING').length ?? 0;
const total = deployment.replicaStates?.length ?? 0;
return (
<div className={styles.statusCard}>
<div className={styles.statusCardHeader}>
<StatusDot variant={DEPLOY_STATUS_DOT[deployment.status as keyof typeof DEPLOY_STATUS_DOT] ?? 'dead'} />
<Badge label={deployment.status} color={STATUS_COLORS[deployment.status as keyof typeof STATUS_COLORS] ?? 'auto'} />
{version && <Badge label={`v${version.version}`} color="auto" />}
</div>
<div className={styles.statusCardGrid}>
{version && <><span>JAR</span><MonoText size="sm">{version.jarFilename}</MonoText></>}
{version && <><span>Checksum</span><MonoText size="xs">{version.jarChecksum.substring(0, 12)}</MonoText></>}
<span>Replicas</span><span>{running}/{total}</span>
<span>URL</span>
{deployment.status === 'RUNNING'
? <a href={externalUrl} target="_blank" rel="noreferrer"><MonoText size="sm">{externalUrl}</MonoText></a>
: <MonoText size="sm">{externalUrl}</MonoText>}
<span>Deployed</span><span>{deployment.deployedAt ? timeAgo(deployment.deployedAt) : '—'}</span>
</div>
<div className={styles.statusCardActions}>
{(deployment.status === 'RUNNING' || deployment.status === 'STARTING' || deployment.status === 'DEGRADED')
&& <Button size="sm" variant="danger" onClick={onStop}>Stop</Button>}
{deployment.status === 'STOPPED' && <Button size="sm" variant="secondary" onClick={onStart}>Start</Button>}
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { useRef } from 'react';
import { SectionHeader, Input, MonoText, Button } from '@cameleer/design-system';
import type { App, AppVersion } from '../../../api/queries/admin/apps';
import type { Environment } from '../../../api/queries/admin/environments';
import { envColorVar } from '../../../components/env-colors';
import styles from './AppDeploymentPage.module.css';
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100);
}
function formatBytes(bytes: number): string {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
interface IdentitySectionProps {
mode: 'net-new' | 'deployed';
environment: Environment;
app: App | null;
currentVersion: AppVersion | null;
name: string;
onNameChange: (next: string) => void;
stagedJar: File | null;
onStagedJarChange: (file: File | null) => void;
deploying: boolean;
}
export function IdentitySection({
mode, environment, app, currentVersion,
name, onNameChange, stagedJar, onStagedJarChange, deploying,
}: IdentitySectionProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const slug = app?.slug ?? slugify(name);
const externalUrl = (() => {
const defaults = environment.defaultContainerConfig ?? {};
const domain = String(defaults.routingDomain ?? '');
if (defaults.routingMode === 'subdomain' && domain) {
return `https://${slug || '...'}-${environment.slug}.${domain}/`;
}
const base = domain ? `https://${domain}` : window.location.origin;
return `${base}/${environment.slug}/${slug || '...'}/`;
})();
return (
<div className={styles.section}>
<SectionHeader>Identity & Artifact</SectionHeader>
<div className={styles.configGrid}>
<span className={styles.configLabel}>Application Name</span>
{mode === 'deployed' ? (
<span className={styles.readOnlyValue}>{name}</span>
) : (
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g. Payment Gateway"
disabled={deploying}
/>
)}
<span className={styles.configLabel}>Slug</span>
<MonoText size="sm">{slug || '...'}</MonoText>
<span className={styles.configLabel}>Environment</span>
<span
className={styles.envPill}
style={{ backgroundColor: envColorVar(environment.color) }}
title={environment.displayName}
>
{environment.displayName}
</span>
<span className={styles.configLabel}>External URL</span>
<MonoText size="sm">{externalUrl}</MonoText>
{currentVersion && (
<>
<span className={styles.configLabel}>Current Version</span>
<span className={styles.readOnlyValue}>
v{currentVersion.version} · {currentVersion.jarFilename} · {formatBytes(currentVersion.jarSizeBytes)}
</span>
</>
)}
<span className={styles.configLabel}>Application JAR</span>
<div className={styles.fileRow}>
<input
ref={fileInputRef}
type="file"
accept=".jar"
className={styles.visuallyHidden}
onChange={(e) => onStagedJarChange(e.target.files?.[0] ?? null)}
/>
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={deploying}
>
{currentVersion ? 'Change JAR' : 'Select JAR'}
</Button>
{stagedJar && (
<span className={styles.stagedJar}>
staged: {stagedJar.name} ({formatBytes(stagedJar.size)})
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Button } from '@cameleer/design-system';
export type PrimaryActionMode = 'save' | 'redeploy' | 'deploying';
interface Props {
mode: PrimaryActionMode;
enabled: boolean;
onClick: () => void;
}
export function PrimaryActionButton({ mode, enabled, onClick }: Props) {
if (mode === 'deploying') {
return <Button size="sm" variant="primary" loading disabled>Deploying</Button>;
}
if (mode === 'redeploy') {
return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Redeploy</Button>;
}
return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Save</Button>;
}
export function computeMode({
deploymentInProgress,
hasLocalEdits,
serverDirtyAgainstDeploy,
}: {
deploymentInProgress: boolean;
hasLocalEdits: boolean;
serverDirtyAgainstDeploy: boolean;
}): PrimaryActionMode {
if (deploymentInProgress) return 'deploying';
if (hasLocalEdits) return 'save';
if (serverDirtyAgainstDeploy) return 'redeploy';
return 'save';
}

View File

@@ -0,0 +1,150 @@
// ui/src/pages/AppsTab/AppDeploymentPage/hooks/useDeploymentPageState.ts
import { useState, useEffect, useMemo, useRef } from 'react';
import type { ApplicationConfig } from '../../../../api/queries/commands';
import type { App } from '../../../../api/queries/admin/apps';
export interface MonitoringFormState {
engineLevel: string;
payloadCaptureMode: string;
payloadSize: string;
payloadUnit: string;
applicationLogLevel: string;
agentLogLevel: string;
metricsEnabled: boolean;
metricsInterval: string;
samplingRate: string;
compressSuccess: boolean;
replayEnabled: boolean;
routeControlEnabled: boolean;
}
export interface ResourcesFormState {
memoryLimit: string;
memoryReserve: string;
cpuRequest: string;
cpuLimit: string;
ports: number[];
appPort: string;
replicas: string;
deployStrategy: string;
stripPrefix: boolean;
sslOffloading: boolean;
runtimeType: string;
customArgs: string;
extraNetworks: string[];
}
export interface VariablesFormState {
envVars: { key: string; value: string }[];
}
export interface SensitiveKeysFormState {
sensitiveKeys: string[];
}
export interface DeploymentPageFormState {
monitoring: MonitoringFormState;
resources: ResourcesFormState;
variables: VariablesFormState;
sensitiveKeys: SensitiveKeysFormState;
}
const defaultForm: DeploymentPageFormState = {
monitoring: {
engineLevel: 'REGULAR',
payloadCaptureMode: 'BOTH',
payloadSize: '4',
payloadUnit: 'KB',
applicationLogLevel: 'INFO',
agentLogLevel: 'INFO',
metricsEnabled: true,
metricsInterval: '60',
samplingRate: '1.0',
compressSuccess: false,
replayEnabled: true,
routeControlEnabled: true,
},
resources: {
memoryLimit: '512', memoryReserve: '', cpuRequest: '500', cpuLimit: '',
ports: [], appPort: '8080', replicas: '1', deployStrategy: 'blue-green',
stripPrefix: true, sslOffloading: true, runtimeType: 'auto', customArgs: '',
extraNetworks: [],
},
variables: { envVars: [] },
sensitiveKeys: { sensitiveKeys: [] },
};
export function useDeploymentPageState(
app: App | null,
agentConfig: ApplicationConfig | null,
envDefaults: Record<string, unknown>,
): {
form: DeploymentPageFormState;
setForm: React.Dispatch<React.SetStateAction<DeploymentPageFormState>>;
reset: () => void;
serverState: DeploymentPageFormState;
} {
const serverState = useMemo<DeploymentPageFormState>(() => {
const merged = { ...envDefaults, ...(app?.containerConfig ?? {}) } as Record<string, unknown>;
return {
monitoring: {
engineLevel: (agentConfig?.engineLevel as string) ?? defaultForm.monitoring.engineLevel,
payloadCaptureMode: (agentConfig?.payloadCaptureMode as string) ?? defaultForm.monitoring.payloadCaptureMode,
payloadSize: defaultForm.monitoring.payloadSize,
payloadUnit: defaultForm.monitoring.payloadUnit,
applicationLogLevel: (agentConfig?.applicationLogLevel as string) ?? defaultForm.monitoring.applicationLogLevel,
agentLogLevel: (agentConfig?.agentLogLevel as string) ?? defaultForm.monitoring.agentLogLevel,
metricsEnabled: agentConfig?.metricsEnabled ?? defaultForm.monitoring.metricsEnabled,
metricsInterval: defaultForm.monitoring.metricsInterval,
samplingRate: agentConfig?.samplingRate !== undefined
? (Number.isInteger(agentConfig.samplingRate) ? `${agentConfig.samplingRate}.0` : String(agentConfig.samplingRate))
: defaultForm.monitoring.samplingRate,
compressSuccess: agentConfig?.compressSuccess ?? defaultForm.monitoring.compressSuccess,
replayEnabled: defaultForm.monitoring.replayEnabled,
routeControlEnabled: defaultForm.monitoring.routeControlEnabled,
},
resources: {
memoryLimit: String(merged.memoryLimitMb ?? defaultForm.resources.memoryLimit),
memoryReserve: merged.memoryReserveMb != null ? String(merged.memoryReserveMb) : defaultForm.resources.memoryReserve,
cpuRequest: String(merged.cpuRequest ?? defaultForm.resources.cpuRequest),
cpuLimit: merged.cpuLimit != null ? String(merged.cpuLimit) : defaultForm.resources.cpuLimit,
ports: Array.isArray(merged.exposedPorts) ? (merged.exposedPorts as number[]) : defaultForm.resources.ports,
appPort: String(merged.appPort ?? defaultForm.resources.appPort),
replicas: String(merged.replicas ?? defaultForm.resources.replicas),
deployStrategy: String(merged.deploymentStrategy ?? defaultForm.resources.deployStrategy),
stripPrefix: merged.stripPathPrefix !== false,
sslOffloading: merged.sslOffloading !== false,
runtimeType: String(merged.runtimeType ?? defaultForm.resources.runtimeType),
customArgs: String(merged.customArgs ?? defaultForm.resources.customArgs),
extraNetworks: Array.isArray(merged.extraNetworks) ? (merged.extraNetworks as string[]) : defaultForm.resources.extraNetworks,
},
variables: {
envVars: merged.customEnvVars
? Object.entries(merged.customEnvVars as Record<string, string>).map(([key, value]) => ({ key, value }))
: [],
},
sensitiveKeys: {
sensitiveKeys: Array.isArray(agentConfig?.sensitiveKeys)
? (agentConfig!.sensitiveKeys as string[])
: [],
},
};
}, [app, agentConfig, envDefaults]);
const [form, setForm] = useState<DeploymentPageFormState>(serverState);
const prevServerStateRef = useRef<DeploymentPageFormState>(serverState);
useEffect(() => {
// Only overwrite form if the current form value still matches the previous
// server state (i.e., the user has no local edits). Otherwise preserve
// user edits through background refetches.
setForm((current) => {
const hadLocalEdits =
JSON.stringify(current) !== JSON.stringify(prevServerStateRef.current);
prevServerStateRef.current = serverState;
return hadLocalEdits ? current : serverState;
});
}, [serverState]);
return { form, setForm, reset: () => setForm(serverState), serverState };
}

View File

@@ -0,0 +1,25 @@
import { useMemo } from 'react';
import type { DeploymentPageFormState } from './useDeploymentPageState';
export interface PerTabDirty {
monitoring: boolean;
resources: boolean;
variables: boolean;
sensitiveKeys: boolean;
anyLocalEdit: boolean;
}
export function useFormDirty(
form: DeploymentPageFormState,
serverState: DeploymentPageFormState,
stagedJar: File | null,
): PerTabDirty {
return useMemo(() => {
const monitoring = JSON.stringify(form.monitoring) !== JSON.stringify(serverState.monitoring);
const resources = JSON.stringify(form.resources) !== JSON.stringify(serverState.resources);
const variables = JSON.stringify(form.variables) !== JSON.stringify(serverState.variables);
const sensitiveKeys = JSON.stringify(form.sensitiveKeys) !== JSON.stringify(serverState.sensitiveKeys);
const anyLocalEdit = monitoring || resources || variables || sensitiveKeys || !!stagedJar;
return { monitoring, resources, variables, sensitiveKeys, anyLocalEdit };
}, [form, serverState, stagedJar]);
}

View File

@@ -0,0 +1,26 @@
import { useState, useEffect } from 'react';
import { useBlocker } from 'react-router';
export function useUnsavedChangesBlocker(hasUnsavedChanges: boolean) {
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname
);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => {
if (blocker.state === 'blocked') setDialogOpen(true);
}, [blocker.state]);
return {
dialogOpen,
confirm: () => {
setDialogOpen(false);
blocker.proceed?.();
},
cancel: () => {
setDialogOpen(false);
blocker.reset?.();
},
};
}

View File

@@ -0,0 +1,558 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router';
import { useQueryClient } from '@tanstack/react-query';
import { AlertDialog, Button, Tabs, useToast } from '@cameleer/design-system';
import { useEnvironmentStore } from '../../../api/environment-store';
import { useEnvironments } from '../../../api/queries/admin/environments';
import {
useApps,
useCreateApp,
useDeleteApp,
useAppVersions,
useUploadJar,
useDeployments,
useCreateDeployment,
useStopDeployment,
useUpdateContainerConfig,
useDirtyState,
} from '../../../api/queries/admin/apps';
import type { Deployment } from '../../../api/queries/admin/apps';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../../api/queries/commands';
import { PageLoader } from '../../../components/PageLoader';
import { IdentitySection } from './IdentitySection';
import { Checkpoints } from './Checkpoints';
import { MonitoringTab } from './ConfigTabs/MonitoringTab';
import { ResourcesTab } from './ConfigTabs/ResourcesTab';
import { VariablesTab } from './ConfigTabs/VariablesTab';
import { SensitiveKeysTab } from './ConfigTabs/SensitiveKeysTab';
import { TracesTapsTab } from './ConfigTabs/TracesTapsTab';
import { RouteRecordingTab } from './ConfigTabs/RouteRecordingTab';
import { DeploymentTab } from './DeploymentTab/DeploymentTab';
import { PrimaryActionButton, computeMode } from './PrimaryActionButton';
import { useDeploymentPageState } from './hooks/useDeploymentPageState';
import { useFormDirty } from './hooks/useFormDirty';
import { useUnsavedChangesBlocker } from './hooks/useUnsavedChangesBlocker';
import { deriveAppName } from './utils/deriveAppName';
import styles from './AppDeploymentPage.module.css';
type TabKey = 'monitoring' | 'resources' | 'variables' | 'sensitive-keys' | 'deployment' | 'traces' | 'recording';
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100);
}
export default function AppDeploymentPage() {
const { appId } = useParams<{ appId?: string }>();
const location = useLocation();
const navigate = useNavigate();
const { toast } = useToast();
const queryClient = useQueryClient();
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: environments = [], isLoading: envLoading } = useEnvironments();
const { data: apps = [], isLoading: appsLoading } = useApps(selectedEnv);
const isNetNew = location.pathname.endsWith('/apps/new');
const app = isNetNew ? null : (apps.find((a) => a.slug === appId) ?? null);
const env = environments.find((e) => e.slug === selectedEnv);
const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug);
const currentVersion = versions.slice().sort((a, b) => b.version - a.version)[0] ?? null;
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null;
const activeDeployment = deployments.find((d) => d.status === 'STARTING') ?? null;
const { data: agentConfig = null } = useApplicationConfig(app?.slug, selectedEnv);
const { data: dirtyState } = useDirtyState(selectedEnv, app?.slug);
// Mutations
const createApp = useCreateApp();
const deleteApp = useDeleteApp();
const uploadJar = useUploadJar();
const createDeployment = useCreateDeployment();
const stopDeployment = useStopDeployment();
const updateContainerConfig = useUpdateContainerConfig();
const updateAgentConfig = useUpdateApplicationConfig();
// Form state
const { form, setForm, reset, serverState } = useDeploymentPageState(
app,
agentConfig,
env?.defaultContainerConfig ?? {},
);
// Local UI state
const [name, setName] = useState('');
const [stagedJar, setStagedJar] = useState<File | null>(null);
const [tab, setTab] = useState<TabKey>('monitoring');
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [stopTarget, setStopTarget] = useState<string | null>(null);
const lastDerivedRef = useRef<string>('');
// Initialize name from app when it loads
useEffect(() => {
if (app) setName(app.displayName);
}, [app?.displayName]); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-derive name from staged JAR (net-new only, don't overwrite manual edits)
useEffect(() => {
if (!stagedJar || app) return;
const derived = deriveAppName(stagedJar.name);
if (!name || name === lastDerivedRef.current) {
setName(derived);
lastDerivedRef.current = derived;
}
}, [stagedJar, app, name]);
// Auto-switch to Deployment tab when a deployment starts
useEffect(() => {
if (activeDeployment) setTab('deployment');
}, [!!activeDeployment]); // eslint-disable-line react-hooks/exhaustive-deps
// Derived
const mode = app ? 'deployed' : 'net-new';
const dirty = useFormDirty(form, serverState, stagedJar);
const { dialogOpen: blockerOpen, confirm: blockerConfirm, cancel: blockerCancel } =
useUnsavedChangesBlocker(dirty.anyLocalEdit);
const serverDirtyAgainstDeploy = dirtyState?.dirty ?? true;
const deploymentInProgress = !!activeDeployment;
const primaryMode = computeMode({
deploymentInProgress,
hasLocalEdits: dirty.anyLocalEdit,
serverDirtyAgainstDeploy,
});
// External URL (same formula as IdentitySection)
const externalUrl = (() => {
if (!env) return '';
const slug = app?.slug ?? slugify(name);
const defaults = env.defaultContainerConfig ?? {};
const domain = String(defaults.routingDomain ?? '');
if (defaults.routingMode === 'subdomain' && domain) {
return `https://${slug || '...'}-${env.slug}.${domain}/`;
}
const base = domain ? `https://${domain}` : window.location.origin;
return `${base}/${env.slug}/${slug || '...'}/`;
})();
// ── Tabs definition ────────────────────────────────────────────────
const tabs: { label: string; value: TabKey }[] = [
{ label: dirty.monitoring ? 'Monitoring *' : 'Monitoring', value: 'monitoring' },
{ label: dirty.resources ? 'Resources *' : 'Resources', value: 'resources' },
{ label: dirty.variables ? 'Variables *' : 'Variables', value: 'variables' },
{ label: dirty.sensitiveKeys ? 'Sensitive Keys *' : 'Sensitive Keys', value: 'sensitive-keys' },
{ label: 'Deployment', value: 'deployment' },
...(app
? ([
{ label: '● Traces & Taps', value: 'traces' },
{ label: '● Route Recording', value: 'recording' },
] as { label: string; value: TabKey }[])
: []),
];
// ── Handlers ────────────────────────────────────────────────────────
async function handleSave() {
const envSlug = selectedEnv!;
try {
let targetApp = app;
// 1. Create app if net-new
if (!targetApp) {
targetApp = await createApp.mutateAsync({
envSlug,
slug: slugify(name),
displayName: name.trim(),
});
}
// 2. Upload JAR if staged
if (stagedJar) {
await uploadJar.mutateAsync({ envSlug, appSlug: targetApp.slug, file: stagedJar });
}
// 3. Save container config
const r = form.resources;
const containerConfig: Record<string, unknown> = {
memoryLimitMb: r.memoryLimit ? parseInt(r.memoryLimit) : null,
memoryReserveMb: r.memoryReserve ? parseInt(r.memoryReserve) : null,
cpuRequest: r.cpuRequest ? parseInt(r.cpuRequest) : null,
cpuLimit: r.cpuLimit ? parseInt(r.cpuLimit) : null,
exposedPorts: r.ports,
customEnvVars: Object.fromEntries(
form.variables.envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value]),
),
appPort: r.appPort ? parseInt(r.appPort) : 8080,
replicas: r.replicas ? parseInt(r.replicas) : 1,
deploymentStrategy: r.deployStrategy,
stripPathPrefix: r.stripPrefix,
sslOffloading: r.sslOffloading,
runtimeType: r.runtimeType,
customArgs: r.customArgs || null,
extraNetworks: r.extraNetworks,
};
await updateContainerConfig.mutateAsync({ envSlug, appSlug: targetApp.slug, config: containerConfig });
// 4. Save agent config (staged — applied on next deploy)
const m = form.monitoring;
await updateAgentConfig.mutateAsync({
config: {
application: targetApp.slug,
version: agentConfig?.version ?? 0,
engineLevel: m.engineLevel,
payloadCaptureMode: m.payloadCaptureMode,
applicationLogLevel: m.applicationLogLevel,
agentLogLevel: m.agentLogLevel,
metricsEnabled: m.metricsEnabled,
samplingRate: parseFloat(m.samplingRate) || 1.0,
compressSuccess: m.compressSuccess,
tracedProcessors: agentConfig?.tracedProcessors ?? {},
taps: agentConfig?.taps ?? [],
tapVersion: agentConfig?.tapVersion ?? 0,
routeRecording: agentConfig?.routeRecording ?? {},
sensitiveKeys:
form.sensitiveKeys.sensitiveKeys.length > 0
? form.sensitiveKeys.sensitiveKeys
: undefined,
},
environment: envSlug,
apply: 'staged',
});
setStagedJar(null);
toast({ title: 'Configuration saved', variant: 'success' });
// Invalidate dirty-state so the button reflects the new saved state
await queryClient.invalidateQueries({ queryKey: ['apps', envSlug, targetApp.slug, 'dirty-state'] });
if (!app) {
// Transition to the existing-app view — refetch apps first so the new app
// is in the cache before the router renders the deployed view (prevents
// the transient Save-disabled flash while useApps is loading).
await queryClient.refetchQueries({ queryKey: ['apps', envSlug] });
navigate(`/apps/${targetApp.slug}`);
}
} catch (e) {
toast({
title: 'Save failed',
description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error',
duration: 86_400_000,
});
}
}
async function handleRedeploy() {
if (!app) return;
const envSlug = selectedEnv!;
setTab('deployment');
try {
let versionId: string;
if (stagedJar) {
const newVersion = await uploadJar.mutateAsync({ envSlug, appSlug: app.slug, file: stagedJar });
versionId = newVersion.id;
} else {
if (!currentVersion) {
toast({
title: 'No JAR version available',
description: 'Upload a JAR before deploying.',
variant: 'error',
duration: 86_400_000,
});
return;
}
versionId = currentVersion.id;
}
await createDeployment.mutateAsync({ envSlug, appSlug: app.slug, appVersionId: versionId });
setStagedJar(null);
// Invalidate dirty-state and versions so button recomputes after deploy
queryClient.invalidateQueries({ queryKey: ['apps', envSlug, app.slug, 'dirty-state'] });
queryClient.invalidateQueries({ queryKey: ['apps', envSlug, app.slug, 'versions'] });
} catch (e) {
toast({
title: 'Redeploy failed',
description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error',
duration: 86_400_000,
});
}
}
async function handleStop(deploymentId: string) {
setStopTarget(deploymentId);
}
async function confirmStop() {
if (!stopTarget || !app) return;
const envSlug = selectedEnv!;
try {
await stopDeployment.mutateAsync({ envSlug, appSlug: app.slug, deploymentId: stopTarget });
} catch (e) {
toast({
title: 'Stop failed',
description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error',
duration: 86_400_000,
});
} finally {
setStopTarget(null);
}
}
async function handleDelete() {
if (!app) return;
const envSlug = selectedEnv!;
try {
await deleteApp.mutateAsync({ envSlug, appSlug: app.slug });
navigate('/apps');
} catch (e) {
toast({
title: 'Delete failed',
description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error',
duration: 86_400_000,
});
}
}
function handleRestore(deploymentId: string) {
const deployment = deployments.find((d) => d.id === deploymentId);
if (!deployment) return;
const snap = deployment.deployedConfigSnapshot;
if (!snap) return;
setForm((prev) => {
const a = snap.agentConfig ?? {};
const c = snap.containerConfig ?? {};
return {
monitoring: {
engineLevel: (a.engineLevel as string) ?? prev.monitoring.engineLevel,
payloadCaptureMode: (a.payloadCaptureMode as string) ?? prev.monitoring.payloadCaptureMode,
payloadSize: prev.monitoring.payloadSize,
payloadUnit: prev.monitoring.payloadUnit,
applicationLogLevel: (a.applicationLogLevel as string) ?? prev.monitoring.applicationLogLevel,
agentLogLevel: (a.agentLogLevel as string) ?? prev.monitoring.agentLogLevel,
metricsEnabled: (a.metricsEnabled as boolean) ?? prev.monitoring.metricsEnabled,
metricsInterval: prev.monitoring.metricsInterval,
samplingRate: a.samplingRate !== undefined ? String(a.samplingRate) : prev.monitoring.samplingRate,
compressSuccess: (a.compressSuccess as boolean) ?? prev.monitoring.compressSuccess,
replayEnabled: prev.monitoring.replayEnabled,
routeControlEnabled: prev.monitoring.routeControlEnabled,
},
resources: {
memoryLimit: c.memoryLimitMb !== undefined ? String(c.memoryLimitMb) : prev.resources.memoryLimit,
memoryReserve: c.memoryReserveMb != null ? String(c.memoryReserveMb) : prev.resources.memoryReserve,
cpuRequest: c.cpuRequest !== undefined ? String(c.cpuRequest) : prev.resources.cpuRequest,
cpuLimit: c.cpuLimit != null ? String(c.cpuLimit) : prev.resources.cpuLimit,
ports: Array.isArray(c.exposedPorts) ? (c.exposedPorts as number[]) : prev.resources.ports,
appPort: c.appPort !== undefined ? String(c.appPort) : prev.resources.appPort,
replicas: c.replicas !== undefined ? String(c.replicas) : prev.resources.replicas,
deployStrategy: (c.deploymentStrategy as string) ?? prev.resources.deployStrategy,
stripPrefix: c.stripPathPrefix !== undefined ? (c.stripPathPrefix as boolean) : prev.resources.stripPrefix,
sslOffloading: c.sslOffloading !== undefined ? (c.sslOffloading as boolean) : prev.resources.sslOffloading,
runtimeType: (c.runtimeType as string) ?? prev.resources.runtimeType,
customArgs: c.customArgs !== undefined ? String(c.customArgs ?? '') : prev.resources.customArgs,
extraNetworks: Array.isArray(c.extraNetworks) ? (c.extraNetworks as string[]) : prev.resources.extraNetworks,
},
variables: {
envVars: c.customEnvVars
? Object.entries(c.customEnvVars as Record<string, string>).map(([key, value]) => ({ key, value }))
: prev.variables.envVars,
},
sensitiveKeys: {
sensitiveKeys: Array.isArray(snap.sensitiveKeys)
? snap.sensitiveKeys
: Array.isArray(a.sensitiveKeys)
? (a.sensitiveKeys as string[])
: prev.sensitiveKeys.sensitiveKeys,
},
};
});
}
// ── Primary button enabled logic ───────────────────────────────────
const primaryEnabled = (() => {
if (primaryMode === 'deploying') return false;
if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
return true; // redeploy always enabled
})();
// ── Loading guard ──────────────────────────────────────────────────
if (envLoading || appsLoading) return <PageLoader />;
if (!env) return <div>Select an environment first.</div>;
return (
<div className={styles.container}>
{/* ── Page header ── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<h2 style={{ margin: 0 }}>{app ? app.displayName : 'Create Application'}</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{dirty.anyLocalEdit && (
<Button
size="sm"
variant="ghost"
onClick={() => {
reset();
setStagedJar(null);
}}
>
Discard
</Button>
)}
<PrimaryActionButton
mode={primaryMode}
enabled={primaryEnabled}
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
/>
{app && (
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>
Delete App
</Button>
)}
</div>
</div>
{/* ── Identity & Artifact ── */}
<IdentitySection
mode={mode}
environment={env}
app={app}
currentVersion={currentVersion}
name={name}
onNameChange={setName}
stagedJar={stagedJar}
onStagedJarChange={setStagedJar}
deploying={deploymentInProgress}
/>
{/* ── Checkpoints (deployed apps only) ── */}
{app && (
<Checkpoints
deployments={deployments}
versions={versions}
currentDeploymentId={currentDeployment?.id ?? null}
onRestore={handleRestore}
/>
)}
{/* ── Config tabs ── */}
<div className={styles.tabGroup}>
<Tabs
tabs={tabs}
active={tab}
onChange={(v) => setTab(v as TabKey)}
/>
<div className={styles.tabContent}>
{tab === 'monitoring' && (
<MonitoringTab
value={form.monitoring}
onChange={(next) => setForm((prev) => ({ ...prev, monitoring: next }))}
disabled={deploymentInProgress}
/>
)}
{tab === 'resources' && (
<ResourcesTab
value={form.resources}
onChange={(next) => setForm((prev) => ({ ...prev, resources: next }))}
disabled={deploymentInProgress}
isProd={env.production ?? false}
/>
)}
{tab === 'variables' && (
<VariablesTab
value={form.variables}
onChange={(next) => setForm((prev) => ({ ...prev, variables: next }))}
disabled={deploymentInProgress}
/>
)}
{tab === 'sensitive-keys' && (
<SensitiveKeysTab
value={form.sensitiveKeys}
onChange={(next) => setForm((prev) => ({ ...prev, sensitiveKeys: next }))}
disabled={deploymentInProgress}
/>
)}
{tab === 'deployment' && app && (
<DeploymentTab
deployments={deployments}
versions={versions}
appSlug={app.slug}
envSlug={env.slug}
externalUrl={externalUrl}
onStop={handleStop}
onStart={(deploymentId) => {
// Re-deploy from a specific historical deployment's version
const d = deployments.find((dep) => dep.id === deploymentId);
if (d && selectedEnv && app) {
setTab('deployment');
createDeployment.mutateAsync({
envSlug: selectedEnv,
appSlug: app.slug,
appVersionId: d.appVersionId,
}).catch((e: unknown) =>
toast({
title: 'Start failed',
description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error',
duration: 86_400_000,
}),
);
}
}}
/>
)}
{tab === 'deployment' && !app && (
<div style={{ color: 'var(--text-muted)', fontSize: 14, padding: 16 }}>
Save the app first to see deployment status.
</div>
)}
{tab === 'traces' && app && (
<TracesTapsTab app={app} environment={env} />
)}
{tab === 'recording' && app && (
<RouteRecordingTab app={app} environment={env} />
)}
</div>
</div>
{/* ── Stop confirmation dialog ── */}
<AlertDialog
open={!!stopTarget}
onClose={() => setStopTarget(null)}
onConfirm={confirmStop}
title="Stop deployment?"
description="This will stop the running container. The app will be unavailable until redeployed."
confirmLabel="Stop"
variant="danger"
/>
{/* ── Delete confirmation dialog ── */}
<AlertDialog
open={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onConfirm={() => {
setDeleteConfirm(false);
handleDelete();
}}
title={`Delete "${app?.displayName ?? ''}"?`}
description="This permanently removes the app, all versions, and all deployments. This cannot be undone."
confirmLabel="Delete"
variant="danger"
/>
{/* ── Unsaved changes navigation blocker ── */}
<AlertDialog
open={blockerOpen}
onClose={blockerCancel}
onConfirm={blockerConfirm}
title="Unsaved changes"
description="You have unsaved changes on this page. Discard and leave?"
confirmLabel="Discard & Leave"
variant="warning"
/>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { deriveAppName } from './deriveAppName';
describe('deriveAppName', () => {
it('truncates at first digit', () => {
expect(deriveAppName('payment-gateway-1.2.0.jar')).toBe('Payment Gateway');
});
it('returns clean title-cased name without digits', () => {
expect(deriveAppName('order-service.jar')).toBe('Order Service');
});
it('strips orphan 1-char token after truncation (v from my-app-v2)', () => {
expect(deriveAppName('my-app-v2.jar')).toBe('My App');
});
it('treats underscore like dash', () => {
expect(deriveAppName('acme_billing-3.jar')).toBe('Acme Billing');
});
it('strips the .jar extension when no digits present', () => {
expect(deriveAppName('acme-billing.jar')).toBe('Acme Billing');
});
it('returns empty string for empty input', () => {
expect(deriveAppName('')).toBe('');
});
it('returns empty string when filename starts with a digit', () => {
expect(deriveAppName('1-my-thing.jar')).toBe('');
});
it('mixed separators are both collapsed to spaces', () => {
expect(deriveAppName('foo_bar-baz.jar')).toBe('Foo Bar Baz');
});
it('strips trailing orphan regardless of letter identity', () => {
expect(deriveAppName('release-x9.jar')).toBe('Release');
});
});

View File

@@ -0,0 +1,38 @@
/**
* Derive a human-readable app name from a JAR filename.
*
* Rule:
* 1. Strip the `.jar` extension.
* 2. Truncate at the first digit (0-9) or `.`.
* 3. Replace `-` and `_` with spaces.
* 4. Collapse multiple spaces and trim.
* 5. Drop 1-char orphan tokens (e.g. the trailing `v` in `my-app-v2`).
* 6. Title-case each remaining word.
*
* The result is a suggestion — the caller is expected to let the user override.
*/
export function deriveAppName(filename: string): string {
if (!filename) return '';
let stem = filename.replace(/\.jar$/i, '');
// Truncate at first digit or dot
const match = stem.match(/[0-9.]/);
if (match && match.index !== undefined) {
stem = stem.slice(0, match.index);
}
// Separators → space
stem = stem.replace(/[-_]+/g, ' ');
// Collapse whitespace + trim
stem = stem.replace(/\s+/g, ' ').trim();
if (!stem) return '';
// Drop 1-char orphan tokens
const tokens = stem.split(' ').filter((t) => t.length > 1);
if (tokens.length === 0) return '';
// Title-case
return tokens.map((t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase()).join(' ');
}

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ const OutboundConnectionsPage = lazy(() => import('./pages/Admin/OutboundConnect
const OutboundConnectionEditor = lazy(() => import('./pages/Admin/OutboundConnectionEditor'));
const SensitiveKeysPage = lazy(() => import('./pages/Admin/SensitiveKeysPage'));
const AppsTab = lazy(() => import('./pages/AppsTab/AppsTab'));
const AppDeploymentPage = lazy(() => import('./pages/AppsTab/AppDeploymentPage'));
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
const InboxPage = lazy(() => import('./pages/Alerts/InboxPage'));
const RulesListPage = lazy(() => import('./pages/Alerts/RulesListPage'));
@@ -76,8 +77,8 @@ export const router = createBrowserRouter([
// Apps tab (OPERATOR+ via UI guard, shows all or single app)
{ path: 'apps', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
{ path: 'apps/new', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
{ path: 'apps/:appId', element: <SuspenseWrapper><AppsTab /></SuspenseWrapper> },
{ path: 'apps/new', element: <SuspenseWrapper><AppDeploymentPage /></SuspenseWrapper> },
{ path: 'apps/:appId', element: <SuspenseWrapper><AppDeploymentPage /></SuspenseWrapper> },
// Alerts
{ path: 'alerts', element: <Navigate to="/alerts/inbox" replace /> },