mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-20 20:16:42 +03:00
Compare commits
4 Commits
8883db7b65
...
1507614a9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1507614a9e | ||
|
|
332487d711 | ||
|
|
de066d0062 | ||
|
|
f1a05d0f9b |
@ -255,6 +255,7 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
def __init__(self, model_path: str, device: str, model_type: str, **kwargs):
|
||||
self.model_path = model_path
|
||||
self.device = device
|
||||
self.model_type = model_type
|
||||
|
||||
if device == "NPU" and not OpenVINOModelRunner.is_model_npu_supported(
|
||||
model_type
|
||||
@ -341,6 +342,13 @@ class OpenVINOModelRunner(BaseModelRunner):
|
||||
# Lock prevents concurrent access to infer_request
|
||||
# Needed for JinaV2: genai thread (text) + embeddings thread (vision)
|
||||
with self._inference_lock:
|
||||
from frigate.embeddings.types import EnrichmentModelTypeEnum
|
||||
|
||||
if self.model_type in [EnrichmentModelTypeEnum.arcface.value]:
|
||||
# For face recognition models, create a fresh infer_request
|
||||
# for each inference to avoid state pollution that causes incorrect results.
|
||||
self.infer_request = self.compiled_model.create_infer_request()
|
||||
|
||||
# Handle single input case for backward compatibility
|
||||
if (
|
||||
len(inputs) == 1
|
||||
|
||||
@ -72,7 +72,10 @@
|
||||
"formattedTimestampFilename": {
|
||||
"12hour": "MM-dd-yy-h-mm-ss-a",
|
||||
"24hour": "MM-dd-yy-HH-mm-ss"
|
||||
}
|
||||
},
|
||||
"inProgress": "In progress",
|
||||
"invalidStartTime": "Invalid start time",
|
||||
"invalidEndTime": "Invalid end time"
|
||||
},
|
||||
"unit": {
|
||||
"speed": {
|
||||
@ -144,7 +147,8 @@
|
||||
"unselect": "Unselect",
|
||||
"export": "Export",
|
||||
"deleteNow": "Delete Now",
|
||||
"next": "Next"
|
||||
"next": "Next",
|
||||
"continue": "Continue"
|
||||
},
|
||||
"menu": {
|
||||
"system": "System",
|
||||
@ -237,6 +241,7 @@
|
||||
"export": "Export",
|
||||
"uiPlayground": "UI Playground",
|
||||
"faceLibrary": "Face Library",
|
||||
"classification": "Classification",
|
||||
"user": {
|
||||
"title": "User",
|
||||
"account": "Account",
|
||||
|
||||
@ -24,8 +24,8 @@
|
||||
"label": "Detail",
|
||||
"noDataFound": "No detail data to review",
|
||||
"aria": "Toggle detail view",
|
||||
"trackedObject_one": "object",
|
||||
"trackedObject_other": "objects",
|
||||
"trackedObject_one": "{{count}} object",
|
||||
"trackedObject_other": "{{count}} objects",
|
||||
"noObjectDetailData": "No object detail data available.",
|
||||
"settings": "Detail View Settings",
|
||||
"alwaysExpandActive": {
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
"snapshot": "snapshot",
|
||||
"thumbnail": "thumbnail",
|
||||
"video": "video",
|
||||
"object_lifecycle": "object lifecycle"
|
||||
"tracking_details": "tracking details"
|
||||
},
|
||||
"trackingDetails": {
|
||||
"title": "Tracking Details",
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
"masksAndZones": "Mask and Zone Editor - Frigate",
|
||||
"motionTuner": "Motion Tuner - Frigate",
|
||||
"object": "Debug - Frigate",
|
||||
"general": "General Settings - Frigate",
|
||||
"general": "UI Settings - Frigate",
|
||||
"frigatePlus": "Frigate+ Settings - Frigate",
|
||||
"notifications": "Notification Settings - Frigate"
|
||||
},
|
||||
@ -37,7 +37,7 @@
|
||||
"noCamera": "No Camera"
|
||||
},
|
||||
"general": {
|
||||
"title": "General Settings",
|
||||
"title": "UI Settings",
|
||||
"liveDashboard": {
|
||||
"title": "Live Dashboard",
|
||||
"automaticLiveView": {
|
||||
@ -51,6 +51,10 @@
|
||||
"displayCameraNames": {
|
||||
"label": "Always Show Camera Names",
|
||||
"desc": "Always show the camera names in a chip in the multi-camera live view dashboard."
|
||||
},
|
||||
"liveFallbackTimeout": {
|
||||
"label": "Live Player Fallback Timeout",
|
||||
"desc": "When a camera's high quality live stream is unavailable, fall back to low bandwidth mode after this many seconds. Default: 3."
|
||||
}
|
||||
},
|
||||
"storedLayouts": {
|
||||
|
||||
@ -454,6 +454,24 @@ export function GeneralFilterContent({
|
||||
onClose,
|
||||
}: GeneralFilterContentProps) {
|
||||
const { t } = useTranslation(["components/filter", "views/events"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const allAudioListenLabels = useMemo<string[]>(() => {
|
||||
if (!config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const labels = new Set<string>();
|
||||
Object.values(config.cameras).forEach((camera) => {
|
||||
if (camera?.audio?.enabled) {
|
||||
camera.audio.listen.forEach((label) => {
|
||||
labels.add(label);
|
||||
});
|
||||
}
|
||||
});
|
||||
return [...labels].sort();
|
||||
}, [config]);
|
||||
return (
|
||||
<>
|
||||
<div className="scrollbar-container h-auto max-h-[80dvh] overflow-y-auto overflow-x-hidden">
|
||||
@ -504,7 +522,10 @@ export function GeneralFilterContent({
|
||||
{allLabels.map((item) => (
|
||||
<FilterSwitch
|
||||
key={item}
|
||||
label={getTranslatedLabel(item)}
|
||||
label={getTranslatedLabel(
|
||||
item,
|
||||
allAudioListenLabels.includes(item) ? "audio" : "object",
|
||||
)}
|
||||
isChecked={filter.labels?.includes(item) ?? false}
|
||||
onCheckedChange={(isChecked) => {
|
||||
if (isChecked) {
|
||||
|
||||
@ -81,6 +81,43 @@ export default function InputWithTags({
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const allAudioListenLabels = useMemo<Set<string>>(() => {
|
||||
if (!config) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const labels = new Set<string>();
|
||||
Object.values(config.cameras).forEach((camera) => {
|
||||
if (camera?.audio?.enabled) {
|
||||
camera.audio.listen.forEach((label) => {
|
||||
labels.add(label);
|
||||
});
|
||||
}
|
||||
});
|
||||
return labels;
|
||||
}, [config]);
|
||||
|
||||
const translatedAudioLabelMap = useMemo<Map<string, string>>(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (!config) return map;
|
||||
|
||||
allAudioListenLabels.forEach((label) => {
|
||||
// getTranslatedLabel likely depends on i18n internally; including `lang`
|
||||
// in deps ensures this map is rebuilt when language changes
|
||||
map.set(label, getTranslatedLabel(label, "audio"));
|
||||
});
|
||||
return map;
|
||||
}, [allAudioListenLabels, config]);
|
||||
|
||||
function resolveLabel(value: string) {
|
||||
const mapped = translatedAudioLabelMap.get(value);
|
||||
if (mapped) return mapped;
|
||||
return getTranslatedLabel(
|
||||
value,
|
||||
allAudioListenLabels.has(value) ? "audio" : "object",
|
||||
);
|
||||
}
|
||||
|
||||
const [inputValue, setInputValue] = useState(search || "");
|
||||
const [currentFilterType, setCurrentFilterType] = useState<FilterType | null>(
|
||||
null,
|
||||
@ -421,7 +458,8 @@ export default function InputWithTags({
|
||||
? t("button.yes", { ns: "common" })
|
||||
: t("button.no", { ns: "common" });
|
||||
} else if (filterType === "labels") {
|
||||
return getTranslatedLabel(String(filterValues));
|
||||
const value = String(filterValues);
|
||||
return resolveLabel(value);
|
||||
} else if (filterType === "search_type") {
|
||||
return t("filter.searchType." + String(filterValues));
|
||||
} else {
|
||||
@ -828,7 +866,7 @@ export default function InputWithTags({
|
||||
>
|
||||
{t("filter.label." + filterType)}:{" "}
|
||||
{filterType === "labels" ? (
|
||||
getTranslatedLabel(value)
|
||||
resolveLabel(value)
|
||||
) : filterType === "cameras" ? (
|
||||
<CameraNameLabel camera={value} />
|
||||
) : filterType === "zones" ? (
|
||||
|
||||
@ -1155,7 +1155,7 @@ function ObjectDetailsTab({
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2 text-sm smart-capitalize">
|
||||
{getIconForLabel(search.label, "size-4 text-primary")}
|
||||
{getTranslatedLabel(search.label)}
|
||||
{getTranslatedLabel(search.label, search.data.type)}
|
||||
{search.sub_label && ` (${search.sub_label})`}
|
||||
{isAdmin && search.end_time && (
|
||||
<Tooltip>
|
||||
@ -1394,7 +1394,9 @@ function ObjectDetailsTab({
|
||||
{state == "submitted" && (
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<FaCheckCircle className="size-4 text-success" />
|
||||
{t("explore.plus.review.state.submitted")}
|
||||
{t("explore.plus.review.state.submitted", {
|
||||
ns: "components/dialog",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -343,6 +343,10 @@ export function TrackingDetails({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [displayedRecordTime]);
|
||||
|
||||
const onUploadFrameToPlus = useCallback(() => {
|
||||
return axios.post(`/${event.camera}/plus/${currentTime}`);
|
||||
}, [event.camera, currentTime]);
|
||||
|
||||
if (!config) {
|
||||
return <ActivityIndicator />;
|
||||
}
|
||||
@ -388,6 +392,7 @@ export function TrackingDetails({
|
||||
frigateControls={true}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onSeekToTime={handleSeekToTime}
|
||||
onUploadFrame={onUploadFrameToPlus}
|
||||
isDetailMode={true}
|
||||
camera={event.camera}
|
||||
currentTimeOverride={currentTime}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
LivePlayerError,
|
||||
PlayerStatsType,
|
||||
@ -71,6 +72,8 @@ function MSEPlayer({
|
||||
const [errorCount, setErrorCount] = useState<number>(0);
|
||||
const totalBytesLoaded = useRef(0);
|
||||
|
||||
const [fallbackTimeout] = usePersistence<number>("liveFallbackTimeout", 3);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTIDRef = useRef<number | null>(null);
|
||||
@ -475,7 +478,10 @@ function MSEPlayer({
|
||||
setBufferTimeout(undefined);
|
||||
}
|
||||
|
||||
const timeoutDuration = bufferTime == 0 ? 5000 : 3000;
|
||||
const timeoutDuration =
|
||||
bufferTime == 0
|
||||
? (fallbackTimeout ?? 3) * 2 * 1000
|
||||
: (fallbackTimeout ?? 3) * 1000;
|
||||
setBufferTimeout(
|
||||
setTimeout(() => {
|
||||
if (
|
||||
@ -500,6 +506,7 @@ function MSEPlayer({
|
||||
onError,
|
||||
onPlaying,
|
||||
playbackEnabled,
|
||||
fallbackTimeout,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -349,7 +349,7 @@ function ReviewGroup({
|
||||
? fetchedEvents.length
|
||||
: (review.data.objects ?? []).length;
|
||||
|
||||
return `${objectCount} ${t("detail.trackedObject", { count: objectCount })}`;
|
||||
return `${t("detail.trackedObject", { count: objectCount })}`;
|
||||
}, [review, t, fetchedEvents]);
|
||||
|
||||
const reviewDuration = useMemo(
|
||||
@ -478,7 +478,7 @@ function ReviewGroup({
|
||||
<div className="rounded-full bg-muted-foreground p-1">
|
||||
{getIconForLabel(audioLabel, "size-3 text-white")}
|
||||
</div>
|
||||
<span>{getTranslatedLabel(audioLabel)}</span>
|
||||
<span>{getTranslatedLabel(audioLabel, "audio")}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -513,7 +513,8 @@ function EventList({
|
||||
|
||||
const isSelected = selectedObjectIds.includes(event.id);
|
||||
|
||||
const label = event.sub_label || getTranslatedLabel(event.label);
|
||||
const label =
|
||||
event.sub_label || getTranslatedLabel(event.label, event.data.type);
|
||||
|
||||
const handleObjectSelect = (event: Event | undefined) => {
|
||||
if (event) {
|
||||
|
||||
@ -6,6 +6,7 @@ import { LivePlayerMode, LiveStreamMetadata } from "@/types/live";
|
||||
export default function useCameraLiveMode(
|
||||
cameras: CameraConfig[],
|
||||
windowVisible: boolean,
|
||||
activeStreams?: { [cameraName: string]: string },
|
||||
) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@ -20,16 +21,20 @@ export default function useCameraLiveMode(
|
||||
);
|
||||
|
||||
if (isRestreamed) {
|
||||
if (activeStreams && activeStreams[camera.name]) {
|
||||
streamNames.add(activeStreams[camera.name]);
|
||||
} else {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
streamNames.add(streamName);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return streamNames.size > 0
|
||||
? Array.from(streamNames).sort().join(",")
|
||||
: null;
|
||||
}, [cameras, config]);
|
||||
}, [cameras, config, activeStreams]);
|
||||
|
||||
const streamsFetcher = useCallback(async (key: string) => {
|
||||
const streamNames = key.split(",");
|
||||
@ -68,7 +73,9 @@ export default function useCameraLiveMode(
|
||||
[key: string]: LiveStreamMetadata;
|
||||
}>(restreamedStreamsKey, streamsFetcher, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
});
|
||||
|
||||
const [preferredLiveModes, setPreferredLiveModes] = useState<{
|
||||
|
||||
@ -244,12 +244,12 @@ export const getDurationFromTimestamps = (
|
||||
abbreviated: boolean = false,
|
||||
): string => {
|
||||
if (isNaN(start_time)) {
|
||||
return "Invalid start time";
|
||||
return i18n.t("time.invalidStartTime", { ns: "common" });
|
||||
}
|
||||
let duration = "In Progress";
|
||||
let duration = i18n.t("time.inProgress", { ns: "common" });
|
||||
if (end_time !== null) {
|
||||
if (isNaN(end_time)) {
|
||||
return "Invalid end time";
|
||||
return i18n.t("time.invalidEndTime", { ns: "common" });
|
||||
}
|
||||
const start = fromUnixTime(start_time);
|
||||
const end = fromUnixTime(end_time);
|
||||
|
||||
@ -86,14 +86,6 @@ export default function DraggableGridLayout({
|
||||
|
||||
// preferred live modes per camera
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible);
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
|
||||
@ -106,6 +98,33 @@ export default function DraggableGridLayout({
|
||||
}
|
||||
}, [allGroupsStreamingSettings, cameraGroup]);
|
||||
|
||||
const activeStreams = useMemo(() => {
|
||||
const streams: { [cameraName: string]: string } = {};
|
||||
cameras.forEach((camera) => {
|
||||
const availableStreams = camera.live.streams || {};
|
||||
const streamNameFromSettings =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
|
||||
const streamExists =
|
||||
streamNameFromSettings &&
|
||||
Object.values(availableStreams).includes(streamNameFromSettings);
|
||||
|
||||
const streamName = streamExists
|
||||
? streamNameFromSettings
|
||||
: Object.values(availableStreams)[0] || "";
|
||||
|
||||
streams[camera.name] = streamName;
|
||||
});
|
||||
return streams;
|
||||
}, [cameras, currentGroupStreamingSettings]);
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
||||
|
||||
// grid layout
|
||||
|
||||
const ResponsiveGridLayout = useMemo(() => WidthProvider(Responsive), []);
|
||||
|
||||
@ -162,6 +162,9 @@ export default function LiveCameraView({
|
||||
isRestreamed ? `go2rtc/streams/${streamName}` : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
revalidateIfStale: false,
|
||||
dedupingInterval: 60000,
|
||||
},
|
||||
);
|
||||
|
||||
@ -1027,6 +1030,7 @@ function FrigateCameraFeatures({
|
||||
disabled={!cameraEnabled || debug || isSnapshotLoading}
|
||||
loading={isSnapshotLoading}
|
||||
/>
|
||||
{!fullscreen && (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>
|
||||
<div
|
||||
@ -1155,7 +1159,9 @@ function FrigateCameraFeatures({
|
||||
{t("stream.audio.tips.title")}
|
||||
<div className="mt-2 flex items-center text-primary">
|
||||
<Link
|
||||
to={getLocaleDocUrl("configuration/live")}
|
||||
to={getLocaleDocUrl(
|
||||
"configuration/live",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline"
|
||||
@ -1315,6 +1321,7 @@ function FrigateCameraFeatures({
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -202,14 +202,6 @@ export default function LiveDashboardView({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible);
|
||||
|
||||
const [globalAutoLive] = usePersistence("autoLiveView", true);
|
||||
const [displayCameraNames] = usePersistence("displayCameraNames", false);
|
||||
|
||||
@ -239,6 +231,33 @@ export default function LiveDashboardView({
|
||||
[visibleCameraObserver.current],
|
||||
);
|
||||
|
||||
const activeStreams = useMemo(() => {
|
||||
const streams: { [cameraName: string]: string } = {};
|
||||
cameras.forEach((camera) => {
|
||||
const availableStreams = camera.live.streams || {};
|
||||
const streamNameFromSettings =
|
||||
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
|
||||
const streamExists =
|
||||
streamNameFromSettings &&
|
||||
Object.values(availableStreams).includes(streamNameFromSettings);
|
||||
|
||||
const streamName = streamExists
|
||||
? streamNameFromSettings
|
||||
: Object.values(availableStreams)[0] || "";
|
||||
|
||||
streams[camera.name] = streamName;
|
||||
});
|
||||
return streams;
|
||||
}, [cameras, currentGroupStreamingSettings]);
|
||||
|
||||
const {
|
||||
preferredLiveModes,
|
||||
setPreferredLiveModes,
|
||||
resetPreferredLiveMode,
|
||||
isRestreamedStates,
|
||||
supportsAudioOutputStates,
|
||||
} = useCameraLiveMode(cameras, windowVisible, activeStreams);
|
||||
|
||||
const birdseyeConfig = useMemo(() => config?.birdseye, [config]);
|
||||
|
||||
const handleError = useCallback(
|
||||
|
||||
@ -649,7 +649,7 @@ export function RecordingView({
|
||||
value="detail"
|
||||
aria-label="Detail Stream"
|
||||
>
|
||||
<div className="">Detail</div>
|
||||
<div className="">{t("detail.label")}</div>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
) : (
|
||||
|
||||
@ -99,6 +99,10 @@ export default function UiSettingsView() {
|
||||
const [playbackRate, setPlaybackRate] = usePersistence("playbackRate", 1);
|
||||
const [weekStartsOn, setWeekStartsOn] = usePersistence("weekStartsOn", 0);
|
||||
const [alertVideos, setAlertVideos] = usePersistence("alertVideos", true);
|
||||
const [fallbackTimeout, setFallbackTimeout] = usePersistence(
|
||||
"liveFallbackTimeout",
|
||||
3,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -161,6 +165,48 @@ export default function UiSettingsView() {
|
||||
<p>{t("general.liveDashboard.displayCameraNames.desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-row items-center justify-start gap-2">
|
||||
<Label
|
||||
className="cursor-pointer"
|
||||
htmlFor="live-fallback-timeout"
|
||||
>
|
||||
{t("general.liveDashboard.liveFallbackTimeout.label")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="my-2 max-w-5xl text-sm text-muted-foreground">
|
||||
<p>{t("general.liveDashboard.liveFallbackTimeout.desc")}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={fallbackTimeout?.toString()}
|
||||
onValueChange={(value) => setFallbackTimeout(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
{t("time.second", {
|
||||
ns: "common",
|
||||
time: fallbackTimeout,
|
||||
count: fallbackTimeout,
|
||||
})}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((timeout) => (
|
||||
<SelectItem
|
||||
key={timeout}
|
||||
className="cursor-pointer"
|
||||
value={timeout.toString()}
|
||||
>
|
||||
{t("time.second", {
|
||||
ns: "common",
|
||||
time: timeout,
|
||||
count: timeout,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-3 flex w-full flex-col space-y-6">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user