mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 03:41:55 +03:00
Miscellaneous fixes (#23295)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* filter motion review by allowed cameras * filter alertCameras by allowed cameras so the recent alerts query for restricted roles doesn't reference cameras they can't access * skip data streams in chapter exports to avoid ffmpeg segfault * formatting * restrict debug replay UI entry points to admin users * Adjust default iGPU name when it can't be found * Fix when model tries to request an invalid camera * Improve prompt * add collapsible main nav items in settings --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
parent
d556ff8df2
commit
ec44398b1c
@ -547,9 +547,21 @@ async def _execute_get_live_context(
|
||||
camera: str,
|
||||
allowed_cameras: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
# Reject wildcards explicitly so models retry with a real camera name
|
||||
# instead of silently fanning out across every camera.
|
||||
if camera in ("*", "all"):
|
||||
return {
|
||||
"error": (
|
||||
"get_live_context requires a single camera name; wildcards "
|
||||
"are not supported. Call this tool once per camera."
|
||||
),
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
|
||||
if camera not in allowed_cameras:
|
||||
return {
|
||||
"error": f"Camera '{camera}' not found or access denied",
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
|
||||
if camera not in request.app.frigate_config.cameras:
|
||||
@ -721,7 +733,14 @@ async def _execute_tool_internal(
|
||||
"Arguments: %s",
|
||||
json.dumps(arguments),
|
||||
)
|
||||
return {"error": "Camera parameter is required"}
|
||||
return {
|
||||
"error": (
|
||||
"get_live_context requires a single camera name; "
|
||||
"wildcards and empty values are not supported. "
|
||||
"Call this tool once per camera."
|
||||
),
|
||||
"available_cameras": allowed_cameras,
|
||||
}
|
||||
return await _execute_get_live_context(request, camera, allowed_cameras)
|
||||
elif tool_name == "start_camera_watch":
|
||||
return await _execute_start_camera_watch(request, arguments)
|
||||
|
||||
@ -518,16 +518,21 @@ def get_tool_definitions(
|
||||
"function": {
|
||||
"name": "get_live_context",
|
||||
"description": (
|
||||
"Get the current live image and detection information for a camera: objects being tracked, "
|
||||
"Get the current live image and detection information for a single camera: objects being tracked, "
|
||||
"zones, timestamps. Use this to understand what is visible in the live view. "
|
||||
"Call this when answering questions about what is happening right now on a specific camera. "
|
||||
"Operates on one camera at a time; call the tool again for each additional camera. "
|
||||
"Wildcards and empty values are not accepted."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"camera": {
|
||||
"type": "string",
|
||||
"description": "Camera name to get live context for.",
|
||||
"description": (
|
||||
"Exact name of a single camera to get live context for. "
|
||||
"Wildcards (e.g. '*', 'all') and empty strings are not accepted."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["camera"],
|
||||
|
||||
@ -579,7 +579,9 @@ class RecordingExporter(threading.Thread):
|
||||
else:
|
||||
chapters_path = self._build_chapter_metadata_file(recordings)
|
||||
chapter_args = (
|
||||
f" -i {chapters_path} -map 0 -map_metadata 1" if chapters_path else ""
|
||||
f" -i {chapters_path} -map 0 -dn -map_metadata 1"
|
||||
if chapters_path
|
||||
else ""
|
||||
)
|
||||
ffmpeg_cmd = (
|
||||
f"{self.config.ffmpeg.ffmpeg_path} -hide_banner {ffmpeg_input}{chapter_args} -c copy -movflags +faststart"
|
||||
|
||||
@ -478,7 +478,7 @@ def get_intel_gpu_stats(
|
||||
overall_pct = min(100.0, compute_pct + dec_pct)
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"name": names.get(pdev) or f"Intel GPU {pdev}",
|
||||
"name": names.get(pdev) or "Intel iGPU",
|
||||
"vendor": "intel",
|
||||
"gpu": f"{round(overall_pct, 2)}%",
|
||||
"mem": "-%",
|
||||
|
||||
@ -130,9 +130,15 @@ export default function SearchResultActions({
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@ -206,7 +212,7 @@ export default function SearchResultActions({
|
||||
<span>{t("itemMenu.addTrigger.label")}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{searchResult.has_clip && (
|
||||
{isAdmin && searchResult.has_clip && (
|
||||
<MenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("itemMenu.debugReplay.aria")}
|
||||
|
||||
@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaFilm } from "react-icons/fa6";
|
||||
|
||||
type ActionsDropdownProps = {
|
||||
onDebugReplayClick: () => void;
|
||||
onDebugReplayClick?: () => void;
|
||||
onExportClick: () => void;
|
||||
onShareTimestampClick: () => void;
|
||||
};
|
||||
@ -42,9 +42,11 @@ export default function ActionsDropdown({
|
||||
<DropdownMenuItem onClick={onShareTimestampClick}>
|
||||
{t("recording.shareTimestamp.label", { ns: "components/dialog" })}
|
||||
</DropdownMenuItem>
|
||||
{onDebugReplayClick && (
|
||||
<DropdownMenuItem onClick={onDebugReplayClick}>
|
||||
{t("title", { ns: "views/replay" })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@ -29,6 +29,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { StartExportResponse } from "@/types/export";
|
||||
import { ShareTimestampContent } from "./ShareTimestampDialog";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
type DrawerMode =
|
||||
| "none"
|
||||
@ -109,6 +110,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
"views/replay",
|
||||
"common",
|
||||
]);
|
||||
const isAdmin = useIsAdmin();
|
||||
const navigate = useNavigate();
|
||||
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
|
||||
const [exportTab, setExportTab] = useState<ExportTab>("export");
|
||||
@ -388,7 +390,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
{t("filter")}
|
||||
</Button>
|
||||
)}
|
||||
{features.includes("debug-replay") && (
|
||||
{isAdmin && features.includes("debug-replay") && (
|
||||
<Button
|
||||
className="flex w-full items-center justify-center gap-2"
|
||||
aria-label={t("title", { ns: "views/replay" })}
|
||||
|
||||
@ -95,9 +95,15 @@ export default function DetailActionsMenu({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@ -229,7 +235,7 @@ export default function DetailActionsMenu({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{search.has_clip && (
|
||||
{isAdmin && search.has_clip && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("itemMenu.debugReplay.aria")}
|
||||
|
||||
@ -94,9 +94,15 @@ export default function EventMenu({
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(t("dialog.toast.error", { error: errorMessage }), {
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@ -177,7 +183,7 @@ export default function EventMenu({
|
||||
{t("itemMenu.findSimilar.label")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{event.has_clip && (
|
||||
{isAdmin && event.has_clip && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
disabled={isStarting}
|
||||
|
||||
@ -16,6 +16,11 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
useCallback,
|
||||
@ -589,7 +594,7 @@ function MobileMenuItem({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-10 w-full cursor-pointer items-center justify-between whitespace-nowrap rounded-md px-4 py-2 pr-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex h-10 w-full cursor-pointer items-center whitespace-nowrap rounded-md px-4 py-2 text-sm font-medium text-primary-variant disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
@ -600,7 +605,6 @@ function MobileMenuItem({
|
||||
<div className="w-full">
|
||||
{label ?? <div>{t("menu." + item.key)}</div>}
|
||||
</div>
|
||||
<LuChevronRight className="size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -613,6 +617,39 @@ export default function Settings() {
|
||||
const [sectionStatusByKey, setSectionStatusByKey] = useState<
|
||||
Partial<Record<SettingsType, SectionStatus>>
|
||||
>({});
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
|
||||
() =>
|
||||
// all collapsed by default
|
||||
new Set(
|
||||
settingsGroups.filter((g) => g.items.length > 1).map((g) => g.label),
|
||||
),
|
||||
);
|
||||
|
||||
const toggleGroupCollapsed = useCallback((label: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) {
|
||||
next.delete(label);
|
||||
} else {
|
||||
next.add(label);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-expand the group containing the active page whenever pageToggle changes
|
||||
useEffect(() => {
|
||||
const containingGroup = settingsGroups.find((group) =>
|
||||
group.items.some((item) => item.key === pageToggle),
|
||||
);
|
||||
if (!containingGroup) return;
|
||||
setCollapsedGroups((prev) => {
|
||||
if (!prev.has(containingGroup.label)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(containingGroup.label);
|
||||
return next;
|
||||
});
|
||||
}, [pageToggle]);
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
@ -1611,14 +1648,10 @@ export default function Settings() {
|
||||
visibleSettingsViews.includes(item.key as SettingsType),
|
||||
);
|
||||
if (filteredItems.length === 0) return null;
|
||||
return (
|
||||
<div key={group.label} className="mb-3">
|
||||
{filteredItems.length > 1 && (
|
||||
<h3 className="mb-2 ml-2 text-sm font-medium text-secondary-foreground">
|
||||
<div>{t("menu." + group.label)}</div>
|
||||
</h3>
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
const isMultiItem = filteredItems.length > 1;
|
||||
const renderedExpanded =
|
||||
!isMultiItem || !collapsedGroups.has(group.label);
|
||||
const items = filteredItems.map((item) => (
|
||||
<MobileMenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
@ -1627,9 +1660,7 @@ export default function Settings() {
|
||||
onSelect={(key) => {
|
||||
if (
|
||||
!isAdmin &&
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(
|
||||
key as SettingsType,
|
||||
)
|
||||
!ALLOWED_VIEWS_FOR_VIEWER.includes(key as SettingsType)
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
@ -1638,7 +1669,28 @@ export default function Settings() {
|
||||
setContentMobileOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
));
|
||||
return (
|
||||
<div key={group.label} className="mb-3">
|
||||
{isMultiItem ? (
|
||||
<Collapsible
|
||||
open={renderedExpanded}
|
||||
onOpenChange={() => toggleGroupCollapsed(group.label)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex min-h-10 w-full items-center justify-between rounded-md py-2 pl-2 pr-2 text-sm font-medium text-secondary-foreground">
|
||||
<div>{t("menu." + group.label)}</div>
|
||||
<LuChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-200",
|
||||
renderedExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{items}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : (
|
||||
items
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -1940,20 +1992,41 @@ export default function Settings() {
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
) : (
|
||||
<>
|
||||
<SidebarGroupLabel
|
||||
className={cn(
|
||||
"ml-2 cursor-default pl-0 text-sm",
|
||||
filteredItems.some(
|
||||
(() => {
|
||||
const hasActiveItem = filteredItems.some(
|
||||
(item) => pageToggle === item.key,
|
||||
)
|
||||
);
|
||||
const renderedExpanded = !collapsedGroups.has(
|
||||
group.label,
|
||||
);
|
||||
return (
|
||||
<Collapsible
|
||||
open={renderedExpanded}
|
||||
onOpenChange={() =>
|
||||
toggleGroupCollapsed(group.label)
|
||||
}
|
||||
>
|
||||
<SidebarGroupLabel
|
||||
asChild
|
||||
className={cn(
|
||||
"ml-2 pl-0 text-sm",
|
||||
hasActiveItem
|
||||
? "text-primary"
|
||||
: "text-sidebar-foreground/80",
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
<div>{t("menu." + group.label)}</div>
|
||||
<LuChevronRight
|
||||
className={cn(
|
||||
"size-4 shrink-0 transition-transform duration-200",
|
||||
renderedExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenuSub className="mx-2 border-0">
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub className="mx-2 border-0 md:mx-0">
|
||||
{filteredItems.map((item) => (
|
||||
<SidebarMenuSubItem key={item.key}>
|
||||
<SidebarMenuSubButton
|
||||
@ -1968,7 +2041,9 @@ export default function Settings() {
|
||||
) {
|
||||
setPageToggle("uiSettings");
|
||||
} else {
|
||||
setPageToggle(item.key as SettingsType);
|
||||
setPageToggle(
|
||||
item.key as SettingsType,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -1981,7 +2056,10 @@ export default function Settings() {
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
@ -66,6 +66,7 @@ import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
import VideoControls from "@/components/player/VideoControls";
|
||||
import { TimeRange } from "@/types/timeline";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import {
|
||||
useCameraMotionNextTimestamp,
|
||||
useCameraMotionOnlyRanges,
|
||||
@ -1008,27 +1009,29 @@ function MotionReview({
|
||||
const { t } = useTranslation(["views/events", "common"]);
|
||||
const segmentDuration = 30;
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const allowedCameras = useAllowedCameras();
|
||||
|
||||
const reviewCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let cameras;
|
||||
if (!filter || !filter.cameras) {
|
||||
cameras = Object.values(config.cameras).filter(
|
||||
(cam) => !isReplayCamera(cam.name),
|
||||
);
|
||||
} else {
|
||||
const filteredCams = filter.cameras;
|
||||
|
||||
cameras = Object.values(config.cameras).filter(
|
||||
(cam) => filteredCams.includes(cam.name) && !isReplayCamera(cam.name),
|
||||
);
|
||||
const selectedCams = filter?.cameras;
|
||||
const cameras = Object.values(config.cameras).filter((cam) => {
|
||||
if (isReplayCamera(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
if (!allowedCameras.includes(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
if (selectedCams && !selectedCams.includes(cam.name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return cameras.sort((a, b) => a.ui.order - b.ui.order);
|
||||
}, [config, filter]);
|
||||
}, [config, filter, allowedCameras]);
|
||||
|
||||
const videoPlayersRef = useRef<{ [camera: string]: PreviewController }>({});
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import {
|
||||
AllGroupsStreamingSettings,
|
||||
@ -90,6 +91,7 @@ export default function LiveDashboardView({
|
||||
// recent events
|
||||
|
||||
const eventUpdate = useFrigateReviews();
|
||||
const allowedCameras = useAllowedCameras();
|
||||
|
||||
const alertCameras = useMemo(() => {
|
||||
if (!config) {
|
||||
@ -98,14 +100,16 @@ export default function LiveDashboardView({
|
||||
|
||||
if (cameraGroup == "default") {
|
||||
return Object.values(config.cameras)
|
||||
.filter((cam) => cam.ui.dashboard)
|
||||
.filter((cam) => cam.ui.dashboard && allowedCameras.includes(cam.name))
|
||||
.map((cam) => cam.name)
|
||||
.join(",");
|
||||
}
|
||||
|
||||
if (includeBirdseye && cameras.length == 0) {
|
||||
return Object.values(config.cameras)
|
||||
.filter((cam) => cam.birdseye.enabled)
|
||||
.filter(
|
||||
(cam) => cam.birdseye.enabled && allowedCameras.includes(cam.name),
|
||||
)
|
||||
.map((cam) => cam.name)
|
||||
.join(",");
|
||||
}
|
||||
@ -114,7 +118,7 @@ export default function LiveDashboardView({
|
||||
.map((cam) => cam.name)
|
||||
.filter((cam) => config.camera_groups[cameraGroup]?.cameras.includes(cam))
|
||||
.join(",");
|
||||
}, [cameras, cameraGroup, config, includeBirdseye]);
|
||||
}, [cameras, cameraGroup, config, includeBirdseye, allowedCameras]);
|
||||
|
||||
const { data: allEvents, mutate: updateEvents } = useSWR<ReviewSegment[]>([
|
||||
"review",
|
||||
|
||||
@ -44,6 +44,7 @@ import {
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import useSWR from "swr";
|
||||
import { TimeRange, TimelineType } from "@/types/timeline";
|
||||
import MobileCameraDrawer from "@/components/overlay/MobileCameraDrawer";
|
||||
@ -109,6 +110,7 @@ export function RecordingView({
|
||||
}: RecordingViewProps) {
|
||||
const { t } = useTranslation(["views/events", "components/dialog"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const isAdmin = useIsAdmin();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
@ -723,13 +725,17 @@ export function RecordingView({
|
||||
setCustomShareTimestamp(initialTimestamp);
|
||||
setShareTimestampOpen(true);
|
||||
}}
|
||||
onDebugReplayClick={() => {
|
||||
onDebugReplayClick={
|
||||
isAdmin
|
||||
? () => {
|
||||
setDebugReplayRange({
|
||||
after: timeRange.before - 60,
|
||||
before: timeRange.before,
|
||||
});
|
||||
setDebugReplayMode("select");
|
||||
}}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onExportClick={() => {
|
||||
const now = new Date(timeRange.before * 1000);
|
||||
now.setHours(now.getHours() - 1);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user