mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-31 08:09:02 +03:00
Debug replay (#22212)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* debug replay implementation * fix masks after dev rebase * fix squash merge issues * fix * fix * fix * no need to write debug replay camera to config * camera and filter button and dropdown * add filters * add ability to edit motion and object config for debug replay * add debug draw overlay to debug replay * add guard to prevent crash when camera is no longer in camera_states * fix overflow due to radix absolutely positioned elements * increase number of messages * ensure deep_merge replaces existing list values when override is true * add back button * add debug replay to explore and review menus * clean up * clean up * update instructions to prevent exposing exception info * fix typing * refactor output logic * refactor with helper function * move init to function for consistency
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
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<TopicCategory, Set<string>> = {
|
||||
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<string, TopicCategory> = {
|
||||
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<string[] | undefined>(
|
||||
() => {
|
||||
if (lockedCamera) return [lockedCamera];
|
||||
if (defaultCamera) return [defaultCamera];
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
const { messages, clear } = useWsMessageBuffer(maxSize, paused, {
|
||||
cameraFilter: selectedCameras,
|
||||
});
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("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<HTMLDivElement>(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 (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<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 && (
|
||||
<WsCamerasFilterButton
|
||||
allCameras={availableCameras}
|
||||
selectedCameras={selectedCameras}
|
||||
updateCameraFilter={setSelectedCameras}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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,
|
||||
})}
|
||||
</Badge>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => setPaused(!paused)}
|
||||
aria-label={
|
||||
paused ? t("logs.websocket.resume") : t("logs.websocket.pause")
|
||||
}
|
||||
>
|
||||
{paused ? (
|
||||
<FaPlay className="size-2.5" />
|
||||
) : (
|
||||
<FaPause className="size-2.5" />
|
||||
)}
|
||||
{paused ? t("logs.websocket.resume") : t("logs.websocket.pause")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={clear}
|
||||
aria-label={t("logs.websocket.clear")}
|
||||
>
|
||||
<FaEraser className="size-2.5" />
|
||||
{t("logs.websocket.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed area */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="scrollbar-container flex-1 overflow-y-auto"
|
||||
>
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="flex size-full items-center justify-center p-8 text-sm text-muted-foreground">
|
||||
{t("logs.websocket.empty")}
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map((msg: WsFeedMessage) => (
|
||||
<WsMessageRow
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
showCameraBadge={showCameraBadge}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { WsFeedMessage } from "@/api/ws";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { extractCameraName } from "@/utils/wsUtil";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { LuCheck, LuCopy } from "react-icons/lu";
|
||||
|
||||
type TopicCategory = "events" | "camera_activity" | "system" | "other";
|
||||
|
||||
const TOPIC_CATEGORY_COLORS: Record<TopicCategory, string> = {
|
||||
events: "bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30",
|
||||
camera_activity:
|
||||
"bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
|
||||
system:
|
||||
"bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30",
|
||||
other: "bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30",
|
||||
};
|
||||
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
start:
|
||||
"bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
|
||||
update: "bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30",
|
||||
end: "bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30",
|
||||
};
|
||||
|
||||
const TRACKED_OBJECT_UPDATE_COLORS: Record<string, string> = {
|
||||
description:
|
||||
"bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30",
|
||||
face: "bg-pink-500/20 text-pink-700 dark:text-pink-300 border-pink-500/30",
|
||||
lpr: "bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 border-yellow-500/30",
|
||||
classification:
|
||||
"bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/30",
|
||||
};
|
||||
|
||||
function getEventTypeColor(eventType: string): string {
|
||||
return (
|
||||
EVENT_TYPE_COLORS[eventType] ||
|
||||
"bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30"
|
||||
);
|
||||
}
|
||||
|
||||
function getTrackedObjectTypeColor(objectType: string): string {
|
||||
return (
|
||||
TRACKED_OBJECT_UPDATE_COLORS[objectType] ||
|
||||
"bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30"
|
||||
);
|
||||
}
|
||||
|
||||
const EVENT_TOPICS = new Set([
|
||||
"events",
|
||||
"reviews",
|
||||
"tracked_object_update",
|
||||
"triggers",
|
||||
]);
|
||||
|
||||
const SYSTEM_TOPICS = new Set([
|
||||
"stats",
|
||||
"model_state",
|
||||
"job_state",
|
||||
"embeddings_reindex_progress",
|
||||
"audio_transcription_state",
|
||||
"birdseye_layout",
|
||||
]);
|
||||
|
||||
function getTopicCategory(topic: string): TopicCategory {
|
||||
if (EVENT_TOPICS.has(topic)) return "events";
|
||||
if (SYSTEM_TOPICS.has(topic)) return "system";
|
||||
if (
|
||||
topic === "camera_activity" ||
|
||||
topic === "audio_detections" ||
|
||||
topic.includes("/motion") ||
|
||||
topic.includes("/audio") ||
|
||||
topic.includes("/detect") ||
|
||||
topic.includes("/recordings") ||
|
||||
topic.includes("/enabled") ||
|
||||
topic.includes("/snapshots") ||
|
||||
topic.includes("/ptz")
|
||||
) {
|
||||
return "camera_activity";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
const ms = String(d.getMilliseconds()).padStart(3, "0");
|
||||
return `${hh}:${mm}:${ss}.${ms}`;
|
||||
}
|
||||
|
||||
function getPayloadSummary(
|
||||
topic: string,
|
||||
payload: unknown,
|
||||
hideType: boolean = false,
|
||||
): string {
|
||||
if (payload === null || payload === undefined) return "";
|
||||
|
||||
try {
|
||||
const data = typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||
|
||||
if (typeof data === "object" && data !== null) {
|
||||
// Topic-specific summary handlers
|
||||
if (topic === "tracked_object_update") {
|
||||
return getTrackedObjectUpdateSummary(data);
|
||||
}
|
||||
|
||||
if ("type" in data && "label" in (data.after || data)) {
|
||||
const after = data.after || data;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (!hideType) {
|
||||
parts.push(`type: ${data.type}`);
|
||||
}
|
||||
parts.push(`label: ${after.label || "?"}`);
|
||||
|
||||
// Add sub_label for events topic if present
|
||||
if (topic === "events" && after.sub_label) {
|
||||
parts.push(`sub_label: ${after.sub_label}`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
if ("type" in data && "camera" in data) {
|
||||
if (hideType) {
|
||||
return `camera: ${data.camera}`;
|
||||
}
|
||||
return `type: ${data.type}, camera: ${data.camera}`;
|
||||
}
|
||||
const keys = Object.keys(data);
|
||||
if (keys.length <= 3) {
|
||||
return keys
|
||||
.map((k) => {
|
||||
const v = data[k];
|
||||
if (typeof v === "string" || typeof v === "number") {
|
||||
return `${k}: ${v}`;
|
||||
}
|
||||
return k;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
return `{${keys.length} keys}`;
|
||||
}
|
||||
|
||||
const str = String(data);
|
||||
return str.length > 80 ? str.slice(0, 80) + "…" : str;
|
||||
} catch {
|
||||
const str = String(payload);
|
||||
return str.length > 80 ? str.slice(0, 80) + "…" : str;
|
||||
}
|
||||
}
|
||||
|
||||
function getTrackedObjectUpdateSummary(data: unknown): string {
|
||||
if (typeof data !== "object" || data === null) return "";
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
const type = obj.type as string;
|
||||
|
||||
switch (type) {
|
||||
case "description":
|
||||
return obj.description ? `${obj.description}` : "no description";
|
||||
|
||||
case "face": {
|
||||
const name = obj.name as string | undefined;
|
||||
return name || "unknown";
|
||||
}
|
||||
|
||||
case "lpr": {
|
||||
const name = obj.name as string | undefined;
|
||||
const plate = obj.plate as string | undefined;
|
||||
return name || plate || "unknown";
|
||||
}
|
||||
|
||||
case "classification": {
|
||||
const parts: string[] = [];
|
||||
const model = obj.model as string | undefined;
|
||||
const subLabel = obj.sub_label as string | undefined;
|
||||
const attribute = obj.attribute as string | undefined;
|
||||
|
||||
if (model) parts.push(`model: ${model}`);
|
||||
if (subLabel) parts.push(`sub_label: ${subLabel}`);
|
||||
if (attribute) parts.push(`attribute: ${attribute}`);
|
||||
|
||||
return parts.length > 0 ? parts.join(", ") : "classification";
|
||||
}
|
||||
|
||||
default:
|
||||
return type || "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function extractTypeForBadge(payload: unknown): string | null {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
|
||||
try {
|
||||
const data = typeof payload === "string" ? JSON.parse(payload) : payload;
|
||||
|
||||
if (typeof data === "object" && data !== null && "type" in data) {
|
||||
return data.type as string;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldShowTypeBadge(type: string | null): boolean {
|
||||
if (!type) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldShowSummary(topic: string): boolean {
|
||||
// Hide summary for reviews topic
|
||||
return topic !== "reviews";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function highlightJson(value: unknown): string {
|
||||
// Try to auto-parse JSON strings
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
value = parsed;
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
}
|
||||
|
||||
const raw = JSON.stringify(value, null, 2) ?? String(value);
|
||||
|
||||
// Single regex pass to colorize JSON tokens
|
||||
return raw.replace(
|
||||
/("(?:[^"\\]|\\.)*")\s*:|("(?:[^"\\]|\\.)*")|(true|false|null)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
|
||||
(match, key: string, str: string, keyword: string, num: string) => {
|
||||
if (key) {
|
||||
return `<span class="text-indigo-400">${escapeHtml(key)}</span>:`;
|
||||
}
|
||||
if (str) {
|
||||
const content = escapeHtml(str);
|
||||
return `<span class="text-green-500">${content}</span>`;
|
||||
}
|
||||
if (keyword) {
|
||||
return `<span class="text-orange-500">${keyword}</span>`;
|
||||
}
|
||||
if (num) {
|
||||
return `<span class="text-cyan-500">${num}</span>`;
|
||||
}
|
||||
return match;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function CopyJsonButton({ payload }: { payload: unknown }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const text =
|
||||
typeof payload === "string"
|
||||
? payload
|
||||
: JSON.stringify(payload, null, 2);
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
},
|
||||
[payload],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Copy JSON"
|
||||
>
|
||||
{copied ? (
|
||||
<LuCheck className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<LuCopy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type WsMessageRowProps = {
|
||||
message: WsFeedMessage;
|
||||
showCameraBadge?: boolean;
|
||||
};
|
||||
|
||||
const WsMessageRow = memo(function WsMessageRow({
|
||||
message,
|
||||
showCameraBadge = true,
|
||||
}: WsMessageRowProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const category = getTopicCategory(message.topic);
|
||||
|
||||
const cameraName = extractCameraName(message);
|
||||
|
||||
const messageType = extractTypeForBadge(message.payload);
|
||||
const showTypeBadge = shouldShowTypeBadge(messageType);
|
||||
|
||||
const summary = getPayloadSummary(message.topic, message.payload);
|
||||
|
||||
const eventLabel = (() => {
|
||||
try {
|
||||
const data =
|
||||
typeof message.payload === "string"
|
||||
? JSON.parse(message.payload)
|
||||
: message.payload;
|
||||
if (typeof data === "object" && data !== null) {
|
||||
return (data.after?.label as string) || (data.label as string) || null;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const parsedPayload = (() => {
|
||||
try {
|
||||
return typeof message.payload === "string"
|
||||
? JSON.parse(message.payload)
|
||||
: message.payload;
|
||||
} catch {
|
||||
return message.payload;
|
||||
}
|
||||
})();
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Determine which color function to use based on topic
|
||||
const getTypeBadgeColor = (type: string | null) => {
|
||||
if (!type) return "";
|
||||
if (message.topic === "tracked_object_update") {
|
||||
return getTrackedObjectTypeColor(type);
|
||||
}
|
||||
return getEventTypeColor(type);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-secondary/50">
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 px-2 py-1.5 transition-colors hover:bg-muted/50",
|
||||
expanded && "bg-muted/30",
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-muted-foreground transition-transform",
|
||||
expanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded border px-1.5 py-0.5 font-mono text-xs",
|
||||
TOPIC_CATEGORY_COLORS[category],
|
||||
)}
|
||||
>
|
||||
{message.topic}
|
||||
</span>
|
||||
|
||||
{showTypeBadge && messageType && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded border px-1.5 py-0.5 text-xs",
|
||||
getTypeBadgeColor(messageType),
|
||||
)}
|
||||
>
|
||||
{messageType}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showCameraBadge && cameraName && (
|
||||
<span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
|
||||
{cameraName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{eventLabel && (
|
||||
<span className="shrink-0">
|
||||
{getIconForLabel(
|
||||
eventLabel,
|
||||
"object",
|
||||
"size-3.5 text-primary-variant",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{shouldShowSummary(message.topic) && (
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground">
|
||||
{summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-secondary/30 bg-background_alt/50 px-4 py-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t("logs.websocket.expanded.payload")}
|
||||
</span>
|
||||
<CopyJsonButton payload={parsedPayload} />
|
||||
</div>
|
||||
<pre
|
||||
className="scrollbar-container max-h-[60vh] overflow-auto rounded bg-background p-2 font-mono text-[11px] leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: highlightJson(parsedPayload) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default WsMessageRow;
|
||||
Reference in New Issue
Block a user