diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 65b834ca1..826efd1f3 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -21,11 +21,14 @@ "clear": "Clear", "filter": { "all": "All topics", + "topics": "Topics", "events": "Events", "camera_activity": "Camera activity", "system": "System", "camera": "Camera", - "all_cameras": "All cameras" + "all_cameras": "All cameras", + "cameras_count_one": "{{count}} Camera", + "cameras_count_other": "{{count}} Cameras" }, "empty": "No messages captured yet", "count": "{{count}} messages", diff --git a/web/src/components/ws/WsMessageFeed.tsx b/web/src/components/ws/WsMessageFeed.tsx index e87e8cbaa..b7c79fe45 100644 --- a/web/src/components/ws/WsMessageFeed.tsx +++ b/web/src/components/ws/WsMessageFeed.tsx @@ -5,22 +5,28 @@ import { WsFeedMessage } from "@/api/ws"; import { useWsMessageBuffer } from "@/hooks/use-ws-message-buffer"; import WsMessageRow from "./WsMessageRow"; import { Button } from "@/components/ui/button"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; -import { FaEraser, FaPause, FaPlay } from "react-icons/fa"; +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 TopicPreset = "all" | "events" | "camera_activity" | "system"; +type TopicCategory = "events" | "camera_activity" | "system"; +const ALL_TOPIC_CATEGORIES: TopicCategory[] = [ + "events", + "camera_activity", + "system", +]; -const PRESET_TOPICS: Record | "all"> = { - all: "all", +const PRESET_TOPICS: Record> = { events: new Set(["events", "reviews", "tracked_object_update", "triggers"]), camera_activity: new Set(["camera_activity", "audio_detections"]), system: new Set([ @@ -44,15 +50,26 @@ const CAMERA_ACTIVITY_TOPIC_PATTERNS = [ "/ptz", ]; -function matchesPreset(topic: string, preset: TopicPreset): boolean { - const topicSet = PRESET_TOPICS[preset]; - if (topicSet === "all") return true; - if (topicSet.has(topic)) return true; +function matchesCategories( + topic: string, + categories: TopicCategory[] | undefined, +): boolean { + // undefined means all topics + if (!categories) return true; - if (preset === "camera_activity") { - return CAMERA_ACTIVITY_TOPIC_PATTERNS.some((pattern) => - topic.includes(pattern), - ); + 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; @@ -73,30 +90,43 @@ export default function WsMessageFeed({ }: WsMessageFeedProps) { const { t } = useTranslation(["views/system"]); const [paused, setPaused] = useState(false); - const [topicPreset, setTopicPreset] = useState("all"); - const [cameraFilter, setCameraFilter] = useState( - lockedCamera ?? defaultCamera ?? "all", + // 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, + cameraFilter: selectedCameras, }); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); - const cameraNames = useMemo(() => { + const availableCameras = useMemo(() => { if (!config?.cameras) return []; - return Object.keys(config.cameras).sort(); + 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 (!matchesPreset(msg.topic, topicPreset)) return false; + if (!matchesCategories(msg.topic, selectedTopics)) return false; return true; }); - }, [messages, topicPreset]); + }, [messages, selectedTopics]); // Auto-scroll logic const scrollContainerRef = useRef(null); @@ -118,69 +148,23 @@ export default function WsMessageFeed({ return (
{/* Toolbar */} -
-
- { - if (val) setTopicPreset(val as TopicPreset); - }} - className="flex-wrap" - > - - {t("logs.websocket.filter.all")} - - - {t("logs.websocket.filter.events")} - - - {t("logs.websocket.filter.camera_activity")} - - - {t("logs.websocket.filter.system")} - - +
+
+ {!lockedCamera && ( - + )}
-
+
{t("logs.websocket.count", { count: filteredMessages.length, @@ -242,3 +226,332 @@ export default function WsMessageFeed({
); } + +// 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); + } + } + }} + /> + ))} +
+ +
+ + +
+ + ); +} diff --git a/web/src/components/ws/WsMessageRow.tsx b/web/src/components/ws/WsMessageRow.tsx index 437059d36..aa7c89522 100644 --- a/web/src/components/ws/WsMessageRow.tsx +++ b/web/src/components/ws/WsMessageRow.tsx @@ -207,7 +207,7 @@ function extractTypeForBadge(payload: unknown): string | null { return null; } -function shouldShowTypeBadge(topic: string, type: string | null): boolean { +function shouldShowTypeBadge(type: string | null): boolean { if (!type) return false; return true; } @@ -307,7 +307,7 @@ const WsMessageRow = memo(function WsMessageRow({ const cameraName = extractCameraName(message); const messageType = extractTypeForBadge(message.payload); - const showTypeBadge = shouldShowTypeBadge(message.topic, messageType); + const showTypeBadge = shouldShowTypeBadge(messageType); const summary = getPayloadSummary(message.topic, message.payload); @@ -365,13 +365,13 @@ const WsMessageRow = memo(function WsMessageRow({ )} /> - + {formatTimestamp(message.timestamp)} @@ -397,7 +397,11 @@ const WsMessageRow = memo(function WsMessageRow({ {eventLabel && ( - {getIconForLabel(eventLabel, "size-3.5 text-primary-variant")} + {getIconForLabel( + eventLabel, + "object", + "size-3.5 text-primary-variant", + )} )} @@ -417,7 +421,7 @@ const WsMessageRow = memo(function WsMessageRow({
         
diff --git a/web/src/hooks/use-ws-message-buffer.ts b/web/src/hooks/use-ws-message-buffer.ts index 346d38575..6f7f06662 100644 --- a/web/src/hooks/use-ws-message-buffer.ts +++ b/web/src/hooks/use-ws-message-buffer.ts @@ -8,7 +8,7 @@ type UseWsMessageBufferReturn = { }; type MessageFilter = { - cameraFilter?: string; // "all" or specific camera name + cameraFilter?: string | string[]; // "all", specific camera name, or array of camera names (undefined in array = all) }; export function useWsMessageBuffer( @@ -47,10 +47,20 @@ export function useWsMessageBuffer( if (!currentFilter) return true; // Check camera filter - if (currentFilter.cameraFilter && currentFilter.cameraFilter !== "all") { - const msgCamera = extractCameraName(msg); - if (msgCamera !== currentFilter.cameraFilter) { - return false; + const cf = currentFilter.cameraFilter; + if (cf !== undefined) { + if (Array.isArray(cf)) { + // Array of cameras: include messages matching any camera in the list + const msgCamera = extractCameraName(msg); + if (msgCamera && !cf.includes(msgCamera)) { + return false; + } + } else if (cf !== "all") { + // Single string camera filter + const msgCamera = extractCameraName(msg); + if (msgCamera !== cf) { + return false; + } } }