From 660d34387906c206f9e269d17086b036a5cc6c6f Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Sun, 7 Jun 2026 14:02:10 +0300 Subject: [PATCH 1/6] feat: load last detection camera snapshots --- src/api/cameras.ts | 2 ++ src/pages/AnalyticsPage.tsx | 25 +++++++++++++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/api/cameras.ts b/src/api/cameras.ts index 3420de5..66bb03d 100644 --- a/src/api/cameras.ts +++ b/src/api/cameras.ts @@ -84,6 +84,7 @@ export type CameraSnapshot = { export type CameraSnapshotOptions = { annotated?: boolean; + last_detection?: boolean; fallback_to_raw?: boolean; }; @@ -140,6 +141,7 @@ export const camerasApi = { async getSnapshot(cameraId: number, options?: CameraSnapshotOptions): Promise { 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}`); diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 20e15bc..5186f01 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -107,13 +107,13 @@ const GRANULARITY_LABELS: Record = { '1h': '1 час', '1d': '1 день' }; -type CameraSnapshotTab = 'snapshot' | 'raw' | 'annotated'; +type CameraSnapshotTab = 'latest' | 'detection' | 'annotated'; const cameraSnapshotCache = new Map(); const cameraSnapshotRequests = new Map>(); function cameraSnapshotCacheKey(cameraId: number, tab: CameraSnapshotTab) { - return `${cameraId}:${tab === 'annotated' ? 'annotated' : 'raw'}`; + return `${cameraId}:${tab}`; } function revokeCameraSnapshot(snapshot?: CameraSnapshot) { @@ -131,10 +131,12 @@ function fetchCameraSnapshot(cameraId: number, tab: CameraSnapshotTab, force = f if (!force && pending) return pending; let request: Promise; - request = api.getSnapshot(cameraId, { - annotated: tab === 'annotated', - fallback_to_raw: true - }).then(snapshot => { + const options = tab === 'annotated' + ? { annotated: true, fallback_to_raw: true } + : tab === 'detection' + ? { last_detection: true } + : undefined; + request = api.getSnapshot(cameraId, options).then(snapshot => { if (cameraSnapshotRequests.get(cacheKey) !== request) { revokeCameraSnapshot(snapshot); return snapshot; @@ -2266,7 +2268,8 @@ function CameraAnalyticsPage({ cameraId }: { cameraId: string }) { } function CameraSnapshots({ cameraId }: { cameraId: number }) { - const [tab, setTab] = useState('snapshot'); + const canViewAnnotatedSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view')); + const [tab, setTab] = useState('latest'); const [snapshot, setSnapshot] = useState>(emptyState); const [fullscreen, setFullscreen] = useState(false); const visibleRequestRef = useRef(0); @@ -2291,9 +2294,11 @@ function CameraSnapshots({ cameraId }: { cameraId: number }) { function renderTabs(className = '') { return (
- - - + + + {canViewAnnotatedSnapshot && ( + + )}
); } From 4958f2dc0dcf9a6d9823eb9a1b3c85f7b2a01488 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Sun, 7 Jun 2026 14:04:05 +0300 Subject: [PATCH 2/6] docs: document camera snapshot modes --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 43934ae..fe1540c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - карта зон и камер; - отдельные страницы аналитики зоны, камеры и запуска распознавания; - оценка качества распознавания и просмотр feedback; -- кеширование просмотренных снимков камеры до обновления страницы; +- независимое кеширование последнего кадра, последней детекции и размеченного снимка до обновления страницы; - переключение снимков и обновление текущего кадра в полноэкранном просмотре. ### Пользователи @@ -70,8 +70,8 @@ - автоматическое определение размера изображения; - вертикальная прокрутка списка камер; - Яндекс.Карта с выбором и позиционированием камеры; -- кеширование обычного и размеченного snapshot; -- переключение между текущим кадром и распознанными автомобилями; +- кеширование свежего кадра, кадра последней детекции и размеченного snapshot; +- переключение между текущим кадром, последним распознаванием и визуализацией распознавания; - полноэкранный просмотр снимков; - переход к настройке и разметке конкретной камеры; - массовая активация, деактивация и удаление выбранных камер. @@ -288,6 +288,14 @@ Production-образ собирает приложение на Node.js и от - `/health`; - `/version`. +Для `/cameras/{camera_id}/snapshot` поддерживаются три режима: + +- без query-параметров — свежий кадр из видеопотока; +- `last_detection=true` — кадр, сохранённый в момент последней детекции; +- `annotated=true&fallback_to_raw=true` — визуализация распознавания с возможным fallback на обычный кадр. + +Обычный кадр и кадр последней детекции требуют `cameras.view`. Для визуализации распознавания backend дополнительно проверяет `admin.monitoring.view`. + Основные analytics endpoints: - `/admin/analytics/summary`; @@ -324,7 +332,7 @@ Production-образ собирает приложение на Node.js и от 6. Открыть камеру: - проверить фильтры; - создать или отредактировать камеру; - - переключить обычный и размеченный snapshot; + - переключить свежий кадр, последнюю детекцию и размеченный snapshot; - открыть snapshot на весь экран; - отметить камеру на карте. 7. Перейти в разметку: From 9ecefd32443706e504709584a8daa431affcf09b Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Sun, 7 Jun 2026 14:08:33 +0300 Subject: [PATCH 3/6] docs: deleted useless things --- README.md | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/README.md b/README.md index fe1540c..9714826 100644 --- a/README.md +++ b/README.md @@ -313,47 +313,6 @@ Production-образ собирает приложение на Node.js и от Защищённые запросы отправляются с bearer-токеном текущей сессии. Сессия сохраняется в `localStorage`, переживает обновление страницы и валидируется через `/auth/me`. -## Smoke-проверка - -Перед проверкой должны быть запущены PostgreSQL, миграции, backend и frontend. - -1. Войти обычным пользователем и администратором. -2. Обновить страницу и убедиться, что сессия сохранилась. -3. Проверить доступность разделов согласно permissions. -4. Открыть обзор и проверить сводные показатели и зоны внимания. -5. Открыть аналитику: - - изменить период и детализацию; - - выбрать зоны и камеры; - - проверить ручное и автоматическое обновление; - - выбрать срез прогноза; - - проверить tooltips графиков; - - открыть аналитику зоны и камеры; - - проверить таблицу состояния всех зон. -6. Открыть камеру: - - проверить фильтры; - - создать или отредактировать камеру; - - переключить свежий кадр, последнюю детекцию и размеченный snapshot; - - открыть snapshot на весь экран; - - отметить камеру на карте. -7. Перейти в разметку: - - создать зону; - - переместить вершины и линии; - - переместить и масштабировать кадр; - - открыть fullscreen и проверить центрирование; - - изменить геометрию зоны на карте; - - сохранить изменения. -8. Проверить фильтры, редактирование и bulk actions камер, зон, пользователей и партнёров. -9. Проверить добавление пользователя в партнёра и изменение доступов. -10. Проверить реестр источников. -11. Проверить профиль и восстановление пароля. -12. Выполнить production-сборку: - -```bash -npm run build -``` - -Сборка должна завершиться без ошибок TypeScript и Vite. - ## Правила разработки - сохранять существующие API-контракты и permission checks; From e3394bf71e6331123d1bd02debff19b4ff15f9b9 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Sun, 7 Jun 2026 14:37:29 +0300 Subject: [PATCH 4/6] refactor: unify camera snapshot modes --- src/api/cameras.ts | 12 ++++++++++++ src/pages/AnalyticsPage.tsx | 18 ++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/api/cameras.ts b/src/api/cameras.ts index 66bb03d..d52e1e7 100644 --- a/src/api/cameras.ts +++ b/src/api/cameras.ts @@ -88,6 +88,18 @@ export type CameraSnapshotOptions = { fallback_to_raw?: boolean; }; +export type CameraSnapshotMode = 'latest' | 'detection' | 'annotated'; + +export function cameraSnapshotOptions(mode: CameraSnapshotMode): CameraSnapshotOptions | undefined { + if (mode === 'detection') { + return { last_detection: true }; + } + if (mode === 'annotated') { + return { annotated: true, fallback_to_raw: true }; + } + return undefined; +} + function formatBBox(bbox?: CameraBBox | string) { if (!bbox) return undefined; if (typeof bbox === 'string') return bbox; diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 5186f01..1783333 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -31,6 +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'; type PeriodPreset = 'today' | 'yesterday' | '1h' | '6h' | '12h' | '24h' | '7d' | '30d' | 'custom'; type AutoRefreshInterval = 'off' | '10s' | '30s' | '1m' | '5m' | '15m' | '30m' | '1h'; @@ -107,12 +108,10 @@ const GRANULARITY_LABELS: Record = { '1h': '1 час', '1d': '1 день' }; -type CameraSnapshotTab = 'latest' | 'detection' | 'annotated'; - const cameraSnapshotCache = new Map(); const cameraSnapshotRequests = new Map>(); -function cameraSnapshotCacheKey(cameraId: number, tab: CameraSnapshotTab) { +function cameraSnapshotCacheKey(cameraId: number, tab: CameraSnapshotMode) { return `${cameraId}:${tab}`; } @@ -122,7 +121,7 @@ function revokeCameraSnapshot(snapshot?: CameraSnapshot) { } } -function fetchCameraSnapshot(cameraId: number, tab: CameraSnapshotTab, force = false) { +function fetchCameraSnapshot(cameraId: number, tab: CameraSnapshotMode, force = false) { const cacheKey = cameraSnapshotCacheKey(cameraId, tab); const cached = cameraSnapshotCache.get(cacheKey); if (!force && cached) return Promise.resolve(cached); @@ -131,12 +130,7 @@ function fetchCameraSnapshot(cameraId: number, tab: CameraSnapshotTab, force = f if (!force && pending) return pending; let request: Promise; - const options = tab === 'annotated' - ? { annotated: true, fallback_to_raw: true } - : tab === 'detection' - ? { last_detection: true } - : undefined; - request = api.getSnapshot(cameraId, options).then(snapshot => { + request = api.getSnapshot(cameraId, cameraSnapshotOptions(tab)).then(snapshot => { if (cameraSnapshotRequests.get(cacheKey) !== request) { revokeCameraSnapshot(snapshot); return snapshot; @@ -2269,12 +2263,12 @@ 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 [tab, setTab] = useState('latest'); const [snapshot, setSnapshot] = useState>(emptyState); const [fullscreen, setFullscreen] = useState(false); const visibleRequestRef = useRef(0); - const load = useCallback(async (targetTab: CameraSnapshotTab, force = false) => { + const load = useCallback(async (targetTab: CameraSnapshotMode, force = false) => { const requestId = ++visibleRequestRef.current; setSnapshot({ loading: true }); try { From ba3ebbf5de5cc3c5309a6ee29d37744eb714df44 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Sun, 7 Jun 2026 14:40:59 +0300 Subject: [PATCH 5/6] feat: complete camera snapshot mode selector --- src/components/CamerasPage.tsx | 73 +++++++++++++++++++--------------- src/styles.css | 6 +-- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx index 678b216..863d3c6 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -8,6 +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'; function hasCoordinates(latitude?: number | null, longitude?: number | null): latitude is number { return typeof latitude === 'number' @@ -22,8 +23,8 @@ type SnapshotState = { data?: CameraSnapshot; }; -function snapshotCacheKey(cameraId: number, annotated: boolean) { - return `${cameraId}:${annotated ? 'annotated' : 'frame'}`; +function snapshotCacheKey(cameraId: number, mode: CameraSnapshotMode) { + return `${cameraId}:${mode}`; } function revokeSnapshotData(data?: CameraSnapshot) { @@ -48,6 +49,21 @@ 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 { @@ -306,7 +322,7 @@ export default function CamerasPage() { const snapshotCacheAliveRef = useRef(true); const [snapshot, setSnapshot] = useState({ loading: false }); const [snapshotReloadKey, setSnapshotReloadKey] = useState(0); - const [snapshotAnnotated, setSnapshotAnnotated] = useState(true); + const [snapshotMode, setSnapshotMode] = useState('latest'); const [isSnapshotFullscreen, setIsSnapshotFullscreen] = useState(false); const [editor, setEditor] = useState(null); const [saveState, setSaveState] = useState({ loading: false }); @@ -335,8 +351,8 @@ export default function CamerasPage() { }; }, []); - function fetchSnapshot(cameraId: number, annotated: boolean) { - const key = snapshotCacheKey(cameraId, annotated); + function fetchSnapshot(cameraId: number, mode: CameraSnapshotMode) { + const key = snapshotCacheKey(cameraId, mode); const cached = snapshotCacheRef.current.get(key); if (cached) return Promise.resolve(cached); @@ -344,10 +360,7 @@ export default function CamerasPage() { if (inFlight) return inFlight; let request: Promise; - request = api.getSnapshot(cameraId, { - annotated, - fallback_to_raw: true - }).then(data => { + request = api.getSnapshot(cameraId, cameraSnapshotOptions(mode)).then(data => { if (!snapshotCacheAliveRef.current) { revokeSnapshotData(data); return data; @@ -374,16 +387,9 @@ export default function CamerasPage() { return request; } - function prefetchOtherSnapshotMode(cameraId: number, annotated: boolean) { - const otherMode = !annotated; - const key = snapshotCacheKey(cameraId, otherMode); - if (snapshotCacheRef.current.has(key) || snapshotRequestsRef.current.has(key)) return; - fetchSnapshot(cameraId, otherMode).catch(() => undefined); - } - function refreshCurrentSnapshot() { if (selectedCamera) { - const key = snapshotCacheKey(selectedCamera.camera_id, snapshotAnnotated); + const key = snapshotCacheKey(selectedCamera.camera_id, snapshotMode); const cached = snapshotCacheRef.current.get(key); revokeSnapshotData(cached); snapshotCacheRef.current.delete(key); @@ -476,20 +482,18 @@ export default function CamerasPage() { return; } - const key = snapshotCacheKey(selectedCamera.camera_id, snapshotAnnotated); + const key = snapshotCacheKey(selectedCamera.camera_id, snapshotMode); const cached = snapshotCacheRef.current.get(key); if (cached) { setSnapshot({ loading: false, data: cached }); - prefetchOtherSnapshotMode(selectedCamera.camera_id, snapshotAnnotated); return; } setSnapshot({ loading: true }); try { - const data = await fetchSnapshot(selectedCamera.camera_id, snapshotAnnotated); + const data = await fetchSnapshot(selectedCamera.camera_id, snapshotMode); if (!cancelled) { setSnapshot({ loading: false, data }); - prefetchOtherSnapshotMode(selectedCamera.camera_id, snapshotAnnotated); } } catch (e: any) { if (!cancelled) { @@ -502,7 +506,7 @@ export default function CamerasPage() { return () => { cancelled = true; }; - }, [selectedCamera?.camera_id, snapshotAnnotated, snapshotReloadKey]); + }, [selectedCamera?.camera_id, snapshotMode, snapshotReloadKey]); useEffect(() => { if (!selectedCamera) { @@ -938,26 +942,31 @@ export default function CamerasPage() {
-

{snapshotAnnotated ? 'Распознанные автомобили' : 'Текущий кадр'}

-
- {snapshotAnnotated ? 'Разметка включена' : 'Разметка скрыта'} -
+

{SNAPSHOT_MODE_CONTENT[snapshotMode].title}

+
{SNAPSHOT_MODE_CONTENT[snapshotMode].description}
+
{snapshot.data?.image_url && ( diff --git a/src/styles.css b/src/styles.css index 3b89798..4c56841 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1641,15 +1641,15 @@ hr { border: none; height: 1px; background: var(--border); margin: 10px 0; } .snapshot-mode-toggle { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); min-height: 36px; padding: 3px; border: 1px solid var(--border); border-radius: 8px; background: #eef6f0; - min-width: 176px; + min-width: min(100%, 360px); max-width: 100%; - flex: 1 1 190px; + flex: 1 1 360px; } .snapshot-mode-option { From 1d43ca578b478ab3d8739001f141ecedd5a9e310 Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Sun, 7 Jun 2026 14:44:20 +0300 Subject: [PATCH 6/6] fix: respect annotated snapshot permission --- src/components/CamerasPage.tsx | 29 +++++++++++++++++++++-------- src/pages/AnalyticsPage.tsx | 6 ++++++ src/styles.css | 6 +++++- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx index 863d3c6..2ebfa16 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -302,6 +302,7 @@ export default function CamerasPage() { const notifySuccess = useFeedbackStore(state => state.success); const confirmAction = useFeedbackStore(state => state.confirm); const currentPartnerId = useSessionStore(state => state.currentPartnerId); + const canViewAnnotatedSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view')); const [cameras, setCameras] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(); @@ -351,6 +352,12 @@ export default function CamerasPage() { }; }, []); + useEffect(() => { + if (!canViewAnnotatedSnapshot && snapshotMode === 'annotated') { + setSnapshotMode('latest'); + } + }, [canViewAnnotatedSnapshot, snapshotMode]); + function fetchSnapshot(cameraId: number, mode: CameraSnapshotMode) { const key = snapshotCacheKey(cameraId, mode); const cached = snapshotCacheRef.current.get(key); @@ -946,7 +953,11 @@ export default function CamerasPage() {
{SNAPSHOT_MODE_CONTENT[snapshotMode].description}
-
+
- + {canViewAnnotatedSnapshot && ( + + )}
{snapshot.data?.image_url && (