mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-07 11:45:24 +03:00
Show recent events on dashboard
This commit is contained in:
parent
9f00dccdba
commit
182d0f13e8
83
web-new/src/components/card/MiniEventCard.tsx
Normal file
83
web-new/src/components/card/MiniEventCard.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { useApiHost } from "@/api";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { Event as FrigateEvent } from "@/types/event";
|
||||||
|
import { LuClock, LuStar } from "react-icons/lu";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import TimeAgo from "../dynamic/TimeAgo";
|
||||||
|
import { HiOutlineVideoCamera } from "react-icons/hi";
|
||||||
|
import { MdOutlineLocationOn } from "react-icons/md";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
type MiniEventCardProps = {
|
||||||
|
event: FrigateEvent;
|
||||||
|
onUpdate?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MiniEventCard({ event, onUpdate }: MiniEventCardProps) {
|
||||||
|
const baseUrl = useApiHost();
|
||||||
|
const onSave = useCallback(
|
||||||
|
async (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let response;
|
||||||
|
if (!event.retain_indefinitely) {
|
||||||
|
response = await axios.post(`events/${event.id}/retain`);
|
||||||
|
} else {
|
||||||
|
response = await axios.delete(`events/${event.id}/retain`);
|
||||||
|
}
|
||||||
|
if (response.status === 200 && onUpdate) {
|
||||||
|
onUpdate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[event]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mr-2 min-w-[260px] max-w-[320px]">
|
||||||
|
<div className="flex">
|
||||||
|
<div
|
||||||
|
className="relative rounded-l min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${baseUrl}api/events/${event.id}/thumbnail.jpg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LuStar
|
||||||
|
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
|
||||||
|
onClick={(e: Event) => onSave(e)}
|
||||||
|
fill={event.retain_indefinitely ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
{event.end_time ? null : (
|
||||||
|
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
|
||||||
|
In progress
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-1 flex flex-col justify-between">
|
||||||
|
<div className="capitalize text-lg font-bold">
|
||||||
|
{event.label.replaceAll("_", " ")}
|
||||||
|
{event.sub_label
|
||||||
|
? `: ${event.sub_label.replaceAll("_", " ")}`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm flex">
|
||||||
|
<LuClock className="h-4 w-4 mr-2 inline" />
|
||||||
|
<div className="hidden sm:inline">
|
||||||
|
<TimeAgo time={event.start_time * 1000} dense />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="capitalize text-sm flex align-center mt-1 whitespace-nowrap">
|
||||||
|
<HiOutlineVideoCamera className="h-4 w-4 mr-2 inline" />
|
||||||
|
{event.camera.replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
{event.zones.length ? (
|
||||||
|
<div className="capitalize text-sm flex align-center">
|
||||||
|
<MdOutlineLocationOn className="w-4 h-4 mr-2 inline" />
|
||||||
|
{event.zones.join(", ").replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
web-new/src/components/dynamic/TimeAgo.tsx
Normal file
83
web-new/src/components/dynamic/TimeAgo.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { FunctionComponent, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
interface IProp {
|
||||||
|
/** The time to calculate time-ago from */
|
||||||
|
time: number;
|
||||||
|
/** OPTIONAL: overwrite current time */
|
||||||
|
currentTime?: Date;
|
||||||
|
/** OPTIONAL: boolean that determines whether to show the time-ago text in dense format */
|
||||||
|
dense?: boolean;
|
||||||
|
/** OPTIONAL: set custom refresh interval in milliseconds, default 1000 (1 sec) */
|
||||||
|
refreshInterval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeUnit = {
|
||||||
|
unit: string;
|
||||||
|
full: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeAgo = ({ time, currentTime = new Date(), dense = false }: IProp): string => {
|
||||||
|
if (typeof time !== 'number' || time < 0) return 'Invalid Time Provided';
|
||||||
|
|
||||||
|
const pastTime: Date = new Date(time);
|
||||||
|
const elapsedTime: number = currentTime.getTime() - pastTime.getTime();
|
||||||
|
|
||||||
|
const timeUnits: TimeUnit[] = [
|
||||||
|
{ unit: 'yr', full: 'year', value: 31536000 },
|
||||||
|
{ unit: 'mo', full: 'month', value: 0 },
|
||||||
|
{ unit: 'd', full: 'day', value: 86400 },
|
||||||
|
{ unit: 'h', full: 'hour', value: 3600 },
|
||||||
|
{ unit: 'm', full: 'minute', value: 60 },
|
||||||
|
{ unit: 's', full: 'second', value: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const elapsed: number = elapsedTime / 1000;
|
||||||
|
if (elapsed < 10) {
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < timeUnits.length; i++) {
|
||||||
|
// if months
|
||||||
|
if (i === 1) {
|
||||||
|
// Get the month and year for the time provided
|
||||||
|
const pastMonth = pastTime.getUTCMonth();
|
||||||
|
const pastYear = pastTime.getUTCFullYear();
|
||||||
|
|
||||||
|
// get current month and year
|
||||||
|
const currentMonth = currentTime.getUTCMonth();
|
||||||
|
const currentYear = currentTime.getUTCFullYear();
|
||||||
|
|
||||||
|
let monthDiff = (currentYear - pastYear) * 12 + (currentMonth - pastMonth);
|
||||||
|
|
||||||
|
// check if the time provided is the previous month but not exceeded 1 month ago.
|
||||||
|
if (currentTime.getUTCDate() < pastTime.getUTCDate()) {
|
||||||
|
monthDiff--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthDiff > 0) {
|
||||||
|
const unitAmount = monthDiff;
|
||||||
|
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
|
||||||
|
}
|
||||||
|
} else if (elapsed >= timeUnits[i].value) {
|
||||||
|
const unitAmount: number = Math.floor(elapsed / timeUnits[i].value);
|
||||||
|
return `${unitAmount}${dense ? timeUnits[i].unit : ` ${timeUnits[i].full}`}${dense ? '' : 's'} ago`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Invalid Time';
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimeAgo: FunctionComponent<IProp> = ({ refreshInterval = 1000, ...rest }): JSX.Element => {
|
||||||
|
const [currentTime, setCurrentTime] = useState<Date>(new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const intervalId: NodeJS.Timeout = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, refreshInterval);
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [refreshInterval]);
|
||||||
|
|
||||||
|
const timeAgoValue = useMemo(() => timeAgo({ currentTime, ...rest }), [currentTime, rest]);
|
||||||
|
|
||||||
|
return <span>{timeAgoValue}</span>;
|
||||||
|
};
|
||||||
|
export default TimeAgo;
|
||||||
@ -17,10 +17,23 @@ import { AiOutlinePicture } from "react-icons/ai";
|
|||||||
import { FaWalking } from "react-icons/fa";
|
import { FaWalking } from "react-icons/fa";
|
||||||
import { LuEar } from "react-icons/lu";
|
import { LuEar } from "react-icons/lu";
|
||||||
import { TbMovie } from "react-icons/tb";
|
import { TbMovie } from "react-icons/tb";
|
||||||
|
import MiniEventCard from "@/components/card/MiniEventCard";
|
||||||
|
import { Event } from "@/types/event";
|
||||||
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
const recentTimestamp = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(now.getMinutes() - 30);
|
||||||
|
return now.getTime() / 1000;
|
||||||
|
}, []);
|
||||||
|
const { data: events, mutate: updateEvents } = useSWR<Event[]>([
|
||||||
|
"events",
|
||||||
|
{ limit: 10, after: recentTimestamp },
|
||||||
|
]);
|
||||||
|
|
||||||
const sortedCameras = useMemo(() => {
|
const sortedCameras = useMemo(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return [];
|
return [];
|
||||||
@ -39,7 +52,27 @@ export function Dashboard() {
|
|||||||
|
|
||||||
{config && (
|
{config && (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
{events && events.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Heading as="h4">Recent Events</Heading>
|
||||||
|
<ScrollArea>
|
||||||
|
<div className="flex">
|
||||||
|
{events.map((event) => {
|
||||||
|
return (
|
||||||
|
<MiniEventCard
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
onUpdate={() => updateEvents()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Heading as="h4">Cameras</Heading>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||||
{sortedCameras.map((camera) => {
|
{sortedCameras.map((camera) => {
|
||||||
return <Camera key={camera.name} camera={camera} />;
|
return <Camera key={camera.name} camera={camera} />;
|
||||||
})}
|
})}
|
||||||
@ -73,9 +106,9 @@ function Camera({ camera }: { camera: CameraConfig }) {
|
|||||||
<CameraImage camera={camera.name} fitAspect={16 / 9} />
|
<CameraImage camera={camera.name} fitAspect={16 / 9} />
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<Heading className="capitalize p-2" as="h4">
|
<div className="text-lg capitalize p-2">
|
||||||
{camera.name.replaceAll("_", " ")}
|
{camera.name.replaceAll("_", " ")}
|
||||||
</Heading>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
25
web-new/src/types/event.ts
Normal file
25
web-new/src/types/event.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export interface Event {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
sub_label?: string;
|
||||||
|
camera: string;
|
||||||
|
start_time: number;
|
||||||
|
end_time?: number;
|
||||||
|
false_positive: boolean;
|
||||||
|
zones: string[];
|
||||||
|
thumbnail: string;
|
||||||
|
has_clip: boolean;
|
||||||
|
has_snapshot: boolean;
|
||||||
|
retain_indefinitely: boolean;
|
||||||
|
plus_id?: string;
|
||||||
|
model_hash?: string;
|
||||||
|
data: {
|
||||||
|
top_score: number;
|
||||||
|
score: number;
|
||||||
|
region: number[];
|
||||||
|
box: number[];
|
||||||
|
area: number;
|
||||||
|
ratio: number;
|
||||||
|
type: "object" | "audio" | "manual";
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user