feat: add orphaned app cleanup — auto-filter stale discovered apps, manual dismiss with data purge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<AgentInfo> 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<String> 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<String> routeIds = routesByApp.getOrDefault(slug, Set.of());
|
||||
List<String> 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<Void> dismissApplication(@PathVariable String applicationId) {
|
||||
// Check for live agents
|
||||
List<AgentInfo> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
Reference in New Issue
Block a user