Compare commits

..
13 Commits
Author SHA1 Message Date
gwmullinandGitHub f4f81625ce Merge b535fb0b27 into f3c09ae169 2026-07-10 09:13:31 -04:00
Gdub b535fb0b27 Add a testcase for validating that on_conflict_ignore bypasses what was formerly an IntegrityError 2026-06-26 14:02:51 -07:00
Gdub 25cb4d9008 Add on_conflict_ignore, replacing the try/catch/pass on IntegrityError 2026-06-26 13:37:05 -07:00
Greg cb15ba5b60 Update peewee index migration description 2026-06-26 12:14:20 -07:00
Gdub ec1238e723 Remove the CTE and restore the generator with sub-queries, which is more performance (thanks Nick and Blake for testing against your larger DB!) 2026-06-26 11:08:53 -07:00
Greg c575fb223b Editor fail, re-ruff format. 2026-05-18 14:18:17 -07:00
Greg 9fa345f192 Remove 2x unnecessary index on reviewsegment, remove reference to prior code implementation in comment in event.py 2026-05-18 14:16:43 -07:00
Gdub 7b55c4b758 Rerun ruff formatting. 2026-05-18 13:39:12 -07:00
Gdub 570e2e3f76 Slightly simplify review logic and avoid duplicating the json response for empty review IDs. 2026-05-18 13:30:46 -07:00
Greg 39fba9b0a7 Use peewee instead of rw sql for the CTE query. 2026-05-11 16:46:43 -07:00
Greg 328a26b169 Collapse a few sequential queries into a single one. 2026-05-11 15:45:35 -07:00
Greg 311fb1bd19 Rewrite to use a CTE to leverage speedups by using sqllite internal optimization to do a single query instead of a starter query to get distinct labels and a subsequent loop of querys per distinct event labels.
Frigate is currently shipping sqlite 3.46.1, which is above the minimum version 3.25 needed for CTEs.
2026-05-08 16:18:37 -07:00
Greg 48b1426891 Add additional indicies on event and review tables. Every events or timeline endpoint filters on event start time and camera, this should speed things up by avoiding a range scan on the table. 2026-05-08 15:59:23 -07:00
12 changed files with 192 additions and 306 deletions
+2 -2
View File
@@ -73,8 +73,8 @@ Variables prefixed with `FRIGATE_` can be referenced in config fields that suppo
Navigate to <NavPath path="Settings > System > Environment variables" /> to add or edit environment variables. Navigate to <NavPath path="Settings > System > Environment variables" /> to add or edit environment variables.
| Field | Description | | Field | Description |
| ----------------- | --------------------------------------------------------- | | --------- | --------------------------------------------------------- |
| **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) | | **Key** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
| **Value** | The value for the variable | | **Value** | The value for the variable |
Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax. Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
+28 -12
View File
@@ -386,7 +386,9 @@ def events_explore(
limit: int = 10, limit: int = 10,
allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter), allowed_cameras: list[str] = Depends(get_allowed_cameras_for_filter),
): ):
# get distinct labels for all events if not allowed_cameras:
return JSONResponse(content=[])
distinct_labels = ( distinct_labels = (
Event.select(Event.label) Event.select(Event.label)
.where(Event.camera << allowed_cameras) .where(Event.camera << allowed_cameras)
@@ -396,13 +398,31 @@ def events_explore(
label_counts = {} label_counts = {}
explore_columns = (
Event.id,
Event.camera,
Event.label,
Event.sub_label,
Event.zones,
Event.start_time,
Event.end_time,
Event.has_clip,
Event.has_snapshot,
Event.plus_id,
Event.retain_indefinitely,
Event.top_score,
Event.false_positive,
Event.box,
Event.data,
)
def event_generator(): def event_generator():
for label_obj in distinct_labels.iterator(): for label_obj in distinct_labels.iterator():
label = label_obj.label label = label_obj.label
# get most recent events for this label # get most recent events for this label
label_events = ( label_events = (
Event.select() Event.select(*explore_columns)
.where((Event.label == label) & (Event.camera << allowed_cameras)) .where((Event.label == label) & (Event.camera << allowed_cameras))
.order_by(Event.start_time.desc()) .order_by(Event.start_time.desc())
.limit(limit) .limit(limit)
@@ -484,22 +504,18 @@ async def event_ids(ids: str, request: Request):
status_code=400, status_code=400,
) )
for event_id in ids:
try: try:
event = Event.get(Event.id == event_id) events = list(Event.select().where(Event.id << ids).dicts().iterator())
await require_camera_access(event.camera, request=request)
except DoesNotExist:
# we should not fail the entire request if an event is not found
continue
try:
events = Event.select().where(Event.id << ids).dicts().iterator()
return JSONResponse(list(events))
except Exception: except Exception:
return JSONResponse( return JSONResponse(
content=({"success": False, "message": "Events not found"}), status_code=400 content=({"success": False, "message": "Events not found"}), status_code=400
) )
for event in events:
await require_camera_access(event["camera"], request=request)
return JSONResponse(events)
@router.get( @router.get(
"/events/search", "/events/search",
+60 -33
View File
@@ -9,7 +9,7 @@ import pandas as pd
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.params import Depends from fastapi.params import Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from peewee import Case, DoesNotExist, IntegrityError, fn, operator from peewee import Case, DoesNotExist, fn, operator
from playhouse.shortcuts import model_to_dict from playhouse.shortcuts import model_to_dict
from frigate.api.auth import ( from frigate.api.auth import (
@@ -171,11 +171,19 @@ async def review_ids(request: Request, ids: str):
status_code=400, status_code=400,
) )
for review_id in ids:
try: try:
review = ReviewSegment.get(ReviewSegment.id == review_id) reviews = list(
await require_camera_access(review.camera, request=request) ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
except DoesNotExist: )
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
found_ids = {r["id"] for r in reviews}
for review_id in ids:
if review_id not in found_ids:
return JSONResponse( return JSONResponse(
content=( content=(
{"success": False, "message": f"Review {review_id} not found"} {"success": False, "message": f"Review {review_id} not found"}
@@ -183,16 +191,10 @@ async def review_ids(request: Request, ids: str):
status_code=404, status_code=404,
) )
try: for review in reviews:
reviews = ( await require_camera_access(review["camera"], request=request)
ReviewSegment.select().where(ReviewSegment.id << ids).dicts().iterator()
) return JSONResponse(reviews)
return JSONResponse(list(reviews))
except Exception:
return JSONResponse(
content=({"success": False, "message": "Review segments not found"}),
status_code=400,
)
@router.get( @router.get(
@@ -489,27 +491,52 @@ async def set_multiple_reviewed(
user_id = current_user["username"] user_id = current_user["username"]
for review_id in body.ids: reviews = list(
try: ReviewSegment.select(ReviewSegment.id, ReviewSegment.camera).where(
review = ReviewSegment.get(ReviewSegment.id == review_id) ReviewSegment.id << body.ids
)
)
for review in reviews:
await require_camera_access(review.camera, request=request) await require_camera_access(review.camera, request=request)
review_status = UserReviewStatus.get(
UserReviewStatus.user_id == user_id, found_ids = [r.id for r in reviews]
UserReviewStatus.review_segment == review_id,
if found_ids:
existing_statuses = list(
UserReviewStatus.select().where(
(UserReviewStatus.user_id == user_id)
& (UserReviewStatus.review_segment << found_ids)
) )
# Update based on the reviewed parameter
if review_status.has_been_reviewed != body.reviewed:
review_status.has_been_reviewed = body.reviewed
review_status.save()
except DoesNotExist:
try:
UserReviewStatus.create(
user_id=user_id,
review_segment=ReviewSegment.get(id=review_id),
has_been_reviewed=body.reviewed,
) )
except (DoesNotExist, IntegrityError):
pass status_by_review = {s.review_segment_id: s for s in existing_statuses}
to_update = []
to_create = []
for review_id in found_ids:
if review_id in status_by_review:
status = status_by_review[review_id]
if status.has_been_reviewed != body.reviewed:
status.has_been_reviewed = body.reviewed
to_update.append(status)
else:
to_create.append(
{
"user_id": user_id,
"review_segment_id": review_id,
"has_been_reviewed": body.reviewed,
}
)
if to_update:
UserReviewStatus.bulk_update(
to_update, fields=[UserReviewStatus.has_been_reviewed], batch_size=100
)
if to_create:
UserReviewStatus.insert_many(to_create).on_conflict_ignore().execute()
return JSONResponse( return JSONResponse(
content=( content=(
+8 -25
View File
@@ -200,9 +200,6 @@ class EmbeddingMaintainer(threading.Thread):
) )
for model_config in self.config.classification.custom.values(): for model_config in self.config.classification.custom.values():
if not model_config.enabled:
continue
self.realtime_processors.append( self.realtime_processors.append(
CustomStateClassificationProcessor( CustomStateClassificationProcessor(
self.config, model_config, self.requestor, self.metrics self.config, model_config, self.requestor, self.metrics
@@ -335,8 +332,13 @@ class EmbeddingMaintainer(threading.Thread):
for processor in self.post_processors: for processor in self.post_processors:
processor.update_config(topic, payload) processor.update_config(topic, payload)
def _remove_custom_classification_processor(self, model_name: str) -> None: def _handle_custom_classification_update(
"""Shut down and drop any running processor for a custom model.""" self, topic: str, model_config: Any
) -> None:
"""Handle add/remove of custom classification processors."""
model_name = topic.split("/")[-1]
if model_config is None:
remaining = [] remaining = []
for processor in self.realtime_processors: for processor in self.realtime_processors:
if ( if (
@@ -354,14 +356,6 @@ class EmbeddingMaintainer(threading.Thread):
remaining.append(processor) remaining.append(processor)
self.realtime_processors = remaining self.realtime_processors = remaining
def _handle_custom_classification_update(
self, topic: str, model_config: Any
) -> None:
"""Handle add/remove of custom classification processors."""
model_name = topic.split("/")[-1]
if model_config is None:
self._remove_custom_classification_processor(model_name)
logger.info( logger.info(
f"Successfully removed classification processor for model: {model_name}" f"Successfully removed classification processor for model: {model_name}"
) )
@@ -369,13 +363,6 @@ class EmbeddingMaintainer(threading.Thread):
self.config.classification.custom[model_name] = model_config self.config.classification.custom[model_name] = model_config
# A disabled model must not run; tear down any existing processor and
# do not register a new one.
if not model_config.enabled:
self._remove_custom_classification_processor(model_name)
logger.info(f"Disabled classification processor for model: {model_name}")
return
# Check if processor already exists # Check if processor already exists
for processor in self.realtime_processors: for processor in self.realtime_processors:
if isinstance( if isinstance(
@@ -715,11 +702,7 @@ class EmbeddingMaintainer(threading.Thread):
and "license_plate" not in camera_config.objects.track and "license_plate" not in camera_config.objects.track
) )
has_enabled_custom = any( if not dedicated_lpr_enabled and len(self.config.classification.custom) == 0:
c.enabled for c in self.config.classification.custom.values()
)
if not dedicated_lpr_enabled and not has_enabled_custom:
# no active features that use this data # no active features that use this data
return return
+37
View File
@@ -497,6 +497,43 @@ class TestHttpReview(BaseTestHttp):
) )
assert user_review.has_been_reviewed == True assert user_review.has_been_reviewed == True
def test_post_reviews_viewed_concurrent_duplicate_does_not_raise(self):
"""Regression: concurrent requests marking the same review must not 500.
Two requests can both SELECT and find no existing status, then both try
to INSERT, hitting the unique (user_id, review_segment) constraint.
on_conflict_ignore() must silently skip the duplicate instead of raising
an IntegrityError (which was previously caught with try/except).
"""
id = "123456.random"
with AuthTestClient(self.app):
super().insert_mock_review_segment(id)
# Simulate the first request having already committed its insert.
self._insert_user_review_status(id, reviewed=True)
# Simulate the second concurrent request attempting the same insert.
UserReviewStatus.insert_many(
[
{
"user_id": self.user_id,
"review_segment_id": id,
"has_been_reviewed": True,
}
]
).on_conflict_ignore().execute()
# Exactly one row should exist; no exception should have been raised.
count = (
UserReviewStatus.select()
.where(
(UserReviewStatus.user_id == self.user_id)
& (UserReviewStatus.review_segment == id)
)
.count()
)
assert count == 1
#################################################################################################################### ####################################################################################################################
################################### POST reviews/delete Endpoint ################################################ ################################### POST reviews/delete Endpoint ################################################
#################################################################################################################### ####################################################################################################################
-106
View File
@@ -1,106 +0,0 @@
"""Tests that disabled custom classification models are not registered or run."""
import sys
import unittest
from unittest.mock import MagicMock
# Mock TFLite before importing the maintainer / classification modules
_MOCK_MODULES = [
"tflite_runtime",
"tflite_runtime.interpreter",
"ai_edge_litert",
"ai_edge_litert.interpreter",
]
for mod in _MOCK_MODULES:
if mod not in sys.modules:
sys.modules[mod] = MagicMock()
from frigate.data_processing.real_time.custom_classification import ( # noqa: E402
CustomObjectClassificationProcessor,
)
from frigate.embeddings.maintainer import EmbeddingMaintainer # noqa: E402
class TestCustomClassificationEnabledGating(unittest.TestCase):
"""A model with enabled: false must not keep a processor registered."""
def _make_maintainer(self) -> EmbeddingMaintainer:
# Bypass the heavy __init__; only the attributes touched by the
# config update path are needed for these tests.
maintainer = EmbeddingMaintainer.__new__(EmbeddingMaintainer)
maintainer.realtime_processors = []
maintainer.config = MagicMock()
maintainer.config.classification.custom = {}
maintainer.requestor = MagicMock()
maintainer.metrics = MagicMock()
maintainer.event_metadata_publisher = MagicMock()
return maintainer
def _make_model_config(self, name: str, enabled: bool) -> MagicMock:
model_config = MagicMock()
model_config.name = name
model_config.enabled = enabled
model_config.state_config = None
return model_config
def _make_processor(self, name: str) -> MagicMock:
processor = MagicMock(spec=CustomObjectClassificationProcessor)
processor.model_config = MagicMock()
processor.model_config.name = name
return processor
def test_disabled_update_tears_down_existing_processor(self):
"""Toggling a running model to disabled shuts down and drops its processor."""
maintainer = self._make_maintainer()
processor = self._make_processor("atli")
maintainer.realtime_processors = [processor]
maintainer._handle_custom_classification_update(
"config/classification/custom/atli",
self._make_model_config("atli", enabled=False),
)
processor.shutdown.assert_called_once()
self.assertEqual(maintainer.realtime_processors, [])
def test_disabled_update_does_not_register_processor(self):
"""A disabled model that has no processor is never registered."""
maintainer = self._make_maintainer()
maintainer._handle_custom_classification_update(
"config/classification/custom/atli",
self._make_model_config("atli", enabled=False),
)
self.assertEqual(maintainer.realtime_processors, [])
def test_disabled_update_leaves_other_processors_untouched(self):
"""Disabling one model must not affect other running processors."""
maintainer = self._make_maintainer()
other = self._make_processor("simbi")
maintainer.realtime_processors = [other]
maintainer._handle_custom_classification_update(
"config/classification/custom/atli",
self._make_model_config("atli", enabled=False),
)
other.shutdown.assert_not_called()
self.assertEqual(maintainer.realtime_processors, [other])
def test_removed_model_tears_down_processor(self):
"""A None payload (model deleted) still shuts down its processor."""
maintainer = self._make_maintainer()
processor = self._make_processor("atli")
maintainer.realtime_processors = [processor]
maintainer._handle_custom_classification_update(
"config/classification/custom/atli", None
)
processor.shutdown.assert_called_once()
self.assertEqual(maintainer.realtime_processors, [])
if __name__ == "__main__":
unittest.main()
+21
View File
@@ -0,0 +1,21 @@
"""Peewee migrations -- 036_add_perf_indexes.py.
Adds composite/single-column indexes to speed up single-camera queries
issued by the web UI.
"""
import peewee as pw
SQL = pw.SQL
def migrate(migrator, database, fake=False, **kwargs):
migrator.sql(
'CREATE INDEX IF NOT EXISTS "event_camera_start_time" '
'ON "event" ("camera", "start_time" DESC)'
)
def rollback(migrator, database, fake=False, **kwargs):
migrator.sql('DROP INDEX IF EXISTS "event_camera_start_time"')
+1 -8
View File
@@ -1490,14 +1490,7 @@
"keyLabel": "Key", "keyLabel": "Key",
"valueLabel": "Value", "valueLabel": "Value",
"keyPlaceholder": "New key", "keyPlaceholder": "New key",
"remove": "Remove", "remove": "Remove"
"providerNameLabel": "Provider name",
"providerNamePlaceholder": "e.g., openai",
"variableNameLabel": "Variable name",
"variableNamePlaceholder": "e.g., MY_VARIABLE",
"loggerNameLabel": "Logger name",
"loggerNamePlaceholder": "e.g., frigate.record",
"keyPatternError": "Use only letters, numbers, hyphens, and underscores (no spaces)"
}, },
"knownPlates": { "knownPlates": {
"namePlaceholder": "e.g., Wife's Car", "namePlaceholder": "e.g., Wife's Car",
@@ -7,13 +7,7 @@ const environmentVars: SectionConfigOverrides = {
advancedFields: [], advancedFields: [],
uiSchema: { uiSchema: {
additionalProperties: { additionalProperties: {
"ui:options": { "ui:options": { size: "lg" },
size: "lg",
additionalPropertyKeyLabel:
"configForm.additionalProperties.variableNameLabel",
additionalPropertyKeyPlaceholder:
"configForm.additionalProperties.variableNamePlaceholder",
},
}, },
}, },
}, },
@@ -9,15 +9,7 @@ const genai: SectionConfigOverrides = {
uiSchema: { uiSchema: {
"ui:options": { disableNestedCard: true }, "ui:options": { disableNestedCard: true },
"*": { "*": {
"ui:options": { "ui:options": { disableNestedCard: true },
disableNestedCard: true,
additionalPropertyKeyLabel:
"configForm.additionalProperties.providerNameLabel",
additionalPropertyKeyPlaceholder:
"configForm.additionalProperties.providerNamePlaceholder",
additionalPropertyKeyPattern: "^[a-zA-Z0-9_-]+$",
preventKeyRename: true,
},
"ui:order": [ "ui:order": [
"provider", "provider",
"api_key", "api_key",
@@ -12,13 +12,7 @@ const logger: SectionConfigOverrides = {
}, },
logs: { logs: {
additionalProperties: { additionalProperties: {
"ui:options": { "ui:options": { enumI18nPrefix: "logger.logLevel" },
enumI18nPrefix: "logger.logLevel",
additionalPropertyKeyLabel:
"configForm.additionalProperties.loggerNameLabel",
additionalPropertyKeyPlaceholder:
"configForm.additionalProperties.loggerNamePlaceholder",
},
}, },
}, },
}, },
@@ -6,14 +6,12 @@ import {
StrictRJSFSchema, StrictRJSFSchema,
WrapIfAdditionalTemplateProps, WrapIfAdditionalTemplateProps,
} from "@rjsf/utils"; } from "@rjsf/utils";
import { useEffect, useMemo, useState, type FocusEvent } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LuTrash2 } from "react-icons/lu"; import { LuTrash2 } from "react-icons/lu";
import type { ConfigFormContext } from "@/types/configForm";
export function WrapIfAdditionalTemplate< export function WrapIfAdditionalTemplate<
T = unknown, T = unknown,
@@ -32,7 +30,6 @@ export function WrapIfAdditionalTemplate<
onKeyRenameBlur, onKeyRenameBlur,
readonly, readonly,
required, required,
registry,
schema, schema,
uiSchema, uiSchema,
} = props; } = props;
@@ -41,55 +38,6 @@ export function WrapIfAdditionalTemplate<
const additional = ADDITIONAL_PROPERTY_FLAG in schema; const additional = ADDITIONAL_PROPERTY_FLAG in schema;
const uiOptions = getUiOptions(uiSchema);
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
const keyLabelKey =
typeof uiOptions.additionalPropertyKeyLabel === "string"
? uiOptions.additionalPropertyKeyLabel
: undefined;
const keyPlaceholderKey =
typeof uiOptions.additionalPropertyKeyPlaceholder === "string"
? uiOptions.additionalPropertyKeyPlaceholder
: undefined;
const keyPattern =
typeof uiOptions.additionalPropertyKeyPattern === "string"
? uiOptions.additionalPropertyKeyPattern
: undefined;
const preventKeyRename = uiOptions.preventKeyRename === true;
const formContext = registry?.formContext as ConfigFormContext | undefined;
// optionally, lock the key once it's been saved
const baseline = formContext?.baselineFormData;
const keyLocked =
preventKeyRename &&
typeof label === "string" &&
!!baseline &&
Object.prototype.hasOwnProperty.call(baseline, label);
// controlled key value so we can validate live and block invalid renames.
const [keyValue, setKeyValue] = useState<string>(label ?? "");
useEffect(() => {
setKeyValue(label ?? "");
}, [label]);
const keyRegex = useMemo(
() => (keyPattern ? new RegExp(keyPattern) : undefined),
[keyPattern],
);
const keyError = useMemo(() => {
if (!keyRegex || keyLocked) return null;
if (!keyRegex.test(keyValue)) {
return t("configForm.additionalProperties.keyPatternError", {
ns: "views/settings",
defaultValue:
"Use only letters, numbers, hyphens, and underscores (no spaces)",
});
}
return null;
}, [keyRegex, keyLocked, keyValue, t]);
if (!additional) { if (!additional) {
return ( return (
<div className={classNames} style={style}> <div className={classNames} style={style}>
@@ -99,26 +47,20 @@ export function WrapIfAdditionalTemplate<
} }
const keyId = `${id}-key`; const keyId = `${id}-key`;
const keyLabel = keyLabelKey const keyLabel = t("configForm.additionalProperties.keyLabel", {
? t(keyLabelKey, { ns: "views/settings" }) ns: "views/settings",
: t("configForm.additionalProperties.keyLabel", { ns: "views/settings" }); });
const valueLabel = t("configForm.additionalProperties.valueLabel", { const valueLabel = t("configForm.additionalProperties.valueLabel", {
ns: "views/settings", ns: "views/settings",
}); });
const keyPlaceholder = keyPlaceholderKey const keyPlaceholder = t("configForm.additionalProperties.keyPlaceholder", {
? t(keyPlaceholderKey, { ns: "views/settings" })
: t("configForm.additionalProperties.keyPlaceholder", {
ns: "views/settings", ns: "views/settings",
}); });
const removeLabel = t("configForm.additionalProperties.remove", { const removeLabel = t("configForm.additionalProperties.remove", {
ns: "views/settings", ns: "views/settings",
}); });
const uiOptions = getUiOptions(uiSchema);
const commitKeyRename = (e: FocusEvent<HTMLInputElement>) => { const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
if (readonly) return;
if (keyError) return;
onKeyRenameBlur?.(e);
};
return ( return (
<div <div
@@ -128,30 +70,23 @@ export function WrapIfAdditionalTemplate<
{!keyIsReadonly && ( {!keyIsReadonly && (
<div className="col-span-12 space-y-2 md:col-span-2"> <div className="col-span-12 space-y-2 md:col-span-2">
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>} {displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
{keyLocked ? ( {keyIsReadonly ? (
<div <div
id={keyId} id={keyId}
className="flex items-center break-all text-sm text-primary-variant" className="flex items-center text-sm text-muted-foreground"
> >
{label} {label}
</div> </div>
) : ( ) : (
<>
<Input <Input
id={keyId} id={keyId}
name={keyId} name={keyId}
required={required} required={required}
value={keyValue} defaultValue={label}
placeholder={keyPlaceholder} placeholder={keyPlaceholder}
disabled={disabled || readonly} disabled={disabled || readonly}
onChange={(e) => setKeyValue(e.target.value)} onBlur={!readonly ? onKeyRenameBlur : undefined}
onBlur={!readonly ? commitKeyRename : undefined}
aria-invalid={keyError ? true : undefined}
/> />
{keyError && (
<p className="text-xs text-destructive">{keyError}</p>
)}
</>
)} )}
</div> </div>
)} )}