Files
attune/web/src/hooks/useEnforcementStream.ts
David Culbreth c61fe26713
Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Cargo Audit & Deny (push) Failing after 32s
CI / Web Blocking Checks (push) Successful in 47s
CI / Security Blocking Checks (push) Successful in 9s
CI / Clippy (push) Successful in 2m9s
CI / Web Advisory Checks (push) Successful in 37s
CI / Security Advisory Checks (push) Successful in 34s
CI / Tests (push) Failing after 8m37s
eslint and build
2026-03-05 08:18:07 -06:00

241 lines
7.0 KiB
TypeScript

import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
useEntityNotifications,
type Notification,
} from "@/contexts/WebSocketContext";
import type { EnforcementSummary } from "@/api";
interface UseEnforcementStreamOptions {
/**
* Optional enforcement ID to filter updates for a specific enforcement.
* If not provided, receives updates for all enforcements.
*/
enforcementId?: number;
/**
* Whether the stream should be active.
* Defaults to true.
*/
enabled?: boolean;
}
/** Shape of data coming from WebSocket notifications for enforcements */
interface EnforcementNotification {
entity_id: number;
entity_type: string;
notification_type: string;
payload: Partial<EnforcementSummary> & Record<string, unknown>;
timestamp: string;
}
/** Query params shape used in enforcement list query keys */
interface EnforcementQueryParams {
status?: string;
event?: number;
rule?: number;
triggerRef?: string;
ruleRef?: string;
}
/** Shape of the paginated API response stored in React Query cache */
interface EnforcementListCache {
data: EnforcementSummary[];
pagination?: {
total_items?: number;
page?: number;
page_size?: number;
};
}
/** Shape of a single enforcement detail response stored in React Query cache */
interface EnforcementDetailCache {
data: EnforcementSummary;
}
/**
* Check if an enforcement matches the given query parameters
* Only checks fields that are reliably present in WebSocket payloads
*/
function enforcementMatchesParams(
enforcement: Partial<EnforcementSummary>,
params: EnforcementQueryParams | undefined,
): boolean {
if (!params) return true;
// Check status filter
if (params.status && enforcement.status !== params.status) {
return false;
}
// Check event filter
if (params.event !== undefined && enforcement.event !== params.event) {
return false;
}
// Check rule filter
if (params.rule !== undefined && enforcement.rule !== params.rule) {
return false;
}
// Check trigger_ref filter (always present)
if (params.triggerRef && enforcement.trigger_ref !== params.triggerRef) {
return false;
}
// Note: rule_ref is NOT checked here for new enforcements because it may not be
// present in WebSocket payloads. For this filter, we only update existing
// enforcements, never add new ones.
return true;
}
/**
* Check if query params include filters not present in WebSocket payloads
*/
function hasUnsupportedFilters(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_params: EnforcementQueryParams | undefined,
): boolean {
// Currently all enforcement filters are supported in WebSocket payloads
return false;
}
/**
* Hook to subscribe to real-time enforcement updates via WebSocket.
*
* Automatically reconnects on connection loss and updates React Query cache
* when enforcement updates are received.
*
* @example
* ```tsx
* // Listen to all enforcement updates
* useEnforcementStream();
*
* // Listen to updates for a specific enforcement
* useEnforcementStream({ enforcementId: 123 });
* ```
*/
export function useEnforcementStream(
options: UseEnforcementStreamOptions = {},
) {
const { enforcementId, enabled = true } = options;
const queryClient = useQueryClient();
const handleNotification = useCallback(
(raw: Notification) => {
const notification = raw as unknown as EnforcementNotification;
// Filter by enforcement ID if specified
if (enforcementId && notification.entity_id !== enforcementId) {
return;
}
// Extract enforcement data from notification payload (flat structure)
const enforcementData =
notification.payload as Partial<EnforcementSummary>;
// Update specific enforcement query if it exists
queryClient.setQueryData(
["enforcements", notification.entity_id],
(old: EnforcementDetailCache | undefined) => {
if (!old) return old;
return {
...old,
data: {
...old.data,
...enforcementData,
},
};
},
);
// Update enforcement list queries by modifying existing data
// We need to iterate manually to access query keys for filtering
const queries = queryClient
.getQueriesData<EnforcementListCache>({
queryKey: ["enforcements"],
exact: false,
})
.filter(([, data]) => data && Array.isArray(data?.data));
queries.forEach(([queryKey, oldData]) => {
// Extract query params from the query key (format: ["enforcements", params])
const queryParams = queryKey[1] as EnforcementQueryParams | undefined;
const old = oldData as EnforcementListCache;
// Check if enforcement already exists in the list
const existingIndex = old.data.findIndex(
(enf) => enf.id === notification.entity_id,
);
let updatedData: EnforcementSummary[];
if (existingIndex >= 0) {
// Always update existing enforcement in the list
updatedData = [...old.data];
updatedData[existingIndex] = {
...updatedData[existingIndex],
...enforcementData,
};
// Note: We don't remove enforcements from cache based on filters.
// The cache represents what the API query returned.
// Client-side filtering (in the page component) handles what's displayed.
} else {
// For new enforcements, be conservative with filters we can't verify
if (hasUnsupportedFilters(queryParams)) {
// Don't add new enforcement when using filters we can't verify
return;
}
// Only add new enforcement if it matches the query parameters
if (enforcementMatchesParams(enforcementData, queryParams)) {
// Add to beginning and cap at 50 items to prevent performance issues
updatedData = [
enforcementData as EnforcementSummary,
...old.data,
].slice(0, 50);
} else {
// Don't modify the list if the new enforcement doesn't match the query
return;
}
}
// Update the query with the new data
queryClient.setQueryData(queryKey, {
...old,
data: updatedData,
pagination: {
...old.pagination,
total_items: (old.pagination?.total_items || 0) + 1,
},
});
});
// Also update related queries (rules and events enforcements)
if (enforcementData.rule) {
queryClient.invalidateQueries({
queryKey: ["rules", enforcementData.rule, "enforcements"],
});
}
if (enforcementData.event) {
queryClient.invalidateQueries({
queryKey: ["events", enforcementData.event, "enforcements"],
});
}
},
[enforcementId, queryClient],
);
const { connected } = useEntityNotifications(
"enforcement",
handleNotification,
enabled,
);
return {
isConnected: connected,
error: null,
};
}