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: diff --git a/src/api/cameras.ts b/src/api/cameras.ts index d52e1e7..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[]; +}; -export 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) { @@ -150,16 +181,20 @@ export const camerasApi = { return request('GET', '/cameras/next'); }, - 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}`); + async getSnapshot(cameraId: number, mode: CameraSnapshotMode = 'latest'): Promise { + 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 1acd20c..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, @@ -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/CameraSnapshotModeSelector.tsx b/src/components/CameraSnapshotModeSelector.tsx new file mode 100644 index 0000000..1cf2047 --- /dev/null +++ b/src/components/CameraSnapshotModeSelector.tsx @@ -0,0 +1,98 @@ +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 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; + canViewStoredSnapshots: boolean; + canViewLiveSnapshot: boolean; + onChange: (mode: CameraSnapshotMode) => void; + ariaLabel?: string; +}; + +export function CameraSnapshotModeSelector({ + value, + canViewStoredSnapshots, + canViewLiveSnapshot, + onChange, + ariaLabel = 'Режим просмотра кадра' +}: CameraSnapshotModeSelectorProps) { + const modes = availableCameraSnapshotModes({ + canViewStoredSnapshots, + canViewLiveSnapshot + }); + + if (modes.length === 0) return null; + + 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 2ebfa16..5901bba 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -8,7 +8,13 @@ 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'; +import { + CAMERA_SNAPSHOT_MODE_CONTENT, + CameraSnapshotModeSelector, + canViewCameraSnapshotMode, + defaultCameraSnapshotMode +} from './CameraSnapshotModeSelector'; function hasCoordinates(latitude?: number | null, longitude?: number | null): latitude is number { return typeof latitude === 'number' @@ -49,21 +55,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 { @@ -302,7 +293,8 @@ 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 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(); @@ -323,7 +315,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({ canViewStoredSnapshots, canViewLiveSnapshot }) + ); const [isSnapshotFullscreen, setIsSnapshotFullscreen] = useState(false); const [editor, setEditor] = useState(null); const [saveState, setSaveState] = useState({ loading: false }); @@ -353,10 +347,11 @@ export default function CamerasPage() { }, []); useEffect(() => { - if (!canViewAnnotatedSnapshot && snapshotMode === 'annotated') { - setSnapshotMode('latest'); + 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); @@ -367,7 +362,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; @@ -489,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) { @@ -513,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) { @@ -949,39 +961,16 @@ 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 +267,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 +281,9 @@ export default function TopBar() { )}
+ {isLabeler && snapshotError && ( +
{snapshotError}
+ )}
); } @@ -157,3 +298,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/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 6cbb23b..6f71588 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -31,7 +31,13 @@ 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'; +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'; @@ -130,7 +136,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; @@ -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 => ( + + ))}
); } 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 {