Compare commits

...
3 Commits
Author SHA1 Message Date
Blake BlackshearandGitHub 93016c662f add synaptics to release (#21591)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-01-10 11:50:37 -06:00
c08fa15724 Miscellaneous Fixes (0.17 beta) (#21575)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* icon improvements

add type to getIconForLabel
provide default icon for audio events

* Add preferred language to review docs

* prevent react Suspense crash during auth redirect

add redirect-check guards to stop rendering lazy routes while navigation is pending (fixes some users seeing React error #426 when auth expires)

* Uppsercase model name

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-01-09 16:23:33 -07:00
GuoQing LiuandGitHub f3543cfee2 I18N Miscellaneous Fixes (#21573)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* fix: fix classification none tag i18n wrong

* fix: fix set password dialog jwt time i18n wrong

* fix: fix wizard other camera i18n

* fix: fix explore tracking detail audio i18n

* feat: add system processes info i18n

* fix: fix live page label i18n
2026-01-08 14:28:18 -07:00
22 changed files with 163 additions and 59 deletions
+2 -2
View File
@@ -39,14 +39,14 @@ jobs:
STABLE_TAG=${BASE}:stable
PULL_TAG=${BASE}:${BUILD_TAG}
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${VERSION_TAG}
for variant in standard-arm64 tensorrt tensorrt-jp6 rk rocm; do
for variant in standard-arm64 tensorrt tensorrt-jp6 rk rocm synaptics; do
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${VERSION_TAG}-${variant}
done
# stable tag
if [[ "${BUILD_TYPE}" == "stable" ]]; then
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG} docker://${STABLE_TAG}
for variant in standard-arm64 tensorrt tensorrt-jp6 rk rocm; do
for variant in standard-arm64 tensorrt tensorrt-jp6 rk rocm synaptics; do
docker run --rm -v $HOME/.docker/config.json:/config.json quay.io/skopeo/stable:latest copy --authfile /config.json --multi-arch all docker://${PULL_TAG}-${variant} docker://${STABLE_TAG}-${variant}
done
fi
@@ -112,6 +112,17 @@ review:
- animals in the garden
```
### Preferred Language
By default, review summaries are generated in English. You can configure Frigate to generate summaries in your preferred language by setting the `preferred_language` option:
```yaml
review:
genai:
enabled: true
preferred_language: Spanish
```
## Review Reports
Along with individual review item summaries, Generative AI provides the ability to request a report of a given time period. For example, you can get a daily report while on a vacation of any suspicious activity or other concerns that may require review.
+2 -1
View File
@@ -101,7 +101,8 @@
"show": "Show {{item}}",
"ID": "ID",
"none": "None",
"all": "All"
"all": "All",
"other": "Other"
},
"list": {
"two": "{{0}} and {{1}}",
+8 -1
View File
@@ -86,7 +86,14 @@
"otherProcesses": {
"title": "Other Processes",
"processCpuUsage": "Process CPU Usage",
"processMemoryUsage": "Process Memory Usage"
"processMemoryUsage": "Process Memory Usage",
"series": {
"go2rtc": "go2rtc",
"recording": "recording",
"review_segment": "review segment",
"embeddings": "embeddings",
"audio_detector": "audio detector"
}
}
},
"storage": {
+11
View File
@@ -15,6 +15,7 @@ import { AuthProvider } from "@/context/auth-context";
import useSWR from "swr";
import { FrigateConfig } from "./types/frigateConfig";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { isRedirectingToLogin } from "@/api/auth-redirect";
const Live = lazy(() => import("@/pages/Live"));
const Events = lazy(() => import("@/pages/Events"));
@@ -58,6 +59,16 @@ function DefaultAppView() {
? Object.keys(config.auth.roles)
: undefined;
// Show loading indicator during redirect to prevent React from attempting to render
// lazy components, which would cause error #426 (suspension during synchronous navigation)
if (isRedirectingToLogin()) {
return (
<div className="size-full overflow-hidden">
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
);
}
return (
<div className="size-full overflow-hidden">
{isDesktop && <Sidebar />}
@@ -28,6 +28,14 @@ export default function ProtectedRoute({
}
}, [auth.isLoading, auth.isAuthenticated, auth.user]);
// Show loading indicator during redirect to prevent React from attempting to render
// lazy components, which would cause error #426 (suspension during synchronous navigation)
if (isRedirectingToLogin()) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
);
}
if (auth.isLoading) {
return (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
@@ -166,7 +166,7 @@ export const ClassificationCard = forwardRef<
<div className="break-all smart-capitalize">
{data.name == "unknown"
? t("details.unknown")
: data.name == "none"
: data.name.toLowerCase() == "none"
? t("details.none")
: data.name}
</div>
+2 -2
View File
@@ -181,7 +181,7 @@ export default function ReviewCard({
key={`${object}-${idx}`}
className="rounded-full bg-muted-foreground p-1"
>
{getIconForLabel(object, "size-3 text-white")}
{getIconForLabel(object, "object", "size-3 text-white")}
</div>
))}
{event.data.audio.map((audio, idx) => (
@@ -189,7 +189,7 @@ export default function ReviewCard({
key={`${audio}-${idx}`}
className="rounded-full bg-muted-foreground p-1"
>
{getIconForLabel(audio, "size-3 text-white")}
{getIconForLabel(audio, "audio", "size-3 text-white")}
</div>
))}
</div>
+5 -1
View File
@@ -133,7 +133,11 @@ export default function SearchThumbnail({
className={`z-0 flex items-center justify-between gap-1 space-x-1 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-xs capitalize`}
onClick={() => onClick(searchResult, false, true)}
>
{getIconForLabel(objectLabel, "size-3 text-white")}
{getIconForLabel(
objectLabel,
searchResult.data.type,
"size-3 text-white",
)}
{Math.floor(
(searchResult.data.score ??
searchResult.data.top_score ??
@@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import useSWR from "swr";
import { formatSecondsToDuration } from "@/utils/dateUtil";
import { useDateLocale } from "@/hooks/use-date-locale";
import ActivityIndicator from "../indicators/activity-indicator";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
@@ -48,12 +49,13 @@ export default function SetPasswordDialog({
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const isAdmin = useIsAdmin();
const dateLocale = useDateLocale();
const { data: config } = useSWR("config");
const refreshSeconds: number | undefined =
config?.auth?.refresh_time ?? undefined;
const refreshTimeLabel = refreshSeconds
? formatSecondsToDuration(refreshSeconds)
? formatSecondsToDuration(refreshSeconds, dateLocale)
: t("time.30minutes", { ns: "common" });
// visibility toggles for password fields
@@ -1296,7 +1296,11 @@ function ObjectDetailsTab({
{t("details.label")}
</div>
<div className="flex flex-row items-center gap-2 text-sm smart-capitalize">
{getIconForLabel(search.label, "size-4 text-primary")}
{getIconForLabel(
search.label,
search.data.type,
"size-4 text-primary",
)}
{getTranslatedLabel(search.label, search.data.type)}
{search.sub_label && ` (${search.sub_label})`}
{isAdmin && search.end_time && (
@@ -266,7 +266,7 @@ export function TrackingDetails({
const label = event.sub_label
? event.sub_label
: getTranslatedLabel(event.label);
: getTranslatedLabel(event.label, event.data.type);
const getZoneColor = useCallback(
(zoneName: string) => {
@@ -665,6 +665,7 @@ export function TrackingDetails({
>
{getIconForLabel(
event.sub_label ? event.label + "-verified" : event.label,
event.data.type,
"size-4 text-white",
)}
</div>
@@ -998,7 +999,7 @@ function LifecycleIconRow({
<div className="ml-3 flex-shrink-0 px-1 text-right text-xs text-primary-variant">
<div className="flex flex-row items-center gap-3">
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
{((isAdmin && config?.plus?.enabled) || item.data.box) && (
{isAdmin && config?.plus?.enabled && item.data.box && (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger>
<div className="rounded p-1 pr-2" role="button">
+23 -16
View File
@@ -16,7 +16,6 @@ import {
} from "@/types/live";
import { getIconForLabel } from "@/utils/iconUtil";
import Chip from "../indicators/Chip";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { cn } from "@/lib/utils";
import { TbExclamationCircle } from "react-icons/tb";
import { TooltipPortal } from "@radix-ui/react-tooltip";
@@ -26,6 +25,8 @@ import { LuVideoOff } from "react-icons/lu";
import { Trans, useTranslation } from "react-i18next";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { ImageShadowOverlay } from "../overlay/ImageShadowOverlay";
import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
type LivePlayerProps = {
cameraRef?: (ref: HTMLDivElement | null) => void;
@@ -358,7 +359,11 @@ export default function LivePlayer({
]),
]
.map((label) => {
return getIconForLabel(label, "size-3 text-white");
return getIconForLabel(
label,
"object",
"size-3 text-white",
);
})
.sort()}
</Chip>
@@ -367,20 +372,22 @@ export default function LivePlayer({
</div>
<TooltipPortal>
<TooltipContent className="smart-capitalize">
{[
...new Set([
...(objects || []).map(({ label, sub_label }) =>
label.endsWith("verified")
? sub_label
: label.replaceAll("_", " "),
),
]),
]
.filter((label) => label?.includes("-verified") == false)
.map((label) => capitalizeFirstLetter(label))
.sort()
.join(", ")
.replaceAll("-verified", "")}
{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(),
)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
@@ -262,10 +262,18 @@ export default function PreviewThumbnailPlayer({
onClick={() => onClick(review, false, true)}
>
{review.data.objects.sort().map((object) => {
return getIconForLabel(object, "size-3 text-white");
return getIconForLabel(
object,
"object",
"size-3 text-white",
);
})}
{review.data.audio.map((audio) => {
return getIconForLabel(audio, "size-3 text-white");
return getIconForLabel(
audio,
"audio",
"size-3 text-white",
);
})}
</Chip>
</>
@@ -417,7 +417,9 @@ export default function Step1NameCamera({
<SelectContent>
{CAMERA_BRANDS.map((brand) => (
<SelectItem key={brand.value} value={brand.value}>
{brand.label}
{brand.label.toLowerCase() === "other"
? t("label.other", { ns: "common" })
: brand.label}
</SelectItem>
))}
</SelectContent>
+27 -14
View File
@@ -14,6 +14,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
import ActivityIndicator from "../indicators/activity-indicator";
import { Event } from "@/types/event";
import { EventType } from "@/types/search";
import { getIconForLabel } from "@/utils/iconUtil";
import { REVIEW_PADDING, ReviewSegment } from "@/types/review";
import { LuChevronDown, LuCircle, LuChevronRight } from "react-icons/lu";
@@ -346,22 +347,29 @@ function ReviewGroup({
: null,
);
const rawIconLabels: string[] = [
const rawIconLabels: Array<{ label: string; type: EventType }> = [
...(fetchedEvents
? fetchedEvents.map((e) =>
e.sub_label ? e.label + "-verified" : e.label,
)
: (review.data?.objects ?? [])),
...(review.data?.audio ?? []),
? fetchedEvents.map((e) => ({
label: e.sub_label ? e.label + "-verified" : e.label,
type: e.data.type,
}))
: (review.data?.objects ?? []).map((obj) => ({
label: obj,
type: "object" as EventType,
}))),
...(review.data?.audio ?? []).map((audio) => ({
label: audio,
type: "audio" as EventType,
})),
];
// limit to 5 icons
const seen = new Set<string>();
const iconLabels: string[] = [];
for (const lbl of rawIconLabels) {
if (!seen.has(lbl)) {
seen.add(lbl);
iconLabels.push(lbl);
const iconLabels: Array<{ label: string; type: EventType }> = [];
for (const item of rawIconLabels) {
if (!seen.has(item.label)) {
seen.add(item.label);
iconLabels.push(item);
if (iconLabels.length >= 5) break;
}
}
@@ -418,12 +426,12 @@ function ReviewGroup({
<div className="flex flex-row gap-3">
<div className="text-sm font-medium">{displayTime}</div>
<div className="relative flex items-center gap-2 text-white">
{iconLabels.slice(0, 5).map((lbl, idx) => (
{iconLabels.slice(0, 5).map(({ label: lbl, type }, idx) => (
<div
key={`${lbl}-${idx}`}
className="rounded-full bg-muted-foreground p-1"
>
{getIconForLabel(lbl, "size-3 text-white")}
{getIconForLabel(lbl, type, "size-3 text-white")}
</div>
))}
</div>
@@ -516,7 +524,11 @@ function ReviewGroup({
>
<div className="ml-1.5 flex items-center gap-2 text-sm font-medium">
<div className="rounded-full bg-muted-foreground p-1">
{getIconForLabel(audioLabel, "size-3 text-white")}
{getIconForLabel(
audioLabel,
"audio",
"size-3 text-white",
)}
</div>
<span>{getTranslatedLabel(audioLabel, "audio")}</span>
</div>
@@ -618,6 +630,7 @@ function EventList({
>
{getIconForLabel(
event.sub_label ? event.label + "-verified" : event.label,
event.data.type,
"size-3 text-white",
)}
</div>
+7 -2
View File
@@ -1,5 +1,5 @@
import { fromUnixTime, intervalToDuration, formatDuration } from "date-fns";
import { Locale } from "date-fns/locale";
import { enUS, Locale } from "date-fns/locale";
import { formatInTimeZone } from "date-fns-tz";
import i18n from "@/utils/i18n";
export const longToDate = (long: number): Date => new Date(long * 1000);
@@ -293,9 +293,13 @@ export const getDurationFromTimestamps = (
/**
*
* @param seconds - number of seconds to convert into hours, minutes and seconds
* @param locale - the date-fns locale to use for formatting
* @returns string - formatted duration in hours, minutes and seconds
*/
export const formatSecondsToDuration = (seconds: number): string => {
export const formatSecondsToDuration = (
seconds: number,
locale?: Locale,
): string => {
if (isNaN(seconds) || seconds < 0) {
return "Invalid duration";
}
@@ -304,6 +308,7 @@ export const formatSecondsToDuration = (seconds: number): string => {
return formatDuration(duration, {
format: ["hours", "minutes", "seconds"],
delimiter: ", ",
locale: locale ?? enUS,
});
};
+24 -7
View File
@@ -1,5 +1,6 @@
import { IconName } from "@/components/icons/IconPicker";
import { FrigateConfig } from "@/types/frigateConfig";
import { EventType } from "@/types/search";
import { BsPersonWalking } from "react-icons/bs";
import {
FaAmazon,
@@ -32,6 +33,7 @@ import {
GiRabbit,
GiRaccoonHead,
GiSailboat,
GiSoundWaves,
GiSquirrel,
} from "react-icons/gi";
import { LuBox, LuLassoSelect, LuScanBarcode } from "react-icons/lu";
@@ -56,11 +58,15 @@ export function isValidIconName(value: string): value is IconName {
return Object.keys(LuIcons).includes(value as IconName);
}
export function getIconForLabel(label: string, className?: string) {
export function getIconForLabel(
label: string,
type: EventType = "object",
className?: string,
) {
if (label.endsWith("-verified")) {
return getVerifiedIcon(label, className);
return getVerifiedIcon(label, className, type);
} else if (label.endsWith("-plate")) {
return getRecognizedPlateIcon(label, className);
return getRecognizedPlateIcon(label, className, type);
}
switch (label) {
@@ -152,27 +158,38 @@ export function getIconForLabel(label: string, className?: string) {
case "usps":
return <FaUsps key={label} className={className} />;
default:
if (type === "audio") {
return <GiSoundWaves key={label} className={className} />;
}
return <LuLassoSelect key={label} className={className} />;
}
}
function getVerifiedIcon(label: string, className?: string) {
function getVerifiedIcon(
label: string,
className?: string,
type: EventType = "object",
) {
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
return (
<div key={label} className="flex items-center">
{getIconForLabel(simpleLabel, className)}
{getIconForLabel(simpleLabel, type, className)}
<FaCheckCircle className="absolute size-2 translate-x-[80%] translate-y-3/4" />
</div>
);
}
function getRecognizedPlateIcon(label: string, className?: string) {
function getRecognizedPlateIcon(
label: string,
className?: string,
type: EventType = "object",
) {
const simpleLabel = label.substring(0, label.lastIndexOf("-"));
return (
<div key={label} className="flex items-center">
{getIconForLabel(simpleLabel, className)}
{getIconForLabel(simpleLabel, type, className)}
<LuScanBarcode className="absolute size-2.5 translate-x-[50%] translate-y-3/4" />
</div>
);
+4 -1
View File
@@ -12,7 +12,10 @@ export function getLifecycleItemDescription(
const label = lifecycleItem.data.sub_label
? capitalizeFirstLetter(rawLabel)
: getTranslatedLabel(rawLabel);
: getTranslatedLabel(
rawLabel,
lifecycleItem.class_type === "heard" ? "audio" : "object",
);
switch (lifecycleItem.class_type) {
case "visible":
@@ -88,7 +88,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
// title
useEffect(() => {
document.title = `${model.name} - ${t("documentTitle")}`;
document.title = `${model.name.toUpperCase()} - ${t("documentTitle")}`;
}, [model.name, t]);
// model state
@@ -406,7 +406,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
: getColorForObjectName(obj.label),
}}
>
{getIconForLabel(obj.label, "size-5 text-white")}
{getIconForLabel(obj.label, "object", "size-5 text-white")}
</div>
<div className="ml-3 text-lg">
{getTranslatedLabel(obj.label)}
@@ -494,7 +494,7 @@ function AudioList({ cameraConfig, audioDetections }: AudioListProps) {
<div className="flex flex-row items-center gap-3 pb-1">
<div className="flex flex-1 flex-row items-center justify-start p-3 pl-1">
<div className="rounded-lg bg-selected p-2">
{getIconForLabel(key, "size-5 text-white")}
{getIconForLabel(key, "audio", "size-5 text-white")}
</div>
<div className="ml-3 text-lg">{getTranslatedLabel(key)}</div>
</div>
+1 -1
View File
@@ -855,7 +855,7 @@ export default function GeneralMetrics({
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-cpu`}
name={series.name.replaceAll("_", " ")}
name={t(`general.otherProcesses.series.${series.name}`)}
unit="%"
threshold={DetectorCpuThreshold}
updateTimes={updateTimes}