From 90c82238a04a5bcccf9185ea856263b61c7eaea4 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:19:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20orphaned=20app=20cleanup=20?= =?UTF-8?q?=E2=80=94=20auto-filter=20stale=20discovered=20apps,=20manual?= =?UTF-8?q?=20dismiss=20with=20data=20purge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/controller/CatalogController.java | 82 +++++++++++++++++-- .../src/main/resources/application.yml | 2 + ui/src/api/queries/catalog.ts | 23 +++++- ui/src/pages/AgentHealth/AgentHealth.tsx | 48 +++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) 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 681b900e..3a870474 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 @@ -1,5 +1,6 @@ package com.cameleer3.server.app.controller; +import com.cameleer3.server.app.config.TenantProperties; import com.cameleer3.server.app.dto.AgentSummary; import com.cameleer3.server.app.dto.CatalogApp; import com.cameleer3.server.app.dto.RouteSummary; @@ -15,12 +16,11 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; import java.sql.Timestamp; import java.time.Instant; @@ -46,6 +46,10 @@ public class CatalogController { private final AppService appService; private final EnvironmentService envService; private final DeploymentRepository deploymentRepo; + private final TenantProperties tenantProperties; + + @Value("${cameleer.server.catalog.discoveryttldays:7}") + private int discoveryTtlDays; public CatalogController(AgentRegistryService registryService, DiagramStore diagramStore, @@ -53,7 +57,8 @@ public class CatalogController { RouteStateRegistry routeStateRegistry, AppService appService, EnvironmentService envService, - DeploymentRepository deploymentRepo) { + DeploymentRepository deploymentRepo, + TenantProperties tenantProperties) { this.registryService = registryService; this.diagramStore = diagramStore; this.jdbc = jdbc; @@ -61,6 +66,7 @@ public class CatalogController { this.appService = appService; this.envService = envService; this.deploymentRepo = deploymentRepo; + this.tenantProperties = tenantProperties; } @GetMapping @@ -159,6 +165,17 @@ public class CatalogController { for (String slug : allSlugs) { App app = appsBySlug.get(slug); List agents = agentsByApp.getOrDefault(slug, List.of()); + + // Auto-cleanup: skip discovered apps with no live agents and no recent data + if (app == null && agents.isEmpty()) { + Set routes = routesByApp.getOrDefault(slug, Set.of()); + boolean hasRecentData = routes.stream().anyMatch(routeId -> { + Instant lastSeen = routeLastSeen.get(slug + "/" + routeId); + return lastSeen != null && lastSeen.isAfter(Instant.now().minus(discoveryTtlDays, ChronoUnit.DAYS)); + }); + if (!hasRecentData) continue; + } + Set routeIds = routesByApp.getOrDefault(slug, Set.of()); List agentIds = agents.stream().map(AgentInfo::instanceId).toList(); @@ -293,4 +310,59 @@ public class CatalogController { } return depPart; } + + @DeleteMapping("/{applicationId}") + @Operation(summary = "Dismiss application and purge all data") + @ApiResponse(responseCode = "204", description = "Application dismissed") + @ApiResponse(responseCode = "409", description = "Cannot dismiss — live agents connected") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity dismissApplication(@PathVariable String applicationId) { + // Check for live agents + List liveAgents = registryService.findAll().stream() + .filter(a -> applicationId.equals(a.applicationId())) + .filter(a -> a.state() != AgentState.DEAD) + .toList(); + if (!liveAgents.isEmpty()) { + return ResponseEntity.status(409).build(); + } + + // Get tenant ID for scoped deletion + String tenantId = tenantProperties.getId(); + + // Delete ClickHouse data + deleteClickHouseData(tenantId, applicationId); + + // Delete managed app if exists (PostgreSQL) + try { + App app = appService.getBySlug(applicationId); + appService.deleteApp(app.id()); + log.info("Dismissed managed app '{}' — deleted PG record and all CH data", applicationId); + } catch (IllegalArgumentException e) { + log.info("Dismissed discovered app '{}' — deleted all CH data", applicationId); + } + + return ResponseEntity.noContent().build(); + } + + private void deleteClickHouseData(String tenantId, String applicationId) { + String[] tablesWithAppId = { + "executions", "processor_executions", "route_diagrams", "agent_events", + "stats_1m_app", "stats_1m_route", "stats_1m_processor_type", "stats_1m_processor" + }; + for (String table : tablesWithAppId) { + try { + jdbc.execute("ALTER TABLE " + table + " DELETE WHERE tenant_id = " + lit(tenantId) + + " AND application_id = " + lit(applicationId)); + } catch (Exception e) { + log.warn("Failed to delete from CH table '{}' for app '{}': {}", table, applicationId, e.getMessage()); + } + } + // logs table uses 'application' instead of 'application_id' + try { + jdbc.execute("ALTER TABLE logs DELETE WHERE tenant_id = " + lit(tenantId) + + " AND application = " + lit(applicationId)); + } catch (Exception e) { + log.warn("Failed to delete from CH table 'logs' for app '{}': {}", applicationId, e.getMessage()); + } + } } diff --git a/cameleer3-server-app/src/main/resources/application.yml b/cameleer3-server-app/src/main/resources/application.yml index aa18d7ca..d8742be5 100644 --- a/cameleer3-server-app/src/main/resources/application.yml +++ b/cameleer3-server-app/src/main/resources/application.yml @@ -57,6 +57,8 @@ cameleer: indexer: debouncems: ${CAMELEER_SERVER_INDEXER_DEBOUNCEMS:2000} queuesize: ${CAMELEER_SERVER_INDEXER_QUEUESIZE:10000} + catalog: + discoveryttldays: ${CAMELEER_SERVER_CATALOG_DISCOVERYTTLDAYS:7} license: token: ${CAMELEER_SERVER_LICENSE_TOKEN:} file: ${CAMELEER_SERVER_LICENSE_FILE:} diff --git a/ui/src/api/queries/catalog.ts b/ui/src/api/queries/catalog.ts index 94303989..d4d142d8 100644 --- a/ui/src/api/queries/catalog.ts +++ b/ui/src/api/queries/catalog.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { config } from '../../config'; import { useAuthStore } from '../../auth/auth-store'; import { useRefreshInterval } from './use-refresh-interval'; @@ -61,6 +61,27 @@ export function useCatalog(environment?: string) { }); } +export function useDismissApp() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (applicationId: string) => { + const token = useAuthStore.getState().accessToken; + const res = await fetch(`${config.apiBaseUrl}/catalog/${applicationId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + 'X-Cameleer-Protocol-Version': '1', + }, + }); + if (res.status === 409) throw new Error('Cannot dismiss — live agents are still connected'); + if (!res.ok) throw new Error(`Failed to dismiss: ${res.status}`); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['catalog'] }); + }, + }); +} + export function useRouteMetrics(from?: string, to?: string, appId?: string, environment?: string) { const refetchInterval = useRefreshInterval(30_000); return useQuery({ diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index fd227e2c..ed13241a 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -13,6 +13,8 @@ import logStyles from '../../styles/log-panel.module.css'; import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useApplicationLogs } from '../../api/queries/logs'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; +import { useCatalog, useDismissApp } from '../../api/queries/catalog'; +import { useIsAdmin } from '../../auth/auth-store'; import { useEnvironmentStore } from '../../api/environment-store'; import type { ConfigUpdateResponse } from '../../api/queries/commands'; import type { AgentInstance } from '../../api/types'; @@ -103,6 +105,12 @@ export default function AgentHealth() { const { data: appConfig } = useApplicationConfig(appId); const updateConfig = useUpdateApplicationConfig(); + const isAdmin = useIsAdmin(); + const selectedEnvForCatalog = useEnvironmentStore((s) => s.environment); + const { data: catalogApps } = useCatalog(selectedEnvForCatalog); + const dismissApp = useDismissApp(); + const catalogEntry = catalogApps?.find((a) => a.slug === appId); + const [configEditing, setConfigEditing] = useState(false); const [configDraft, setConfigDraft] = useState>({}); @@ -468,6 +476,46 @@ export default function AgentHealth() { )} + {/* Dismiss application card — shown when app-scoped, no agents, admin */} + {appId && agentList.length === 0 && isAdmin && ( +
+
+
+ No agents connected + {catalogEntry && ( + + {catalogEntry.managed ? 'Managed app' : 'Discovered app'} — {catalogEntry.exchangeCount.toLocaleString()} exchanges recorded + + )} +
+ +
+
+ )} + {/* Group cards grid */}
{groups.map((group) => (