Reviewed buttons (#10271)

* mark items as reviewed when they are opened

* Update api to use json and add button to mark all as reviewed

* fix api so last24 hours has its own review summary

* fix sidebar spacing

* formatting

* Bug fixes

* Make motion activity respect filters
This commit is contained in:
Nicolas Mowen 2024-03-05 17:39:37 -07:00 committed by GitHub
parent b5edcd2fae
commit 68ed18d3f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 219 additions and 75 deletions

View File

@ -76,11 +76,112 @@ def review():
def review_summary(): def review_summary():
tz_name = request.args.get("timezone", default="utc", type=str) tz_name = request.args.get("timezone", default="utc", type=str)
hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name) hour_modifier, minute_modifier, seconds_offset = get_tz_modifiers(tz_name)
day_ago = (datetime.now() - timedelta(hours=24)).timestamp()
month_ago = (datetime.now() - timedelta(days=30)).timestamp() month_ago = (datetime.now() - timedelta(days=30)).timestamp()
cameras = request.args.get("cameras", "all") cameras = request.args.get("cameras", "all")
labels = request.args.get("labels", "all") labels = request.args.get("labels", "all")
clauses = [(ReviewSegment.start_time > day_ago)]
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((ReviewSegment.camera << camera_list))
if labels != "all":
# use matching so segments with multiple labels
# still match on a search where any label matches
label_clauses = []
filtered_labels = labels.split(",")
for label in filtered_labels:
label_clauses.append(
(ReviewSegment.data["objects"].cast("text") % f'*"{label}"*')
)
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
last_24 = (
ReviewSegment.select(
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "alert"),
ReviewSegment.has_been_reviewed,
)
],
0,
)
).alias("reviewed_alert"),
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "detection"),
ReviewSegment.has_been_reviewed,
)
],
0,
)
).alias("reviewed_detection"),
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "significant_motion"),
ReviewSegment.has_been_reviewed,
)
],
0,
)
).alias("reviewed_motion"),
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "alert"),
1,
)
],
0,
)
).alias("total_alert"),
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "detection"),
1,
)
],
0,
)
).alias("total_detection"),
fn.SUM(
Case(
None,
[
(
(ReviewSegment.severity == "significant_motion"),
1,
)
],
0,
)
).alias("total_motion"),
)
.where(reduce(operator.and_, clauses))
.dicts()
.get()
)
clauses = [(ReviewSegment.start_time > month_ago)] clauses = [(ReviewSegment.start_time > month_ago)]
if cameras != "all": if cameras != "all":
@ -101,7 +202,7 @@ def review_summary():
label_clause = reduce(operator.or_, label_clauses) label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause)) clauses.append((label_clause))
groups = ( last_month = (
ReviewSegment.select( ReviewSegment.select(
fn.strftime( fn.strftime(
"%Y-%m-%d", "%Y-%m-%d",
@ -192,29 +293,20 @@ def review_summary():
.order_by(ReviewSegment.start_time.desc()) .order_by(ReviewSegment.start_time.desc())
) )
return jsonify([e for e in groups.dicts().iterator()]) data = {
"last24Hours": last_24,
}
for e in last_month.dicts().iterator():
data[e["day"]] = e
return jsonify(data)
@ReviewBp.route("/review/<id>/viewed", methods=("POST",)) @ReviewBp.route("/reviews/viewed", methods=("POST",))
def set_reviewed(id): def set_multiple_reviewed():
try: json: dict[str, any] = request.get_json(silent=True) or {}
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id) list_of_ids = json.get("ids", "")
except DoesNotExist:
return make_response(
jsonify({"success": False, "message": "Review " + id + " not found"}), 404
)
review.has_been_reviewed = True
review.save()
return make_response(
jsonify({"success": True, "message": "Reviewed " + id + " viewed"}), 200
)
@ReviewBp.route("/reviews/<ids>/viewed", methods=("POST",))
def set_multiple_reviewed(ids: str):
list_of_ids = ids.split(",")
if not list_of_ids or len(list_of_ids) == 0: if not list_of_ids or len(list_of_ids) == 0:
return make_response( return make_response(
@ -264,13 +356,17 @@ def delete_reviews(ids: str):
@ReviewBp.route("/review/activity") @ReviewBp.route("/review/activity")
def review_activity(): def review_activity():
"""Get motion and audio activity.""" """Get motion and audio activity."""
cameras = request.args.get("cameras", "all")
before = request.args.get("before", type=float, default=datetime.now().timestamp()) before = request.args.get("before", type=float, default=datetime.now().timestamp())
after = request.args.get( after = request.args.get(
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp() "after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
) )
# get scale in seconds clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
scale = request.args.get("scale", type=int, default=30)
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list))
all_recordings: list[Recordings] = ( all_recordings: list[Recordings] = (
Recordings.select( Recordings.select(
@ -280,7 +376,7 @@ def review_activity():
Recordings.motion, Recordings.motion,
Recordings.dBFS, Recordings.dBFS,
) )
.where((Recordings.start_time > after) & (Recordings.end_time < before)) .where(reduce(operator.and_, clauses))
.order_by(Recordings.start_time.asc()) .order_by(Recordings.start_time.asc())
.iterator() .iterator()
) )
@ -298,6 +394,9 @@ def review_activity():
} }
) )
# get scale in seconds
scale = request.args.get("scale", type=int, default=30)
# resample data using pandas to get activity on scaled basis # resample data using pandas to get activity on scaled basis
df = pd.DataFrame(data, columns=["start_time", "motion", "audio"]) df = pd.DataFrame(data, columns=["start_time", "motion", "audio"])

View File

@ -22,7 +22,7 @@ export default function ReviewActionGroup({
const onMarkAsReviewed = useCallback(async () => { const onMarkAsReviewed = useCallback(async () => {
const idList = selectedReviews.join(","); const idList = selectedReviews.join(",");
await axios.post(`reviews/${idList}/viewed`); await axios.post(`reviews/viewed`, { ids: idList });
setSelectedReviews([]); setSelectedReviews([]);
pullLatestData(); pullLatestData();
}, [selectedReviews, setSelectedReviews, pullLatestData]); }, [selectedReviews, setSelectedReviews, pullLatestData]);

View File

@ -13,20 +13,23 @@ function Sidebar() {
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
<div className="w-full flex flex-col gap-0 items-center"> <div className="w-full flex flex-col gap-0 items-center">
<Logo className="w-8 h-8 mb-6" /> <Logo className="w-8 h-8 mb-6" />
{navbarLinks.map((item) => ( {navbarLinks.map((item) => {
const showCameraGroups =
item.id == 1 && item.url == location.pathname;
return (
<div key={item.id}> <div key={item.id}>
<NavItem <NavItem
className={`mx-[10px] ${item.id == 1 ? "mb-2" : "mb-4"}`} className={`mx-[10px] ${showCameraGroups ? "mb-2" : "mb-4"}`}
Icon={item.icon} Icon={item.icon}
title={item.title} title={item.title}
url={item.url} url={item.url}
dev={item.dev} dev={item.dev}
/> />
{item.id == 1 && item.url == location.pathname && ( {showCameraGroups && <CameraGroupSelector className="mb-4" />}
<CameraGroupSelector className="mb-4" />
)}
</div> </div>
))} );
})}
</div> </div>
<SettingsNavItems className="hidden md:flex flex-col items-center mb-8" /> <SettingsNavItems className="hidden md:flex flex-col items-center mb-8" />
</aside> </aside>

View File

@ -29,7 +29,7 @@ type DynamicVideoPlayerProps = {
timeRange: { start: number; end: number }; timeRange: { start: number; end: number };
cameraPreviews: Preview[]; cameraPreviews: Preview[];
previewOnly?: boolean; previewOnly?: boolean;
onControllerReady?: (controller: DynamicVideoController) => void; onControllerReady: (controller: DynamicVideoController) => void;
onClick?: () => void; onClick?: () => void;
}; };
export default function DynamicVideoPlayer({ export default function DynamicVideoPlayer({
@ -86,14 +86,17 @@ export default function DynamicVideoPlayer({
}, [camera, config, previewOnly]); }, [camera, config, previewOnly]);
useEffect(() => { useEffect(() => {
if (!controller) { if (!playerRef.current && !previewRef.current) {
return; return;
} }
if (onControllerReady) { if (controller) {
onControllerReady(controller); onControllerReady(controller);
} }
}, [controller, onControllerReady]);
// we only want to fire once when players are ready
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerRef, previewRef]);
const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true); const [hasRecordingAtTime, setHasRecordingAtTime] = useState(true);
@ -277,10 +280,6 @@ export default function DynamicVideoPlayer({
player.on("ended", () => player.on("ended", () =>
controller.fireClipChangeEvent("forward"), controller.fireClipChangeEvent("forward"),
); );
if (onControllerReady) {
onControllerReady(controller);
}
}} }}
onDispose={() => { onDispose={() => {
playerRef.current = undefined; playerRef.current = undefined;

View File

@ -10,6 +10,7 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
select: "bg-select text-white hover:bg-select/90",
destructive: destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90", "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: outline:

View File

@ -115,9 +115,7 @@ export default function Events() {
// review summary // review summary
const { data: reviewSummary, mutate: updateSummary } = useSWR< const { data: reviewSummary, mutate: updateSummary } = useSWR<ReviewSummary>([
ReviewSummary[]
>([
"review/summary", "review/summary",
{ {
timezone: timezone, timezone: timezone,
@ -164,7 +162,7 @@ export default function Events() {
const markItemAsReviewed = useCallback( const markItemAsReviewed = useCallback(
async (review: ReviewSegment) => { async (review: ReviewSegment) => {
const resp = await axios.post(`review/${review.id}/viewed`); const resp = await axios.post(`reviews/viewed`, { ids: [review.id] });
if (resp.status == 200) { if (resp.status == 200) {
updateSegments( updateSegments(
@ -197,23 +195,30 @@ export default function Events() {
); );
updateSummary( updateSummary(
(data: ReviewSummary[] | undefined) => { (data: ReviewSummary | undefined) => {
if (!data) { if (!data) {
return data; return data;
} }
const day = new Date(review.start_time * 1000); const day = new Date(review.start_time * 1000);
const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`; const today = new Date();
const index = data.findIndex((summary) => summary.day == key); today.setHours(0, 0, 0, 0);
if (index == -1) { let key;
if (day.getTime() > today.getTime()) {
key = "last24Hours";
} else {
key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
}
if (!Object.keys(data).includes(key)) {
return data; return data;
} }
const item = data[index]; const item = data[key];
return [ return {
...data.slice(0, index), ...data,
{ [key]: {
...item, ...item,
reviewed_alert: reviewed_alert:
review.severity == "alert" review.severity == "alert"
@ -228,8 +233,7 @@ export default function Events() {
? item.reviewed_motion + 1 ? item.reviewed_motion + 1
: item.reviewed_motion, : item.reviewed_motion,
}, },
...data.slice(index + 1), };
];
}, },
{ revalidate: false, populateCache: true }, { revalidate: false, populateCache: true },
); );
@ -279,6 +283,11 @@ export default function Events() {
return undefined; return undefined;
} }
// mark item as reviewed since it has been opened
if (!selectedReview?.has_been_reviewed) {
markItemAsReviewed(selectedReview);
}
return { return {
camera: selectedReview.camera, camera: selectedReview.camera,
severity: selectedReview.severity, severity: selectedReview.severity,

View File

@ -28,7 +28,7 @@ export type ReviewFilter = {
showReviewed?: 0 | 1; showReviewed?: 0 | 1;
}; };
export type ReviewSummary = { type ReviewSummaryDay = {
day: string; day: string;
reviewed_alert: number; reviewed_alert: number;
reviewed_detection: number; reviewed_detection: number;
@ -38,6 +38,10 @@ export type ReviewSummary = {
total_motion: number; total_motion: number;
}; };
export type ReviewSummary = {
[day: string]: ReviewSummaryDay;
};
export type MotionData = { export type MotionData = {
start_time: number; start_time: number;
motion: number; motion: number;

View File

@ -35,10 +35,11 @@ import { LuFolderCheck } from "react-icons/lu";
import { MdCircle } from "react-icons/md"; import { MdCircle } from "react-icons/md";
import useSWR from "swr"; import useSWR from "swr";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline"; import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import { Button } from "@/components/ui/button";
type EventViewProps = { type EventViewProps = {
reviewPages?: ReviewSegment[][]; reviewPages?: ReviewSegment[][];
reviewSummary?: ReviewSummary[]; reviewSummary?: ReviewSummary;
relevantPreviews?: Preview[]; relevantPreviews?: Preview[];
timeRange: { before: number; after: number }; timeRange: { before: number; after: number };
reachedEnd: boolean; reachedEnd: boolean;
@ -74,17 +75,17 @@ export default function EventView({
// review counts // review counts
const reviewCounts = useMemo(() => { const reviewCounts = useMemo(() => {
if (!reviewSummary || reviewSummary.length == 0) { if (!reviewSummary) {
return { alert: 0, detection: 0, significant_motion: 0 }; return { alert: 0, detection: 0, significant_motion: 0 };
} }
let summary; let summary;
if (filter?.before == undefined) { if (filter?.before == undefined) {
summary = reviewSummary[0]; summary = reviewSummary["last24Hours"];
} else { } else {
const day = new Date(filter.before * 1000); const day = new Date(filter.before * 1000);
const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`; const key = `${day.getFullYear()}-${("0" + (day.getMonth() + 1)).slice(-2)}-${("0" + day.getDate()).slice(-2)}`;
summary = reviewSummary.find((check) => check.day == key); summary = reviewSummary[key];
} }
if (!summary) { if (!summary) {
@ -211,9 +212,11 @@ export default function EventView({
<ToggleGroup <ToggleGroup
className="*:px-3 *:py-4 *:rounded-2xl" className="*:px-3 *:py-4 *:rounded-2xl"
type="single" type="single"
defaultValue="alert"
size="sm" size="sm"
onValueChange={(value: ReviewSeverity) => setSeverity(value)} value={severity}
onValueChange={(value: ReviewSeverity) =>
value ? setSeverity(value) : null
} // don't allow the severity to be unselected
> >
<ToggleGroupItem <ToggleGroupItem
className={`${severity == "alert" ? "" : "text-gray-500"}`} className={`${severity == "alert" ? "" : "text-gray-500"}`}
@ -241,9 +244,7 @@ export default function EventView({
aria-label="Select motion" aria-label="Select motion"
> >
<MdCircle className="size-2 md:mr-[10px] text-severity_motion" /> <MdCircle className="size-2 md:mr-[10px] text-severity_motion" />
<div className="hidden md:block"> <div className="hidden md:block">Motion</div>
Motion {reviewCounts.significant_motion}
</div>
</ToggleGroupItem> </ToggleGroupItem>
</ToggleGroup> </ToggleGroup>
@ -303,6 +304,7 @@ type DetectionReviewProps = {
detection: ReviewSegment[]; detection: ReviewSegment[];
significant_motion: ReviewSegment[]; significant_motion: ReviewSegment[];
}; };
itemsToReview?: number;
relevantPreviews?: Preview[]; relevantPreviews?: Preview[];
pagingObserver: MutableRefObject<IntersectionObserver | null>; pagingObserver: MutableRefObject<IntersectionObserver | null>;
selectedReviews: string[]; selectedReviews: string[];
@ -320,6 +322,7 @@ function DetectionReview({
contentRef, contentRef,
currentItems, currentItems,
reviewItems, reviewItems,
itemsToReview,
relevantPreviews, relevantPreviews,
pagingObserver, pagingObserver,
selectedReviews, selectedReviews,
@ -359,6 +362,17 @@ function DetectionReview({
[isValidating, pagingObserver, reachedEnd, loadNextPage], [isValidating, pagingObserver, reachedEnd, loadNextPage],
); );
const markAllReviewed = useCallback(async () => {
if (!currentItems) {
return;
}
await axios.post(`reviews/viewed`, {
ids: currentItems?.map((seg) => seg.id),
});
pullLatestData();
}, [currentItems, pullLatestData]);
// timeline interaction // timeline interaction
const { alignStartDateToTimeline } = useEventUtils( const { alignStartDateToTimeline } = useEventUtils(
@ -453,7 +467,7 @@ function DetectionReview({
/> />
)} )}
{!isValidating && currentItems == null && ( {(itemsToReview == 0 || (currentItems == null && !isValidating)) && (
<div className="size-full flex flex-col justify-center items-center"> <div className="size-full flex flex-col justify-center items-center">
<LuFolderCheck className="size-16" /> <LuFolderCheck className="size-16" />
There are no {severity.replace(/_/g, " ")} items to review There are no {severity.replace(/_/g, " ")} items to review
@ -489,13 +503,27 @@ function DetectionReview({
onClick={onSelectReview} onClick={onSelectReview}
/> />
</div> </div>
{lastRow && !reachedEnd && <ActivityIndicator />}
</div> </div>
); );
}) })
) : severity != "alert" ? ( ) : itemsToReview != 0 ? (
<div ref={lastReviewRef} /> <div ref={lastReviewRef} />
) : null} ) : null}
{currentItems && (
<div className="col-span-full flex justify-center items-center">
{reachedEnd ? (
<Button
className="text-white"
variant="select"
onClick={markAllReviewed}
>
Mark all items as reviewed
</Button>
) : (
<ActivityIndicator />
)}
</div>
)}
</div> </div>
</div> </div>
<div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar"> <div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
@ -574,6 +602,7 @@ function MotionReview({
before: timeRange.before, before: timeRange.before,
after: timeRange.after, after: timeRange.after,
scale: segmentDuration / 2, scale: segmentDuration / 2,
cameras: filter?.cameras?.join(",") ?? null,
}, },
]); ]);