Compare commits
13 Commits
837e5d46f5
...
4371372a26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4371372a26 | ||
|
|
f8dccaae2b | ||
|
|
9ecc9ee72a | ||
|
|
9c54313ff1 | ||
|
|
e5eb48b0fa | ||
|
|
b655de3975 | ||
|
|
4e19f925c6 | ||
|
|
8a7f9cb370 | ||
|
|
b5ecd39100 | ||
|
|
629a009b36 | ||
|
|
ffdaeabc9f | ||
|
|
703bd412ed | ||
|
|
4d4c59efe3 |
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user