sort cameras by UI order in various components for consistent display

This commit is contained in:
Josh Hawkins 2026-07-06 16:47:51 -05:00
parent 105d1e8885
commit 0cb3c720ca
13 changed files with 158 additions and 125 deletions

View File

@ -64,7 +64,6 @@ export default function Step2StateArea({
const selectedCameraNames = cameraAreas.map((ca) => ca.camera); const selectedCameraNames = cameraAreas.map((ca) => ca.camera);
return Object.entries(config.cameras) return Object.entries(config.cameras)
.sort()
.filter( .filter(
([name, cam]) => ([name, cam]) =>
cam.enabled && cam.enabled &&
@ -72,6 +71,7 @@ export default function Step2StateArea({
!isReplayCamera(name) && !isReplayCamera(name) &&
!selectedCameraNames.includes(name), !selectedCameraNames.includes(name),
) )
.sort(([, a], [, b]) => a.ui.order - b.ui.order)
.map(([name]) => ({ .map(([name]) => ({
name, name,
displayName: resolveCameraName(config, name), displayName: resolveCameraName(config, name),

View File

@ -29,9 +29,13 @@ export default function ExportFilterGroup({
const filterValues = useMemo( const filterValues = useMemo(
() => ({ () => ({
cameras: allowedCameras, cameras: [...allowedCameras].sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
}), }),
[allowedCameras], [config, allowedCameras],
); );
const groups = useMemo(() => { const groups = useMemo(() => {

View File

@ -127,12 +127,16 @@ export default function SearchFilterGroup({
const filterValues = useMemo( const filterValues = useMemo(
() => ({ () => ({
cameras: allowedCameras, cameras: [...allowedCameras].sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
),
labels: Object.values(allLabels || {}), labels: Object.values(allLabels || {}),
zones: Object.values(allZones || {}), zones: Object.values(allZones || {}),
search_type: ["thumbnail", "description"] as SearchSource[], search_type: ["thumbnail", "description"] as SearchSource[],
}), }),
[allLabels, allZones, allowedCameras], [config, allLabels, allZones, allowedCameras],
); );
const availableSortTypes = useMemo(() => { const availableSortTypes = useMemo(() => {

View File

@ -53,9 +53,13 @@ export default function CreateRoleDialog({
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {}).filter( const cameras = Object.keys(config.cameras || {})
(name) => !isReplayCamera(name), .filter((name) => !isReplayCamera(name))
); .sort(
(a, b) =>
(config.cameras[a]?.ui?.order ?? 0) -
(config.cameras[b]?.ui?.order ?? 0),
);
const existingRoles = Object.keys(config.auth?.roles || {}); const existingRoles = Object.keys(config.auth?.roles || {});

View File

@ -47,9 +47,13 @@ export default function EditRoleCamerasDialog({
const { t } = useTranslation(["views/settings"]); const { t } = useTranslation(["views/settings"]);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const cameras = Object.keys(config.cameras || {}).filter( const cameras = Object.keys(config.cameras || {})
(name) => !isReplayCamera(name), .filter((name) => !isReplayCamera(name))
); .sort(
(a, b) =>
(config.cameras[a]?.ui?.order ?? 0) -
(config.cameras[b]?.ui?.order ?? 0),
);
const formSchema = z.object({ const formSchema = z.object({
cameras: z cameras: z

View File

@ -39,7 +39,9 @@ export default function ObjectPathPlotter() {
const cameraNames = useMemo(() => { const cameraNames = useMemo(() => {
if (!config) return []; if (!config) return [];
return Object.keys(config.cameras).filter((name) => !isReplayCamera(name)); return Object.keys(config.cameras)
.filter((name) => !isReplayCamera(name))
.sort((a, b) => config.cameras[a].ui.order - config.cameras[b].ui.order);
}, [config]); }, [config]);
useEffect(() => { useEffect(() => {

View File

@ -96,7 +96,7 @@ export default function CloneCameraDialog({
if (!config) return []; if (!config) return [];
return Object.keys(config.cameras) return Object.keys(config.cameras)
.filter((c) => !isReplayCamera(c)) .filter((c) => !isReplayCamera(c))
.sort(); .sort((a, b) => config.cameras[a].ui.order - config.cameras[b].ui.order);
}, [config]); }, [config]);
const formSchema = useMemo(() => { const formSchema = useMemo(() => {
@ -176,7 +176,7 @@ export default function CloneCameraDialog({
if (!config) return []; if (!config) return [];
return Object.keys(config.cameras) return Object.keys(config.cameras)
.filter((c) => c !== sourceCamera && !isReplayCamera(c)) .filter((c) => c !== sourceCamera && !isReplayCamera(c))
.sort(); .sort((a, b) => config.cameras[a].ui.order - config.cameras[b].ui.order);
}, [config, sourceCamera]); }, [config, sourceCamera]);
const srcCfg = config?.cameras?.[sourceCamera]; const srcCfg = config?.cameras?.[sourceCamera];

View File

@ -169,7 +169,7 @@ export default function WsMessageFeed({
const cam = config.cameras[name]; const cam = config.cameras[name];
return !isReplayCamera(name) && cam.enabled_in_config; return !isReplayCamera(name) && cam.enabled_in_config;
}) })
.sort(); .sort((a, b) => config.cameras[a].ui.order - config.cameras[b].ui.order);
}, [config]); }, [config]);
const filteredMessages = useMemo(() => { const filteredMessages = useMemo(() => {

View File

@ -30,9 +30,9 @@ export default function MotionSearch() {
const cameras = useMemo(() => { const cameras = useMemo(() => {
if (!config?.cameras) return []; if (!config?.cameras) return [];
return Object.keys(config.cameras).filter((cam) => return Object.keys(config.cameras)
allowedCameras.includes(cam), .filter((cam) => allowedCameras.includes(cam))
); .sort((a, b) => config.cameras[a].ui.order - config.cameras[b].ui.order);
}, [config?.cameras, allowedCameras]); }, [config?.cameras, allowedCameras]);
// Selected camera state // Selected camera state

View File

@ -208,7 +208,12 @@ export default function CameraManagementView({
!config.cameras[camera].enabled_in_config && !config.cameras[camera].enabled_in_config &&
!isReplayCamera(camera), !isReplayCamera(camera),
) )
.sort(); .sort((a, b) => {
const orderA = config.cameras[a].ui?.order ?? 0;
const orderB = config.cameras[b].ui?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a.localeCompare(b);
});
} }
return []; return [];
}, [config]); }, [config]);
@ -217,7 +222,12 @@ export default function CameraManagementView({
if (config) { if (config) {
return Object.keys(config.cameras) return Object.keys(config.cameras)
.filter((camera) => !isReplayCamera(camera)) .filter((camera) => !isReplayCamera(camera))
.sort(); .sort((a, b) => {
const orderA = config.cameras[a].ui?.order ?? 0;
const orderB = config.cameras[b].ui?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a.localeCompare(b);
});
} }
return []; return [];
}, [config]); }, [config]);

View File

@ -140,6 +140,7 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
<tbody> <tbody>
{Object.entries(config.cameras) {Object.entries(config.cameras)
.filter(([name]) => !isReplayCamera(name)) .filter(([name]) => !isReplayCamera(name))
.sort(([, a], [, b]) => a.ui.order - b.ui.order)
.map(([name, camera]) => ( .map(([name, camera]) => (
<tr <tr
key={name} key={name}

View File

@ -148,7 +148,7 @@ export default function ProfilesView({
const data: Record<string, Record<string, string[]>> = {}; const data: Record<string, Record<string, string[]>> = {};
const cameras = Object.keys(config.cameras) const cameras = Object.keys(config.cameras)
.filter((name) => !isReplayCamera(name)) .filter((name) => !isReplayCamera(name))
.sort(); .sort((a, b) => config.cameras[a].ui.order - config.cameras[b].ui.order);
for (const profile of allProfileNames) { for (const profile of allProfileNames) {
data[profile] = {}; data[profile] = {};
@ -474,7 +474,11 @@ export default function ProfilesView({
const color = getProfileColor(profile, allProfileNames); const color = getProfileColor(profile, allProfileNames);
const isActive = activeProfile === profile; const isActive = activeProfile === profile;
const cameraData = profileOverviewData[profile] ?? {}; const cameraData = profileOverviewData[profile] ?? {};
const cameras = Object.keys(cameraData).sort(); const cameras = Object.keys(cameraData).sort(
(a, b) =>
(config?.cameras[a]?.ui?.order ?? 0) -
(config?.cameras[b]?.ui?.order ?? 0),
);
const isExpanded = expandedProfiles.has(profile); const isExpanded = expandedProfiles.has(profile);
return ( return (

View File

@ -316,114 +316,114 @@ export default function CameraMetrics({
</div> </div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{config && {config &&
Object.values(config.cameras).map((camera) => { Object.values(config.cameras)
if (camera.enabled && !isReplayCamera(camera.name)) { .sort((a, b) => a.ui.order - b.ui.order)
return ( .map((camera) => {
<Fragment key={camera.name}> if (camera.enabled && !isReplayCamera(camera.name)) {
{probeCameraName == camera.name && ( return (
<CameraInfoDialog <Fragment key={camera.name}>
camera={camera} {probeCameraName == camera.name && (
showCameraInfoDialog={showCameraInfoDialog} <CameraInfoDialog
setShowCameraInfoDialog={setShowCameraInfoDialog} camera={camera}
/> showCameraInfoDialog={showCameraInfoDialog}
)} setShowCameraInfoDialog={setShowCameraInfoDialog}
<div className="flex w-full flex-col gap-3"> />
<div className="flex flex-row items-center justify-between"> )}
<div className="flex items-center gap-2"> <div className="flex w-full flex-col gap-3">
<div className="text-sm font-medium text-muted-foreground smart-capitalize"> <div className="flex flex-row items-center justify-between">
<CameraNameLabel camera={camera} /> <div className="flex items-center gap-2">
</div> <div className="text-sm font-medium text-muted-foreground smart-capitalize">
{statsHistory.length > 0 && <CameraNameLabel camera={camera} />
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
] && (
<ConnectionQualityIndicator
quality={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.connection_quality
}
expectedFps={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.expected_fps || 0
}
reconnects={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.reconnects_last_hour || 0
}
stalls={
statsHistory[statsHistory.length - 1]?.cameras[
camera.name
]?.stalls_last_hour || 0
}
/>
)}
</div>
<Tooltip>
<TooltipTrigger>
<MdInfo
className="size-5 cursor-pointer text-muted-foreground"
onClick={() => {
setShowCameraInfoDialog(true);
setProbeCameraName(camera.name);
}}
/>
</TooltipTrigger>
<TooltipContent>
{t("cameras.info.tips.title")}
</TooltipContent>
</Tooltip>
</div>
<div
key={camera.name}
className="grid gap-2 sm:grid-cols-2"
>
{Object.keys(cameraCpuSeries).includes(camera.name) ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">CPU</div>
<CameraLineGraph
graphId={`${camera.name}-cpu`}
unit="%"
dataLabels={["ffmpeg", "capture", "detect"]}
updateTimes={updateTimes}
data={Object.values(
cameraCpuSeries[camera.name] || {},
)}
isActive={isActive}
/>
</div>
) : (
<Skeleton className="aspect-video size-full" />
)}
{Object.keys(cameraFpsSeries).includes(camera.name) ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">
{t("cameras.framesAndDetections")}
</div> </div>
<CameraLineGraph {statsHistory.length > 0 &&
graphId={`${camera.name}-dps`} statsHistory[statsHistory.length - 1]?.cameras[
unit="" camera.name
dataLabels={["camera", "detect", "skipped"]} ] && (
updateTimes={updateTimes} <ConnectionQualityIndicator
data={Object.values( quality={
cameraFpsSeries[camera.name] || {}, statsHistory[statsHistory.length - 1]
?.cameras[camera.name]?.connection_quality
}
expectedFps={
statsHistory[statsHistory.length - 1]
?.cameras[camera.name]?.expected_fps || 0
}
reconnects={
statsHistory[statsHistory.length - 1]
?.cameras[camera.name]
?.reconnects_last_hour || 0
}
stalls={
statsHistory[statsHistory.length - 1]
?.cameras[camera.name]?.stalls_last_hour ||
0
}
/>
)} )}
isActive={isActive}
/>
</div> </div>
) : ( <Tooltip>
<Skeleton className="aspect-video size-full" /> <TooltipTrigger>
)} <MdInfo
className="size-5 cursor-pointer text-muted-foreground"
onClick={() => {
setShowCameraInfoDialog(true);
setProbeCameraName(camera.name);
}}
/>
</TooltipTrigger>
<TooltipContent>
{t("cameras.info.tips.title")}
</TooltipContent>
</Tooltip>
</div>
<div
key={camera.name}
className="grid gap-2 sm:grid-cols-2"
>
{Object.keys(cameraCpuSeries).includes(camera.name) ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">CPU</div>
<CameraLineGraph
graphId={`${camera.name}-cpu`}
unit="%"
dataLabels={["ffmpeg", "capture", "detect"]}
updateTimes={updateTimes}
data={Object.values(
cameraCpuSeries[camera.name] || {},
)}
isActive={isActive}
/>
</div>
) : (
<Skeleton className="aspect-video size-full" />
)}
{Object.keys(cameraFpsSeries).includes(camera.name) ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">
{t("cameras.framesAndDetections")}
</div>
<CameraLineGraph
graphId={`${camera.name}-dps`}
unit=""
dataLabels={["camera", "detect", "skipped"]}
updateTimes={updateTimes}
data={Object.values(
cameraFpsSeries[camera.name] || {},
)}
isActive={isActive}
/>
</div>
) : (
<Skeleton className="aspect-video size-full" />
)}
</div>
</div> </div>
</div> </Fragment>
</Fragment> );
); }
}
return null; return null;
})} })}
</div> </div>
</div> </div>
); );