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

319 lines
8.7 KiB
Markdown

# 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):
```typescript
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:
```typescript
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
```typescript
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
```typescript
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
```typescript
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:
```typescript
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:
```typescript
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:
```bash
# .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
```typescript
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:
```typescript
// ❌ OLD - creates separate connection
import { useEntityNotifications } from "@/hooks/useWebSocket";
const { connected } = useEntityNotifications("event", () => {
queryClient.invalidateQueries({ queryKey: ["events"] });
});
```
Update to:
```typescript
// ✅ NEW - uses shared connection
import { useEntityNotifications } from "@/contexts/WebSocketContext";
const handleNotification = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["events"] });
}, [queryClient]);
const { connected } = useEntityNotifications("event", handleNotification);
```