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:
hsiegeln
2026-04-12 16:19:59 +02:00
parent d161ad38a8
commit 90c82238a0
4 changed files with 149 additions and 6 deletions

View File

@@ -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({

View File

@@ -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<Record<string, string | boolean>>({});
@@ -468,6 +476,46 @@ export default function AgentHealth() {
</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'} &mdash; {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 */}
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
{groups.map((group) => (