mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-20 23:28:23 +03:00
camera and filter button and dropdown
This commit is contained in:
parent
14143c4b3e
commit
5ab85f257f
@ -21,11 +21,14 @@
|
|||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"filter": {
|
"filter": {
|
||||||
"all": "All topics",
|
"all": "All topics",
|
||||||
|
"topics": "Topics",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"camera_activity": "Camera activity",
|
"camera_activity": "Camera activity",
|
||||||
"system": "System",
|
"system": "System",
|
||||||
"camera": "Camera",
|
"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",
|
"empty": "No messages captured yet",
|
||||||
"count": "{{count}} messages",
|
"count": "{{count}} messages",
|
||||||
|
|||||||
@ -5,22 +5,28 @@ import { WsFeedMessage } from "@/api/ws";
|
|||||||
import { useWsMessageBuffer } from "@/hooks/use-ws-message-buffer";
|
import { useWsMessageBuffer } from "@/hooks/use-ws-message-buffer";
|
||||||
import WsMessageRow from "./WsMessageRow";
|
import WsMessageRow from "./WsMessageRow";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { 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 { 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"> = {
|
const PRESET_TOPICS: Record<TopicCategory, Set<string>> = {
|
||||||
all: "all",
|
|
||||||
events: new Set(["events", "reviews", "tracked_object_update", "triggers"]),
|
events: new Set(["events", "reviews", "tracked_object_update", "triggers"]),
|
||||||
camera_activity: new Set(["camera_activity", "audio_detections"]),
|
camera_activity: new Set(["camera_activity", "audio_detections"]),
|
||||||
system: new Set([
|
system: new Set([
|
||||||
@ -44,15 +50,26 @@ const CAMERA_ACTIVITY_TOPIC_PATTERNS = [
|
|||||||
"/ptz",
|
"/ptz",
|
||||||
];
|
];
|
||||||
|
|
||||||
function matchesPreset(topic: string, preset: TopicPreset): boolean {
|
function matchesCategories(
|
||||||
const topicSet = PRESET_TOPICS[preset];
|
topic: string,
|
||||||
if (topicSet === "all") return true;
|
categories: TopicCategory[] | undefined,
|
||||||
if (topicSet.has(topic)) return true;
|
): boolean {
|
||||||
|
// undefined means all topics
|
||||||
|
if (!categories) return true;
|
||||||
|
|
||||||
if (preset === "camera_activity") {
|
for (const cat of categories) {
|
||||||
return CAMERA_ACTIVITY_TOPIC_PATTERNS.some((pattern) =>
|
const topicSet = PRESET_TOPICS[cat];
|
||||||
topic.includes(pattern),
|
if (topicSet.has(topic)) return true;
|
||||||
);
|
|
||||||
|
if (cat === "camera_activity") {
|
||||||
|
if (
|
||||||
|
CAMERA_ACTIVITY_TOPIC_PATTERNS.some((pattern) =>
|
||||||
|
topic.includes(pattern),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@ -73,30 +90,43 @@ export default function WsMessageFeed({
|
|||||||
}: WsMessageFeedProps) {
|
}: WsMessageFeedProps) {
|
||||||
const { t } = useTranslation(["views/system"]);
|
const { t } = useTranslation(["views/system"]);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [topicPreset, setTopicPreset] = useState<TopicPreset>("all");
|
// undefined = all topics
|
||||||
const [cameraFilter, setCameraFilter] = useState<string>(
|
const [selectedTopics, setSelectedTopics] = useState<
|
||||||
lockedCamera ?? defaultCamera ?? "all",
|
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, {
|
const { messages, clear } = useWsMessageBuffer(maxSize, paused, {
|
||||||
cameraFilter,
|
cameraFilter: selectedCameras,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cameraNames = useMemo(() => {
|
const availableCameras = useMemo(() => {
|
||||||
if (!config?.cameras) return [];
|
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]);
|
}, [config]);
|
||||||
|
|
||||||
const filteredMessages = useMemo(() => {
|
const filteredMessages = useMemo(() => {
|
||||||
return messages.filter((msg: WsFeedMessage) => {
|
return messages.filter((msg: WsFeedMessage) => {
|
||||||
if (!matchesPreset(msg.topic, topicPreset)) return false;
|
if (!matchesCategories(msg.topic, selectedTopics)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [messages, topicPreset]);
|
}, [messages, selectedTopics]);
|
||||||
|
|
||||||
// Auto-scroll logic
|
// Auto-scroll logic
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@ -118,69 +148,23 @@ export default function WsMessageFeed({
|
|||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col">
|
<div className="flex size-full flex-col">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-row items-start justify-between gap-2 border-b border-secondary p-2">
|
<div className="flex flex-row flex-wrap items-center justify-between gap-2 border-b border-secondary p-2">
|
||||||
<div className="flex flex-col flex-wrap items-start gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||||
<ToggleGroup
|
<TopicFilterButton
|
||||||
type="single"
|
selectedTopics={selectedTopics}
|
||||||
size="sm"
|
updateTopicFilter={setSelectedTopics}
|
||||||
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>
|
|
||||||
|
|
||||||
{!lockedCamera && (
|
{!lockedCamera && (
|
||||||
<Select value={cameraFilter} onValueChange={setCameraFilter}>
|
<WsCamerasFilterButton
|
||||||
<SelectTrigger className="h-8 w-[140px] text-xs">
|
allCameras={availableCameras}
|
||||||
<SelectValue placeholder={t("logs.websocket.filter.camera")} />
|
selectedCameras={selectedCameras}
|
||||||
</SelectTrigger>
|
updateCameraFilter={setSelectedCameras}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<Badge variant="secondary" className="text-xs text-primary-variant">
|
||||||
{t("logs.websocket.count", {
|
{t("logs.websocket.count", {
|
||||||
count: filteredMessages.length,
|
count: filteredMessages.length,
|
||||||
@ -242,3 +226,332 @@ export default function WsMessageFeed({
|
|||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -207,7 +207,7 @@ function extractTypeForBadge(payload: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldShowTypeBadge(topic: string, type: string | null): boolean {
|
function shouldShowTypeBadge(type: string | null): boolean {
|
||||||
if (!type) return false;
|
if (!type) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -307,7 +307,7 @@ const WsMessageRow = memo(function WsMessageRow({
|
|||||||
const cameraName = extractCameraName(message);
|
const cameraName = extractCameraName(message);
|
||||||
|
|
||||||
const messageType = extractTypeForBadge(message.payload);
|
const messageType = extractTypeForBadge(message.payload);
|
||||||
const showTypeBadge = shouldShowTypeBadge(message.topic, messageType);
|
const showTypeBadge = shouldShowTypeBadge(messageType);
|
||||||
|
|
||||||
const summary = getPayloadSummary(message.topic, message.payload);
|
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)}
|
{formatTimestamp(message.timestamp)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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],
|
TOPIC_CATEGORY_COLORS[category],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -397,7 +397,11 @@ const WsMessageRow = memo(function WsMessageRow({
|
|||||||
|
|
||||||
{eventLabel && (
|
{eventLabel && (
|
||||||
<span className="shrink-0">
|
<span className="shrink-0">
|
||||||
{getIconForLabel(eventLabel, "size-3.5 text-primary-variant")}
|
{getIconForLabel(
|
||||||
|
eventLabel,
|
||||||
|
"object",
|
||||||
|
"size-3.5 text-primary-variant",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -417,7 +421,7 @@ const WsMessageRow = memo(function WsMessageRow({
|
|||||||
<CopyJsonButton payload={parsedPayload} />
|
<CopyJsonButton payload={parsedPayload} />
|
||||||
</div>
|
</div>
|
||||||
<pre
|
<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) }}
|
dangerouslySetInnerHTML={{ __html: highlightJson(parsedPayload) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ type UseWsMessageBufferReturn = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type MessageFilter = {
|
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(
|
export function useWsMessageBuffer(
|
||||||
@ -47,10 +47,20 @@ export function useWsMessageBuffer(
|
|||||||
if (!currentFilter) return true;
|
if (!currentFilter) return true;
|
||||||
|
|
||||||
// Check camera filter
|
// Check camera filter
|
||||||
if (currentFilter.cameraFilter && currentFilter.cameraFilter !== "all") {
|
const cf = currentFilter.cameraFilter;
|
||||||
const msgCamera = extractCameraName(msg);
|
if (cf !== undefined) {
|
||||||
if (msgCamera !== currentFilter.cameraFilter) {
|
if (Array.isArray(cf)) {
|
||||||
return false;
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user