Files
attune/docs/web-ui/websocket-usage.md
2026-02-04 17:46:30 -06:00

8.7 KiB

WebSocket Usage in Web UI

Overview

The Attune web UI uses a single shared WebSocket connection for real-time notifications across all pages. This connection is managed by WebSocketProvider and accessed via React hooks.

Architecture

App.tsx
  └── WebSocketProvider (manages single WS connection)
        ├── EventsPage (subscribes to "event" notifications)
        ├── ExecutionsPage (subscribes to "execution" notifications)
        └── DashboardPage (subscribes to multiple entity types)

Only one WebSocket connection is created per browser tab, regardless of how many pages subscribe to notifications.

Basic Usage

1. Ensure Provider is Configured

The WebSocketProvider should be set up in App.tsx (already configured):

import { WebSocketProvider } from "@/contexts/WebSocketContext";

function App() {
  return (
    <WebSocketProvider>
      {/* Your app routes */}
    </WebSocketProvider>
  );
}

2. Subscribe to Entity Notifications

Use useEntityNotifications to receive real-time updates for a specific entity type:

import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useEntityNotifications } from "@/contexts/WebSocketContext";

function MyPage() {
  const queryClient = useQueryClient();
  
  // IMPORTANT: Wrap handler in useCallback for stable reference
  const handleNotification = useCallback(() => {
    // Invalidate queries to refetch data
    queryClient.invalidateQueries({ queryKey: ["myEntity"] });
  }, [queryClient]);
  
  const { connected } = useEntityNotifications("myEntity", handleNotification);
  
  return (
    <div>
      {connected && <span>Live updates enabled</span>}
      {/* Rest of your component */}
    </div>
  );
}

Available Entity Types

Subscribe to these entity types to receive notifications:

  • "event" - Event creation/updates
  • "execution" - Execution status changes
  • "enforcement" - Rule enforcement events
  • "inquiry" - Human-in-the-loop interactions
  • "action" - Action changes (from pack updates)
  • "rule" - Rule changes
  • "trigger" - Trigger changes
  • "sensor" - Sensor changes

Advanced Usage

Multiple Subscriptions in One Component

function DashboardPage() {
  const queryClient = useQueryClient();
  
  const handleEventNotification = useCallback(() => {
    queryClient.invalidateQueries({ queryKey: ["events"] });
  }, [queryClient]);
  
  const handleExecutionNotification = useCallback(() => {
    queryClient.invalidateQueries({ queryKey: ["executions"] });
  }, [queryClient]);
  
  useEntityNotifications("event", handleEventNotification);
  useEntityNotifications("execution", handleExecutionNotification);
  
  // Both subscriptions share the same WebSocket connection
}

Custom Notification Handling

import { useCallback } from "react";
import { useEntityNotifications, Notification } from "@/contexts/WebSocketContext";

function MyPage() {
  const handleNotification = useCallback((notification: Notification) => {
    console.log("Received notification:", notification);
    
    // Access notification details
    console.log("Entity type:", notification.entity_type);
    console.log("Entity ID:", notification.entity_id);
    console.log("Payload:", notification.payload);
    console.log("Timestamp:", notification.timestamp);
    
    // Custom logic based on notification
    if (notification.entity_id === specificId) {
      // Do something specific
    }
  }, []);
  
  useEntityNotifications("execution", handleNotification);
}

Conditional Subscriptions

function MyPage({ entityType }: { entityType: string }) {
  const [enabled, setEnabled] = useState(true);
  
  const handleNotification = useCallback(() => {
    // Handle notification
  }, []);
  
  // Only subscribe when enabled is true
  useEntityNotifications(entityType, handleNotification, enabled);
}

Direct Context Access

For advanced use cases, access the WebSocket context directly:

import { useWebSocketContext } from "@/contexts/WebSocketContext";

function AdvancedComponent() {
  const { connected, subscribe, unsubscribe } = useWebSocketContext();
  
  useEffect(() => {
    const handler = (notification) => {
      // Custom handling
    };
    
    // Subscribe to a custom filter
    subscribe("entity_type:execution", handler);
    
    return () => {
      unsubscribe("entity_type:execution", handler);
    };
  }, [subscribe, unsubscribe]);
}

Important Guidelines

DO

  • Always wrap handlers in useCallback to prevent re-subscriptions
  • Include all dependencies in the useCallback dependency array
  • Use queryClient.invalidateQueries for simple cache invalidation
  • Show connection status in the UI when relevant

DON'T

  • Don't create inline functions as handlers (causes re-subscriptions)
  • Don't create multiple WebSocket connections (use the context)
  • Don't forget to handle the connected state
  • Don't use the deprecated useWebSocket from @/hooks/useWebSocket.ts

Connection Status

Display connection status to users:

function MyPage() {
  const { connected } = useEntityNotifications("event", handleNotification);
  
  return (
    <div>
      {connected ? (
        <div className="flex items-center gap-2 text-green-600">
          <div className="w-2 h-2 bg-green-600 rounded-full animate-pulse" />
          <span>Live updates</span>
        </div>
      ) : (
        <div className="text-gray-400">Connecting...</div>
      )}
    </div>
  );
}

Configuration

WebSocket connection is configured via environment variables:

# .env.local or .env.development
VITE_WS_URL=ws://localhost:8081

The provider automatically appends /ws to the URL if not present.

Troubleshooting

Multiple Connections Opening

Problem: Browser DevTools shows multiple WebSocket connections.

Solution:

  • Ensure WebSocketProvider is only added once in App.tsx
  • Check that handlers are wrapped in useCallback
  • Verify React StrictMode isn't causing double-mounting in development

Notifications Not Received

Problem: Component doesn't receive notifications.

Checklist:

  1. Is WebSocketProvider wrapping your app?
  2. Is the notifier service running? (make run-notifier)
  3. Is connected returning true?
  4. Is the entity type spelled correctly?
  5. Are notifications actually being sent? (check server logs)

Connection Keeps Reconnecting

Problem: WebSocket disconnects and reconnects repeatedly.

Possible causes:

  • Notifier service restarting
  • Network issues
  • Authentication token expired (WebSocket doesn't currently use auth)
  • Check console for error messages

Example: Complete Page

import { useState, useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useEntityNotifications } from "@/contexts/WebSocketContext";
import { useEvents } from "@/hooks/useEvents";

export default function EventsPage() {
  const queryClient = useQueryClient();
  const [page, setPage] = useState(1);
  
  // Real-time updates
  const handleEventNotification = useCallback(() => {
    queryClient.invalidateQueries({ queryKey: ["events"] });
  }, [queryClient]);
  
  const { connected } = useEntityNotifications("event", handleEventNotification);
  
  // Fetch data
  const { data, isLoading } = useEvents({ page, pageSize: 20 });
  
  return (
    <div>
      <div className="flex items-center justify-between">
        <h1>Events</h1>
        {connected && (
          <div className="text-green-600">
            <div className="w-2 h-2 bg-green-600 rounded-full animate-pulse" />
            Live updates
          </div>
        )}
      </div>
      
      {/* Render events */}
    </div>
  );
}

Performance Notes

  • The single shared connection significantly reduces network overhead
  • Subscriptions are reference-counted (only subscribe to each filter once)
  • Handlers are called synchronously when notifications arrive
  • No polling is needed - all updates are push-based via WebSocket

Migration from Old Pattern

If you find code using the old pattern:

// ❌ OLD - creates separate connection
import { useEntityNotifications } from "@/hooks/useWebSocket";
const { connected } = useEntityNotifications("event", () => {
  queryClient.invalidateQueries({ queryKey: ["events"] });
});

Update to:

// ✅ NEW - uses shared connection
import { useEntityNotifications } from "@/contexts/WebSocketContext";
const handleNotification = useCallback(() => {
  queryClient.invalidateQueries({ queryKey: ["events"] });
}, [queryClient]);
const { connected } = useEntityNotifications("event", handleNotification);