Compare commits

...

4 Commits

Author SHA1 Message Date
hsiegeln
c5b6f2bbad fix(dirty-state): exclude live-pushed fields from deploy diff
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m13s
CI / docker (push) Successful in 1m2s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
SonarQube / sonarqube (push) Successful in 4m17s
Live-pushed config fields (taps, tapVersion, tracedProcessors,
routeRecording) apply via SSE CONFIG_UPDATE — they take effect on
running agents without a redeploy and are fetched on agent restart
from application_config. They must not contribute to the
"pending deploy" diff against the last-successful-deployment snapshot.

Before this fix, applying a tap from the process diagram correctly
rolled out in real time but then marked the app "Pending Deploy (1)"
because DirtyStateCalculator compared every agentConfig field. This
also contradicted the UI rule (ui.md) that the live tabs "never mark
dirty".

Adds taps, tapVersion, tracedProcessors, routeRecording to
AGENT_CONFIG_IGNORED_KEYS. Updates the nested-path test to use a
staged field (sensitiveKeys) and adds a new test asserting that
divergent live-push fields keep dirty=false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:42:07 +02:00
83c3ac3ef3 Merge pull request 'feat(ui): show deployment status + rich pending-deploy tooltip on app header' (#151) from feature/deployment-status-badge into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m20s
CI / docker (push) Successful in 23s
CI / deploy (push) Successful in 43s
CI / deploy-feature (push) Has been skipped
Reviewed-on: #151
2026-04-24 13:50:00 +02:00
7dd7317cb8 Merge branch 'main' into feature/deployment-status-badge
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m7s
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m48s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Failing after 2m19s
2026-04-24 13:49:51 +02:00
hsiegeln
888f589934 feat(ui): show deployment status + rich pending-deploy tooltip on app header
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m24s
CI / docker (push) Successful in 1m12s
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Add a StatusDot + colored Badge next to the app name in the deployment
page header, showing the latest deployment's status (RUNNING / STARTING
/ FAILED / STOPPED / DEGRADED / STOPPING). The existing "Pending
deploy" badge now carries a tooltip explaining *why*: either a list of
local unsaved edits, or a per-field diff against the last successful
deploy's snapshot (field, staged vs deployed values). When server-side
differences exist, the badge shows the count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:47:04 +02:00
3 changed files with 71 additions and 7 deletions

View File

@@ -23,8 +23,13 @@ import java.util.UUID;
*/
public class DirtyStateCalculator {
// Live-pushed fields are excluded from the deploy diff: changes to them take effect
// via SSE config-update without a redeploy, so they are not "pending deploy" when they
// differ from the last successful deployment snapshot. See ui/rules: the Traces & Taps
// and Route Recording tabs apply with ?apply=live and "never mark dirty".
private static final Set<String> AGENT_CONFIG_IGNORED_KEYS = Set.of(
"version", "updatedAt", "updatedBy", "environment", "application"
"version", "updatedAt", "updatedBy", "environment", "application",
"taps", "tapVersion", "tracedProcessors", "routeRecording"
);
private final ObjectMapper mapper;

View File

@@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -114,9 +115,9 @@ class DirtyStateCalculatorTest {
DirtyStateCalculator calc = CALC;
ApplicationConfig deployed = new ApplicationConfig();
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
deployed.setSensitiveKeys(List.of("password", "token"));
ApplicationConfig desired = new ApplicationConfig();
desired.setTracedProcessors(Map.of("proc-1", "TRACE"));
desired.setSensitiveKeys(List.of("password", "token", "secret"));
UUID jarId = UUID.randomUUID();
DeploymentConfigSnapshot snap = new DeploymentConfigSnapshot(jarId, deployed, Map.of(), null);
@@ -124,7 +125,29 @@ class DirtyStateCalculatorTest {
assertThat(result.dirty()).isTrue();
assertThat(result.differences()).extracting(DirtyStateResult.Difference::field)
.contains("agentConfig.tracedProcessors.proc-1");
.anyMatch(f -> f.startsWith("agentConfig.sensitiveKeys"));
}
@Test
void livePushedFields_doNotMarkDirty() {
// Taps, tracedProcessors, and routeRecording apply via live SSE push (never redeploy),
// so they must not appear as "pending deploy" when they differ from the last deploy snapshot.
ApplicationConfig deployed = new ApplicationConfig();
deployed.setTracedProcessors(Map.of("proc-1", "DEBUG"));
deployed.setRouteRecording(Map.of("route-a", true));
deployed.setTapVersion(1);
ApplicationConfig desired = new ApplicationConfig();
desired.setTracedProcessors(Map.of("proc-1", "TRACE", "proc-2", "DEBUG"));
desired.setRouteRecording(Map.of("route-a", false, "route-b", true));
desired.setTapVersion(5);
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()).isFalse();
assertThat(result.differences()).isEmpty();
}
@Test

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router';
import { useQueryClient } from '@tanstack/react-query';
import { AlertDialog, Badge, Button, Tabs, useToast } from '@cameleer/design-system';
import { AlertDialog, Badge, Button, StatusDot, Tabs, useToast } from '@cameleer/design-system';
import { useEnvironmentStore } from '../../../api/environment-store';
import { useEnvironments } from '../../../api/queries/admin/environments';
import {
@@ -39,6 +39,16 @@ import styles from './AppDeploymentPage.module.css';
type TabKey = 'monitoring' | 'resources' | 'variables' | 'sensitive-keys' | 'deployment' | 'traces' | 'recording';
const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: 'auto',
DEGRADED: 'warning', STOPPING: 'auto',
};
const DEPLOY_STATUS_DOT: Record<string, 'live' | 'stale' | 'dead' | 'success' | 'warning' | 'error' | 'running'> = {
RUNNING: 'live', STARTING: 'running', DEGRADED: 'stale',
STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error',
};
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100);
}
@@ -393,9 +403,35 @@ export default function AppDeploymentPage() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<h2 style={{ margin: 0 }}>{app ? app.displayName : 'Create Application'}</h2>
{app && !deploymentInProgress && (dirty.anyLocalEdit || serverDirtyAgainstDeploy) && (
<Badge label="Pending deploy" color="warning" />
{app && latestDeployment && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<StatusDot variant={DEPLOY_STATUS_DOT[latestDeployment.status] ?? 'dead'} />
<Badge
label={latestDeployment.status}
color={STATUS_COLORS[latestDeployment.status] ?? 'auto'}
/>
</span>
)}
{app && !deploymentInProgress && (dirty.anyLocalEdit || serverDirtyAgainstDeploy) && (() => {
const diffs = dirtyState?.differences ?? [];
const noSnapshot = diffs.length === 1 && diffs[0].field === 'snapshot';
const tooltip = dirty.anyLocalEdit
? 'Local edits not yet saved — see tabs marked with *.'
: noSnapshot
? 'No successful deployment recorded for this app yet.'
: diffs.length > 0
? `Differs from last successful deploy:\n` +
diffs.map((d) => `${d.field}\n staged: ${d.staged}\n deployed: ${d.deployed}`).join('\n')
: 'Server reports config differs from last successful deploy.';
return (
<span title={tooltip} style={{ display: 'inline-flex' }}>
<Badge
label={dirty.anyLocalEdit ? 'Pending deploy' : `Pending deploy (${diffs.length})`}
color="warning"
/>
</span>
);
})()}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{dirty.anyLocalEdit && (