Ability to manually create events through the API (#3184)

* Move to events package

* Improve handling of external events

* Handle external events in the event queue

* Pass in event processor

* Check event json

* Fix json parsing and change defaults

* Fix snapshot saving

* Hide % score when not available

* Correct docs and add json example

* Save event png db

* Adjust image

* Formatting

* Add catch for failure ending event

* Add init to modules

* Fix naming

* Formatting

* Fix http creation

* fix test

* Change to PUT and include response in docs

* Add ability to set bounding box locations in snapshot

* Support multiple box annotations

* Cleanup docs example response

Co-authored-by: Blake Blackshear <blake@frigate.video>

* Cleanup docs wording

Co-authored-by: Blake Blackshear <blake@frigate.video>

* Store full frame for thumbnail

* Formatting

* Set thumbnail height to 175

* Formatting

---------

Co-authored-by: Blake Blackshear <blake@frigate.video>
This commit is contained in:
Nicolas Mowen 2023-05-19 04:16:11 -06:00 committed by GitHub
parent 6d0c2ec5c8
commit e357715a8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 466 additions and 180 deletions

View File

@ -295,3 +295,41 @@ Get ffprobe output for camera feed paths.
### `GET /api/<camera_name>/ptz/info`
Get PTZ info for the camera.
### `POST /api/events/<camera_name>/<label>/create`
Create a manual API with a given `label` (ex: doorbell press) to capture a specific event besides an object being detected.
**Optional Body:**
```json
{
"subLabel": "some_string", // add sub label to event
"duration": 30, // predetermined length of event (default: 30 seconds) or can be to null for indeterminate length event
"include_recording": true, // whether the event should save recordings along with the snapshot that is taken
"draw": {
// optional annotations that will be drawn on the snapshot
"boxes": [
{
"box": [0.5, 0.5, 0.25, 0.25], // box consists of x, y, width, height which are on a scale between 0 - 1
"color": [255, 0, 0], // color of the box, default is red
"score": 100 // optional score associated with the box
}
]
}
}
```
**Success Response:**
```json
{
"event_id": "1682970645.13116-1ug7ns",
"message": "Successfully created event.",
"success": true
}
```
### `PUT /api/events/<event_id>/end`
End a specific manual event without a predetermined length.

View File

@ -28,7 +28,9 @@ from frigate.const import (
RECORD_DIR,
)
from frigate.object_detection import ObjectDetectProcess
from frigate.events import EventCleanup, EventProcessor
from frigate.events.cleanup import EventCleanup
from frigate.events.external import ExternalEventProcessor
from frigate.events.maintainer import EventProcessor
from frigate.http import create_app
from frigate.log import log_process, root_configurer
from frigate.models import Event, Recordings, Timeline
@ -204,6 +206,11 @@ class FrigateApp:
self.config, self.camera_metrics, self.detectors, self.processes
)
def init_external_event_processor(self) -> None:
self.external_event_processor = ExternalEventProcessor(
self.config, self.event_queue
)
def init_web_server(self) -> None:
self.flask_app = create_app(
self.config,
@ -212,6 +219,7 @@ class FrigateApp:
self.detected_frames_processor,
self.storage_maintainer,
self.onvif_controller,
self.external_event_processor,
self.plus_api,
)
@ -436,6 +444,7 @@ class FrigateApp:
self.start_camera_capture_processes()
self.start_storage_maintainer()
self.init_stats()
self.init_external_event_processor()
self.init_web_server()
self.start_timeline_processor()
self.start_event_processor()

View File

176
frigate/events/cleanup.py Normal file
View File

@ -0,0 +1,176 @@
"""Cleanup events based on configured retention."""
import datetime
import logging
import os
import threading
from pathlib import Path
from peewee import fn
from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR
from frigate.models import Event
from multiprocessing.synchronize import Event as MpEvent
logger = logging.getLogger(__name__)
class EventCleanup(threading.Thread):
def __init__(self, config: FrigateConfig, stop_event: MpEvent):
threading.Thread.__init__(self)
self.name = "event_cleanup"
self.config = config
self.stop_event = stop_event
self.camera_keys = list(self.config.cameras.keys())
def expire(self, media_type: str) -> None:
# TODO: Refactor media_type to enum
## Expire events from unlisted cameras based on the global config
if media_type == "clips":
retain_config = self.config.record.events.retain
file_extension = "mp4"
update_params = {"has_clip": False}
else:
retain_config = self.config.snapshots.retain
file_extension = "jpg"
update_params = {"has_snapshot": False}
distinct_labels = (
Event.select(Event.label)
.where(Event.camera.not_in(self.camera_keys))
.distinct()
)
# loop over object types in db
for l in distinct_labels:
# get expiration time for this label
expire_days = retain_config.objects.get(l.label, retain_config.default)
expire_after = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# grab all events after specific time
expired_events = Event.select().where(
Event.camera.not_in(self.camera_keys),
Event.start_time < expire_after,
Event.label == l.label,
Event.retain_indefinitely == False,
)
# delete the media from disk
for event in expired_events:
media_name = f"{event.camera}-{event.id}"
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
)
media_path.unlink(missing_ok=True)
if file_extension == "jpg":
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
)
media_path.unlink(missing_ok=True)
# update the clips attribute for the db entry
update_query = Event.update(update_params).where(
Event.camera.not_in(self.camera_keys),
Event.start_time < expire_after,
Event.label == l.label,
Event.retain_indefinitely == False,
)
update_query.execute()
## Expire events from cameras based on the camera config
for name, camera in self.config.cameras.items():
if media_type == "clips":
retain_config = camera.record.events.retain
else:
retain_config = camera.snapshots.retain
# get distinct objects in database for this camera
distinct_labels = (
Event.select(Event.label).where(Event.camera == name).distinct()
)
# loop over object types in db
for l in distinct_labels:
# get expiration time for this label
expire_days = retain_config.objects.get(l.label, retain_config.default)
expire_after = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# grab all events after specific time
expired_events = Event.select().where(
Event.camera == name,
Event.start_time < expire_after,
Event.label == l.label,
Event.retain_indefinitely == False,
)
# delete the grabbed clips from disk
for event in expired_events:
media_name = f"{event.camera}-{event.id}"
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
)
media_path.unlink(missing_ok=True)
if file_extension == "jpg":
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
)
media_path.unlink(missing_ok=True)
# update the clips attribute for the db entry
update_query = Event.update(update_params).where(
Event.camera == name,
Event.start_time < expire_after,
Event.label == l.label,
Event.retain_indefinitely == False,
)
update_query.execute()
def purge_duplicates(self) -> None:
duplicate_query = """with grouped_events as (
select id,
label,
camera,
has_snapshot,
has_clip,
row_number() over (
partition by label, camera, round(start_time/5,0)*5
order by end_time-start_time desc
) as copy_number
from event
)
select distinct id, camera, has_snapshot, has_clip from grouped_events
where copy_number > 1;"""
duplicate_events = Event.raw(duplicate_query)
for event in duplicate_events:
logger.debug(f"Removing duplicate: {event.id}")
media_name = f"{event.camera}-{event.id}"
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
media_path.unlink(missing_ok=True)
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
media_path.unlink(missing_ok=True)
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
media_path.unlink(missing_ok=True)
(
Event.delete()
.where(Event.id << [event.id for event in duplicate_events])
.execute()
)
def run(self) -> None:
# only expire events every 5 minutes
while not self.stop_event.wait(300):
self.expire("clips")
self.expire("snapshots")
self.purge_duplicates()
# drop events from db where has_clip and has_snapshot are false
delete_query = Event.delete().where(
Event.has_clip == False, Event.has_snapshot == False
)
delete_query.execute()
logger.info(f"Exiting event cleanup...")

132
frigate/events/external.py Normal file
View File

@ -0,0 +1,132 @@
"""Handle external events created by the user."""
import base64
import cv2
import datetime
import glob
import logging
import os
import random
import string
from typing import Optional
from multiprocessing.queues import Queue
from frigate.config import CameraConfig, FrigateConfig
from frigate.const import CLIPS_DIR
from frigate.events.maintainer import EventTypeEnum
from frigate.util import draw_box_with_label
logger = logging.getLogger(__name__)
class ExternalEventProcessor:
def __init__(self, config: FrigateConfig, queue: Queue) -> None:
self.config = config
self.queue = queue
self.default_thumbnail = None
def create_manual_event(
self,
camera: str,
label: str,
sub_label: Optional[str],
duration: Optional[int],
include_recording: bool,
draw: dict[str, any],
snapshot_frame: any,
) -> str:
now = datetime.datetime.now().timestamp()
camera_config = self.config.cameras.get(camera)
# create event id and start frame time
rand_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
event_id = f"{now}-{rand_id}"
thumbnail = self._write_images(
camera_config, label, event_id, draw, snapshot_frame
)
self.queue.put(
(
EventTypeEnum.api,
"new",
camera_config,
{
"id": event_id,
"label": label,
"sub_label": sub_label,
"camera": camera,
"start_time": now,
"end_time": now + duration if duration is not None else None,
"thumbnail": thumbnail,
"has_clip": camera_config.record.enabled and include_recording,
"has_snapshot": True,
},
)
)
return event_id
def finish_manual_event(self, event_id: str) -> None:
"""Finish external event with indeterminate duration."""
now = datetime.datetime.now().timestamp()
self.queue.put(
(EventTypeEnum.api, "end", None, {"id": event_id, "end_time": now})
)
def _write_images(
self,
camera_config: CameraConfig,
label: str,
event_id: str,
draw: dict[str, any],
img_frame: any,
) -> str:
# write clean snapshot if enabled
if camera_config.snapshots.clean_copy:
ret, png = cv2.imencode(".png", img_frame)
if ret:
with open(
os.path.join(
CLIPS_DIR,
f"{camera_config.name}-{event_id}-clean.png",
),
"wb",
) as p:
p.write(png.tobytes())
# write jpg snapshot with optional annotations
if draw.get("boxes") and isinstance(draw.get("boxes"), list):
for box in draw.get("boxes"):
x = box["box"][0] * camera_config.detect.width
y = box["box"][1] * camera_config.detect.height
width = box["box"][2] * camera_config.detect.width
height = box["box"][3] * camera_config.detect.height
draw_box_with_label(
img_frame,
x,
y,
x + width,
y + height,
label,
f"{box.get('score', '-')}% {int(width * height)}",
thickness=2,
color=box.get("color", (255, 0, 0)),
)
ret, jpg = cv2.imencode(".jpg", img_frame)
with open(
os.path.join(CLIPS_DIR, f"{camera_config.name}-{event_id}.jpg"),
"wb",
) as j:
j.write(jpg.tobytes())
# create thumbnail with max height of 175 and save
width = int(175 * img_frame.shape[1] / img_frame.shape[0])
thumb = cv2.resize(img_frame, dsize=(width, 175), interpolation=cv2.INTER_AREA)
ret, jpg = cv2.imencode(".jpg", thumb)
return base64.b64encode(jpg.tobytes()).decode("utf-8")

View File

@ -1,16 +1,13 @@
import datetime
import logging
import os
import queue
import threading
from enum import Enum
from pathlib import Path
from peewee import fn
from frigate.config import EventsConfig, FrigateConfig
from frigate.const import CLIPS_DIR
from frigate.models import Event
from frigate.types import CameraMetricsTypes
from frigate.util import to_relative_box
@ -23,7 +20,7 @@ logger = logging.getLogger(__name__)
class EventTypeEnum(str, Enum):
# api = "api"
api = "api"
# audio = "audio"
tracked_object = "tracked_object"
@ -97,6 +94,8 @@ class EventProcessor(threading.Thread):
continue
self.handle_object_detection(event_type, camera, event_data)
elif source_type == EventTypeEnum.api:
self.handle_external_detection(event_type, event_data)
# set an end_time on events without an end_time before exiting
Event.update(end_time=datetime.datetime.now().timestamp()).where(
@ -197,160 +196,35 @@ class EventProcessor(threading.Thread):
del self.events_in_process[event_data["id"]]
self.event_processed_queue.put((event_data["id"], camera))
def handle_external_detection(self, type: str, event_data: Event):
if type == "new":
event = {
Event.id: event_data["id"],
Event.label: event_data["label"],
Event.sub_label: event_data["sub_label"],
Event.camera: event_data["camera"],
Event.start_time: event_data["start_time"],
Event.end_time: event_data["end_time"],
Event.thumbnail: event_data["thumbnail"],
Event.has_clip: event_data["has_clip"],
Event.has_snapshot: event_data["has_snapshot"],
Event.zones: [],
Event.data: {},
}
elif type == "end":
event = {
Event.id: event_data["id"],
Event.end_time: event_data["end_time"],
}
class EventCleanup(threading.Thread):
def __init__(self, config: FrigateConfig, stop_event: MpEvent):
threading.Thread.__init__(self)
self.name = "event_cleanup"
self.config = config
self.stop_event = stop_event
self.camera_keys = list(self.config.cameras.keys())
def expire(self, media_type: str) -> None:
# TODO: Refactor media_type to enum
## Expire events from unlisted cameras based on the global config
if media_type == "clips":
retain_config = self.config.record.events.retain
file_extension = "mp4"
update_params = {"has_clip": False}
else:
retain_config = self.config.snapshots.retain
file_extension = "jpg"
update_params = {"has_snapshot": False}
distinct_labels = (
Event.select(Event.label)
.where(Event.camera.not_in(self.camera_keys))
.distinct()
)
# loop over object types in db
for l in distinct_labels:
# get expiration time for this label
expire_days = retain_config.objects.get(l.label, retain_config.default)
expire_after = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# grab all events after specific time
expired_events = Event.select().where(
Event.camera.not_in(self.camera_keys),
Event.start_time < expire_after,
Event.label == l.label,
Event.retain_indefinitely == False,
)
# delete the media from disk
for event in expired_events:
media_name = f"{event.camera}-{event.id}"
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
)
media_path.unlink(missing_ok=True)
if file_extension == "jpg":
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
)
media_path.unlink(missing_ok=True)
# update the clips attribute for the db entry
update_query = Event.update(update_params).where(
Event.camera.not_in(self.camera_keys),
Event.start_time < expire_after,
Event.label == l.label,
Event.retain_indefinitely == False,
)
update_query.execute()
## Expire events from cameras based on the camera config
for name, camera in self.config.cameras.items():
if media_type == "clips":
retain_config = camera.record.events.retain
else:
retain_config = camera.snapshots.retain
# get distinct objects in database for this camera
distinct_labels = (
Event.select(Event.label).where(Event.camera == name).distinct()
)
# loop over object types in db
for l in distinct_labels:
# get expiration time for this label
expire_days = retain_config.objects.get(l.label, retain_config.default)
expire_after = (
datetime.datetime.now() - datetime.timedelta(days=expire_days)
).timestamp()
# grab all events after specific time
expired_events = Event.select().where(
Event.camera == name,
Event.start_time < expire_after,
Event.label == l.label,
Event.retain_indefinitely == False,
)
# delete the grabbed clips from disk
for event in expired_events:
media_name = f"{event.camera}-{event.id}"
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}.{file_extension}"
)
media_path.unlink(missing_ok=True)
if file_extension == "jpg":
media_path = Path(
f"{os.path.join(CLIPS_DIR, media_name)}-clean.png"
)
media_path.unlink(missing_ok=True)
# update the clips attribute for the db entry
update_query = Event.update(update_params).where(
Event.camera == name,
Event.start_time < expire_after,
Event.label == l.label,
Event.retain_indefinitely == False,
)
update_query.execute()
def purge_duplicates(self) -> None:
duplicate_query = """with grouped_events as (
select id,
label,
camera,
has_snapshot,
has_clip,
row_number() over (
partition by label, camera, round(start_time/5,0)*5
order by end_time-start_time desc
) as copy_number
from event
)
select distinct id, camera, has_snapshot, has_clip from grouped_events
where copy_number > 1;"""
duplicate_events = Event.raw(duplicate_query)
for event in duplicate_events:
logger.debug(f"Removing duplicate: {event.id}")
media_name = f"{event.camera}-{event.id}"
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.jpg")
media_path.unlink(missing_ok=True)
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}-clean.png")
media_path.unlink(missing_ok=True)
media_path = Path(f"{os.path.join(CLIPS_DIR, media_name)}.mp4")
media_path.unlink(missing_ok=True)
try:
(
Event.delete()
.where(Event.id << [event.id for event in duplicate_events])
Event.insert(event)
.on_conflict(
conflict_target=[Event.id],
update=event,
)
.execute()
)
def run(self) -> None:
# only expire events every 5 minutes
while not self.stop_event.wait(300):
self.expire("clips")
self.expire("snapshots")
self.purge_duplicates()
# drop events from db where has_clip and has_snapshot are false
delete_query = Event.delete().where(
Event.has_clip == False, Event.has_snapshot == False
)
delete_query.execute()
logger.info(f"Exiting event cleanup...")
except Exception:
logger.warning(f"Failed to update manual event: {event_data['id']}")

View File

@ -1,8 +1,8 @@
import base64
from datetime import datetime, timedelta, timezone
import copy
import glob
import logging
import glob
import json
import os
import subprocess as sp
@ -34,6 +34,7 @@ from playhouse.shortcuts import model_to_dict
from frigate.config import FrigateConfig
from frigate.const import CLIPS_DIR, MAX_SEGMENT_DURATION, RECORD_DIR
from frigate.models import Event, Recordings, Timeline
from frigate.events.external import ExternalEventProcessor
from frigate.object_processing import TrackedObject
from frigate.plus import PlusApi
from frigate.ptz import OnvifController
@ -60,6 +61,7 @@ def create_app(
detected_frames_processor,
storage_maintainer: StorageMaintainer,
onvif: OnvifController,
external_processor: ExternalEventProcessor,
plus_api: PlusApi,
):
app = Flask(__name__)
@ -79,6 +81,7 @@ def create_app(
app.detected_frames_processor = detected_frames_processor
app.storage_maintainer = storage_maintainer
app.onvif = onvif
app.external_processor = external_processor
app.plus_api = plus_api
app.camera_error_image = None
app.hwaccel_errors = []
@ -195,7 +198,7 @@ def send_to_plus(id):
return make_response(jsonify({"success": False, "message": message}), 404)
# events from before the conversion to relative dimensions cant include annotations
if any(d > 1 for d in event.data["box"]):
if any(d > 1 for d in event.box):
include_annotation = None
if event.end_time is None:
@ -251,8 +254,8 @@ def send_to_plus(id):
event.save()
if not include_annotation is None:
region = event.data["region"]
box = event.data["box"]
region = event.region
box = event.box
try:
current_app.plus_api.add_annotation(
@ -293,7 +296,7 @@ def false_positive(id):
return make_response(jsonify({"success": False, "message": message}), 404)
# events from before the conversion to relative dimensions cant include annotations
if any(d > 1 for d in event.data["box"]):
if any(d > 1 for d in event.box):
message = f"Events prior to 0.13 cannot be submitted as false positives"
logger.error(message)
return make_response(jsonify({"success": False, "message": message}), 400)
@ -310,15 +313,11 @@ def false_positive(id):
# need to refetch the event now that it has a plus_id
event = Event.get(Event.id == id)
region = event.data["region"]
box = event.data["box"]
region = event.region
box = event.box
# provide top score if score is unavailable
score = (
(event.data["top_score"] if event.data["top_score"] else event.top_score)
if event.data["score"] is None
else event.data["score"]
)
score = event.top_score if event.score is None else event.score
try:
current_app.plus_api.add_false_positive(
@ -759,7 +758,6 @@ def events():
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
]
if camera != "all":
@ -848,6 +846,58 @@ def events():
return jsonify([model_to_dict(e, exclude=excluded_fields) for e in events])
@bp.route("/events/<camera_name>/<label>/create", methods=["POST"])
def create_event(camera_name, label):
if not camera_name or not current_app.frigate_config.cameras.get(camera_name):
return jsonify(
{"success": False, "message": f"{camera_name} is not a valid camera."}, 404
)
if not label:
return jsonify({"success": False, "message": f"{label} must be set."}, 404)
json: dict[str, any] = request.get_json(silent=True) or {}
try:
frame = current_app.detected_frames_processor.get_current_frame(camera_name)
event_id = current_app.external_processor.create_manual_event(
camera_name,
label,
json.get("sub_label", None),
json.get("duration", 30),
json.get("include_recording", True),
json.get("draw", {}),
frame,
)
except Exception as e:
logger.error(f"The error is {e}")
return jsonify(
{"success": False, "message": f"An unknown error occurred: {e}"}, 404
)
return jsonify(
{
"success": True,
"message": "Successfully created event.",
"event_id": event_id,
},
200,
)
@bp.route("/events/<event_id>/end", methods=["PUT"])
def end_event(event_id):
try:
current_app.external_processor.finish_manual_event(event_id)
except:
return jsonify(
{"success": False, "message": f"{event_id} must be set and valid."}, 404
)
return jsonify({"success": True, "message": f"Event successfully ended."}, 200)
@bp.route("/config")
def config():
config = current_app.frigate_config.dict()
@ -866,11 +916,6 @@ def config():
config["plus"] = {"enabled": current_app.plus_api.is_active()}
for detector, detector_config in config["detectors"].items():
detector_config["model"][
"labelmap"
] = current_app.frigate_config.model.merged_labelmap
return jsonify(config)

View File

@ -21,7 +21,7 @@ from frigate.config import (
FrigateConfig,
)
from frigate.const import CLIPS_DIR
from frigate.events import EventTypeEnum
from frigate.events.maintainer import EventTypeEnum
from frigate.util import (
SharedMemoryFrameManager,
calculate_region,

View File

View File

@ -120,6 +120,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
id = "123456.random"
@ -155,6 +156,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
id = "123456.random"
@ -175,6 +177,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
id = "123456.random"
@ -194,6 +197,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
id = "123456.random"
@ -215,6 +219,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
id = "123456.random"
@ -240,6 +245,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
id = "123456.random"
@ -274,6 +280,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
id = "123456.random"
@ -298,6 +305,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
@ -314,6 +322,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
id = "123456.random"
@ -333,6 +342,7 @@ class TestHttp(unittest.TestCase):
None,
None,
None,
None,
PlusApi(),
)
mock_stats.return_value = self.test_stats

View File

@ -5,7 +5,7 @@ import threading
import queue
from frigate.config import FrigateConfig
from frigate.events import EventTypeEnum
from frigate.events.maintainer import EventTypeEnum
from frigate.models import Timeline
from multiprocessing.queues import Queue

View File

@ -599,7 +599,9 @@ export default function Events({ path, ...props }) {
{event.sub_label
? `${event.label.replaceAll('_', ' ')}: ${event.sub_label.replaceAll('_', ' ')}`
: event.label.replaceAll('_', ' ')}
({((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%)
{(event?.data?.top_score || event.top_score || 0) == 0
? null
: ` (${((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%)`}
</div>
<div className="text-sm flex">
<Clock className="h-5 w-5 mr-2 inline" />