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;
|
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.AgentSummary;
|
||||||
import com.cameleer3.server.app.dto.CatalogApp;
|
import com.cameleer3.server.app.dto.CatalogApp;
|
||||||
import com.cameleer3.server.app.dto.RouteSummary;
|
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 io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -46,6 +46,10 @@ public class CatalogController {
|
|||||||
private final AppService appService;
|
private final AppService appService;
|
||||||
private final EnvironmentService envService;
|
private final EnvironmentService envService;
|
||||||
private final DeploymentRepository deploymentRepo;
|
private final DeploymentRepository deploymentRepo;
|
||||||
|
private final TenantProperties tenantProperties;
|
||||||
|
|
||||||
|
@Value("${cameleer.server.catalog.discoveryttldays:7}")
|
||||||
|
private int discoveryTtlDays;
|
||||||
|
|
||||||
public CatalogController(AgentRegistryService registryService,
|
public CatalogController(AgentRegistryService registryService,
|
||||||
DiagramStore diagramStore,
|
DiagramStore diagramStore,
|
||||||
@@ -53,7 +57,8 @@ public class CatalogController {
|
|||||||
RouteStateRegistry routeStateRegistry,
|
RouteStateRegistry routeStateRegistry,
|
||||||
AppService appService,
|
AppService appService,
|
||||||
EnvironmentService envService,
|
EnvironmentService envService,
|
||||||
DeploymentRepository deploymentRepo) {
|
DeploymentRepository deploymentRepo,
|
||||||
|
TenantProperties tenantProperties) {
|
||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
this.diagramStore = diagramStore;
|
this.diagramStore = diagramStore;
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
@@ -61,6 +66,7 @@ public class CatalogController {
|
|||||||
this.appService = appService;
|
this.appService = appService;
|
||||||
this.envService = envService;
|
this.envService = envService;
|
||||||
this.deploymentRepo = deploymentRepo;
|
this.deploymentRepo = deploymentRepo;
|
||||||
|
this.tenantProperties = tenantProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -159,6 +165,17 @@ public class CatalogController {
|
|||||||
for (String slug : allSlugs) {
|
for (String slug : allSlugs) {
|
||||||
App app = appsBySlug.get(slug);
|
App app = appsBySlug.get(slug);
|
||||||
List<AgentInfo> agents = agentsByApp.getOrDefault(slug, List.of());
|
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());
|
Set<String> routeIds = routesByApp.getOrDefault(slug, Set.of());
|
||||||
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
|
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
|
||||||
|
|
||||||
@@ -293,4 +310,59 @@ public class CatalogController {
|
|||||||
}
|
}
|
||||||
return depPart;
|
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:
|
indexer:
|
||||||
debouncems: ${CAMELEER_SERVER_INDEXER_DEBOUNCEMS:2000}
|
debouncems: ${CAMELEER_SERVER_INDEXER_DEBOUNCEMS:2000}
|
||||||
queuesize: ${CAMELEER_SERVER_INDEXER_QUEUESIZE:10000}
|
queuesize: ${CAMELEER_SERVER_INDEXER_QUEUESIZE:10000}
|
||||||
|
catalog:
|
||||||
|
discoveryttldays: ${CAMELEER_SERVER_CATALOG_DISCOVERYTTLDAYS:7}
|
||||||
license:
|
license:
|
||||||
token: ${CAMELEER_SERVER_LICENSE_TOKEN:}
|
token: ${CAMELEER_SERVER_LICENSE_TOKEN:}
|
||||||
file: ${CAMELEER_SERVER_LICENSE_FILE:}
|
file: ${CAMELEER_SERVER_LICENSE_FILE:}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
import { useRefreshInterval } from './use-refresh-interval';
|
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) {
|
export function useRouteMetrics(from?: string, to?: string, appId?: string, environment?: string) {
|
||||||
const refetchInterval = useRefreshInterval(30_000);
|
const refetchInterval = useRefreshInterval(30_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import logStyles from '../../styles/log-panel.module.css';
|
|||||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||||
import { useApplicationLogs } from '../../api/queries/logs';
|
import { useApplicationLogs } from '../../api/queries/logs';
|
||||||
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
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 { useEnvironmentStore } from '../../api/environment-store';
|
||||||
import type { ConfigUpdateResponse } from '../../api/queries/commands';
|
import type { ConfigUpdateResponse } from '../../api/queries/commands';
|
||||||
import type { AgentInstance } from '../../api/types';
|
import type { AgentInstance } from '../../api/types';
|
||||||
@@ -103,6 +105,12 @@ export default function AgentHealth() {
|
|||||||
const { data: appConfig } = useApplicationConfig(appId);
|
const { data: appConfig } = useApplicationConfig(appId);
|
||||||
const updateConfig = useUpdateApplicationConfig();
|
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 [configEditing, setConfigEditing] = useState(false);
|
||||||
const [configDraft, setConfigDraft] = useState<Record<string, string | boolean>>({});
|
const [configDraft, setConfigDraft] = useState<Record<string, string | boolean>>({});
|
||||||
|
|
||||||
@@ -468,6 +476,46 @@ export default function AgentHealth() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dismiss application card — shown when app-scoped, no agents, admin */}
|
||||||
|
{appId && agentList.length === 0 && isAdmin && (
|
||||||
|
<div className={`${sectionStyles.section}`} style={{ marginBottom: 16, padding: '16px 20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<strong>No agents connected</strong>
|
||||||
|
{catalogEntry && (
|
||||||
|
<span style={{ marginLeft: 8, color: 'var(--text-muted)', fontSize: 13 }}>
|
||||||
|
{catalogEntry.managed ? 'Managed app' : 'Discovered app'} — {catalogEntry.exchangeCount.toLocaleString()} exchanges recorded
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={dismissApp.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const count = catalogEntry?.exchangeCount ?? 0;
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Dismiss "${appId}" and permanently delete all data (${count.toLocaleString()} exchanges)?\n\nThis action cannot be undone.`
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
dismissApp.mutate(appId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: 'Application dismissed', description: `${appId} and all associated data have been deleted`, variant: 'success' });
|
||||||
|
navigate('/runtime');
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({ title: 'Dismiss failed', description: err.message, variant: 'error', duration: 86_400_000 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dismissApp.isPending ? 'Dismissing\u2026' : 'Dismiss Application'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Group cards grid */}
|
{/* Group cards grid */}
|
||||||
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
|
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user