import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; import { WsFeedMessage } from "@/api/ws"; import { useWsMessageBuffer } from "@/hooks/use-ws-message-buffer"; import WsMessageRow from "./WsMessageRow"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { FaEraser, FaFilter, FaPause, FaPlay, FaVideo } from "react-icons/fa"; import { FrigateConfig } from "@/types/frigateConfig"; import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"; import FilterSwitch from "@/components/filter/FilterSwitch"; import { isMobile } from "react-device-detect"; import { isReplayCamera } from "@/utils/cameraUtil"; type TopicCategory = | "events" | "camera_activity" | "system" | "reviews" | "classification" | "face_recognition" | "lpr"; const ALL_TOPIC_CATEGORIES: TopicCategory[] = [ "events", "reviews", "classification", "face_recognition", "lpr", "camera_activity", "system", ]; const PRESET_TOPICS: Record> = { events: new Set(["events", "triggers"]), reviews: new Set(["reviews"]), classification: new Set(["tracked_object_update"]), face_recognition: new Set(["tracked_object_update"]), lpr: new Set(["tracked_object_update"]), camera_activity: new Set(["camera_activity", "audio_detections"]), system: new Set([ "stats", "model_state", "job_state", "embeddings_reindex_progress", "audio_transcription_state", "birdseye_layout", ]), }; // Maps tracked_object_update payload type to TopicCategory const TRACKED_UPDATE_TYPE_MAP: Record = { classification: "classification", face: "face_recognition", lpr: "lpr", }; // camera_activity preset also matches topics with camera prefix patterns const CAMERA_ACTIVITY_TOPIC_PATTERNS = [ "/motion", "/audio", "/detect", "/recordings", "/enabled", "/snapshots", "/ptz", ]; function matchesCategories( msg: WsFeedMessage, categories: TopicCategory[] | undefined, ): boolean { // undefined means all topics if (!categories) return true; const { topic, payload } = msg; // Handle tracked_object_update with payload-based sub-categories if (topic === "tracked_object_update") { // payload might be a JSON string or a parsed object let data: unknown = payload; if (typeof data === "string") { try { data = JSON.parse(data); } catch { // not valid JSON, fall through } } const updateType = data && typeof data === "object" && "type" in data ? (data as { type: string }).type : undefined; if (updateType && updateType in TRACKED_UPDATE_TYPE_MAP) { const mappedCategory = TRACKED_UPDATE_TYPE_MAP[updateType]; return categories.includes(mappedCategory); } // tracked_object_update with other types (e.g. "description") falls under "events" return categories.includes("events"); } for (const cat of categories) { const topicSet = PRESET_TOPICS[cat]; if (topicSet.has(topic)) return true; if (cat === "camera_activity") { if ( CAMERA_ACTIVITY_TOPIC_PATTERNS.some((pattern) => topic.includes(pattern), ) ) { return true; } } } return false; } type WsMessageFeedProps = { maxSize?: number; defaultCamera?: string; lockedCamera?: string; showCameraBadge?: boolean; }; export default function WsMessageFeed({ maxSize = 500, defaultCamera, lockedCamera, showCameraBadge = true, }: WsMessageFeedProps) { const { t } = useTranslation(["views/system"]); const [paused, setPaused] = useState(false); // undefined = all topics const [selectedTopics, setSelectedTopics] = useState< TopicCategory[] | undefined >(undefined); // undefined = all cameras const [selectedCameras, setSelectedCameras] = useState( () => { if (lockedCamera) return [lockedCamera]; if (defaultCamera) return [defaultCamera]; return undefined; }, ); const { messages, clear } = useWsMessageBuffer(maxSize, paused, { cameraFilter: selectedCameras, }); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); const availableCameras = useMemo(() => { if (!config?.cameras) return []; return Object.keys(config.cameras) .filter((name) => { const cam = config.cameras[name]; return !isReplayCamera(name) && cam.enabled_in_config; }) .sort(); }, [config]); const filteredMessages = useMemo(() => { return messages.filter((msg: WsFeedMessage) => { if (!matchesCategories(msg, selectedTopics)) return false; return true; }); }, [messages, selectedTopics]); // Auto-scroll logic const scrollContainerRef = useRef(null); const autoScrollRef = useRef(true); const handleScroll = useCallback(() => { const el = scrollContainerRef.current; if (!el) return; const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; autoScrollRef.current = atBottom; }, []); useEffect(() => { const el = scrollContainerRef.current; if (!el || !autoScrollRef.current) return; el.scrollTop = el.scrollHeight; }, [filteredMessages.length]); return (
{/* Toolbar */}
{!lockedCamera && ( )}
{t("logs.websocket.count", { count: filteredMessages.length, })}
{/* Feed area */}
{filteredMessages.length === 0 ? (
{t("logs.websocket.empty")}
) : ( filteredMessages.map((msg: WsFeedMessage) => ( )) )}
); } // Topic Filter Button type TopicFilterButtonProps = { selectedTopics: TopicCategory[] | undefined; updateTopicFilter: (topics: TopicCategory[] | undefined) => void; }; function TopicFilterButton({ selectedTopics, updateTopicFilter, }: TopicFilterButtonProps) { const { t } = useTranslation(["views/system"]); const [open, setOpen] = useState(false); const [currentTopics, setCurrentTopics] = useState< TopicCategory[] | undefined >(selectedTopics); useEffect(() => { setCurrentTopics(selectedTopics); }, [selectedTopics]); const isFiltered = selectedTopics !== undefined; const trigger = ( ); const content = ( { updateTopicFilter(currentTopics); setOpen(false); }} onReset={() => { setCurrentTopics(undefined); updateTopicFilter(undefined); }} /> ); if (isMobile) { return ( { if (!open) setCurrentTopics(selectedTopics); setOpen(open); }} > {trigger} {content} ); } return ( { if (!open) setCurrentTopics(selectedTopics); setOpen(open); }} > {trigger} {content} ); } type TopicFilterContentProps = { currentTopics: TopicCategory[] | undefined; setCurrentTopics: (topics: TopicCategory[] | undefined) => void; onApply: () => void; onReset: () => void; }; function TopicFilterContent({ currentTopics, setCurrentTopics, onApply, onReset, }: TopicFilterContentProps) { const { t } = useTranslation(["views/system", "common"]); return ( <>
{ if (isChecked) { setCurrentTopics(undefined); } }} /> {ALL_TOPIC_CATEGORIES.map((cat) => ( { if (isChecked) { const updated = currentTopics ? [...currentTopics, cat] : [cat]; setCurrentTopics(updated); } else { const updated = currentTopics ? currentTopics.filter((c) => c !== cat) : []; if (updated.length === 0) { setCurrentTopics(undefined); } else { setCurrentTopics(updated); } } }} /> ))}
); } // Camera Filter Button type WsCamerasFilterButtonProps = { allCameras: string[]; selectedCameras: string[] | undefined; updateCameraFilter: (cameras: string[] | undefined) => void; }; function WsCamerasFilterButton({ allCameras, selectedCameras, updateCameraFilter, }: WsCamerasFilterButtonProps) { const { t } = useTranslation(["views/system", "common"]); const [open, setOpen] = useState(false); const [currentCameras, setCurrentCameras] = useState( selectedCameras, ); useEffect(() => { setCurrentCameras(selectedCameras); }, [selectedCameras]); const isFiltered = selectedCameras !== undefined; const trigger = ( ); const content = ( { updateCameraFilter(currentCameras); setOpen(false); }} onReset={() => { setCurrentCameras(undefined); updateCameraFilter(undefined); }} /> ); if (isMobile) { return ( { if (!open) setCurrentCameras(selectedCameras); setOpen(open); }} > {trigger} {content} ); } return ( { if (!open) setCurrentCameras(selectedCameras); setOpen(open); }} > {trigger} {content} ); } type WsCamerasFilterContentProps = { allCameras: string[]; currentCameras: string[] | undefined; setCurrentCameras: (cameras: string[] | undefined) => void; onApply: () => void; onReset: () => void; }; function WsCamerasFilterContent({ allCameras, currentCameras, setCurrentCameras, onApply, onReset, }: WsCamerasFilterContentProps) { const { t } = useTranslation(["views/system", "common"]); return ( <>
{ if (isChecked) { setCurrentCameras(undefined); } }} /> {allCameras.map((cam) => ( { if (isChecked) { const updated = currentCameras ? [...currentCameras] : []; if (!updated.includes(cam)) { updated.push(cam); } setCurrentCameras(updated); } else { const updated = currentCameras ? [...currentCameras] : []; if (updated.length > 1) { updated.splice(updated.indexOf(cam), 1); setCurrentCameras(updated); } } }} /> ))}
); }