feat: add i18n (translation/localization) (#16877)

* Translation module init

* Add more i18n keys

* fix: fix string wrong

* refactor: use namespace translation file

* chore: add more translation key

* fix: fix some page name error

* refactor: change Trans tag for t function

* chore: fix some key not work

* chore: fix SearchFilterDialog i18n key error

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* chore: fix en i18n file filter missing some keys

* chore: add some i18n keys

* chore: add more i18n keys again

* feat: add search page i18n

* feat: add explore model i18n keys

* Update web/src/components/menu/GeneralSettings.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/components/menu/GeneralSettings.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/components/menu/GeneralSettings.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* feat: add more live i18n keys

* feat: add more search setting i18n keys

* fix: remove some comment

* fix: fix some setting page url error

* Update web/src/views/settings/SearchSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* fix: add system missing keys

* fix: update password update i18n keys

* chore: remove outdate translation.json file

* fix: fix exploreSettings error

* chore: add object setting i18n keys

* Update web/src/views/recording/RecordingView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/public/locales/en/components/filter.json

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/components/overlay/ExportDialog.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* feat: add more i18n keys

* fix: fix motionDetectionTuner html node

* feat: add more page i18n keys

* fix: cameraStream i18n keys error

* feat: add Player i18n keys

* feat: add more toast i18n keys

* feat: change explore setting name

* feat: add more document title i18n keys

* feat: add more search i18n keys

* fix: fix accessDenied i18n keys error

* chore: add objectType i18n

* chore: add  inputWithTags i18n

* chore: add SearchFilterDialog i18n

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Update web/src/views/settings/ObjectSettingsView.tsx

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* chore: add some missing i18n keys

* chore: remove most import { t } from "i18next";

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
GuoQing Liu
2025-03-16 10:36:20 -05:00
committed by GitHub
co-authored by Josh Hawkins
parent db541abed4
commit d34533981f
150 changed files with 6809 additions and 1926 deletions
+30 -16
View File
@@ -54,7 +54,9 @@ import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
import { GiSoundWaves } from "react-icons/gi";
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useTranslation } from "react-i18next";
type EventViewProps = {
reviewItems?: SegmentedReviewData;
@@ -94,6 +96,7 @@ export default function EventView({
pullLatestData,
updateFilter,
}: EventViewProps) {
const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement | null>(null);
@@ -198,8 +201,10 @@ export default function EventView({
.then((response) => {
if (response.status == 200) {
toast.success(
"Successfully started export. View the file in the /exports folder.",
{ position: "top-center" },
t("export.toast.success", { ns: "components/dialog" }),
{
position: "top-center",
},
);
}
})
@@ -208,12 +213,18 @@ export default function EventView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to start export: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("export.toast.error.failed", {
ns: "components/dialog",
message: errorMessage,
}),
{
position: "top-center",
},
);
});
},
[reviewItems],
[reviewItems, t],
);
const [motionOnly, setMotionOnly] = useState(false);
@@ -266,7 +277,7 @@ export default function EventView({
<ToggleGroupItem
className={cn(severityToggle != "alert" && "text-muted-foreground")}
value="alert"
aria-label="Select alerts"
aria-label={t("alerts")}
>
{isMobileOnly ? (
<div
@@ -285,7 +296,7 @@ export default function EventView({
<>
<MdCircle className="size-2 text-severity_alert md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
Alerts
{t("alerts")}
{reviewCounts.alert > -1 ? (
`${reviewCounts.alert}`
) : (
@@ -300,7 +311,7 @@ export default function EventView({
severityToggle != "detection" && "text-muted-foreground",
)}
value="detection"
aria-label="Select detections"
aria-label={t("detections")}
>
{isMobileOnly ? (
<div
@@ -321,7 +332,7 @@ export default function EventView({
<>
<MdCircle className="size-2 text-severity_detection md:mr-[10px]" />
<div className="hidden md:flex md:flex-row md:items-center">
Detections
{t("detections")}
{reviewCounts.detection > -1 ? (
`${reviewCounts.detection}`
) : (
@@ -337,14 +348,14 @@ export default function EventView({
severityToggle != "significant_motion" && "text-muted-foreground",
)}
value="significant_motion"
aria-label="Select motion"
aria-label={t("motion.label")}
>
{isMobileOnly ? (
<GiSoundWaves className="size-6 rotate-90 text-severity_significant_motion" />
) : (
<>
<MdCircle className="size-2 text-severity_significant_motion md:mr-[10px]" />
<div className="hidden md:block">Motion</div>
<div className="hidden md:block">{t("motion.label")}</div>
</>
)}
</ToggleGroupItem>
@@ -461,6 +472,8 @@ function DetectionReview({
setSelectedReviews,
pullLatestData,
}: DetectionReviewProps) {
const { t } = useTranslation(["views/events"]);
const reviewTimelineRef = useRef<HTMLDivElement>(null);
// detail
@@ -712,7 +725,7 @@ function DetectionReview({
{!loading && currentItems?.length === 0 && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderCheck className="size-16" />
There are no {severity.replace(/_/g, " ")}s to review
{t("empty." + severity.replace(/_/g, " "))}
</div>
)}
@@ -779,14 +792,14 @@ function DetectionReview({
<div className="col-span-full flex items-center justify-center">
<Button
className="text-white"
aria-label="Mark these items as reviewed"
aria-label={t("markTheseItemsAsReviewed")}
variant="select"
onClick={() => {
setSelectedReviews([]);
markAllItemsAsReviewed(currentItems ?? []);
}}
>
Mark these items as reviewed
{t("markTheseItemsAsReviewed")}
</Button>
</div>
)}
@@ -862,6 +875,7 @@ function MotionReview({
motionOnly = false,
onOpenRecording,
}: MotionReviewProps) {
const { t } = useTranslation(["views/events"]);
const segmentDuration = 30;
const { data: config } = useSWR<FrigateConfig>("config");
@@ -1051,7 +1065,7 @@ function MotionReview({
return (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuFolderX className="size-16" />
No motion data found
{t("empty.motion")}
</div>
);
}
+10 -9
View File
@@ -21,6 +21,7 @@ import TimeAgo from "@/components/dynamic/TimeAgo";
import SearchResultActions from "@/components/menu/SearchResultActions";
import { SearchTab } from "@/components/overlay/detail/SearchDetailDialog";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
type ExploreViewProps = {
searchDetail: SearchResult | undefined;
@@ -35,11 +36,12 @@ export default function ExploreView({
setSimilaritySearch,
onSelectSearch,
}: ExploreViewProps) {
const { t } = useTranslation(["views/explore"]);
// title
useEffect(() => {
document.title = "Explore - Frigate";
}, []);
document.title = t("documentTitle");
}, [t]);
// data
@@ -137,6 +139,7 @@ function ThumbnailRow({
setSimilaritySearch,
onSelectSearch,
}: ThumbnailRowType) {
const { t } = useTranslation(["views/explore"]);
const navigate = useNavigate();
const handleSearch = (label: string) => {
@@ -149,15 +152,13 @@ function ThumbnailRow({
return (
<div className="rounded-lg bg-background_alt p-2 md:px-4">
<div className="flex flex-row items-center text-lg capitalize">
{objectType.replaceAll("_", " ")}
{t(objectType, { ns: "objects" })}
{searchResults && (
<span className="ml-3 text-sm text-secondary-foreground">
(
{
{t("trackedObjectsCount", {
// @ts-expect-error we know this is correct
searchResults[0].event_count
}{" "}
tracked objects){" "}
count: searchResults[0].event_count,
})}
</span>
)}
{isValidating && <ActivityIndicator className="ml-2 size-4" />}
@@ -225,7 +226,7 @@ function ExploreThumbnailImage({
};
const handleShowObjectLifecycle = () => {
onSelectSearch(event, false, "object lifecycle");
onSelectSearch(event, false, "object_lifecycle");
};
const handleShowSnapshot = () => {
+11 -3
View File
@@ -50,6 +50,7 @@ import { Toaster } from "@/components/ui/sonner";
import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next";
type DraggableGridLayoutProps = {
cameras: CameraConfig[];
@@ -79,6 +80,7 @@ export default function DraggableGridLayout({
fullscreen,
toggleFullscreen,
}: DraggableGridLayoutProps) {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
@@ -658,7 +660,9 @@ export default function DraggableGridLayout({
</div>
</TooltipTrigger>
<TooltipContent>
{isEditMode ? "Exit Editing" : "Edit Layout"}
{isEditMode
? t("editLayout.exitEdit")
: t("editLayout.label")}
</TooltipContent>
</Tooltip>
{!isEditMode && (
@@ -676,7 +680,9 @@ export default function DraggableGridLayout({
</div>
</TooltipTrigger>
<TooltipContent>
{isEditMode ? "Exit Editing" : "Edit Camera Group"}
{isEditMode
? t("editLayout.exitEdit")
: t("editLayout.group.label")}
</TooltipContent>
</Tooltip>
)}
@@ -694,7 +700,9 @@ export default function DraggableGridLayout({
</div>
</TooltipTrigger>
<TooltipContent>
{fullscreen ? "Exit Fullscreen" : "Fullscreen"}
{fullscreen
? t("button.exitFullscreen", { ns: "common" })
: t("button.fullscreen", { ns: "common" })}
</TooltipContent>
</Tooltip>
</>
+18 -4
View File
@@ -14,6 +14,7 @@ import {
isSafari,
useMobileOrientation,
} from "react-device-detect";
import { useTranslation } from "react-i18next";
import { FaCompress, FaExpand } from "react-icons/fa";
import { IoMdArrowBack } from "react-icons/io";
import { LuPictureInPicture } from "react-icons/lu";
@@ -32,6 +33,7 @@ export default function LiveBirdseyeView({
fullscreen,
toggleFullscreen,
}: LiveBirdseyeViewProps) {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
const navigate = useNavigate();
const { isPortrait } = useMobileOrientation();
@@ -144,12 +146,16 @@ export default function LiveBirdseyeView({
{!fullscreen ? (
<Button
className={`flex items-center gap-2 rounded-lg ${isMobile ? "ml-2" : "ml-0"}`}
aria-label="Go Back"
aria-label={t("label.back", { ns: "common" })}
size={isMobile ? "icon" : "sm"}
onClick={() => navigate(-1)}
>
<IoMdArrowBack className="size-5" />
{isDesktop && <div className="text-primary">Back</div>}
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
) : (
<div />
@@ -164,7 +170,11 @@ export default function LiveBirdseyeView({
variant={fullscreen ? "overlay" : "primary"}
Icon={fullscreen ? FaCompress : FaExpand}
isActive={fullscreen}
title={fullscreen ? "Close" : "Fullscreen"}
title={
fullscreen
? t("button.close", { ns: "common" })
: t("button.fullscreen", { ns: "common" })
}
onClick={toggleFullscreen}
/>
)}
@@ -174,7 +184,11 @@ export default function LiveBirdseyeView({
variant={fullscreen ? "overlay" : "primary"}
Icon={LuPictureInPicture}
isActive={pip}
title={pip ? "Close" : "Picture in Picture"}
title={
pip
? t("button.close", { ns: "common" })
: t("button.pictureInPicture", { ns: "common" })
}
onClick={() => {
if (!pip) {
setPip(true);
+193 -117
View File
@@ -103,6 +103,7 @@ import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import useSWR from "swr";
import { cn } from "@/lib/utils";
import { useSessionPersistence } from "@/hooks/use-session-persistence";
import {
Select,
SelectContent,
@@ -117,6 +118,7 @@ import axios from "axios";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { Trans, useTranslation } from "react-i18next";
type LiveCameraViewProps = {
config?: FrigateConfig;
@@ -132,6 +134,7 @@ export default function LiveCameraView({
fullscreen,
toggleFullscreen,
}: LiveCameraViewProps) {
const { t } = useTranslation(["views/live"]);
const navigate = useNavigate();
const { isPortrait } = useMobileOrientation();
const mainRef = useRef<HTMLDivElement | null>(null);
@@ -427,16 +430,20 @@ export default function LiveCameraView({
>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label="Go back"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">Back</div>}
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label="Show historical footage"
aria-label={t("history.label")}
size="sm"
onClick={() => {
navigate("review", {
@@ -452,7 +459,11 @@ export default function LiveCameraView({
}}
>
<LuHistory className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">History</div>}
{isDesktop && (
<div className="text-primary">
{t("button.history", { ns: "common" })}
</div>
)}
</Button>
</div>
) : (
@@ -465,13 +476,15 @@ export default function LiveCameraView({
{fullscreen && (
<Button
className="bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 text-primary"
aria-label="Go back"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-secondary-foreground">Back</div>
<div className="text-secondary-foreground">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
)}
@@ -481,7 +494,11 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"}
Icon={fullscreen ? FaCompress : FaExpand}
isActive={fullscreen}
title={fullscreen ? "Close" : "Fullscreen"}
title={
fullscreen
? t("button.close", { ns: "common" })
: t("button.fullscreen", { ns: "common" })
}
onClick={toggleFullscreen}
/>
)}
@@ -491,7 +508,11 @@ export default function LiveCameraView({
variant={fullscreen ? "overlay" : "primary"}
Icon={LuPictureInPicture}
isActive={pip}
title={pip ? "Close" : "Picture in Picture"}
title={
pip
? t("button.close", { ns: "common" })
: t("button.pictureInPicture", { ns: "common" })
}
onClick={() => {
if (!pip) {
setPip(true);
@@ -667,6 +688,7 @@ function PtzControlPanel({
clickOverlay: boolean;
setClickOverlay: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const { t } = useTranslation(["views/live"]);
const { data: ptz } = useSWR<CameraPtzInfo>(`${camera}/ptz/info`);
const { send: sendPtz } = usePtzCommand(camera);
@@ -752,7 +774,7 @@ function PtzControlPanel({
{ptz?.features?.includes("pt") && (
<>
<TooltipButton
label="Move camera left"
label={t("ptz.move.left.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_LEFT");
@@ -767,7 +789,7 @@ function PtzControlPanel({
<FaAngleLeft />
</TooltipButton>
<TooltipButton
label="Move camera up"
label={t("ptz.move.up.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_UP");
@@ -782,7 +804,7 @@ function PtzControlPanel({
<FaAngleUp />
</TooltipButton>
<TooltipButton
label="Move camera down"
label={t("ptz.move.down.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_DOWN");
@@ -797,7 +819,7 @@ function PtzControlPanel({
<FaAngleDown />
</TooltipButton>
<TooltipButton
label="Move camera right"
label={t("ptz.move.right.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("MOVE_RIGHT");
@@ -816,7 +838,7 @@ function PtzControlPanel({
{ptz?.features?.includes("zoom") && (
<>
<TooltipButton
label="Zoom in"
label={t("ptz.zoom.in.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("ZOOM_IN");
@@ -831,7 +853,7 @@ function PtzControlPanel({
<MdZoomIn />
</TooltipButton>
<TooltipButton
label="Zoom out"
label={t("ptz.zoom.out.label")}
onMouseDown={(e) => {
e.preventDefault();
sendPtz("ZOOM_OUT");
@@ -854,14 +876,19 @@ function PtzControlPanel({
<TooltipTrigger asChild>
<Button
className={`${clickOverlay ? "text-selected" : "text-primary"}`}
aria-label="Click in the frame to center the camera"
aria-label={t("ptz.move.clickMove.label")}
onClick={() => setClickOverlay(!clickOverlay)}
>
<TbViewfinder />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{clickOverlay ? "Disable" : "Enable"} click to move</p>
<p>
{clickOverlay
? t("ptz.move.clickMove.disable")
: t("ptz.move.clickMove.enable")}{" "}
click to move
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -872,7 +899,7 @@ function PtzControlPanel({
<TooltipTrigger asChild>
<DropdownMenu modal={!isDesktop}>
<DropdownMenuTrigger asChild>
<Button aria-label="PTZ camera presets">
<Button aria-label={t("ptz.presets")}>
<BsThreeDotsVertical />
</Button>
</DropdownMenuTrigger>
@@ -894,7 +921,7 @@ function PtzControlPanel({
</DropdownMenu>
</TooltipTrigger>
<TooltipContent>
<p>PTZ camera presets</p>
<p>{t("ptz.presets")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -904,6 +931,7 @@ function PtzControlPanel({
}
function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
const { t } = useTranslation(["views/live"]);
const rankMap = { all: 0, motion: 1, active_objects: 2 };
const getValidMode = (retain?: { mode?: string }): keyof typeof rankMap => {
const mode = retain?.mode;
@@ -918,13 +946,25 @@ function OnDemandRetentionMessage({ camera }: { camera: CameraConfig }) {
? recordRetainMode
: alertsRetainMode;
const source = effectiveRetainMode === recordRetainMode ? "camera" : "alerts";
const source =
effectiveRetainMode === recordRetainMode
? t("camera", { ns: "views/events" })
: t("alerts", { ns: "views/events" });
return effectiveRetainMode !== "all" ? (
<div>
Your {source} recording retention configuration is set to{" "}
<code>mode: {effectiveRetainMode}</code>, so this on-demand recording will
only keep segments with {effectiveRetainMode.replaceAll("_", " ")}.
<Trans
ns="views/live"
values={{
source,
effectiveRetainMode,
effectiveRetainModeName: t(
"effectiveRetainMode.modes." + effectiveRetainMode,
),
}}
>
effectiveRetainMode.notAllTips
</Trans>
</div>
) : null;
}
@@ -967,6 +1007,8 @@ function FrigateCameraFeatures({
supports2WayTalk,
cameraEnabled,
}: FrigateCameraFeaturesProps) {
const { t } = useTranslation(["views/live"]);
const { payload: detectState, send: sendDetect } = useDetectState(
camera.name,
);
@@ -1010,14 +1052,9 @@ function FrigateCameraFeatures({
setIsRecording(true);
const toastId = toast.success(
<div className="flex flex-col space-y-3">
<div className="font-semibold">
Started manual on-demand recording.
</div>
<div className="font-semibold">{t("manualRecording.started")}</div>
{!camera.record.enabled || camera.record.alerts.retain.days == 0 ? (
<div>
Since recording is disabled or restricted in the config for this
camera, only a snapshot will be saved.
</div>
<div>{t("manualRecording.recordDisabledTips")}</div>
) : (
<OnDemandRetentionMessage camera={camera} />
)}
@@ -1030,11 +1067,11 @@ function FrigateCameraFeatures({
setActiveToastId(toastId);
}
} catch (error) {
toast.error("Failed to start manual on-demand recording.", {
toast.error(t("manualRecording.failedToStart"), {
position: "top-center",
});
}
}, [camera]);
}, [camera, t]);
const endEvent = useCallback(() => {
if (activeToastId) {
@@ -1047,16 +1084,16 @@ function FrigateCameraFeatures({
});
recordingEventIdRef.current = null;
setIsRecording(false);
toast.success("Ended manual on-demand recording.", {
toast.success(t("manualRecording.ended"), {
position: "top-center",
});
}
} catch (error) {
toast.error("Failed to end manual on-demand recording.", {
toast.error(t("manualRecording.failedToEnd"), {
position: "top-center",
});
}
}, [activeToastId]);
}, [activeToastId, t]);
const handleEventButtonClick = useCallback(() => {
if (isRecording) {
@@ -1092,7 +1129,9 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={enabledState == "ON" ? LuPower : LuPowerOff}
isActive={enabledState == "ON"}
title={`${enabledState == "ON" ? "Disable" : "Enable"} Camera`}
title={
enabledState == "ON" ? t("camera.disable") : t("camera.enable")
}
onClick={() => sendEnabled(enabledState == "ON" ? "OFF" : "ON")}
disabled={false}
/>
@@ -1101,7 +1140,9 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={detectState == "ON" ? MdPersonSearch : MdPersonOff}
isActive={detectState == "ON"}
title={`${detectState == "ON" ? "Disable" : "Enable"} Detect`}
title={
detectState == "ON" ? t("detect.disable") : t("detect.enable")
}
onClick={() => sendDetect(detectState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1110,7 +1151,11 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={recordState == "ON" ? LuVideo : LuVideoOff}
isActive={recordState == "ON"}
title={`${recordState == "ON" ? "Disable" : "Enable"} Recording`}
title={
recordState == "ON"
? t("recording.disable")
: t("recording.enable")
}
onClick={() => sendRecord(recordState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1119,7 +1164,11 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={snapshotState == "ON" ? MdPhotoCamera : MdNoPhotography}
isActive={snapshotState == "ON"}
title={`${snapshotState == "ON" ? "Disable" : "Enable"} Snapshots`}
title={
snapshotState == "ON"
? t("snapshots.disable")
: t("snapshots.enable")
}
onClick={() => sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1129,7 +1178,11 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={audioState == "ON" ? LuEar : LuEarOff}
isActive={audioState == "ON"}
title={`${audioState == "ON" ? "Disable" : "Enable"} Audio Detect`}
title={
audioState == "ON"
? t("audioDetect.disable")
: t("audioDetect.enable")
}
onClick={() => sendAudio(audioState == "ON" ? "OFF" : "ON")}
disabled={!cameraEnabled}
/>
@@ -1142,7 +1195,11 @@ function FrigateCameraFeatures({
autotrackingState == "ON" ? TbViewfinder : TbViewfinderOff
}
isActive={autotrackingState == "ON"}
title={`${autotrackingState == "ON" ? "Disable" : "Enable"} Autotracking`}
title={
autotrackingState == "ON"
? t("autotracking.disable")
: t("autotracking.enable")
}
onClick={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
}
@@ -1159,7 +1216,7 @@ function FrigateCameraFeatures({
variant={fullscreen ? "overlay" : "primary"}
Icon={isRecording ? TbRecordMail : TbRecordMailOff}
isActive={isRecording}
title={`${isRecording ? "Stop" : "Start"} on-demand recording`}
title={t("manualRecording." + (isRecording ? "stop" : "start"))}
onClick={handleEventButtonClick}
disabled={!cameraEnabled}
/>
@@ -1180,20 +1237,29 @@ function FrigateCameraFeatures({
<div className="flex flex-col gap-5 p-4">
{!isRestreamed && (
<div className="flex flex-col gap-2">
<Label>Stream</Label>
<Label>
{t("streaming.label", { ns: "components/dialog" })}
</Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>Restreaming is not enabled for this camera.</div>
<div>
{t("streaming.restreaming.NotEnabled", {
ns: "components/dialog",
})}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Set up go2rtc for additional live view options and audio
for this camera.
{t("streaming.restreaming.desc", {
ns: "components/dialog",
})}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -1201,7 +1267,9 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("streaming.restreaming.readTheDocumentation", {
ns: "components/dialog",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1213,7 +1281,9 @@ function FrigateCameraFeatures({
{isRestreamed &&
Object.values(camera.live.streams).length > 0 && (
<div className="flex flex-col gap-1">
<Label htmlFor="streaming-method">Stream</Label>
<Label htmlFor="streaming-method">
{t("stream.title")}
</Label>
<Select
value={streamName}
onValueChange={(value) => {
@@ -1248,22 +1318,23 @@ function FrigateCameraFeatures({
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>Audio is available for this stream</div>
<div>{t("stream.audio.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<div>{t("stream.audio.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Audio must be output from your camera and
configured in go2rtc for this stream.
{t("stream.audio.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -1271,7 +1342,7 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("stream.audio.tips.documentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1288,26 +1359,23 @@ function FrigateCameraFeatures({
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>
Two-way talk is available for this stream
</div>
<div>{t("stream.twoWayTalk.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>
Two-way talk is unavailable for this stream
</div>
<div>{t("stream.twoWayTalk.available")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Your device must suppport the feature and
WebRTC must be configured for two-way talk.
{t("stream.twoWayTalk.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
@@ -1315,7 +1383,9 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t(
"stream.twoWayTalk.tips.documentation",
)}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1332,20 +1402,19 @@ function FrigateCameraFeatures({
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
Live view is in low-bandwidth mode due to buffering
or stream errors.
{t("stream.lowBandwidth.tips")}
</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
aria-label="Reset the stream"
aria-label={t("stream.lowBandwidth.resetStream")}
variant="outline"
size="sm"
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">
Reset stream
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
@@ -1359,7 +1428,7 @@ function FrigateCameraFeatures({
className="mx-0 cursor-pointer text-primary"
htmlFor="backgroundplay"
>
Play in background
{t("stream.playInBackground.label")}
</Label>
<Switch
className="ml-1"
@@ -1371,8 +1440,7 @@ function FrigateCameraFeatures({
/>
</div>
<p className="text-sm text-muted-foreground">
Enable this option to continue streaming when the player is
hidden.
{t("stream.playInBackground.tips")}
</p>
</div>
)}
@@ -1382,7 +1450,9 @@ function FrigateCameraFeatures({
className="mx-0 cursor-pointer text-primary"
htmlFor="showstats"
>
Show stream stats
{t("streaming.showStats.label", {
ns: "components/dialog",
})}
</Label>
<Switch
className="ml-1"
@@ -1392,13 +1462,12 @@ function FrigateCameraFeatures({
/>
</div>
<p className="text-sm text-muted-foreground">
Enable this option to show stream statistics as an overlay on
the camera feed.
{t("streaming.showStats.desc", { ns: "components/dialog" })}
</p>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-sm font-medium leading-none">
Debug View
{t("streaming.debugView", { ns: "components/dialog" })}
<LuExternalLink
onClick={() =>
navigate(`/settings?page=debug&camera=${camera.name}`)
@@ -1427,7 +1496,7 @@ function FrigateCameraFeatures({
variant="primary"
Icon={FaCog}
isActive={false}
title={`${camera} Settings`}
title={t("cameraSettings.title", { camera })}
/>
</DrawerTrigger>
<DrawerContent className="rounded-2xl px-2 py-4">
@@ -1435,14 +1504,14 @@ function FrigateCameraFeatures({
{isAdmin && (
<>
<FilterSwitch
label="Camera Enabled"
label={t("cameraSettings.cameraEnabled")}
isChecked={enabledState == "ON"}
onCheckedChange={() =>
sendEnabled(enabledState == "ON" ? "OFF" : "ON")
}
/>
<FilterSwitch
label="Object Detection"
label={t("cameraSettings.objectDetection")}
isChecked={detectState == "ON"}
onCheckedChange={() =>
sendDetect(detectState == "ON" ? "OFF" : "ON")
@@ -1450,7 +1519,7 @@ function FrigateCameraFeatures({
/>
{recordingEnabled && (
<FilterSwitch
label="Recording"
label={t("cameraSettings.recording")}
isChecked={recordState == "ON"}
onCheckedChange={() =>
sendRecord(recordState == "ON" ? "OFF" : "ON")
@@ -1458,7 +1527,7 @@ function FrigateCameraFeatures({
/>
)}
<FilterSwitch
label="Snapshots"
label={t("cameraSettings.snapshots")}
isChecked={snapshotState == "ON"}
onCheckedChange={() =>
sendSnapshot(snapshotState == "ON" ? "OFF" : "ON")
@@ -1466,7 +1535,7 @@ function FrigateCameraFeatures({
/>
{audioDetectEnabled && (
<FilterSwitch
label="Audio Detection"
label={t("cameraSettings.audioDetection")}
isChecked={audioState == "ON"}
onCheckedChange={() =>
sendAudio(audioState == "ON" ? "OFF" : "ON")
@@ -1475,7 +1544,7 @@ function FrigateCameraFeatures({
)}
{autotrackingEnabled && (
<FilterSwitch
label="Autotracking"
label={t("cameraSettings.autotracking")}
isChecked={autotrackingState == "ON"}
onCheckedChange={() =>
sendAutotracking(autotrackingState == "ON" ? "OFF" : "ON")
@@ -1489,20 +1558,27 @@ function FrigateCameraFeatures({
<div className="mt-3 flex flex-col gap-5">
{!isRestreamed && (
<div className="flex flex-col gap-2 p-2">
<Label>Stream</Label>
<Label>{t("streaming", { ns: "components/dialog" })}</Label>
<div className="flex flex-row items-center gap-1 text-sm text-muted-foreground">
<LuX className="size-4 text-danger" />
<div>Restreaming is not enabled for this camera.</div>
<div>
{t("streaming.restreaming.disabled", {
ns: "components/dialog",
})}
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-xs">
Set up go2rtc for additional live view options and audio for
this camera.
{t("streaming.restreaming.desc", {
ns: "components/dialog",
})}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -1510,7 +1586,9 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("streaming.restreaming.readTheDocumentation", {
ns: "components/dialog",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1521,7 +1599,7 @@ function FrigateCameraFeatures({
)}
{isRestreamed && Object.values(camera.live.streams).length > 0 && (
<div className="mt-1 p-2">
<div className="mb-1 text-sm">Stream</div>
<div className="mb-1 text-sm">{t("stream.title")}</div>
<Select
value={streamName}
onValueChange={(value) => {
@@ -1555,22 +1633,23 @@ function FrigateCameraFeatures({
{supportsAudioOutput ? (
<>
<LuCheck className="size-4 text-success" />
<div>Audio is available for this stream</div>
<div>{t("stream.audio.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Audio is unavailable for this stream</div>
<div>{t("stream.audio.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-52 text-xs">
Audio must be output from your camera and configured
in go2rtc for this stream.
{t("stream.audio.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live"
@@ -1578,7 +1657,7 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("stream.audio.tips.documentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1595,22 +1674,23 @@ function FrigateCameraFeatures({
{supports2WayTalk ? (
<>
<LuCheck className="size-4 text-success" />
<div>Two-way talk is available for this stream</div>
<div>{t("stream.twoWayTalk.available")}</div>
</>
) : (
<>
<LuX className="size-4 text-danger" />
<div>Two-way talk is unavailable for this stream</div>
<div>{t("stream.twoWayTalk.unavailable")}</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-52 text-xs">
Your device must suppport the feature and WebRTC
must be configured for two-way talk.
{t("stream.twoWayTalk.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/live/#webrtc-extra-configuration"
@@ -1618,7 +1698,7 @@ function FrigateCameraFeatures({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("stream.twoWayTalk.tips.documentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -1633,10 +1713,7 @@ function FrigateCameraFeatures({
<div className="flex flex-row items-center gap-2">
<IoIosWarning className="mr-1 size-8 text-danger" />
<p className="text-sm">
Live view is in low-bandwidth mode due to buffering or
stream errors.
</p>
<p className="text-sm">{t("stream.lowBandwidth.tips")}</p>
</div>
<Button
className={`flex items-center gap-2.5 rounded-lg`}
@@ -1646,7 +1723,9 @@ function FrigateCameraFeatures({
onClick={() => setLowBandwidth(false)}
>
<MdOutlineRestartAlt className="size-5 text-primary-variant" />
<div className="text-primary-variant">Reset stream</div>
<div className="text-primary-variant">
{t("stream.lowBandwidth.resetStream")}
</div>
</Button>
</div>
)}
@@ -1654,7 +1733,7 @@ function FrigateCameraFeatures({
)}
<div className="flex flex-col gap-1 px-2">
<div className="mb-1 text-sm font-medium leading-none">
On-Demand Recording
{t("manualRecording.title")}
</div>
<Button
onClick={handleEventButtonClick}
@@ -1663,46 +1742,43 @@ function FrigateCameraFeatures({
isRecording && "animate-pulse bg-red-500 hover:bg-red-600",
)}
>
{isRecording ? "End" : "Start"} on-demand recording
{t("manualRecording." + isRecording ? "end" : "start")}
</Button>
<p className="text-sm text-muted-foreground">
Start a manual event based on this camera's recording retention
settings.
{t("manualRecording.tips")}
</p>
</div>
{isRestreamed && (
<>
<div className="flex flex-col gap-2">
<FilterSwitch
label="Play in Background"
label={t("manualRecording.playInBackground.label")}
isChecked={playInBackground}
onCheckedChange={(checked) => {
setPlayInBackground(checked);
}}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
Enable this option to continue streaming when the player is
hidden.
{t("manualRecording.playInBackground.desc")}
</p>
</div>
<div className="flex flex-col gap-2">
<FilterSwitch
label="Show Stats"
label={t("manualRecording.showStats.label")}
isChecked={showStats}
onCheckedChange={(checked) => {
setShowStats(checked);
}}
/>
<p className="mx-2 -mt-2 text-sm text-muted-foreground">
Enable this option to show stream statistics as an overlay on
the camera feed.
{t("manualRecording.showStats.desc")}
</p>
</div>
</>
)}
<div className="mb-3 flex flex-col gap-1 px-2">
<div className="flex items-center justify-between text-sm font-medium leading-none">
Debug View
{t("manualRecording.debugView")}
<LuExternalLink
onClick={() =>
navigate(`/settings?page=debug&camera=${camera.name}`)
+9 -2
View File
@@ -43,6 +43,7 @@ import useCameraLiveMode from "@/hooks/use-camera-live-mode";
import { useResizeObserver } from "@/hooks/resize-observer";
import LiveContextMenu from "@/components/menu/LiveContextMenu";
import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next";
type LiveDashboardViewProps = {
cameras: CameraConfig[];
@@ -60,6 +61,8 @@ export default function LiveDashboardView({
fullscreen,
toggleFullscreen,
}: LiveDashboardViewProps) {
const { t } = useTranslation(["views/live"]);
const { data: config } = useSWR<FrigateConfig>("config");
// layout
@@ -471,7 +474,9 @@ export default function LiveDashboardView({
}
const streamName =
currentGroupStreamingSettings?.[camera.name]?.streamName ||
Object.values(camera.live.streams)?.[0];
camera?.live?.streams
? Object?.values(camera?.live?.streams)?.[0]
: "";
const autoLive =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"no-streaming";
@@ -560,7 +565,9 @@ export default function LiveDashboardView({
</div>
</TooltipTrigger>
<TooltipContent>
{fullscreen ? "Exit Fullscreen" : "Fullscreen"}
{fullscreen
? t("button.exitFullscreen", { ns: "common" })
: t("button.fullscreen", { ns: "common" })}
</TooltipContent>
</Tooltip>
</div>
+19 -8
View File
@@ -49,6 +49,7 @@ import { cn } from "@/lib/utils";
import { useFullscreen } from "@/hooks/use-fullscreen";
import { useTimezone } from "@/hooks/use-date-utils";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useTranslation } from "react-i18next";
type RecordingViewProps = {
startCamera: string;
@@ -72,6 +73,7 @@ export function RecordingView({
filter,
updateFilter,
}: RecordingViewProps) {
const { t } = useTranslation(["views/events"]);
const { data: config } = useSWR<FrigateConfig>("config");
const navigate = useNavigate();
const contentRef = useRef<HTMLDivElement | null>(null);
@@ -393,12 +395,16 @@ export function RecordingView({
<div className={cn("flex items-center gap-2")}>
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label="Go back"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">Back</div>}
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
<Button
className="flex items-center gap-2.5 rounded-lg"
@@ -409,7 +415,11 @@ export function RecordingView({
}}
>
<FaVideo className="size-5 text-secondary-foreground" />
{isDesktop && <div className="text-primary">Live</div>}
{isDesktop && (
<div className="text-primary">
{t("menu.live", { ns: "common" })}
</div>
)}
</Button>
</div>
<div className="flex items-center justify-end gap-2">
@@ -482,16 +492,16 @@ export function RecordingView({
<ToggleGroupItem
className={`${timelineType == "timeline" ? "" : "text-muted-foreground"}`}
value="timeline"
aria-label="Select timeline"
aria-label={t("timeline.aria")}
>
<div className="">Timeline</div>
<div className="">{t("timeline")}</div>
</ToggleGroupItem>
<ToggleGroupItem
className={`${timelineType == "events" ? "" : "text-muted-foreground"}`}
value="events"
aria-label="Select events"
aria-label={t("events.aria")}
>
<div className="">Events</div>
<div className="">{t("events.label")}</div>
</ToggleGroupItem>
</ToggleGroup>
) : (
@@ -689,6 +699,7 @@ function Timeline({
setScrubbing,
setExportRange,
}: TimelineProps) {
const { t } = useTranslation(["views/events"]);
const internalTimelineRef = useRef<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef;
@@ -798,7 +809,7 @@ function Timeline({
>
{mainCameraReviewItems.length === 0 ? (
<div className="mt-5 text-center text-primary">
No events found for this time period.
{t("events.noFoundForTimePeriod")}
</div>
) : (
mainCameraReviewItems.map((review) => {
+6 -4
View File
@@ -22,7 +22,7 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { isEqual } from "lodash";
import { formatDateToLocaleString } from "@/utils/dateUtil";
import SearchThumbnailFooter from "@/components/card/SearchThumbnailFooter";
import SearchSettings from "@/components/settings/SearchSettings";
import ExploreSettings from "@/components/settings/SearchSettings";
import {
Tooltip,
TooltipContent,
@@ -31,6 +31,7 @@ import {
import Chip from "@/components/indicators/Chip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import SearchActionGroup from "@/components/filter/SearchActionGroup";
import { useTranslation } from "react-i18next";
type SearchViewProps = {
search: string;
@@ -70,6 +71,7 @@ export default function SearchView({
setColumns,
setDefaultView,
}: SearchViewProps) {
const { t } = useTranslation(["views/explore"]);
const contentRef = useRef<HTMLDivElement | null>(null);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
@@ -495,7 +497,7 @@ export default function SearchView({
filter={searchFilter}
onUpdateFilter={onUpdateFilter}
/>
<SearchSettings
<ExploreSettings
columns={columns}
setColumns={setColumns}
defaultView={defaultView}
@@ -531,7 +533,7 @@ export default function SearchView({
{uniqueResults?.length == 0 && !isLoading && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
<LuSearchX className="size-16" />
No Tracked Objects Found
{t("noTrackedObjects")}
</div>
)}
@@ -616,7 +618,7 @@ export default function SearchView({
}}
refreshResults={refresh}
showObjectLifecycle={() =>
onSelectSearch(value, false, "object lifecycle")
onSelectSearch(value, false, "object_lifecycle")
}
showSnapshot={() =>
onSelectSearch(value, false, "snapshot")
+81 -49
View File
@@ -13,6 +13,7 @@ import { toast } from "sonner";
import DeleteUserDialog from "@/components/overlay/DeleteUserDialog";
import { HiTrash } from "react-icons/hi";
import { FaUserEdit } from "react-icons/fa";
import { LuPlus, LuShield, LuUserCog } from "react-icons/lu";
import {
Table,
@@ -30,8 +31,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import RoleChangeDialog from "@/components/overlay/RoleChangeDialog";
import { useTranslation } from "react-i18next";
export default function AuthenticationView() {
const { t } = useTranslation("views/settings");
const { data: config } = useSWR<FrigateConfig>("config");
const { data: users, mutate: mutateUsers } = useSWR<User[]>("users");
@@ -46,30 +49,38 @@ export default function AuthenticationView() {
>();
useEffect(() => {
document.title = "Authentication Settings - Frigate";
}, []);
document.title = t("documentTitle.authentication");
}, [t]);
const onSavePassword = useCallback((user: string, password: string) => {
axios
.put(`users/${user}/password`, { password })
.then((response) => {
if (response.status === 200) {
setShowSetPassword(false);
toast.success("Password updated successfully", {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save password: ${errorMessage}`, {
position: "top-center",
const onSavePassword = useCallback(
(user: string, password: string) => {
axios
.put(`users/${user}/password`, { password })
.then((response) => {
if (response.status === 200) {
setShowSetPassword(false);
toast.success(t("users.toast.success.updatePassword"), {
position: "top-center",
});
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("users.toast.error.setPasswordFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
});
}, []);
},
[t],
);
const onCreate = (
user: string,
@@ -85,7 +96,7 @@ export default function AuthenticationView() {
users?.push({ username: user, role: role });
return users;
}, false);
toast.success(`User ${user} created successfully`, {
toast.success(t("users.toast.success.createUser", { user }), {
position: "top-center",
});
}
@@ -95,9 +106,14 @@ export default function AuthenticationView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to create user: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("users.toast.error.createUserFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
};
@@ -111,7 +127,7 @@ export default function AuthenticationView() {
(users) => users?.filter((u) => u.username !== user),
false,
);
toast.success(`User ${user} deleted successfully`, {
toast.success(t("users.toast.success.deleteUser", { user }), {
position: "top-center",
});
}
@@ -121,9 +137,14 @@ export default function AuthenticationView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to delete user: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("users.toast.error.deleteUserFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
};
@@ -142,7 +163,7 @@ export default function AuthenticationView() {
),
false,
);
toast.success(`Role updated for ${user}`, {
toast.success(t("users.toast.success.roleUpdated", { user }), {
position: "top-center",
});
}
@@ -152,9 +173,14 @@ export default function AuthenticationView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to update role: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("users.toast.error.roleUpdateFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
};
@@ -173,20 +199,20 @@ export default function AuthenticationView() {
<div className="mb-5 flex flex-row items-center justify-between gap-2">
<div className="flex flex-col items-start">
<Heading as="h3" className="my-2">
User Management
{t("users.management")}
</Heading>
<p className="text-sm text-muted-foreground">
Manage this Frigate instance's user accounts.
{t("users.management.desc")}
</p>
</div>
<Button
className="flex items-center gap-2 self-start sm:self-auto"
aria-label="Add a new user"
aria-label={t("users.addUser")}
variant="default"
onClick={() => setShowCreate(true)}
>
<LuPlus className="size-4" />
Add User
{t("users.addUser")}
</Button>
</div>
<div className="mb-6 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -195,16 +221,20 @@ export default function AuthenticationView() {
<Table>
<TableHeader className="sticky top-0 bg-muted/50">
<TableRow>
<TableHead className="w-[250px]">Username</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead className="w-[250px]">
{t("users.table.username")}
</TableHead>
<TableHead>{t("users.table.role")}</TableHead>
<TableHead className="text-right">
{t("users.table.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
No users found.
{t("users.table.noUsers")}
</TableCell>
</TableRow>
) : (
@@ -231,7 +261,9 @@ export default function AuthenticationView() {
: ""
}
>
{user.role || "viewer"}
{t("role." + (user.role || "viewer"), {
ns: "common",
})}
</Badge>
</TableCell>
<TableCell className="text-right">
@@ -255,12 +287,12 @@ export default function AuthenticationView() {
>
<LuUserCog className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
Role
{t("role.title", { ns: "common" })}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Change user role</p>
<p>{t("users.table.changeRole")}</p>
</TooltipContent>
</Tooltip>
)}
@@ -278,12 +310,12 @@ export default function AuthenticationView() {
>
<FaUserEdit className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
Password
{t("users.table.password")}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Update password</p>
<p>{t("users.updatePassword")}</p>
</TooltipContent>
</Tooltip>
@@ -301,12 +333,12 @@ export default function AuthenticationView() {
>
<HiTrash className="size-3.5" />
<span className="ml-1.5 hidden sm:inline-block">
Delete
{t("button.delete", { ns: "common" })}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete user</p>
<p>{t("users.table.deleteUser")}</p>
</TooltipContent>
</Tooltip>
)}
+148 -67
View File
@@ -27,6 +27,7 @@ import { LuExternalLink } from "react-icons/lu";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { MdCircle } from "react-icons/md";
import { cn } from "@/lib/utils";
import { Trans, useTranslation } from "react-i18next";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useAlertsState, useDetectionsState, useEnabledState } from "@/api/ws";
@@ -45,6 +46,8 @@ export default function CameraSettingsView({
selectedCamera,
setUnsavedChanges,
}: CameraSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
@@ -76,18 +79,18 @@ export default function CameraSettingsView({
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? cameraConfig.review.alerts.labels
.map((label) => label.replaceAll("_", " "))
.map((label) => t(label, { ns: "objects" }))
.join(", ")
: "";
}, [cameraConfig]);
}, [cameraConfig, t]);
const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels
? cameraConfig.review.detections.labels
.map((label) => label.replaceAll("_", " "))
.map((label) => t(label, { ns: "objects" }))
.join(", ")
: "";
}, [cameraConfig]);
}, [cameraConfig, t]);
// form
@@ -157,17 +160,20 @@ export default function CameraSettingsView({
})
.then((res) => {
if (res.status === 200) {
toast.success(
`Review classification configuration has been saved. Restart Frigate to apply changes.`,
toast.success(t("camera.reviewClassification.toast.success"), {
position: "top-center",
});
updateConfig();
} else {
toast.error(
t("toast.save.error", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
}
})
.catch((error) => {
@@ -175,15 +181,21 @@ export default function CameraSettingsView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("toast.save.error", {
errorMessage,
ns: "common",
}),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
});
},
[updateConfig, setIsLoading, selectedCamera, cameraConfig],
[updateConfig, setIsLoading, selectedCamera, cameraConfig, t],
);
const onCancel = useCallback(() => {
@@ -252,13 +264,13 @@ export default function CameraSettingsView({
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
Camera Settings
<Trans ns="views/settings">camera.title</Trans>
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Streams
<Trans ns="views/settings">camera.streams.title</Trans>
</Heading>
<div className="flex flex-row items-center">
@@ -271,19 +283,18 @@ export default function CameraSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="camera-enabled">Enable</Label>
<Label htmlFor="camera-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Disabling a camera completely stops Frigate's processing of this
camera's streams. Detection, recording, and debugging will be
unavailable.
<br /> <em>Note: This does not disable go2rtc restreams.</em>
<Trans ns="views/settings">camera.streams.desc</Trans>
</div>
<Separator className="mb-2 mt-4 flex bg-secondary" />
<Heading as="h4" className="my-2">
Review
<Trans ns="views/settings">camera.review.title</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
@@ -297,7 +308,9 @@ export default function CameraSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="alerts-enabled">Alerts</Label>
<Label htmlFor="alerts-enabled">
<Trans ns="views/settings">camera.review.alerts</Trans>
</Label>
</div>
</div>
<div className="flex flex-col">
@@ -311,12 +324,13 @@ export default function CameraSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="detections-enabled">Detections</Label>
<Label htmlFor="detections-enabled">
<Trans ns="views/settings">camera.review.detections</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Enable/disable alerts and detections for this camera. When
disabled, no new review items will be generated.
<Trans ns="views/settings">camera.review.desc</Trans>
</div>
</div>
</div>
@@ -324,16 +338,15 @@ export default function CameraSettingsView({
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Review Classification
<Trans ns="views/settings">camera.reviewClassification.title</Trans>
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Frigate categorizes review items as Alerts and Detections. By
default, all <em>person</em> and <em>car</em> objects are
considered Alerts. You can refine categorization of your review
items by configuring required zones for them.
<Trans ns="views/settings">
camera.reviewClassification.desc
</Trans>
</p>
<div className="flex items-center text-primary">
<Link
@@ -342,7 +355,9 @@ export default function CameraSettingsView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation{" "}
<Trans ns="views/settings">
camera.reviewClassification.readTheDocumentation
</Trans>{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -371,11 +386,15 @@ export default function CameraSettingsView({
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
Alerts{" "}
<Trans ns="views/settings">
camera.review.alerts
</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</FormLabel>
<FormDescription>
Select zones for Alerts
<Trans ns="views/settings">
camera.reviewClassification.selectAlertsZones
</Trans>
</FormDescription>
</div>
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
@@ -424,20 +443,37 @@ export default function CameraSettingsView({
</>
) : (
<div className="font-normal text-destructive">
No zones are defined for this camera.
<Trans ns="views/settings">
camera.reviewClassification.noDefinedZones
</Trans>
</div>
)}
<FormMessage />
<div className="text-sm">
All {alertsLabels} objects
{watchedAlertsZones && watchedAlertsZones.length > 0
? ` detected in ${watchedAlertsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
: ""}{" "}
on{" "}
{capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " ")}{" "}
will be shown as Alerts.
? t(
"camera.reviewClassification.zoneObjectAlertsTips",
{
alertsLabels,
zone: watchedAlertsZones
.map((zone) =>
capitalizeFirstLetter(zone).replaceAll(
"_",
" ",
),
)
.join(", "),
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
},
)
: t("camera.reviewClassification.objectAlertsTips", {
alertsLabels,
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
})}
</div>
</FormItem>
)}
@@ -452,12 +488,16 @@ export default function CameraSettingsView({
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
Detections{" "}
<Trans ns="views/settings">
camera.review.detections
</Trans>
<MdCircle className="ml-3 size-2 text-severity_detection" />
</FormLabel>
{selectDetections && (
<FormDescription>
Select zones for Detections
<Trans ns="views/settings">
camera.reviewClassification.selectDetectionsZones
</Trans>
</FormDescription>
)}
</div>
@@ -520,7 +560,9 @@ export default function CameraSettingsView({
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Limit detections to specific zones
<Trans ns="views/settings">
camera.reviewClassification.limitDetections
</Trans>
</label>
</div>
</div>
@@ -528,22 +570,59 @@ export default function CameraSettingsView({
)}
<div className="text-sm">
All {detectionsLabels} objects{" "}
<em>not classified as Alerts</em>{" "}
{watchedDetectionsZones &&
watchedDetectionsZones.length > 0
? ` that are detected in ${watchedDetectionsZones.map((zone) => capitalizeFirstLetter(zone).replaceAll("_", " ")).join(", ")}`
: ""}{" "}
on{" "}
{capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " ")}{" "}
will be shown as Detections
{(!selectDetections ||
(watchedDetectionsZones &&
watchedDetectionsZones.length === 0)) &&
", regardless of zone"}
.
watchedDetectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips"
values={{
detectionsLabels,
zone: watchedDetectionsZones
.map((zone) =>
capitalizeFirstLetter(zone).replaceAll(
"_",
" ",
),
)
.join(", "),
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}}
ns="views/settings"
></Trans>
) : (
<Trans
i18nKey="camera.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: watchedDetectionsZones
.map((zone) =>
capitalizeFirstLetter(zone).replaceAll(
"_",
" ",
),
)
.join(", "),
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}}
ns="views/settings"
/>
)
) : (
<Trans
i18nKey="camera.reviewClassification.objectDetectionsTips"
values={{
detectionsLabels,
cameraName: capitalizeFirstLetter(
cameraConfig?.name ?? "",
).replaceAll("_", " "),
}}
ns="views/settings"
/>
)}
</div>
</FormItem>
)}
@@ -554,26 +633,28 @@ export default function CameraSettingsView({
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label="Cancel"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
Cancel
<Trans>button.cancel</Trans>
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
<span>
<Trans>button.saving</Trans>
</span>
</div>
) : (
"Save"
<Trans>button.save</Trans>
)}
</Button>
</div>
@@ -20,6 +20,7 @@ import {
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Trans, useTranslation } from "react-i18next";
type ClassificationSettings = {
search: {
@@ -41,6 +42,7 @@ type ClassificationSettingsViewProps = {
export default function ClassificationSettingsView({
setUnsavedChanges,
}: ClassificationSettingsViewProps) {
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
@@ -141,15 +143,18 @@ export default function ClassificationSettingsView({
)
.then((res) => {
if (res.status === 200) {
toast.success("Classification settings have been saved.", {
toast.success(t("classification.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("classification.toast.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
@@ -157,14 +162,14 @@ export default function ClassificationSettingsView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
})
.finally(() => {
setIsLoading(false);
});
}, [updateConfig, classificationSettings.search]);
}, [updateConfig, classificationSettings.search, t]);
const onCancel = useCallback(() => {
setClassificationSettings(origSearchSettings);
@@ -188,8 +193,8 @@ export default function ClassificationSettingsView({
}, [changedValue]);
useEffect(() => {
document.title = "Classification Settings - Frigate";
}, []);
document.title = t("documentTitle.classification");
}, [t]);
if (!config) {
return <ActivityIndicator />;
@@ -200,19 +205,15 @@ export default function ClassificationSettingsView({
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
Classification Settings
{t("classification.title")}
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Semantic Search
{t("classification.semanticSearch.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Semantic Search in Frigate allows you to find tracked objects
within your review items using either the image itself, a
user-defined text description, or an automatically generated one.
</p>
<p>{t("classification.semanticSearch.desc")}</p>
<div className="flex items-center text-primary">
<Link
@@ -221,7 +222,7 @@ export default function ClassificationSettingsView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
{t("classification.semanticSearch.readTheDocumentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -242,7 +243,9 @@ export default function ClassificationSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
<div className="flex flex-col">
@@ -259,31 +262,38 @@ export default function ClassificationSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="reindex">Re-Index On Startup</Label>
<Label htmlFor="reindex">
{t("classification.semanticSearch.reindexOnStartup.label")}
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
Re-indexing will reprocess all thumbnails and descriptions (if
enabled) and apply the embeddings on each startup.{" "}
<em>Don't forget to disable the option after restarting!</em>
<Trans ns="views/settings">
classification.semanticSearch.reindexOnStartup.desc
</Trans>
</div>
</div>
<div className="mt-2 flex flex-col space-y-6">
<div className="space-y-0.5">
<div className="text-md">Model Size</div>
<div className="text-md">
{t("classification.semanticSearch.modelSize.label")}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<p>
The size of the model used for Semantic Search embeddings.
<Trans ns="views/settings">
classification.semanticSearch.modelSize.desc
</Trans>
</p>
<ul className="list-disc pl-5 text-sm">
<li>
Using <em>small</em> employs a quantized version of the
model that uses less RAM and runs faster on CPU with a very
negligible difference in embedding quality.
<Trans ns="views/settings">
classification.semanticSearch.modelSize.small.desc
</Trans>
</li>
<li>
Using <em>large</em> employs the full Jina model and will
automatically run on the GPU if applicable.
<Trans ns="views/settings">
classification.semanticSearch.modelSize.large.desc
</Trans>
</li>
</ul>
</div>
@@ -309,7 +319,7 @@ export default function ClassificationSettingsView({
className="cursor-pointer"
value={size}
>
{size}
{t("classification.semanticSearch.modelSize." + size)}
</SelectItem>
))}
</SelectGroup>
@@ -322,16 +332,11 @@ export default function ClassificationSettingsView({
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Face Recognition
{t("classification.faceRecognition.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Face recognition allows people to be assigned names and when
their face is recognized Frigate will assign the person's name
as a sub label. This information is included in the UI, filters,
as well as in notifications.
</p>
<p>{t("classification.faceRecognition.desc")}</p>
<div className="flex items-center text-primary">
<Link
@@ -340,7 +345,7 @@ export default function ClassificationSettingsView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
{t("classification.faceRecognition.readTheDocumentation")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -361,7 +366,9 @@ export default function ClassificationSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
</div>
@@ -369,18 +376,11 @@ export default function ClassificationSettingsView({
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
License Plate Recognition
{t("classification.licensePlateRecognition.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Frigate can recognize license plates on vehicles and
automatically add the detected characters to the
recognized_license_plate field or a known name as a sub_label to
objects that are of type car. A common use case may be to read
the license plates of cars pulling into a driveway or cars
passing by on a street.
</p>
<p>{t("classification.licensePlateRecognition.desc")}</p>
<div className="flex items-center text-primary">
<Link
@@ -389,7 +389,9 @@ export default function ClassificationSettingsView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation
{t(
"classification.licensePlateRecognition.readTheDocumentation",
)}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -410,7 +412,9 @@ export default function ClassificationSettingsView({
}}
/>
<div className="space-y-0.5">
<Label htmlFor="enabled">Enabled</Label>
<Label htmlFor="enabled">
{t("button.enabled", { ns: "common" })}
</Label>
</div>
</div>
</div>
@@ -420,10 +424,10 @@ export default function ClassificationSettingsView({
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
<Button
variant="select"
@@ -435,10 +439,10 @@ export default function ClassificationSettingsView({
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
"Save"
t("button.save", { ns: "common" })
)}
</Button>
</div>
+40 -36
View File
@@ -37,7 +37,9 @@ import PolygonItem from "@/components/settings/PolygonItem";
import { Link } from "react-router-dom";
import { isDesktop } from "react-device-detect";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { useTranslation } from "react-i18next";
type MasksAndZoneViewProps = {
selectedCamera: string;
@@ -50,6 +52,7 @@ export default function MasksAndZonesView({
selectedZoneMask,
setUnsavedChanges,
}: MasksAndZoneViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const [allPolygons, setAllPolygons] = useState<Polygon[]>([]);
const [editingPolygons, setEditingPolygons] = useState<Polygon[]>([]);
@@ -182,8 +185,8 @@ export default function MasksAndZonesView({
setActivePolygonIndex(undefined);
setHoveredPolygonIndex(null);
setUnsavedChanges(false);
document.title = "Mask and Zone Editor - Frigate";
}, [allPolygons, setUnsavedChanges]);
document.title = t("documentTitle.masksAndZones");
}, [allPolygons, setUnsavedChanges, t]);
const handleSave = useCallback(() => {
setAllPolygons([...(editingPolygons ?? [])]);
@@ -218,12 +221,16 @@ export default function MasksAndZonesView({
.map((point) => `${point[0]},${point[1]}`)
.join(","),
);
toast.success(`Copied coordinates for ${poly.name} to clipboard.`);
toast.success(
t("masksAndZones.toast.success.copyCoordinates", {
polyName: poly.name,
}),
);
} else {
toast.error("Could not copy coordinates to clipboard.");
toast.error(t("masksAndZones.toast.error.copyCoordinatesFailed"));
}
},
[allPolygons, scaledHeight, scaledWidth],
[allPolygons, scaledHeight, scaledWidth, t],
);
useEffect(() => {
@@ -418,8 +425,8 @@ export default function MasksAndZonesView({
});
useEffect(() => {
document.title = "Mask and Zone Editor - Frigate";
}, []);
document.title = t("documentTitle.masksAndZones");
}, [t]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
@@ -480,7 +487,7 @@ export default function MasksAndZonesView({
{editPane === undefined && (
<>
<Heading as="h3" className="my-2">
Masks / Zones
{t("menu.masksAndZones")}
</Heading>
<div className="flex w-full flex-col">
{(selectedZoneMask === undefined ||
@@ -489,15 +496,13 @@ export default function MasksAndZonesView({
<div className="my-3 flex flex-row items-center justify-between">
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">Zones</div>
<div className="text-md cursor-default">
{t("masksAndZones.zones.label")}
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>
Zones allow you to define a specific area of the
frame so you can determine whether or not an
object is within a particular area.
</p>
<p>{t("masksAndZones.zones.desc")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/zones"
@@ -505,7 +510,7 @@ export default function MasksAndZonesView({
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
{t("masksAndZones.zones.desc.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -517,7 +522,7 @@ export default function MasksAndZonesView({
<Button
variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
aria-label="Add a new zone"
aria-label={t("masksAndZones.zones.add")}
onClick={() => {
setEditPane("zone");
handleNewPolygon("zone");
@@ -526,7 +531,9 @@ export default function MasksAndZonesView({
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Zone</TooltipContent>
<TooltipContent>
{t("masksAndZones.zones.add")}
</TooltipContent>
</Tooltip>
</div>
{allPolygons
@@ -556,17 +563,12 @@ export default function MasksAndZonesView({
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
Motion Masks
{t("masksAndZones.motionMasks.label")}
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>
Motion masks are used to prevent unwanted types
of motion from triggering detection. Over
masking will make it more difficult for objects
to be tracked.
</p>
<p>{t("masksAndZones.motionMasks.desc")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#motion-masks"
@@ -574,7 +576,9 @@ export default function MasksAndZonesView({
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
{t(
"masksAndZones.motionMasks.desc.documentation",
)}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -586,7 +590,7 @@ export default function MasksAndZonesView({
<Button
variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
aria-label="Add a new motion mask"
aria-label={t("masksAndZones.motionMasks.add")}
onClick={() => {
setEditPane("motion_mask");
handleNewPolygon("motion_mask");
@@ -595,7 +599,9 @@ export default function MasksAndZonesView({
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Motion Mask</TooltipContent>
<TooltipContent>
{t("masksAndZones.motionMasks.add")}
</TooltipContent>
</Tooltip>
</div>
{allPolygons
@@ -627,16 +633,12 @@ export default function MasksAndZonesView({
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-md cursor-default">
Object Masks
{t("masksAndZones.objectMasks.label")}
</div>
</HoverCardTrigger>
<HoverCardContent>
<div className="my-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>
Object filter masks are used to filter out false
positives for a given object type based on
location.
</p>
<p>{t("masksAndZones.objectMasks.desc")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/masks#object-filter-masks"
@@ -644,7 +646,7 @@ export default function MasksAndZonesView({
rel="noopener noreferrer"
className="inline"
>
Documentation{" "}
{t("masksAndZones.objectMasks.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -656,7 +658,7 @@ export default function MasksAndZonesView({
<Button
variant="secondary"
className="size-6 rounded-md bg-secondary-foreground p-1 text-background"
aria-label="Add a new object mask"
aria-label={t("masksAndZones.objectMasks.add")}
onClick={() => {
setEditPane("object_mask");
handleNewPolygon("object_mask");
@@ -665,7 +667,9 @@ export default function MasksAndZonesView({
<LuPlus />
</Button>
</TooltipTrigger>
<TooltipContent>Add Object Mask</TooltipContent>
<TooltipContent>
{t("masksAndZones.objectMasks.add")}
</TooltipContent>
</Tooltip>
</div>
{allPolygons
+41 -30
View File
@@ -21,6 +21,7 @@ import { Separator } from "@/components/ui/separator";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { Trans, useTranslation } from "react-i18next";
type MotionTunerViewProps = {
selectedCamera: string;
@@ -37,6 +38,7 @@ export default function MotionTunerView({
selectedCamera,
setUnsavedChanges,
}: MotionTunerViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
@@ -117,20 +119,29 @@ export default function MotionTunerView({
)
.then((res) => {
if (res.status === 200) {
toast.success("Motion settings have been saved.", {
toast.success(t("motionDetectionTuner.toast.success"), {
position: "top-center",
});
setChangedValue(false);
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("toast.save.error", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
toast.error(
`Failed to save config changes: ${error.response.data.message}`,
t("toast.save.error", {
errorMessage: error.response.data.message,
ns: "common",
}),
{ position: "top-center" },
);
})
@@ -143,6 +154,7 @@ export default function MotionTunerView({
motionSettings.contour_area,
motionSettings.improve_contrast,
selectedCamera,
t,
]);
const onCancel = useCallback(() => {
@@ -167,8 +179,8 @@ export default function MotionTunerView({
}, [changedValue, selectedCamera]);
useEffect(() => {
document.title = "Motion Tuner - Frigate";
}, []);
document.title = t("documentTitle.motionTuner");
}, [t]);
if (!cameraConfig && !selectedCamera) {
return <ActivityIndicator />;
@@ -179,14 +191,10 @@ export default function MotionTunerView({
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
<Heading as="h3" className="my-2">
Motion Detection Tuner
{t("motionDetectionTuner.title")}
</Heading>
<div className="my-3 space-y-3 text-sm text-muted-foreground">
<p>
Frigate uses motion detection as a first line check to see if there
is anything happening in the frame worth checking with object
detection.
</p>
<p>{t("motionDetectionTuner.desc")}</p>
<div className="flex items-center text-primary">
<Link
@@ -195,7 +203,7 @@ export default function MotionTunerView({
rel="noopener noreferrer"
className="inline"
>
Read the Motion Tuning Guide{" "}
{t("motionDetectionTuner.desc.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -205,14 +213,12 @@ export default function MotionTunerView({
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
Threshold
{t("motionDetectionTuner.Threshold")}
</Label>
<div className="my-2 text-sm text-muted-foreground">
<p>
The threshold value dictates how much of a change in a pixel's
luminance is required to be considered motion.{" "}
<em>Default: 30</em>
</p>
<Trans ns="views/settings">
motionDetectionTuner.Threshold.desc
</Trans>
</div>
</div>
<div className="flex flex-row justify-between">
@@ -236,12 +242,13 @@ export default function MotionTunerView({
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<Label htmlFor="motion-threshold" className="text-md">
Contour Area
{t("motionDetectionTuner.contourArea")}
</Label>
<div className="my-2 text-sm text-muted-foreground">
<p>
The contour area value is used to decide which groups of
changed pixels qualify as motion. <em>Default: 10</em>
<Trans ns="views/settings">
motionDetectionTuner.contourArea.desc
</Trans>
</p>
</div>
</div>
@@ -266,9 +273,13 @@ export default function MotionTunerView({
<Separator className="my-2 flex bg-secondary" />
<div className="flex flex-row items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="improve-contrast">Improve Contrast</Label>
<Label htmlFor="improve-contrast">
{t("motionDetectionTuner.improveContrast")}
</Label>
<div className="text-sm text-muted-foreground">
Improve contrast for darker scenes. <em>Default: ON</em>
<Trans ns="views/settings">
motionDetectionTuner.improveContrast.desc
</Trans>
</div>
</div>
<Switch
@@ -286,25 +297,25 @@ export default function MotionTunerView({
<div className="flex flex-row gap-2 pt-5">
<Button
className="flex flex-1"
aria-label="Reset"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
Reset
{t("button.reset", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex flex-1"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
onClick={saveToConfig}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
"Save"
t("button.save", { ns: "common" })
)}
</Button>
</div>
@@ -18,8 +18,10 @@ import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Link } from "react-router-dom";
@@ -41,6 +43,7 @@ import {
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next";
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
@@ -56,6 +59,8 @@ type NotificationsSettingsViewProps = {
export default function NotificationView({
setUnsavedChanges,
}: NotificationsSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: updateConfig } = useSWR<FrigateConfig>(
"config",
{
@@ -138,23 +143,20 @@ export default function NotificationView({
sub: pushSubscription,
})
.catch(() => {
toast.error("Failed to save notification registration.", {
toast.error(t("notification.toast.error.registerFailed"), {
position: "top-center",
});
pushSubscription.unsubscribe();
registration.unregister();
setRegistration(null);
});
toast.success(
"Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.",
{
position: "top-center",
},
);
toast.success(t("notification.toast.success.registered"), {
position: "top-center",
});
});
}
},
[publicKey, addMessage],
[publicKey, addMessage, t],
);
// notification state
@@ -256,14 +258,20 @@ export default function NotificationView({
)
.then((res) => {
if (res.status === 200) {
toast.success("Notification settings have been saved.", {
toast.success(t("notification.toast.success.settingSaved"), {
position: "top-center",
});
updateConfig();
} else {
toast.error(`Failed to save config changes: ${res.statusText}`, {
position: "top-center",
});
toast.error(
t("toast.save.error", {
errorMessage: res.statusText,
ns: "common",
}),
{
position: "top-center",
},
);
}
})
.catch((error) => {
@@ -271,7 +279,7 @@ export default function NotificationView({
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to save config changes: ${errorMessage}`, {
toast.error(t("toast.save.error", { errorMessage, ns: "common" }), {
position: "top-center",
});
})
@@ -279,7 +287,7 @@ export default function NotificationView({
setIsLoading(false);
});
},
[updateConfig, setIsLoading, allCameras],
[updateConfig, setIsLoading, allCameras, t],
);
function onSubmit(values: z.infer<typeof formSchema>) {
@@ -293,14 +301,11 @@ export default function NotificationView({
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-1">
<Heading as="h3" className="my-2">
Notification Settings
{t("notification.notificationSettings.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Frigate can natively send push notifications to your device
when it is running in the browser or installed as a PWA.
</p>
<p>{t("notification.notificationSettings.desc")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/notifications"
@@ -308,7 +313,9 @@ export default function NotificationView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation{" "}
<p>
{t("notification.notificationSettings.documentation")}
</p>{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -316,12 +323,13 @@ export default function NotificationView({
</div>
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>Notifications Unavailable</AlertTitle>
<AlertTitle>
{t("notification.notificationUnavailable.title")}
</AlertTitle>
<AlertDescription>
Web push notifications require a secure context (
<code>https://...</code>). This is a browser limitation. Access
Frigate securely to use notifications.
<Trans ns="views/settings">
notification.notificationUnavailable.desc
</Trans>
<div className="mt-3 flex items-center">
<Link
to="https://docs.frigate.video/configuration/authentication"
@@ -329,7 +337,9 @@ export default function NotificationView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation{" "}
<p>
{t("notification.notificationUnavailable.documentation")}
</p>{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -349,15 +359,12 @@ export default function NotificationView({
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-1">
<Heading as="h3" className="my-2">
Notification Settings
{t("notification.notificationSettings.title")}
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
Frigate can natively send push notifications to your device
when it is running in the browser or installed as a PWA.
</p>
<p>{t("notification.notificationSettings.desc")}</p>
<div className="flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/notifications"
@@ -365,7 +372,7 @@ export default function NotificationView({
rel="noopener noreferrer"
className="inline"
>
Read the Documentation{" "}
{t("notification.notificationSettings.documentation")}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -382,17 +389,16 @@ export default function NotificationView({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormLabel>{t("notification.email")}</FormLabel>
<FormControl>
<Input
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
placeholder="example@email.com"
placeholder={t("notification.email.placeholder")}
{...field}
/>
</FormControl>
<FormDescription>
Entering a valid email is required, as this is used by
the push server in case problems occur.
{t("notification.email.desc")}
</FormDescription>
<FormMessage />
</FormItem>
@@ -408,7 +414,7 @@ export default function NotificationView({
<>
<div className="mb-2">
<FormLabel className="flex flex-row items-center text-base">
Cameras
{t("notification.cameras")}
</FormLabel>
</div>
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
@@ -417,7 +423,9 @@ export default function NotificationView({
name="allEnabled"
render={({ field }) => (
<FilterSwitch
label="All Cameras"
label={t("cameras.all", {
ns: "components/filter",
})}
isChecked={field.value}
onCheckedChange={(checked) => {
setChangedValue(true);
@@ -456,13 +464,13 @@ export default function NotificationView({
</>
) : (
<div className="font-normal text-destructive">
No cameras available.
{t("notification.cameras.noCameras")}
</div>
)}
<FormMessage />
<FormDescription>
Select the cameras to enable notifications for.
{t("notification.cameras.desc")}
</FormDescription>
</FormItem>
)}
@@ -471,26 +479,26 @@ export default function NotificationView({
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
<Button
className="flex flex-1"
aria-label="Cancel"
aria-label={t("button.cancel", { ns: "common" })}
onClick={onCancel}
type="button"
>
Cancel
{t("button.cancel", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={isLoading}
className="flex flex-1"
aria-label="Save"
aria-label={t("button.save", { ns: "common" })}
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>Saving...</span>
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
"Save"
t("button.save", { ns: "common" })
)}
</Button>
</div>
@@ -503,7 +511,7 @@ export default function NotificationView({
<div className="flex flex-col gap-2 md:max-w-[50%]">
<Separator className="my-2 flex bg-secondary md:hidden" />
<Heading as="h4" className="my-2">
Device-Specific Settings
{t("notification.deviceSpecific")}
</Heading>
<Button
aria-label="Register or unregister notifications for this device"
@@ -546,14 +554,16 @@ export default function NotificationView({
}
}}
>
{`${registration != null ? "Unregister" : "Register"} this device`}
{registration != null
? t("notification.unregisterDevice")
: t("notification.registerDevice")}
</Button>
{registration != null && registration.active && (
<Button
aria-label="Send a test notification"
aria-label={t("notification.sendTestNotification")}
onClick={() => sendTestNotification("notification_test")}
>
Send a test notification
{t("notification.sendTestNotification")}
</Button>
)}
</div>
@@ -563,14 +573,11 @@ export default function NotificationView({
<div className="space-y-3">
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Global Settings
{t("notification.globalSettings.title")}
</Heading>
<div className="max-w-xl">
<div className="mb-5 mt-2 flex flex-col gap-2 text-sm text-primary-variant">
<p>
Temporarily suspend notifications for specific cameras
on all registered devices.
</p>
<p>{t("notification.globalSettings.desc")}</p>
</div>
</div>
@@ -606,6 +613,7 @@ export function CameraNotificationSwitch({
config,
camera,
}: CameraNotificationSwitchProps) {
const { t } = useTranslation(["views/settings"]);
const { payload: notificationState, send: sendNotification } =
useNotifications(camera);
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
@@ -635,14 +643,19 @@ export function CameraNotificationSwitch({
};
const formatSuspendedUntil = (timestamp: string) => {
if (timestamp === "0") return "Frigate restarts.";
// Some languages require a change in word order
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
return formatUnixTimestampToDateTime(parseInt(timestamp), {
const time = formatUnixTimestampToDateTime(parseInt(timestamp), {
time_style: "medium",
date_style: "medium",
timezone: config?.ui.timezone,
strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`,
strftime_fmt:
config?.ui.time_format == "24hour"
? t("time.formattedTimestampExcludeSeconds.24hour", { ns: "common" })
: t("time.formattedTimestampExcludeSeconds", { ns: "common" }),
});
return t("time.untilForTime", { ns: "common", time });
};
return (
@@ -664,12 +677,13 @@ export function CameraNotificationSwitch({
{!isSuspended ? (
<div className="flex flex-row items-center gap-2 text-sm text-success">
Notifications Active
{t("notification.active")}
</div>
) : (
<div className="flex flex-row items-center gap-2 text-sm text-danger">
Notifications suspended until{" "}
{formatSuspendedUntil(notificationSuspendUntil)}
{t("notification.suspended", {
time: formatSuspendedUntil(notificationSuspendUntil),
})}
</div>
)}
</div>
@@ -682,13 +696,27 @@ export function CameraNotificationSwitch({
<SelectValue placeholder="Suspend" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">Suspend for 5 minutes</SelectItem>
<SelectItem value="10">Suspend for 10 minutes</SelectItem>
<SelectItem value="30">Suspend for 30 minutes</SelectItem>
<SelectItem value="60">Suspend for 1 hour</SelectItem>
<SelectItem value="840">Suspend for 12 hours</SelectItem>
<SelectItem value="1440">Suspend for 24 hours</SelectItem>
<SelectItem value="off">Suspend until restart</SelectItem>
<SelectItem value="5">
{t("notification.suspendTime.5minutes")}
</SelectItem>
<SelectItem value="10">
{t("notification.suspendTime.10minutes")}
</SelectItem>
<SelectItem value="30">
{t("notification.suspendTime.30minutes")}
</SelectItem>
<SelectItem value="60">
{t("notification.suspendTime.1hour")}
</SelectItem>
<SelectItem value="840">
{t("notification.suspendTime.12hour")}
</SelectItem>
<SelectItem value="1440">
{t("notification.suspendTime.24hour")}
</SelectItem>
<SelectItem value="off">
{t("notification.suspendTime.untilRestart")}
</SelectItem>
</SelectContent>
</Select>
) : (
@@ -697,7 +725,7 @@ export function CameraNotificationSwitch({
size="sm"
onClick={handleCancelSuspension}
>
Cancel Suspension
{t("notification.cancelSuspension")}
</Button>
)}
</div>
+47 -83
View File
@@ -23,9 +23,11 @@ import { getIconForLabel } from "@/utils/iconUtil";
import { capitalizeFirstLetter } from "@/utils/stringUtil";
import { LuExternalLink, LuInfo } from "react-icons/lu";
import { Link } from "react-router-dom";
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
import { Separator } from "@/components/ui/separator";
import { isDesktop } from "react-device-detect";
import { Trans, useTranslation } from "react-i18next";
type ObjectSettingsViewProps = {
selectedCamera?: string;
@@ -38,6 +40,8 @@ const emptyObject = Object.freeze({});
export default function ObjectSettingsView({
selectedCamera,
}: ObjectSettingsViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const containerRef = useRef<HTMLDivElement>(null);
@@ -45,80 +49,45 @@ export default function ObjectSettingsView({
const DEBUG_OPTIONS = [
{
param: "bbox",
title: "Bounding boxes",
description: "Show bounding boxes around tracked objects",
title: t("debug.boundingBoxes.title"),
description: t("debug.boundingBoxes.desc"),
info: (
<>
<p className="mb-2">
<strong>Object Bounding Box Colors</strong>
<strong>{t("debug.boundingBoxes.colors.label")}</strong>
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
At startup, different colors will be assigned to each object label
</li>
<li>
A dark blue thin line indicates that object is not detected at
this current point in time
</li>
<li>
A gray thin line indicates that object is detected as being
stationary
</li>
<li>
A thick line indicates that object is the subject of autotracking
(when enabled)
</li>
<Trans ns="views/settings">debug.boundingBoxes.colors.info</Trans>
</ul>
</>
),
},
{
param: "timestamp",
title: "Timestamp",
description: "Overlay a timestamp on the image",
title: t("debug.timestamp.title"),
description: t("debug.timestamp.desc"),
},
{
param: "zones",
title: "Zones",
description: "Show an outline of any defined zones",
title: t("debug.zones.title"),
description: t("debug.zones.desc"),
},
{
param: "mask",
title: "Motion masks",
description: "Show motion mask polygons",
title: t("debug.mask.title"),
description: t("debug.mask.desc"),
},
{
param: "motion",
title: "Motion boxes",
description: "Show boxes around areas where motion is detected",
info: (
<>
<p className="mb-2">
<strong>Motion Boxes</strong>
</p>
<p>
Red boxes will be overlaid on areas of the frame where motion is
currently being detected
</p>
</>
),
title: t("debug.motion.title"),
description: t("debug.motion.desc"),
info: <Trans ns="views/settings">debug.motion.tips</Trans>,
},
{
param: "regions",
title: "Regions",
description:
"Show a box of the region of interest sent to the object detector",
info: (
<>
<p className="mb-2">
<strong>Region Boxes</strong>
</p>
<p>
Bright green boxes will be overlaid on areas of interest in the
frame that are being sent to the object detector.
</p>
</>
),
title: t("debug.regions.title"),
description: t("debug.regions.desc"),
info: <Trans ns="views/settings">debug.regions.tips</Trans>,
},
];
@@ -167,8 +136,8 @@ export default function ObjectSettingsView({
}, [options, optionsLoaded]);
useEffect(() => {
document.title = "Object Settings - Frigate";
}, []);
document.title = t("documentTitle.object");
}, [t]);
if (!cameraConfig) {
return <ActivityIndicator />;
@@ -179,25 +148,19 @@ export default function ObjectSettingsView({
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-3/12">
<Heading as="h3" className="my-2">
Debug
{t("debug.title")}
</Heading>
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
<p>
Frigate uses your detectors{" "}
{config
? "(" +
Object.keys(config?.detectors)
.map((detector) => capitalizeFirstLetter(detector))
.join(",") +
")"
: ""}{" "}
to detect objects in your camera's video stream.
</p>
<p>
Debugging view shows a real-time view of tracked objects and their
statistics. The object list shows a time-delayed summary of detected
objects.
{t("debug.detectorDesc", {
detectors: config
? Object.keys(config?.detectors)
.map((detector) => capitalizeFirstLetter(detector))
.join(",")
: "",
})}
</p>
<p>{t("debug.desc")}</p>
</div>
{config?.cameras[cameraConfig.name]?.webui_url && (
<div className="mb-5 text-sm text-muted-foreground">
@@ -217,8 +180,10 @@ export default function ObjectSettingsView({
<Tabs defaultValue="debug" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="debug">Debugging</TabsTrigger>
<TabsTrigger value="objectlist">Object List</TabsTrigger>
<TabsTrigger value="debug">{t("debug.debugging")}</TabsTrigger>
<TabsTrigger value="objectlist">
{t("debug.objectList")}
</TabsTrigger>
</TabsList>
<TabsContent value="debug">
<div className="flex w-full flex-col space-y-6">
@@ -277,21 +242,20 @@ export default function ObjectSettingsView({
className="mb-0 cursor-pointer capitalize text-primary"
htmlFor="debugdraw"
>
Object Shape Filter Drawing
{t("debug.objectShapeFilterDrawing.title")}
</Label>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">Info</span>
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
Enable this option to draw a rectangle on the
camera image to show its area and ratio. These
values can then be used to set object shape filter
parameters in your config.
{t("debug.objectShapeFilterDrawing.tips")}
<div className="mt-2 flex items-center text-primary">
<Link
to="https://docs.frigate.video/configuration/object_filters#object-shape"
@@ -299,7 +263,7 @@ export default function ObjectSettingsView({
rel="noopener noreferrer"
className="inline"
>
Read the documentation{" "}
{t("debug.objectShapeFilterDrawing.document")}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
@@ -307,8 +271,7 @@ export default function ObjectSettingsView({
</Popover>
</div>
<div className="mt-1 text-xs text-muted-foreground">
Draw a rectangle on the image to view area and ratio
details
{t("debug.objectShapeFilterDrawing.desc")}
</div>
</div>
<Switch
@@ -364,6 +327,7 @@ type ObjectListProps = {
};
function ObjectList({ cameraConfig, objects }: ObjectListProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config");
const colormap = useMemo(() => {
@@ -409,7 +373,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
<div className="text-md mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
Score
{t("debug.objectShapeFilterDrawing.score")}
</p>
{obj.score
? (obj.score * 100).toFixed(1).toString()
@@ -420,7 +384,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
<div className="text-md mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
Ratio
{t("debug.objectShapeFilterDrawing.ratio")}
</p>
{obj.ratio ? obj.ratio.toFixed(2).toString() : "-"}
</div>
@@ -428,7 +392,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
<div className="text-md mr-2 w-1/3">
<div className="flex flex-col items-end justify-end">
<p className="mb-1.5 text-sm text-primary-variant">
Area
{t("debug.objectShapeFilterDrawing.area")}
</p>
{obj.area ? (
<>
@@ -457,7 +421,7 @@ function ObjectList({ cameraConfig, objects }: ObjectListProps) {
);
})
) : (
<div className="p-3 text-center">No objects</div>
<div className="p-3 text-center">{t("debug.noObjects")}</div>
)}
</div>
);
+65 -57
View File
@@ -18,13 +18,14 @@ import {
SelectItem,
SelectTrigger,
} from "../../components/ui/select";
import { useTranslation } from "react-i18next";
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
const WEEK_STARTS_ON = ["Sunday", "Monday"];
export default function UiSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation("views/settings");
const clearStoredLayouts = useCallback(() => {
if (!config) {
return [];
@@ -33,21 +34,29 @@ export default function UiSettingsView() {
Object.entries(config.camera_groups).forEach(async (value) => {
await delData(`${value[0]}-draggable-layout`)
.then(() => {
toast.success(`Cleared stored layout for ${value[0]}`, {
position: "top-center",
});
toast.success(
t("general.toast.success.clearStoredLayout", {
cameraName: value[0],
}),
{
position: "top-center",
},
);
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to clear stored layout: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("general.toast.error.clearStoredLayoutFailed", { errorMessage }),
{
position: "top-center",
},
);
});
});
}, [config]);
}, [config, t]);
const clearStreamingSettings = useCallback(async () => {
if (!config) {
@@ -56,7 +65,7 @@ export default function UiSettingsView() {
await delData(`streaming-settings`)
.then(() => {
toast.success(`Cleared streaming settings for all camera groups.`, {
toast.success(t("general.toast.success.clearStreamingSettings"), {
position: "top-center",
});
})
@@ -65,15 +74,20 @@ export default function UiSettingsView() {
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(`Failed to clear streaming settings: ${errorMessage}`, {
position: "top-center",
});
toast.error(
t("general.toast.error.clearStreamingSettingsFailed", {
errorMessage,
}),
{
position: "top-center",
},
);
});
}, [config]);
}, [config, t]);
useEffect(() => {
document.title = "General Settings - Frigate";
}, []);
document.title = t("documentTitle.general");
}, [t]);
// settings
@@ -88,13 +102,13 @@ export default function UiSettingsView() {
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
<Heading as="h3" className="my-2">
General Settings
{t("general.title")}
</Heading>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Live Dashboard
{t("general.liveDashboard.title")}
</Heading>
<div className="mt-2 space-y-6">
@@ -106,19 +120,11 @@ export default function UiSettingsView() {
onCheckedChange={setAutoLive}
/>
<Label className="cursor-pointer" htmlFor="auto-live">
Automatic Live View
{t("general.liveDashboard.automaticLiveView.label")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
Automatically switch to a camera's live view when activity is
detected. Disabling this option causes static camera images on
the your dashboards to only update once per minute.{" "}
<em>
This is a global setting but can be overridden on each
camera <strong>in camera groups only</strong>.
</em>
</p>
<p>{t("general.liveDashboard.automaticLiveView.desc")}</p>
</div>
</div>
<div className="space-y-3">
@@ -129,15 +135,11 @@ export default function UiSettingsView() {
onCheckedChange={setAlertVideos}
/>
<Label className="cursor-pointer" htmlFor="images-only">
Play Alert Videos
{t("general.liveDashboard.playAlertVideos.label")}
</Label>
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
By default, recent alerts on the Live dashboard play as small
looping videos. Disable this option to only show a static
image of recent alerts on this device/browser.
</p>
<p>{t("general.liveDashboard.playAlertVideos.desc")}</p>
</div>
</div>
</div>
@@ -145,52 +147,53 @@ export default function UiSettingsView() {
<div className="my-3 flex w-full flex-col space-y-6">
<div className="mt-2 space-y-3">
<div className="space-y-0.5">
<div className="text-md">Stored Layouts</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
The layout of cameras in a camera group can be
dragged/resized. The positions are stored in your browser's
local storage.
</p>
<div className="text-md">
{t("general.storedLayouts.title")}
</div>
<div className="my-2 text-sm text-muted-foreground">
<p>{t("general.storedLayouts.desc")}</p>
</div>
</div>
<Button
aria-label="Clear all saved layouts"
aria-label={t("general.storedLayouts.clearAll")}
onClick={clearStoredLayouts}
>
Clear All Layouts
{t("general.storedLayouts.clearAll")}
</Button>
</div>
<div className="mt-2 space-y-3">
<div className="space-y-0.5">
<div className="text-md">Camera Group Streaming Settings</div>
<div className="text-md">
{t("general.cameraGroupStreaming.title")}
</div>
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
<p>
Streaming settings for each camera group are stored in your
browser's local storage.
</p>
<p>{t("general.cameraGroupStreaming.desc")}</p>
</div>
</div>
<Button
aria-label="Clear all group streaming settings"
aria-label={t("general.cameraGroupStreaming.clearAll")}
onClick={clearStreamingSettings}
>
Clear All Streaming Settings
{t("general.cameraGroupStreaming.clearAll")}
</Button>
</div>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Recordings Viewer
{t("general.recordingsViewer.title")}
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">Default Playback Rate</div>
<div className="text-md">
{t("general.recordingsViewer.defaultPlaybackRate.label")}
</div>
<div className="my-2 text-sm text-muted-foreground">
<p>Default playback rate for recordings playback.</p>
<p>
{t("general.recordingsViewer.defaultPlaybackRate.desc")}
</p>
</div>
</div>
</div>
@@ -218,14 +221,16 @@ export default function UiSettingsView() {
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
Calendar
{t("general.calendar.title")}
</Heading>
<div className="mt-2 space-y-6">
<div className="space-y-0.5">
<div className="text-md">First Weekday</div>
<div className="text-md">
{t("general.calendar.firstWeekday.label")}
</div>
<div className="my-2 text-sm text-muted-foreground">
<p>The day that the weeks of the review calendar begin on.</p>
<p>{t("general.calendar.firstWeekday.desc")}</p>
</div>
</div>
</div>
@@ -234,7 +239,10 @@ export default function UiSettingsView() {
onValueChange={(value) => setWeekStartsOn(parseInt(value))}
>
<SelectTrigger className="w-32">
{WEEK_STARTS_ON[weekStartsOn ?? 0]}
{t(
"general.calendar.firstWeekday." +
WEEK_STARTS_ON[weekStartsOn ?? 0].toLowerCase(),
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
@@ -244,7 +252,7 @@ export default function UiSettingsView() {
className="cursor-pointer"
value={index.toString()}
>
{day}
{t("general.calendar.firstWeekday." + day.toLowerCase())}
</SelectItem>
))}
</SelectGroup>
+12 -5
View File
@@ -12,6 +12,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
type CameraMetricsProps = {
lastUpdated: number;
@@ -22,7 +23,7 @@ export default function CameraMetrics({
setLastUpdated,
}: CameraMetricsProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const { t } = useTranslation(["views/system"]);
// camera info dialog
const [showCameraInfoDialog, setShowCameraInfoDialog] = useState(false);
@@ -223,11 +224,13 @@ export default function CameraMetrics({
return (
<div className="scrollbar-container mt-4 flex size-full flex-col gap-3 overflow-y-auto">
<div className="text-sm font-medium text-muted-foreground">Overview</div>
<div className="text-sm font-medium text-muted-foreground">
{t("cameras.overview")}
</div>
<div className="grid grid-cols-1 md:grid-cols-3">
{statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">Frames / Detections</div>
<div className="mb-5">{t("cameras.framesAndDetections")}</div>
<CameraLineGraph
graphId="overall-stats"
unit=""
@@ -269,7 +272,9 @@ export default function CameraMetrics({
}}
/>
</TooltipTrigger>
<TooltipContent>Camera Probe Info</TooltipContent>
<TooltipContent>
{t("cameras.info.tips.title")}
</TooltipContent>
</Tooltip>
</div>
<div
@@ -294,7 +299,9 @@ export default function CameraMetrics({
)}
{Object.keys(cameraFpsSeries).includes(camera.name) ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">Frames / Detections</div>
<div className="mb-5">
{t("cameras.framesAndDetections")}
</div>
<CameraLineGraph
graphId={`${camera.name}-dps`}
unit=""
+3 -1
View File
@@ -6,6 +6,7 @@ import { EmbeddingThreshold } from "@/types/graph";
import { Skeleton } from "@/components/ui/skeleton";
import { ThresholdBarGraph } from "@/components/graph/SystemGraph";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type FeatureMetricsProps = {
lastUpdated: number;
@@ -16,6 +17,7 @@ export default function FeatureMetrics({
setLastUpdated,
}: FeatureMetricsProps) {
// stats
const { t } = useTranslation(["views/system"]);
const { data: initialStats } = useSWR<FrigateStats[]>(
["stats/history", { keys: "embeddings,service" }],
@@ -87,7 +89,7 @@ export default function FeatureMetrics({
<>
<div className="scrollbar-container mt-4 flex size-full flex-col overflow-y-auto">
<div className="text-sm font-medium text-muted-foreground">
Features
{t("features.title")}
</div>
<div
className={cn(
+27 -14
View File
@@ -15,6 +15,7 @@ import GPUInfoDialog from "@/components/overlay/GPUInfoDialog";
import { Skeleton } from "@/components/ui/skeleton";
import { ThresholdBarGraph } from "@/components/graph/SystemGraph";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
type GeneralMetricsProps = {
lastUpdated: number;
@@ -25,7 +26,7 @@ export default function GeneralMetrics({
setLastUpdated,
}: GeneralMetricsProps) {
// extra info
const { t } = useTranslation(["views/system"]);
const [showVainfo, setShowVainfo] = useState(false);
// stats
@@ -448,7 +449,7 @@ export default function GeneralMetrics({
<div className="scrollbar-container mt-4 flex size-full flex-col overflow-y-auto">
<div className="text-sm font-medium text-muted-foreground">
Detectors
{t("general.detector.title")}
</div>
<div
className={cn(
@@ -458,7 +459,7 @@ export default function GeneralMetrics({
>
{statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">Detector Inference Speed</div>
<div className="mb-5">{t("general.detector.inferenceSpeed")}</div>
{detInferenceTimeSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
@@ -496,7 +497,7 @@ export default function GeneralMetrics({
)}
{statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">Detector CPU Usage</div>
<div className="mb-5">{t("general.detector.cpuUsage")}</div>
{detCpuSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
@@ -514,7 +515,7 @@ export default function GeneralMetrics({
)}
{statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">Detector Memory Usage</div>
<div className="mb-5">{t("general.detector.memoryUsage")}</div>
{detMemSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
@@ -541,11 +542,11 @@ export default function GeneralMetrics({
{canGetGpuInfo && (
<Button
className="cursor-pointer"
aria-label="Hardware information"
aria-label={t("general.hardwareInfo.title")}
size="sm"
onClick={() => setShowVainfo(true)}
>
Hardware Info
{t("general.hardwareInfo.title")}
</Button>
)}
</div>
@@ -557,7 +558,9 @@ export default function GeneralMetrics({
>
{statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">GPU Usage</div>
<div className="mb-5">
{t("general.hardwareInfo.gpuUsage")}
</div>
{gpuSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
@@ -577,7 +580,9 @@ export default function GeneralMetrics({
<>
{gpuMemSeries && (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">GPU Memory</div>
<div className="mb-5">
{t("general.hardwareInfo.gpuMemroy")}
</div>
{gpuMemSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
@@ -599,7 +604,9 @@ export default function GeneralMetrics({
<>
{gpuEncSeries && gpuEncSeries?.length != 0 && (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">GPU Encoder</div>
<div className="mb-5">
{t("general.hardwareInfo.gpuEncoder")}
</div>
{gpuEncSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
@@ -621,7 +628,9 @@ export default function GeneralMetrics({
<>
{gpuDecSeries && gpuDecSeries?.length != 0 && (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">GPU Decoder</div>
<div className="mb-5">
{t("general.hardwareInfo.gpuDecoder")}
</div>
{gpuDecSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
@@ -644,12 +653,14 @@ export default function GeneralMetrics({
)}
<div className="mt-4 text-sm font-medium text-muted-foreground">
Other Processes
{t("general.otherProcesses.title")}
</div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2">
{statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">Process CPU Usage</div>
<div className="mb-5">
{t("general.otherProcesses.processCpuUsage")}
</div>
{otherProcessCpuSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
@@ -667,7 +678,9 @@ export default function GeneralMetrics({
)}
{statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">Process Memory Usage</div>
<div className="mb-5">
{t("general.otherProcesses.processMemoryUsage")}
</div>
{otherProcessMemSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
+17 -12
View File
@@ -13,6 +13,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { useTimezone } from "@/hooks/use-date-utils";
import { RecordingsSummary } from "@/types/review";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { useTranslation } from "react-i18next";
type CameraStorage = {
[key: string]: {
@@ -33,7 +34,7 @@ export default function StorageMetrics({
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const { t } = useTranslation(["views/system"]);
const timezone = useTimezone(config);
const totalStorage = useMemo(() => {
@@ -75,29 +76,31 @@ export default function StorageMetrics({
return (
<div className="scrollbar-container mt-4 flex size-full flex-col overflow-y-auto">
<div className="text-sm font-medium text-muted-foreground">Overview</div>
<div className="text-sm font-medium text-muted-foreground">
{t("storage.overview")}
</div>
<div className="mt-4 grid grid-cols-1 gap-2 sm:grid-cols-3">
<div className="flex-col rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5 flex flex-row items-center justify-between">
Recordings
{t("storage.recordings.title")}
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label="Unused Storage Information"
aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
>
<CiCircleAlert
className="size-5"
aria-label="Unused Storage Information"
aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
/>
</button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
This value represents the total storage used by the recordings
in Frigate's database. Frigate does not track storage usage
for all files on your disk.
</div>
<div className="space-y-2">{t("storage.recordings.tips")}</div>
</PopoverContent>
</Popover>
</div>
@@ -108,7 +111,9 @@ export default function StorageMetrics({
/>
{earliestDate && (
<div className="mt-2 text-xs text-primary-variant">
<span className="font-medium">Earliest recording available:</span>{" "}
<span className="font-medium">
{t("storage.recordings.earliestRecording")}
</span>{" "}
{formatUnixTimestampToDateTime(earliestDate, {
timezone: timezone,
strftime_fmt:
@@ -135,7 +140,7 @@ export default function StorageMetrics({
</div>
</div>
<div className="mt-4 text-sm font-medium text-muted-foreground">
Camera Storage
{t("storage.cameraStorage.title")}
</div>
<div className="mt-4 bg-background_alt p-2.5 md:rounded-2xl">
<CombinedStorageGraph