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():
tz_name = request.args.get("timezone", default="utc", type=str)
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()
cameras = request.args.get("cameras", "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)]
if cameras != "all":
@ -101,7 +202,7 @@ def review_summary():
label_clause = reduce(operator.or_, label_clauses)
clauses.append((label_clause))
groups = (
last_month = (
ReviewSegment.select(
fn.strftime(
"%Y-%m-%d",
@ -192,29 +293,20 @@ def review_summary():
.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",))
def set_reviewed(id):
try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == id)
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(",")
@ReviewBp.route("/reviews/viewed", methods=("POST",))
def set_multiple_reviewed():
json: dict[str, any] = request.get_json(silent=True) or {}
list_of_ids = json.get("ids", "")
if not list_of_ids or len(list_of_ids) == 0:
return make_response(
@ -264,13 +356,17 @@ def delete_reviews(ids: str):
@ReviewBp.route("/review/activity")
def review_activity():
"""Get motion and audio activity."""
cameras = request.args.get("cameras", "all")
before = request.args.get("before", type=float, default=datetime.now().timestamp())
after = request.args.get(
"after", type=float, default=(datetime.now() - timedelta(hours=1)).timestamp()
)
# get scale in seconds
scale = request.args.get("scale", type=int, default=30)
clauses = [(Recordings.start_time > after) & (Recordings.end_time < before)]
if cameras != "all":
camera_list = cameras.split(",")
clauses.append((Recordings.camera << camera_list))
all_recordings: list[Recordings] = (
Recordings.select(
@ -280,7 +376,7 @@ def review_activity():
Recordings.motion,
Recordings.dBFS,
)
.where((Recordings.start_time > after) & (Recordings.end_time < before))
.where(reduce(operator.and_, clauses))
.order_by(Recordings.start_time.asc())
.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
df = pd.DataFrame(data, columns=["start_time", "motion", "audio"])

View File

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

View File

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

View File

@ -29,7 +29,7 @@ type DynamicVideoPlayerProps = {
timeRange: { start: number; end: number };
cameraPreviews: Preview[];
previewOnly?: boolean;
onControllerReady?: (controller: DynamicVideoController) => void;
onControllerReady: (controller: DynamicVideoController) => void;
onClick?: () => void;
};
export default function DynamicVideoPlayer({
@ -86,14 +86,17 @@ export default function DynamicVideoPlayer({
}, [camera, config, previewOnly]);
useEffect(() => {
if (!controller) {
if (!playerRef.current && !previewRef.current) {
return;
}
if (onControllerReady) {
if (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);
@ -277,10 +280,6 @@ export default function DynamicVideoPlayer({
player.on("ended", () =>
controller.fireClipChangeEvent("forward"),
);
if (onControllerReady) {
onControllerReady(controller);
}
}}
onDispose={() => {
playerRef.current = undefined;

View File

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

View File

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

View File

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

View File

@ -35,10 +35,11 @@ import { LuFolderCheck } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import useSWR from "swr";
import MotionReviewTimeline from "@/components/timeline/MotionReviewTimeline";
import { Button } from "@/components/ui/button";
type EventViewProps = {
reviewPages?: ReviewSegment[][];
reviewSummary?: ReviewSummary[];
reviewSummary?: ReviewSummary;
relevantPreviews?: Preview[];
timeRange: { before: number; after: number };
reachedEnd: boolean;
@ -74,17 +75,17 @@ export default function EventView({
// review counts
const reviewCounts = useMemo(() => {
if (!reviewSummary || reviewSummary.length == 0) {
if (!reviewSummary) {
return { alert: 0, detection: 0, significant_motion: 0 };
}
let summary;
if (filter?.before == undefined) {
summary = reviewSummary[0];
summary = reviewSummary["last24Hours"];
} else {
const day = new Date(filter.before * 1000);
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) {
@ -211,9 +212,11 @@ export default function EventView({
<ToggleGroup
className="*:px-3 *:py-4 *:rounded-2xl"
type="single"
defaultValue="alert"
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
className={`${severity == "alert" ? "" : "text-gray-500"}`}
@ -241,9 +244,7 @@ export default function EventView({
aria-label="Select motion"
>
<MdCircle className="size-2 md:mr-[10px] text-severity_motion" />
<div className="hidden md:block">
Motion {reviewCounts.significant_motion}
</div>
<div className="hidden md:block">Motion</div>
</ToggleGroupItem>
</ToggleGroup>
@ -303,6 +304,7 @@ type DetectionReviewProps = {
detection: ReviewSegment[];
significant_motion: ReviewSegment[];
};
itemsToReview?: number;
relevantPreviews?: Preview[];
pagingObserver: MutableRefObject<IntersectionObserver | null>;
selectedReviews: string[];
@ -320,6 +322,7 @@ function DetectionReview({
contentRef,
currentItems,
reviewItems,
itemsToReview,
relevantPreviews,
pagingObserver,
selectedReviews,
@ -359,6 +362,17 @@ function DetectionReview({
[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
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">
<LuFolderCheck className="size-16" />
There are no {severity.replace(/_/g, " ")} items to review
@ -489,13 +503,27 @@ function DetectionReview({
onClick={onSelectReview}
/>
</div>
{lastRow && !reachedEnd && <ActivityIndicator />}
</div>
);
})
) : severity != "alert" ? (
) : itemsToReview != 0 ? (
<div ref={lastReviewRef} />
) : 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 className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
@ -574,6 +602,7 @@ function MotionReview({
before: timeRange.before,
after: timeRange.after,
scale: segmentDuration / 2,
cameras: filter?.cameras?.join(",") ?? null,
},
]);