Skip to content
Merged
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
55 changes: 11 additions & 44 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
- карта зон и камер;
- отдельные страницы аналитики зоны, камеры и запуска распознавания;
- оценка качества распознавания и просмотр feedback;
- кеширование просмотренных снимков камеры до обновления страницы;
- независимое кеширование последнего кадра, последней детекции и размеченного снимка до обновления страницы;
- переключение снимков и обновление текущего кадра в полноэкранном просмотре.

### Пользователи
Expand Down Expand Up @@ -70,8 +70,8 @@
- автоматическое определение размера изображения;
- вертикальная прокрутка списка камер;
- Яндекс.Карта с выбором и позиционированием камеры;
- кеширование обычного и размеченного snapshot;
- переключение между текущим кадром и распознанными автомобилями;
- кеширование свежего кадра, кадра последней детекции и размеченного snapshot;
- переключение между текущим кадром, последним распознаванием и визуализацией распознавания;
- полноэкранный просмотр снимков;
- переход к настройке и разметке конкретной камеры;
- массовая активация, деактивация и удаление выбранных камер.
Expand Down Expand Up @@ -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`;
Expand All @@ -305,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;
Expand Down
14 changes: 14 additions & 0 deletions src/api/cameras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,22 @@ export type CameraSnapshot = {

export type CameraSnapshotOptions = {
annotated?: boolean;
last_detection?: boolean;
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;
Expand Down Expand Up @@ -140,6 +153,7 @@ export const camerasApi = {
async getSnapshot(cameraId: number, options?: CameraSnapshotOptions): Promise<CameraSnapshot> {
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}`);
Expand Down
88 changes: 55 additions & 33 deletions src/components/CamerasPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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) {
Expand All @@ -48,6 +49,21 @@ type CameraSaveState = {
error?: string;
};

const SNAPSHOT_MODE_CONTENT: Record<CameraSnapshotMode, { title: string; description: string }> = {
latest: {
title: 'Последний снимок',
description: 'Свежий кадр из видеопотока'
},
detection: {
title: 'Последнее распознавание',
description: 'Кадр, сохранённый в момент последней детекции'
},
annotated: {
title: 'С разметкой',
description: 'Последняя визуализация распознавания'
}
};

function formatDate(dateStr?: string): string {
if (!dateStr) return '—';
try {
Expand Down Expand Up @@ -286,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<Camera[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>();
Expand All @@ -306,7 +323,7 @@ export default function CamerasPage() {
const snapshotCacheAliveRef = useRef(true);
const [snapshot, setSnapshot] = useState<SnapshotState>({ loading: false });
const [snapshotReloadKey, setSnapshotReloadKey] = useState(0);
const [snapshotAnnotated, setSnapshotAnnotated] = useState(true);
const [snapshotMode, setSnapshotMode] = useState<CameraSnapshotMode>('latest');
const [isSnapshotFullscreen, setIsSnapshotFullscreen] = useState(false);
const [editor, setEditor] = useState<CameraEditorState | null>(null);
const [saveState, setSaveState] = useState<CameraSaveState>({ loading: false });
Expand Down Expand Up @@ -335,19 +352,22 @@ export default function CamerasPage() {
};
}, []);

function fetchSnapshot(cameraId: number, annotated: boolean) {
const key = snapshotCacheKey(cameraId, annotated);
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);
if (cached) return Promise.resolve(cached);

const inFlight = snapshotRequestsRef.current.get(key);
if (inFlight) return inFlight;

let request: Promise<CameraSnapshot>;
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;
Expand All @@ -374,16 +394,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);
Expand Down Expand Up @@ -476,20 +489,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) {
Expand All @@ -502,7 +513,7 @@ export default function CamerasPage() {
return () => {
cancelled = true;
};
}, [selectedCamera?.camera_id, snapshotAnnotated, snapshotReloadKey]);
}, [selectedCamera?.camera_id, snapshotMode, snapshotReloadKey]);

useEffect(() => {
if (!selectedCamera) {
Expand Down Expand Up @@ -938,27 +949,38 @@ export default function CamerasPage() {
<div className="camera-preview" ref={snapshotPreviewRef}>
<div className="camera-preview-header">
<div>
<h3>{snapshotAnnotated ? 'Распознанные автомобили' : 'Текущий кадр'}</h3>
<div className="small">
{snapshotAnnotated ? 'Разметка включена' : 'Разметка скрыта'}
</div>
<h3>{SNAPSHOT_MODE_CONTENT[snapshotMode].title}</h3>
<div className="small">{SNAPSHOT_MODE_CONTENT[snapshotMode].description}</div>
</div>
<div className="camera-preview-actions">
<div className="snapshot-mode-toggle" role="group" aria-label="Режим просмотра кадра">
<div
className={`snapshot-mode-toggle ${canViewAnnotatedSnapshot ? 'three-options' : ''}`}
role="group"
aria-label="Режим просмотра кадра"
>
<button
type="button"
className={`snapshot-mode-option ${snapshotAnnotated ? 'active' : ''}`}
onClick={() => setSnapshotAnnotated(true)}
className={`snapshot-mode-option ${snapshotMode === 'latest' ? 'active' : ''}`}
onClick={() => setSnapshotMode('latest')}
>
Разметка
Последний снимок
</button>
<button
type="button"
className={`snapshot-mode-option ${!snapshotAnnotated ? 'active' : ''}`}
onClick={() => setSnapshotAnnotated(false)}
className={`snapshot-mode-option ${snapshotMode === 'detection' ? 'active' : ''}`}
onClick={() => setSnapshotMode('detection')}
>
Кадр
Последнее распознавание
</button>
{canViewAnnotatedSnapshot && (
<button
type="button"
className={`snapshot-mode-option ${snapshotMode === 'annotated' ? 'active' : ''}`}
onClick={() => setSnapshotMode('annotated')}
>
С разметкой
</button>
)}
</div>
{snapshot.data?.image_url && (
<Button
Expand Down
33 changes: 19 additions & 14 deletions src/pages/AnalyticsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -107,13 +108,11 @@ const GRANULARITY_LABELS: Record<AnalyticsGranularity, string> = {
'1h': '1 час',
'1d': '1 день'
};
type CameraSnapshotTab = 'snapshot' | 'raw' | 'annotated';

const cameraSnapshotCache = new Map<string, CameraSnapshot>();
const cameraSnapshotRequests = new Map<string, Promise<CameraSnapshot>>();

function cameraSnapshotCacheKey(cameraId: number, tab: CameraSnapshotTab) {
return `${cameraId}:${tab === 'annotated' ? 'annotated' : 'raw'}`;
function cameraSnapshotCacheKey(cameraId: number, tab: CameraSnapshotMode) {
return `${cameraId}:${tab}`;
}

function revokeCameraSnapshot(snapshot?: CameraSnapshot) {
Expand All @@ -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);
Expand All @@ -131,10 +130,7 @@ function fetchCameraSnapshot(cameraId: number, tab: CameraSnapshotTab, force = f
if (!force && pending) return pending;

let request: Promise<CameraSnapshot>;
request = api.getSnapshot(cameraId, {
annotated: tab === 'annotated',
fallback_to_raw: true
}).then(snapshot => {
request = api.getSnapshot(cameraId, cameraSnapshotOptions(tab)).then(snapshot => {
if (cameraSnapshotRequests.get(cacheKey) !== request) {
revokeCameraSnapshot(snapshot);
return snapshot;
Expand Down Expand Up @@ -2266,12 +2262,19 @@ function CameraAnalyticsPage({ cameraId }: { cameraId: string }) {
}

function CameraSnapshots({ cameraId }: { cameraId: number }) {
const [tab, setTab] = useState<CameraSnapshotTab>('snapshot');
const canViewAnnotatedSnapshot = useSessionStore(state => state.hasPermission('admin.monitoring.view'));
const [tab, setTab] = useState<CameraSnapshotMode>('latest');
const [snapshot, setSnapshot] = useState<LoadState<CameraSnapshot>>(emptyState);
const [fullscreen, setFullscreen] = useState(false);
const visibleRequestRef = useRef(0);

const load = useCallback(async (targetTab: CameraSnapshotTab, force = false) => {
useEffect(() => {
if (!canViewAnnotatedSnapshot && tab === 'annotated') {
setTab('latest');
}
}, [canViewAnnotatedSnapshot, tab]);

const load = useCallback(async (targetTab: CameraSnapshotMode, force = false) => {
const requestId = ++visibleRequestRef.current;
setSnapshot({ loading: true });
try {
Expand All @@ -2291,9 +2294,11 @@ function CameraSnapshots({ cameraId }: { cameraId: number }) {
function renderTabs(className = '') {
return (
<div className={`segmented analytics-snapshot-tabs ${className}`.trim()} role="tablist" aria-label="Вариант снимка камеры">
<button type="button" role="tab" aria-selected={tab === 'snapshot'} className={tab === 'snapshot' ? 'active' : ''} onClick={() => setTab('snapshot')}>Последний снимок</button>
<button type="button" role="tab" aria-selected={tab === 'raw'} className={tab === 'raw' ? 'active' : ''} onClick={() => setTab('raw')}>Последнее распознавание</button>
<button type="button" role="tab" aria-selected={tab === 'annotated'} className={tab === 'annotated' ? 'active' : ''} onClick={() => setTab('annotated')}>С разметкой</button>
<button type="button" role="tab" aria-selected={tab === 'latest'} className={tab === 'latest' ? 'active' : ''} onClick={() => setTab('latest')}>Последний снимок</button>
<button type="button" role="tab" aria-selected={tab === 'detection'} className={tab === 'detection' ? 'active' : ''} onClick={() => setTab('detection')}>Последнее распознавание</button>
{canViewAnnotatedSnapshot && (
<button type="button" role="tab" aria-selected={tab === 'annotated'} className={tab === 'annotated' ? 'active' : ''} onClick={() => setTab('annotated')}>С разметкой</button>
)}
</div>
);
}
Expand Down
Loading
Loading