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.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
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.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -33,6 +34,7 @@ import org.springframework.http.HttpStatus;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -114,12 +116,15 @@ public class ApplicationConfigController {
|
|||||||
@ApiResponse(responseCode = "400", description = "Unknown apply value (must be 'staged' or 'live')")
|
@ApiResponse(responseCode = "400", description = "Unknown apply value (must be 'staged' or 'live')")
|
||||||
public ResponseEntity<ConfigUpdateResponse> updateConfig(@EnvPath Environment env,
|
public ResponseEntity<ConfigUpdateResponse> updateConfig(@EnvPath Environment env,
|
||||||
@PathVariable String appSlug,
|
@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,
|
@RequestParam(name = "apply", defaultValue = "live") String apply,
|
||||||
@RequestBody ApplicationConfig config,
|
@RequestBody ApplicationConfig config,
|
||||||
Authentication auth,
|
Authentication auth,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
if (!"staged".equalsIgnoreCase(apply) && !"live".equalsIgnoreCase(apply)) {
|
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";
|
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 ===
|
// === START REPLICAS ===
|
||||||
updateStage(deployment.id(), DeployStage.START_REPLICAS);
|
updateStage(deployment.id(), DeployStage.START_REPLICAS);
|
||||||
|
|
||||||
@@ -244,16 +259,12 @@ public class DeploymentExecutor {
|
|||||||
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
|
pgDeployRepo.updateReplicaStates(deployment.id(), replicaStates);
|
||||||
|
|
||||||
// === SWAP TRAFFIC ===
|
// === 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);
|
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 ===
|
// === COMPLETE ===
|
||||||
updateStage(deployment.id(), DeployStage.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));
|
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) {
|
public List<Deployment> findByStatus(List<DeploymentStatus> statuses) {
|
||||||
String placeholders = String.join(",", statuses.stream().map(s -> "'" + s.name() + "'").toList());
|
String placeholders = String.join(",", statuses.stream().map(s -> "'" + s.name() + "'").toList());
|
||||||
return jdbc.query(
|
return jdbc.query(
|
||||||
@@ -140,10 +150,12 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Deployment> findLatestSuccessfulByAppAndEnv(UUID appId, UUID envId) {
|
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(
|
var results = jdbc.query(
|
||||||
"SELECT " + SELECT_COLS + " FROM deployments "
|
"SELECT " + SELECT_COLS + " FROM deployments "
|
||||||
+ "WHERE app_id = ? AND environment_id = ? "
|
+ "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",
|
+ "ORDER BY deployed_at DESC NULLS LAST LIMIT 1",
|
||||||
(rs, rowNum) -> mapRow(rs), appId, envId);
|
(rs, rowNum) -> mapRow(rs), appId, envId);
|
||||||
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
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.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
import org.springframework.util.LinkedMultiValueMap;
|
import org.springframework.util.LinkedMultiValueMap;
|
||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
|
|
||||||
@@ -34,8 +35,10 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies that DeploymentExecutor writes DeploymentConfigSnapshot on successful
|
* 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 {
|
class DeploymentSnapshotIT extends AbstractPostgresIT {
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
@@ -189,6 +192,53 @@ class DeploymentSnapshotIT extends AbstractPostgresIT {
|
|||||||
assertThat(failed.deployedConfigSnapshot()).isNull();
|
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
|
// Helpers
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public interface DeploymentRepository {
|
|||||||
List<Deployment> findByEnvironmentId(UUID environmentId);
|
List<Deployment> findByEnvironmentId(UUID environmentId);
|
||||||
Optional<Deployment> findById(UUID id);
|
Optional<Deployment> findById(UUID id);
|
||||||
Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId);
|
Optional<Deployment> findActiveByAppIdAndEnvironmentId(UUID appId, UUID environmentId);
|
||||||
|
Optional<Deployment> findActiveByAppIdAndEnvironmentIdExcluding(UUID appId, UUID environmentId, UUID excludeDeploymentId);
|
||||||
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName);
|
UUID create(UUID appId, UUID appVersionId, UUID environmentId, String containerName);
|
||||||
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
|
void updateStatus(UUID id, DeploymentStatus status, String containerId, String errorMessage);
|
||||||
void markDeployed(UUID id);
|
void markDeployed(UUID id);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
@@ -221,7 +220,7 @@ export default function EnvironmentsPage() {
|
|||||||
items={filtered}
|
items={filtered}
|
||||||
renderItem={(env) => (
|
renderItem={(env) => (
|
||||||
<>
|
<>
|
||||||
<Avatar name={env.displayName} size="sm" />
|
<EnvColoredAvatar name={env.displayName} color={env.color} size="sm" />
|
||||||
<div className={styles.entityInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.entityName}>{env.displayName}</div>
|
<div className={styles.entityName}>{env.displayName}</div>
|
||||||
<div className={styles.entityMeta}>
|
<div className={styles.entityMeta}>
|
||||||
@@ -250,7 +249,7 @@ export default function EnvironmentsPage() {
|
|||||||
selected ? (
|
selected ? (
|
||||||
<>
|
<>
|
||||||
<div className={styles.detailHeader}>
|
<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.detailHeaderInfo}>
|
||||||
<div className={styles.detailName}>
|
<div className={styles.detailName}>
|
||||||
{isDefault ? (
|
{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 ─────────────────────────────────────────
|
// ── Default Resource Limits ─────────────────────────────────────────
|
||||||
|
|
||||||
function DefaultResourcesSection({ environment, onSave, saving }: {
|
function DefaultResourcesSection({ environment, onSave, saving }: {
|
||||||
|
|||||||
@@ -252,6 +252,26 @@
|
|||||||
.statusCardGrid { display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px; font-size: 13px; }
|
.statusCardGrid { display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px; font-size: 13px; }
|
||||||
.statusCardActions { display: flex; gap: 8px; }
|
.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 */
|
||||||
.deploymentTab {
|
.deploymentTab {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -15,9 +15,15 @@ export function Checkpoints({ deployments, versions, currentDeploymentId, onRest
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
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
|
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 ?? ''));
|
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,11 +12,9 @@ interface Props {
|
|||||||
appSlug: string;
|
appSlug: string;
|
||||||
envSlug: string;
|
envSlug: string;
|
||||||
externalUrl: 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
|
const latest = deployments
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
|
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
|
||||||
@@ -33,8 +31,6 @@ export function DeploymentTab({ deployments, versions, appSlug, envSlug, externa
|
|||||||
deployment={latest}
|
deployment={latest}
|
||||||
version={version}
|
version={version}
|
||||||
externalUrl={externalUrl}
|
externalUrl={externalUrl}
|
||||||
onStop={() => onStop(latest.id)}
|
|
||||||
onStart={() => onStart(latest.id)}
|
|
||||||
/>
|
/>
|
||||||
{latest.status === 'STARTING' && (
|
{latest.status === 'STARTING' && (
|
||||||
<DeploymentProgress currentStage={latest.deployStage} failed={false} />
|
<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 { DataTable } from '@cameleer/design-system';
|
||||||
import type { Column } from '@cameleer/design-system';
|
import type { Column } from '@cameleer/design-system';
|
||||||
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
|
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
|
||||||
@@ -16,8 +16,15 @@ interface Props {
|
|||||||
export function HistoryDisclosure({ deployments, versions, appSlug, envSlug }: Props) {
|
export function HistoryDisclosure({ deployments, versions, appSlug, envSlug }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const logPanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
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
|
const rows = deployments
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||||
@@ -55,7 +62,11 @@ export function HistoryDisclosure({ deployments, versions, appSlug, envSlug }: P
|
|||||||
{expanded && (() => {
|
{expanded && (() => {
|
||||||
const d = rows.find((r) => r.id === expanded);
|
const d = rows.find((r) => r.id === expanded);
|
||||||
if (!d) return null;
|
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 type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
|
||||||
import { timeAgo } from '../../../../utils/format-utils';
|
import { timeAgo } from '../../../../utils/format-utils';
|
||||||
import styles from '../AppDeploymentPage.module.css';
|
import styles from '../AppDeploymentPage.module.css';
|
||||||
@@ -13,21 +13,13 @@ const DEPLOY_STATUS_DOT = {
|
|||||||
STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error',
|
STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error',
|
||||||
} as const;
|
} 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 {
|
interface Props {
|
||||||
deployment: Deployment;
|
deployment: Deployment;
|
||||||
version: AppVersion | null;
|
version: AppVersion | null;
|
||||||
externalUrl: string;
|
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 running = deployment.replicaStates?.filter((r) => r.status === 'RUNNING').length ?? 0;
|
||||||
const total = deployment.replicaStates?.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>
|
<span>Deployed</span><span>{deployment.deployedAt ? timeAgo(deployment.deployedAt) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.statusCardActions}>
|
{deployment.status === 'FAILED' && deployment.errorMessage && (
|
||||||
{(deployment.status === 'RUNNING' || deployment.status === 'STARTING' || deployment.status === 'DEGRADED')
|
<div className={styles.statusCardError}>
|
||||||
&& <Button size="sm" variant="danger" onClick={onStop}>Stop</Button>}
|
<span className={styles.statusCardErrorLabel}>Failure reason</span>
|
||||||
{deployment.status === 'STOPPED' && <Button size="sm" variant="secondary" onClick={onStart}>Start</Button>}
|
{deployment.errorMessage}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function useDeploymentPageState(
|
|||||||
metricsEnabled: agentConfig?.metricsEnabled ?? defaultForm.monitoring.metricsEnabled,
|
metricsEnabled: agentConfig?.metricsEnabled ?? defaultForm.monitoring.metricsEnabled,
|
||||||
metricsInterval: defaultForm.monitoring.metricsInterval,
|
metricsInterval: defaultForm.monitoring.metricsInterval,
|
||||||
samplingRate: agentConfig?.samplingRate !== undefined
|
samplingRate: agentConfig?.samplingRate !== undefined
|
||||||
? (Number.isInteger(agentConfig.samplingRate) ? `${agentConfig.samplingRate}.0` : String(agentConfig.samplingRate))
|
? String(agentConfig.samplingRate)
|
||||||
: defaultForm.monitoring.samplingRate,
|
: defaultForm.monitoring.samplingRate,
|
||||||
compressSuccess: agentConfig?.compressSuccess ?? defaultForm.monitoring.compressSuccess,
|
compressSuccess: agentConfig?.compressSuccess ?? defaultForm.monitoring.compressSuccess,
|
||||||
replayEnabled: defaultForm.monitoring.replayEnabled,
|
replayEnabled: defaultForm.monitoring.replayEnabled,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { DeploymentPageFormState } from './useDeploymentPageState';
|
import type { DeploymentPageFormState, MonitoringFormState } from './useDeploymentPageState';
|
||||||
|
|
||||||
export interface PerTabDirty {
|
export interface PerTabDirty {
|
||||||
monitoring: boolean;
|
monitoring: boolean;
|
||||||
@@ -9,13 +9,22 @@ export interface PerTabDirty {
|
|||||||
anyLocalEdit: boolean;
|
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(
|
export function useFormDirty(
|
||||||
form: DeploymentPageFormState,
|
form: DeploymentPageFormState,
|
||||||
serverState: DeploymentPageFormState,
|
serverState: DeploymentPageFormState,
|
||||||
stagedJar: File | null,
|
stagedJar: File | null,
|
||||||
): PerTabDirty {
|
): PerTabDirty {
|
||||||
return useMemo(() => {
|
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 resources = JSON.stringify(form.resources) !== JSON.stringify(serverState.resources);
|
||||||
const variables = JSON.stringify(form.variables) !== JSON.stringify(serverState.variables);
|
const variables = JSON.stringify(form.variables) !== JSON.stringify(serverState.variables);
|
||||||
const sensitiveKeys = JSON.stringify(form.sensitiveKeys) !== JSON.stringify(serverState.sensitiveKeys);
|
const sensitiveKeys = JSON.stringify(form.sensitiveKeys) !== JSON.stringify(serverState.sensitiveKeys);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, useLocation, useNavigate } from 'react-router';
|
import { useParams, useLocation, useNavigate } from 'react-router';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
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 { useEnvironmentStore } from '../../../api/environment-store';
|
||||||
import { useEnvironments } from '../../../api/queries/admin/environments';
|
import { useEnvironments } from '../../../api/queries/admin/environments';
|
||||||
import {
|
import {
|
||||||
@@ -60,9 +60,12 @@ export default function AppDeploymentPage() {
|
|||||||
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
|
const { data: deployments = [] } = useDeployments(selectedEnv, app?.slug);
|
||||||
const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null;
|
const currentDeployment = deployments.find((d) => d.status === 'RUNNING') ?? null;
|
||||||
const activeDeployment = deployments.find((d) => d.status === 'STARTING') ?? 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: agentConfig = null } = useApplicationConfig(app?.slug, selectedEnv);
|
||||||
const { data: dirtyState } = useDirtyState(selectedEnv, app?.slug);
|
const { data: dirtyState, isLoading: dirtyLoading } = useDirtyState(selectedEnv, app?.slug);
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const createApp = useCreateApp();
|
const createApp = useCreateApp();
|
||||||
@@ -113,7 +116,11 @@ export default function AppDeploymentPage() {
|
|||||||
const dirty = useFormDirty(form, serverState, stagedJar);
|
const dirty = useFormDirty(form, serverState, stagedJar);
|
||||||
const { dialogOpen: blockerOpen, confirm: blockerConfirm, cancel: blockerCancel } =
|
const { dialogOpen: blockerOpen, confirm: blockerConfirm, cancel: blockerCancel } =
|
||||||
useUnsavedChangesBlocker(dirty.anyLocalEdit);
|
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 deploymentInProgress = !!activeDeployment;
|
||||||
const primaryMode = computeMode({
|
const primaryMode = computeMode({
|
||||||
deploymentInProgress,
|
deploymentInProgress,
|
||||||
@@ -321,7 +328,14 @@ export default function AppDeploymentPage() {
|
|||||||
const deployment = deployments.find((d) => d.id === deploymentId);
|
const deployment = deployments.find((d) => d.id === deploymentId);
|
||||||
if (!deployment) return;
|
if (!deployment) return;
|
||||||
const snap = deployment.deployedConfigSnapshot;
|
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) => {
|
setForm((prev) => {
|
||||||
const a = snap.agentConfig ?? {};
|
const a = snap.agentConfig ?? {};
|
||||||
@@ -387,7 +401,12 @@ export default function AppDeploymentPage() {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* ── Page header ── */}
|
{/* ── Page header ── */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
<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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{dirty.anyLocalEdit && (
|
{dirty.anyLocalEdit && (
|
||||||
<Button
|
<Button
|
||||||
@@ -406,6 +425,40 @@ export default function AppDeploymentPage() {
|
|||||||
enabled={primaryEnabled}
|
enabled={primaryEnabled}
|
||||||
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
|
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 && (
|
{app && (
|
||||||
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>
|
<Button size="sm" variant="danger" onClick={() => setDeleteConfirm(true)}>
|
||||||
Delete App
|
Delete App
|
||||||
@@ -482,26 +535,6 @@ export default function AppDeploymentPage() {
|
|||||||
appSlug={app.slug}
|
appSlug={app.slug}
|
||||||
envSlug={env.slug}
|
envSlug={env.slug}
|
||||||
externalUrl={externalUrl}
|
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 && (
|
{tab === 'deployment' && !app && (
|
||||||
|
|||||||
Reference in New Issue
Block a user