Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions components/ImageViewer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const ImageViewer = ({
defaultZoom = null,
zoomLabel = "Zoom to Plate",
onFullscreenChange = null,
onImageLoad = null,
}) => {
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
Expand Down Expand Up @@ -170,17 +171,22 @@ const ImageViewer = ({

useEffect(() => {
setImageSize(null);
let active = true;
const img = new Image();
img.onload = () => {
if (active) setImageSize({ url: image.url, width: img.width, height: img.height });
};
img.src = image.url;
return () => {
active = false;
};
}, [image.url]);

const handleImageLoad = useCallback((event) => {
const element = event.currentTarget;
const width = Number(element.naturalWidth || element.width);
const height = Number(element.naturalHeight || element.height);
if (width > 0 && height > 0) {
setImageSize({ url: image.url, width, height });
}
onImageLoad?.({
url: image.url,
width: width > 0 ? width : null,
height: height > 0 ? height : null,
});
}, [image.url, onImageLoad]);

useEffect(() => {
const needsFocusMeasurements = defaultZoom === null && Boolean(
image?.focus_coordinates || image?.crop_coordinates
Expand Down Expand Up @@ -423,6 +429,7 @@ const ImageViewer = ({
className="object-contain"
draggable={false}
unoptimized
onLoad={handleImageLoad}
/>
</div>
</div>
Expand Down
130 changes: 110 additions & 20 deletions components/PlateTable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@
saveLiveFeedPopupView,
} from "@/lib/live-feed-popup-preference.mjs";
import { buildBlueIrisUiUrl } from "@/lib/blue-iris-ui-url.mjs";
import {
elapsedMilliseconds,
recordLiveFeedPerformance,
} from "@/lib/live-feed-performance.mjs";
import {
Sheet,
SheetContent,
Expand Down Expand Up @@ -302,9 +306,10 @@
sort = { field: "", direction: "" },
onSort = () => {},
matchingSettings,
isLive = true,
onLiveChange = () => {},
onViewerOpenChange = () => {},
}) {
console.log("PlateTable rendering with data:", data.length);

const { can } = useAccess();
const canRead = can("plate.read");
const canReview = can("plate.review");
Expand Down Expand Up @@ -392,7 +397,6 @@
const [isDirectionReviewOpen, setIsDirectionReviewOpen] = useState(false);
const [directionReviewError, setDirectionReviewError] = useState("");
const [searchInput, setSearchInput] = useState(filters.search || "");
const [isLive, setIsLive] = useState(true);
const [prefetchedImages, setPrefetchedImages] = useState(new Set());
const [biHost, setBiHost] = useState(null);
const [isFilterSheetOpen, setIsFilterSheetOpen] = useState(false);
Expand All @@ -402,13 +406,18 @@
const confirmNextTokenSequenceRef = useRef(0);
const activeConfirmNextOperationRef = useRef(null);
const selectedImageIdRef = useRef(null);
const viewerNavigationTimingRef = useRef(null);

const router = useRouter();

useEffect(() => {
selectedImageIdRef.current = selectedImage?.id ?? null;
}, [selectedImage?.id]);

useEffect(() => {
onViewerOpenChange(selectedImage !== null);
}, [onViewerOpenChange, selectedImage]);

const cancelConfirmNextOperation = useCallback(() => {
activeConfirmNextOperationRef.current = null;
setConfirmNextOperation(null);
Expand All @@ -423,6 +432,7 @@
useEffect(() => () => {
activeConfirmNextOperationRef.current = null;
selectedImageIdRef.current = null;
viewerNavigationTimingRef.current = null;
}, []);

useEffect(() => {
Expand Down Expand Up @@ -552,20 +562,6 @@
fetchBiHost();
}, []);

useEffect(() => {
let interval;
if (
isLive &&
pendingUnconfirmedNavigation === null &&
confirmNextOperation === null
) {
interval = setInterval(() => {
router.refresh();
}, 4500);
}
return () => clearInterval(interval);
}, [confirmNextOperation, isLive, pendingUnconfirmedNavigation, router]);

// Helper functions
const getImageUrl = (base64Data) => {
if (!base64Data) return "/placeholder-image.jpg";
Expand Down Expand Up @@ -603,6 +599,14 @@
crop_coordinates = plate.crop_coordinates;
}

if (viewerNavigationTimingRef.current) {
viewerNavigationTimingRef.current = {
...viewerNavigationTimingRef.current,
targetReadId: Number(plate.id),
targetCamera: plate.camera_name || "",
};
}

setSelectedIndex(plateIndex);
setSelectedImage({
url: imageUrl,
Expand Down Expand Up @@ -681,6 +685,7 @@
]
);
const onViewerPageChange = pagination.onViewerPageChange;
const onViewerDataRefresh = pagination.onViewerDataRefresh;

const handleViewerNavigation = useCallback((direction) => {
if (
Expand All @@ -691,6 +696,23 @@
) return;

const destination = getViewerNavigation(direction);
if (destination.kind === "none") return;

viewerNavigationTimingRef.current = {
metric: "viewer_navigation",
operation: direction,
boundary: destination.kind === "item" ? "same_page" : "cross_page",
startedAt: performance.now(),
sourceReadId: Number(selectedImage.id),
sourceCamera: selectedImage.cameraName || "",
targetPage: destination.kind === "page" ? destination.page : pagination.page,
targetReadId: destination.kind === "item"
? Number(data[destination.index]?.id)
: null,
targetCamera: destination.kind === "item"
? data[destination.index]?.camera_name || ""
: "",
};
if (destination.kind === "item") {
handleImageClick(
{ preventDefault: () => {} },
Expand All @@ -713,6 +735,7 @@
onViewerPageChange,
pendingUnconfirmedNavigation,
pendingViewerNavigation,
pagination.page,
selectedImage,
]);

Expand Down Expand Up @@ -801,6 +824,25 @@
? "vehicle"
: "plate";

const handleViewerImageLoad = useCallback(({ url, width, height }) => {
const timing = viewerNavigationTimingRef.current;
if (!timing || !selectedImage?.id) return;
if (timing.targetReadId && timing.targetReadId !== Number(selectedImage.id)) return;

recordLiveFeedPerformance({
...timing,
durationMs: elapsedMilliseconds(timing.startedAt, performance.now()),
outcome: "image_loaded",
targetReadId: Number(selectedImage.id),
targetCamera: selectedImage.cameraName || timing.targetCamera || "",
imageView: displayedImageView,
imageUrlKind: String(url || "").startsWith("data:") ? "inline" : "stored_file",
imageWidth: width,
imageHeight: height,
});
viewerNavigationTimingRef.current = null;
}, [displayedImageView, selectedImage]);

useEffect(() => {
if (selectedImage && data && data.length > 0) {
const currentPlate = data.find((plate) => plate.id === selectedImage.id);
Expand Down Expand Up @@ -939,6 +981,8 @@

const readId = selectedImage.id;
const nextValidated = !selectedImage.validated;
const reviewStartedAt = performance.now();
let reviewSucceeded = false;
const previousReviewState = {
validated: selectedImage.validated,
plateNumber: selectedImage.plateNumber,
Expand Down Expand Up @@ -984,12 +1028,21 @@
result.data?.reviewRevision ?? previous.reviewRevision,
};
});
reviewSucceeded = true;
return true;
} catch (error) {
rollbackReviewState();
console.error("Failed to update plate review:", error);
return false;
} finally {
recordLiveFeedPerformance({
metric: "review_action",
operation: nextValidated ? "confirm" : "reopen",
durationMs: elapsedMilliseconds(reviewStartedAt, performance.now()),
success: reviewSucceeded,
readId: Number(readId),
camera: selectedImage.cameraName || "",
});
setPendingReviewReadId((current) => (current === readId ? null : current));
setPendingReviewTargetValidated(null);
}
Expand All @@ -1013,9 +1066,21 @@
activeConfirmNextOperationRef.current = operation;
setConfirmNextOperation(operation);
const nextRead = nextUnconfirmedIndex >= 0 ? data[nextUnconfirmedIndex] : null;
const waitsForFilteredRemoval =
const waitsForFilteredBoundaryRefresh =
!nextRead &&
selectedReviewStatuses.length > 0 &&
!selectedReviewStatuses.includes("confirmed");
viewerNavigationTimingRef.current = {
metric: "viewer_navigation",
operation: "confirm_and_next",
boundary: nextRead ? "same_page" : "cross_page",
startedAt: performance.now(),
sourceReadId: Number(selectedImage.id),
sourceCamera: selectedImage.cameraName || "",
targetPage: nextRead ? pagination.page : pagination.page + 1,
targetReadId: nextRead ? Number(nextRead.id) : null,
targetCamera: nextRead?.camera_name || "",
};
const confirmed = await handleSelectedImageValidation();
if (!isConfirmNextOperationCurrent({
activeToken: activeConfirmNextOperationRef.current?.token ?? null,
Expand All @@ -1024,6 +1089,15 @@
originReadId: origin.originReadId,
})) return;
if (!confirmed) {
const timing = viewerNavigationTimingRef.current;
if (timing?.operation === "confirm_and_next") {
recordLiveFeedPerformance({
...timing,
durationMs: elapsedMilliseconds(timing.startedAt, performance.now()),
outcome: "review_failed",
});
viewerNavigationTimingRef.current = null;
}
cancelConfirmNextOperation();
return;
}
Expand All @@ -1036,12 +1110,17 @@
...origin,
deadlineAt: Date.now() + CONFIRM_NEXT_SCAN_TIMEOUT_MS,
};
if (waitsForFilteredRemoval) {
if (
waitsForFilteredBoundaryRefresh &&
typeof onViewerDataRefresh === "function"
) {
setPendingUnconfirmedNavigation({
...pending,
phase: "await-filtered-removal",
targetPage: origin.originPage,
originDataRevision: pagination.dataRevision,
});
onViewerDataRefresh();
return;
}
if (hasLaterResultPage && typeof onViewerPageChange === "function") {
Expand All @@ -1057,6 +1136,13 @@
useEffect(() => {
const pending = pendingUnconfirmedNavigation;
if (!pending) return;
if (
pending.phase === "await-filtered-removal" &&
pending.originDataRevision === pagination.dataRevision &&
Date.now() < pending.deadlineAt
) {
return;
}
const transition = resolveUnconfirmedPageTransition({
pending,
reads: data,
Expand Down Expand Up @@ -1084,6 +1170,7 @@
onViewerPageChange,
pagination.page,
pagination.pageSize,
pagination.dataRevision,
pagination.total,
pendingUnconfirmedNavigation,
]);
Expand Down Expand Up @@ -1143,7 +1230,7 @@
}
});
}
}, [data, loading]);

Check warning on line 1233 in components/PlateTable.jsx

View workflow job for this annotation

GitHub Actions / Test, typecheck, lint, and build

React Hook useEffect has a missing dependency: 'prefetchedImages'. Either include it or remove the dependency array

const handleOpenInNewTab = () => {
if (!selectedImage) return;
Expand Down Expand Up @@ -1279,6 +1366,7 @@
if (deletingSelectedRead) {
cancelConfirmNextFlow();
selectedImageIdRef.current = null;
viewerNavigationTimingRef.current = null;
setSelectedImage(null);
setSelectedIndex(-1);
setPendingViewerNavigation(null);
Expand Down Expand Up @@ -1815,7 +1903,7 @@
<div className="flex shrink-0 items-center gap-2 rounded-md border px-3 py-2 dark:bg-[#161618]">
<Switch
checked={isLive}
onCheckedChange={setIsLive}
onCheckedChange={onLiveChange}
id="live-updates"
/>
<Label
Expand Down Expand Up @@ -2781,6 +2869,7 @@
if (!open) {
cancelConfirmNextFlow();
selectedImageIdRef.current = null;
viewerNavigationTimingRef.current = null;
setIsImageFullscreen(false);
setSelectedImage(null);
setSelectedIndex(-1);
Expand Down Expand Up @@ -2912,6 +3001,7 @@
defaultZoom={null}
zoomLabel={displayedImageView === "vehicle" ? "Zoom to Vehicle" : "Zoom to Plate"}
onFullscreenChange={setIsImageFullscreen}
onImageLoad={handleViewerImageLoad}
/>
</div>
</div>
Expand Down
Loading
Loading