Compare commits

13 Commits

Author SHA1 Message Date
hsiegeln
4371372a26 ui(admin): solid env-colored circle in place of name-hash Avatar
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m7s
CI / docker (push) Successful in 1m21s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 41s
SonarQube / sonarqube (push) Successful in 6m8s
Previous ring approach was too subtle against most env colors. Replace
the DS Avatar with a purpose-built circle rendered in the environment's
chosen color, showing 1–2 letter initials in white. Fills the full
circle so the color reads at a glance from across the list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:02:10 +02:00
hsiegeln
f8dccaae2b fix(deploy): stop previous active deployment before START_REPLICAS (fixes 409)
Container names are deterministic: {tenant}-{envSlug}-{appSlug}-{replica}.
The prior code did the stop-existing step at SWAP_TRAFFIC, *after*
START_REPLICAS had already tried to create containers with the same
names — so a redeploy against a RUNNING app consistently failed with
Docker 409 "container name already in use".

Move the stop-existing block to run right after CREATE_NETWORK and
before START_REPLICAS. SWAP_TRAFFIC becomes a label-only marker (traffic
is swapped implicitly by Traefik labels once new replicas are healthy).

Also: add `findActiveByAppIdAndEnvironmentIdExcluding` so the SQL
excludes the current deployment by id — previously the Java-side
`!id.equals(me)` guard failed because the newly-inserted row has
status=STARTING (DB default) and ORDER BY created_at DESC LIMIT 1
picked the new row, hiding the actual previous deployment.

Trade-off: this is destroy-then-start rather than true blue/green —
brief downtime during the swap. Matches the pre-unified-page behavior
and is what users reasonably expect. True blue/green would require
per-deployment container names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:01:00 +02:00
hsiegeln
9ecc9ee72a ui(deploy): pending-deploy badge + Start/Stop in page header
1. Add a 'Pending deploy' Badge next to the app name when there are
   local edits or the saved state differs from the last deploy. Makes
   the undeployed-changes state visible even when the user isn't looking
   at the tab asterisks.

2. Move Start/Stop buttons from StatusCard into the page header, next
   to Delete. Runs off the latest deployment's status — Stop when
   RUNNING/STARTING/DEGRADED, Start (triggers a redeploy of the last
   version) when STOPPED. DeploymentTab and StatusCard shed their
   onStop/onStart props.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:51:11 +02:00
hsiegeln
9c54313ff1 ui(deploy): surface deployment failure reason in StatusCard
DeploymentExecutor already persists errorMessage on FAILED transitions
but the UI never rendered it — users saw "FAILED" with no explanation.
Add a bordered error block above the action row when a deployment is
FAILED, preserving whitespace and wrapping long Docker error bodies
(e.g. 409 conflict JSON).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:49:29 +02:00
hsiegeln
e5eb48b0fa ui(admin): env-colored ring on environment avatars
Wrap Avatar in a span with box-shadow outline in the environment's
chosen color (slate/red/amber/green/teal/blue/purple/pink). Applied to
both the list row and the detail header. Keeps the Avatar's name-hash
interior so initials remain distinguishable; the ring just signals
which env you're looking at at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:48:51 +02:00
hsiegeln
b655de3975 fix(config): structured 400 body on unknown apply value
Replace empty-body ResponseEntity.status(BAD_REQUEST).build() with
ResponseStatusException so Spring returns the usual error body shape
with a descriptive reason string, matching the idiom used by
UserAdminController, AppSettingsController, ThresholdAdminController.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:45:31 +02:00
hsiegeln
4e19f925c6 ui(deploy): loading-aware default for dirty-state baseline
Previously `dirtyState?.dirty ?? true` caused a stale `Redeploy` label
to flash briefly while the first fetch was in flight. Gate the default
on isLoading so the button starts as `Save (disabled)` until the
endpoint resolves — spurious Redeploy clicks were harmless but the
loading-state UX was wrong.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:42:48 +02:00
hsiegeln
8a7f9cb370 fix(deploy): compare samplingRate as number in dirty detection
Drop the Number.isInteger normalization hack in useDeploymentPageState
that mapped 1.0 → "1.0" but broke for values like 1.10 (which round-trip
to 1.1). Instead, useFormDirty now parseFloats samplingRate on both sides
before comparing, so "1", "1.0", and "1.00" all compare equal regardless
of how the backend serializes the number.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:41:33 +02:00
hsiegeln
b5ecd39100 docs(api): document ?apply query param on updateConfig (Swagger)
Adds @Parameter description so the generated OpenAPI spec / Swagger UI
explains what 'staged' vs 'live' means instead of just surfacing the
bare param name. Follow-up: run `cd ui && npm run generate-api:live`
against a live backend to refresh openapi.json + schema.d.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:39:10 +02:00
hsiegeln
629a009b36 ui(deploy): scrollIntoView when expanding a history row
On long deployment histories the StartupLogPanel would render off-screen
when the user clicked a row. Ref + useEffect scrolls the panel into view
with block:'nearest' so expanding a row that's already in view doesn't
cause a disorienting jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:38:23 +02:00
hsiegeln
ffdaeabc9f test(deploy): lock in FAILED→null snapshot for health-check-fail path
Existing IT only exercises the startContainer-throws path, where the
exception bypasses the entire try block. Add a test where startContainer
succeeds but getContainerStatus never returns healthy — this covers the
early-exit at the HEALTH_CHECK stage, which is the common real-world
failure shape and closest to the snapshot-write point.

Shortens healthchecktimeout to 2s via @TestPropertySource so the test
completes in a few seconds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:37:37 +02:00
hsiegeln
703bd412ed fix(deploy): toast when restoring checkpoint with no snapshot
handleRestore previously returned silently when deployedConfigSnapshot
was null, leaving the user wondering why their click did nothing. Show
a warning toast explaining that the checkpoint predates snapshotting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:34:45 +02:00
hsiegeln
4d4c59efe3 fix(deploy): include DEGRADED deploys as restorable checkpoints
Snapshot is written by DeploymentExecutor before the RUNNING/DEGRADED
split, so DEGRADED rows already carry a deployed_config_snapshot. Treat
them as checkpoints — partial-healthy deploys still produced a working
config worth restoring. Aligns repo query with UI filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:34:25 +02:00
14 changed files with 262 additions and 66 deletions

View File

@@ -24,6 +24,7 @@ import com.cameleer.server.core.storage.DiagramStore;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
@@ -33,6 +34,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
@@ -114,12 +116,15 @@ public class ApplicationConfigController {
@ApiResponse(responseCode = "400", description = "Unknown apply value (must be 'staged' or 'live')")
public ResponseEntity<ConfigUpdateResponse> updateConfig(@EnvPath Environment env,
@PathVariable String appSlug,
@Parameter(name = "apply",
description = "When to apply: 'live' (default) saves and pushes CONFIG_UPDATE to live agents immediately; 'staged' saves without pushing — the next successful deploy applies it.")
@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();
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unknown apply value '" + apply + "' — must be 'staged' or 'live'");
}
String updatedBy = auth != null ? auth.getName() : "system";

View File

@@ -167,6 +167,21 @@ public class DeploymentExecutor {
}
}
// === STOP PREVIOUS ACTIVE DEPLOYMENT ===
// Container names are deterministic ({tenant}-{env}-{app}-{replica}), so a
// previous active deployment holds the Docker names we need. Stop + remove
// it before starting new replicas to avoid a 409 name conflict. Excluding
// the current deployment id by SQL (not Java) because the newly created
// row already has status=STARTING and would otherwise be picked by
// findActiveByAppIdAndEnvironmentId ORDER BY created_at DESC LIMIT 1.
Optional<Deployment> previous = deploymentRepository.findActiveByAppIdAndEnvironmentIdExcluding(
deployment.appId(), deployment.environmentId(), deployment.id());
if (previous.isPresent()) {
log.info("Stopping previous deployment {} before starting new replicas", previous.get().id());
stopDeploymentContainers(previous.get());
deploymentService.markStopped(previous.get().id());
}
// === START REPLICAS ===
updateStage(deployment.id(), DeployStage.START_REPLICAS);
@@ -244,16 +259,12 @@ public class DeploymentExecutor {
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
// === SWAP TRAFFIC ===
// Traffic is routed via Traefik Docker labels, so the "swap" happens
// implicitly once the new replicas are healthy and the old containers
// are gone. The old deployment was already stopped before START_REPLICAS
// to free the deterministic container names.
updateStage(deployment.id(), DeployStage.SWAP_TRAFFIC);
Optional<Deployment> existing = deploymentRepository.findActiveByAppIdAndEnvironmentId(
deployment.appId(), deployment.environmentId());
if (existing.isPresent() && !existing.get().id().equals(deployment.id())) {
stopDeploymentContainers(existing.get());
deploymentService.markStopped(existing.get().id());
log.info("Stopped previous deployment {} for replacement", existing.get().id());
}
// === COMPLETE ===
updateStage(deployment.id(), DeployStage.COMPLETE);

View File

@@ -63,6 +63,16 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@Override
public Optional<Deployment> findActiveByAppIdAndEnvironmentIdExcluding(UUID appId, UUID environmentId, UUID excludeDeploymentId) {
var results = jdbc.query(
"SELECT " + SELECT_COLS + " FROM deployments WHERE app_id = ? AND environment_id = ? " +
"AND status IN ('STARTING', 'RUNNING', 'DEGRADED') AND id <> ? " +
"ORDER BY created_at DESC LIMIT 1",
(rs, rowNum) -> mapRow(rs), appId, environmentId, excludeDeploymentId);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
public List<Deployment> findByStatus(List<DeploymentStatus> statuses) {
String placeholders = String.join(",", statuses.stream().map(s -> "'" + s.name() + "'").toList());
return jdbc.query(
@@ -140,10 +150,12 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
}
public Optional<Deployment> findLatestSuccessfulByAppAndEnv(UUID appId, UUID envId) {
// DEGRADED deploys also carry a snapshot (executor writes before the RUNNING/DEGRADED
// split), and represent a config that reached COMPLETE stage — restorable for the user.
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 "
+ "AND status IN ('RUNNING', 'DEGRADED') 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));

View File

@@ -20,6 +20,7 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
@@ -34,8 +35,10 @@ import static org.mockito.Mockito.when;
/**
* Verifies that DeploymentExecutor writes DeploymentConfigSnapshot on successful
* RUNNING transition and does NOT write it on a FAILED path.
* RUNNING transition and does NOT write it on a FAILED path (both the
* startContainer-throws path and the health-check-fails path).
*/
@TestPropertySource(properties = "cameleer.server.runtime.healthchecktimeout=2")
class DeploymentSnapshotIT extends AbstractPostgresIT {
@MockBean
@@ -189,6 +192,53 @@ class DeploymentSnapshotIT extends AbstractPostgresIT {
assertThat(failed.deployedConfigSnapshot()).isNull();
}
// -----------------------------------------------------------------------
// Test 3: snapshot is NOT populated when the health check never passes.
// This exercises the early-exit path in DeploymentExecutor (line ~231) —
// startContainer succeeds, but no replica ever reports healthy, so
// waitForAnyHealthy returns 0 before the snapshot-write point.
// -----------------------------------------------------------------------
@Test
void snapshot_isNotPopulated_whenHealthCheckFails() throws Exception {
// --- given: container starts but never becomes healthy ---
String fakeContainerId = "fake-unhealthy-" + UUID.randomUUID();
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
when(runtimeOrchestrator.startContainer(any())).thenReturn(fakeContainerId);
when(runtimeOrchestrator.getContainerStatus(fakeContainerId))
.thenReturn(new ContainerStatus("starting", true, 0, null));
String appSlug = "snap-unhealthy-" + UUID.randomUUID().toString().substring(0, 8);
post("/api/v1/environments/default/apps", String.format("""
{"slug": "%s", "displayName": "Snapshot Unhealthy App"}
""", appSlug), operatorJwt);
put("/api/v1/environments/default/apps/" + appSlug + "/container-config",
"""
{"runtimeType": "spring-boot", "appPort": 8081}
""", operatorJwt);
String versionId = uploadJar(appSlug, ("fake-jar-unhealthy-" + appSlug).getBytes());
// --- when: trigger deploy ---
JsonNode deployResponse = post(
"/api/v1/environments/default/apps/" + appSlug + "/deployments",
String.format("{\"appVersionId\": \"%s\"}", versionId), operatorJwt);
String deploymentId = deployResponse.path("id").asText();
// --- await FAILED (healthchecktimeout overridden to 2s in @TestPropertySource) ---
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 (snapshot-write is gated behind health check) ---
Deployment failed = deploymentRepository.findById(UUID.fromString(deploymentId)).orElseThrow();
assertThat(failed.deployedConfigSnapshot()).isNull();
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------

View File

@@ -9,6 +9,7 @@ public interface DeploymentRepository {
List<Deployment> findByEnvironmentId(UUID environmentId);
Optional<Deployment> findById(UUID id);
Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId);
Optional<Deployment> findActiveByAppIdAndEnvironmentIdExcluding(UUID appId, UUID environmentId, UUID excludeDeploymentId);
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName);
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
void markDeployed(UUID id);

View File

@@ -1,6 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import {
Avatar,
Badge,
Button,
Input,
@@ -221,7 +220,7 @@ export default function EnvironmentsPage() {
items={filtered}
renderItem={(env) => (
<>
<Avatar name={env.displayName} size="sm" />
<EnvColoredAvatar name={env.displayName} color={env.color} size="sm" />
<div className={styles.entityInfo}>
<div className={styles.entityName}>{env.displayName}</div>
<div className={styles.entityMeta}>
@@ -250,7 +249,7 @@ export default function EnvironmentsPage() {
selected ? (
<>
<div className={styles.detailHeader}>
<Avatar name={selected.displayName} size="lg" />
<EnvColoredAvatar name={selected.displayName} color={selected.color} size="lg" />
<div className={styles.detailHeaderInfo}>
<div className={styles.detailName}>
{isDefault ? (
@@ -392,6 +391,56 @@ export default function EnvironmentsPage() {
);
}
// ── Env-colored Avatar ──────────────────────────────────────────────
// Custom circular initial badge filled with the environment's color. We don't
// wrap the DS `Avatar` because it picks its background from a name hash with
// no color override — we need the env color to be the dominant signal.
const ENV_AVATAR_SIZES = { sm: 24, md: 28, lg: 40 } as const;
const ENV_AVATAR_FONT = { sm: 10, md: 12, lg: 16 } as const;
function envInitials(displayName: string): string {
const words = displayName.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '?';
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
return (words[0][0] + words[1][0]).toUpperCase();
}
function EnvColoredAvatar({ name, color, size }: {
name: string;
color: string | null | undefined;
size: 'sm' | 'md' | 'lg';
}) {
const dimension = ENV_AVATAR_SIZES[size];
const fontSize = ENV_AVATAR_FONT[size];
return (
<span
aria-label={name}
title={name}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: dimension,
height: dimension,
borderRadius: '50%',
background: envColorVar(color),
color: '#fff',
fontSize,
fontWeight: 600,
flexShrink: 0,
// subtle border so the circle still reads on bg-surface when the env
// color itself is very light (amber, green)
border: '1px solid var(--border-subtle)',
userSelect: 'none',
textShadow: '0 1px 1px rgba(0,0,0,0.25)',
}}
>
{envInitials(name)}
</span>
);
}
// ── Default Resource Limits ─────────────────────────────────────────
function DefaultResourcesSection({ environment, onSave, saving }: {

View File

@@ -252,6 +252,26 @@
.statusCardGrid { display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px; font-size: 13px; }
.statusCardActions { display: flex; gap: 8px; }
.statusCardError {
padding: 10px 12px;
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-family: var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
}
.statusCardErrorLabel {
font-family: var(--font-sans);
color: var(--error);
font-weight: 600;
display: block;
margin-bottom: 4px;
}
/* DeploymentTab */
.deploymentTab {
display: flex;

View File

@@ -15,9 +15,15 @@ export function Checkpoints({ deployments, versions, currentDeploymentId, onRest
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.
// Deployments that reached COMPLETE stage and captured a snapshot (RUNNING or DEGRADED).
// Exclude the currently-running one.
const checkpoints = deployments
.filter((d) => d.deployedAt && d.status === 'RUNNING' && d.id !== currentDeploymentId)
.filter(
(d) =>
d.deployedAt &&
(d.status === 'RUNNING' || d.status === 'DEGRADED') &&
d.id !== currentDeploymentId,
)
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
return (

View File

@@ -12,11 +12,9 @@ interface Props {
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) {
export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl }: Props) {
const latest = deployments
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
@@ -33,8 +31,6 @@ export function DeploymentTab({ deployments, versions, appSlug, envSlug, externa
deployment={latest}
version={version}
externalUrl={externalUrl}
onStop={() => onStop(latest.id)}
onStart={() => onStart(latest.id)}
/>
{latest.status === 'STARTING' && (
<DeploymentProgress currentStage={latest.deployStage} failed={false} />

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, 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';
@@ -16,8 +16,15 @@ interface Props {
export function HistoryDisclosure({ deployments, versions, appSlug, envSlug }: Props) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const logPanelRef = useRef<HTMLDivElement | null>(null);
const versionMap = new Map(versions.map((v) => [v.id, v]));
useEffect(() => {
if (expanded && logPanelRef.current) {
logPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [expanded]);
const rows = deployments
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
@@ -55,7 +62,11 @@ export function HistoryDisclosure({ deployments, versions, appSlug, envSlug }: P
{expanded && (() => {
const d = rows.find((r) => r.id === expanded);
if (!d) return null;
return <StartupLogPanel deployment={d} appSlug={appSlug} envSlug={envSlug} />;
return (
<div ref={logPanelRef}>
<StartupLogPanel deployment={d} appSlug={appSlug} envSlug={envSlug} />
</div>
);
})()}
</>
)}

View File

@@ -1,4 +1,4 @@
import { Badge, StatusDot, MonoText, Button } from '@cameleer/design-system';
import { Badge, StatusDot, MonoText } 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';
@@ -13,21 +13,13 @@ const DEPLOY_STATUS_DOT = {
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) {
export function StatusCard({ deployment, version, externalUrl }: Props) {
const running = deployment.replicaStates?.filter((r) => r.status === 'RUNNING').length ?? 0;
const total = deployment.replicaStates?.length ?? 0;
@@ -50,11 +42,12 @@ export function StatusCard({ deployment, version, externalUrl, onStop, onStart }
<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>
{deployment.status === 'FAILED' && deployment.errorMessage && (
<div className={styles.statusCardError}>
<span className={styles.statusCardErrorLabel}>Failure reason</span>
{deployment.errorMessage}
</div>
)}
</div>
);
}

View File

@@ -97,7 +97,7 @@ export function useDeploymentPageState(
metricsEnabled: agentConfig?.metricsEnabled ?? defaultForm.monitoring.metricsEnabled,
metricsInterval: defaultForm.monitoring.metricsInterval,
samplingRate: agentConfig?.samplingRate !== undefined
? (Number.isInteger(agentConfig.samplingRate) ? `${agentConfig.samplingRate}.0` : String(agentConfig.samplingRate))
? String(agentConfig.samplingRate)
: defaultForm.monitoring.samplingRate,
compressSuccess: agentConfig?.compressSuccess ?? defaultForm.monitoring.compressSuccess,
replayEnabled: defaultForm.monitoring.replayEnabled,

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import type { DeploymentPageFormState } from './useDeploymentPageState';
import type { DeploymentPageFormState, MonitoringFormState } from './useDeploymentPageState';
export interface PerTabDirty {
monitoring: boolean;
@@ -9,13 +9,22 @@ export interface PerTabDirty {
anyLocalEdit: boolean;
}
// Normalize free-text numeric fields (user types "1.0" / "1" / "1.00" — all equal).
// NaN compares as NaN through JSON, which is harmless since both sides coerce the same.
function normalizeMonitoring(m: MonitoringFormState): Omit<MonitoringFormState, 'samplingRate'> & { samplingRate: number } {
const { samplingRate, ...rest } = m;
return { ...rest, samplingRate: parseFloat(samplingRate) };
}
export function useFormDirty(
form: DeploymentPageFormState,
serverState: DeploymentPageFormState,
stagedJar: File | null,
): PerTabDirty {
return useMemo(() => {
const monitoring = JSON.stringify(form.monitoring) !== JSON.stringify(serverState.monitoring);
const monitoring =
JSON.stringify(normalizeMonitoring(form.monitoring)) !==
JSON.stringify(normalizeMonitoring(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);

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, Button, Tabs, useToast } from '@cameleer/design-system';
import { AlertDialog, Badge, Button, Tabs, useToast } from '@cameleer/design-system';
import { useEnvironmentStore } from '../../../api/environment-store';
import { useEnvironments } from '../../../api/queries/admin/environments';
import {
@@ -60,9 +60,12 @@ export default function AppDeploymentPage() {
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 latestDeployment = deployments
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
const { data: agentConfig = null } = useApplicationConfig(app?.slug, selectedEnv);
const { data: dirtyState } = useDirtyState(selectedEnv, app?.slug);
const { data: dirtyState, isLoading: dirtyLoading } = useDirtyState(selectedEnv, app?.slug);
// Mutations
const createApp = useCreateApp();
@@ -113,7 +116,11 @@ export default function AppDeploymentPage() {
const dirty = useFormDirty(form, serverState, stagedJar);
const { dialogOpen: blockerOpen, confirm: blockerConfirm, cancel: blockerCancel } =
useUnsavedChangesBlocker(dirty.anyLocalEdit);
const serverDirtyAgainstDeploy = dirtyState?.dirty ?? true;
// Before the first dirty-state fetch resolves, default to "not dirty" so the
// primary button shows `Save (disabled)` — not a stale `Redeploy`. Once loaded,
// fall back to `true` if the endpoint failed entirely (fail-safe for the
// redeploy path).
const serverDirtyAgainstDeploy = app && dirtyLoading ? false : (dirtyState?.dirty ?? true);
const deploymentInProgress = !!activeDeployment;
const primaryMode = computeMode({
deploymentInProgress,
@@ -321,7 +328,14 @@ export default function AppDeploymentPage() {
const deployment = deployments.find((d) => d.id === deploymentId);
if (!deployment) return;
const snap = deployment.deployedConfigSnapshot;
if (!snap) return;
if (!snap) {
toast({
title: 'Cannot restore checkpoint',
description: 'This checkpoint predates snapshotting and cannot be restored.',
variant: 'warning',
});
return;
}
setForm((prev) => {
const a = snap.agentConfig ?? {};
@@ -387,7 +401,12 @@ export default function AppDeploymentPage() {
<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: 10 }}>
<h2 style={{ margin: 0 }}>{app ? app.displayName : 'Create Application'}</h2>
{app && !deploymentInProgress && (dirty.anyLocalEdit || serverDirtyAgainstDeploy) && (
<Badge label="Pending deploy" color="warning" />
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{dirty.anyLocalEdit && (
<Button
@@ -406,6 +425,40 @@ export default function AppDeploymentPage() {
enabled={primaryEnabled}
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
/>
{app && latestDeployment && (
latestDeployment.status === 'RUNNING'
|| latestDeployment.status === 'STARTING'
|| latestDeployment.status === 'DEGRADED'
) && (
<Button size="sm" variant="danger" onClick={() => handleStop(latestDeployment.id)}>
Stop
</Button>
)}
{app && latestDeployment && latestDeployment.status === 'STOPPED' && (
<Button
size="sm"
variant="secondary"
onClick={() => {
setTab('deployment');
createDeployment
.mutateAsync({
envSlug: selectedEnv!,
appSlug: app.slug,
appVersionId: latestDeployment.appVersionId,
})
.catch((e: unknown) =>
toast({
title: 'Start failed',
description: e instanceof Error ? e.message : 'Unknown error',
variant: 'error',
duration: 86_400_000,
}),
);
}}
>
Start
</Button>
)}
{app && (
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>
Delete App
@@ -482,26 +535,6 @@ export default function AppDeploymentPage() {
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 && (