feat: active config snapshot, composite StatusDot with tooltip
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 43s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Part 1 — Config snapshot:
- V8 migration adds resolved_config JSONB to deployments table
- DeploymentExecutor saves the full resolved config at deploy time
- Deployment record includes resolvedConfig for auditability

Part 2 — Composite health StatusDot:
- CatalogController computes composite health from deployment status +
  agent health (green only when RUNNING AND agent live)
- CatalogApp includes healthTooltip (e.g. "Deployment: RUNNING,
  Agents: live (1 connected)")
- StatusDot added to app detail header with deployment status Badge
- StatusDot added to deployment table rows
- Sidebar passes composite health + tooltip through to tree nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 08:00:54 +02:00
parent 7b822a787a
commit 2df5e0d7ba
10 changed files with 113 additions and 14 deletions

View File

@@ -180,17 +180,19 @@ public class CatalogController {
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
.toList();
// Health
String health = agents.isEmpty() ? "offline" : computeWorstHealth(agents);
// Agent health
String agentHealth = agents.isEmpty() ? "offline" : computeWorstHealth(agents);
// Total exchanges
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
// Deployment summary (managed apps only)
CatalogApp.DeploymentSummary deploymentSummary = null;
DeploymentStatus deployStatus = null;
if (app != null) {
Deployment dep = activeDeployments.get(app.id());
if (dep != null) {
deployStatus = dep.status();
int healthy = 0, total = 0;
if (dep.replicaStates() != null) {
total = dep.replicaStates().size();
@@ -198,7 +200,6 @@ public class CatalogController {
.filter(r -> "RUNNING".equals(r.get("status")))
.count();
}
// Get version number from app version
int version = 0;
try {
var versions = appService.listVersions(app.id());
@@ -216,6 +217,10 @@ public class CatalogController {
}
}
// Composite health + tooltip
String health = compositeHealth(app != null ? deployStatus : null, agentHealth);
String healthTooltip = buildHealthTooltip(app != null, deployStatus, agentHealth, agents.size());
String displayName = app != null ? app.displayName() : slug;
String appEnvSlug = envSlug;
if (app != null && appEnvSlug.isEmpty()) {
@@ -226,7 +231,7 @@ public class CatalogController {
catalog.add(new CatalogApp(
slug, displayName, app != null, appEnvSlug,
health, agents.size(), routeSummaries, agentSummaries,
health, healthTooltip, agents.size(), routeSummaries, agentSummaries,
totalExchanges, deploymentSummary
));
}
@@ -263,4 +268,29 @@ public class CatalogController {
if (hasStale) return "stale";
return "live";
}
private String compositeHealth(DeploymentStatus deployStatus, String agentHealth) {
if (deployStatus == null) return agentHealth; // unmanaged or no deployment
return switch (deployStatus) {
case STARTING -> "running";
case STOPPING, DEGRADED -> "stale";
case STOPPED -> "dead";
case FAILED -> "error";
case RUNNING -> "offline".equals(agentHealth) ? "stale" : agentHealth;
};
}
private String buildHealthTooltip(boolean managed, DeploymentStatus deployStatus, String agentHealth, int agentCount) {
if (!managed) {
return "Agents: " + agentHealth + " (" + agentCount + " connected)";
}
if (deployStatus == null) {
return "No deployment";
}
String depPart = "Deployment: " + deployStatus.name();
if (deployStatus == DeploymentStatus.RUNNING || deployStatus == DeploymentStatus.DEGRADED) {
return depPart + ", Agents: " + agentHealth + " (" + agentCount + " connected)";
}
return depPart;
}
}

View File

@@ -10,7 +10,8 @@ public record CatalogApp(
@Schema(description = "Display name") String displayName,
@Schema(description = "True if a managed App record exists in the database") boolean managed,
@Schema(description = "Environment slug") String environmentSlug,
@Schema(description = "Worst health state among agents: live, stale, dead, offline") String health,
@Schema(description = "Composite health: deployment status + agent health") String health,
@Schema(description = "Human-readable tooltip explaining the health state") String healthTooltip,
@Schema(description = "Number of connected agents") int agentCount,
@Schema(description = "Live routes from agents") List<RouteSummary> routes,
@Schema(description = "Connected agent summaries") List<AgentSummary> agents,

View File

@@ -95,6 +95,7 @@ public class DeploymentExecutor {
globalDefaults, env.defaultContainerConfig(), app.containerConfig());
pgDeployRepo.updateDeploymentStrategy(deployment.id(), config.deploymentStrategy());
pgDeployRepo.updateResolvedConfig(deployment.id(), resolvedConfigToMap(config));
// === PRE-FLIGHT ===
updateStage(deployment.id(), DeployStage.PRE_FLIGHT);
@@ -312,4 +313,23 @@ public class DeploymentExecutor {
if (limit.endsWith("m")) return (int) Double.parseDouble(limit.replace("m", ""));
return Integer.parseInt(limit);
}
private Map<String, Object> resolvedConfigToMap(ResolvedContainerConfig config) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("memoryLimitMb", config.memoryLimitMb());
if (config.memoryReserveMb() != null) map.put("memoryReserveMb", config.memoryReserveMb());
map.put("cpuRequest", config.cpuRequest());
if (config.cpuLimit() != null) map.put("cpuLimit", config.cpuLimit());
map.put("appPort", config.appPort());
map.put("exposedPorts", config.exposedPorts());
map.put("customEnvVars", config.customEnvVars());
map.put("stripPathPrefix", config.stripPathPrefix());
map.put("sslOffloading", config.sslOffloading());
map.put("routingMode", config.routingMode());
map.put("routingDomain", config.routingDomain());
map.put("serverUrl", config.serverUrl());
map.put("replicas", config.replicas());
map.put("deploymentStrategy", config.deploymentStrategy());
return map;
}
}

View File

@@ -21,7 +21,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
private static final String SELECT_COLS =
"id, app_id, app_version_id, environment_id, status, target_state, deployment_strategy, " +
"replica_states, deploy_stage, container_id, container_name, error_message, " +
"deployed_at, stopped_at, created_at";
"resolved_config, deployed_at, stopped_at, created_at";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
@@ -113,6 +113,15 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
appId, environmentId);
}
public void updateResolvedConfig(UUID id, Map<String, Object> resolvedConfig) {
try {
String json = objectMapper.writeValueAsString(resolvedConfig);
jdbc.update("UPDATE deployments SET resolved_config = ?::jsonb WHERE id = ?", json, id);
} catch (Exception e) {
throw new RuntimeException("Failed to serialize resolved_config", e);
}
}
public Optional<Deployment> findByContainerId(String containerId) {
var results = jdbc.query(
"SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? " +
@@ -133,6 +142,15 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
throw new SQLException("Failed to deserialize replica_states", e);
}
}
Map<String, Object> resolvedConfig = null;
String resolvedConfigJson = rs.getString("resolved_config");
if (resolvedConfigJson != null) {
try {
resolvedConfig = objectMapper.readValue(resolvedConfigJson, new TypeReference<>() {});
} catch (Exception e) {
throw new SQLException("Failed to deserialize resolved_config", e);
}
}
return new Deployment(
UUID.fromString(rs.getString("id")),
UUID.fromString(rs.getString("app_id")),
@@ -146,6 +164,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
rs.getString("container_id"),
rs.getString("container_name"),
rs.getString("error_message"),
resolvedConfig,
deployedAt != null ? deployedAt.toInstant() : null,
stoppedAt != null ? stoppedAt.toInstant() : null,
rs.getTimestamp("created_at").toInstant()

View File

@@ -0,0 +1 @@
ALTER TABLE deployments ADD COLUMN resolved_config JSONB;