feat(#117): agent-count toasts and persistent error toast dismiss
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 30s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-02 19:08:00 +02:00
parent c3b4f70913
commit ca1d472b78
9 changed files with 86 additions and 40 deletions

View File

@@ -6,7 +6,7 @@ import {
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { ApplicationConfig, TapDefinition } from '../../api/queries/commands'; import type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
import { useRouteCatalog } from '../../api/queries/catalog'; import { useRouteCatalog } from '../../api/queries/catalog';
import type { AppCatalogEntry, RouteSummary } from '../../api/types'; import type { AppCatalogEntry, RouteSummary } from '../../api/types';
import styles from './AppConfigDetailPage.module.css'; import styles from './AppConfigDetailPage.module.css';
@@ -153,12 +153,17 @@ export default function AppConfigDetailPage() {
routeRecording: routeRecordingDraft, routeRecording: routeRecordingDraft,
} as ApplicationConfig; } as ApplicationConfig;
updateConfig.mutate(updated, { updateConfig.mutate(updated, {
onSuccess: (saved) => { onSuccess: (saved: ConfigUpdateResponse) => {
setEditing(false); setEditing(false);
toast({ title: 'Config saved', description: `${appId} updated to v${saved.config.version}`, variant: 'success' }); if (saved.pushResult.success) {
toast({ title: 'Config saved', description: `${appId} updated to v${saved.config.version} — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents`, variant: 'success' });
} else {
const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut];
toast({ title: 'Config saved — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 });
}
}, },
onError: () => { onError: () => {
toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error' }); toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error', duration: 86_400_000 });
}, },
}); });
} }

View File

@@ -6,7 +6,7 @@ import {
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useAllApplicationConfigs, useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../api/queries/commands'; import { useAllApplicationConfigs, useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../api/queries/commands';
import type { ApplicationConfig, TapDefinition } from '../../api/queries/commands'; import type { ApplicationConfig, TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
import { useRouteCatalog } from '../../api/queries/catalog'; import { useRouteCatalog } from '../../api/queries/catalog';
import type { AppCatalogEntry, RouteSummary } from '../../api/types'; import type { AppCatalogEntry, RouteSummary } from '../../api/types';
import styles from './AppConfigPage.module.css'; import styles from './AppConfigPage.module.css';
@@ -141,8 +141,16 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi
if (!config || !form) return; if (!config || !form) return;
const updated = { ...config, ...form, tracedProcessors: tracedDraft, routeRecording: routeRecordingDraft } as ApplicationConfig; const updated = { ...config, ...form, tracedProcessors: tracedDraft, routeRecording: routeRecordingDraft } as ApplicationConfig;
updateConfig.mutate(updated, { updateConfig.mutate(updated, {
onSuccess: (saved) => { setEditing(false); toast({ title: 'Config saved', description: `${appId} updated to v${saved.config.version}`, variant: 'success' }); }, onSuccess: (saved: ConfigUpdateResponse) => {
onError: () => { toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error' }); }, setEditing(false);
if (saved.pushResult.success) {
toast({ title: 'Config saved', description: `${appId} updated to v${saved.config.version} — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents`, variant: 'success' });
} else {
const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut];
toast({ title: 'Config saved — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 });
}
},
onError: () => { toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error', duration: 86_400_000 }); },
}); });
} }

View File

@@ -122,7 +122,7 @@ export default function GroupsTab() {
setNewName(''); setNewName('');
setNewParent(''); setNewParent('');
} catch { } catch {
toast({ title: 'Failed to create group', variant: 'error' }); toast({ title: 'Failed to create group', variant: 'error', duration: 86_400_000 });
} }
} }
@@ -138,7 +138,7 @@ export default function GroupsTab() {
if (selectedId === deleteTarget.id) setSelectedId(null); if (selectedId === deleteTarget.id) setSelectedId(null);
setDeleteTarget(null); setDeleteTarget(null);
} catch { } catch {
toast({ title: 'Failed to delete group', variant: 'error' }); toast({ title: 'Failed to delete group', variant: 'error', duration: 86_400_000 });
setDeleteTarget(null); setDeleteTarget(null);
} }
} }
@@ -153,7 +153,7 @@ export default function GroupsTab() {
}); });
toast({ title: 'Group renamed', variant: 'success' }); toast({ title: 'Group renamed', variant: 'success' });
} catch { } catch {
toast({ title: 'Failed to rename group', variant: 'error' }); toast({ title: 'Failed to rename group', variant: 'error', duration: 86_400_000 });
} }
} }
@@ -166,7 +166,7 @@ export default function GroupsTab() {
}); });
toast({ title: 'Member removed', variant: 'success' }); toast({ title: 'Member removed', variant: 'success' });
} catch { } catch {
toast({ title: 'Failed to remove member', variant: 'error' }); toast({ title: 'Failed to remove member', variant: 'error', duration: 86_400_000 });
} }
} }
@@ -180,7 +180,7 @@ export default function GroupsTab() {
}); });
toast({ title: 'Member added', variant: 'success' }); toast({ title: 'Member added', variant: 'success' });
} catch { } catch {
toast({ title: 'Failed to add member', variant: 'error' }); toast({ title: 'Failed to add member', variant: 'error', duration: 86_400_000 });
} }
} }
} }
@@ -195,7 +195,7 @@ export default function GroupsTab() {
}); });
toast({ title: 'Role assigned', variant: 'success' }); toast({ title: 'Role assigned', variant: 'success' });
} catch { } catch {
toast({ title: 'Failed to assign role', variant: 'error' }); toast({ title: 'Failed to assign role', variant: 'error', duration: 86_400_000 });
} }
} }
} }
@@ -209,7 +209,7 @@ export default function GroupsTab() {
}); });
toast({ title: 'Role removed', variant: 'success' }); toast({ title: 'Role removed', variant: 'success' });
} catch { } catch {
toast({ title: 'Failed to remove role', variant: 'error' }); toast({ title: 'Failed to remove role', variant: 'error', duration: 86_400_000 });
} }
} }

View File

@@ -79,7 +79,7 @@ export default function OidcConfigPage() {
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' }); toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' });
} catch (e: any) { } catch (e: any) {
setError(e.message); setError(e.message);
toast({ title: 'Save failed', description: e.message, variant: 'error' }); toast({ title: 'Save failed', description: e.message, variant: 'error', duration: 86_400_000 });
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -94,7 +94,7 @@ export default function OidcConfigPage() {
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' }); toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
} catch (e: any) { } catch (e: any) {
setError(e.message); setError(e.message);
toast({ title: 'Connection test failed', description: e.message, variant: 'error' }); toast({ title: 'Connection test failed', description: e.message, variant: 'error', duration: 86_400_000 });
} finally { } finally {
setTesting(false); setTesting(false);
} }
@@ -109,7 +109,7 @@ export default function OidcConfigPage() {
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' }); toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' });
} catch (e: any) { } catch (e: any) {
setError(e.message); setError(e.message);
toast({ title: 'Delete failed', description: e.message, variant: 'error' }); toast({ title: 'Delete failed', description: e.message, variant: 'error', duration: 86_400_000 });
} }
} }

View File

@@ -73,7 +73,7 @@ export default function RolesTab() {
setNewDesc(''); setNewDesc('');
}, },
onError: () => { onError: () => {
toast({ title: 'Failed to create role', variant: 'error' }); toast({ title: 'Failed to create role', variant: 'error', duration: 86_400_000 });
}, },
}, },
); );
@@ -92,7 +92,7 @@ export default function RolesTab() {
setDeleteTarget(null); setDeleteTarget(null);
}, },
onError: () => { onError: () => {
toast({ title: 'Failed to delete role', variant: 'error' }); toast({ title: 'Failed to delete role', variant: 'error', duration: 86_400_000 });
setDeleteTarget(null); setDeleteTarget(null);
}, },
}); });

View File

@@ -135,7 +135,7 @@ export default function UsersTab() {
setNewProvider('local'); setNewProvider('local');
}, },
onError: () => { onError: () => {
toast({ title: 'Failed to create user', variant: 'error' }); toast({ title: 'Failed to create user', variant: 'error', duration: 86_400_000 });
}, },
}, },
); );
@@ -154,7 +154,7 @@ export default function UsersTab() {
setDeleteTarget(null); setDeleteTarget(null);
}, },
onError: () => { onError: () => {
toast({ title: 'Failed to delete user', variant: 'error' }); toast({ title: 'Failed to delete user', variant: 'error', duration: 86_400_000 });
setDeleteTarget(null); setDeleteTarget(null);
}, },
}); });
@@ -175,7 +175,7 @@ export default function UsersTab() {
setNewPw(''); setNewPw('');
}, },
onError: () => { onError: () => {
toast({ title: 'Failed to update password', variant: 'error' }); toast({ title: 'Failed to update password', variant: 'error', duration: 86_400_000 });
}, },
}, },
); );
@@ -333,6 +333,7 @@ export default function UsersTab() {
toast({ toast({
title: 'Failed to update name', title: 'Failed to update name',
variant: 'error', variant: 'error',
duration: 86_400_000,
}), }),
}, },
) )
@@ -451,6 +452,7 @@ export default function UsersTab() {
toast({ toast({
title: 'Failed to remove group', title: 'Failed to remove group',
variant: 'error', variant: 'error',
duration: 86_400_000,
}), }),
}, },
); );
@@ -474,6 +476,7 @@ export default function UsersTab() {
toast({ toast({
title: 'Failed to add group', title: 'Failed to add group',
variant: 'error', variant: 'error',
duration: 86_400_000,
}), }),
}, },
); );
@@ -506,6 +509,7 @@ export default function UsersTab() {
toast({ toast({
title: 'Failed to remove role', title: 'Failed to remove role',
variant: 'error', variant: 'error',
duration: 86_400_000,
}), }),
}, },
); );
@@ -542,6 +546,7 @@ export default function UsersTab() {
toast({ toast({
title: 'Failed to assign role', title: 'Failed to assign role',
variant: 'error', variant: 'error',
duration: 86_400_000,
}), }),
}, },
); );
@@ -583,6 +588,7 @@ export default function UsersTab() {
toast({ toast({
title: 'Failed to remove group', title: 'Failed to remove group',
variant: 'error', variant: 'error',
duration: 86_400_000,
}), }),
}, },
); );

View File

@@ -11,6 +11,7 @@ import styles from './AgentHealth.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 type { ConfigUpdateResponse } from '../../api/queries/commands';
import type { AgentInstance } from '../../api/types'; import type { AgentInstance } from '../../api/types';
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
@@ -142,13 +143,18 @@ export default function AgentHealth() {
if (!appConfig) return; if (!appConfig) return;
const updated = { ...appConfig, ...configDraft }; const updated = { ...appConfig, ...configDraft };
updateConfig.mutate(updated, { updateConfig.mutate(updated, {
onSuccess: (saved) => { onSuccess: (saved: ConfigUpdateResponse) => {
setConfigEditing(false); setConfigEditing(false);
setConfigDraft({}); setConfigDraft({});
toast({ title: 'Config updated', description: `${appId} (v${saved.config.version})`, variant: 'success' }); if (saved.pushResult.success) {
toast({ title: 'Config updated', description: `${appId} (v${saved.config.version}) — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents`, variant: 'success' });
} else {
const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut];
toast({ title: 'Config updated — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 });
}
}, },
onError: () => { onError: () => {
toast({ title: 'Config update failed', variant: 'error' }); toast({ title: 'Config update failed', variant: 'error', duration: 86_400_000 });
}, },
}); });
}, [appConfig, configDraft, updateConfig, toast, appId]); }, [appConfig, configDraft, updateConfig, toast, appId]);

View File

@@ -5,7 +5,7 @@ import { useExecutionDetail } from '../../api/queries/executions';
import { useDiagramByRoute } from '../../api/queries/diagrams'; import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useRouteCatalog } from '../../api/queries/catalog'; import { useRouteCatalog } from '../../api/queries/catalog';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { TapDefinition } from '../../api/queries/commands'; import type { TapDefinition, ConfigUpdateResponse } from '../../api/queries/commands';
import { useTracingStore } from '../../stores/tracing-store'; import { useTracingStore } from '../../stores/tracing-store';
import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types'; import type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types';
import { TapConfigModal } from '../../components/TapConfigModal'; import { TapConfigModal } from '../../components/TapConfigModal';
@@ -215,11 +215,16 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
const handleTapSave = useCallback((updatedConfig: typeof appConfig) => { const handleTapSave = useCallback((updatedConfig: typeof appConfig) => {
if (!updatedConfig) return; if (!updatedConfig) return;
updateConfig.mutate(updatedConfig, { updateConfig.mutate(updatedConfig, {
onSuccess: (saved) => { onSuccess: (saved: ConfigUpdateResponse) => {
toast({ title: 'Tap configuration saved', description: `Pushed to agents (v${saved.config.version})`, variant: 'success' }); if (saved.pushResult.success) {
toast({ title: 'Tap configuration saved', description: `Pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents (v${saved.config.version})`, variant: 'success' });
} else {
const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut];
toast({ title: 'Tap configuration saved — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 });
}
}, },
onError: () => { onError: () => {
toast({ title: 'Tap update failed', description: 'Could not save configuration', variant: 'error' }); toast({ title: 'Tap update failed', description: 'Could not save configuration', variant: 'error', duration: 86_400_000 });
}, },
}); });
}, [updateConfig, toast]); }, [updateConfig, toast]);
@@ -228,11 +233,16 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
if (!appConfig) return; if (!appConfig) return;
const taps = appConfig.taps.filter(t => t.tapId !== tap.tapId); const taps = appConfig.taps.filter(t => t.tapId !== tap.tapId);
updateConfig.mutate({ ...appConfig, taps }, { updateConfig.mutate({ ...appConfig, taps }, {
onSuccess: (saved) => { onSuccess: (saved: ConfigUpdateResponse) => {
toast({ title: 'Tap deleted', description: `${tap.attributeName} removed (v${saved.config.version})`, variant: 'success' }); if (saved.pushResult.success) {
toast({ title: 'Tap deleted', description: `${tap.attributeName} removed — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents (v${saved.config.version})`, variant: 'success' });
} else {
const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut];
toast({ title: 'Tap deleted — partial push failure', description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 });
}
}, },
onError: () => { onError: () => {
toast({ title: 'Tap delete failed', description: 'Could not save configuration', variant: 'error' }); toast({ title: 'Tap delete failed', description: 'Could not save configuration', variant: 'error', duration: 86_400_000 });
}, },
}); });
}, [appConfig, updateConfig, toast]); }, [appConfig, updateConfig, toast]);
@@ -255,12 +265,17 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
...appConfig, ...appConfig,
tracedProcessors, tracedProcessors,
}, { }, {
onSuccess: (saved) => { onSuccess: (saved: ConfigUpdateResponse) => {
toast({ title: `Tracing ${enabled ? 'enabled' : 'disabled'}`, description: `${nodeId} — pushed to agents (v${saved.config.version})`, variant: 'success' }); if (saved.pushResult.success) {
toast({ title: `Tracing ${enabled ? 'enabled' : 'disabled'}`, description: `${nodeId} — pushed to ${saved.pushResult.total}/${saved.pushResult.total} agents (v${saved.config.version})`, variant: 'success' });
} else {
const failed = [...saved.pushResult.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...saved.pushResult.timedOut];
toast({ title: `Tracing update — partial push failure`, description: `${saved.pushResult.responded}/${saved.pushResult.total} responded. Failed: ${failed.join(', ')}`, variant: 'warning', duration: 86_400_000 });
}
}, },
onError: () => { onError: () => {
useTracingStore.getState().toggleProcessor(appId, nodeId); useTracingStore.getState().toggleProcessor(appId, nodeId);
toast({ title: 'Tracing update failed', description: 'Could not save configuration', variant: 'error' }); toast({ title: 'Tracing update failed', description: 'Could not save configuration', variant: 'error', duration: 86_400_000 });
}, },
}); });
} }

View File

@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { Play, Square, Pause, PlayCircle, RotateCcw, Loader2 } from 'lucide-react'; import { Play, Square, Pause, PlayCircle, RotateCcw, Loader2 } from 'lucide-react';
import { useToast, ConfirmDialog } from '@cameleer/design-system'; import { useToast, ConfirmDialog } from '@cameleer/design-system';
import { useSendRouteCommand, useReplayExchange } from '../../api/queries/commands'; import { useSendRouteCommand, useReplayExchange } from '../../api/queries/commands';
import type { CommandGroupResponse } from '../../api/queries/commands';
import styles from './RouteControlBar.module.css'; import styles from './RouteControlBar.module.css';
interface RouteControlBarProps { interface RouteControlBarProps {
@@ -47,12 +48,17 @@ export function RouteControlBar({ application, routeId, hasRouteControl, hasRepl
sendRouteCommand.mutate( sendRouteCommand.mutate(
{ application, action, routeId }, { application, action, routeId },
{ {
onSuccess: () => { onSuccess: (result: CommandGroupResponse) => {
toast({ title: `Route ${action} sent`, description: `${routeId} on ${application}`, variant: 'success' }); if (result.success) {
toast({ title: `Route ${action} sent`, description: `${routeId}${result.total}/${result.total} agents responded`, variant: 'success' });
} else {
const failedAgents = [...result.responses.filter(r => r.status !== 'SUCCESS').map(r => r.agentId), ...result.timedOut];
toast({ title: `Route ${action} — partial failure`, description: `${result.responded}/${result.total} responded. Failed: ${failedAgents.join(', ')}`, variant: 'warning', duration: 86_400_000 });
}
setSendingAction(null); setSendingAction(null);
}, },
onError: (err) => { onError: (err) => {
toast({ title: `Route ${action} failed`, description: err.message, variant: 'error' }); toast({ title: `Route ${action} failed`, description: err.message, variant: 'error', duration: 86_400_000 });
setSendingAction(null); setSendingAction(null);
}, },
}, },
@@ -71,12 +77,12 @@ export function RouteControlBar({ application, routeId, hasRouteControl, hasRepl
if (result.status === 'SUCCESS') { if (result.status === 'SUCCESS') {
toast({ title: 'Replay completed', description: result.message ?? `${routeId} on ${agentId}`, variant: 'success' }); toast({ title: 'Replay completed', description: result.message ?? `${routeId} on ${agentId}`, variant: 'success' });
} else { } else {
toast({ title: 'Replay failed', description: result.message ?? 'Agent reported failure', variant: 'error' }); toast({ title: 'Replay failed', description: result.message ?? 'Agent reported failure', variant: 'error', duration: 86_400_000 });
} }
setSendingAction(null); setSendingAction(null);
}, },
onError: (err) => { onError: (err) => {
toast({ title: 'Replay failed', description: err.message, variant: 'error' }); toast({ title: 'Replay failed', description: err.message, variant: 'error', duration: 86_400_000 });
setSendingAction(null); setSendingAction(null);
}, },
}, },