From eee1a6083381290e7230e44583ccbedf3a6d491d Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Thu, 23 Jul 2026 23:59:47 +0300 Subject: [PATCH 1/6] refactor: centralize camera snapshot mode requests --- src/api/cameras.ts | 5 +++-- src/api/client.ts | 4 ++-- src/components/CamerasPage.tsx | 4 ++-- src/components/TopBar.tsx | 2 +- src/pages/AnalyticsPage.tsx | 4 ++-- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/api/cameras.ts b/src/api/cameras.ts index d52e1e7..e967895 100644 --- a/src/api/cameras.ts +++ b/src/api/cameras.ts @@ -90,7 +90,7 @@ export type CameraSnapshotOptions = { export type CameraSnapshotMode = 'latest' | 'detection' | 'annotated'; -export function cameraSnapshotOptions(mode: CameraSnapshotMode): CameraSnapshotOptions | undefined { +function cameraSnapshotOptions(mode: CameraSnapshotMode): CameraSnapshotOptions | undefined { if (mode === 'detection') { return { last_detection: true }; } @@ -150,7 +150,8 @@ export const camerasApi = { return request('GET', '/cameras/next'); }, - async getSnapshot(cameraId: number, options?: CameraSnapshotOptions): Promise { + async getSnapshot(cameraId: number, mode: CameraSnapshotMode = 'latest'): Promise { + const options = cameraSnapshotOptions(mode); const query = buildQuery({ annotated: options?.annotated, last_detection: options?.last_detection, diff --git a/src/api/client.ts b/src/api/client.ts index 1acd20c..8e3646b 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -522,8 +522,8 @@ export const api = { return camerasApi.getNext(); }, - async getSnapshot(cameraId: number, options?: import('./cameras').CameraSnapshotOptions) { - return camerasApi.getSnapshot(cameraId, options); + async getSnapshot(cameraId: number, mode?: import('./cameras').CameraSnapshotMode) { + return camerasApi.getSnapshot(cameraId, mode); }, // --- System --- diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx index 2ebfa16..43899ea 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -8,7 +8,7 @@ import { useFeedbackStore } from '@/feedback/feedbackStore'; import { useSessionStore } from '@/auth/sessionStore'; import { fitYandexMap, yandexPoint, type YandexPoint } from '@/maps/yandex'; import { useYandexMap } from '@/maps/useYandexMap'; -import { cameraSnapshotOptions, type CameraSnapshotMode } from '@/api/cameras'; +import { type CameraSnapshotMode } from '@/api/cameras'; function hasCoordinates(latitude?: number | null, longitude?: number | null): latitude is number { return typeof latitude === 'number' @@ -367,7 +367,7 @@ export default function CamerasPage() { if (inFlight) return inFlight; let request: Promise; - request = api.getSnapshot(cameraId, cameraSnapshotOptions(mode)).then(data => { + request = api.getSnapshot(cameraId, mode).then(data => { if (!snapshotCacheAliveRef.current) { revokeSnapshotData(data); return data; diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index fe37b24..3f98852 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -43,7 +43,7 @@ export default function TopBar() { setLoadingSnapshot(true); try { apiConfig.set(apiBase, effectiveToken); - const snap = await api.getSnapshot(parseInt(cameraId, 10)); + const snap = await api.getSnapshot(parseInt(cameraId, 10), 'latest'); if (snap?.image_url) { const img = await loadImage(snap.image_url); diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 6cbb23b..899427b 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -31,7 +31,7 @@ import { useYandexMap } from '@/maps/useYandexMap'; import { fitYandexMap, yandexPoint, type YandexPoint } from '@/maps/yandex'; import { useStore } from '@/store/useStore'; import { navigate } from '@/router/routes'; -import { cameraSnapshotOptions, type CameraSnapshotMode } from '@/api/cameras'; +import { type CameraSnapshotMode } from '@/api/cameras'; type PeriodPreset = 'today' | 'yesterday' | '1h' | '6h' | '12h' | '24h' | '7d' | '30d' | 'custom'; type AutoRefreshInterval = 'off' | '10s' | '30s' | '1m' | '5m' | '15m' | '30m' | '1h'; @@ -130,7 +130,7 @@ function fetchCameraSnapshot(cameraId: number, tab: CameraSnapshotMode, force = if (!force && pending) return pending; let request: Promise; - request = api.getSnapshot(cameraId, cameraSnapshotOptions(tab)).then(snapshot => { + request = api.getSnapshot(cameraId, tab).then(snapshot => { if (cameraSnapshotRequests.get(cacheKey) !== request) { revokeCameraSnapshot(snapshot); return snapshot; From c3c607efd495188ff549ab28a8cfaaba7c6cf0dc Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Fri, 24 Jul 2026 00:05:17 +0300 Subject: [PATCH 2/6] fix: prioritize annotated camera snapshots --- src/components/CameraSnapshotModeSelector.tsx | 75 +++++++++++++++++++ src/components/CamerasPage.tsx | 64 ++++------------ 2 files changed, 91 insertions(+), 48 deletions(-) create mode 100644 src/components/CameraSnapshotModeSelector.tsx diff --git a/src/components/CameraSnapshotModeSelector.tsx b/src/components/CameraSnapshotModeSelector.tsx new file mode 100644 index 0000000..6e389ad --- /dev/null +++ b/src/components/CameraSnapshotModeSelector.tsx @@ -0,0 +1,75 @@ +import type { CameraSnapshotMode } from '@/api/cameras'; + +export const CAMERA_SNAPSHOT_MODE_CONTENT: Record< + CameraSnapshotMode, + { label: string; title: string; description: string } +> = { + annotated: { + label: 'С разметкой', + title: 'С разметкой', + description: 'Последняя визуализация распознавания' + }, + detection: { + label: 'Последнее распознавание', + title: 'Последнее распознавание', + description: 'Кадр, сохранённый в момент последней детекции' + }, + latest: { + label: 'Последний снимок', + title: 'Последний снимок', + description: 'Свежий кадр из видеопотока' + } +}; + +const CAMERA_SNAPSHOT_MODE_ORDER: CameraSnapshotMode[] = [ + 'annotated', + 'detection', + 'latest' +]; + +export function defaultCameraSnapshotMode(canViewAnnotated: boolean): CameraSnapshotMode { + return canViewAnnotated ? 'annotated' : 'detection'; +} + +type CameraSnapshotModeSelectorProps = { + value: CameraSnapshotMode; + canViewAnnotated: boolean; + onChange: (mode: CameraSnapshotMode) => void; + ariaLabel?: string; +}; + +export function CameraSnapshotModeSelector({ + value, + canViewAnnotated, + onChange, + ariaLabel = 'Режим просмотра кадра' +}: CameraSnapshotModeSelectorProps) { + const modes = CAMERA_SNAPSHOT_MODE_ORDER.filter( + mode => mode !== 'annotated' || canViewAnnotated + ); + + return ( +
+ {modes.map(mode => { + const content = CAMERA_SNAPSHOT_MODE_CONTENT[mode]; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx index 43899ea..e8ae9fd 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -9,6 +9,11 @@ import { useSessionStore } from '@/auth/sessionStore'; import { fitYandexMap, yandexPoint, type YandexPoint } from '@/maps/yandex'; import { useYandexMap } from '@/maps/useYandexMap'; import { type CameraSnapshotMode } from '@/api/cameras'; +import { + CAMERA_SNAPSHOT_MODE_CONTENT, + CameraSnapshotModeSelector, + defaultCameraSnapshotMode +} from './CameraSnapshotModeSelector'; function hasCoordinates(latitude?: number | null, longitude?: number | null): latitude is number { return typeof latitude === 'number' @@ -49,21 +54,6 @@ type CameraSaveState = { error?: string; }; -const SNAPSHOT_MODE_CONTENT: Record = { - latest: { - title: 'Последний снимок', - description: 'Свежий кадр из видеопотока' - }, - detection: { - title: 'Последнее распознавание', - description: 'Кадр, сохранённый в момент последней детекции' - }, - annotated: { - title: 'С разметкой', - description: 'Последняя визуализация распознавания' - } -}; - function formatDate(dateStr?: string): string { if (!dateStr) return '—'; try { @@ -323,7 +313,9 @@ export default function CamerasPage() { const snapshotCacheAliveRef = useRef(true); const [snapshot, setSnapshot] = useState({ loading: false }); const [snapshotReloadKey, setSnapshotReloadKey] = useState(0); - const [snapshotMode, setSnapshotMode] = useState('latest'); + const [snapshotMode, setSnapshotMode] = useState( + () => defaultCameraSnapshotMode(canViewAnnotatedSnapshot) + ); const [isSnapshotFullscreen, setIsSnapshotFullscreen] = useState(false); const [editor, setEditor] = useState(null); const [saveState, setSaveState] = useState({ loading: false }); @@ -354,7 +346,7 @@ export default function CamerasPage() { useEffect(() => { if (!canViewAnnotatedSnapshot && snapshotMode === 'annotated') { - setSnapshotMode('latest'); + setSnapshotMode('detection'); } }, [canViewAnnotatedSnapshot, snapshotMode]); @@ -949,39 +941,15 @@ export default function CamerasPage() {
-

{SNAPSHOT_MODE_CONTENT[snapshotMode].title}

-
{SNAPSHOT_MODE_CONTENT[snapshotMode].description}
+

{CAMERA_SNAPSHOT_MODE_CONTENT[snapshotMode].title}

+
{CAMERA_SNAPSHOT_MODE_CONTENT[snapshotMode].description}
-
- - - {canViewAnnotatedSnapshot && ( - - )} -
+ {snapshot.data?.image_url && ( +
+ )} + {isLabeler && (
@@ -130,6 +257,7 @@ export default function TopBar() { accept="image/*" onPick={async (f) => { try { + clearSnapshotCache(); const url = URL.createObjectURL(f); const img = await loadImage(url); setImage(img, cameraId || undefined); @@ -143,6 +271,9 @@ export default function TopBar() { )}
+ {isLabeler && snapshotError && ( +
{snapshotError}
+ )}
); } @@ -157,3 +288,13 @@ async function loadImage(url: string) { }); return { url, naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight }; } + +function snapshotCacheKey(cameraId: string, mode: CameraSnapshotMode) { + return `${cameraId}:${mode}`; +} + +function revokeImage(image?: { url: string }) { + if (image?.url.startsWith('blob:')) { + URL.revokeObjectURL(image.url); + } +} diff --git a/src/store/useStore.ts b/src/store/useStore.ts index a850ccf..406edff 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -69,7 +69,11 @@ type State = { setViewMode(mode: ViewMode): void; setCamera(id: string): void; setLabelerReturnRoute(route?: 'cameras' | 'zones'): void; - setImage(img: ImageMeta | undefined, cameraId?: string): void; + setImage( + img: ImageMeta | undefined, + cameraId?: string, + options?: { revokePrevious?: boolean } + ): void; loadCameraMeta(id: number): Promise; saveCamera(id: number, patch: Partial): Promise; @@ -129,9 +133,13 @@ export const useStore = create((set, get) => ({ }); }, setLabelerReturnRoute(route) { set({ labelerReturnRoute: route }); }, - setImage(img, cameraId) { + setImage(img, cameraId, options) { set((state) => { - if (state.image?.url && state.image.url !== img?.url) { + if ( + options?.revokePrevious !== false + && state.image?.url + && state.image.url !== img?.url + ) { revokeImage(state.image); } return { diff --git a/src/styles.css b/src/styles.css index b543180..babdd64 100644 --- a/src/styles.css +++ b/src/styles.css @@ -28,6 +28,42 @@ body { background: var(--bg); color: var(--text); font-family: Inter, system-ui, } .topbar { grid-area: topbar; border-bottom: 1px solid var(--border); background: var(--panel); padding: 10px 12px; } + +.labeler-snapshot-controls { + display: flex; + align-items: stretch; + gap: 8px; + flex: 1 1 520px; + min-width: 0; + max-width: 720px; +} + +.labeler-snapshot-controls .snapshot-mode-toggle { + flex: 1 1 420px; +} + +.labeler-snapshot-controls .button { + flex: 0 1 auto; + white-space: nowrap; +} + +.labeler-snapshot-error { + margin-top: 8px; +} + +@media (max-width: 760px) { + .labeler-snapshot-controls { + flex-basis: 100%; + max-width: none; + flex-wrap: wrap; + } + + .labeler-snapshot-controls .snapshot-mode-toggle, + .labeler-snapshot-controls .button { + width: 100%; + flex-basis: 100%; + } +} .sidebar { grid-area: sidebar; border-right: 1px solid var(--border); background: var(--panel); padding: 12px; overflow: auto; } .canvas { grid-area: canvas; position: relative; background: #101712; } .yandex-map-host { From 0ba4bafeede359d9ccedafa3c3549df76b403199 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 29 Jul 2026 14:48:59 +0300 Subject: [PATCH 4/6] fix: align camera snapshots with backend artifacts --- src/api/cameras.ts | 74 +++++++++++++++++++++++++++++++++------------- src/api/client.ts | 2 +- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/api/cameras.ts b/src/api/cameras.ts index e967895..58e1255 100644 --- a/src/api/cameras.ts +++ b/src/api/cameras.ts @@ -1,4 +1,4 @@ -import { buildQuery, request, requestBlob } from './http'; +import { ApiRequestError, buildQuery, request, requestBlob } from './http'; export type Camera = { camera_id: number; @@ -80,24 +80,55 @@ export type CameraSnapshot = { captured_at?: string; width?: number; height?: number; + variant?: 'live' | 'raw' | 'annotated'; + detection_run_id?: number | string; }; -export type CameraSnapshotOptions = { - annotated?: boolean; - last_detection?: boolean; - fallback_to_raw?: boolean; +export type CameraSnapshotMode = 'latest' | 'detection' | 'annotated'; + +type CameraDetectionSnapshot = { + detection_run_id: number | string; + started_at?: string | null; + finished_at?: string | null; + raw_snapshot_url?: string | null; + annotated_snapshot_url?: string | null; }; -export type CameraSnapshotMode = 'latest' | 'detection' | 'annotated'; +type CameraDetectionSnapshotList = { + items: CameraDetectionSnapshot[]; +}; -function cameraSnapshotOptions(mode: CameraSnapshotMode): CameraSnapshotOptions | undefined { - if (mode === 'detection') { - return { last_detection: true }; +async function getStoredCameraSnapshot( + cameraId: number, + mode: Extract +): Promise { + const response = await request( + 'GET', + `/admin/analytics/cameras/${encodeURIComponent(cameraId)}/detections?limit=1` + ); + const detection = response.items[0]; + + if (!detection) { + throw new ApiRequestError('Для камеры пока нет сохранённых распознаваний.', 404); } - if (mode === 'annotated') { - return { annotated: true, fallback_to_raw: true }; + + const annotatedUrl = detection.annotated_snapshot_url?.trim(); + const rawUrl = detection.raw_snapshot_url?.trim(); + const imageUrl = mode === 'annotated' ? annotatedUrl || rawUrl : rawUrl; + + if (!imageUrl) { + const message = mode === 'annotated' + ? 'Для последнего распознавания нет размеченного или исходного снимка.' + : 'Для последнего распознавания нет исходного снимка.'; + throw new ApiRequestError(message, 404); } - return undefined; + + return { + image_url: imageUrl, + captured_at: detection.started_at || detection.finished_at || undefined, + variant: mode === 'annotated' && annotatedUrl ? 'annotated' : 'raw', + detection_run_id: detection.detection_run_id + }; } function formatBBox(bbox?: CameraBBox | string) { @@ -151,16 +182,19 @@ export const camerasApi = { }, async getSnapshot(cameraId: number, mode: CameraSnapshotMode = 'latest'): Promise { - const options = cameraSnapshotOptions(mode); - const query = buildQuery({ - annotated: options?.annotated, - last_detection: options?.last_detection, - fallback_to_raw: options?.fallback_to_raw - }); - const { blob, headers } = await requestBlob(`/cameras/${encodeURIComponent(cameraId)}/snapshot${query}`); + if (mode !== 'latest') { + return getStoredCameraSnapshot(cameraId, mode); + } + + const { blob, headers } = await requestBlob( + `/admin/cameras/${encodeURIComponent(cameraId)}/snapshot` + ); return { image_url: URL.createObjectURL(blob), - captured_at: headers.get('X-Captured-At') || undefined + captured_at: headers.get('X-Snapshot-Captured-At') + || headers.get('X-Captured-At') + || undefined, + variant: 'live' }; } }; diff --git a/src/api/client.ts b/src/api/client.ts index 8e3646b..f60217b 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -15,8 +15,8 @@ export type { CameraBBox, CameraListFilters, CameraMapItem, - CameraSnapshotOptions, CameraSnapshot, + CameraSnapshotMode, CamerasNextResponse, CameraView, CreateCameraRequest, From 0e1f7b5238ef9fcd9c68ad13006134e1094af463 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 29 Jul 2026 14:49:46 +0300 Subject: [PATCH 5/6] fix: enforce camera snapshot access modes --- src/components/CameraSnapshotModeSelector.tsx | 37 +++++++++--- src/components/CamerasPage.tsx | 35 +++++++++--- src/components/TopBar.tsx | 28 +++++++--- src/pages/AnalyticsPage.tsx | 56 +++++++++++++++---- 4 files changed, 122 insertions(+), 34 deletions(-) diff --git a/src/components/CameraSnapshotModeSelector.tsx b/src/components/CameraSnapshotModeSelector.tsx index 6e389ad..1cf2047 100644 --- a/src/components/CameraSnapshotModeSelector.tsx +++ b/src/components/CameraSnapshotModeSelector.tsx @@ -27,26 +27,49 @@ const CAMERA_SNAPSHOT_MODE_ORDER: CameraSnapshotMode[] = [ 'latest' ]; -export function defaultCameraSnapshotMode(canViewAnnotated: boolean): CameraSnapshotMode { - return canViewAnnotated ? 'annotated' : 'detection'; +export type CameraSnapshotAccess = { + canViewStoredSnapshots: boolean; + canViewLiveSnapshot: boolean; +}; + +export function canViewCameraSnapshotMode( + mode: CameraSnapshotMode, + access: CameraSnapshotAccess +) { + return mode === 'latest' + ? access.canViewLiveSnapshot + : access.canViewStoredSnapshots; +} + +export function availableCameraSnapshotModes(access: CameraSnapshotAccess) { + return CAMERA_SNAPSHOT_MODE_ORDER.filter(mode => canViewCameraSnapshotMode(mode, access)); +} + +export function defaultCameraSnapshotMode(access: CameraSnapshotAccess): CameraSnapshotMode { + return availableCameraSnapshotModes(access)[0] ?? 'latest'; } type CameraSnapshotModeSelectorProps = { value: CameraSnapshotMode; - canViewAnnotated: boolean; + canViewStoredSnapshots: boolean; + canViewLiveSnapshot: boolean; onChange: (mode: CameraSnapshotMode) => void; ariaLabel?: string; }; export function CameraSnapshotModeSelector({ value, - canViewAnnotated, + canViewStoredSnapshots, + canViewLiveSnapshot, onChange, ariaLabel = 'Режим просмотра кадра' }: CameraSnapshotModeSelectorProps) { - const modes = CAMERA_SNAPSHOT_MODE_ORDER.filter( - mode => mode !== 'annotated' || canViewAnnotated - ); + const modes = availableCameraSnapshotModes({ + canViewStoredSnapshots, + canViewLiveSnapshot + }); + + if (modes.length === 0) return null; return (
state.success); const confirmAction = useFeedbackStore(state => state.confirm); const currentPartnerId = useSessionStore(state => state.currentPartnerId); - const canViewAnnotatedSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view')); + const canViewStoredSnapshots = useSessionStore(state => state.hasPermission('analytics.view')); + const canViewLiveSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view')); const [cameras, setCameras] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(); @@ -314,7 +316,7 @@ export default function CamerasPage() { const [snapshot, setSnapshot] = useState({ loading: false }); const [snapshotReloadKey, setSnapshotReloadKey] = useState(0); const [snapshotMode, setSnapshotMode] = useState( - () => defaultCameraSnapshotMode(canViewAnnotatedSnapshot) + () => defaultCameraSnapshotMode({ canViewStoredSnapshots, canViewLiveSnapshot }) ); const [isSnapshotFullscreen, setIsSnapshotFullscreen] = useState(false); const [editor, setEditor] = useState(null); @@ -345,10 +347,11 @@ export default function CamerasPage() { }, []); useEffect(() => { - if (!canViewAnnotatedSnapshot && snapshotMode === 'annotated') { - setSnapshotMode('detection'); + const access = { canViewStoredSnapshots, canViewLiveSnapshot }; + if (!canViewCameraSnapshotMode(snapshotMode, access)) { + setSnapshotMode(defaultCameraSnapshotMode(access)); } - }, [canViewAnnotatedSnapshot, snapshotMode]); + }, [canViewLiveSnapshot, canViewStoredSnapshots, snapshotMode]); function fetchSnapshot(cameraId: number, mode: CameraSnapshotMode) { const key = snapshotCacheKey(cameraId, mode); @@ -481,6 +484,17 @@ export default function CamerasPage() { return; } + if (!canViewCameraSnapshotMode(snapshotMode, { + canViewStoredSnapshots, + canViewLiveSnapshot + })) { + setSnapshot({ + loading: false, + error: 'Недостаточно прав для просмотра снимков этой камеры.' + }); + return; + } + const key = snapshotCacheKey(selectedCamera.camera_id, snapshotMode); const cached = snapshotCacheRef.current.get(key); if (cached) { @@ -505,7 +519,13 @@ export default function CamerasPage() { return () => { cancelled = true; }; - }, [selectedCamera?.camera_id, snapshotMode, snapshotReloadKey]); + }, [ + canViewLiveSnapshot, + canViewStoredSnapshots, + selectedCamera?.camera_id, + snapshotMode, + snapshotReloadKey + ]); useEffect(() => { if (!selectedCamera) { @@ -947,7 +967,8 @@ export default function CamerasPage() {
{snapshot.data?.image_url && ( diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 519865b..799c021 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -9,18 +9,20 @@ import type { CameraSnapshotMode } from '@/api/cameras'; import { CAMERA_SNAPSHOT_MODE_CONTENT, CameraSnapshotModeSelector, + canViewCameraSnapshotMode, defaultCameraSnapshotMode } from './CameraSnapshotModeSelector'; export default function TopBar() { const { apiBase, token, cameraId, viewMode, setImage, image, imageCameraId, setViewMode, labelerReturnRoute } = useStore(); const sessionAccessToken = useSessionStore(state => state.accessToken); - const canViewAnnotatedSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view')); + const canViewStoredSnapshots = useSessionStore(state => state.hasPermission('analytics.view')); + const canViewLiveSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view')); const [imageUrlInput, setImageUrlInput] = useState(''); const [loadingSnapshot, setLoadingSnapshot] = useState(false); const [snapshotError, setSnapshotError] = useState(); const [snapshotMode, setSnapshotMode] = useState( - () => defaultCameraSnapshotMode(canViewAnnotatedSnapshot) + () => defaultCameraSnapshotMode({ canViewStoredSnapshots, canViewLiveSnapshot }) ); const snapshotCacheRef = useRef>>>(new Map()); const snapshotRequestsRef = useRef>>>>(new Map()); @@ -56,10 +58,11 @@ export default function TopBar() { }, [clearSnapshotCache]); useEffect(() => { - if (!canViewAnnotatedSnapshot && snapshotMode === 'annotated') { - setSnapshotMode('detection'); + const access = { canViewStoredSnapshots, canViewLiveSnapshot }; + if (!canViewCameraSnapshotMode(snapshotMode, access)) { + setSnapshotMode(defaultCameraSnapshotMode(access)); } - }, [canViewAnnotatedSnapshot, snapshotMode]); + }, [canViewLiveSnapshot, canViewStoredSnapshots, snapshotMode]); async function loadImageFromUrl() { const url = imageUrlInput.trim(); @@ -81,9 +84,14 @@ export default function TopBar() { ) => { if (!cameraId) return; - if (targetMode === 'annotated' && !canViewAnnotatedSnapshot) { + const access = { canViewStoredSnapshots, canViewLiveSnapshot }; + if (!canViewCameraSnapshotMode(targetMode, access)) { setLoadingSnapshot(false); - setSnapshotMode('detection'); + setSnapshotError('Недостаточно прав для просмотра этого варианта снимка.'); + const fallbackMode = defaultCameraSnapshotMode(access); + if (canViewCameraSnapshotMode(fallbackMode, access)) { + setSnapshotMode(fallbackMode); + } return; } @@ -172,7 +180,8 @@ export default function TopBar() { }, [ apiBase, cameraId, - canViewAnnotatedSnapshot, + canViewLiveSnapshot, + canViewStoredSnapshots, effectiveToken, image?.url, imageCameraId, @@ -222,7 +231,8 @@ export default function TopBar() {
diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 899427b..6f71588 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -32,6 +32,12 @@ import { fitYandexMap, yandexPoint, type YandexPoint } from '@/maps/yandex'; import { useStore } from '@/store/useStore'; import { navigate } from '@/router/routes'; import { type CameraSnapshotMode } from '@/api/cameras'; +import { + availableCameraSnapshotModes, + CAMERA_SNAPSHOT_MODE_CONTENT, + canViewCameraSnapshotMode, + defaultCameraSnapshotMode +} from '@/components/CameraSnapshotModeSelector'; type PeriodPreset = 'today' | 'yesterday' | '1h' | '6h' | '12h' | '24h' | '7d' | '30d' | 'custom'; type AutoRefreshInterval = 'off' | '10s' | '30s' | '1m' | '5m' | '15m' | '30m' | '1h'; @@ -2262,19 +2268,35 @@ function CameraAnalyticsPage({ cameraId }: { cameraId: string }) { } function CameraSnapshots({ cameraId }: { cameraId: number }) { - const canViewAnnotatedSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view')); - const [tab, setTab] = useState('latest'); + const canViewStoredSnapshots = useSessionStore(state => state.hasPermission('analytics.view')); + const canViewLiveSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view')); + const [tab, setTab] = useState(() => defaultCameraSnapshotMode({ + canViewStoredSnapshots, + canViewLiveSnapshot + })); const [snapshot, setSnapshot] = useState>(emptyState); const [fullscreen, setFullscreen] = useState(false); const visibleRequestRef = useRef(0); useEffect(() => { - if (!canViewAnnotatedSnapshot && tab === 'annotated') { - setTab('latest'); + const access = { canViewStoredSnapshots, canViewLiveSnapshot }; + if (!canViewCameraSnapshotMode(tab, access)) { + setTab(defaultCameraSnapshotMode(access)); } - }, [canViewAnnotatedSnapshot, tab]); + }, [canViewLiveSnapshot, canViewStoredSnapshots, tab]); const load = useCallback(async (targetTab: CameraSnapshotMode, force = false) => { + if (!canViewCameraSnapshotMode(targetTab, { + canViewStoredSnapshots, + canViewLiveSnapshot + })) { + setSnapshot({ + loading: false, + error: 'Недостаточно прав для просмотра этого варианта снимка.' + }); + return; + } + const requestId = ++visibleRequestRef.current; setSnapshot({ loading: true }); try { @@ -2285,20 +2307,32 @@ function CameraSnapshots({ cameraId }: { cameraId: number }) { if (visibleRequestRef.current !== requestId) return; setSnapshot({ loading: false, error: blockError(error) }); } - }, [cameraId]); + }, [cameraId, canViewLiveSnapshot, canViewStoredSnapshots]); useEffect(() => { load(tab); }, [load, tab]); function renderTabs(className = '') { + const modes = availableCameraSnapshotModes({ + canViewStoredSnapshots, + canViewLiveSnapshot + }); + return (
- - - {canViewAnnotatedSnapshot && ( - - )} + {modes.map(mode => ( + + ))}
); } From 844dc909fd43502439d49ad809858b32ece3a01c Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Wed, 29 Jul 2026 14:50:29 +0300 Subject: [PATCH 6/6] docs: update camera snapshot contract --- README.en.md | 10 +++++----- README.md | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.en.md b/README.en.md index dfc56f5..393311d 100644 --- a/README.en.md +++ b/README.en.md @@ -290,13 +290,13 @@ The frontend uses the following primary ParkTrack API groups: - `/health`; - `/version`. -Three modes are supported for `/cameras/{camera_id}/snapshot`: +The interface supports three camera snapshot modes: -- no query parameters: a fresh snapshot from the video stream; -- `last_detection=true`: the snapshot saved at the time of the last detection; -- `annotated=true&fallback_to_raw=true`: recognition visualization with an optional fallback to a raw snapshot. +- `Annotated`: the latest detection's `annotated_snapshot_url`, falling back to `raw_snapshot_url`; +- `Last detection`: the latest detection's `raw_snapshot_url`; +- `Latest snapshot`: a fresh frame from the video stream. -Raw snapshots and last detection snapshots require `cameras.view`. For recognition visualization, the backend additionally checks `admin.monitoring.view`. +Stored artifacts are loaded through `/admin/analytics/cameras/{camera_id}/detections?limit=1` and require `analytics.view`. The live frame is requested through `/admin/cameras/{camera_id}/snapshot` and requires `admin.monitoring.view`. Each mode is cached independently until the page is refreshed, while refresh invalidates only the active variant. Primary analytics endpoints: diff --git a/README.md b/README.md index f786162..a80f313 100644 --- a/README.md +++ b/README.md @@ -290,13 +290,13 @@ Production-образ собирает приложение на Node.js и от - `/health`; - `/version`. -Для `/cameras/{camera_id}/snapshot` поддерживаются три режима: +Интерфейс поддерживает три режима снимков камеры: -- без query-параметров — свежий кадр из видеопотока; -- `last_detection=true` — кадр, сохранённый в момент последней детекции; -- `annotated=true&fallback_to_raw=true` — визуализация распознавания с возможным fallback на обычный кадр. +- `С разметкой` — `annotated_snapshot_url` последней детекции с fallback на `raw_snapshot_url`; +- `Последнее распознавание` — `raw_snapshot_url` последней детекции; +- `Последний снимок` — свежий кадр из видеопотока. -Обычный кадр и кадр последней детекции требуют `cameras.view`. Для визуализации распознавания backend дополнительно проверяет `admin.monitoring.view`. +Сохранённые артефакты загружаются через `/admin/analytics/cameras/{camera_id}/detections?limit=1` и требуют `analytics.view`. Свежий кадр запрашивается через `/admin/cameras/{camera_id}/snapshot` и требует `admin.monitoring.view`. Каждый режим кешируется отдельно до обновления страницы, а кнопка обновления сбрасывает только активный вариант. Основные analytics endpoints: