feat: active config snapshot, composite StatusDot with tooltip
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:
@@ -180,17 +180,19 @@ public class CatalogController {
|
|||||||
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
|
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Health
|
// Agent health
|
||||||
String health = agents.isEmpty() ? "offline" : computeWorstHealth(agents);
|
String agentHealth = agents.isEmpty() ? "offline" : computeWorstHealth(agents);
|
||||||
|
|
||||||
// Total exchanges
|
// Total exchanges
|
||||||
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
|
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
|
||||||
|
|
||||||
// Deployment summary (managed apps only)
|
// Deployment summary (managed apps only)
|
||||||
CatalogApp.DeploymentSummary deploymentSummary = null;
|
CatalogApp.DeploymentSummary deploymentSummary = null;
|
||||||
|
DeploymentStatus deployStatus = null;
|
||||||
if (app != null) {
|
if (app != null) {
|
||||||
Deployment dep = activeDeployments.get(app.id());
|
Deployment dep = activeDeployments.get(app.id());
|
||||||
if (dep != null) {
|
if (dep != null) {
|
||||||
|
deployStatus = dep.status();
|
||||||
int healthy = 0, total = 0;
|
int healthy = 0, total = 0;
|
||||||
if (dep.replicaStates() != null) {
|
if (dep.replicaStates() != null) {
|
||||||
total = dep.replicaStates().size();
|
total = dep.replicaStates().size();
|
||||||
@@ -198,7 +200,6 @@ public class CatalogController {
|
|||||||
.filter(r -> "RUNNING".equals(r.get("status")))
|
.filter(r -> "RUNNING".equals(r.get("status")))
|
||||||
.count();
|
.count();
|
||||||
}
|
}
|
||||||
// Get version number from app version
|
|
||||||
int version = 0;
|
int version = 0;
|
||||||
try {
|
try {
|
||||||
var versions = appService.listVersions(app.id());
|
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 displayName = app != null ? app.displayName() : slug;
|
||||||
String appEnvSlug = envSlug;
|
String appEnvSlug = envSlug;
|
||||||
if (app != null && appEnvSlug.isEmpty()) {
|
if (app != null && appEnvSlug.isEmpty()) {
|
||||||
@@ -226,7 +231,7 @@ public class CatalogController {
|
|||||||
|
|
||||||
catalog.add(new CatalogApp(
|
catalog.add(new CatalogApp(
|
||||||
slug, displayName, app != null, appEnvSlug,
|
slug, displayName, app != null, appEnvSlug,
|
||||||
health, agents.size(), routeSummaries, agentSummaries,
|
health, healthTooltip, agents.size(), routeSummaries, agentSummaries,
|
||||||
totalExchanges, deploymentSummary
|
totalExchanges, deploymentSummary
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -263,4 +268,29 @@ public class CatalogController {
|
|||||||
if (hasStale) return "stale";
|
if (hasStale) return "stale";
|
||||||
return "live";
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ public record CatalogApp(
|
|||||||
@Schema(description = "Display name") String displayName,
|
@Schema(description = "Display name") String displayName,
|
||||||
@Schema(description = "True if a managed App record exists in the database") boolean managed,
|
@Schema(description = "True if a managed App record exists in the database") boolean managed,
|
||||||
@Schema(description = "Environment slug") String environmentSlug,
|
@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 = "Number of connected agents") int agentCount,
|
||||||
@Schema(description = "Live routes from agents") List<RouteSummary> routes,
|
@Schema(description = "Live routes from agents") List<RouteSummary> routes,
|
||||||
@Schema(description = "Connected agent summaries") List<AgentSummary> agents,
|
@Schema(description = "Connected agent summaries") List<AgentSummary> agents,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ public class DeploymentExecutor {
|
|||||||
globalDefaults, env.defaultContainerConfig(), app.containerConfig());
|
globalDefaults, env.defaultContainerConfig(), app.containerConfig());
|
||||||
|
|
||||||
pgDeployRepo.updateDeploymentStrategy(deployment.id(), config.deploymentStrategy());
|
pgDeployRepo.updateDeploymentStrategy(deployment.id(), config.deploymentStrategy());
|
||||||
|
pgDeployRepo.updateResolvedConfig(deployment.id(), resolvedConfigToMap(config));
|
||||||
|
|
||||||
// === PRE-FLIGHT ===
|
// === PRE-FLIGHT ===
|
||||||
updateStage(deployment.id(), DeployStage.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", ""));
|
if (limit.endsWith("m")) return (int) Double.parseDouble(limit.replace("m", ""));
|
||||||
return Integer.parseInt(limit);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
|||||||
private static final String SELECT_COLS =
|
private static final String SELECT_COLS =
|
||||||
"id, app_id, app_version_id, environment_id, status, target_state, deployment_strategy, " +
|
"id, app_id, app_version_id, environment_id, status, target_state, deployment_strategy, " +
|
||||||
"replica_states, deploy_stage, container_id, container_name, error_message, " +
|
"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 JdbcTemplate jdbc;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
@@ -113,6 +113,15 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
|||||||
appId, environmentId);
|
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) {
|
public Optional<Deployment> findByContainerId(String containerId) {
|
||||||
var results = jdbc.query(
|
var results = jdbc.query(
|
||||||
"SELECT " + SELECT_COLS + " FROM deployments WHERE replica_states::text LIKE ? " +
|
"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);
|
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(
|
return new Deployment(
|
||||||
UUID.fromString(rs.getString("id")),
|
UUID.fromString(rs.getString("id")),
|
||||||
UUID.fromString(rs.getString("app_id")),
|
UUID.fromString(rs.getString("app_id")),
|
||||||
@@ -146,6 +164,7 @@ public class PostgresDeploymentRepository implements DeploymentRepository {
|
|||||||
rs.getString("container_id"),
|
rs.getString("container_id"),
|
||||||
rs.getString("container_name"),
|
rs.getString("container_name"),
|
||||||
rs.getString("error_message"),
|
rs.getString("error_message"),
|
||||||
|
resolvedConfig,
|
||||||
deployedAt != null ? deployedAt.toInstant() : null,
|
deployedAt != null ? deployedAt.toInstant() : null,
|
||||||
stoppedAt != null ? stoppedAt.toInstant() : null,
|
stoppedAt != null ? stoppedAt.toInstant() : null,
|
||||||
rs.getTimestamp("created_at").toInstant()
|
rs.getTimestamp("created_at").toInstant()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE deployments ADD COLUMN resolved_config JSONB;
|
||||||
@@ -18,6 +18,7 @@ public record Deployment(
|
|||||||
String containerId,
|
String containerId,
|
||||||
String containerName,
|
String containerName,
|
||||||
String errorMessage,
|
String errorMessage,
|
||||||
|
Map<String, Object> resolvedConfig,
|
||||||
Instant deployedAt,
|
Instant deployedAt,
|
||||||
Instant stoppedAt,
|
Instant stoppedAt,
|
||||||
Instant createdAt
|
Instant createdAt
|
||||||
@@ -25,6 +26,7 @@ public record Deployment(
|
|||||||
public Deployment withStatus(DeploymentStatus newStatus) {
|
public Deployment withStatus(DeploymentStatus newStatus) {
|
||||||
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
|
return new Deployment(id, appId, appVersionId, environmentId, newStatus,
|
||||||
targetState, deploymentStrategy, replicaStates, deployStage,
|
targetState, deploymentStrategy, replicaStates, deployStage,
|
||||||
containerId, containerName, errorMessage, deployedAt, stoppedAt, createdAt);
|
containerId, containerName, errorMessage, resolvedConfig,
|
||||||
|
deployedAt, stoppedAt, createdAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export interface CatalogApp {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
managed: boolean;
|
managed: boolean;
|
||||||
environmentSlug: string;
|
environmentSlug: string;
|
||||||
health: 'live' | 'stale' | 'dead' | 'offline';
|
health: 'live' | 'stale' | 'dead' | 'offline' | 'running' | 'error';
|
||||||
|
healthTooltip: string;
|
||||||
agentCount: number;
|
agentCount: number;
|
||||||
routes: CatalogRoute[];
|
routes: CatalogRoute[];
|
||||||
agents: CatalogAgent[];
|
agents: CatalogAgent[];
|
||||||
|
|||||||
@@ -403,7 +403,8 @@ function LayoutContent() {
|
|||||||
.map((app: any) => ({
|
.map((app: any) => ({
|
||||||
id: app.slug,
|
id: app.slug,
|
||||||
name: app.displayName || app.slug,
|
name: app.displayName || app.slug,
|
||||||
health: (app.health === 'offline' ? 'dead' : app.health) as 'live' | 'stale' | 'dead',
|
health: (app.health === 'offline' ? 'dead' : app.health) as SidebarApp['health'],
|
||||||
|
healthTooltip: app.healthTooltip,
|
||||||
exchangeCount: app.exchangeCount,
|
exchangeCount: app.exchangeCount,
|
||||||
routes: [...(app.routes || [])]
|
routes: [...(app.routes || [])]
|
||||||
.sort((a: any, b: any) => cmp(a.routeId, b.routeId))
|
.sort((a: any, b: any) => cmp(a.routeId, b.routeId))
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ReactNode } from 'react';
|
import { createElement, type ReactNode } from 'react';
|
||||||
import type { SidebarTreeNode } from '@cameleer/design-system';
|
import type { SidebarTreeNode } from '@cameleer/design-system';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -15,7 +15,8 @@ export interface SidebarRoute {
|
|||||||
export interface SidebarApp {
|
export interface SidebarApp {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
health: 'live' | 'stale' | 'dead';
|
health: 'live' | 'stale' | 'dead' | 'running' | 'error';
|
||||||
|
healthTooltip?: string;
|
||||||
exchangeCount: number;
|
exchangeCount: number;
|
||||||
routes: SidebarRoute[];
|
routes: SidebarRoute[];
|
||||||
}
|
}
|
||||||
@@ -70,7 +71,9 @@ export function buildAppTreeNodes(
|
|||||||
return apps.map((app) => ({
|
return apps.map((app) => ({
|
||||||
id: app.id,
|
id: app.id,
|
||||||
label: app.name,
|
label: app.name,
|
||||||
icon: statusDot(app.health),
|
icon: app.healthTooltip
|
||||||
|
? createElement('span', { title: app.healthTooltip }, statusDot(app.health))
|
||||||
|
: statusDot(app.health),
|
||||||
badge: formatCount(app.exchangeCount),
|
badge: formatCount(app.exchangeCount),
|
||||||
path: `/exchanges/${app.id}`,
|
path: `/exchanges/${app.id}`,
|
||||||
starrable: true,
|
starrable: true,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SectionHeader,
|
SectionHeader,
|
||||||
Select,
|
Select,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
StatusDot,
|
||||||
Toggle,
|
Toggle,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
@@ -58,6 +59,11 @@ const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | '
|
|||||||
DEGRADED: 'warning', STOPPING: 'auto',
|
DEGRADED: 'warning', STOPPING: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEPLOY_STATUS_DOT: Record<string, string> = {
|
||||||
|
RUNNING: 'live', STARTING: 'running', DEGRADED: 'stale',
|
||||||
|
STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
function slugify(name: string): string {
|
function slugify(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -481,6 +487,8 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: allApps = [] } = useAllApps();
|
const { data: allApps = [] } = useAllApps();
|
||||||
const app = useMemo(() => allApps.find((a) => a.slug === appSlug), [allApps, appSlug]);
|
const app = useMemo(() => allApps.find((a) => a.slug === appSlug), [allApps, appSlug]);
|
||||||
|
const { data: catalogApps } = useCatalog(selectedEnv);
|
||||||
|
const catalogEntry = useMemo(() => (catalogApps ?? []).find((c: CatalogApp) => c.slug === appSlug), [catalogApps, appSlug]);
|
||||||
const { data: versions = [] } = useAppVersions(appSlug);
|
const { data: versions = [] } = useAppVersions(appSlug);
|
||||||
const { data: deployments = [] } = useDeployments(appSlug);
|
const { data: deployments = [] } = useDeployments(appSlug);
|
||||||
const uploadJar = useUploadJar();
|
const uploadJar = useUploadJar();
|
||||||
@@ -534,9 +542,19 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.detailHeader}>
|
<div className={styles.detailHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h2 className={styles.detailTitle}>{app.displayName}</h2>
|
<h2 className={styles.detailTitle}>
|
||||||
|
{catalogEntry && (
|
||||||
|
<span title={catalogEntry.healthTooltip} style={{ marginRight: 6, verticalAlign: 'middle' }}>
|
||||||
|
<StatusDot variant={catalogEntry.health === 'offline' ? 'dead' : catalogEntry.health as any} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{app.displayName}
|
||||||
|
</h2>
|
||||||
<div className={styles.detailMeta}>
|
<div className={styles.detailMeta}>
|
||||||
{app.slug} · <Badge label={env?.displayName ?? '?'} color="auto" />
|
{app.slug} · <Badge label={env?.displayName ?? '?'} color="auto" />
|
||||||
|
{catalogEntry?.deployment && (
|
||||||
|
<> · <Badge label={catalogEntry.deployment.status} color={STATUS_COLORS[catalogEntry.deployment.status as keyof typeof STATUS_COLORS] ?? 'auto'} /></>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.detailActions}>
|
<div className={styles.detailActions}>
|
||||||
@@ -626,7 +644,10 @@ function OverviewSubTab({ app, deployments, versions, environments, envMap, sele
|
|||||||
<Badge label={dEnv?.displayName ?? '?'} color={dEnv?.production ? 'error' : 'auto'} />
|
<Badge label={dEnv?.displayName ?? '?'} color={dEnv?.production ? 'error' : 'auto'} />
|
||||||
</td>
|
</td>
|
||||||
<td><Badge label={version ? `v${version.version}` : '?'} color="auto" /></td>
|
<td><Badge label={version ? `v${version.version}` : '?'} color="auto" /></td>
|
||||||
<td><Badge label={d.status} color={STATUS_COLORS[d.status] ?? 'auto'} /></td>
|
<td style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<StatusDot variant={DEPLOY_STATUS_DOT[d.status] ?? 'dead'} />
|
||||||
|
<Badge label={d.status} color={STATUS_COLORS[d.status] ?? 'auto'} />
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{d.replicaStates && d.replicaStates.length > 0 ? (
|
{d.replicaStates && d.replicaStates.length > 0 ? (
|
||||||
<span className={styles.cellMeta}>
|
<span className={styles.cellMeta}>
|
||||||
|
|||||||
Reference in New Issue
Block a user