Miscellaneous Fixes (#21208)

* conditionally display actions for admin role only

* only allow admins to save annotation offset

* Fix classification reset filter

* fix explore context menu from blocking pointer events on the body element after dialog close

applying modal=false to the menu (not to the dialog) to fix this in the same way as elsewhere in the codebase

* add select all link to face library, classification, and explore

* Disable iOS image dragging for classification card

* add proxmox ballooning comment

* lpr docs tweaks

* yaml list

* clarify tls_insecure

* Improve security summary format and usefulness

---------

Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
Josh Hawkins
2025-12-11 07:23:34 -07:00
committed by GitHub
co-authored by Nicolas Mowen
parent 9cdc10008d
commit fa6dda6735
18 changed files with 272 additions and 182 deletions
+10 -1
View File
@@ -7,7 +7,7 @@ import {
} from "@/types/classification";
import { Event } from "@/types/event";
import { forwardRef, useMemo, useRef, useState } from "react";
import { isDesktop, isMobile, isMobileOnly } from "react-device-detect";
import { isDesktop, isIOS, isMobile, isMobileOnly } from "react-device-detect";
import { useTranslation } from "react-i18next";
import TimeAgo from "../dynamic/TimeAgo";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
@@ -127,6 +127,15 @@ export const ClassificationCard = forwardRef<
imgClassName,
isMobile && "w-full",
)}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
loading="lazy"
onLoad={() => setImageLoaded(true)}
src={`${baseUrl}${data.filepath}`}
+19 -15
View File
@@ -19,6 +19,7 @@ import {
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useIsAdmin } from "@/hooks/use-is-admin";
type ReviewActionGroupProps = {
selectedReviews: ReviewSegment[];
@@ -33,6 +34,7 @@ export default function ReviewActionGroup({
pullLatestData,
}: ReviewActionGroupProps) {
const { t } = useTranslation(["components/dialog"]);
const isAdmin = useIsAdmin();
const onClearSelected = useCallback(() => {
setSelectedReviews([]);
}, [setSelectedReviews]);
@@ -185,21 +187,23 @@ export default function ReviewActionGroup({
</div>
)}
</Button>
<Button
className="flex items-center gap-2 p-2"
aria-label={t("button.delete", { ns: "common" })}
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div>
)}
</Button>
{isAdmin && (
<Button
className="flex items-center gap-2 p-2"
aria-label={t("button.delete", { ns: "common" })}
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog
? t("recording.button.deleteNow")
: t("button.delete", { ns: "common" })}
</div>
)}
</Button>
)}
</div>
</div>
</>
+35 -16
View File
@@ -16,18 +16,24 @@ import {
import useKeyboardListener from "@/hooks/use-keyboard-listener";
import { toast } from "sonner";
import { Trans, useTranslation } from "react-i18next";
import { useIsAdmin } from "@/hooks/use-is-admin";
type SearchActionGroupProps = {
selectedObjects: string[];
setSelectedObjects: (ids: string[]) => void;
pullLatestData: () => void;
onSelectAllObjects: () => void;
totalItems: number;
};
export default function SearchActionGroup({
selectedObjects,
setSelectedObjects,
pullLatestData,
onSelectAllObjects,
totalItems,
}: SearchActionGroupProps) {
const { t } = useTranslation(["components/filter"]);
const isAdmin = useIsAdmin();
const onClearSelected = useCallback(() => {
setSelectedObjects([]);
}, [setSelectedObjects]);
@@ -122,24 +128,37 @@ export default function SearchActionGroup({
>
{t("button.unselect", { ns: "common" })}
</div>
</div>
<div className="flex items-center gap-1 md:gap-2">
<Button
className="flex items-center gap-2 p-2"
aria-label={t("button.delete", { ns: "common" })}
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
{selectedObjects.length < totalItems && (
<>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={onSelectAllObjects}
>
{t("select_all", { ns: "views/events" })}
</div>
)}
</Button>
</>
)}
</div>
{isAdmin && (
<div className="flex items-center gap-1 md:gap-2">
<Button
className="flex items-center gap-2 p-2"
aria-label={t("button.delete", { ns: "common" })}
size="sm"
onClick={handleDelete}
>
<HiTrash className="text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{bypassDialog
? t("button.deleteNow", { ns: "common" })
: t("button.delete", { ns: "common" })}
</div>
)}
</Button>
</div>
)}
</div>
</>
);
@@ -31,6 +31,7 @@ import {
import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next";
import BlurredIconButton from "../button/BlurredIconButton";
import { useIsAdmin } from "@/hooks/use-is-admin";
type SearchResultActionsProps = {
searchResult: SearchResult;
@@ -52,6 +53,7 @@ export default function SearchResultActions({
children,
}: SearchResultActionsProps) {
const { t } = useTranslation(["views/explore"]);
const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config");
@@ -137,7 +139,8 @@ export default function SearchResultActions({
<span>{t("itemMenu.findSimilar.label")}</span>
</MenuItem>
)}
{config?.semantic_search?.enabled &&
{isAdmin &&
config?.semantic_search?.enabled &&
searchResult.data.type == "object" && (
<MenuItem
aria-label={t("itemMenu.addTrigger.aria")}
@@ -146,12 +149,14 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem>
)}
<MenuItem
aria-label={t("itemMenu.deleteTrackedObject.label")}
onClick={() => setDeleteDialogOpen(true)}
>
<span>{t("button.delete", { ns: "common" })}</span>
</MenuItem>
{isAdmin && (
<MenuItem
aria-label={t("itemMenu.deleteTrackedObject.label")}
onClick={() => setDeleteDialogOpen(true)}
>
<span>{t("button.delete", { ns: "common" })}</span>
</MenuItem>
)}
</>
);
@@ -184,7 +189,7 @@ export default function SearchResultActions({
</AlertDialogContent>
</AlertDialog>
{isContextMenu ? (
<ContextMenu>
<ContextMenu modal={false}>
<ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>{menuItems}</ContextMenuContent>
</ContextMenu>
@@ -10,6 +10,7 @@ import { Trans, useTranslation } from "react-i18next";
import { LuInfo } from "react-icons/lu";
import { cn } from "@/lib/utils";
import { isMobile } from "react-device-detect";
import { useIsAdmin } from "@/hooks/use-is-admin";
type Props = {
className?: string;
@@ -17,6 +18,7 @@ type Props = {
export default function AnnotationOffsetSlider({ className }: Props) {
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
const isAdmin = useIsAdmin();
const { mutate } = useSWRConfig();
const { t } = useTranslation(["views/explore"]);
const [isSaving, setIsSaving] = useState(false);
@@ -101,11 +103,13 @@ export default function AnnotationOffsetSlider({ className }: Props) {
<Button size="sm" variant="ghost" onClick={reset}>
{t("button.reset", { ns: "common" })}
</Button>
<Button size="sm" onClick={save} disabled={isSaving}>
{isSaving
? t("button.saving", { ns: "common" })
: t("button.save", { ns: "common" })}
</Button>
{isAdmin && (
<Button size="sm" onClick={save} disabled={isSaving}>
{isSaving
? t("button.saving", { ns: "common" })
: t("button.save", { ns: "common" })}
</Button>
)}
</div>
</div>
<div
@@ -24,6 +24,7 @@ import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Trans, useTranslation } from "react-i18next";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useIsAdmin } from "@/hooks/use-is-admin";
type AnnotationSettingsPaneProps = {
event: Event;
@@ -36,6 +37,7 @@ export function AnnotationSettingsPane({
setAnnotationOffset,
}: AnnotationSettingsPaneProps) {
const { t } = useTranslation(["views/explore"]);
const isAdmin = useIsAdmin();
const { getLocaleDocUrl } = useDocDomain();
const { data: config, mutate: updateConfig } =
@@ -201,22 +203,24 @@ export function AnnotationSettingsPane({
>
{t("button.apply", { ns: "common" })}
</Button>
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
{isAdmin && (
<Button
variant="select"
aria-label={t("button.save", { ns: "common" })}
disabled={isLoading}
className="flex flex-1"
type="submit"
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
)}
</div>
</div>
</form>
@@ -15,6 +15,7 @@ import {
import { HiDotsHorizontal } from "react-icons/hi";
import { SearchResult } from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig";
import { useIsAdmin } from "@/hooks/use-is-admin";
type Props = {
search: SearchResult | Event;
@@ -35,6 +36,7 @@ export default function DetailActionsMenu({
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const isAdmin = useIsAdmin();
const clipTimeRange = useMemo(() => {
const startTime = (search.start_time ?? 0) - REVIEW_PADDING;
@@ -130,22 +132,24 @@ export default function DetailActionsMenu({
</DropdownMenuItem>
)}
{config?.semantic_search.enabled && search.data.type == "object" && (
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
setTimeout(() => {
navigate(
`/settings?page=triggers&camera=${search.camera}&event_id=${search.id}`,
);
}, 0);
}}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.addTrigger.label")}</span>
</div>
</DropdownMenuItem>
)}
{isAdmin &&
config?.semantic_search.enabled &&
search.data.type == "object" && (
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
setTimeout(() => {
navigate(
`/settings?page=triggers&camera=${search.camera}&event_id=${search.id}`,
);
}, 0);
}}
>
<div className="flex cursor-pointer items-center gap-2">
<span>{t("itemMenu.addTrigger.label")}</span>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
@@ -97,20 +97,9 @@ export default function TrainFilterDialog({
<Button
aria-label={t("reset.label")}
onClick={() => {
setCurrentFilter((prevFilter) => ({
...prevFilter,
time_range: undefined,
zones: undefined,
sub_labels: undefined,
search_type: undefined,
min_score: undefined,
max_score: undefined,
min_speed: undefined,
max_speed: undefined,
has_snapshot: undefined,
has_clip: undefined,
recognized_license_plate: undefined,
}));
const resetFilter: TrainFilter = {};
setCurrentFilter(resetFilter);
onUpdateFilter(resetFilter);
}}
>
{t("button.reset", { ns: "common" })}
+34 -4
View File
@@ -52,7 +52,7 @@ import {
useRef,
useState,
} from "react";
import { isDesktop } from "react-device-detect";
import { isDesktop, isMobileOnly } from "react-device-detect";
import { Trans, useTranslation } from "react-i18next";
import {
LuFolderCheck,
@@ -370,10 +370,10 @@ export default function FaceLibrary() {
/>
{selectedFaces?.length > 0 ? (
<div className="flex items-center justify-center gap-2">
<div className="mx-1 flex w-48 items-center justify-center text-sm text-muted-foreground">
<div className="mx-1 flex w-auto items-center justify-center text-sm text-muted-foreground">
<div className="p-1">
{t("selected", {
ns: "views/event",
ns: "views/events",
count: selectedFaces.length,
})}
</div>
@@ -384,6 +384,24 @@ export default function FaceLibrary() {
>
{t("button.unselect", { ns: "common" })}
</div>
{selectedFaces.length <
(pageToggle === "train"
? trainImages.length
: faceImages.length) && (
<>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={() =>
setSelectedFaces([
...(pageToggle === "train" ? trainImages : faceImages),
])
}
>
{t("select_all", { ns: "views/events" })}
</div>
</>
)}
</div>
<Button
className="flex gap-2"
@@ -482,6 +500,18 @@ function LibrarySelector({
[renameFace],
);
const pageTitle = useMemo(() => {
if (pageToggle != "train") {
return pageToggle;
}
if (isMobileOnly) {
return t("train.titleShort");
}
return t("train.title");
}, [pageToggle, t]);
return (
<>
<Dialog
@@ -532,7 +562,7 @@ function LibrarySelector({
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button className="flex justify-between smart-capitalize">
{pageToggle == "train" ? t("train.title") : pageToggle}
{pageTitle}
<span className="ml-2 text-primary-variant">
({(pageToggle && faceData?.[pageToggle]?.length) || 0})
</span>
@@ -421,10 +421,10 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
isMobileOnly && "justify-between",
)}
>
<div className="flex w-48 items-center justify-center text-sm text-muted-foreground">
<div className="flex w-auto items-center justify-center text-sm text-muted-foreground md:w-auto">
<div className="p-1">
{t("selected", {
ns: "views/event",
ns: "views/events",
count: selectedImages.length,
})}
</div>
@@ -435,6 +435,26 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
>
{t("button.unselect", { ns: "common" })}
</div>
{selectedImages.length <
(pageToggle === "train"
? trainImages?.length || 0
: dataset?.[pageToggle]?.length || 0) && (
<>
<div className="p-1">{"|"}</div>
<div
className="cursor-pointer p-2 text-primary hover:rounded-lg hover:bg-secondary"
onClick={() =>
setSelectedImages([
...(pageToggle === "train"
? trainImages || []
: dataset?.[pageToggle] || []),
])
}
>
{t("select_all", { ns: "views/events" })}
</div>
</>
)}
</div>
<Button
className="flex gap-2"
+2
View File
@@ -572,6 +572,8 @@ export default function SearchView({
selectedObjects={selectedObjects}
setSelectedObjects={setSelectedObjects}
pullLatestData={refresh}
onSelectAllObjects={onSelectAllObjects}
totalItems={uniqueResults.length}
/>
</div>
)}