Compare commits

...
2 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 39f76e4a2c Add a notice registry and System Health tab (#24178)
* add a notice registry and System Health tab

Problems Frigate detects on its own (ffmpeg crash loops, stuck detectors, failed model downloads, recordings deleted before their retention period) only ever existed as log lines. This adds a `NoticeRegistry` in the main process backed by two tables, an `update_notice` IPC topic so producers in other processes can reach it through the dispatcher, an admin-only API and websocket topic, and a Health tab that lists them. Kinds declare their own mode, severity, and category in one place: state notices are resolved by their producer, event notices are dismissed by the user.

* treat a prerelease as behind its final release

* fix notices clearing early

* rename menu items and update docs

* don't resolve the update notice on a failed version lookup
2026-09-03 14:58:13 -06:00
Nicolas MowenandGitHub b6cf95a844 GenAI Chat Improvements (#24173)
* Initial tool approval implementation

* Cleanups and fixes

* Improve robustness of loading case
2026-09-03 09:02:22 -05:00
68 changed files with 3852 additions and 324 deletions
+1 -1
View File
@@ -517,6 +517,6 @@ objects:
7. If descriptions are generated but the results are poor or inconsistent, look at the model and the context window.
- Empty fields, missing `shortSummary` values, or `Failed to parse review description` errors usually mean the model is not following the requested JSON schema. Smaller models struggle with structured output. Try a larger parameter size or one of the [recommended models](#recommended-local-models).
- Frigate calculates how many frames to send from the context size the provider reports. If your server reports a different value than it is actually running with, frames will be truncated or the request will fail. Pin the value by adding `context_size` under <NavPath path="Settings > Enrichments > Generative AI > Provider options" /> (`genai.<provider>.provider_options`), and for Ollama also confirm `options.num_ctx` there matches the context you have configured.
- Check **Review Description Speed** and **Object Description Speed** in <NavPath path="System metrics > Enrichments" />. If inference takes tens of seconds, requests will queue behind each other and descriptions will appear to stop. For Ollama, review `OLLAMA_NUM_PARALLEL`, `OLLAMA_MAX_QUEUE`, and `OLLAMA_MAX_LOADED_MODELS` so that concurrent requests from Frigate are handled the way you expect.
- Check **Review Description Speed** and **Object Description Speed** in <NavPath path="Health and Metrics > Enrichments" />. If inference takes tens of seconds, requests will queue behind each other and descriptions will appear to stop. For Ollama, review `OLLAMA_NUM_PARALLEL`, `OLLAMA_MAX_QUEUE`, and `OLLAMA_MAX_LOADED_MODELS` so that concurrent requests from Frigate are handled the way you expect.
</FaqItem>
@@ -697,7 +697,7 @@ lpr:
- You may need to adjust your `detection_threshold` if your plates are not being detected.
4. Ensure the characters on detected plates are being _recognized_.
- Check the **Plate recognition** inference time in Enrichment metrics (<NavPath path="System metrics > Enrichments" />). High inference times (> 100ms) could lead to poor recognition results, especially for dedicated LPR cameras where the plate crosses the frame quickly.
- Check the **Plate recognition** inference time in Enrichment metrics (<NavPath path="Health and Metrics > Enrichments" />). High inference times (> 100ms) could lead to poor recognition results, especially for dedicated LPR cameras where the plate crosses the frame quickly.
- Enable `debug_save_plates` to save images of detected text on plates to the clips directory (`/media/frigate/clips/lpr`). Ensure these images are readable and the text is clear.
- Watch the debug view to see plates recognized in real-time. For non-dedicated LPR cameras, the vehicle's label will change to the recognized plate when LPR is enabled and working.
- Adjust `recognition_threshold` settings per the suggestions [above](#advanced-configuration).
+1 -1
View File
@@ -134,7 +134,7 @@ Along with picking a detector for your hardware, you will choose a model's **inp
**Resolution (320x320 vs 640x640):** Frigate is optimized for `320x320` models, and `320x320` is the best choice for the vast majority of setups. Frigate is specifically designed to compensate for the smaller model by cropping a region of motion from the full frame and zooming into it before running detection, so a `320x320` model is actually _better_ at small and distant objects, not worse. A `640x640` model is slower and uses more resources, and its main benefit is fitting more objects into a single inference when many objects are spread across a large area. Recent versions of Frigate have improved support for `640x640` models, but `320x320` remains the recommended starting point for nearly all setups.
**Variant size (tiny/small/medium):** Larger variants are gradually more accurate but slower. Whether the difference is noticeable depends on your specific cameras and scenes. A good rule of thumb is to use the largest model your hardware can run without skipping detections, which you can monitor on the <NavPath path="System > Metrics > Cameras" /> page in the UI. Better accuracy only helps if your detector keeps up with the detection load across all cameras.
**Variant size (tiny/small/medium):** Larger variants are gradually more accurate but slower. Whether the difference is noticeable depends on your specific cameras and scenes. A good rule of thumb is to use the largest model your hardware can run without skipping detections, which you can monitor on the <NavPath path="Health and Metrics > Cameras" /> page in the UI. Better accuracy only helps if your detector keeps up with the detection load across all cameras.
**Acceptable inference time depends on your hardware.** Inference time alone does not tell the whole story, because different hardware has different capacity. A GPU can run multiple instances of the same model concurrently, so an inference time around 30ms can still keep up with several cameras. A Google Coral runs only a single instance of the model, so it needs a much lower inference time (around 10ms) to keep up.
+1 -1
View File
@@ -515,7 +515,7 @@ The storage usage Frigate reports will not exactly match what the operating syst
### How Frigate measures recording usage
The **Recordings** value on the Storage Metrics page (<NavPath path="System > Storage" />), and the per-camera **Camera Storage** breakdown, is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
The **Recordings** value on the Storage Metrics page (<NavPath path="Health and Metrics > Storage" />), and the per-camera **Camera Storage** breakdown, is the sum of the recording segment sizes Frigate has written, taken from Frigate's database. It is **not** computed by a scan of the disk. Frigate tracks usage this way by design: repeatedly walking the entire drive to total its size would keep hard drives spun up and add unnecessary I/O.
The disk **total** shown beside it, and the free-space figure Frigate uses to decide when to delete recordings, instead come from the operating system's report for the whole filesystem mounted at `/media/frigate`. As a result, the **Unused** value on the page is _total disk capacity minus Frigate's recordings_, not the drive's real free space, which will be lower whenever anything else is stored on the disk.
+1 -1
View File
@@ -60,7 +60,7 @@ If you’re running Frigate via Docker (recommended method), follow these steps:
```bash
docker logs frigate
```
- Visit the Frigate Web UI (default: `http://<your-ip>:5000`) to confirm the new version is running. The version number is displayed at the top of the System Metrics page.
- Visit the Frigate Web UI (default: `http://<your-ip>:5000`) to confirm the new version is running. The version number is displayed at the top of the Health and Metrics page.
### Notes
+5 -1
View File
@@ -121,7 +121,7 @@ You can still configure Frigate to use UDP by using ffmpeg input args or the pre
### Frigate is slow to start up with a "probing detect stream" message in the logs
When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under System Metrics.
When `detect.width` and `detect.height` are not set, Frigate probes each camera's detect stream on startup (and when saving the config) to auto-detect its resolution. For RTSP streams Frigate probes with ffprobe and automatically retries over TCP if UDP doesn't respond, with a 5 second timeout per attempt. A camera that cannot be reached over either transport will add up to ~10 seconds to startup before Frigate falls through with default dimensions, which may show up as width `0` and height `0` in Camera Probe Info under Health and Metrics.
To skip the probe entirely and make startup instant, set `detect.width` and `detect.height` explicitly in your camera config:
@@ -167,3 +167,7 @@ Frigate's object detection relies on a machine learning [model](../frigate/gloss
- If the false positive is always in the same fixed spot (like a statue or mailbox that reads as a person), add an [object filter mask](../configuration/masks.md#object-filter-masks) over that location.
Filters and masks only hide the incorrect result - they don't teach Frigate what the object actually is. For that, fine-tune your own model or use Frigate+.
### Where do I see problems Frigate has detected?
Open System > Health. The Notices list shows problems the backend has noticed on its own, such as a camera whose ffmpeg keeps crashing, a detector that had to be restarted, a model download that failed, or recordings being deleted before their retention period. Entries that describe a one-time event can be dismissed; entries that describe an ongoing condition clear themselves once it is fixed.
+1 -1
View File
@@ -15,7 +15,7 @@ When a stream won't play or behaves oddly, the most important first step is to f
### 1. Read the go2rtc logs
Access the go2rtc logs in the Frigate UI under <NavPath path="System Logs" /> in the sidebar (select the **go2rtc** tab). If go2rtc cannot connect to your camera you will usually see a clear error here: `401 Unauthorized` (bad or incorrectly encoded credentials), `Connection refused` / `timeout` (wrong IP, port, or the camera is at its connection limit), or `404 Not Found` (wrong RTSP path, or the referenced stream name does not exist).
Access the go2rtc logs in the Frigate UI under <NavPath path="Logs" /> in the sidebar (select the **go2rtc** tab). If go2rtc cannot connect to your camera you will usually see a clear error here: `401 Unauthorized` (bad or incorrectly encoded credentials), `Connection refused` / `timeout` (wrong IP, port, or the camera is at its connection limit), or `404 Not Found` (wrong RTSP path, or the referenced stream name does not exist).
### 2. Test the stream in the go2rtc web interface
+1 -1
View File
@@ -374,7 +374,7 @@ If segments are only ~1 second instead of ~10 seconds, the camera is sending cor
:::tip
You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the System → Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce.
You don't have to run `ffprobe` by hand to catch this. Open a camera's **Camera Probe Info** dialog (the info icon on the Health and Metrics → Cameras page) and check the **Keyframe analysis** section. It probes the record stream and flags sparse or variable keyframes, which is what smart/"+" codecs (H.264+/H.265+) and long keyframe intervals produce.
:::
+102
View File
@@ -4108,6 +4108,96 @@ paths:
security:
- frigateAdminAuth: []
x-required-role: admin
/notices:
get:
tags:
- Notices
summary: Get Notices
description: |-
**Access:** Admin role required.
Get the active notices, most severe first.
Args:
include_dismissed: Also return event notices the user has dismissed
Returns:
The active notices
operationId: get_notices_notices_get
parameters:
- name: include_dismissed
in: query
required: false
schema:
type: boolean
default: false
title: Include Dismissed
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/stats:
get:
tags:
- Notices
summary: Get Notice Stats
description: |-
**Access:** Admin role required.
Get lifetime occurrence counts per notice kind.
operationId: get_notice_stats_notices_stats_get
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
security:
- frigateAdminAuth: []
x-required-role: admin
/notices/{notice_id}/dismiss:
post:
tags:
- Notices
summary: Dismiss Notice
description: |-
**Access:** Admin role required.
Hide an event notice until it is raised again.
operationId: dismiss_notice_notices__notice_id__dismiss_post
parameters:
- name: notice_id
in: path
required: true
schema:
type: string
title: Notice Id
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
security:
- frigateAdminAuth: []
x-required-role: admin
/events:
get:
tags:
@@ -7762,6 +7852,18 @@ components:
description: Per-request thinking toggle. None means use the provider
default. Ignored by providers that do not expose a per-request
thinking switch.
tool_decisions:
additionalProperties:
type: string
enum:
- approve
- reject
type: object
title: Tool Decisions
description: Decisions for tool calls that paused for approval, keyed
by tool call ID. Send these with the conversation chain returned
alongside an approval request; rejected calls are reported to the
model as declined instead of being executed.
type: object
required:
- messages
+3
View File
@@ -1301,6 +1301,9 @@ async def delete_camera(
if request.app.dispatcher is not None:
request.app.dispatcher.clear_runtime_state_for_camera(camera_name)
if request.app.notice_registry is not None:
request.app.notice_registry.resolve_camera(camera_name)
# Publish removal to stop ffmpeg processes and clean up runtime state
request.app.config_publisher.publish_update(
CameraConfigUpdateTopic(CameraConfigUpdateEnum.remove, camera_name),
+495 -160
View File
@@ -10,6 +10,7 @@ from functools import reduce
from typing import Any, Literal
import cv2
import numpy as np
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel
@@ -23,6 +24,7 @@ from frigate.api.chat_util import (
chunk_content,
distance_to_score,
format_events_with_local_time,
format_local_time,
fuse_scores,
hydrate_event,
parse_iso_to_timestamp,
@@ -33,29 +35,44 @@ from frigate.api.defs.response.chat_response import (
ChatCompletionResponse,
ChatMessageResponse,
ToolCall,
ToolCallInvocation,
)
from frigate.api.defs.tags import Tags
from frigate.api.event import _build_attribute_filter_clause, events
from frigate.api.export import _build_export_job, _validate_export_source
from frigate.config import FrigateConfig
from frigate.config.classification import SemanticSearchModelEnum
from frigate.genai.prompts import (
build_chat_system_prompt,
get_attribute_classifications,
get_tool_definitions,
get_write_tool_names,
strip_tool_access,
)
from frigate.genai.utils import build_assistant_message_for_conversation
from frigate.genai.utils import (
build_assistant_message_for_conversation,
parse_tool_calls_from_message,
)
from frigate.jobs.export import ExportQueueFullError, start_export_job
from frigate.jobs.vlm_watch import (
get_vlm_watch_job,
start_vlm_watch_job,
stop_vlm_watch_job,
)
from frigate.models import Event
from frigate.models import Event, Export, ExportCase
from frigate.record.export import PlaybackSourceEnum
from frigate.util.file import get_event_thumbnail_bytes, load_event_snapshot_image
from frigate.util.object_names import get_categorized_object_names
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.chat])
# Tool result recorded for a rejected write tool call. Providers require a
# result for every requested call; the user's intent is conveyed in a
# follow-up user message built by _rejection_message.
TOOL_REJECTED_RESULT: dict[str, str] = {"error": "user_rejected"}
class ToolExecuteRequest(BaseModel):
"""Request model for tool execution."""
@@ -666,29 +683,39 @@ async def _get_live_frame_image_url(
frame = frame_processor.get_current_frame(camera, {})
if frame is None:
return None
height, width = frame.shape[:2]
target_height = 480
if height > target_height:
scale = target_height / height
frame = cv2.resize(
frame,
(int(width * scale), target_height),
interpolation=cv2.INTER_AREA,
)
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
return f"data:image/jpeg;base64,{b64}"
return _encode_frame_data_url(frame)
except Exception as e:
logger.debug("Failed to get live frame for %s: %s", camera, e)
return None
def _encode_frame_data_url(frame: np.ndarray, target_height: int = 480) -> str:
"""Downscale a BGR frame and encode it as a JPEG data URL for the model."""
height, width = frame.shape[:2]
if height > target_height:
scale = target_height / height
frame = cv2.resize(
frame,
(int(width * scale), target_height),
interpolation=cv2.INTER_AREA,
)
_, img_encoded = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
b64 = base64.b64encode(img_encoded.tobytes()).decode("utf-8")
return f"data:image/jpeg;base64,{b64}"
def _request_roles(request: Request) -> list[str]:
"""Roles from the auth proxy header, split on the configured separator."""
separator = request.app.frigate_config.proxy.separator
header = request.headers.get("remote-role", "")
return [r.strip() for r in header.split(separator) if r.strip()]
async def _execute_set_camera_state(
request: Request,
arguments: dict[str, Any],
) -> dict[str, Any]:
role = request.headers.get("remote-role", "")
if "admin" not in [r.strip() for r in role.split(",")]:
if "admin" not in _request_roles(request):
return {"error": "Admin privileges required to change camera settings."}
camera = arguments.get("camera", "").strip()
@@ -738,6 +765,189 @@ def _execute_get_categorized_object_names(
return {"names": names}
def _execute_get_export_cases(allowed_cameras: list[str]) -> dict[str, Any]:
"""List export cases with how many accessible exports each one holds."""
from peewee import fn
count_rows = (
Export.select(Export.export_case, fn.COUNT(Export.id))
.where(Export.camera << allowed_cameras, Export.export_case.is_null(False))
.group_by(Export.export_case)
.tuples()
)
counts = {case_id: count for case_id, count in count_rows}
cases: list[dict[str, Any]] = []
for case in ExportCase.select().order_by(ExportCase.created_at.desc()):
created_at = case.created_at
cases.append(
{
"id": case.id,
"name": case.name,
"description": case.description,
"created_at_local": format_local_time(created_at.timestamp())
if isinstance(created_at, datetime)
else str(created_at),
"export_count": counts.get(case.id, 0),
}
)
if not cases:
return {"cases": [], "message": "No export cases exist yet."}
return {"cases": cases}
async def _execute_create_export(
request: Request,
arguments: dict[str, Any],
allowed_cameras: list[str],
) -> dict[str, Any]:
"""Queue a recording export, optionally attached to an existing case."""
config = request.app.frigate_config
camera = (arguments.get("camera") or "").strip()
start_time = parse_iso_to_timestamp(arguments.get("start_time"))
end_time = parse_iso_to_timestamp(arguments.get("end_time"))
name = (arguments.get("name") or "").strip() or None
if not camera or start_time is None or end_time is None:
return {"error": "camera, start_time, and end_time are all required."}
if camera not in config.cameras:
return {"error": f"Camera '{camera}' not found."}
if camera not in allowed_cameras:
return {"error": f"Camera '{camera}' not found or access denied"}
if end_time <= start_time:
return {"error": "end_time must be after start_time."}
try:
playback_source = PlaybackSourceEnum(arguments.get("source") or "recordings")
except ValueError:
return {"error": "source must be 'recordings' or 'preview'."}
# Mirror the export API: attaching to an existing case is admin-only
# until case-level ACLs exist.
export_case_id = (arguments.get("export_case_id") or "").strip() or None
if export_case_id is not None:
if "admin" not in _request_roles(request):
return {"error": "Only admins can attach exports to an existing case."}
try:
ExportCase.get(ExportCase.id == export_case_id)
except ExportCase.DoesNotExist:
return {"error": f"Export case '{export_case_id}' not found."}
source_error = _validate_export_source(
camera, start_time, end_time, playback_source
)
if source_error is not None:
return {"error": source_error}
export_job = _build_export_job(
camera,
start_time,
end_time,
name,
None,
playback_source,
export_case_id,
chapters=config.cameras[camera].record.export.chapters,
)
try:
start_export_job(config, export_job)
except ExportQueueFullError:
return {"error": "Export queue is full. Try again once current exports finish."}
return {
"success": True,
"export_id": export_job.id,
"status": "queued",
"camera": camera,
"name": name,
"source": playback_source.value,
"start_time_local": format_local_time(start_time),
"end_time_local": format_local_time(end_time),
"export_case_id": export_case_id,
"message": "Export queued. It will appear on the Export page when finished.",
}
async def _execute_get_event_image(
request: Request,
arguments: dict[str, Any],
allowed_cameras: list[str],
) -> dict[str, Any]:
"""Attach an event's thumbnail or snapshot for a vision model to view."""
event_id = (arguments.get("event_id") or "").strip()
if not event_id:
return {"error": "event_id is required."}
image_type = arguments.get("image") or "thumbnail"
if image_type not in ("thumbnail", "snapshot"):
return {"error": "image must be 'thumbnail' or 'snapshot'."}
try:
event = Event.get(Event.id == event_id)
except Event.DoesNotExist:
return {"error": f"Could not find event {event_id}."}
if event.camera not in allowed_cameras:
return {"error": f"Event {event_id} not found or access denied"}
chat_client = request.app.genai_manager.chat_client
if chat_client is None or not chat_client.supports_vision:
return {
"error": (
"The configured chat model does not support vision, so images "
"cannot be viewed."
)
}
note = None
frame = None
if image_type == "snapshot":
if event.has_snapshot:
frame, _ = load_event_snapshot_image(event)
if frame is None:
note = "Snapshot not available; returning the thumbnail instead."
image_type = "thumbnail"
if frame is None:
thumbnail = get_event_thumbnail_bytes(event)
if thumbnail:
frame = cv2.imdecode(
np.frombuffer(thumbnail, dtype=np.uint8), cv2.IMREAD_COLOR
)
if frame is None:
return {"error": f"No image is available for event {event_id}."}
result: dict[str, Any] = {
"id": event.id,
"camera": event.camera,
"label": event.label,
"sub_label": event.sub_label,
"zones": event.zones,
"start_time_local": format_local_time(event.start_time),
"image": image_type,
}
if event.end_time is not None:
result["end_time_local"] = format_local_time(event.end_time)
description = (event.data or {}).get("description")
if description:
result["description"] = description
if note:
result["note"] = note
result["_image_url"] = _encode_frame_data_url(frame)
result["_image_text"] = (
f"Here is the {image_type} for event {event.id} "
f"({event.sub_label or event.label} on {event.camera})."
)
return result
async def _execute_tool_internal(
tool_name: str,
arguments: dict[str, Any],
@@ -793,11 +1003,18 @@ async def _execute_tool_internal(
return _execute_get_profile_status(request)
elif tool_name == "get_recap":
return _execute_get_recap(arguments, allowed_cameras)
elif tool_name == "get_export_cases":
return _execute_get_export_cases(allowed_cameras)
elif tool_name == "create_export":
return await _execute_create_export(request, arguments, allowed_cameras)
elif tool_name == "get_event_image":
return await _execute_get_event_image(request, arguments, allowed_cameras)
else:
logger.error(
"Tool call failed: unknown tool %r. Expected one of: search_objects, find_similar_objects, "
"get_categorized_object_names, get_live_context, start_camera_watch, stop_camera_watch, "
"get_profile_status, get_recap. Arguments received: %s",
"get_profile_status, get_recap, get_export_cases, create_export, get_event_image. "
"Arguments received: %s",
tool_name,
json.dumps(arguments),
)
@@ -1026,14 +1243,74 @@ def _execute_get_recap(
return {"error": "Failed to fetch recap data."}
def _pending_tool_calls_from_tail(
conversation: list[dict[str, Any]],
) -> list[dict[str, Any]] | None:
"""Return the tool calls of a trailing assistant message, if any.
A conversation that ends with an assistant message requesting tools is a
resume after an approval pause: the client sends the chain back with its
decisions and the loop runs those calls before asking the model again.
"""
if not conversation:
return None
tail = conversation[-1]
if tail.get("role") != "assistant" or not tail.get("tool_calls"):
return None
return parse_tool_calls_from_message(tail)
def _tool_calls_awaiting_approval(
pending_tool_calls: list[dict[str, Any]],
body: ChatCompletionRequest,
write_tools: set[str],
) -> list[dict[str, Any]]:
"""Return the write tool calls the user still has to decide on."""
return [
{
"id": tc["id"],
"name": tc["name"],
"arguments": tc.get("arguments") or {},
}
for tc in pending_tool_calls
if tc["name"] in write_tools and tc["id"] not in body.tool_decisions
]
def _rejection_message(tool_names: list[str]) -> dict[str, Any]:
"""User message telling the model a rejected call should not proceed.
Uses list-form content so the UI, which only renders string user
content, does not show it as something the user typed.
"""
names = ", ".join(name.replace("_", " ") for name in tool_names)
return {
"role": "user",
"content": [
{
"type": "text",
"text": (
f"I do not want to proceed with the {names} call. Ask me for "
"clarification or suggest adjustments instead of running it."
),
}
],
}
async def _execute_pending_tools(
pending_tool_calls: list[dict[str, Any]],
request: Request,
allowed_cameras: list[str],
decisions: dict[str, str] | None = None,
) -> tuple[list[ToolCall], list[dict[str, Any]], list[dict[str, Any]]]:
"""
Execute a list of tool calls.
Calls the user rejected (per `decisions`) are not executed; they get a
placeholder result and a user message saying not to proceed is appended
after the tool results.
Returns:
(ToolCall list for API response,
tool result dicts for conversation,
@@ -1042,10 +1319,28 @@ async def _execute_pending_tools(
tool_calls_out: list[ToolCall] = []
tool_results: list[dict[str, Any]] = []
extra_messages: list[dict[str, Any]] = []
rejected_tools: list[str] = []
for tool_call in pending_tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call.get("arguments") or {}
tool_call_id = tool_call["id"]
if decisions and decisions.get(tool_call_id) == "reject":
logger.debug(
"Tool %s (id: %s) was rejected by the user", tool_name, tool_call_id
)
rejected_tools.append(tool_name)
rejected_content = json.dumps(TOOL_REJECTED_RESULT)
tool_calls_out.append(
ToolCall(name=tool_name, arguments=tool_args, response=rejected_content)
)
tool_results.append(
{
"role": "tool",
"tool_call_id": tool_call_id,
"content": rejected_content,
}
)
continue
logger.debug(
f"Executing tool: {tool_name} (id: {tool_call_id}) with arguments: {json.dumps(tool_args, indent=2)}"
)
@@ -1079,17 +1374,21 @@ async def _execute_pending_tools(
if isinstance(evt, dict)
]
# Extract _image_url from get_live_context results — images can
# only be sent in user messages, not tool results
# Extract _image_url from tool results — images can only be sent
# in user messages, not tool results
if isinstance(tool_result, dict) and "_image_url" in tool_result:
image_url = tool_result.pop("_image_url")
image_text = tool_result.pop("_image_text", None) or (
"Here is the current live image from camera "
f"'{tool_result.get('camera', 'unknown')}'."
)
extra_messages.append(
{
"role": "user",
"content": [
{
"type": "text",
"text": f"Here is the current live image from camera '{tool_result.get('camera', 'unknown')}'.",
"text": image_text,
},
{
"type": "image_url",
@@ -1133,6 +1432,8 @@ async def _execute_pending_tools(
"content": error_content,
}
)
if rejected_tools:
extra_messages.append(_rejection_message(rejected_tools))
return (tool_calls_out, tool_results, extra_messages)
@@ -1179,6 +1480,8 @@ async def chat_completion(
attribute_classifications=attribute_classifications,
embeddings_language=_embeddings_language(config),
)
write_tools = get_write_tool_names(tools)
llm_tools = strip_tool_access(tools)
conversation = []
# Build the system message only when the client hasn't already pinned one.
@@ -1217,6 +1520,10 @@ async def chat_completion(
tool_calls: list[ToolCall] = []
max_iterations = body.max_tool_iterations
# Resume after an approval pause: run the tail's tool calls (honoring the
# client's decisions) before asking the model for anything new.
resume_pending = _pending_tool_calls_from_tail(conversation)
logger.debug(
f"Starting chat completion with {len(conversation)} message(s), "
f"{len(tools)} tool(s) available, max_iterations={max_iterations}"
@@ -1228,93 +1535,64 @@ async def chat_completion(
async def stream_body_llm():
nonlocal conversation, stream_iterations
pending: list[dict[str, Any]] | None = resume_pending
def _emit_chain(extra: list[dict[str, Any]] | None = None):
def _emit(payload: dict[str, Any]) -> bytes:
return json.dumps(payload).encode("utf-8") + b"\n"
def _emit_chain(extra: list[dict[str, Any]] | None = None) -> bytes:
# Return the full conversation (including the system message) so
# the client persists and replays it verbatim next turn.
chain = conversation + (extra or [])
return (
json.dumps({"type": "messages", "messages": chain}).encode("utf-8")
+ b"\n"
return _emit(
{"type": "messages", "messages": conversation + (extra or [])}
)
while stream_iterations < max_iterations:
if await request.is_disconnected():
logger.debug("Client disconnected, stopping chat stream")
return
logger.debug(
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
f"with {len(conversation)} message(s)"
)
async for event in genai_client.chat_with_tools_stream(
messages=conversation,
tools=tools if tools else None,
tool_choice="auto",
enable_thinking=body.enable_thinking,
):
if await request.is_disconnected():
logger.debug("Client disconnected, stopping chat stream")
return
kind, value = event
if kind == "content_delta":
yield (
json.dumps({"type": "content", "delta": value}).encode(
"utf-8"
)
+ b"\n"
)
elif kind == "reasoning_delta":
yield (
json.dumps({"type": "reasoning", "delta": value}).encode(
"utf-8"
)
+ b"\n"
)
elif kind == "stats":
yield (
json.dumps({"type": "stats", **value}).encode("utf-8")
+ b"\n"
)
elif kind == "message":
msg = value
if msg.get("finish_reason") == "error":
yield (
json.dumps(
if pending is None:
logger.debug(
f"Streaming LLM (iteration {stream_iterations + 1}/{max_iterations}) "
f"with {len(conversation)} message(s)"
)
async for event in genai_client.chat_with_tools_stream(
messages=conversation,
tools=llm_tools if llm_tools else None,
tool_choice="auto",
enable_thinking=body.enable_thinking,
):
if await request.is_disconnected():
logger.debug("Client disconnected, stopping chat stream")
return
kind, value = event
if kind == "content_delta":
yield _emit({"type": "content", "delta": value})
elif kind == "reasoning_delta":
yield _emit({"type": "reasoning", "delta": value})
elif kind == "stats":
yield _emit({"type": "stats", **value})
elif kind == "message":
msg = value
if msg.get("finish_reason") == "error":
yield _emit(
{
"type": "error",
"error": "An error occurred while processing your request.",
}
).encode("utf-8")
+ b"\n"
)
return
pending = msg.get("tool_calls")
if pending:
stream_iterations += 1
conversation.append(
build_assistant_message_for_conversation(
msg.get("content"), pending
)
)
if await request.is_disconnected():
logger.debug(
"Client disconnected before tool execution"
)
return
(
_executed_calls,
tool_results,
extra_msgs,
) = await _execute_pending_tools(
pending, request, allowed_cameras
)
conversation.extend(tool_results)
conversation.extend(extra_msgs)
# Emit the running chain so the client can render tool
# calls live and replay them verbatim next turn.
yield _emit_chain()
break
else:
requested = msg.get("tool_calls")
if requested:
stream_iterations += 1
conversation.append(
build_assistant_message_for_conversation(
msg.get("content"), requested
)
)
pending = requested
break
# Streaming never appends the final assistant message
# to the conversation, so add it to the chain.
yield _emit_chain(
@@ -1325,11 +1603,41 @@ async def chat_completion(
}
]
)
yield (json.dumps({"type": "done"}).encode("utf-8") + b"\n")
yield _emit({"type": "done"})
return
else:
if pending is None:
# The stream ended without a final message; nothing
# more to run.
break
awaiting = _tool_calls_awaiting_approval(pending, body, write_tools)
if awaiting:
# Pause before running write tools. The client shows the
# calls, collects decisions, and resends the chain.
yield _emit_chain()
yield _emit({"type": "approval_required", "tool_calls": awaiting})
yield _emit({"type": "done"})
return
if await request.is_disconnected():
logger.debug("Client disconnected before tool execution")
return
(
_executed_calls,
tool_results,
extra_msgs,
) = await _execute_pending_tools(
pending, request, allowed_cameras, decisions=body.tool_decisions
)
conversation.extend(tool_results)
conversation.extend(extra_msgs)
pending = None
# Emit the running chain so the client can render tool
# calls live and replay them verbatim next turn.
yield _emit_chain()
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
yield _emit_chain()
yield _emit({"type": "done"})
return StreamingResponse(
stream_body_llm(),
@@ -1338,102 +1646,129 @@ async def chat_completion(
)
try:
pending_tool_calls = resume_pending
while tool_iterations < max_iterations:
logger.debug(
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
f"with {len(conversation)} message(s) in conversation"
)
response = genai_client.chat_with_tools(
messages=conversation,
tools=tools if tools else None,
tool_choice="auto",
enable_thinking=body.enable_thinking,
)
if response.get("finish_reason") == "error":
logger.error("GenAI client returned an error")
return JSONResponse(
content={
"error": "An error occurred while processing your request.",
},
status_code=500,
)
conversation.append(
build_assistant_message_for_conversation(
response.get("content"), response.get("tool_calls")
)
)
pending_tool_calls = response.get("tool_calls")
if not pending_tool_calls:
if pending_tool_calls is None:
logger.debug(
f"Chat completion finished with final answer (iterations: {tool_iterations})"
f"Calling LLM (iteration {tool_iterations + 1}/{max_iterations}) "
f"with {len(conversation)} message(s) in conversation"
)
response = genai_client.chat_with_tools(
messages=conversation,
tools=llm_tools if llm_tools else None,
tool_choice="auto",
enable_thinking=body.enable_thinking,
)
final_content = response.get("content") or ""
if body.stream:
final_reasoning = response.get("reasoning")
if response.get("finish_reason") == "error":
logger.error("GenAI client returned an error")
return JSONResponse(
content={
"error": "An error occurred while processing your request.",
},
status_code=500,
)
chain = list(conversation)
conversation.append(
build_assistant_message_for_conversation(
response.get("content"), response.get("tool_calls")
)
)
async def stream_body() -> Any:
yield (
json.dumps({"type": "messages", "messages": chain}).encode(
"utf-8"
)
+ b"\n"
)
# Emit the full reasoning trace up front when the
# underlying client did not stream it
if final_reasoning:
pending_tool_calls = response.get("tool_calls")
if not pending_tool_calls:
logger.debug(
f"Chat completion finished with final answer (iterations: {tool_iterations})"
)
final_content = response.get("content") or ""
if body.stream:
final_reasoning = response.get("reasoning")
chain = list(conversation)
async def stream_body() -> Any:
yield (
json.dumps(
{"type": "reasoning", "delta": final_reasoning}
{"type": "messages", "messages": chain}
).encode("utf-8")
+ b"\n"
)
# Stream content in word-sized chunks for smooth UX
for part in chunk_content(final_content):
yield (
json.dumps({"type": "content", "delta": part}).encode(
"utf-8"
# Emit the full reasoning trace up front when the
# underlying client did not stream it
if final_reasoning:
yield (
json.dumps(
{"type": "reasoning", "delta": final_reasoning}
).encode("utf-8")
+ b"\n"
)
+ b"\n"
)
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
# Stream content in word-sized chunks for smooth UX
for part in chunk_content(final_content):
yield (
json.dumps(
{"type": "content", "delta": part}
).encode("utf-8")
+ b"\n"
)
yield json.dumps({"type": "done"}).encode("utf-8") + b"\n"
return StreamingResponse(
stream_body(),
media_type="application/x-ndjson",
return StreamingResponse(
stream_body(),
media_type="application/x-ndjson",
)
return JSONResponse(
content=ChatCompletionResponse(
message=ChatMessageResponse(
role="assistant",
content=final_content,
reasoning=response.get("reasoning"),
tool_calls=None,
),
finish_reason=response.get("finish_reason", "stop"),
tool_iterations=tool_iterations,
tool_calls=tool_calls,
messages=list(conversation),
).model_dump(),
)
tool_iterations += 1
logger.debug(
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
f"{len(pending_tool_calls)} tool(s) to execute"
)
awaiting = _tool_calls_awaiting_approval(
pending_tool_calls, body, write_tools
)
if awaiting:
# Pause before running write tools; the client resends the
# returned chain with its decisions to continue.
return JSONResponse(
content=ChatCompletionResponse(
message=ChatMessageResponse(
role="assistant",
content=final_content,
reasoning=response.get("reasoning"),
tool_calls=None,
content=None,
tool_calls=[ToolCallInvocation(**tc) for tc in awaiting],
),
finish_reason=response.get("finish_reason", "stop"),
finish_reason="approval_required",
tool_iterations=tool_iterations,
tool_calls=tool_calls,
messages=list(conversation),
).model_dump(),
)
tool_iterations += 1
logger.debug(
f"Tool calls detected (iteration {tool_iterations}/{max_iterations}): "
f"{len(pending_tool_calls)} tool(s) to execute"
)
executed_calls, tool_results, extra_msgs = await _execute_pending_tools(
pending_tool_calls, request, allowed_cameras
pending_tool_calls,
request,
allowed_cameras,
decisions=body.tool_decisions,
)
tool_calls.extend(executed_calls)
conversation.extend(tool_results)
conversation.extend(extra_msgs)
pending_tool_calls = None
logger.debug(
f"Added {len(tool_results)} tool result(s) to conversation. "
f"Continuing with next LLM call..."
+7 -4
View File
@@ -44,6 +44,11 @@ def chunk_content(content: str, chunk_size: int = 80) -> Generator[str, None, No
yield " ".join(current)
def format_local_time(timestamp: float) -> str:
"""Format a unix timestamp as the server-local string quoted to users."""
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %I:%M:%S %p")
def format_events_with_local_time(
events_list: list[dict[str, Any]],
) -> list[dict[str, Any]]:
@@ -58,11 +63,9 @@ def format_events_with_local_time(
start_ts = evt.get("start_time")
end_ts = evt.get("end_time")
if start_ts is not None:
dt_start = datetime.fromtimestamp(start_ts)
copy_evt["start_time_local"] = dt_start.strftime("%Y-%m-%d %I:%M:%S %p")
copy_evt["start_time_local"] = format_local_time(start_ts)
if end_ts is not None:
dt_end = datetime.fromtimestamp(end_ts)
copy_evt["end_time_local"] = dt_end.strftime("%Y-%m-%d %I:%M:%S %p")
copy_evt["end_time_local"] = format_local_time(end_ts)
except (TypeError, ValueError, OSError):
pass
result.append(copy_evt)
+10 -1
View File
@@ -1,6 +1,6 @@
"""Chat API request models."""
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, Field
@@ -59,3 +59,12 @@ class ChatCompletionRequest(BaseModel):
"Ignored by providers that do not expose a per-request thinking switch."
),
)
tool_decisions: dict[str, Literal["approve", "reject"]] = Field(
default_factory=dict,
description=(
"Decisions for tool calls that paused for approval, keyed by tool "
"call ID. Send these with the conversation chain returned alongside "
"an approval request; rejected calls are reported to the model as "
"declined instead of being executed."
),
)
+1
View File
@@ -13,6 +13,7 @@ class Tags(Enum):
logs = "Logs"
media = "Media"
motion_search = "Motion Search"
notices = "Notices"
notifications = "Notifications"
preview = "Preview"
recordings = "Recordings"
+5
View File
@@ -24,6 +24,7 @@ from frigate.api import (
hardware,
media,
motion_search,
notices,
notification,
preview,
record,
@@ -41,6 +42,7 @@ from frigate.config.profile_manager import ProfileManager
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
from frigate.embeddings import EmbeddingsContext
from frigate.genai import GenAIClientManager
from frigate.notices.registry import NoticeRegistry
from frigate.ptz.onvif import OnvifController
from frigate.stats.emitter import StatsEmitter
from frigate.storage import StorageMaintainer
@@ -77,6 +79,7 @@ def create_fastapi_app(
profile_manager: ProfileManager | None = None,
enforce_default_admin: bool = True,
config_holder: ConfigHolder | None = None,
notice_registry: NoticeRegistry | None = None,
):
logger.info("Starting FastAPI app")
app = FastAPI(
@@ -147,6 +150,7 @@ def create_fastapi_app(
app.include_router(notification.router)
app.include_router(export.router)
app.include_router(hardware.router)
app.include_router(notices.router)
app.include_router(event.router)
app.include_router(media.router)
app.include_router(motion_search.router)
@@ -163,6 +167,7 @@ def create_fastapi_app(
app.camera_error_image = None
app.onvif = onvif
app.stats_emitter = stats_emitter
app.notice_registry = notice_registry
app.event_metadata_updater = event_metadata_updater
app.config_publisher = config_publisher
app.replay_manager = replay_manager
+60
View File
@@ -0,0 +1,60 @@
"""Notice APIs."""
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.notices.registry import DismissResult
logger = logging.getLogger(__name__)
router = APIRouter(tags=[Tags.notices])
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
"""Get the active notices, most severe first.
Args:
include_dismissed: Also return event notices the user has dismissed
Returns:
The active notices
"""
return JSONResponse(
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
)
@router.get("/notices/stats", dependencies=[Depends(require_role(["admin"]))])
def get_notice_stats(request: Request) -> JSONResponse:
"""Get lifetime occurrence counts per notice kind."""
return JSONResponse(content=request.app.notice_registry.stats())
@router.post(
"/notices/{notice_id}/dismiss", dependencies=[Depends(require_role(["admin"]))]
)
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide an event notice until it is raised again."""
result = request.app.notice_registry.dismiss(notice_id)
if result == DismissResult.not_found:
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
if result == DismissResult.not_dismissable:
return JSONResponse(
content={
"success": False,
"message": "This notice clears itself when the problem is fixed",
},
status_code=400,
)
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
+18 -2
View File
@@ -62,6 +62,8 @@ from frigate.log import _stop_logging
from frigate.models import (
Event,
Export,
Notice,
NoticeStats,
Previews,
Recordings,
RecordingsToDelete,
@@ -71,6 +73,7 @@ from frigate.models import (
Trigger,
User,
)
from frigate.notices.registry import NoticeRegistry
from frigate.object_detection.base import ObjectDetectProcess
from frigate.object_detection.util import detection_frame_size
from frigate.output.output import OutputProcess
@@ -294,6 +297,8 @@ class FrigateApp:
models = [
Event,
Export,
Notice,
NoticeStats,
Previews,
Recordings,
RecordingsToDelete,
@@ -305,6 +310,10 @@ class FrigateApp:
]
self.db.bind(models)
self.notice_registry = NoticeRegistry()
# producers confirm state notices again at startup if they still hold
self.notice_registry.mark_state_notices_unconfirmed()
def check_db_data_migrations(self) -> None:
# check if vacuum needs to be run
if not os.path.exists(f"{CONFIG_DIR}/.exports"):
@@ -354,6 +363,7 @@ class FrigateApp:
self.onvif_controller,
self.ptz_metrics,
comms,
notice_registry=self.notice_registry,
)
self.dispatcher.start_communicators()
@@ -500,7 +510,9 @@ class FrigateApp:
self.record_cleanup.start()
def start_storage_maintainer(self) -> None:
self.storage_maintainer = StorageMaintainer(self.config, self.stop_event)
self.storage_maintainer = StorageMaintainer(
self.config, self.stop_event, self.notice_registry
)
self.storage_maintainer.start()
def start_stats_emitter(self) -> None:
@@ -514,11 +526,14 @@ class FrigateApp:
self.processes,
),
self.stop_event,
notice_registry=self.notice_registry,
)
self.stats_emitter.start()
def start_watchdog(self) -> None:
self.frigate_watchdog = FrigateWatchdog(self.detectors, self.stop_event)
self.frigate_watchdog = FrigateWatchdog(
self.detectors, self.stop_event, self.notice_registry
)
# (attribute on self, key in self.processes, factory)
specs: list[tuple[str, str, Callable[[], FrigateProcess]]] = [
@@ -686,6 +701,7 @@ class FrigateApp:
self.dispatcher,
self.profile_manager,
config_holder=self.config_holder,
notice_registry=self.notice_registry,
),
host="127.0.0.1",
port=5001,
+54
View File
@@ -11,6 +11,7 @@ from peewee import IntegrityError
from frigate.camera import PTZMetrics
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
from frigate.comms.base_communicator import Communicator
from frigate.comms.mqtt import MqttClient
from frigate.comms.runtime_state import RuntimeStatePersistence
from frigate.comms.webpush import WebPushClient
from frigate.config import (
@@ -40,10 +41,12 @@ from frigate.const import (
UPDATE_EVENT_DESCRIPTION,
UPDATE_JOB_STATE,
UPDATE_MODEL_STATE,
UPDATE_NOTICE,
UPDATE_REVIEW_DESCRIPTION,
UPSERT_REVIEW_SEGMENT,
)
from frigate.models import Event, Previews, Recordings, ReviewSegment
from frigate.notices.registry import NoticeRegistry
from frigate.ptz.onvif import OnvifCommandEnum, OnvifController
from frigate.types import ModelStatusTypesEnum, TrackedObjectUpdateTypesEnum
from frigate.util.object import get_camera_regions_grid
@@ -67,12 +70,18 @@ class Dispatcher:
onvif: OnvifController,
ptz_metrics: dict[str, PTZMetrics],
communicators: list[Communicator],
notice_registry: NoticeRegistry | None = None,
) -> None:
self.config = config
self.config_updater = config_updater
self.onvif = onvif
self.ptz_metrics = ptz_metrics
self.comms = communicators
self.notice_registry = notice_registry
if notice_registry is not None:
notice_registry.subscribe(self._publish_notices)
self.camera_activity = CameraActivityManager(config, self.publish)
self.audio_activity = AudioActivityManager(config, self.publish)
self.model_state: dict[str, ModelStatusTypesEnum] = {}
@@ -212,6 +221,10 @@ class Dispatcher:
False,
)
publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()), False)
if self.notice_registry is not None:
publish("notices", json.dumps(self.notice_registry.active()), False)
publish("audio_detections", json.dumps(audio_detections), False)
publish(
"profile/state",
@@ -339,6 +352,29 @@ class Dispatcher:
self.model_state[model] = ModelStatusTypesEnum[state]
self.publish("model_state", json.dumps(self.model_state))
def handle_update_notice() -> None:
if self.notice_registry is None or not isinstance(payload, dict):
return
try:
action = payload.get("action")
kind = payload.get("kind")
scope = payload.get("scope")
if not isinstance(kind, str):
logger.warning("Ignoring notice update without a kind")
elif action == "raise":
self.notice_registry.raise_notice(
kind, scope=scope, params=payload.get("params") or {}
)
elif action == "resolve":
self.notice_registry.resolve(kind, scope)
else:
logger.warning("Ignoring notice update with action %s", action)
except Exception:
# a raise here would kill the REP thread for every process
logger.exception("Failed to apply notice update")
def handle_model_state() -> None:
self.publish("model_state", json.dumps(self.model_state.copy()))
@@ -406,6 +442,7 @@ class Dispatcher:
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
UPDATE_MODEL_STATE: handle_update_model_state,
UPDATE_JOB_STATE: handle_update_job_state,
UPDATE_NOTICE: handle_update_notice,
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
@@ -464,6 +501,23 @@ class Dispatcher:
for comm in self.comms:
comm.publish(topic, payload, retain)
def publish_local(self, topic: str, payload: Any) -> None:
"""Publish to every communicator except MQTT.
Used for topics whose external schema is not settled yet.
"""
for comm in self.comms:
if isinstance(comm, MqttClient):
continue
comm.publish(topic, payload, False)
def _publish_notices(self) -> None:
if self.notice_registry is None:
return
self.publish_local("notices", json.dumps(self.notice_registry.active()))
def stop(self) -> None:
self.camera_activity.stop()
+18
View File
@@ -31,6 +31,7 @@ from frigate.const import (
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_EVENT_DESCRIPTION,
UPDATE_MODEL_STATE,
UPDATE_NOTICE,
UPDATE_REVIEW_DESCRIPTION,
UPSERT_REVIEW_SEGMENT,
)
@@ -56,6 +57,7 @@ _WS_BLOCKED_TOPICS = frozenset(
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
UPDATE_BIRDSEYE_LAYOUT,
UPDATE_AUDIO_TRANSCRIPTION_STATE,
UPDATE_NOTICE,
}
)
@@ -158,6 +160,16 @@ _WS_UNRESTRICTED_ONLY_TOPICS = frozenset(
}
)
# Topics only an admin connection may receive. unrestricted_only is not
# enough: the built-in viewer role has an empty camera allow-list, which the
# camera policy treats as full access, while the REST side of these topics
# is admin-only.
_WS_ADMIN_ONLY_TOPICS = frozenset(
{
"notices",
}
)
# Topics whose payload (parsed as JSON) names a single owning camera at the
# given key path. Used to scope events, reviews, triggers, etc.
_WS_PAYLOAD_CAMERA_TOPICS: dict[str, tuple[str, ...]] = {
@@ -280,6 +292,7 @@ def _classify_outbound(
- "global" : send to every authenticated client
- "drop" : send to nobody (fail-closed for unknowns)
- "unrestricted_only" : send only to admin/full-access roles
- "admin_only" : send only to connections with the admin role
- "camera" : extra is the owning camera name
- "payload_camera" : extra is the JSON key path to the camera name
- "reshape_by_camera_key"
@@ -288,6 +301,8 @@ def _classify_outbound(
"""
if topic in _WS_GLOBAL_OUTBOUND_TOPICS:
return ("global", None)
if topic in _WS_ADMIN_ONLY_TOPICS:
return ("admin_only", None)
if topic in _WS_UNRESTRICTED_ONLY_TOPICS:
return ("unrestricted_only", None)
if topic in _WS_RESHAPE_BY_CAMERA_KEY_TOPICS:
@@ -391,6 +406,9 @@ def _materialize_for_ws(
if kind == "unrestricted_only":
return full_message if _ws_is_unrestricted(ws, config) else None
if kind == "admin_only":
return full_message if "admin" in _ws_valid_roles(ws, config) else None
if kind == "camera":
return full_message if ws_has_camera_access(ws, extra, config) else None
+1
View File
@@ -155,6 +155,7 @@ UPDATE_MODEL_STATE = "update_model_state"
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
UPDATE_BIRDSEYE_LAYOUT = "update_birdseye_layout"
UPDATE_JOB_STATE = "update_job_state"
UPDATE_NOTICE = "update_notice"
NOTIFICATION_TEST = "notification_test"
# IO Nice Values
+124
View File
@@ -311,6 +311,10 @@ def get_tool_definitions(
Descriptions here stay mechanical: which tool to reach for, and how the
filters relate to each other, is stated once in the system prompt so the
guidance is not paid for twice on every request.
Each definition carries a Frigate-only `access` field ("read" or "write");
write tools pause for user approval in the chat loop. Strip it with
`strip_tool_access` before sending the list to a provider.
"""
search_objects_properties: dict[str, Any] = {
"camera": {
@@ -382,6 +386,7 @@ def get_tool_definitions(
return [
{
"type": "function",
"access": "read",
"function": {
"name": "search_objects",
"description": search_objects_description,
@@ -394,6 +399,7 @@ def get_tool_definitions(
},
{
"type": "function",
"access": "read",
"function": {
"name": "get_categorized_object_names",
"description": (
@@ -411,6 +417,7 @@ def get_tool_definitions(
},
{
"type": "function",
"access": "read",
"function": {
"name": "find_similar_objects",
"description": (
@@ -474,6 +481,7 @@ def get_tool_definitions(
},
{
"type": "function",
"access": "write",
"function": {
"name": "set_camera_state",
"description": (
@@ -529,6 +537,7 @@ def get_tool_definitions(
},
{
"type": "function",
"access": "read",
"function": {
"name": "get_live_context",
"description": (
@@ -553,6 +562,7 @@ def get_tool_definitions(
},
{
"type": "function",
"access": "write",
"function": {
"name": "start_camera_watch",
"description": (
@@ -596,6 +606,7 @@ def get_tool_definitions(
},
{
"type": "function",
"access": "write",
"function": {
"name": "stop_camera_watch",
"description": "Cancel the currently running watch job.",
@@ -608,6 +619,7 @@ def get_tool_definitions(
},
{
"type": "function",
"access": "read",
"function": {
"name": "get_profile_status",
"description": (
@@ -624,6 +636,7 @@ def get_tool_definitions(
},
{
"type": "function",
"access": "read",
"function": {
"name": "get_recap",
"description": (
@@ -656,9 +669,120 @@ def get_tool_definitions(
},
},
},
{
"type": "function",
"access": "read",
"function": {
"name": "get_export_cases",
"description": (
"List the export cases (named groups of exported clips) with "
"their IDs, descriptions, and how many exports each holds. "
"Call this before create_export when the user wants a clip "
"added to an existing case."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
},
},
{
"type": "function",
"access": "write",
"function": {
"name": "create_export",
"description": (
"Export a camera's recording for a time range to a "
"downloadable file, optionally attached to an existing export "
"case. Only call this when the user explicitly asks to export "
"or save a clip."
),
"parameters": {
"type": "object",
"properties": {
"camera": {
"type": "string",
"description": "Camera ID to export from.",
},
"start_time": {
"type": "string",
"description": "Start of the clip in ISO 8601 format (e.g. '2025-03-15T08:00:00').",
},
"end_time": {
"type": "string",
"description": "End of the clip in ISO 8601 format (e.g. '2025-03-15T08:05:00').",
},
"name": {
"type": "string",
"description": "Friendly name for the export (optional).",
},
"source": {
"type": "string",
"enum": ["recordings", "preview"],
"description": (
"'recordings' (default) exports full-quality footage; "
"'preview' builds a low-resolution timelapse."
),
"default": "recordings",
},
"export_case_id": {
"type": "string",
"description": (
"ID of an existing export case to attach the export "
"to. Use get_export_cases to find it."
),
},
},
"required": ["camera", "start_time", "end_time"],
},
},
},
{
"type": "function",
"access": "read",
"function": {
"name": "get_event_image",
"description": (
"View the thumbnail or snapshot image of a specific tracked "
"object so you can describe what it shows. Use the event id "
"from search_objects, find_similar_objects, or an attached "
"event."
),
"parameters": {
"type": "object",
"properties": {
"event_id": {
"type": "string",
"description": "ID of the tracked object to view.",
},
"image": {
"type": "string",
"enum": ["thumbnail", "snapshot"],
"description": (
"'thumbnail' (default) is a small crop of the object; "
"'snapshot' is the full camera frame."
),
"default": "thumbnail",
},
},
"required": ["event_id"],
},
},
},
]
def get_write_tool_names(tools: list[dict[str, Any]]) -> set[str]:
"""Names of the tools whose `access` is "write" (they change state)."""
return {tool["function"]["name"] for tool in tools if tool.get("access") == "write"}
def strip_tool_access(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Drop the Frigate-only `access` field before handing tools to a provider."""
return [{k: v for k, v in tool.items() if k != "access"} for tool in tools]
def build_chat_system_prompt(
config: FrigateConfig,
allowed_cameras: list[str],
+22
View File
@@ -183,3 +183,25 @@ class Trigger(Model):
class Meta:
primary_key = CompositeKey("camera", "name")
class Notice(Model):
id = CharField(null=False, primary_key=True, max_length=150)
kind = CharField(index=True, max_length=50)
scope = CharField(max_length=100, null=True)
params = JSONField()
first_seen = DateTimeField()
last_seen = DateTimeField()
count = IntegerField(default=1)
dismissed_at = DateTimeField(null=True)
class NoticeStats(Model):
kind = CharField(null=False, primary_key=True, max_length=50)
occurrences = IntegerField(default=0)
dismissals = IntegerField(default=0)
first_seen = DateTimeField()
last_seen = DateTimeField()
# watermarks for a future analytics reporter; unused until then
reported_occurrences = IntegerField(default=0)
reported_dismissals = IntegerField(default=0)
View File
+263
View File
@@ -0,0 +1,263 @@
"""Registry of active notices and per-kind occurrence counts."""
import logging
import threading
from collections.abc import Callable
from datetime import datetime
from enum import Enum
from typing import Any
from frigate.const import REPLAY_CAMERA_PREFIX
from frigate.models import Notice, NoticeStats
from frigate.notices.types import (
NOTICE_KINDS,
SEVERITY_ORDER,
NoticeMode,
notice_id,
)
logger = logging.getLogger(__name__)
class DismissResult(str, Enum):
ok = "ok"
not_found = "not_found"
not_dismissable = "not_dismissable"
# last_seen value of a state notice that has not been raised since startup
UNCONFIRMED = 0
class NoticeRegistry:
"""Owns the notice tables. Lives in the main process only.
Producers in the main process call it directly. Other processes send an
update_notice request that the dispatcher forwards here. The FastAPI app
runs inside the main process too, so the API's dismiss shares this exact
object, its lock, and its listeners.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._listeners: list[Callable[[], None]] = []
def subscribe(self, listener: Callable[[], None]) -> None:
"""Call listener after every change to the active list."""
self._listeners.append(listener)
def _notify(self) -> None:
for listener in self._listeners:
try:
listener()
except Exception:
logger.exception("Notice listener failed")
def raise_notice(
self,
kind: str,
*,
scope: str | None = None,
params: dict[str, Any] | None = None,
) -> None:
"""Insert or update a notice. See the counting rule in the spec."""
definition = NOTICE_KINDS.get(kind)
if definition is None:
logger.warning("Ignoring notice of unknown kind %s", kind)
return
if (
definition.category == "camera"
and scope
and scope.startswith(REPLAY_CAMERA_PREFIX)
):
return
now = datetime.now().timestamp()
row_id = notice_id(kind, scope)
params = params or {}
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
Notice.create(
id=row_id,
kind=kind,
scope=scope,
params=params,
first_seen=now,
last_seen=now,
count=1,
dismissed_at=None,
)
self._bump_occurrences(kind, now)
elif definition.mode == NoticeMode.event:
Notice.update(
count=Notice.count + 1,
last_seen=now,
params=params,
dismissed_at=None,
).where(Notice.id == row_id).execute()
self._bump_occurrences(kind, now)
else:
# one episode counts once; a repeat with nothing new is free
confirmed = existing.last_seen != UNCONFIRMED
if confirmed and existing.params == params:
return
Notice.update(last_seen=now, params=params).where(
Notice.id == row_id
).execute()
self._notify()
def resolve(self, kind: str, scope: str | None = None) -> None:
"""Delete a notice if present. Safe to call when it is absent."""
with self._lock:
deleted = (
Notice.delete().where(Notice.id == notice_id(kind, scope)).execute()
)
if deleted:
self._notify()
def resolve_camera(self, camera: str) -> None:
"""Drop every camera-category notice scoped to a camera being deleted."""
camera_kinds = [
key
for key, definition in NOTICE_KINDS.items()
if definition.category == "camera"
]
with self._lock:
deleted = (
Notice.delete()
.where(
Notice.kind.in_(camera_kinds), # type: ignore[call-arg, arg-type, misc]
Notice.scope == camera,
)
.execute()
)
if deleted:
self._notify()
def dismiss(self, row_id: str) -> DismissResult:
"""Hide an event notice until it is raised again."""
with self._lock:
existing = Notice.get_or_none(Notice.id == row_id)
if existing is None:
return DismissResult.not_found
definition = NOTICE_KINDS.get(existing.kind)
if definition is None or definition.mode == NoticeMode.state:
return DismissResult.not_dismissable
now = datetime.now().timestamp()
Notice.update(dismissed_at=now).where(Notice.id == row_id).execute()
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where(
NoticeStats.kind == existing.kind
).execute()
self._notify()
return DismissResult.ok
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]:
"""The active notices, most severe first, then most recent first."""
rows = []
for row in Notice.select():
definition = NOTICE_KINDS.get(row.kind)
if definition is None:
continue
if row.dismissed_at is not None and not include_dismissed:
continue
if definition.mode == NoticeMode.state and row.last_seen == UNCONFIRMED:
continue
rows.append(
{
"id": row.id,
"kind": row.kind,
"mode": definition.mode.value,
"severity": definition.severity.value,
"category": definition.category,
"scope": row.scope,
"params": row.params,
"first_seen": row.first_seen,
"last_seen": row.last_seen,
"count": row.count,
"dismissed_at": row.dismissed_at,
}
)
rows.sort(
key=lambda n: (
SEVERITY_ORDER[NOTICE_KINDS[n["kind"]].severity],
-n["last_seen"],
)
)
return rows
def stats(self) -> list[dict[str, Any]]:
"""Lifetime counts per kind, for the UI meta line and later analytics."""
return [
{
"kind": row.kind,
"occurrences": row.occurrences,
"dismissals": row.dismissals,
"first_seen": row.first_seen,
"last_seen": row.last_seen,
"reported_occurrences": row.reported_occurrences,
"reported_dismissals": row.reported_dismissals,
}
for row in NoticeStats.select()
if row.kind in NOTICE_KINDS
]
def mark_state_notices_unconfirmed(self) -> None:
"""Hide state notices until their producer raises them again.
Rows already unconfirmed from the previous run are deleted. Hiding
rather than deleting keeps one episode that spans a restart at one
occurrence.
"""
state_kinds = [
key
for key, definition in NOTICE_KINDS.items()
if definition.mode == NoticeMode.state
]
with self._lock:
Notice.delete().where(
Notice.kind.in_(state_kinds), # type: ignore[call-arg, arg-type, misc]
Notice.last_seen == UNCONFIRMED,
).execute()
Notice.update(last_seen=UNCONFIRMED).where(
Notice.kind.in_(state_kinds) # type: ignore[call-arg, arg-type, misc]
).execute()
def _bump_occurrences(self, kind: str, now: float) -> None:
# called with the lock held
stats = NoticeStats.get_or_none(NoticeStats.kind == kind)
if stats is None:
NoticeStats.create(
kind=kind,
occurrences=1,
dismissals=0,
first_seen=now,
last_seen=now,
)
else:
NoticeStats.update(
occurrences=NoticeStats.occurrences + 1, last_seen=now
).where(NoticeStats.kind == kind).execute()
+63
View File
@@ -0,0 +1,63 @@
"""Notice kinds and the definitions that fix their mode, severity, and category."""
from dataclasses import dataclass
from enum import Enum
class NoticeMode(str, Enum):
"""Whether a producer resolves the notice itself or the user dismisses it."""
state = "state"
event = "event"
class NoticeSeverity(str, Enum):
error = "error"
warning = "warning"
info = "info"
SEVERITY_ORDER: dict[NoticeSeverity, int] = {
NoticeSeverity.error: 0,
NoticeSeverity.warning: 1,
NoticeSeverity.info: 2,
}
@dataclass(frozen=True)
class NoticeKind:
"""One kind of notice. Producers pass the key; everything else comes from here.
Attributes:
key: Stable machine-readable name, also the translation key suffix
mode: state notices are resolved by their producer, event notices by the user
severity: error, warning, or info
category: what the scope names: camera, detector, and model kinds carry a scope
reportable: whether a future analytics reporter may send this kind's counts
"""
key: str
mode: NoticeMode
severity: NoticeSeverity
category: str
reportable: bool = True
SCOPED_CATEGORIES = frozenset({"camera", "detector", "model"})
_KINDS = (
NoticeKind("ffmpeg_crash_loop", NoticeMode.state, NoticeSeverity.error, "camera"),
NoticeKind("detector_stuck", NoticeMode.event, NoticeSeverity.warning, "detector"),
NoticeKind(
"model_download_failed", NoticeMode.event, NoticeSeverity.error, "model"
),
NoticeKind("retention_unmet", NoticeMode.state, NoticeSeverity.error, "storage"),
NoticeKind("update_available", NoticeMode.state, NoticeSeverity.info, "system"),
)
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
def notice_id(kind: str, scope: str | None) -> str:
"""The row id for a kind and optional scope."""
return f"{kind}:{scope}" if scope else kind
+48 -1
View File
@@ -11,16 +11,21 @@ from typing import Any
from frigate.comms.inter_process import InterProcessRequestor
from frigate.config import FrigateConfig
from frigate.const import FREQUENCY_STATS_POINTS
from frigate.notices.registry import NoticeRegistry
from frigate.stats.hardware import HardwareStats
from frigate.stats.prometheus import update_metrics
from frigate.stats.util import stats_snapshot
from frigate.stats.util import get_latest_version, is_newer_version, stats_snapshot
from frigate.types import StatsTrackingTypes
from frigate.version import VERSION
logger = logging.getLogger(__name__)
MAX_STATS_POINTS = 80
# how often to ask GitHub for the latest release
VERSION_REFRESH_S = 24 * 60 * 60
class StatsEmitter(threading.Thread):
def __init__(
@@ -28,11 +33,13 @@ class StatsEmitter(threading.Thread):
config: FrigateConfig,
stats_tracking: StatsTrackingTypes,
stop_event: MpEvent,
notice_registry: NoticeRegistry | None = None,
):
super().__init__(name="frigate_stats_emitter")
self.config = config
self.stats_tracking = stats_tracking
self.stop_event = stop_event
self.notice_registry = notice_registry
self.hardware_stats = HardwareStats(config)
self.stats_history: list[dict[str, Any]] = []
@@ -125,14 +132,54 @@ class StatsEmitter(threading.Thread):
update_metrics(stats)
return stats
def _check_update_notice(self) -> None:
"""Raise or resolve the update notice from the tracked latest version."""
if self.notice_registry is None:
return
latest = self.stats_tracking["latest_frigate_version"]
# a failed lookup says nothing about whether an update exists
if latest == "unknown":
return
if is_newer_version(VERSION, latest):
self.notice_registry.raise_notice(
"update_available", params={"version": latest}
)
else:
self.notice_registry.resolve("update_available")
def _refresh_latest_version(self) -> None:
"""Refresh the latest release on a daemon thread so the request never stalls stats."""
def refresh() -> None:
latest = get_latest_version(self.config)
# keep the last good value; stats surface it as service.latest_version
if latest != "unknown":
self.stats_tracking["latest_frigate_version"] = latest
self._check_update_notice()
threading.Thread(
target=refresh, name="frigate_version_check", daemon=True
).start()
def run(self) -> None:
time.sleep(10)
self._check_update_notice()
last_version_check = time.time()
for counter in itertools.cycle(
range(int(self.config.mqtt.stats_interval / FREQUENCY_STATS_POINTS))
):
if self.stop_event.wait(FREQUENCY_STATS_POINTS):
break
if time.time() - last_version_check >= VERSION_REFRESH_S:
self._refresh_latest_version()
last_version_check = time.time()
logger.debug("Starting stats collection")
stats = stats_snapshot(
self.config, self.stats_tracking, self.hardware_stats
+37
View File
@@ -1,6 +1,7 @@
"""Utilities for stats."""
import logging
import re
import shutil
import time
from json import JSONDecodeError
@@ -45,6 +46,42 @@ def get_latest_version(config: FrigateConfig) -> str:
return "unknown"
def _version_tuple(value: str) -> tuple[int, int, int] | None:
match = re.match(r"^(\d+)\.(\d+)\.(\d+)", value or "")
if match is None:
return None
return int(match.group(1)), int(match.group(2)), int(match.group(3))
# a build's git hash suffix is not a prerelease marker
PRERELEASE_PATTERN = re.compile(r"^\d+\.\d+\.\d+-(?:beta|rc)", re.IGNORECASE)
def _is_prerelease(value: str) -> bool:
return PRERELEASE_PATTERN.match(value or "") is not None
def is_newer_version(current: str, latest: str) -> bool:
"""Whether latest is a release newer than the running version.
Build suffixes after the third number are ignored, and a value that does
not parse (disabled, unknown) is never newer. A prerelease is behind the
final release of the same number, so 0.19.0-beta2 is behind 0.19.0.
"""
current_tuple = _version_tuple(current)
latest_tuple = _version_tuple(latest)
if current_tuple is None or latest_tuple is None:
return False
if current_tuple == latest_tuple:
return _is_prerelease(current) and not _is_prerelease(latest)
return latest_tuple > current_tuple
def stats_init(
config: FrigateConfig,
camera_metrics: DictProxy,
+42 -18
View File
@@ -20,6 +20,7 @@ from frigate.const import (
STREAM_TYPE_SUB,
)
from frigate.models import Event, Recordings
from frigate.notices.registry import NoticeRegistry
from frigate.util.builtin import clear_and_unlink
logger = logging.getLogger(__name__)
@@ -34,10 +35,16 @@ BANDWIDTH_SAMPLE_TARGET = 50
class StorageMaintainer(threading.Thread):
"""Maintain frigates recording storage."""
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
def __init__(
self,
config: FrigateConfig,
stop_event: MpEvent,
notice_registry: NoticeRegistry | None = None,
) -> None:
super().__init__(name="storage_maintainer")
self.config = config
self.stop_event = stop_event
self.notice_registry = notice_registry
self.camera_storage_stats: dict[str, dict] = {}
self.config_subscriber = CameraConfigUpdateSubscriber(
self.config,
@@ -342,9 +349,21 @@ class StorageMaintainer(threading.Thread):
except FileNotFoundError:
# this file was not found so we must assume no space was cleaned up
pass
if self.notice_registry is not None:
self.notice_registry.raise_notice(
"retention_unmet",
params={
"needed_mb": round(float(hourly_bandwidth), 1),
"cleared_mb": round(float(deleted_segments_size), 1),
},
)
else:
logger.info(f"Cleaned up {deleted_segments_size:.2f} MB of recordings")
if self.notice_registry is not None:
self.notice_registry.resolve("retention_unmet")
logger.debug(f"Expiring {len(deleted_recordings)} recordings")
# delete up to 100,000 at a time
max_deletes = 100000
@@ -406,23 +425,28 @@ class StorageMaintainer(threading.Thread):
self.calculate_camera_bandwidth()
while not self.stop_event.wait(300):
updated_topics = self.config_subscriber.check_for_updates()
for camera in updated_topics.get(CameraConfigUpdateEnum.record.name, []):
if camera in self.camera_storage_stats:
self.camera_storage_stats[camera]["needs_refresh"] = True
if not self.camera_storage_stats or True in [
r["needs_refresh"] for r in self.camera_storage_stats.values()
]:
self.calculate_camera_bandwidth()
logger.debug(f"Default camera bandwidths: {self.camera_storage_stats}.")
if self.check_storage_needs_cleanup():
logger.info(
"Less than 1 hour of recording space left, running storage maintenance..."
)
self.reduce_storage_consumption()
self._maintain_once()
self.config_subscriber.stop()
logger.info("Exiting storage maintainer...")
def _maintain_once(self) -> None:
updated_topics = self.config_subscriber.check_for_updates()
for camera in updated_topics.get(CameraConfigUpdateEnum.record.name, []):
if camera in self.camera_storage_stats:
self.camera_storage_stats[camera]["needs_refresh"] = True
if not self.camera_storage_stats or True in [
r["needs_refresh"] for r in self.camera_storage_stats.values()
]:
self.calculate_camera_bandwidth()
logger.debug(f"Default camera bandwidths: {self.camera_storage_stats}.")
if self.check_storage_needs_cleanup():
logger.info(
"Less than 1 hour of recording space left, running storage maintenance..."
)
self.reduce_storage_consumption()
elif self.notice_registry is not None:
self.notice_registry.resolve("retention_unmet")
+4 -1
View File
@@ -144,7 +144,9 @@ class BaseTestHttp(unittest.TestCase):
except OSError:
pass
def create_app(self, stats=None, event_metadata_publisher=None):
def create_app(
self, stats=None, event_metadata_publisher=None, notice_registry=None
):
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
app = create_fastapi_app(
@@ -159,6 +161,7 @@ class BaseTestHttp(unittest.TestCase):
None,
DebugReplayManager(),
enforce_default_admin=False,
notice_registry=notice_registry,
)
# Default test mocks for authentication
@@ -0,0 +1,72 @@
"""Tests for the notices API."""
from fastapi.testclient import TestClient
from frigate.models import Notice, NoticeStats
from frigate.notices.registry import NoticeRegistry
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
class TestHttpNotices(BaseTestHttp):
def setUp(self):
super().setUp([Notice, NoticeStats])
self.registry = NoticeRegistry()
self.app = self.create_app(notice_registry=self.registry)
def client(self) -> TestClient:
return AuthTestClient(self.app)
def test_get_notices_orders_by_severity(self):
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
with self.client() as client:
response = client.get("/notices")
self.assertEqual(response.status_code, 200)
self.assertEqual(
[n["id"] for n in response.json()], ["retention_unmet", "detector_stuck"]
)
self.assertEqual(response.json()[0]["severity"], "error")
def test_get_stats(self):
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
with self.client() as client:
response = client.get("/notices/stats")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()[0]["kind"], "detector_stuck")
self.assertEqual(response.json()[0]["occurrences"], 1)
def test_dismiss_event(self):
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
with self.client() as client:
response = client.post("/notices/detector_stuck/dismiss")
listed = client.get("/notices").json()
self.assertEqual(response.status_code, 200)
self.assertEqual(listed, [])
def test_dismiss_unknown_is_404(self):
with self.client() as client:
response = client.post("/notices/nope/dismiss")
self.assertEqual(response.status_code, 404)
def test_dismiss_state_is_400(self):
self.registry.raise_notice("retention_unmet", params={})
with self.client() as client:
response = client.post("/notices/retention_unmet/dismiss")
self.assertEqual(response.status_code, 400)
def test_requires_admin(self):
with TestClient(self.app) as client:
response = client.get(
"/notices", headers={"remote-user": "viewer", "remote-role": "viewer"}
)
self.assertEqual(response.status_code, 403)
+132 -50
View File
@@ -6,62 +6,72 @@ from unittest.mock import MagicMock, patch
from frigate.config import FrigateConfig
from frigate.const import STREAM_TYPE_MAIN, STREAM_TYPE_SUB
from frigate.video.ffmpeg import CameraWatchdog
from frigate.video.ffmpeg import (
CRASH_LOOP_RECOVERY_S,
CRASH_LOOP_RESTARTS,
CameraWatchdog,
)
def build_watchdog(
sub_enabled: bool = True, output_args: dict | None = None
) -> CameraWatchdog:
config = FrigateConfig(
**{
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"output_args": output_args or {},
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["record"],
},
{
"path": "rtsp://10.0.0.1:554/video2",
"roles": ["detect", "record_sub"],
},
],
},
"record": {
"enabled": True,
"sub": {"enabled": sub_enabled},
},
}
},
}
)
camera_config = config.cameras["front_door"]
with (
patch("frigate.video.ffmpeg.LogPipe"),
patch("frigate.video.ffmpeg.InterProcessRequestor"),
patch("frigate.video.ffmpeg.RecordingsDataSubscriber"),
patch("frigate.video.ffmpeg.CameraConfigUpdateSubscriber"),
):
watchdog = CameraWatchdog(
camera_config,
1,
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
)
watchdog.requestor = MagicMock()
return watchdog
class TestCameraWatchdogStreamHealth(unittest.TestCase):
def _build_watchdog(
self, sub_enabled: bool = True, output_args: dict | None = None
) -> CameraWatchdog:
config = FrigateConfig(
**{
"mqtt": {"host": "mqtt"},
"cameras": {
"front_door": {
"ffmpeg": {
"output_args": output_args or {},
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["record"],
},
{
"path": "rtsp://10.0.0.1:554/video2",
"roles": ["detect", "record_sub"],
},
],
},
"record": {
"enabled": True,
"sub": {"enabled": sub_enabled},
},
}
},
}
)
camera_config = config.cameras["front_door"]
with (
patch("frigate.video.ffmpeg.LogPipe"),
patch("frigate.video.ffmpeg.InterProcessRequestor"),
patch("frigate.video.ffmpeg.RecordingsDataSubscriber"),
patch("frigate.video.ffmpeg.CameraConfigUpdateSubscriber"),
):
watchdog = CameraWatchdog(
camera_config,
1,
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
MagicMock(),
)
watchdog.requestor = MagicMock()
return watchdog
return build_watchdog(sub_enabled, output_args)
def test_stale_sub_does_not_mark_main_stale(self):
watchdog = self._build_watchdog()
@@ -218,3 +228,75 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
assert watchdog.record_stale_threshold[STREAM_TYPE_MAIN] == 120
assert watchdog.record_stale_threshold[STREAM_TYPE_SUB] == 150
class TestCameraWatchdogCrashLoop(unittest.TestCase):
"""The crash loop notice follows restarts in the trailing hour."""
def _build_watchdog(self) -> CameraWatchdog:
return build_watchdog()
def _notice_payloads(self, watchdog: CameraWatchdog) -> list[dict]:
return [
call.args[1]
for call in watchdog.requestor.send_data.call_args_list
if call.args[0] == "update_notice"
]
def test_below_threshold_raises_nothing(self):
watchdog = self._build_watchdog()
now = datetime.now().timestamp()
watchdog.reconnect_timestamps.extend([now] * (CRASH_LOOP_RESTARTS - 1))
watchdog.capture_thread = MagicMock()
watchdog.capture_thread.is_alive.return_value = False
watchdog._update_crash_loop_notice(now)
self.assertEqual(self._notice_payloads(watchdog), [])
def test_threshold_raises_once_per_restart_count(self):
watchdog = self._build_watchdog()
now = datetime.now().timestamp()
watchdog.reconnect_timestamps.extend([now] * CRASH_LOOP_RESTARTS)
watchdog.capture_thread = MagicMock()
watchdog.capture_thread.is_alive.return_value = False
watchdog._update_crash_loop_notice(now)
watchdog._update_crash_loop_notice(now)
payloads = self._notice_payloads(watchdog)
self.assertEqual(len(payloads), 1)
self.assertEqual(payloads[0]["action"], "raise")
self.assertEqual(payloads[0]["kind"], "ffmpeg_crash_loop")
self.assertEqual(payloads[0]["scope"], "front_door")
self.assertEqual(payloads[0]["params"], {"restarts": CRASH_LOOP_RESTARTS})
def test_recovery_window_resolves(self):
watchdog = self._build_watchdog()
now = datetime.now().timestamp()
# the last restart is at `now`, so recovery is measured from there
watchdog.reconnect_timestamps.extend([now] * CRASH_LOOP_RESTARTS)
watchdog.capture_thread = MagicMock()
watchdog.capture_thread.is_alive.return_value = False
watchdog._update_crash_loop_notice(now)
watchdog.capture_thread.is_alive.return_value = True
watchdog._update_crash_loop_notice(now + CRASH_LOOP_RECOVERY_S - 1)
self.assertEqual(len(self._notice_payloads(watchdog)), 1)
watchdog._update_crash_loop_notice(now + CRASH_LOOP_RECOVERY_S + 1)
payloads = self._notice_payloads(watchdog)
self.assertEqual(len(payloads), 2)
self.assertEqual(payloads[1]["action"], "resolve")
self.assertFalse(watchdog.crash_loop_raised)
def test_disable_resolves(self):
watchdog = self._build_watchdog()
watchdog.crash_loop_raised = True
watchdog._resolve_crash_loop()
payloads = self._notice_payloads(watchdog)
self.assertEqual(payloads[-1]["action"], "resolve")
self.assertFalse(watchdog.crash_loop_raised)
+479
View File
@@ -0,0 +1,479 @@
"""Tests for chat tool approval and the export and event image tools."""
import asyncio
import base64
import json
import os
import tempfile
import unittest
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import cv2
import numpy as np
from playhouse.sqlite_ext import SqliteExtDatabase
from frigate.api.chat import (
TOOL_REJECTED_RESULT,
_execute_create_export,
_execute_get_event_image,
_execute_get_export_cases,
_execute_pending_tools,
_pending_tool_calls_from_tail,
_tool_calls_awaiting_approval,
)
from frigate.api.defs.request.chat_body import ChatCompletionRequest
from frigate.genai.prompts import (
get_tool_definitions,
get_write_tool_names,
strip_tool_access,
)
from frigate.genai.utils import build_assistant_message_for_conversation
from frigate.jobs.export import ExportQueueFullError
from frigate.models import Event, Export, ExportCase, Previews, Recordings
def _run(coro):
return asyncio.new_event_loop().run_until_complete(coro)
def _request(role: str = "admin", supports_vision: bool = True):
camera = SimpleNamespace(
record=SimpleNamespace(export=SimpleNamespace(chapters=None)),
)
app = SimpleNamespace(
frigate_config=SimpleNamespace(
cameras={"driveway": camera, "garage": camera},
proxy=SimpleNamespace(separator=","),
),
genai_manager=SimpleNamespace(
chat_client=SimpleNamespace(supports_vision=supports_vision),
),
)
return SimpleNamespace(app=app, headers={"remote-role": role})
def _body(**kwargs) -> ChatCompletionRequest:
return ChatCompletionRequest(messages=[], **kwargs)
WRITE_TOOLS = get_write_tool_names(get_tool_definitions())
class TestToolRegistry(unittest.TestCase):
def test_every_tool_declares_access(self):
for tool in get_tool_definitions():
self.assertIn(tool.get("access"), ("read", "write"), tool)
def test_write_tools(self):
self.assertEqual(
WRITE_TOOLS,
{
"set_camera_state",
"start_camera_watch",
"stop_camera_watch",
"create_export",
},
)
def test_strip_tool_access_removes_frigate_field(self):
for tool in strip_tool_access(get_tool_definitions()):
self.assertNotIn("access", tool)
self.assertEqual(set(tool), {"type", "function"})
def test_new_tools_are_registered(self):
names = {t["function"]["name"] for t in get_tool_definitions()}
self.assertIn("get_export_cases", names)
self.assertIn("create_export", names)
self.assertIn("get_event_image", names)
def test_create_export_requires_time_range(self):
tool = next(
t
for t in get_tool_definitions()
if t["function"]["name"] == "create_export"
)
params = tool["function"]["parameters"]
self.assertEqual(params["required"], ["camera", "start_time", "end_time"])
self.assertNotIn("event_id", params["properties"])
self.assertNotIn("new_case_name", params["properties"])
def test_get_event_image_requires_event_id(self):
tool = next(
t
for t in get_tool_definitions()
if t["function"]["name"] == "get_event_image"
)
self.assertEqual(tool["function"]["parameters"]["required"], ["event_id"])
class TestApprovalHelpers(unittest.TestCase):
def test_tail_without_tool_calls_is_not_pending(self):
self.assertIsNone(_pending_tool_calls_from_tail([]))
self.assertIsNone(
_pending_tool_calls_from_tail([{"role": "user", "content": "hi"}])
)
self.assertIsNone(
_pending_tool_calls_from_tail([{"role": "assistant", "content": "ok"}])
)
def test_tail_with_tool_calls_is_parsed(self):
tail = build_assistant_message_for_conversation(
None, [{"id": "call_1", "name": "create_export", "arguments": {"a": 1}}]
)
pending = _pending_tool_calls_from_tail([{"role": "user"}, tail])
self.assertEqual(
pending, [{"id": "call_1", "name": "create_export", "arguments": {"a": 1}}]
)
def test_read_tools_never_await_approval(self):
pending = [{"id": "c1", "name": "search_objects", "arguments": {}}]
self.assertEqual(
_tool_calls_awaiting_approval(pending, _body(), WRITE_TOOLS), []
)
def test_write_tools_await_approval(self):
pending = [
{"id": "c1", "name": "search_objects", "arguments": {}},
{"id": "c2", "name": "create_export", "arguments": {"camera": "x"}},
]
awaiting = _tool_calls_awaiting_approval(pending, _body(), WRITE_TOOLS)
self.assertEqual(
awaiting,
[{"id": "c2", "name": "create_export", "arguments": {"camera": "x"}}],
)
def test_decided_calls_skip_approval(self):
pending = [
{"id": "c2", "name": "create_export", "arguments": {}},
{"id": "c3", "name": "set_camera_state", "arguments": {}},
]
body = _body(tool_decisions={"c2": "approve", "c3": "reject"})
self.assertEqual(_tool_calls_awaiting_approval(pending, body, WRITE_TOOLS), [])
class TestExecutePendingTools(unittest.TestCase):
def test_rejected_call_is_not_executed(self):
execute = AsyncMock(return_value={"success": True})
pending = [{"id": "c1", "name": "create_export", "arguments": {}}]
with patch("frigate.api.chat._execute_tool_internal", execute):
calls, results, extra = _run(
_execute_pending_tools(
pending, _request(), ["driveway"], decisions={"c1": "reject"}
)
)
execute.assert_not_called()
self.assertEqual(json.loads(results[0]["content"]), TOOL_REJECTED_RESULT)
self.assertEqual(results[0]["tool_call_id"], "c1")
self.assertEqual(calls[0].name, "create_export")
# The user's intent goes to the model as a follow-up user message.
self.assertEqual(len(extra), 1)
self.assertEqual(extra[0]["role"], "user")
text = extra[0]["content"][0]["text"]
self.assertIn("do not want to proceed", text)
self.assertIn("create export", text)
self.assertIn("clarification", text)
def test_approved_call_is_executed(self):
execute = AsyncMock(return_value={"success": True})
pending = [{"id": "c1", "name": "create_export", "arguments": {}}]
with patch("frigate.api.chat._execute_tool_internal", execute):
_calls, results, _extra = _run(
_execute_pending_tools(
pending, _request(), ["driveway"], decisions={"c1": "approve"}
)
)
execute.assert_awaited_once()
self.assertEqual(json.loads(results[0]["content"]), {"success": True})
def test_image_text_becomes_user_message(self):
execute = AsyncMock(
return_value={
"id": "evt",
"_image_url": "data:image/jpeg;base64,xx",
"_image_text": "Here is the thumbnail.",
}
)
pending = [{"id": "c1", "name": "get_event_image", "arguments": {}}]
with patch("frigate.api.chat._execute_tool_internal", execute):
_calls, results, extra = _run(
_execute_pending_tools(pending, _request(), ["driveway"])
)
self.assertEqual(json.loads(results[0]["content"]), {"id": "evt"})
self.assertEqual(extra[0]["role"], "user")
self.assertEqual(extra[0]["content"][0]["text"], "Here is the thumbnail.")
self.assertEqual(
extra[0]["content"][1]["image_url"]["url"], "data:image/jpeg;base64,xx"
)
class DatabaseTestCase(unittest.TestCase):
models = [Event, Export, ExportCase, Recordings, Previews]
def setUp(self):
self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
self.tmp.close()
self.db = SqliteExtDatabase(self.tmp.name)
for model in self.models:
model.bind(self.db, bind_refs=False, bind_backrefs=False)
self.db.connect()
self.db.create_tables(self.models)
def tearDown(self):
self.db.close()
os.unlink(self.tmp.name)
def make_event(self, event_id, camera="driveway", thumbnail="", **overrides):
fields = dict(
id=event_id,
label="car",
sub_label=None,
camera=camera,
start_time=1_700_000_100,
end_time=1_700_000_110,
top_score=0.9,
score=0.9,
false_positive=False,
zones=[],
thumbnail=thumbnail,
has_clip=True,
has_snapshot=False,
region=[0, 0, 1, 1],
box=[0, 0, 1, 1],
area=1,
retain_indefinitely=False,
ratio=1.0,
plus_id="",
model_hash="",
detector_type="",
model_type="",
data={},
)
fields.update(overrides)
return Event.create(**fields)
def make_case(self, case_id, name="Case"):
now = datetime.fromtimestamp(1_700_000_000)
return ExportCase.create(
id=case_id, name=name, description=None, created_at=now, updated_at=now
)
def _jpeg_base64() -> str:
frame = np.zeros((8, 8, 3), dtype=np.uint8)
_, encoded = cv2.imencode(".jpg", frame)
return base64.b64encode(encoded.tobytes()).decode("utf-8")
class TestGetEventImage(DatabaseTestCase):
def test_requires_vision(self):
self.make_event("evt", thumbnail=_jpeg_base64())
result = _run(
_execute_get_event_image(
_request(supports_vision=False), {"event_id": "evt"}, ["driveway"]
)
)
self.assertIn("vision", result["error"])
def test_unknown_event(self):
result = _run(
_execute_get_event_image(_request(), {"event_id": "nope"}, ["driveway"])
)
self.assertIn("nope", result["error"])
def test_camera_access_denied(self):
self.make_event("evt", camera="garage", thumbnail=_jpeg_base64())
result = _run(
_execute_get_event_image(_request(), {"event_id": "evt"}, ["driveway"])
)
self.assertIn("access denied", result["error"])
def test_thumbnail_is_attached(self):
self.make_event(
"evt", thumbnail=_jpeg_base64(), data={"description": "a red car"}
)
result = _run(
_execute_get_event_image(_request(), {"event_id": "evt"}, ["driveway"])
)
self.assertEqual(result["id"], "evt")
self.assertEqual(result["image"], "thumbnail")
self.assertEqual(result["description"], "a red car")
self.assertIn("start_time_local", result)
self.assertTrue(result["_image_url"].startswith("data:image/jpeg;base64,"))
self.assertIn("thumbnail", result["_image_text"])
def test_snapshot_falls_back_to_thumbnail(self):
self.make_event("evt", thumbnail=_jpeg_base64(), has_snapshot=False)
result = _run(
_execute_get_event_image(
_request(), {"event_id": "evt", "image": "snapshot"}, ["driveway"]
)
)
self.assertEqual(result["image"], "thumbnail")
self.assertIn("note", result)
def test_no_image_available(self):
self.make_event("evt", thumbnail="")
with patch("frigate.api.chat.get_event_thumbnail_bytes", return_value=None):
result = _run(
_execute_get_event_image(_request(), {"event_id": "evt"}, ["driveway"])
)
self.assertIn("error", result)
class TestGetExportCases(DatabaseTestCase):
def test_no_cases(self):
result = _execute_get_export_cases(["driveway"])
self.assertEqual(result["cases"], [])
self.assertIn("message", result)
def test_counts_only_accessible_exports(self):
self.make_case("case_a", name="Break-in")
self.make_case("case_b", name="Empty")
for idx, camera in enumerate(["driveway", "driveway", "garage"]):
Export.create(
id=f"exp_{idx}",
camera=camera,
name=f"Export {idx}",
date=datetime.fromtimestamp(1_700_000_000 + idx),
video_path=f"/exports/{idx}.mp4",
thumb_path=f"/exports/{idx}.jpg",
in_progress=False,
export_case="case_a",
)
result = _execute_get_export_cases(["driveway"])
by_id = {c["id"]: c for c in result["cases"]}
self.assertEqual(by_id["case_a"]["export_count"], 2)
self.assertEqual(by_id["case_b"]["export_count"], 0)
self.assertEqual(by_id["case_a"]["name"], "Break-in")
self.assertIn("created_at_local", by_id["case_a"])
class TestCreateExport(DatabaseTestCase):
def setUp(self):
super().setUp()
Recordings.create(
id="rec_1",
camera="driveway",
path="/recordings/rec_1.mp4",
start_time=1_700_000_000,
end_time=1_700_001_000,
duration=1000,
)
@staticmethod
def _iso(timestamp: int) -> str:
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%dT%H:%M:%S")
def _args(self, **overrides):
args = {
"camera": "driveway",
"start_time": self._iso(1_700_000_100),
"end_time": self._iso(1_700_000_200),
}
args.update(overrides)
return args
def test_requires_camera_and_range(self):
result = _run(_execute_create_export(_request(), {}, ["driveway"]))
self.assertIn("required", result["error"])
def test_camera_access_denied(self):
result = _run(
_execute_create_export(
_request(), self._args(camera="garage"), ["driveway"]
)
)
self.assertIn("access denied", result["error"])
def test_end_before_start(self):
result = _run(
_execute_create_export(
_request(),
self._args(end_time=self._iso(1_700_000_050)),
["driveway"],
)
)
self.assertIn("after start_time", result["error"])
def test_existing_case_requires_admin(self):
self.make_case("case_a")
result = _run(
_execute_create_export(
_request(role="viewer"),
self._args(export_case_id="case_a"),
["driveway"],
)
)
self.assertIn("admins", result["error"])
def test_unknown_case(self):
result = _run(
_execute_create_export(
_request(), self._args(export_case_id="nope"), ["driveway"]
)
)
self.assertIn("nope", result["error"])
def test_export_is_queued(self):
self.make_case("case_a")
with patch("frigate.api.chat.start_export_job") as start:
result = _run(
_execute_create_export(
_request(),
self._args(name="Delivery", export_case_id="case_a"),
["driveway"],
)
)
self.assertTrue(result["success"])
self.assertEqual(result["status"], "queued")
self.assertEqual(result["camera"], "driveway")
self.assertEqual(result["export_case_id"], "case_a")
job = start.call_args.args[1]
self.assertEqual(job.camera, "driveway")
self.assertEqual(job.request_start_time, 1_700_000_100)
self.assertEqual(job.request_end_time, 1_700_000_200)
self.assertEqual(job.name, "Delivery")
self.assertEqual(job.export_case_id, "case_a")
def test_no_recordings_in_range(self):
result = _run(
_execute_create_export(
_request(),
{
"camera": "driveway",
"start_time": "2030-01-01T00:00:00",
"end_time": "2030-01-01T01:00:00",
},
["driveway"],
)
)
self.assertIn("No recordings", result["error"])
def test_queue_full(self):
with patch(
"frigate.api.chat.start_export_job", side_effect=ExportQueueFullError()
):
result = _run(
_execute_create_export(_request(), self._args(), ["driveway"])
)
self.assertIn("queue is full", result["error"])
class TestSetCameraStateRoles(unittest.TestCase):
def test_non_admin_is_rejected(self):
from frigate.api.chat import _execute_set_camera_state
result = _run(
_execute_set_camera_state(
_request(role="viewer"),
{"camera": "driveway", "feature": "detect", "value": "OFF"},
)
)
self.assertIn("Admin", result["error"])
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch
from frigate.app import FrigateApp
from frigate.comms.dispatcher import Dispatcher
from frigate.comms.mqtt import MqttClient
from frigate.comms.runtime_state import RuntimeStatePersistence
from frigate.config import BirdseyeModeEnum
@@ -510,3 +511,92 @@ class TestStartupAppliesConfigLayersBeforeWorkersStart(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class TestNoticeWiring(unittest.TestCase):
"""The dispatcher forwards update_notice requests and publishes the list."""
def setUp(self) -> None:
self.registry = MagicMock()
self.registry.active.return_value = [{"id": "retention_unmet"}]
config = MagicMock()
config.cameras = {}
with (
patch("frigate.comms.dispatcher.CameraActivityManager"),
patch("frigate.comms.dispatcher.AudioActivityManager"),
):
self.dispatcher = Dispatcher(
config,
MagicMock(),
MagicMock(),
{},
[],
notice_registry=self.registry,
)
def test_registry_listener_is_subscribed(self) -> None:
self.registry.subscribe.assert_called_once_with(
self.dispatcher._publish_notices
)
def test_raise_request_reaches_registry(self) -> None:
self.dispatcher._receive(
"update_notice",
{
"action": "raise",
"kind": "ffmpeg_crash_loop",
"scope": "front_door",
"params": {"restarts": 5},
},
)
self.registry.raise_notice.assert_called_once_with(
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
)
def test_resolve_request_reaches_registry(self) -> None:
self.dispatcher._receive(
"update_notice",
{"action": "resolve", "kind": "retention_unmet", "scope": None},
)
self.registry.resolve.assert_called_once_with("retention_unmet", None)
def test_malformed_request_does_not_raise(self) -> None:
self.registry.raise_notice.side_effect = RuntimeError("boom")
self.dispatcher._receive("update_notice", "not a dict")
self.dispatcher._receive(
"update_notice", {"action": "raise", "kind": "x", "params": {}}
)
def test_publish_local_skips_mqtt(self) -> None:
mqtt = MagicMock(spec=MqttClient)
other = MagicMock()
self.dispatcher.comms = [mqtt, other]
self.dispatcher.publish_local("notices", "[]")
mqtt.publish.assert_not_called()
other.publish.assert_called_once_with("notices", "[]", False)
def test_listener_publishes_active_list(self) -> None:
self.dispatcher.publish_local = MagicMock()
self.dispatcher._publish_notices()
self.dispatcher.publish_local.assert_called_once_with(
"notices", '[{"id": "retention_unmet"}]'
)
def test_snapshot_includes_notices(self) -> None:
publisher = MagicMock()
self.dispatcher._build_camera_activity_snapshot = MagicMock(
return_value=({}, {})
)
self.dispatcher.web_push_client = None
self.dispatcher.publish_runtime_snapshot(publisher)
publisher.assert_any_call("notices", '[{"id": "retention_unmet"}]', False)
+102
View File
@@ -0,0 +1,102 @@
"""Tests for the model download failure notice."""
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
from frigate.util import downloader
from frigate.util.downloader import ModelDownloader
class TestModelDownloadNotice(unittest.TestCase):
def setUp(self):
self.download_path = tempfile.mkdtemp()
downloader.last_download_error.clear()
def _notice_calls(self, requestor: MagicMock) -> list[dict]:
return [
call.args[1]
for call in requestor.send_data.call_args_list
if call.args[0] == "update_notice"
]
def _downloader(self, download_func) -> ModelDownloader:
with patch("frigate.util.downloader.InterProcessRequestor"):
return ModelDownloader(
"facedet", self.download_path, ["facedet.onnx"], download_func
)
def test_raising_download_reports_and_reraises(self):
def failing(path: str) -> None:
raise RuntimeError("HTTP 503 from upstream\nmore detail")
model_downloader = self._downloader(failing)
with self.assertRaises(RuntimeError):
model_downloader._download_models()
notices = self._notice_calls(model_downloader.requestor)
self.assertEqual(len(notices), 1)
self.assertEqual(notices[0]["action"], "raise")
self.assertEqual(notices[0]["kind"], "model_download_failed")
self.assertEqual(notices[0]["scope"], "facedet/facedet.onnx")
self.assertEqual(
notices[0]["params"],
{"file": "facedet.onnx", "error": "HTTP 503 from upstream"},
)
def test_swallowing_download_that_leaves_no_file_reports(self):
target = os.path.join(self.download_path, "facedet.onnx")
def swallowing(path: str) -> None:
# mirrors the processors: download_from_url fails, they only log
with patch(
"frigate.util.downloader.requests.get", side_effect=OSError("dns")
):
try:
ModelDownloader.download_from_url("http://x/facedet.onnx", path)
except Exception:
pass
model_downloader = self._downloader(swallowing)
model_downloader._download_models()
notices = self._notice_calls(model_downloader.requestor)
self.assertEqual(len(notices), 1)
self.assertEqual(notices[0]["action"], "raise")
self.assertEqual(notices[0]["params"], {"file": "facedet.onnx", "error": "dns"})
self.assertFalse(os.path.exists(target))
def test_success_resolves(self):
def succeeding(path: str) -> None:
with open(path, "w") as f:
f.write("model")
model_downloader = self._downloader(succeeding)
model_downloader._download_models()
notices = self._notice_calls(model_downloader.requestor)
self.assertEqual(len(notices), 1)
self.assertEqual(notices[0]["action"], "resolve")
self.assertEqual(notices[0]["scope"], "facedet/facedet.onnx")
def test_a_sibling_downloader_only_resolves_its_own_files(self):
"""PaddleOCR, facedet and jina each spread one model_name over several
downloaders, so a resolve must never reach a sibling's file."""
def succeeding(path: str) -> None:
with open(path, "w") as f:
f.write("model")
with patch("frigate.util.downloader.InterProcessRequestor"):
sibling = ModelDownloader(
"paddleocr-onnx", self.download_path, ["det.onnx"], succeeding
)
sibling._download_models()
self.assertEqual(
[n["scope"] for n in self._notice_calls(sibling.requestor)],
["paddleocr-onnx/det.onnx"],
)
+236
View File
@@ -0,0 +1,236 @@
"""Tests for the notice registry and its kind definitions."""
import logging
import os
import unittest
from unittest.mock import MagicMock
from peewee_migrate import Router
from playhouse.sqlite_ext import SqliteExtDatabase
from playhouse.sqliteq import SqliteQueueDatabase
from frigate.models import Notice, NoticeStats
from frigate.notices.registry import DismissResult, NoticeRegistry
from frigate.notices.types import (
NOTICE_KINDS,
NoticeMode,
NoticeSeverity,
notice_id,
)
from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
class TestNoticeKinds(unittest.TestCase):
def test_every_kind_is_keyed_by_its_own_key(self):
for key, definition in NOTICE_KINDS.items():
self.assertEqual(key, definition.key)
def test_first_producers_are_defined(self):
self.assertEqual(NOTICE_KINDS["ffmpeg_crash_loop"].mode, NoticeMode.state)
self.assertEqual(
NOTICE_KINDS["ffmpeg_crash_loop"].severity, NoticeSeverity.error
)
self.assertEqual(NOTICE_KINDS["detector_stuck"].mode, NoticeMode.event)
self.assertEqual(
NOTICE_KINDS["detector_stuck"].severity, NoticeSeverity.warning
)
self.assertEqual(NOTICE_KINDS["model_download_failed"].mode, NoticeMode.event)
self.assertEqual(NOTICE_KINDS["retention_unmet"].mode, NoticeMode.state)
self.assertEqual(NOTICE_KINDS["update_available"].mode, NoticeMode.state)
self.assertEqual(NOTICE_KINDS["update_available"].severity, NoticeSeverity.info)
def test_notice_id_with_and_without_scope(self):
self.assertEqual(notice_id("retention_unmet", None), "retention_unmet")
self.assertEqual(
notice_id("ffmpeg_crash_loop", "front_door"),
"ffmpeg_crash_loop:front_door",
)
class TestNoticeRegistry(unittest.TestCase):
def setUp(self):
migrate_db = SqliteExtDatabase("test.db")
del logging.getLogger("peewee_migrate").handlers[:]
router = Router(migrate_db)
router.run()
migrate_db.close()
self.db = SqliteQueueDatabase(TEST_DB)
self.db.bind([Notice, NoticeStats])
self.registry = NoticeRegistry()
self.listener = MagicMock()
self.registry.subscribe(self.listener)
def tearDown(self):
if not self.db.is_closed():
self.db.close()
try:
for file in TEST_DB_CLEANUPS:
os.remove(file)
except OSError:
pass
def test_raise_inserts_and_counts_one_occurrence(self):
self.registry.raise_notice(
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
)
active = self.registry.active()
self.assertEqual(len(active), 1)
self.assertEqual(active[0]["id"], "ffmpeg_crash_loop:front_door")
self.assertEqual(active[0]["severity"], "error")
self.assertEqual(active[0]["mode"], "state")
self.assertEqual(active[0]["category"], "camera")
self.assertEqual(active[0]["scope"], "front_door")
self.assertEqual(active[0]["params"], {"restarts": 5})
self.assertEqual(active[0]["count"], 1)
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_called_once()
def test_state_re_raise_with_new_params_updates_without_counting(self):
self.registry.raise_notice(
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
)
self.listener.reset_mock()
self.registry.raise_notice(
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 6}
)
active = self.registry.active()
self.assertEqual(active[0]["count"], 1)
self.assertEqual(active[0]["params"], {"restarts": 6})
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_called_once()
def test_state_re_raise_with_same_params_is_a_no_op(self):
self.registry.raise_notice(
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
)
before = self.registry.active()[0]["last_seen"]
self.listener.reset_mock()
self.registry.raise_notice(
"ffmpeg_crash_loop", scope="front_door", params={"restarts": 5}
)
self.assertEqual(self.registry.active()[0]["last_seen"], before)
self.listener.assert_not_called()
def test_event_re_raise_counts_and_undismisses(self):
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
self.assertEqual(self.registry.dismiss("detector_stuck"), DismissResult.ok)
self.assertEqual(self.registry.active(), [])
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
# no scope here on purpose: a global event id is still valid
active = self.registry.active()
self.assertEqual(len(active), 1)
self.assertEqual(active[0]["count"], 2)
self.assertIsNone(active[0]["dismissed_at"])
stats = self.registry.stats()[0]
self.assertEqual(stats["occurrences"], 2)
self.assertEqual(stats["dismissals"], 1)
def test_resolve_deletes_and_keeps_stats(self):
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
self.listener.reset_mock()
self.registry.resolve("retention_unmet")
self.registry.resolve("retention_unmet")
self.assertEqual(self.registry.active(), [])
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
self.listener.assert_called_once()
def test_state_notice_cannot_be_dismissed(self):
self.registry.raise_notice("retention_unmet", params={})
self.assertEqual(
self.registry.dismiss("retention_unmet"), DismissResult.not_dismissable
)
self.assertEqual(len(self.registry.active()), 1)
def test_dismiss_unknown_id(self):
self.assertEqual(self.registry.dismiss("nope"), DismissResult.not_found)
def test_dismissed_hidden_unless_requested(self):
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
self.registry.dismiss("detector_stuck")
self.assertEqual(self.registry.active(), [])
self.assertEqual(len(self.registry.active(include_dismissed=True)), 1)
def test_startup_hides_state_notices_until_confirmed(self):
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
self.registry.raise_notice(
"detector_stuck", scope="ov", params={"detector": "ov"}
)
self.registry.mark_state_notices_unconfirmed()
ids = [n["id"] for n in self.registry.active()]
self.assertEqual(ids, ["detector_stuck:ov"])
# the producer confirms the same episode: visible again, no new occurrence
self.registry.raise_notice("retention_unmet", params={"needed_mb": 1})
ids = [n["id"] for n in self.registry.active()]
self.assertEqual(ids, ["retention_unmet", "detector_stuck:ov"])
self.assertEqual(
[s for s in self.registry.stats() if s["kind"] == "retention_unmet"][0][
"occurrences"
],
1,
)
def test_unconfirmed_across_two_startups_is_deleted(self):
self.registry.raise_notice("retention_unmet", params={})
self.registry.mark_state_notices_unconfirmed()
self.registry.mark_state_notices_unconfirmed()
self.assertEqual(self.registry.active(include_dismissed=True), [])
def test_replay_camera_is_ignored(self):
self.registry.raise_notice(
"ffmpeg_crash_loop", scope="_replay_front_door", params={}
)
self.assertEqual(self.registry.active(), [])
self.assertEqual(self.registry.stats(), [])
def test_unknown_kind_is_dropped(self):
self.registry.raise_notice("not_a_kind", params={})
self.assertEqual(self.registry.active(), [])
self.listener.assert_not_called()
def test_active_sorts_by_severity_then_recency(self):
self.registry.raise_notice("detector_stuck", params={"detector": "ov"})
self.registry.raise_notice("retention_unmet", params={})
ids = [n["id"] for n in self.registry.active()]
self.assertEqual(ids, ["retention_unmet", "detector_stuck"])
def test_listener_exception_does_not_propagate(self):
self.registry.subscribe(MagicMock(side_effect=RuntimeError("boom")))
self.registry.raise_notice("retention_unmet", params={})
self.assertEqual(len(self.registry.active()), 1)
def test_resolve_camera_drops_only_that_camera(self):
self.registry.raise_notice("ffmpeg_crash_loop", scope="front_door", params={})
self.registry.raise_notice("ffmpeg_crash_loop", scope="garage", params={})
self.registry.raise_notice(
"model_download_failed", scope="front_door", params={}
)
self.registry.resolve_camera("front_door")
ids = sorted(n["id"] for n in self.registry.active())
self.assertEqual(
ids, ["ffmpeg_crash_loop:garage", "model_download_failed:front_door"]
)
+129
View File
@@ -0,0 +1,129 @@
"""Tests for the daily latest version refresh and the update notice."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.stats import emitter
from frigate.stats.util import is_newer_version
class TestIsNewerVersion(unittest.TestCase):
def test_newer(self):
self.assertTrue(is_newer_version("0.18.1-abcdef", "0.19.0"))
self.assertTrue(is_newer_version("0.19.0-abcdef", "0.19.1"))
self.assertTrue(is_newer_version("0.19.0-abcdef", "1.0.0"))
def test_not_newer(self):
self.assertFalse(is_newer_version("0.19.0-abcdef", "0.19.0"))
self.assertFalse(is_newer_version("0.19.1-abcdef", "0.19.0"))
self.assertFalse(is_newer_version("0.19.0-abcdef", "0.19.0-beta2"))
def test_prerelease_is_behind_its_final_release(self):
self.assertTrue(is_newer_version("0.19.0-beta2", "0.19.0"))
self.assertTrue(is_newer_version("0.19.0-rc1", "0.19.0"))
self.assertTrue(is_newer_version("0.19.0-RC1", "0.19.0"))
def test_prerelease_of_a_later_line_is_not_behind(self):
self.assertFalse(is_newer_version("0.20.0-beta1", "0.19.0"))
self.assertFalse(is_newer_version("0.19.0-beta2", "0.19.0-beta2"))
def test_unparseable_is_never_newer(self):
self.assertFalse(is_newer_version("0.19.0-abcdef", "disabled"))
self.assertFalse(is_newer_version("0.19.0-abcdef", "unknown"))
self.assertFalse(is_newer_version("dev", "0.19.0"))
class TestUpdateNotice(unittest.TestCase):
def _emitter(self, latest: str) -> emitter.StatsEmitter:
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
stats_emitter.config = MagicMock()
stats_emitter.stats_tracking = {"latest_frigate_version": latest}
stats_emitter.notice_registry = MagicMock()
return stats_emitter
def test_newer_release_raises(self):
stats_emitter = self._emitter("0.19.0")
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
stats_emitter._check_update_notice()
stats_emitter.notice_registry.raise_notice.assert_called_once_with(
"update_available", params={"version": "0.19.0"}
)
stats_emitter.notice_registry.resolve.assert_not_called()
def test_current_or_disabled_resolves(self):
for latest in ("0.18.1", "disabled"):
stats_emitter = self._emitter(latest)
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
stats_emitter._check_update_notice()
stats_emitter.notice_registry.raise_notice.assert_not_called()
stats_emitter.notice_registry.resolve.assert_called_once_with(
"update_available"
)
def test_a_failed_lookup_leaves_the_notice_alone(self):
stats_emitter = self._emitter("unknown")
with patch.object(emitter, "VERSION", "0.18.1-abcdef"):
stats_emitter._check_update_notice()
stats_emitter.notice_registry.raise_notice.assert_not_called()
stats_emitter.notice_registry.resolve.assert_not_called()
def test_refresh_updates_tracking_on_a_thread_then_checks(self):
stats_emitter = self._emitter("0.18.0")
with (
patch.object(emitter, "get_latest_version", return_value="0.19.0"),
patch.object(emitter, "VERSION", "0.18.1-abcdef"),
patch.object(emitter.threading, "Thread") as thread,
):
stats_emitter._refresh_latest_version()
thread.return_value.start.assert_called_once()
# run the thread body inline
thread.call_args.kwargs["target"]()
self.assertEqual(
stats_emitter.stats_tracking["latest_frigate_version"], "0.19.0"
)
stats_emitter.notice_registry.raise_notice.assert_called_once()
def test_failed_refresh_keeps_the_last_known_version(self):
stats_emitter = self._emitter("0.20.0")
with (
patch.object(emitter, "get_latest_version", return_value="unknown"),
patch.object(emitter, "VERSION", "0.19.0-abcdef"),
patch.object(emitter.threading, "Thread") as thread,
):
stats_emitter._refresh_latest_version()
thread.call_args.kwargs["target"]()
self.assertEqual(
stats_emitter.stats_tracking["latest_frigate_version"], "0.20.0"
)
stats_emitter.notice_registry.raise_notice.assert_called_once_with(
"update_available", params={"version": "0.20.0"}
)
stats_emitter.notice_registry.resolve.assert_not_called()
def test_disabled_version_check_still_clears_the_notice(self):
stats_emitter = self._emitter("0.20.0")
with (
patch.object(emitter, "get_latest_version", return_value="disabled"),
patch.object(emitter, "VERSION", "0.19.0-abcdef"),
patch.object(emitter.threading, "Thread") as thread,
):
stats_emitter._refresh_latest_version()
thread.call_args.kwargs["target"]()
self.assertEqual(
stats_emitter.stats_tracking["latest_frigate_version"], "disabled"
)
stats_emitter.notice_registry.resolve.assert_called_once_with(
"update_available"
)
+29 -1
View File
@@ -3,7 +3,7 @@ import logging
import os
import tempfile
import unittest
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from peewee import DoesNotExist
from peewee_migrate import Router
@@ -556,6 +556,34 @@ class TestHttp(unittest.TestCase):
assert Recordings.get(Recordings.id == rec_k2_id)
assert Recordings.get(Recordings.id == rec_k3_id)
def test_unmet_retention_raises_notice(self):
registry = MagicMock()
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock(), registry)
storage.camera_storage_stats = {
"front_door": {"needs_refresh": False, "usage": 10, "bandwidth": 10}
}
with patch.object(storage, "expected_hourly_bandwidth", return_value=100.0):
storage.reduce_storage_consumption()
registry.raise_notice.assert_called_once()
kind = registry.raise_notice.call_args.args[0]
params = registry.raise_notice.call_args.kwargs["params"]
self.assertEqual(kind, "retention_unmet")
self.assertEqual(params["needed_mb"], 100.0)
self.assertEqual(params["cleared_mb"], 0.0)
def test_clean_run_resolves_notice(self):
registry = MagicMock()
config = FrigateConfig(**self.minimal_config)
storage = StorageMaintainer(config, MagicMock(), registry)
with patch.object(storage, "check_storage_needs_cleanup", return_value=False):
storage._maintain_once()
registry.resolve.assert_called_once_with("retention_unmet")
def _insert_mock_event(
id: str,
+38
View File
@@ -0,0 +1,38 @@
"""Tests for the detector stuck notice raised by the Frigate watchdog."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.watchdog import FrigateWatchdog
class TestDetectorStuckNotice(unittest.TestCase):
def _stuck_detector(self) -> MagicMock:
detector = MagicMock()
detector.detection_start.value = 1.0
detector.detect_process.is_alive.return_value = True
return detector
def test_stuck_restart_raises_notice(self):
registry = MagicMock()
detector = self._stuck_detector()
watchdog = FrigateWatchdog({"ov": detector}, MagicMock(), registry)
with patch("frigate.watchdog.datetime") as mock_datetime:
mock_datetime.datetime.now.return_value.timestamp.return_value = 100.0
watchdog._check_detectors()
detector.start_or_restart.assert_called_once()
registry.raise_notice.assert_called_once_with(
"detector_stuck", scope="ov", params={"detector": "ov"}
)
def test_healthy_detector_raises_nothing(self):
registry = MagicMock()
detector = self._stuck_detector()
detector.detection_start.value = 0.0
watchdog = FrigateWatchdog({"ov": detector}, MagicMock(), registry)
watchdog._check_detectors()
registry.raise_notice.assert_not_called()
+20
View File
@@ -7,6 +7,7 @@ from types import SimpleNamespace
from typing import Any
from frigate.comms.ws import (
_WS_BLOCKED_TOPICS,
WebSocketClient,
_classify_outbound,
_collect_zone_names,
@@ -106,6 +107,12 @@ class TestClassifyOutbound(unittest.TestCase):
def test_birdseye_layout_is_unrestricted_only(self):
self.assertEqual(self._classify("birdseye_layout"), ("unrestricted_only", None))
def test_notices_is_admin_only(self):
self.assertEqual(self._classify("notices"), ("admin_only", None))
def test_update_notice_is_blocked_inbound(self):
self.assertIn("update_notice", _WS_BLOCKED_TOPICS)
# --- Camera-prefixed ---
def test_camera_state_topic_resolves_to_camera(self):
@@ -295,6 +302,19 @@ class TestMaterializeForWs(unittest.TestCase):
full = json.dumps({"topic": topic, "payload": payload})
return _materialize_for_ws(ws, topic, full, scope, parsed, self.config)
def test_admin_only_topic_reaches_admin_not_viewer(self):
admin = SimpleNamespace(environ={"HTTP_REMOTE_ROLE": "admin"})
viewer = SimpleNamespace(environ={"HTTP_REMOTE_ROLE": "viewer"})
scope = ("admin_only", None)
self.assertEqual(
_materialize_for_ws(admin, "notices", "msg", scope, None, self.config),
"msg",
)
self.assertIsNone(
_materialize_for_ws(viewer, "notices", "msg", scope, None, self.config)
)
# --- Globals: every authenticated client sees them ---
def test_globals_reach_admin(self):
+52 -2
View File
@@ -7,12 +7,22 @@ from pathlib import Path
import requests
from frigate.comms.inter_process import InterProcessRequestor
from frigate.const import UPDATE_MODEL_STATE
from frigate.const import UPDATE_MODEL_STATE, UPDATE_NOTICE
from frigate.types import ModelStatusTypesEnum
from frigate.util.file import FileLock
logger = logging.getLogger(__name__)
# target path -> first line of the last download error for it; every existing
# download function swallows its exceptions, so this is how the downloader
# thread learns why a file is still missing
last_download_error: dict[str, str] = {}
def _first_line(error: BaseException) -> str:
text = str(error).strip().splitlines()
return (text[0] if text else type(error).__name__)[:200]
class ModelDownloader:
def __init__(
@@ -48,6 +58,33 @@ class ModelDownloader:
)
self.download_thread.start()
def _notice_scope(self, file_name: str) -> str:
# per file: several loaders share one model_name with disjoint files,
# so a model wide scope lets one of them clear another's notice
return f"{self.model_name}/{file_name}"
def _report_failure(self, file_name: str, error: str) -> None:
self.requestor.send_data(
UPDATE_NOTICE,
{
"action": "raise",
"kind": "model_download_failed",
"scope": self._notice_scope(file_name),
"params": {"file": file_name, "error": error},
},
)
def _resolve_failure(self, file_name: str) -> None:
self.requestor.send_data(
UPDATE_NOTICE,
{
"action": "resolve",
"kind": "model_download_failed",
"scope": self._notice_scope(file_name),
"params": {},
},
)
def _download_models(self):
for file_name in self.file_names:
path = os.path.join(self.download_path, file_name)
@@ -57,8 +94,20 @@ class ModelDownloader:
if not os.path.exists(path):
with lock:
if not os.path.exists(path):
self.download_func(path)
try:
self.download_func(path)
except Exception as e:
self._report_failure(file_name, _first_line(e))
raise
if not os.path.exists(path):
self._report_failure(
file_name,
last_download_error.pop(path, "download failed"),
)
continue
self._resolve_failure(file_name)
self.requestor.send_data(
UPDATE_MODEL_STATE,
{
@@ -93,6 +142,7 @@ class ModelDownloader:
temporary_filename.rename(save_path)
except Exception as e:
logger.error(f"Error downloading model: {str(e)}")
last_download_error[save_path] = _first_line(e)
raise
if not silent:
+60
View File
@@ -29,6 +29,7 @@ from frigate.const import (
STREAM_TYPE_MAIN,
STREAM_TYPE_SUB,
STREAM_TYPE_TO_ROLE,
UPDATE_NOTICE,
)
from frigate.log import LogPipe
from frigate.util.builtin import EventsPerSecond, get_record_segment_time
@@ -43,6 +44,11 @@ logger = logging.getLogger(__name__)
RECORD_GRACE_SECONDS = 90
# a camera whose ffmpeg restarts this many times in the trailing hour is in a
# crash loop; it recovers once the capture thread stays up this long
CRASH_LOOP_RESTARTS = 5
CRASH_LOOP_RECOVERY_S = 600
def capture_frames(
ffmpeg_process: sp.Popen[Any],
@@ -140,6 +146,8 @@ class CameraWatchdog(threading.Thread):
self.stop_event = stop_event
self.sleeptime = self.config.ffmpeg.retry_interval
self.reconnect_timestamps = deque()
self.crash_loop_raised = False
self._crash_loop_sent_restarts = 0
self.stalls = stalls
self.reconnects = reconnects
self.detection_frame = detection_frame
@@ -189,6 +197,53 @@ class CameraWatchdog(threading.Thread):
self._last_record_status: dict[str, str] = {}
self._last_status_update_time: float = 0.0
def _update_crash_loop_notice(self, now: float) -> None:
"""Raise the crash loop notice as restarts pile up, resolve once stable.
Called on every 1 second tick, so raises are sent only when the restart
count changes; the registry treats repeats of a state notice as updates.
"""
restarts = len(self.reconnect_timestamps)
alive = self.capture_thread is not None and self.capture_thread.is_alive()
if not alive and restarts >= CRASH_LOOP_RESTARTS:
if self.crash_loop_raised and restarts == self._crash_loop_sent_restarts:
return
self.requestor.send_data(
UPDATE_NOTICE,
{
"action": "raise",
"kind": "ffmpeg_crash_loop",
"scope": self.config.name,
"params": {"restarts": restarts},
},
)
self.crash_loop_raised = True
self._crash_loop_sent_restarts = restarts
elif (
self.crash_loop_raised
and alive
and (
restarts == 0
or now - self.reconnect_timestamps[-1] >= CRASH_LOOP_RECOVERY_S
)
):
self._resolve_crash_loop()
def _resolve_crash_loop(self) -> None:
self.requestor.send_data(
UPDATE_NOTICE,
{
"action": "resolve",
"kind": "ffmpeg_crash_loop",
"scope": self.config.name,
"params": {},
},
)
self.crash_loop_raised = False
self._crash_loop_sent_restarts = 0
def _send_detect_status(self, status: str, now: float) -> None:
"""Send detect status only if changed or retry_interval has elapsed."""
if (
@@ -379,6 +434,9 @@ class CameraWatchdog(threading.Thread):
# cameras without a sub stream never get a record_sub topic
if self.config.record.sub.enabled:
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
if self.crash_loop_raised:
self._resolve_crash_loop()
self.was_enabled = enabled
continue
@@ -456,6 +514,7 @@ class CameraWatchdog(threading.Thread):
self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
)
self._update_crash_loop_notice(now)
if can_restart:
self.reset_capture_thread(terminate=False)
last_restart_time = now
@@ -485,6 +544,7 @@ class CameraWatchdog(threading.Thread):
# process is running normally
self._send_detect_status("online", now)
self.fps_overflow_count = 0
self._update_crash_loop_notice(now)
for p in self.ffmpeg_other_processes:
poll = p["process"].poll()
+28 -18
View File
@@ -7,6 +7,7 @@ from collections.abc import Callable
from dataclasses import dataclass, field
from multiprocessing.synchronize import Event as MpEvent
from frigate.notices.registry import NoticeRegistry
from frigate.object_detection.base import ObjectDetectProcess
from frigate.util.process import FrigateProcess
from frigate.util.services import restart_frigate
@@ -44,10 +45,12 @@ class FrigateWatchdog(threading.Thread):
self,
detectors: dict[str, ObjectDetectProcess],
stop_event: MpEvent,
notice_registry: NoticeRegistry | None = None,
):
super().__init__(name="frigate_watchdog")
self.detectors = detectors
self.stop_event = stop_event
self.notice_registry = notice_registry
self._monitored: list[MonitoredProcess] = []
def register(
@@ -111,27 +114,34 @@ class FrigateWatchdog(threading.Thread):
except Exception:
logger.exception("Failed to restart %s", entry.name)
def _check_detectors(self) -> None:
now = datetime.datetime.now().timestamp()
for name, detector in self.detectors.items():
detection_start = detector.detection_start.value # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
if detection_start > 0.0 and now - detection_start > 10:
logger.info(
"Detection appears to be stuck. Restarting detection process..."
)
detector.start_or_restart()
if self.notice_registry is not None:
self.notice_registry.raise_notice(
"detector_stuck", scope=name, params={"detector": name}
)
elif (
detector.detect_process is not None
and not detector.detect_process.is_alive()
):
logger.info("Detection appears to have stopped. Exiting Frigate...")
restart_frigate()
def run(self) -> None:
time.sleep(10)
while not self.stop_event.wait(10):
now = datetime.datetime.now().timestamp()
# check the detection processes
for detector in self.detectors.values():
detection_start = detector.detection_start.value # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards
if detection_start > 0.0 and now - detection_start > 10:
logger.info(
"Detection appears to be stuck. Restarting detection process..."
)
detector.start_or_restart()
elif (
detector.detect_process is not None
and not detector.detect_process.is_alive()
):
logger.info("Detection appears to have stopped. Exiting Frigate...")
restart_frigate()
self._check_detectors()
for entry in self._monitored:
self._check_process(entry)
+2
View File
@@ -59,6 +59,7 @@ from frigate.api import (
hardware,
media,
motion_search,
notices,
notification,
preview,
record,
@@ -154,6 +155,7 @@ def build_app() -> FastAPI:
notification.router,
export.router,
hardware.router,
notices.router,
event.router,
media.router,
motion_search.router,
+38
View File
@@ -0,0 +1,38 @@
"""Peewee migrations -- 038_create_notices.py.
Creates the two notice tables: active notices, and per-kind lifetime counts.
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE TABLE IF NOT EXISTS "notice" ('
'"id" VARCHAR(150) NOT NULL PRIMARY KEY, '
'"kind" VARCHAR(50) NOT NULL, '
'"scope" VARCHAR(100), '
'"params" TEXT NOT NULL, '
'"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, '
'"count" INTEGER NOT NULL, '
'"dismissed_at" DATETIME)'
)
migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")')
migrator.sql(
'CREATE TABLE IF NOT EXISTS "noticestats" ('
'"kind" VARCHAR(50) NOT NULL PRIMARY KEY, '
'"occurrences" INTEGER NOT NULL, '
'"dismissals" INTEGER NOT NULL, '
'"first_seen" DATETIME NOT NULL, '
'"last_seen" DATETIME NOT NULL, '
'"reported_occurrences" INTEGER NOT NULL DEFAULT 0, '
'"reported_dismissals" INTEGER NOT NULL DEFAULT 0)'
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('DROP TABLE IF EXISTS "notice"')
migrator.sql('DROP TABLE IF EXISTS "noticestats"')
+1 -1
View File
@@ -19,7 +19,7 @@ export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
function deepMerge<T extends Record<string, unknown>>(
export function deepMerge<T extends Record<string, unknown>>(
base: T,
overrides?: DeepPartial<T>,
): T {
+2 -2
View File
@@ -2,7 +2,7 @@
* FrigateStats factory for E2E tests.
*/
import type { DeepPartial } from "./config";
import { deepMerge, type DeepPartial } from "./config";
function cameraStats(_name: string) {
return {
@@ -72,5 +72,5 @@ export function statsFactory(
overrides?: DeepPartial<typeof BASE_STATS>,
): typeof BASE_STATS {
if (!overrides) return BASE_STATS;
return { ...BASE_STATS, ...overrides } as typeof BASE_STATS;
return deepMerge(BASE_STATS, overrides) as typeof BASE_STATS;
}
+11
View File
@@ -48,6 +48,8 @@ export interface ApiMockOverrides {
available?: { key: string; presets: Record<string, string> }[];
};
users?: { username: string; role: string }[];
notices?: unknown[];
noticeStats?: unknown[];
}
export class ApiMocker {
@@ -201,6 +203,15 @@ export class ApiMocker {
}),
);
// Notices. The stats route is registered after the list route so it wins
// for /api/notices/stats; the list glob does not match a sub-path anyway.
await this.page.route("**/api/notices", (route) =>
route.fulfill({ json: overrides?.notices ?? [] }),
);
await this.page.route("**/api/notices/stats", (route) =>
route.fulfill({ json: overrides?.noticeStats ?? [] }),
);
// Users. GET lists them; POST/PUT (create, password) just succeed, so
// tests assert on the intercepted request body instead of a response.
await this.page.route("**/api/users**", (route) =>
+3 -3
View File
@@ -130,8 +130,8 @@ test.describe("Navigation — settings menu (desktop) @critical", () => {
const TARGETS = [
{ label: "Settings", url: /\/settings/ },
{ label: "System metrics", url: /\/system/ },
{ label: "System logs", url: /\/logs/ },
{ label: "Health and Metrics", url: /\/system/ },
{ label: "Logs", url: /\/logs/ },
{ label: "Configuration Editor", url: /\/config/ },
];
@@ -142,7 +142,7 @@ test.describe("Navigation — settings menu (desktop) @critical", () => {
.locator("aside .mb-8 div[class*='cursor-pointer']")
.first();
await gear.click();
await frigateApp.page.getByLabel(target.label).click();
await frigateApp.page.getByLabel(target.label, { exact: true }).click();
await expect(frigateApp.page).toHaveURL(target.url);
});
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Health tab tests -- MEDIUM tier.
*
* Default tab, notice list rendering, dismiss, empty state, update notice.
*/
import { test, expect } from "../fixtures/frigate-test";
const NOW = Math.floor(Date.now() / 1000);
const STATE_NOTICE = {
id: "ffmpeg_crash_loop:front_door",
kind: "ffmpeg_crash_loop",
mode: "state",
severity: "error",
category: "camera",
scope: "front_door",
params: { restarts: 6 },
first_seen: NOW - 600,
last_seen: NOW,
count: 1,
dismissed_at: null,
};
const EVENT_NOTICE = {
id: "detector_stuck",
kind: "detector_stuck",
mode: "event",
severity: "warning",
category: "detector",
scope: null,
params: { detector: "ov" },
first_seen: NOW - 7200,
last_seen: NOW - 60,
count: 3,
dismissed_at: null,
};
test.describe("System — Health tab @medium", () => {
test("Health is the default tab and lists notices by severity", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
notices: [STATE_NOTICE, EVENT_NOTICE],
noticeStats: [
{
kind: "ffmpeg_crash_loop",
occurrences: 14,
dismissals: 0,
first_seen: NOW - 86400 * 20,
last_seen: NOW,
},
],
});
await frigateApp.goto("/system");
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
const rows = frigateApp.page.locator("[data-testid^='health-problem-']");
await expect(rows).toHaveCount(2);
await expect(rows.nth(0)).toHaveAttribute("data-severity", "error");
await expect(rows.nth(0)).toContainText("ffmpeg has crashed 6 times");
await expect(rows.nth(0)).toContainText("14 times since");
// the default fixture's cpu detector is slow (75.5 ms); PR 2 merges live
// problems into this list, PR 1 does not, so no extra row here
await expect(
rows.nth(0).getByRole("button", { name: "Dismiss" }),
).toHaveCount(0);
await expect(rows.nth(1)).toContainText("Detector ov was restarted");
await expect(rows.nth(1)).toContainText("3 times");
await expect(
rows.nth(1).getByRole("button", { name: "Dismiss" }),
).toBeVisible();
});
test("dismiss posts and removes the row", async ({ frigateApp }) => {
await frigateApp.installDefaults({ notices: [EVENT_NOTICE] });
// the list shrinks after the dismiss so the refetch shows the row gone
let dismissed = false;
await frigateApp.page.route("**/api/notices", (route) =>
route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }),
);
await frigateApp.page.route(
"**/api/notices/detector_stuck/dismiss",
(route) => {
dismissed = true;
return route.fulfill({ json: { success: true } });
},
);
await frigateApp.goto("/system#health");
const request = frigateApp.page.waitForRequest(
(req) =>
req.url().includes("/api/notices/detector_stuck/dismiss") &&
req.method() === "POST",
);
await frigateApp.page.getByRole("button", { name: "Dismiss" }).click();
await request;
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0, { timeout: 5_000 });
await expect(frigateApp.page.getByText("No notices")).toBeVisible();
});
test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults();
await frigateApp.goto("/system#health");
await expect(frigateApp.page.getByText("No notices")).toBeVisible({
timeout: 15_000,
});
});
test("update notice renders as info with a release link", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
notices: [
{
id: "update_available",
kind: "update_available",
mode: "state",
severity: "info",
category: "system",
scope: null,
params: { version: "0.19.0" },
first_seen: NOW - 3600,
last_seen: NOW,
count: 1,
dismissed_at: null,
},
],
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"health-problem-notice:update_available",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toHaveAttribute("data-severity", "info");
await expect(row).toContainText("Frigate 0.19.0 is available");
await expect(row.getByRole("link", { name: "Open link" })).toHaveAttribute(
"href",
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
);
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
});
});
test.describe("System — Health tab mobile @medium @mobile", () => {
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
test("notices render at mobile viewport", async ({ frigateApp }) => {
await frigateApp.installDefaults({ notices: [STATE_NOTICE] });
await frigateApp.goto("/system#health");
await expect(
frigateApp.page.getByTestId(
"health-problem-notice:ffmpeg_crash_loop:front_door",
),
).toBeVisible({ timeout: 15_000 });
});
});
+2 -2
View File
@@ -168,9 +168,9 @@
},
"menu": {
"system": "System",
"systemMetrics": "System metrics",
"systemMetrics": "Health and Metrics",
"configuration": "Configuration",
"systemLogs": "System logs",
"systemLogs": "Logs",
"profiles": "Profiles",
"settings": "Settings",
"configurationEditor": "Configuration Editor",
+16
View File
@@ -55,6 +55,12 @@
"auto_scroll": {
"title": "Auto-scroll",
"desc": "Follow new messages as they arrive."
},
"always_allow": {
"title": "Always allowed actions",
"desc": "Actions the assistant may run without asking first.",
"none": "None",
"reset": "Reset"
}
},
"stats": {
@@ -68,5 +74,15 @@
},
"thinking": {
"toggle": "Toggle thinking"
},
"approval": {
"title": "Approve {{tool}}?",
"desc": "This action changes something in Frigate. Review the details before approving.",
"approve": "Approve",
"always_allow": "Always allow",
"reject": "Reject",
"approved": "Approved",
"rejected": "Rejected",
"placeholder": "Approve or reject the pending action to continue"
}
}
+22 -1
View File
@@ -9,10 +9,31 @@
"go2rtc": "Go2RTC Logs - Frigate",
"nginx": "Nginx Logs - Frigate",
"websocket": "Messages Logs - Frigate"
}
},
"health": "Health - Frigate"
},
"title": "System",
"metrics": "System metrics",
"health": {
"title": "Health",
"notices": {
"title": "Notices",
"empty": "No notices",
"dismiss": "Dismiss",
"openSettings": "Open settings",
"openLink": "Open link",
"since": "Since {{time}}",
"sinceWithCount": "Since {{time}} · {{times}} times since {{firstSeen}}",
"firstSeen": "First seen {{time}} · {{times}} times",
"kinds": {
"ffmpeg_crash_loop": "ffmpeg has crashed {{restarts}} times in the last hour",
"detector_stuck": "Detector {{detector}} was restarted after it stopped responding",
"model_download_failed": "Downloading {{file}} failed: {{error}}",
"retention_unmet": "Recordings were deleted before their retention period to free space ({{cleared_mb}} of {{needed_mb}} MB needed)",
"update_available": "Frigate {{version}} is available"
}
}
},
"logs": {
"websocket": {
"label": "Messages",
+10 -4
View File
@@ -26,6 +26,9 @@ type ChatComposerProps = {
isLoading?: boolean;
onStop?: () => void;
/** Blocks input without showing the stop button, e.g. while a tool call
* is waiting for the user's approval. */
disabled?: boolean;
attachedEventId?: string | null;
onClearAttachment?: () => void;
@@ -45,6 +48,7 @@ export function ChatComposer({
setThinkingEnabled,
isLoading = false,
onStop,
disabled = false,
attachedEventId,
onClearAttachment,
onAttach,
@@ -62,6 +66,7 @@ export function ChatComposer({
const showPaperclip = !!onAttach;
const showStop = isLoading && !!onStop;
const inputBlocked = isLoading || disabled;
return (
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
@@ -77,7 +82,7 @@ export function ChatComposer({
{attachedEventId && (
<ChatQuickReplies
onSend={(text) => sendMessage(text)}
disabled={isLoading}
disabled={inputBlocked}
/>
)}
<div className="flex w-full flex-row items-center gap-2">
@@ -85,7 +90,7 @@ export function ChatComposer({
<ChatPaperclipButton
recentEventIds={recentEventIds ?? []}
onAttach={onAttach!}
disabled={isLoading || attachedEventId != null}
disabled={inputBlocked || attachedEventId != null}
/>
)}
{supportsThinking && (
@@ -103,7 +108,7 @@ export function ChatComposer({
!thinkingEnabled && "text-secondary-foreground",
)}
onClick={() => setThinkingEnabled(!thinkingEnabled)}
disabled={isLoading}
disabled={inputBlocked}
>
<LuBrain className="size-4" />
</Button>
@@ -122,6 +127,7 @@ export function ChatComposer({
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
aria-busy={isLoading}
disabled={disabled}
/>
{showStop ? (
<Button
@@ -135,7 +141,7 @@ export function ChatComposer({
<Button
variant="select"
className="size-10 shrink-0 rounded-full"
disabled={!input.trim() || isLoading}
disabled={!input.trim() || inputBlocked}
onClick={() => sendMessage()}
>
<FaArrowUpLong className="size-4" />
+39
View File
@@ -16,12 +16,15 @@ import { Label } from "@/components/ui/label";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { useTranslation } from "react-i18next";
import type { ShowStatsMode } from "@/types/chat";
import { formatToolName } from "@/utils/chatUtil";
type ChatSettingsProps = {
showStats: ShowStatsMode;
setShowStats: (mode: ShowStatsMode) => void;
autoScroll: boolean;
setAutoScroll: (enabled: boolean) => void;
alwaysAllowTools: string[];
clearAlwaysAllowTools: () => void;
};
export default function ChatSettings({
@@ -29,6 +32,8 @@ export default function ChatSettings({
setShowStats,
autoScroll,
setAutoScroll,
alwaysAllowTools,
clearAlwaysAllowTools,
}: ChatSettingsProps) {
const { t } = useTranslation(["views/chat"]);
const [open, setOpen] = useState(false);
@@ -90,6 +95,40 @@ export default function ChatSettings({
onCheckedChange={setAutoScroll}
/>
</div>
<DropdownMenuSeparator />
<div className="space-y-3">
<div className="space-y-0.5">
<div>{t("settings.always_allow.title")}</div>
<div className="text-xs text-muted-foreground">
{t("settings.always_allow.desc")}
</div>
</div>
{alwaysAllowTools.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{alwaysAllowTools.map((name) => (
<span
key={name}
className="rounded-md bg-secondary px-2 py-0.5 text-xs text-secondary-foreground"
>
{formatToolName(name)}
</span>
))}
</div>
) : (
<div className="text-xs text-muted-foreground">
{t("settings.always_allow.none")}
</div>
)}
<Button
size="sm"
variant="outline"
className="w-full"
disabled={alwaysAllowTools.length === 0}
onClick={clearAlwaysAllowTools}
>
{t("settings.always_allow.reset")}
</Button>
</div>
</div>
);
@@ -0,0 +1,97 @@
import { useTranslation } from "react-i18next";
import { LuShieldAlert, LuCheck, LuX } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { formatToolName } from "@/utils/chatUtil";
import type { PendingToolCall, ToolDecision } from "@/types/chat";
type ToolApprovalCardProps = {
toolCall: PendingToolCall;
decision?: ToolDecision;
onApprove: (id: string) => void;
onAlwaysAllow: (id: string, name: string) => void;
onReject: (id: string) => void;
};
/**
* Prompt shown when the assistant wants to run a state-changing tool.
* Renders the call's arguments and approve / always allow / reject actions;
* once decided it collapses into a status line.
*/
export function ToolApprovalCard({
toolCall,
decision,
onApprove,
onAlwaysAllow,
onReject,
}: ToolApprovalCardProps) {
const { t } = useTranslation(["views/chat"]);
const displayName = formatToolName(toolCall.name);
const hasArguments = Object.keys(toolCall.arguments ?? {}).length > 0;
return (
<div
className="flex w-full max-w-[85%] flex-col gap-3 self-start rounded-xl border border-border bg-muted px-4 py-3"
role="group"
aria-label={t("approval.title", { tool: displayName })}
>
<div className="flex items-start gap-2">
<LuShieldAlert className="mt-0.5 size-4 shrink-0 text-primary" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
{t("approval.title", { tool: displayName })}
</span>
<span className="text-xs text-muted-foreground">
{t("approval.desc")}
</span>
</div>
</div>
{hasArguments && (
<pre className="scrollbar-container max-h-40 overflow-auto whitespace-pre-wrap break-words rounded bg-background/50 p-2 text-[10px]">
{JSON.stringify(toolCall.arguments, null, 2)}
</pre>
)}
{decision ? (
<div
className={cn(
"flex items-center gap-1.5 text-xs font-medium",
decision === "approve" ? "text-success" : "text-destructive",
)}
>
{decision === "approve" ? (
<LuCheck className="size-3.5" />
) : (
<LuX className="size-3.5" />
)}
{decision === "approve"
? t("approval.approved")
: t("approval.rejected")}
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => onApprove(toolCall.id)}
>
{t("approval.approve")}
</Button>
<Button
size="sm"
variant="select"
onClick={() => onAlwaysAllow(toolCall.id, toolCall.name)}
>
{t("approval.always_allow")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => onReject(toolCall.id)}
>
{t("approval.reject")}
</Button>
</div>
)}
</div>
);
}
+2 -9
View File
@@ -7,19 +7,12 @@ import {
} from "@/components/ui/collapsible";
import { LuChevronsUpDown } from "react-icons/lu";
import type { ToolCall } from "@/types/chat";
import { formatToolName } from "@/utils/chatUtil";
type ToolCallsGroupProps = {
toolCalls: ToolCall[];
};
function normalizeName(name: string): string {
return name
.replace(/_/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
export function ToolCallsGroup({ toolCalls }: ToolCallsGroupProps) {
const grouped = useMemo(() => {
const map = new Map<string, ToolCall[]>();
@@ -53,7 +46,7 @@ type ToolCallRowProps = {
function ToolCallRow({ name, calls }: ToolCallRowProps) {
const { t } = useTranslation(["views/chat"]);
const [open, setOpen] = useState(false);
const displayName = normalizeName(name);
const displayName = formatToolName(name);
const label =
calls.length > 1 ? `${displayName} (\u00d7${calls.length})` : displayName;
@@ -0,0 +1,88 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { FaTriangleExclamation } from "react-icons/fa6";
import {
LuExternalLink,
LuInfo,
LuSlidersHorizontal,
LuX,
} from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import type { HealthProblem } from "@/types/health";
type HealthProblemRowProps = {
problem: HealthProblem;
};
export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
const { t } = useTranslation(["views/system"]);
return (
<div
className="flex items-start gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
data-testid={`health-problem-${problem.id}`}
data-severity={problem.severity}
>
<div className="mt-0.5 flex shrink-0">
{problem.severity === "error" && <LuX className="size-4 text-danger" />}
{problem.severity === "warning" && (
<FaTriangleExclamation className="size-4 text-yellow-500" />
)}
{problem.severity === "info" && (
<LuInfo className="size-4 text-selected" />
)}
</div>
{problem.scope && (
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground smart-capitalize">
{problem.scopeIsCamera ? (
<CameraNameLabel camera={problem.scope} />
) : (
problem.scope
)}
</span>
)}
<div className="min-w-0 flex-1">
<div>{problem.text}</div>
{problem.meta && (
<div className="mt-0.5 text-xs text-muted-foreground">
{problem.meta}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 text-muted-foreground">
{problem.onDismiss && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={problem.onDismiss}
aria-label={t("health.notices.dismiss")}
>
{t("health.notices.dismiss")}
</Button>
)}
{problem.link && (
<Link
to={problem.link}
aria-label={t("health.notices.openSettings")}
className="hover:text-primary"
>
<LuSlidersHorizontal className="size-4" />
</Link>
)}
{problem.externalLink && (
<a
href={problem.externalLink}
target="_blank"
rel="noreferrer"
aria-label={t("health.notices.openLink")}
className="hover:text-primary"
>
<LuExternalLink className="size-4" />
</a>
)}
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FaCircleCheck } from "react-icons/fa6";
import useSWR from "swr";
import HealthProblemRow from "@/components/health/HealthProblemRow";
import { Skeleton } from "@/components/ui/skeleton";
import { useNotices } from "@/hooks/use-notices";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useTimezone } from "@/hooks/use-date-utils";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { releaseUrl } from "@/utils/versionUtil";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import type { Notice, NoticeKind, NoticeStats } from "@/types/notice";
const SETTINGS_LINK_BY_KIND: Partial<
Record<NoticeKind, (scope: string | null) => string>
> = {
ffmpeg_crash_loop: (scope) => `/settings?page=cameraFfmpeg&camera=${scope}`,
retention_unmet: () => "/system#storage",
detector_stuck: () => "/system#general",
};
const EXTERNAL_LINK_BY_KIND: Partial<
Record<NoticeKind, (params: Notice["params"]) => string | undefined>
> = {
update_available: (params) =>
typeof params.version === "string" ? releaseUrl(params.version) : undefined,
};
function useNoticeProblems(
notices: Notice[] | undefined,
statsByKind: Partial<Record<NoticeKind, NoticeStats>>,
dismiss: (id: string) => Promise<void>,
): HealthProblem[] {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useTimezone(config);
const locale = useDateLocale();
return useMemo(() => {
if (!notices) {
return [];
}
const formatTime = (timestamp: number, date_format: string) =>
formatUnixTimestampToDateTime(timestamp, {
timezone,
date_format,
locale,
});
return notices.map((notice) => {
const stats = statsByKind[notice.kind];
const seen = formatTime(notice.first_seen, "MMM d, h:mm a");
let meta: string;
if (notice.mode === "event") {
meta = t("health.notices.firstSeen", {
time: seen,
times: notice.count,
});
} else if (stats && stats.occurrences > 1) {
meta = t("health.notices.sinceWithCount", {
time: seen,
times: stats.occurrences,
firstSeen: formatTime(stats.first_seen, "MMM d"),
});
} else {
meta = t("health.notices.since", { time: seen });
}
const link = SETTINGS_LINK_BY_KIND[notice.kind];
const external = EXTERNAL_LINK_BY_KIND[notice.kind];
return {
id: `notice:${notice.id}`,
severity: notice.severity,
scope: notice.scope ?? undefined,
scopeIsCamera: notice.category === "camera",
// replace keeps backend params out of i18next's own option names
text: t(`health.notices.kinds.${notice.kind}`, {
replace: notice.params,
}),
meta,
link: link ? link(notice.scope) : undefined,
externalLink: external ? external(notice.params) : undefined,
onDismiss:
notice.mode === "event" ? () => dismiss(notice.id) : undefined,
};
});
}, [notices, statsByKind, dismiss, t, timezone, locale]);
}
export default function NoticesPane() {
const { t } = useTranslation(["views/system"]);
const { notices, statsByKind, dismiss } = useNotices();
const problems = useNoticeProblems(notices, statsByKind, dismiss);
return (
<div className="flex flex-col gap-4">
<div>
<div className="text-md font-medium text-primary-variant">
{t("health.notices.title")}
</div>
</div>
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
{notices === undefined ? (
<Skeleton className="h-24 w-full" />
) : problems.length === 0 ? (
<div className="flex items-center gap-2 px-1 py-2 text-sm">
<FaCircleCheck className="size-4 text-success" />
<span>{t("health.notices.empty")}</span>
</div>
) : (
<div className="flex flex-col">
{problems.map((problem) => (
<HealthProblemRow key={problem.id} problem={problem} />
))}
</div>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -326,7 +326,7 @@ export default function GeneralSettings({
<DropdownMenuLabel>{t("menu.system")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
<Link to="/system#general">
<Link to="/system">
<MenuItem
className={
isDesktop
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useMemo } from "react";
import axios from "axios";
import useSWR from "swr";
import { useWs } from "@/api/ws";
import type { Notice, NoticeStats } from "@/types/notice";
/**
* Active notices: a REST snapshot, replaced by every `notices` websocket
* payload after that. Stats are fetched separately and only change on
* dismiss or a new occurrence, so they revalidate after each dismiss.
*/
export function useNotices() {
const { data: initial, mutate } = useSWR<Notice[]>("notices", {
revalidateOnFocus: false,
});
const { data: stats, mutate: mutateStats } = useSWR<NoticeStats[]>(
"notices/stats",
{ revalidateOnFocus: false },
);
const {
value: { payload },
} = useWs("notices", "");
const live = useMemo(
() =>
payload && typeof payload === "string"
? (JSON.parse(payload) as Notice[])
: undefined,
[payload],
);
// once a websocket frame has arrived it is the source of truth; a dismiss
// still shows up because the registry publishes a new frame after it
const notices = live ?? initial;
const statsByKind = useMemo(() => {
const byKind: Partial<Record<Notice["kind"], NoticeStats>> = {};
(stats ?? []).forEach((entry) => {
byKind[entry.kind] = entry;
});
return byKind;
}, [stats]);
const dismiss = useCallback(
async (id: string) => {
await axios.post(`notices/${id}/dismiss`);
mutate();
mutateStats();
},
[mutate, mutateStats],
);
return { notices, statsByKind, dismiss };
}
+149 -15
View File
@@ -8,6 +8,7 @@ import { ChatEventThumbnailsRow } from "@/components/chat/ChatEventThumbnailsRow
import { MessageBubble } from "@/components/chat/ChatMessage";
import { ReasoningBubble } from "@/components/chat/ReasoningBubble";
import { ToolCallsGroup } from "@/components/chat/ToolCallsGroup";
import { ToolApprovalCard } from "@/components/chat/ToolApprovalCard";
import { ChatStartingState } from "@/components/chat/ChatStartingState";
import { ChatComposer } from "@/components/chat/ChatComposer";
import ChatSettings from "@/components/chat/ChatSettings";
@@ -15,11 +16,13 @@ import type {
ChatMessage,
ChatStats,
GenAIModelsResponse,
PendingToolCall,
ShowStatsMode,
ToolDecision,
} from "@/types/chat";
import { usePersistence } from "@/hooks/use-persistence";
import {
getEventIdsFromSearchObjectsToolCalls,
getEventIdsFromToolCalls,
getFindSimilarObjectsFromToolCalls,
prependAttachment,
streamChatCompletion,
@@ -40,6 +43,13 @@ const hasText = (content: unknown): content is string =>
const toWire = (messages: ChatMessage[]): ChatMessage[] =>
messages.map(({ reasoning: _r, stats: _s, ...rest }) => rest);
// Stable default so usePersistence does not reload on every render.
const NO_TOOLS: string[] = [];
type ResumeOptions = {
toolDecisions: Record<string, ToolDecision>;
};
export default function ChatPage() {
const { t } = useTranslation(["views/chat"]);
const [input, setInput] = useState("");
@@ -48,6 +58,21 @@ export default function ChatPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
// Write tool calls the backend paused on, plus the user's decisions so far.
const [pendingApprovals, setPendingApprovals] = useState<
PendingToolCall[] | null
>(null);
const [approvalDecisions, setApprovalDecisions] = useState<
Record<string, ToolDecision>
>({});
// Tools the user chose to always allow. Kept only in this browser; the
// backend never sees the list, the client just answers for them.
const [alwaysAllowTools, setAlwaysAllowTools] = usePersistence<string[]>(
"chat-always-allow-tools",
NO_TOOLS,
);
const alwaysAllowRef = useRef<string[]>(NO_TOOLS);
alwaysAllowRef.current = alwaysAllowTools ?? NO_TOOLS;
const [showStats, setShowStats] = usePersistence<ShowStatsMode>(
"chat-show-stats",
"while_generating",
@@ -62,6 +87,7 @@ export default function ChatPage() {
);
const scrollRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const loadingRef = useRef(false);
const { data: genaiInfo } = useSWR<GenAIModelsResponse>("genai/models", {
revalidateOnFocus: false,
@@ -92,14 +118,27 @@ export default function ChatPage() {
}, [messages, streaming, autoScroll]);
const submitConversation = useCallback(
async (messagesToSend: ChatMessage[]) => {
if (isLoading) return;
async function submit(
messagesToSend: ChatMessage[],
resume?: ResumeOptions,
) {
if (loadingRef.current) return;
const last = messagesToSend[messagesToSend.length - 1];
if (!last || last.role !== "user" || !hasText(last.content)) return;
if (!last) return;
// A normal turn ends with the user's message; a resume after an
// approval pause ends with the assistant's pending tool calls.
if (resume) {
if (last.role !== "assistant" || !last.tool_calls?.length) return;
} else if (last.role !== "user" || !hasText(last.content)) {
return;
}
setError(null);
setPendingApprovals(null);
setApprovalDecisions({});
setMessages(messagesToSend);
setStreaming({ content: "", reasoning: "", chain: [] });
loadingRef.current = true;
setIsLoading(true);
const baseURL = axios.defaults.baseURL ?? "";
@@ -116,6 +155,7 @@ export default function ChatPage() {
let stats: ChatStats | undefined;
let reasoning = "";
let hadError = false;
let approvals: PendingToolCall[] | null = null;
await streamChatCompletion(
url,
@@ -138,32 +178,99 @@ export default function ChatPage() {
stats = s;
setStreaming((cur) => (cur ? { ...cur, stats: s } : cur));
},
onApprovalRequired: (toolCalls) => {
approvals = toolCalls;
},
onError: (message) => {
hadError = true;
setError(message);
},
onDone: () => {
abortRef.current = null;
loadingRef.current = false;
setIsLoading(false);
setStreaming(null);
const lastMsg = chain[chain.length - 1];
if (!hadError && lastMsg?.role === "assistant") {
setMessages(
chain.map((m, i) =>
i === chain.length - 1
? { ...m, reasoning: reasoning || undefined, stats }
: m,
),
const committed = chain.map((m, i) =>
i === chain.length - 1
? { ...m, reasoning: reasoning || undefined, stats }
: m,
);
setMessages(committed);
if (approvals?.length) {
// Calls to always-allowed tools are answered here without
// prompting; anything else waits for the user.
const allowed = alwaysAllowRef.current;
const auto: Record<string, ToolDecision> = {};
for (const tc of approvals) {
if (allowed.includes(tc.name)) auto[tc.id] = "approve";
}
if (Object.keys(auto).length === approvals.length) {
submit(committed, { toolDecisions: auto });
} else {
setApprovalDecisions(auto);
setPendingApprovals(approvals);
}
}
}
},
defaultErrorMessage: t("error"),
},
controller.signal,
supportsThinking ? { enableThinking: !!thinkingEnabled } : {},
{
...(supportsThinking ? { enableThinking: !!thinkingEnabled } : {}),
toolDecisions: resume?.toolDecisions,
},
);
},
[isLoading, supportsThinking, t, thinkingEnabled],
[supportsThinking, t, thinkingEnabled],
);
// Resume the paused turn once every pending call has a decision.
const applyDecisions = useCallback(
(next: Record<string, ToolDecision>) => {
setApprovalDecisions(next);
if (!pendingApprovals) return;
if (!pendingApprovals.every((tc) => next[tc.id] !== undefined)) return;
submitConversation(messages, { toolDecisions: next });
},
[messages, pendingApprovals, submitConversation],
);
const handleApprove = useCallback(
(id: string) => applyDecisions({ ...approvalDecisions, [id]: "approve" }),
[applyDecisions, approvalDecisions],
);
const handleReject = useCallback(
(id: string) => applyDecisions({ ...approvalDecisions, [id]: "reject" }),
[applyDecisions, approvalDecisions],
);
const handleAlwaysAllow = useCallback(
(id: string, name: string) => {
const current = alwaysAllowTools ?? NO_TOOLS;
const allowed = current.includes(name) ? current : [...current, name];
setAlwaysAllowTools(allowed);
const next = { ...approvalDecisions, [id]: "approve" as const };
for (const tc of pendingApprovals ?? []) {
if (tc.name === name) next[tc.id] = "approve";
}
applyDecisions(next);
},
[
alwaysAllowTools,
applyDecisions,
approvalDecisions,
pendingApprovals,
setAlwaysAllowTools,
],
);
const clearAlwaysAllowTools = useCallback(
() => setAlwaysAllowTools(NO_TOOLS),
[setAlwaysAllowTools],
);
const recentEventIds = useMemo(() => {
@@ -174,7 +281,7 @@ export default function ChatPage() {
const calls = toolCallsForMessage(msg, responses);
const similar = getFindSimilarObjectsFromToolCalls(calls);
if (similar) return similar.results.map((e) => e.id);
const events = getEventIdsFromSearchObjectsToolCalls(calls);
const events = getEventIdsFromToolCalls(calls);
if (events.length > 0) return events.map((e) => e.id);
}
return [];
@@ -197,19 +304,25 @@ export default function ChatPage() {
const stopGeneration = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
loadingRef.current = false;
setIsLoading(false);
setStreaming(null);
setPendingApprovals(null);
setApprovalDecisions({});
}, []);
const startNewChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
loadingRef.current = false;
setIsLoading(false);
setStreaming(null);
setMessages([]);
setInput("");
setAttachedEventId(null);
setError(null);
setPendingApprovals(null);
setApprovalDecisions({});
}, []);
const handleEditSubmit = useCallback(
@@ -260,7 +373,7 @@ export default function ChatPage() {
const calls = toolCallsForMessage(msg, responses);
const contentText = hasText(msg.content) ? msg.content : "";
const similar = getFindSimilarObjectsFromToolCalls(calls);
const events = similar ? [] : getEventIdsFromSearchObjectsToolCalls(calls);
const events = similar ? [] : getEventIdsFromToolCalls(calls);
return (
<div key={i} className="flex flex-col gap-2">
@@ -324,6 +437,8 @@ export default function ChatPage() {
setShowStats={setShowStats}
autoScroll={autoScroll ?? true}
setAutoScroll={setAutoScroll}
alwaysAllowTools={alwaysAllowTools ?? NO_TOOLS}
clearAlwaysAllowTools={clearAlwaysAllowTools}
/>
</div>
<div
@@ -335,6 +450,20 @@ export default function ChatPage() {
{hasStarted ? (
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
{renderList.map((msg, i) => renderMessage(msg, i))}
{pendingApprovals && !streaming && (
<div className="flex flex-col gap-2">
{pendingApprovals.map((tc) => (
<ToolApprovalCard
key={tc.id}
toolCall={tc}
decision={approvalDecisions[tc.id]}
onApprove={handleApprove}
onAlwaysAllow={handleAlwaysAllow}
onReject={handleReject}
/>
))}
</div>
)}
{streaming &&
!finalShown &&
(streaming.content || streaming.reasoning ? (
@@ -391,7 +520,12 @@ export default function ChatPage() {
setInput={setInput}
sendMessage={sendMessage}
isLoading={isLoading}
placeholder={t("placeholder")}
disabled={pendingApprovals != null}
placeholder={
pendingApprovals != null
? t("approval.placeholder")
: t("placeholder")
}
attachedEventId={attachedEventId}
onClearAttachment={handleClearAttachment}
onAttach={setAttachedEventId}
+23 -6
View File
@@ -6,7 +6,12 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { isDesktop, isMobile } from "react-device-detect";
import GeneralMetrics from "@/views/system/GeneralMetrics";
import StorageMetrics from "@/views/system/StorageMetrics";
import { LuActivity, LuHardDrive, LuSearchCode } from "react-icons/lu";
import {
LuActivity,
LuHardDrive,
LuHeartPulse,
LuSearchCode,
} from "react-icons/lu";
import { FaVideo } from "react-icons/fa";
import Logo from "@/components/Logo";
import useOptimisticState from "@/hooks/use-optimistic-state";
@@ -15,9 +20,16 @@ import { useHashState } from "@/hooks/use-overlay-state";
import { Toaster } from "@/components/ui/sonner";
import { FrigateConfig } from "@/types/frigateConfig";
import EnrichmentMetrics from "@/views/system/EnrichmentMetrics";
import HealthMetrics from "@/views/system/HealthMetrics";
import { useTranslation } from "react-i18next";
const allMetrics = ["general", "enrichments", "storage", "cameras"] as const;
const allMetrics = [
"health",
"general",
"enrichments",
"storage",
"cameras",
] as const;
type SystemMetric = (typeof allMetrics)[number];
function System() {
@@ -44,8 +56,9 @@ function System() {
// stats page
const [page, setPage] = useHashState<SystemMetric>();
// useHashState yields "" with no hash, which ?? would not catch
const [pageToggle, setPageToggle] = useOptimisticState(
page ?? "general",
page || "health",
setPage,
100,
);
@@ -56,9 +69,7 @@ function System() {
// Track which tabs have been visited so we can keep them mounted after first visit.
// Using a ref updated during render avoids extra render cycles from state/effects.
const visitedTabsRef = useRef(new Set<string>());
if (page) {
visitedTabsRef.current.add(page);
}
visitedTabsRef.current.add(pageToggle);
const visitedTabs = visitedTabsRef.current;
useEffect(() => {
@@ -98,6 +109,7 @@ function System() {
value={item}
aria-label={`Select ${item}`}
>
{item == "health" && <LuHeartPulse className="size-4" />}
{item == "general" && <LuActivity className="size-4" />}
{item == "enrichments" && <LuSearchCode className="size-4" />}
{item == "storage" && <LuHardDrive className="size-4" />}
@@ -126,6 +138,11 @@ function System() {
</div>
)}
</div>
{visitedTabs.has("health") && (
<div className={pageToggle == "health" ? "contents" : "hidden"}>
<HealthMetrics />
</div>
)}
{visitedTabs.has("general") && (
<div className={page == "general" ? "contents" : "hidden"}>
<GeneralMetrics
+10
View File
@@ -20,11 +20,21 @@ export type ChatMessage = {
};
export type ToolCall = {
id?: string;
name: string;
arguments?: Record<string, unknown>;
response?: string;
};
export type ToolDecision = "approve" | "reject";
/** A state-changing tool call the backend paused on, awaiting the user. */
export type PendingToolCall = {
id: string;
name: string;
arguments: Record<string, unknown>;
};
export type StartingRequest = {
label: string;
prompt: string;
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
export type HealthSeverity = "error" | "warning" | "info";
/**
* One row in the Health tab's Notices list. Every source (registry notices
* now, live stats, config checks, and stream checks in PR 2) maps into this
* shape so the row component never needs to know where a problem came from.
*/
export type HealthProblem = {
id: string;
severity: HealthSeverity;
/** camera name or other scope shown as a chip before the text */
scope?: string;
/** whether scope is a camera name, so the chip can use the friendly name */
scopeIsCamera?: boolean;
text: string;
/** muted line under the text, for example when it was first seen */
meta?: ReactNode;
/** in-app route for a settings icon link */
link?: string;
/** docs path for an external link, rendered in PR 2 */
docLink?: string;
/** absolute URL rendered as an external link (the update notice's release page) */
externalLink?: string;
onDismiss?: () => void;
};
+36
View File
@@ -0,0 +1,36 @@
export type NoticeMode = "state" | "event";
export type NoticeSeverity = "error" | "warning" | "info";
export type NoticeCategory =
| "camera"
| "detector"
| "storage"
| "model"
| "system";
export type NoticeKind =
| "ffmpeg_crash_loop"
| "detector_stuck"
| "model_download_failed"
| "retention_unmet"
| "update_available";
export type Notice = {
id: string;
kind: NoticeKind;
mode: NoticeMode;
severity: NoticeSeverity;
category: NoticeCategory;
scope: string | null;
params: Record<string, string | number | boolean>;
first_seen: number;
last_seen: number;
count: number;
dismissed_at: number | null;
};
export type NoticeStats = {
kind: NoticeKind;
occurrences: number;
dismissals: number;
first_seen: number;
last_seen: number;
};
+56 -14
View File
@@ -1,4 +1,10 @@
import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
import type {
ChatMessage,
ChatStats,
PendingToolCall,
ToolCall,
ToolDecision,
} from "@/types/chat";
export type StreamChatCallbacks = {
/** Streamed delta of the assistant's final answer text. */
@@ -11,6 +17,10 @@ export type StreamChatCallbacks = {
onChain: (chain: ChatMessage[]) => void;
/** Token/timing stats for the turn. */
onStats: (stats: ChatStats) => void;
/** The backend paused before running state-changing tools; the chain
* emitted just before this ends with the assistant message requesting
* them. Resend that chain with `toolDecisions` to continue. */
onApprovalRequired?: (toolCalls: PendingToolCall[]) => void;
/** Called when the stream sends an error or fetch fails. */
onError: (message: string) => void;
/** Called when the stream finishes (success or error). */
@@ -30,6 +40,7 @@ type StatsChunk = {
type StreamChunk =
| { type: "error"; error: string }
| { type: "messages"; messages: ChatMessage[] }
| { type: "approval_required"; tool_calls: PendingToolCall[] }
| { type: "content"; delta: string }
| { type: "reasoning"; delta: string }
| StatsChunk;
@@ -40,6 +51,8 @@ type StreamChunk =
*/
export type StreamChatOptions = {
enableThinking?: boolean;
/** Decisions for tool calls that paused for approval, keyed by call id. */
toolDecisions?: Record<string, ToolDecision>;
};
export async function streamChatCompletion(
@@ -55,6 +68,7 @@ export async function streamChatCompletion(
onReasoningDelta,
onChain,
onStats,
onApprovalRequired,
onError,
onDone,
defaultErrorMessage = "Something went wrong. Please try again.",
@@ -68,6 +82,9 @@ export async function streamChatCompletion(
if (options.enableThinking !== undefined) {
body.enable_thinking = options.enableThinking;
}
if (options.toolDecisions && Object.keys(options.toolDecisions).length) {
body.tool_decisions = options.toolDecisions;
}
const res = await fetch(url, {
method: "POST",
headers,
@@ -103,6 +120,10 @@ export async function streamChatCompletion(
onChain(data.messages ?? []);
return "continue";
}
if (data.type === "approval_required") {
onApprovalRequired?.(data.tool_calls ?? []);
return "continue";
}
if (data.type === "content" && data.delta !== undefined) {
onContentDelta(data.delta);
return "continue";
@@ -198,6 +219,7 @@ export function toolCallsForMessage(
}
}
return {
id: tc.id,
name: tc.function?.name ?? "",
arguments: args,
response: responses.get(tc.id),
@@ -205,28 +227,48 @@ export function toolCallsForMessage(
});
}
/** Human-readable tool name: "search_objects" -> "Search Objects". */
export function formatToolName(name: string): string {
return name
.replace(/_/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
const hasStringId = (item: unknown): item is { id: string } =>
!!item &&
typeof item === "object" &&
"id" in item &&
typeof (item as { id: unknown }).id === "string";
/**
* Parse search_objects tool call response(s) into event ids for thumbnails.
* Collect event ids from tool responses that reference tracked objects:
* search_objects returns a list of events and get_event_image a single one.
*/
export function getEventIdsFromSearchObjectsToolCalls(
export function getEventIdsFromToolCalls(
toolCalls: ToolCall[] | undefined,
): { id: string }[] {
if (!toolCalls?.length) return [];
const results: { id: string }[] = [];
const seen = new Set<string>();
const push = (item: unknown) => {
if (hasStringId(item) && !seen.has(item.id)) {
seen.add(item.id);
results.push({ id: item.id });
}
};
for (const tc of toolCalls) {
if (tc.name !== "search_objects" || !tc.response?.trim()) continue;
if (!tc.response?.trim()) continue;
if (tc.name !== "search_objects" && tc.name !== "get_event_image") {
continue;
}
try {
const parsed = JSON.parse(tc.response) as unknown;
if (!Array.isArray(parsed)) continue;
for (const item of parsed) {
if (
item &&
typeof item === "object" &&
"id" in item &&
typeof (item as { id: unknown }).id === "string"
) {
results.push({ id: (item as { id: string }).id });
}
if (Array.isArray(parsed)) {
parsed.forEach(push);
} else {
push(parsed);
}
} catch {
// ignore parse errors
+4
View File
@@ -0,0 +1,4 @@
/** GitHub release page for a Frigate version such as "0.19.0". */
export function releaseUrl(version: string): string {
return `https://github.com/blakeblackshear/frigate/releases/tag/v${version}`;
}
+9
View File
@@ -0,0 +1,9 @@
import NoticesPane from "@/components/health/NoticesPane";
export default function HealthMetrics() {
return (
<div className="scrollbar-container mt-4 flex size-full flex-col gap-4 overflow-y-auto">
<NoticesPane />
</div>
);
}