diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/CatalogController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/CatalogController.java index 1e93cd6d..681b900e 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/CatalogController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/CatalogController.java @@ -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; + } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CatalogApp.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CatalogApp.java index 82f826ab..2d45d63a 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CatalogApp.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/dto/CatalogApp.java @@ -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 routes, @Schema(description = "Connected agent summaries") List agents, diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java index e734a0b4..059accd4 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java @@ -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 resolvedConfigToMap(ResolvedContainerConfig config) { + Map 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; + } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresDeploymentRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresDeploymentRepository.java index 9dcd24da..69533761 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresDeploymentRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresDeploymentRepository.java @@ -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 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 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 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() diff --git a/cameleer3-server-app/src/main/resources/db/migration/V8__deployment_active_config.sql b/cameleer3-server-app/src/main/resources/db/migration/V8__deployment_active_config.sql new file mode 100644 index 00000000..b66bf4d8 --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V8__deployment_active_config.sql @@ -0,0 +1 @@ +ALTER TABLE deployments ADD COLUMN resolved_config JSONB; diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Deployment.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Deployment.java index 1a1d76b0..e72791a6 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Deployment.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/runtime/Deployment.java @@ -18,6 +18,7 @@ public record Deployment( String containerId, String containerName, String errorMessage, + Map resolvedConfig, Instant deployedAt, Instant stoppedAt, Instant createdAt @@ -25,6 +26,7 @@ public record Deployment( public Deployment withStatus(DeploymentStatus newStatus) { return new Deployment(id, appId, appVersionId, environmentId, newStatus, targetState, deploymentStrategy, replicaStates, deployStage, - containerId, containerName, errorMessage, deployedAt, stoppedAt, createdAt); + containerId, containerName, errorMessage, resolvedConfig, + deployedAt, stoppedAt, createdAt); } } diff --git a/ui/src/api/queries/catalog.ts b/ui/src/api/queries/catalog.ts index 1ce859e5..20b5ee72 100644 --- a/ui/src/api/queries/catalog.ts +++ b/ui/src/api/queries/catalog.ts @@ -29,7 +29,8 @@ export interface CatalogApp { displayName: string; managed: boolean; environmentSlug: string; - health: 'live' | 'stale' | 'dead' | 'offline'; + health: 'live' | 'stale' | 'dead' | 'offline' | 'running' | 'error'; + healthTooltip: string; agentCount: number; routes: CatalogRoute[]; agents: CatalogAgent[]; diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index ec3c3c68..93c5ea87 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -403,7 +403,8 @@ function LayoutContent() { .map((app: any) => ({ id: 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, routes: [...(app.routes || [])] .sort((a: any, b: any) => cmp(a.routeId, b.routeId)) diff --git a/ui/src/components/sidebar-utils.ts b/ui/src/components/sidebar-utils.ts index 49e3cada..41a34dce 100644 --- a/ui/src/components/sidebar-utils.ts +++ b/ui/src/components/sidebar-utils.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import { createElement, type ReactNode } from 'react'; import type { SidebarTreeNode } from '@cameleer/design-system'; /* ------------------------------------------------------------------ */ @@ -15,7 +15,8 @@ export interface SidebarRoute { export interface SidebarApp { id: string; name: string; - health: 'live' | 'stale' | 'dead'; + health: 'live' | 'stale' | 'dead' | 'running' | 'error'; + healthTooltip?: string; exchangeCount: number; routes: SidebarRoute[]; } @@ -70,7 +71,9 @@ export function buildAppTreeNodes( return apps.map((app) => ({ id: app.id, 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), path: `/exchanges/${app.id}`, starrable: true, diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index 94e62985..69addbe9 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -10,6 +10,7 @@ import { SectionHeader, Select, Spinner, + StatusDot, Toggle, useToast, } from '@cameleer/design-system'; @@ -58,6 +59,11 @@ const STATUS_COLORS: Record = { + RUNNING: 'live', STARTING: 'running', DEGRADED: 'stale', + STOPPING: 'stale', STOPPED: 'dead', FAILED: 'error', +}; + function slugify(name: string): string { return name .toLowerCase() @@ -481,6 +487,8 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s const navigate = useNavigate(); const { data: allApps = [] } = useAllApps(); 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: deployments = [] } = useDeployments(appSlug); const uploadJar = useUploadJar(); @@ -534,9 +542,19 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
-

{app.displayName}

+

+ {catalogEntry && ( + + + + )} + {app.displayName} +

{app.slug} · + {catalogEntry?.deployment && ( + <> · + )}
@@ -626,7 +644,10 @@ function OverviewSubTab({ app, deployments, versions, environments, envMap, sele - + + + + {d.replicaStates && d.replicaStates.length > 0 ? (