camera and filter button and dropdown

This commit is contained in:
Josh Hawkins 2026-02-28 17:09:59 -06:00
parent 14143c4b3e
commit 5ab85f257f
4 changed files with 428 additions and 98 deletions

View File

@ -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",

View File

@ -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<TopicPreset, Set<string> | "all"> = {
all: "all",
const PRESET_TOPICS: Record<TopicCategory, Set<string>> = {
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<TopicPreset>("all");
const [cameraFilter, setCameraFilter] = useState<string>(
lockedCamera ?? defaultCamera ?? "all",
// undefined = all topics
const [selectedTopics, setSelectedTopics] = useState<
TopicCategory[] | undefined
>(undefined);
// undefined = all cameras
const [selectedCameras, setSelectedCameras] = useState<string[] | undefined>(
() => {
if (lockedCamera) return [lockedCamera];
if (defaultCamera) return [defaultCamera];
return undefined;
},
);
const { messages, clear } = useWsMessageBuffer(maxSize, paused, {
cameraFilter,
cameraFilter: selectedCameras,
});
const { data: config } = useSWR<FrigateConfig>("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<HTMLDivElement>(null);
@ -118,69 +148,23 @@ export default function WsMessageFeed({
return (
<div className="flex size-full flex-col">
{/* Toolbar */}
<div className="flex flex-row items-start justify-between gap-2 border-b border-secondary p-2">
<div className="flex flex-col flex-wrap items-start gap-2">
<ToggleGroup
type="single"
size="sm"
value={topicPreset}
onValueChange={(val: string) => {
if (val) setTopicPreset(val as TopicPreset);
}}
className="flex-wrap"
>
<ToggleGroupItem
value="all"
className={topicPreset === "all" ? "" : "text-muted-foreground"}
>
{t("logs.websocket.filter.all")}
</ToggleGroupItem>
<ToggleGroupItem
value="events"
className={
topicPreset === "events" ? "" : "text-muted-foreground"
}
>
{t("logs.websocket.filter.events")}
</ToggleGroupItem>
<ToggleGroupItem
value="camera_activity"
className={
topicPreset === "camera_activity" ? "" : "text-muted-foreground"
}
>
{t("logs.websocket.filter.camera_activity")}
</ToggleGroupItem>
<ToggleGroupItem
value="system"
className={
topicPreset === "system" ? "" : "text-muted-foreground"
}
>
{t("logs.websocket.filter.system")}
</ToggleGroupItem>
</ToggleGroup>
<div className="flex flex-row flex-wrap items-center justify-between gap-2 border-b border-secondary p-2">
<div className="flex flex-row flex-wrap items-center gap-1">
<TopicFilterButton
selectedTopics={selectedTopics}
updateTopicFilter={setSelectedTopics}
/>
{!lockedCamera && (
<Select value={cameraFilter} onValueChange={setCameraFilter}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue placeholder={t("logs.websocket.filter.camera")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("logs.websocket.filter.all_cameras")}
</SelectItem>
{cameraNames.map((cam) => (
<SelectItem key={cam} value={cam}>
{config?.cameras[cam]?.friendly_name || cam}
</SelectItem>
))}
</SelectContent>
</Select>
<WsCamerasFilterButton
allCameras={availableCameras}
selectedCameras={selectedCameras}
updateCameraFilter={setSelectedCameras}
/>
)}
</div>
<div className="flex flex-col items-end gap-3">
<div className="flex flex-row items-center gap-2">
<Badge variant="secondary" className="text-xs text-primary-variant">
{t("logs.websocket.count", {
count: filteredMessages.length,
@ -242,3 +226,332 @@ export default function WsMessageFeed({
</div>
);
}
// 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 = (
<Button
variant={isFiltered ? "select" : "outline"}
size="sm"
className="h-7 gap-1 px-2 text-xs"
aria-label={t("logs.websocket.filter.all")}
>
<FaFilter
className={`size-2.5 ${isFiltered ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<span className={isFiltered ? "text-selected-foreground" : ""}>
{t("logs.websocket.filter.topics")}
</span>
</Button>
);
const content = (
<TopicFilterContent
currentTopics={currentTopics}
setCurrentTopics={setCurrentTopics}
onApply={() => {
updateTopicFilter(currentTopics);
setOpen(false);
}}
onReset={() => {
setCurrentTopics(undefined);
updateTopicFilter(undefined);
}}
/>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) setCurrentTopics(selectedTopics);
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
if (!open) setCurrentTopics(selectedTopics);
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<>
<div className="flex flex-col gap-2.5 p-4">
<FilterSwitch
isChecked={currentTopics === undefined}
label={t("logs.websocket.filter.all")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentTopics(undefined);
}
}}
/>
<DropdownMenuSeparator />
{ALL_TOPIC_CATEGORIES.map((cat) => (
<FilterSwitch
key={cat}
isChecked={currentTopics?.includes(cat) ?? false}
label={t(`logs.websocket.filter.${cat}`)}
onCheckedChange={(isChecked) => {
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);
}
}
}}
/>
))}
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label={t("button.apply", { ns: "common" })}
variant="select"
size="sm"
onClick={onApply}
>
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label={t("button.reset", { ns: "common" })}
size="sm"
onClick={onReset}
>
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
);
}
// 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<string[] | undefined>(
selectedCameras,
);
useEffect(() => {
setCurrentCameras(selectedCameras);
}, [selectedCameras]);
const isFiltered = selectedCameras !== undefined;
const trigger = (
<Button
variant={isFiltered ? "select" : "outline"}
size="sm"
className="h-7 gap-1 px-2 text-xs"
aria-label={t("logs.websocket.filter.all_cameras")}
>
<FaVideo
className={`size-2.5 ${isFiltered ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<span className={isFiltered ? "text-selected-foreground" : ""}>
{!selectedCameras
? t("logs.websocket.filter.all_cameras")
: t("logs.websocket.filter.cameras_count", {
count: selectedCameras.length,
})}
</span>
</Button>
);
const content = (
<WsCamerasFilterContent
allCameras={allCameras}
currentCameras={currentCameras}
setCurrentCameras={setCurrentCameras}
onApply={() => {
updateCameraFilter(currentCameras);
setOpen(false);
}}
onReset={() => {
setCurrentCameras(undefined);
updateCameraFilter(undefined);
}}
/>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) setCurrentCameras(selectedCameras);
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
if (!open) setCurrentCameras(selectedCameras);
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<>
<div className="scrollbar-container flex max-h-[60dvh] flex-col gap-2.5 overflow-y-auto p-4">
<FilterSwitch
isChecked={currentCameras === undefined}
label={t("logs.websocket.filter.all_cameras")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentCameras(undefined);
}
}}
/>
<DropdownMenuSeparator />
{allCameras.map((cam) => (
<FilterSwitch
key={cam}
isChecked={currentCameras?.includes(cam) ?? false}
label={cam}
type="camera"
onCheckedChange={(isChecked) => {
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);
}
}
}}
/>
))}
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label={t("button.apply", { ns: "common" })}
variant="select"
size="sm"
disabled={currentCameras?.length === 0}
onClick={onApply}
>
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label={t("button.reset", { ns: "common" })}
size="sm"
onClick={onReset}
>
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
);
}

View File

@ -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({
)}
/>
<span className="font-mono shrink-0 text-xs text-muted-foreground">
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{formatTimestamp(message.timestamp)}
</span>
<span
className={cn(
"font-mono shrink-0 rounded border px-1.5 py-0.5 text-xs",
"shrink-0 rounded border px-1.5 py-0.5 font-mono text-xs",
TOPIC_CATEGORY_COLORS[category],
)}
>
@ -397,7 +397,11 @@ const WsMessageRow = memo(function WsMessageRow({
{eventLabel && (
<span className="shrink-0">
{getIconForLabel(eventLabel, "size-3.5 text-primary-variant")}
{getIconForLabel(
eventLabel,
"object",
"size-3.5 text-primary-variant",
)}
</span>
)}
@ -417,7 +421,7 @@ const WsMessageRow = memo(function WsMessageRow({
<CopyJsonButton payload={parsedPayload} />
</div>
<pre
className="font-mono scrollbar-container max-h-[60vh] overflow-auto rounded bg-background p-2 text-[11px] leading-relaxed"
className="scrollbar-container max-h-[60vh] overflow-auto rounded bg-background p-2 font-mono text-[11px] leading-relaxed"
dangerouslySetInnerHTML={{ __html: highlightJson(parsedPayload) }}
/>
</div>

View File

@ -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;
}
}
}