From ca1d472b78297cbc07231a363d76dc8fed569b4d Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:08:00 +0200 Subject: [PATCH] feat(#117): agent-count toasts and persistent error toast dismiss Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/Admin/AppConfigDetailPage.tsx | 13 +++++--- ui/src/pages/Admin/AppConfigPage.tsx | 14 +++++++-- ui/src/pages/Admin/GroupsTab.tsx | 14 ++++----- ui/src/pages/Admin/OidcConfigPage.tsx | 6 ++-- ui/src/pages/Admin/RolesTab.tsx | 4 +-- ui/src/pages/Admin/UsersTab.tsx | 12 ++++++-- ui/src/pages/AgentHealth/AgentHealth.tsx | 12 ++++++-- ui/src/pages/Exchanges/ExchangesPage.tsx | 35 +++++++++++++++------- ui/src/pages/Exchanges/RouteControlBar.tsx | 16 ++++++---- 9 files changed, 86 insertions(+), 40 deletions(-) diff --git a/ui/src/pages/Admin/AppConfigDetailPage.tsx b/ui/src/pages/Admin/AppConfigDetailPage.tsx index ece195ad..908670b9 100644 --- a/ui/src/pages/Admin/AppConfigDetailPage.tsx +++ b/ui/src/pages/Admin/AppConfigDetailPage.tsx @@ -6,7 +6,7 @@ import { } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; 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 type { AppCatalogEntry, RouteSummary } from '../../api/types'; import styles from './AppConfigDetailPage.module.css'; @@ -153,12 +153,17 @@ export default function AppConfigDetailPage() { routeRecording: routeRecordingDraft, } as ApplicationConfig; updateConfig.mutate(updated, { - onSuccess: (saved) => { + onSuccess: (saved: ConfigUpdateResponse) => { 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: () => { - 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 }); }, }); } diff --git a/ui/src/pages/Admin/AppConfigPage.tsx b/ui/src/pages/Admin/AppConfigPage.tsx index aac5da41..e2eb4786 100644 --- a/ui/src/pages/Admin/AppConfigPage.tsx +++ b/ui/src/pages/Admin/AppConfigPage.tsx @@ -6,7 +6,7 @@ import { } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; 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 type { AppCatalogEntry, RouteSummary } from '../../api/types'; import styles from './AppConfigPage.module.css'; @@ -141,8 +141,16 @@ function AppConfigDetail({ appId, onClose }: { appId: string; onClose: () => voi if (!config || !form) return; const updated = { ...config, ...form, tracedProcessors: tracedDraft, routeRecording: routeRecordingDraft } as ApplicationConfig; updateConfig.mutate(updated, { - onSuccess: (saved) => { setEditing(false); toast({ title: 'Config saved', description: `${appId} updated to v${saved.config.version}`, variant: 'success' }); }, - onError: () => { toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error' }); }, + onSuccess: (saved: ConfigUpdateResponse) => { + 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 }); }, }); } diff --git a/ui/src/pages/Admin/GroupsTab.tsx b/ui/src/pages/Admin/GroupsTab.tsx index 88056eef..12fd05b5 100644 --- a/ui/src/pages/Admin/GroupsTab.tsx +++ b/ui/src/pages/Admin/GroupsTab.tsx @@ -122,7 +122,7 @@ export default function GroupsTab() { setNewName(''); setNewParent(''); } 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); setDeleteTarget(null); } catch { - toast({ title: 'Failed to delete group', variant: 'error' }); + toast({ title: 'Failed to delete group', variant: 'error', duration: 86_400_000 }); setDeleteTarget(null); } } @@ -153,7 +153,7 @@ export default function GroupsTab() { }); toast({ title: 'Group renamed', variant: 'success' }); } 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' }); } 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' }); } 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' }); } 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' }); } catch { - toast({ title: 'Failed to remove role', variant: 'error' }); + toast({ title: 'Failed to remove role', variant: 'error', duration: 86_400_000 }); } } diff --git a/ui/src/pages/Admin/OidcConfigPage.tsx b/ui/src/pages/Admin/OidcConfigPage.tsx index dc279b2d..6ee52e14 100644 --- a/ui/src/pages/Admin/OidcConfigPage.tsx +++ b/ui/src/pages/Admin/OidcConfigPage.tsx @@ -79,7 +79,7 @@ export default function OidcConfigPage() { toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' }); } catch (e: any) { 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 { setSaving(false); } @@ -94,7 +94,7 @@ export default function OidcConfigPage() { toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' }); } catch (e: any) { 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 { setTesting(false); } @@ -109,7 +109,7 @@ export default function OidcConfigPage() { toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' }); } catch (e: any) { 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 }); } } diff --git a/ui/src/pages/Admin/RolesTab.tsx b/ui/src/pages/Admin/RolesTab.tsx index b11be314..9be3cb81 100644 --- a/ui/src/pages/Admin/RolesTab.tsx +++ b/ui/src/pages/Admin/RolesTab.tsx @@ -73,7 +73,7 @@ export default function RolesTab() { setNewDesc(''); }, 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); }, onError: () => { - toast({ title: 'Failed to delete role', variant: 'error' }); + toast({ title: 'Failed to delete role', variant: 'error', duration: 86_400_000 }); setDeleteTarget(null); }, }); diff --git a/ui/src/pages/Admin/UsersTab.tsx b/ui/src/pages/Admin/UsersTab.tsx index c6b44703..585904b2 100644 --- a/ui/src/pages/Admin/UsersTab.tsx +++ b/ui/src/pages/Admin/UsersTab.tsx @@ -135,7 +135,7 @@ export default function UsersTab() { setNewProvider('local'); }, 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); }, onError: () => { - toast({ title: 'Failed to delete user', variant: 'error' }); + toast({ title: 'Failed to delete user', variant: 'error', duration: 86_400_000 }); setDeleteTarget(null); }, }); @@ -175,7 +175,7 @@ export default function UsersTab() { setNewPw(''); }, 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({ title: 'Failed to update name', variant: 'error', + duration: 86_400_000, }), }, ) @@ -451,6 +452,7 @@ export default function UsersTab() { toast({ title: 'Failed to remove group', variant: 'error', + duration: 86_400_000, }), }, ); @@ -474,6 +476,7 @@ export default function UsersTab() { toast({ title: 'Failed to add group', variant: 'error', + duration: 86_400_000, }), }, ); @@ -506,6 +509,7 @@ export default function UsersTab() { toast({ title: 'Failed to remove role', variant: 'error', + duration: 86_400_000, }), }, ); @@ -542,6 +546,7 @@ export default function UsersTab() { toast({ title: 'Failed to assign role', variant: 'error', + duration: 86_400_000, }), }, ); @@ -583,6 +588,7 @@ export default function UsersTab() { toast({ title: 'Failed to remove group', variant: 'error', + duration: 86_400_000, }), }, ); diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 01e1d9ed..94ab1e36 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -11,6 +11,7 @@ import styles from './AgentHealth.module.css'; import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useApplicationLogs } from '../../api/queries/logs'; import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands'; +import type { ConfigUpdateResponse } from '../../api/queries/commands'; import type { AgentInstance } from '../../api/types'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -142,13 +143,18 @@ export default function AgentHealth() { if (!appConfig) return; const updated = { ...appConfig, ...configDraft }; updateConfig.mutate(updated, { - onSuccess: (saved) => { + onSuccess: (saved: ConfigUpdateResponse) => { setConfigEditing(false); 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: () => { - toast({ title: 'Config update failed', variant: 'error' }); + toast({ title: 'Config update failed', variant: 'error', duration: 86_400_000 }); }, }); }, [appConfig, configDraft, updateConfig, toast, appId]); diff --git a/ui/src/pages/Exchanges/ExchangesPage.tsx b/ui/src/pages/Exchanges/ExchangesPage.tsx index c69ede67..635547af 100644 --- a/ui/src/pages/Exchanges/ExchangesPage.tsx +++ b/ui/src/pages/Exchanges/ExchangesPage.tsx @@ -5,7 +5,7 @@ import { useExecutionDetail } from '../../api/queries/executions'; import { useDiagramByRoute } from '../../api/queries/diagrams'; import { useRouteCatalog } from '../../api/queries/catalog'; 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 type { NodeAction, NodeConfig } from '../../components/ProcessDiagram/types'; import { TapConfigModal } from '../../components/TapConfigModal'; @@ -215,11 +215,16 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS const handleTapSave = useCallback((updatedConfig: typeof appConfig) => { if (!updatedConfig) return; updateConfig.mutate(updatedConfig, { - onSuccess: (saved) => { - toast({ title: 'Tap configuration saved', description: `Pushed to agents (v${saved.config.version})`, variant: 'success' }); + onSuccess: (saved: ConfigUpdateResponse) => { + 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: () => { - 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]); @@ -228,11 +233,16 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS if (!appConfig) return; const taps = appConfig.taps.filter(t => t.tapId !== tap.tapId); updateConfig.mutate({ ...appConfig, taps }, { - onSuccess: (saved) => { - toast({ title: 'Tap deleted', description: `${tap.attributeName} removed (v${saved.config.version})`, variant: 'success' }); + onSuccess: (saved: ConfigUpdateResponse) => { + 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: () => { - 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]); @@ -255,12 +265,17 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS ...appConfig, tracedProcessors, }, { - onSuccess: (saved) => { - toast({ title: `Tracing ${enabled ? 'enabled' : 'disabled'}`, description: `${nodeId} — pushed to agents (v${saved.config.version})`, variant: 'success' }); + onSuccess: (saved: ConfigUpdateResponse) => { + 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: () => { 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 }); }, }); } diff --git a/ui/src/pages/Exchanges/RouteControlBar.tsx b/ui/src/pages/Exchanges/RouteControlBar.tsx index 0b37b4b8..ec794a32 100644 --- a/ui/src/pages/Exchanges/RouteControlBar.tsx +++ b/ui/src/pages/Exchanges/RouteControlBar.tsx @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import { Play, Square, Pause, PlayCircle, RotateCcw, Loader2 } from 'lucide-react'; import { useToast, ConfirmDialog } from '@cameleer/design-system'; import { useSendRouteCommand, useReplayExchange } from '../../api/queries/commands'; +import type { CommandGroupResponse } from '../../api/queries/commands'; import styles from './RouteControlBar.module.css'; interface RouteControlBarProps { @@ -47,12 +48,17 @@ export function RouteControlBar({ application, routeId, hasRouteControl, hasRepl sendRouteCommand.mutate( { application, action, routeId }, { - onSuccess: () => { - toast({ title: `Route ${action} sent`, description: `${routeId} on ${application}`, variant: 'success' }); + onSuccess: (result: CommandGroupResponse) => { + 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); }, 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); }, }, @@ -71,12 +77,12 @@ export function RouteControlBar({ application, routeId, hasRouteControl, hasRepl if (result.status === 'SUCCESS') { toast({ title: 'Replay completed', description: result.message ?? `${routeId} on ${agentId}`, variant: 'success' }); } 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); }, 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); }, },