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
241 lines
7.0 KiB
TypeScript
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,
|
|
};
|
|
}
|