mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-08 20:42:51 -05:00
Compare commits
5 Commits
dev
...
misc-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21565a209 | ||
|
|
e658a70e0f | ||
|
|
82cb69526b | ||
|
|
fe3677c7df | ||
|
|
1fec95f88e |
@@ -166,6 +166,10 @@ In this example:
|
||||
- If no mapping matches, Frigate falls back to `default_role` if configured.
|
||||
- If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name.
|
||||
|
||||
**Note on matching semantics:**
|
||||
|
||||
- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups).
|
||||
|
||||
#### Port Considerations
|
||||
|
||||
**Authenticated Port (8971)**
|
||||
|
||||
@@ -439,10 +439,11 @@ def resolve_role(
|
||||
Determine the effective role for a request based on proxy headers and configuration.
|
||||
|
||||
Order of resolution:
|
||||
1. If a role header is defined in proxy_config.header_map.role:
|
||||
- If a role_map is configured, treat the header as group claims
|
||||
(split by proxy_config.separator) and map to roles.
|
||||
- If no role_map is configured, treat the header as role names directly.
|
||||
1. If a role header is defined in proxy_config.header_map.role:
|
||||
- If a role_map is configured, treat the header as group claims
|
||||
(split by proxy_config.separator) and map to roles.
|
||||
Admin matches short-circuit to admin.
|
||||
- If no role_map is configured, treat the header as role names directly.
|
||||
2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'.
|
||||
|
||||
Args:
|
||||
@@ -492,6 +493,12 @@ def resolve_role(
|
||||
}
|
||||
logger.debug("Matched roles from role_map: %s", matched_roles)
|
||||
|
||||
# If admin matches, prioritize it to avoid accidental downgrade when
|
||||
# users belong to both admin and lower-privilege groups.
|
||||
if "admin" in matched_roles and "admin" in config_roles:
|
||||
logger.debug("Resolved role (with role_map) to 'admin'.")
|
||||
return "admin"
|
||||
|
||||
if matched_roles:
|
||||
resolved = next(
|
||||
(r for r in config_roles if r in matched_roles), validated_default
|
||||
|
||||
@@ -31,6 +31,21 @@ class TestProxyRoleResolution(unittest.TestCase):
|
||||
role = resolve_role(headers, self.proxy_config, self.config_roles)
|
||||
self.assertEqual(role, "admin")
|
||||
|
||||
def test_role_map_or_matching(self):
|
||||
config = self.proxy_config
|
||||
config.header_map.role_map = {
|
||||
"admin": ["group_admin", "group_privileged"],
|
||||
}
|
||||
|
||||
# OR semantics: a single matching group should map to the role
|
||||
headers = {"x-remote-role": "group_admin"}
|
||||
role = resolve_role(headers, config, self.config_roles)
|
||||
self.assertEqual(role, "admin")
|
||||
|
||||
headers = {"x-remote-role": "group_admin|group_privileged"}
|
||||
role = resolve_role(headers, config, self.config_roles)
|
||||
self.assertEqual(role, "admin")
|
||||
|
||||
def test_direct_role_header_with_separator(self):
|
||||
config = self.proxy_config
|
||||
config.header_map.role_map = None # disable role_map
|
||||
|
||||
@@ -214,6 +214,7 @@ class CameraWatchdog(threading.Thread):
|
||||
self.latest_valid_segment_time: float = 0
|
||||
self.latest_invalid_segment_time: float = 0
|
||||
self.latest_cache_segment_time: float = 0
|
||||
self.record_enable_time: datetime | None = None
|
||||
|
||||
def _update_enabled_state(self) -> bool:
|
||||
"""Fetch the latest config and update enabled state."""
|
||||
@@ -261,6 +262,9 @@ class CameraWatchdog(threading.Thread):
|
||||
def run(self) -> None:
|
||||
if self._update_enabled_state():
|
||||
self.start_all_ffmpeg()
|
||||
# If recording is enabled at startup, set the grace period timer
|
||||
if self.config.record.enabled:
|
||||
self.record_enable_time = datetime.now().astimezone(timezone.utc)
|
||||
|
||||
time.sleep(self.sleeptime)
|
||||
while not self.stop_event.wait(self.sleeptime):
|
||||
@@ -270,13 +274,15 @@ class CameraWatchdog(threading.Thread):
|
||||
self.logger.debug(f"Enabling camera {self.config.name}")
|
||||
self.start_all_ffmpeg()
|
||||
|
||||
# reset all timestamps
|
||||
# reset all timestamps and record the enable time for grace period
|
||||
self.latest_valid_segment_time = 0
|
||||
self.latest_invalid_segment_time = 0
|
||||
self.latest_cache_segment_time = 0
|
||||
self.record_enable_time = datetime.now().astimezone(timezone.utc)
|
||||
else:
|
||||
self.logger.debug(f"Disabling camera {self.config.name}")
|
||||
self.stop_all_ffmpeg()
|
||||
self.record_enable_time = None
|
||||
|
||||
# update camera status
|
||||
self.requestor.send_data(
|
||||
@@ -361,6 +367,12 @@ class CameraWatchdog(threading.Thread):
|
||||
if self.config.record.enabled and "record" in p["roles"]:
|
||||
now_utc = datetime.now().astimezone(timezone.utc)
|
||||
|
||||
# Check if we're within the grace period after enabling recording
|
||||
# Grace period: 90 seconds allows time for ffmpeg to start and create first segment
|
||||
in_grace_period = self.record_enable_time is not None and (
|
||||
now_utc - self.record_enable_time
|
||||
) < timedelta(seconds=90)
|
||||
|
||||
latest_cache_dt = (
|
||||
datetime.fromtimestamp(
|
||||
self.latest_cache_segment_time, tz=timezone.utc
|
||||
@@ -386,10 +398,16 @@ class CameraWatchdog(threading.Thread):
|
||||
)
|
||||
|
||||
# ensure segments are still being created and that they have valid video data
|
||||
cache_stale = now_utc > (latest_cache_dt + timedelta(seconds=120))
|
||||
valid_stale = now_utc > (latest_valid_dt + timedelta(seconds=120))
|
||||
# Skip checks during grace period to allow segments to start being created
|
||||
cache_stale = not in_grace_period and now_utc > (
|
||||
latest_cache_dt + timedelta(seconds=120)
|
||||
)
|
||||
valid_stale = not in_grace_period and now_utc > (
|
||||
latest_valid_dt + timedelta(seconds=120)
|
||||
)
|
||||
invalid_stale_condition = (
|
||||
self.latest_invalid_segment_time > 0
|
||||
and not in_grace_period
|
||||
and now_utc > (latest_invalid_dt + timedelta(seconds=120))
|
||||
and self.latest_valid_segment_time
|
||||
<= self.latest_invalid_segment_time
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Button } from "../ui/button";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
|
||||
type AnimatedEventCardProps = {
|
||||
event: ReviewSegment;
|
||||
@@ -50,26 +52,37 @@ export function AnimatedEventCard({
|
||||
fetchPreviews: !currentHour,
|
||||
});
|
||||
|
||||
const getEventType = useCallback(
|
||||
(text: string) => {
|
||||
if (event.data.sub_labels?.includes(text)) return "manual";
|
||||
if (event.data.audio.includes(text)) return "audio";
|
||||
return "object";
|
||||
},
|
||||
[event],
|
||||
);
|
||||
|
||||
const tooltipText = useMemo(() => {
|
||||
if (event?.data?.metadata?.title) {
|
||||
return event.data.metadata.title;
|
||||
}
|
||||
|
||||
return (
|
||||
`${[
|
||||
...new Set([
|
||||
...(event.data.objects || []),
|
||||
...(event.data.sub_labels || []),
|
||||
...(event.data.audio || []),
|
||||
]),
|
||||
]
|
||||
.filter((item) => item !== undefined && !item.includes("-verified"))
|
||||
.map((text) => text.charAt(0).toUpperCase() + text.substring(1))
|
||||
.sort()
|
||||
.join(", ")
|
||||
.replaceAll("-verified", "")} ` + t("detected")
|
||||
`${formatList(
|
||||
[
|
||||
...new Set([
|
||||
...(event.data.objects || []).map((text) =>
|
||||
text.replace("-verified", ""),
|
||||
),
|
||||
...(event.data.sub_labels || []),
|
||||
...(event.data.audio || []),
|
||||
]),
|
||||
]
|
||||
.filter((item) => item !== undefined)
|
||||
.map((text) => getTranslatedLabel(text, getEventType(text)))
|
||||
.sort(),
|
||||
)} ` + t("detected")
|
||||
);
|
||||
}, [event, t]);
|
||||
}, [event, getEventType, t]);
|
||||
|
||||
// visibility
|
||||
|
||||
|
||||
@@ -33,13 +33,14 @@ import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { capitalizeFirstLetter } from "@/utils/stringUtil";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LuCircle } from "react-icons/lu";
|
||||
import { MdAutoAwesome } from "react-icons/md";
|
||||
import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
|
||||
type ReviewCardProps = {
|
||||
event: ReviewSegment;
|
||||
@@ -123,6 +124,12 @@ export default function ReviewCard({
|
||||
}
|
||||
}, [bypassDialogRef, onDelete]);
|
||||
|
||||
const getEventType = (text: string) => {
|
||||
if (event.data.sub_labels?.includes(text)) return "manual";
|
||||
if (event.data.audio.includes(text)) return "audio";
|
||||
return "object";
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className="relative flex w-full cursor-pointer flex-col gap-1.5"
|
||||
@@ -197,20 +204,20 @@ export default function ReviewCard({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="smart-capitalize">
|
||||
{[
|
||||
...new Set([
|
||||
...(event.data.objects || []),
|
||||
...(event.data.sub_labels || []),
|
||||
...(event.data.audio || []),
|
||||
]),
|
||||
]
|
||||
.filter(
|
||||
(item) => item !== undefined && !item.includes("-verified"),
|
||||
)
|
||||
.map((text) => capitalizeFirstLetter(text))
|
||||
.sort()
|
||||
.join(", ")
|
||||
.replaceAll("-verified", "")}
|
||||
{formatList(
|
||||
[
|
||||
...new Set([
|
||||
...(event.data.objects || []).map((text) =>
|
||||
text.replace("-verified", ""),
|
||||
),
|
||||
...(event.data.sub_labels || []),
|
||||
...(event.data.audio || []),
|
||||
]),
|
||||
]
|
||||
.filter((item) => item !== undefined)
|
||||
.map((text) => getTranslatedLabel(text, getEventType(text)))
|
||||
.sort(),
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<TimeAgo
|
||||
|
||||
@@ -371,22 +371,23 @@ export default function LivePlayer({
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
<TooltipPortal>
|
||||
<TooltipContent className="smart-capitalize">
|
||||
<TooltipContent>
|
||||
{formatList(
|
||||
[
|
||||
...new Set([
|
||||
...(objects || []).map(({ label, sub_label }) =>
|
||||
label.endsWith("verified")
|
||||
? sub_label
|
||||
: label.replaceAll("_", " "),
|
||||
),
|
||||
]),
|
||||
]
|
||||
.filter((label) => label?.includes("-verified") == false)
|
||||
.map((label) =>
|
||||
getTranslatedLabel(label.replace("-verified", "")),
|
||||
)
|
||||
.sort(),
|
||||
...new Set(
|
||||
(objects || [])
|
||||
.map(({ label, sub_label }) => {
|
||||
const isManual = label.endsWith("verified");
|
||||
const text = isManual ? sub_label : label;
|
||||
const type = isManual ? "manual" : "object";
|
||||
return getTranslatedLabel(text, type);
|
||||
})
|
||||
.filter(
|
||||
(translated) =>
|
||||
translated && !translated.includes("-verified"),
|
||||
),
|
||||
),
|
||||
].sort(),
|
||||
)}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaExclamationTriangle } from "react-icons/fa";
|
||||
import { MdOutlinePersonSearch } from "react-icons/md";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
|
||||
type PreviewPlayerProps = {
|
||||
review: ReviewSegment;
|
||||
@@ -182,9 +183,8 @@ export default function PreviewThumbnailPlayer({
|
||||
);
|
||||
|
||||
const getEventType = (text: string) => {
|
||||
if (review.data.objects.includes(text)) return "object";
|
||||
if (review.data.audio.includes(text)) return "audio";
|
||||
if (review.data.sub_labels?.includes(text)) return "manual";
|
||||
if (review.data.audio.includes(text)) return "audio";
|
||||
return "object";
|
||||
};
|
||||
|
||||
@@ -268,13 +268,16 @@ export default function PreviewThumbnailPlayer({
|
||||
className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`}
|
||||
onClick={() => onClick(review, false, true)}
|
||||
>
|
||||
{review.data.objects.sort().map((object) => {
|
||||
return getIconForLabel(
|
||||
object,
|
||||
"object",
|
||||
"size-3 text-white",
|
||||
);
|
||||
})}
|
||||
{review.data.objects
|
||||
.sort()
|
||||
.map((object, idx) =>
|
||||
getIconForLabel(
|
||||
object,
|
||||
"object",
|
||||
"size-3 text-white",
|
||||
`${object}-${idx}`,
|
||||
),
|
||||
)}
|
||||
{review.data.audio.map((audio) => {
|
||||
return getIconForLabel(
|
||||
audio,
|
||||
@@ -288,23 +291,25 @@ export default function PreviewThumbnailPlayer({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
<TooltipContent className="smart-capitalize">
|
||||
<TooltipContent>
|
||||
{review.data.metadata
|
||||
? review.data.metadata.title
|
||||
: [
|
||||
...new Set([
|
||||
...(review.data.objects || []),
|
||||
...(review.data.sub_labels || []),
|
||||
...(review.data.audio || []),
|
||||
]),
|
||||
]
|
||||
.filter(
|
||||
(item) =>
|
||||
item !== undefined && !item.includes("-verified"),
|
||||
)
|
||||
.map((text) => getTranslatedLabel(text, getEventType(text)))
|
||||
.sort()
|
||||
.join(", ")}
|
||||
: formatList(
|
||||
[
|
||||
...new Set([
|
||||
...(review.data.objects || []).map((text) =>
|
||||
text.replace("-verified", ""),
|
||||
),
|
||||
...(review.data.sub_labels || []),
|
||||
...(review.data.audio || []),
|
||||
]),
|
||||
]
|
||||
.filter((item) => item !== undefined)
|
||||
.map((text) =>
|
||||
getTranslatedLabel(text, getEventType(text)),
|
||||
)
|
||||
.sort(),
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{!!(
|
||||
|
||||
@@ -62,83 +62,86 @@ export function getIconForLabel(
|
||||
label: string,
|
||||
type: EventType = "object",
|
||||
className?: string,
|
||||
key?: string,
|
||||
) {
|
||||
const iconKey = key || label;
|
||||
|
||||
if (label.endsWith("-verified")) {
|
||||
return getVerifiedIcon(label, className, type);
|
||||
return getVerifiedIcon(label, className, type, iconKey);
|
||||
} else if (label.endsWith("-plate")) {
|
||||
return getRecognizedPlateIcon(label, className, type);
|
||||
return getRecognizedPlateIcon(label, className, type, iconKey);
|
||||
}
|
||||
|
||||
switch (label) {
|
||||
// objects
|
||||
case "bear":
|
||||
return <GiPolarBear key={label} className={className} />;
|
||||
return <GiPolarBear key={iconKey} className={className} />;
|
||||
case "bicycle":
|
||||
return <FaBicycle key={label} className={className} />;
|
||||
return <FaBicycle key={iconKey} className={className} />;
|
||||
case "bird":
|
||||
return <PiBirdFill key={label} className={className} />;
|
||||
return <PiBirdFill key={iconKey} className={className} />;
|
||||
case "boat":
|
||||
return <GiSailboat key={label} className={className} />;
|
||||
return <GiSailboat key={iconKey} className={className} />;
|
||||
case "bus":
|
||||
case "school_bus":
|
||||
return <FaBus key={label} className={className} />;
|
||||
return <FaBus key={iconKey} className={className} />;
|
||||
case "car":
|
||||
case "vehicle":
|
||||
return <FaCarSide key={label} className={className} />;
|
||||
return <FaCarSide key={iconKey} className={className} />;
|
||||
case "cat":
|
||||
return <FaCat key={label} className={className} />;
|
||||
return <FaCat key={iconKey} className={className} />;
|
||||
case "deer":
|
||||
return <GiDeer key={label} className={className} />;
|
||||
return <GiDeer key={iconKey} className={className} />;
|
||||
case "animal":
|
||||
case "bark":
|
||||
case "dog":
|
||||
return <FaDog key={label} className={className} />;
|
||||
return <FaDog key={iconKey} className={className} />;
|
||||
case "fox":
|
||||
return <GiFox key={label} className={className} />;
|
||||
return <GiFox key={iconKey} className={className} />;
|
||||
case "goat":
|
||||
return <GiGoat key={label} className={className} />;
|
||||
return <GiGoat key={iconKey} className={className} />;
|
||||
case "horse":
|
||||
return <FaHorse key={label} className={className} />;
|
||||
return <FaHorse key={iconKey} className={className} />;
|
||||
case "kangaroo":
|
||||
return <GiKangaroo key={label} className={className} />;
|
||||
return <GiKangaroo key={iconKey} className={className} />;
|
||||
case "license_plate":
|
||||
return <LuScanBarcode key={label} className={className} />;
|
||||
return <LuScanBarcode key={iconKey} className={className} />;
|
||||
case "motorcycle":
|
||||
return <FaMotorcycle key={label} className={className} />;
|
||||
return <FaMotorcycle key={iconKey} className={className} />;
|
||||
case "mouse":
|
||||
return <FaMouse key={label} className={className} />;
|
||||
return <FaMouse key={iconKey} className={className} />;
|
||||
case "package":
|
||||
return <LuBox key={label} className={className} />;
|
||||
return <LuBox key={iconKey} className={className} />;
|
||||
case "person":
|
||||
return <BsPersonWalking key={label} className={className} />;
|
||||
return <BsPersonWalking key={iconKey} className={className} />;
|
||||
case "rabbit":
|
||||
return <GiRabbit key={label} className={className} />;
|
||||
return <GiRabbit key={iconKey} className={className} />;
|
||||
case "raccoon":
|
||||
return <GiRaccoonHead key={label} className={className} />;
|
||||
return <GiRaccoonHead key={iconKey} className={className} />;
|
||||
case "robot_lawnmower":
|
||||
return <FaHockeyPuck key={label} className={className} />;
|
||||
return <FaHockeyPuck key={iconKey} className={className} />;
|
||||
case "sports_ball":
|
||||
return <FaFootballBall key={label} className={className} />;
|
||||
return <FaFootballBall key={iconKey} className={className} />;
|
||||
case "skunk":
|
||||
return <GiSquirrel key={label} className={className} />;
|
||||
return <GiSquirrel key={iconKey} className={className} />;
|
||||
case "squirrel":
|
||||
return <LuIcons.LuSquirrel key={label} className={className} />;
|
||||
return <LuIcons.LuSquirrel key={iconKey} className={className} />;
|
||||
case "umbrella":
|
||||
return <FaUmbrella key={label} className={className} />;
|
||||
return <FaUmbrella key={iconKey} className={className} />;
|
||||
case "waste_bin":
|
||||
return <FaRegTrashAlt key={label} className={className} />;
|
||||
return <FaRegTrashAlt key={iconKey} className={className} />;
|
||||
// audio
|
||||
case "crying":
|
||||
case "laughter":
|
||||
case "scream":
|
||||
case "speech":
|
||||
case "yell":
|
||||
return <MdRecordVoiceOver key={label} className={className} />;
|
||||
return <MdRecordVoiceOver key={iconKey} className={className} />;
|
||||
case "fire_alarm":
|
||||
return <FaFire key={label} className={className} />;
|
||||
return <FaFire key={iconKey} className={className} />;
|
||||
// sub labels
|
||||
case "amazon":
|
||||
return <FaAmazon key={label} className={className} />;
|
||||
return <FaAmazon key={iconKey} className={className} />;
|
||||
case "an_post":
|
||||
case "canada_post":
|
||||
case "dpd":
|
||||
@@ -148,20 +151,20 @@ export function getIconForLabel(
|
||||
case "postnord":
|
||||
case "purolator":
|
||||
case "royal_mail":
|
||||
return <GiPostStamp key={label} className={className} />;
|
||||
return <GiPostStamp key={iconKey} className={className} />;
|
||||
case "dhl":
|
||||
return <FaDhl key={label} className={className} />;
|
||||
return <FaDhl key={iconKey} className={className} />;
|
||||
case "fedex":
|
||||
return <FaFedex key={label} className={className} />;
|
||||
return <FaFedex key={iconKey} className={className} />;
|
||||
case "ups":
|
||||
return <FaUps key={label} className={className} />;
|
||||
return <FaUps key={iconKey} className={className} />;
|
||||
case "usps":
|
||||
return <FaUsps key={label} className={className} />;
|
||||
return <FaUsps key={iconKey} className={className} />;
|
||||
default:
|
||||
if (type === "audio") {
|
||||
return <GiSoundWaves key={label} className={className} />;
|
||||
return <GiSoundWaves key={iconKey} className={className} />;
|
||||
}
|
||||
return <LuLassoSelect key={label} className={className} />;
|
||||
return <LuLassoSelect key={iconKey} className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,11 +172,12 @@ function getVerifiedIcon(
|
||||
label: string,
|
||||
className?: string,
|
||||
type: EventType = "object",
|
||||
key?: string,
|
||||
) {
|
||||
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
|
||||
|
||||
return (
|
||||
<div key={label} className="relative flex items-center">
|
||||
<div key={key} className="relative flex items-center">
|
||||
{getIconForLabel(simpleLabel, type, className)}
|
||||
<FaCheckCircle className="absolute -bottom-0.5 -right-0.5 size-2" />
|
||||
</div>
|
||||
@@ -184,11 +188,12 @@ function getRecognizedPlateIcon(
|
||||
label: string,
|
||||
className?: string,
|
||||
type: EventType = "object",
|
||||
key?: string,
|
||||
) {
|
||||
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
|
||||
|
||||
return (
|
||||
<div key={label} className="relative inline-flex items-center">
|
||||
<div key={key} className="relative inline-flex items-center">
|
||||
{getIconForLabel(simpleLabel, type, className)}
|
||||
<LuScanBarcode className="absolute -bottom-0.5 -right-0.5 size-2" />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user