+
+ {visible.length > 1 && (
+
+ )}
+ {visible.map((a) => (
+
{a.type.replace(/_/g, ' ').toUpperCase()}
{a.message}
{a.timestamp ? new Date(a.timestamp).toLocaleTimeString() : ''}
+
))}
diff --git a/server/web/src/components/Toast/ToastStack.css b/server/web/src/components/Toast/ToastStack.css
new file mode 100644
index 0000000..53ff4fb
--- /dev/null
+++ b/server/web/src/components/Toast/ToastStack.css
@@ -0,0 +1,148 @@
+.toast-stack {
+ position: fixed;
+ top: 3.25rem;
+ right: 1rem;
+ z-index: 9990;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.35rem;
+ max-width: min(22rem, calc(100vw - 2rem));
+ pointer-events: none;
+}
+
+.toast-clear-all {
+ pointer-events: auto;
+ border: 1px solid rgba(0, 232, 245, 0.35);
+ background: rgba(0, 0, 0, 0.72);
+ color: var(--neon-cyan, #00e8f5);
+ font-family: var(--font-tech, monospace);
+ font-size: 0.62rem;
+ letter-spacing: 0.06em;
+ padding: 0.2rem 0.55rem;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.toast-clear-all:hover {
+ background: rgba(0, 232, 245, 0.12);
+}
+
+.toast-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ max-height: min(40vh, 18rem);
+ overflow: hidden;
+ width: 100%;
+}
+
+.toast-item {
+ pointer-events: auto;
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ padding: 0.55rem 0.65rem;
+ border-radius: 6px;
+ border: 1px solid rgba(0, 232, 245, 0.28);
+ background: rgba(8, 12, 18, 0.92);
+ box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
+ font-size: 0.82rem;
+ animation: toast-in 0.2s ease-out;
+}
+
+@keyframes toast-in {
+ from {
+ opacity: 0;
+ transform: translateX(0.75rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.toast-info {
+ border-color: rgba(0, 232, 245, 0.35);
+}
+
+.toast-success {
+ border-color: rgba(57, 255, 20, 0.4);
+ background: rgba(8, 18, 10, 0.94);
+}
+
+.toast-warn {
+ border-color: rgba(255, 176, 32, 0.45);
+ background: rgba(18, 14, 8, 0.94);
+}
+
+.toast-error {
+ border-color: rgba(255, 60, 80, 0.5);
+ background: rgba(18, 8, 10, 0.94);
+}
+
+.toast-body {
+ flex: 1;
+ display: flex;
+ gap: 0.55rem;
+ min-width: 0;
+}
+
+.toast-level {
+ font-size: 0.58rem;
+ letter-spacing: 0.08em;
+ opacity: 0.8;
+ flex-shrink: 0;
+ padding-top: 0.1rem;
+}
+
+.toast-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+ min-width: 0;
+}
+
+.toast-message {
+ line-height: 1.35;
+ word-break: break-word;
+}
+
+.toast-detail {
+ font-size: 0.72rem;
+ opacity: 0.72;
+ word-break: break-word;
+}
+
+.toast-dismiss {
+ flex-shrink: 0;
+ width: 1.35rem;
+ height: 1.35rem;
+ border: none;
+ border-radius: 4px;
+ background: transparent;
+ color: var(--text-muted, #8899aa);
+ font-size: 1.1rem;
+ line-height: 1;
+ cursor: pointer;
+ padding: 0;
+}
+
+.toast-dismiss:hover {
+ color: var(--text-primary, #e8edf5);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+@media (max-width: 768px) {
+ .toast-stack {
+ top: auto;
+ bottom: 4.5rem;
+ right: 0.65rem;
+ left: 0.65rem;
+ max-width: none;
+ align-items: stretch;
+ }
+}
diff --git a/server/web/src/components/Toast/ToastStack.test.tsx b/server/web/src/components/Toast/ToastStack.test.tsx
new file mode 100644
index 0000000..59a3e5b
--- /dev/null
+++ b/server/web/src/components/Toast/ToastStack.test.tsx
@@ -0,0 +1,51 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { ToastProvider, useToast } from '../../context/ToastContext';
+import ToastStack from './ToastStack';
+
+function Seed({ count }: { count: number }) {
+ const { showToast } = useToast();
+ return (
+
+ );
+}
+
+describe('ToastStack', () => {
+ afterEach(() => cleanup());
+
+ it('renders dismiss buttons on each toast', () => {
+ render(
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('seed'));
+ expect(screen.getByText('msg 0')).toBeInTheDocument();
+ fireEvent.click(screen.getByLabelText('Dismiss notification'));
+ expect(screen.queryByTestId('toast-stack')).toBeNull();
+ });
+
+ it('shows clear all when multiple toasts are visible', () => {
+ render(
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('seed'));
+ expect(screen.getByLabelText('Clear all notifications')).toBeInTheDocument();
+ fireEvent.click(screen.getByLabelText('Clear all notifications'));
+ expect(screen.queryByTestId('toast-stack')).toBeNull();
+ });
+});
diff --git a/server/web/src/components/Toast/ToastStack.tsx b/server/web/src/components/Toast/ToastStack.tsx
new file mode 100644
index 0000000..80ad6a2
--- /dev/null
+++ b/server/web/src/components/Toast/ToastStack.tsx
@@ -0,0 +1,52 @@
+import { useToast } from '../../context/ToastContext';
+import type { ToastLevel } from '../../context/ToastContext';
+import './ToastStack.css';
+
+const LEVEL_LABEL: Record
= {
+ info: 'INFO',
+ success: 'OK',
+ warn: 'WARN',
+ error: 'ERROR',
+};
+
+export default function ToastStack() {
+ const { toasts, dismissToast, clearAllToasts } = useToast();
+
+ if (toasts.length === 0) return null;
+
+ return (
+
+ {toasts.length > 1 && (
+
+ )}
+
+ {toasts.map((toast) => (
+ -
+
+
{LEVEL_LABEL[toast.level]}
+
+ {toast.message}
+ {toast.detail ? {toast.detail} : null}
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/server/web/src/components/Toast/WsEventToasts.tsx b/server/web/src/components/Toast/WsEventToasts.tsx
new file mode 100644
index 0000000..d018b47
--- /dev/null
+++ b/server/web/src/components/Toast/WsEventToasts.tsx
@@ -0,0 +1,71 @@
+import { useEffect, useRef } from 'react';
+import { useToast } from '../../context/ToastContext';
+import { useWebSocket } from '../../hooks/useWebSocket';
+import type { FleetAlert, WSMessage } from '../../types';
+import type { WSCommandResult } from '../../types/ws';
+
+const SILENT_CMD_ACTIONS = new Set(['get_log', 'wg_status', 'mesh_status']);
+const CMD_RESULT_MIN_MS = 600;
+
+/**
+ * Ephemeral dashboard toasts for high-signal WebSocket events.
+ */
+export default function WsEventToasts() {
+ const { showToast } = useToast();
+ const { latestMessage } = useWebSocket();
+ const lastMsgRef = useRef(null);
+ const lastCmdAt = useRef(0);
+
+ useEffect(() => {
+ if (!latestMessage || latestMessage === lastMsgRef.current) return;
+ lastMsgRef.current = latestMessage;
+
+ switch (latestMessage.type) {
+ case 'agent_online': {
+ const agent = latestMessage.payload as { name?: string; id?: string };
+ const label = agent.name ?? agent.id?.slice(0, 8) ?? 'Agent';
+ showToast(`${label} online`, { level: 'success' });
+ break;
+ }
+ case 'agent_offline': {
+ const { agent_id } = latestMessage.payload as { agent_id: string };
+ showToast(`${agent_id.slice(0, 8)} offline`, { level: 'warn' });
+ break;
+ }
+ case 'fleet_alert': {
+ const alert = latestMessage.payload as FleetAlert;
+ showToast(alert.message, {
+ level: alert.level === 'error' ? 'error' : 'warn',
+ detail: alert.type?.replace(/_/g, ' '),
+ });
+ break;
+ }
+ case 'command_result': {
+ const p = latestMessage.payload as WSCommandResult;
+ if (p.action && SILENT_CMD_ACTIONS.has(p.action)) break;
+ const now = Date.now();
+ if (now - lastCmdAt.current < CMD_RESULT_MIN_MS) break;
+ lastCmdAt.current = now;
+ if (p.success) break;
+ const who = p.agent_id?.slice(0, 8) ?? 'agent';
+ showToast(`${p.action ?? 'command'} failed on ${who}`, {
+ level: 'error',
+ detail: p.message?.slice(0, 120),
+ });
+ break;
+ }
+ case 'agent_capabilities': {
+ const { agent_id } = latestMessage.payload as { agent_id: string };
+ showToast('Agent capabilities refreshed', {
+ level: 'success',
+ detail: agent_id?.slice(0, 8),
+ });
+ break;
+ }
+ default:
+ break;
+ }
+ }, [latestMessage, showToast]);
+
+ return null;
+}
diff --git a/server/web/src/components/components.test.tsx b/server/web/src/components/components.test.tsx
index dfe5a49..dae18e0 100644
--- a/server/web/src/components/components.test.tsx
+++ b/server/web/src/components/components.test.tsx
@@ -623,11 +623,24 @@ describe('FleetPanels', () => {
it('AlertBanner shows up to five alerts', () => {
const alerts = [
- { id: '1', level: 'warning', type: 'offline', message: 'Node down', timestamp: '2026-05-30T12:00:00Z' },
+ { id: '1', level: 'warn', type: 'offline', message: 'Node down', timestamp: '2026-05-30T12:00:00Z' },
];
render();
expect(screen.getByText('OFFLINE')).toBeInTheDocument();
expect(screen.getByText('Node down')).toBeInTheDocument();
+ expect(screen.getByLabelText('Dismiss alert')).toBeInTheDocument();
+ });
+
+ it('AlertBanner dismisses and offers clear all for multiple alerts', () => {
+ const alerts = [
+ { id: '1', level: 'warn', type: 'offline', message: 'A', timestamp: '' },
+ { id: '2', level: 'error', type: 'pool_down', message: 'B', timestamp: '' },
+ ];
+ render();
+ expect(screen.getByText('Clear all alerts')).toBeInTheDocument();
+ fireEvent.click(screen.getAllByLabelText('Dismiss alert')[0]);
+ expect(screen.queryByText('A')).toBeNull();
+ expect(screen.getByText('B')).toBeInTheDocument();
});
it('PoolStatusPanel empty hint', () => {
diff --git a/server/web/src/context/ToastContext.test.tsx b/server/web/src/context/ToastContext.test.tsx
new file mode 100644
index 0000000..dd62b94
--- /dev/null
+++ b/server/web/src/context/ToastContext.test.tsx
@@ -0,0 +1,99 @@
+/**
+ * @vitest-environment happy-dom
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
+import { ToastProvider, useToast, TOAST_DURATION_MS, MAX_VISIBLE_TOASTS } from './ToastContext';
+
+function Probe() {
+ const { toasts, showToast, dismissToast, clearAllToasts } = useToast();
+ return (
+
+ {toasts.length}
+
+
+
+
+
+ );
+}
+
+describe('ToastContext', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it('uses shorter default durations for info than error', () => {
+ expect(TOAST_DURATION_MS.info).toBeLessThan(TOAST_DURATION_MS.error);
+ expect(TOAST_DURATION_MS.info).toBeGreaterThanOrEqual(4000);
+ expect(TOAST_DURATION_MS.info).toBeLessThanOrEqual(6000);
+ });
+
+ it('auto-dismisses info toasts and keeps errors longer', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('info'));
+ fireEvent.click(screen.getByText('error'));
+ expect(screen.getByTestId('count').textContent).toBe('2');
+
+ act(() => {
+ vi.advanceTimersByTime(TOAST_DURATION_MS.info);
+ });
+ expect(screen.getByTestId('count').textContent).toBe('1');
+
+ act(() => {
+ vi.advanceTimersByTime(TOAST_DURATION_MS.error - TOAST_DURATION_MS.info);
+ });
+ expect(screen.getByTestId('count').textContent).toBe('0');
+ });
+
+ it('caps visible toasts', () => {
+ function Spam() {
+ const { showToast, toasts } = useToast();
+ return (
+
+ );
+ }
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText(/spam/));
+ expect(screen.getByText(/spam/)).toHaveTextContent(String(MAX_VISIBLE_TOASTS));
+ });
+
+ it('dismisses and clears all manually', () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('info'));
+ fireEvent.click(screen.getByText('error'));
+ fireEvent.click(screen.getByText('dismiss'));
+ expect(screen.getByTestId('count').textContent).toBe('1');
+ fireEvent.click(screen.getByText('clear'));
+ expect(screen.getByTestId('count').textContent).toBe('0');
+ });
+});
diff --git a/server/web/src/context/ToastContext.tsx b/server/web/src/context/ToastContext.tsx
new file mode 100644
index 0000000..5dbb9ef
--- /dev/null
+++ b/server/web/src/context/ToastContext.tsx
@@ -0,0 +1,123 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+export type ToastLevel = 'info' | 'success' | 'warn' | 'error';
+
+export interface Toast {
+ id: string;
+ level: ToastLevel;
+ message: string;
+ detail?: string;
+ createdAt: number;
+ durationMs: number;
+}
+
+export interface ShowToastOptions {
+ level?: ToastLevel;
+ detail?: string;
+ /** Override default auto-dismiss; 0 = stay until dismissed */
+ durationMs?: number;
+}
+
+export const TOAST_DURATION_MS: Record = {
+ info: 5000,
+ success: 5000,
+ warn: 6000,
+ error: 10000,
+};
+
+export const MAX_VISIBLE_TOASTS = 5;
+
+let toastCounter = 0;
+function nextToastId(): string {
+ toastCounter += 1;
+ return `toast-${toastCounter}`;
+}
+
+export interface ToastContextValue {
+ toasts: Toast[];
+ showToast: (message: string, options?: ShowToastOptions) => string;
+ dismissToast: (id: string) => void;
+ clearAllToasts: () => void;
+}
+
+const ToastContext = createContext({
+ toasts: [],
+ showToast: () => '',
+ dismissToast: () => {},
+ clearAllToasts: () => {},
+});
+
+export function ToastProvider({ children }: { children: React.ReactNode }) {
+ const [toasts, setToasts] = useState([]);
+ const timersRef = useRef