diff --git a/.env.example b/.env.example index 92f4fa3..c73fc67 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ VITE_API_BASE_URL=http://127.0.0.1:8000/api/v1 VITE_YANDEX_MAPS_API_KEY= +VITE_ANALYTICS_STALE_MINUTES=10 diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index 939b9b8..69f88ae 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -9,6 +9,7 @@ on: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} + IS_DEFAULT: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} jobs: build-and-push: @@ -57,10 +58,37 @@ jobs: with: context: . build-args: | - VITE_API_BASE_URL=${{ vars.VITE_API_BASE_URL }} + VITE_API_BASE_URL=${{ github.ref != format('refs/heads/{0}', github.event.repository.default_branch) && vars.VITE_DEV_API_BASE_URL || vars.VITE_API_BASE_URL }} push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha platforms: linux/amd64,linux/arm64 cache-to: type=gha,mode=max + deploy: + needs: + - build-and-push + + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + if: github.event_name == 'push' + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_IP }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + if [ "${{ env.IS_DEFAULT }}" = "true" ]; then + cd ${{ secrets.COMPOSE_DIRECTORY_PATH }} + docker compose up -d + else + cd ${{ secrets.DEVELOPMENT_COMPOSE_DIRECTORY_PATH }} + docker compose -p parktrack-dev up -d + fi \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index d74c5c2..151a566 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,9 +6,10 @@ import CamerasPage from '@/components/CamerasPage'; import CameraMapSelector from '@/components/CameraMapSelector'; import ZoneMapSelector from '@/components/ZoneMapSelector'; import { useStore } from '@/store/useStore'; -import AdminShell from '@/layout/AdminShell'; +import AdminShell, { canViewAnalyticsSection } from '@/layout/AdminShell'; import AccessStatePage from '@/pages/AccessStatePage'; import AuthPage from '@/pages/AuthPage'; +import AnalyticsPage from '@/pages/AnalyticsPage'; import DashboardPage from '@/pages/DashboardPage'; import PartnersAdminPage from '@/pages/PartnersAdminPage'; import PasswordResetPage from '@/pages/PasswordResetPage'; @@ -155,8 +156,9 @@ export default function App() { ); } else { + const analyticsAccessDenied = route === 'analytics' && !canViewAnalyticsSection(useSessionStore.getState()); const requiredPermissions = routePermissions[route]; - if (requiredPermissions && !requiredPermissions.some(permission => sessionHasPermission(permission))) { + if (analyticsAccessDenied || (requiredPermissions && !requiredPermissions.some(permission => sessionHasPermission(permission)))) { content = ( ; if (route === 'users') return ; if (route === 'partners') return ; + if (route === 'analytics') return ; if (route === 'zones') return ; if (route === 'sources') return ; if (route === 'cameras') { diff --git a/src/api/analytics.ts b/src/api/analytics.ts new file mode 100644 index 0000000..8f8aed5 --- /dev/null +++ b/src/api/analytics.ts @@ -0,0 +1,360 @@ +import { buildQuery, request } from './http'; + +export type AnalyticsGranularity = '5m' | '15m' | '1h' | '1d'; + +export type AnalyticsRange = { + from?: string; + to?: string; +}; + +export type AnalyticsQuery = AnalyticsRange & { + partner_id?: number; + zone_ids?: Array; + camera_ids?: Array; + granularity?: AnalyticsGranularity; + forecast_created_at?: string; + top?: number; + offset?: number; +}; + +export type AnalyticsSummary = { + active_zones?: number | null; + total_capacity?: number | null; + occupied_now?: number | null; + free_now?: number | null; + average_occupancy_percent?: number | null; + newest_update_at?: string | null; + oldest_update_at?: string | null; + average_confidence?: number | null; + zones?: AnalyticsZoneSummary[]; + cameras?: AnalyticsCameraSummary[]; +}; + +export type AnalyticsZoneSummary = { + zone_id: number | string; + camera_id?: number | null; + capacity?: number | null; + occupied?: number | null; + free?: number | null; + occupancy_percent?: number | null; + confidence?: number | null; + last_update_at?: string | null; + status?: AnalyticsDetectorStatus | string | null; +}; + +export type AnalyticsCameraSummary = { + camera_id: number; + title?: string | null; + status?: string | null; + last_update_at?: string | null; + confidence?: number | null; +}; + +export type AnalyticsUpdateFrequency = { + average_interval_seconds?: number | null; + max_interval_seconds?: number | null; + newest_update_at?: string | null; + oldest_update_at?: string | null; + items?: AnalyticsUpdateFrequencyItem[]; +}; + +export type AnalyticsUpdateFrequencyItem = { + zone_id?: number | string | null; + camera_id?: number | null; + average_interval_seconds?: number | null; + max_interval_seconds?: number | null; + newest_update_at?: string | null; + oldest_update_at?: string | null; +}; + +export type AnalyticsConfidence = { + average_confidence?: number | null; + points?: AnalyticsConfidencePoint[]; + items?: AnalyticsConfidencePoint[]; +}; + +export type AnalyticsConfidencePoint = { + ts?: string; + timestamp?: string; + zone_id?: number | string | null; + camera_id?: number | null; + confidence?: number | null; + average_confidence?: number | null; + observations?: number | null; +}; + +export type AnalyticsHistory = { + series?: AnalyticsSeries[]; + points?: AnalyticsHistoryPoint[]; + items?: AnalyticsHistoryPoint[]; +}; + +export type AnalyticsSeries = { + id?: number | string; + zone_id?: number | string; + camera_id?: number; + label?: string; + points: AnalyticsHistoryPoint[]; +}; + +export type AnalyticsHistoryPoint = { + ts?: string; + timestamp?: string; + zone_id?: number | string | null; + camera_id?: number | null; + occupied?: number | null; + free?: number | null; + total?: number | null; + capacity?: number | null; + occupancy_percent?: number | null; + confidence?: number | null; + observations?: number | null; +}; + +export type AnalyticsForecast = { + series?: AnalyticsSeries[]; + points?: AnalyticsForecastPoint[]; + items?: AnalyticsForecastPoint[]; +}; + +export type AnalyticsForecastPoint = AnalyticsHistoryPoint & { + forecast_created_at?: string | null; + predicted_occupied?: number | null; + predicted_free?: number | null; + predicted_occupancy_percent?: number | null; +}; + +export type AnalyticsObservationsRate = { + points?: AnalyticsObservationPoint[]; + items?: AnalyticsObservationPoint[]; +}; + +export type AnalyticsObservationPoint = { + ts?: string; + timestamp?: string; + zone_id?: number | string | null; + camera_id?: number | null; + observations?: number | null; + count?: number | null; +}; + +export type AnalyticsDetectorStatus = + | 'online' + | 'stale' + | 'offline' + | 'no_data' + | 'low_confidence'; + +export type AnalyticsDetectorHealth = { + items: AnalyticsDetectorHealthItem[]; + total?: number; +}; + +export type AnalyticsDetectorHealthItem = { + zone_id: number | string; + camera_id?: number | null; + capacity?: number | null; + occupied?: number | null; + free?: number | null; + occupancy_percent?: number | null; + confidence?: number | null; + last_update_at?: string | null; + stale_seconds?: number | null; + average_interval_seconds?: number | null; + max_interval_seconds?: number | null; + status?: AnalyticsDetectorStatus | string | null; +}; + +export type DetectionRunList = { + items: DetectionRunListItem[]; + total?: number; +}; + +export type DetectionRunListItem = { + detection_run_id: number | string; + camera_id: number; + zone_id?: number | string | null; + started_at?: string | null; + finished_at?: string | null; + status?: string | null; + processing_time_ms?: number | null; + cars_detected?: number | null; + occupied?: number | null; + free?: number | null; + confidence?: number | null; + has_feedback?: boolean | null; +}; + +export type DetectionRunDetail = DetectionRunListItem & { + model_version?: string | null; + total?: number | null; + error?: string | null; + raw_image_url?: string | null; + annotated_image_url?: string | null; + feedback?: DetectionFeedback | null; +}; + +export type DetectionFeedbackRating = 'correct' | 'partially_correct' | 'incorrect'; + +export type DetectionFeedbackErrorType = + | 'extra_car' + | 'missing_car' + | 'wrong_zone' + | 'bad_lighting' + | 'bad_angle' + | 'calibration_issue' + | 'other'; + +export type DetectionFeedback = { + feedback_id?: number | string; + created_at?: string | null; + updated_at?: string | null; + user_id?: number | null; + user_email?: string | null; + rating?: DetectionFeedbackRating | string | null; + correct_occupied?: number | null; + correct_free?: number | null; + error_type?: DetectionFeedbackErrorType | string | null; + comment?: string | null; + history?: unknown[]; +}; + +export type DetectionFeedbackRequest = { + rating: DetectionFeedbackRating; + correct_occupied?: number | null; + correct_free?: number | null; + error_type?: DetectionFeedbackErrorType | null; + comment?: string | null; +}; + +export type DetectionFeedbackList = { + items: DetectionFeedback[]; + total?: number; +}; + +export type LegacyOccupancySeriesPoint = { + observed_at: string; + occupied: number; + free_count: number; + capacity: number; + confidence: number; + confidence_level?: string | null; + source_type?: string | null; +}; + +export type LegacyForecastSeriesPoint = { + predicted_for: string; + predicted_occupied: number; + predicted_free_count: number; + capacity: number; + probability_free_space: number; + confidence: number; + confidence_level?: string | null; + model_type?: string | null; + generated_at?: string | null; +}; + +export type LegacySeriesQuery = AnalyticsRange & { + partner_id?: number; + zone_id?: number | string; + camera_id?: number | string; + granularity?: AnalyticsGranularity; +}; + +function analyticsQuery(query: AnalyticsQuery = {}) { + const search = new URLSearchParams(); + const scalarQuery = buildQuery({ + partner_id: query.partner_id, + from: query.from, + to: query.to, + granularity: query.granularity, + forecast_created_at: query.forecast_created_at, + top: query.top, + offset: query.offset + }); + + if (scalarQuery) { + const scalarParams = new URLSearchParams(scalarQuery.slice(1)); + scalarParams.forEach((value, key) => search.set(key, value)); + } + + query.zone_ids?.forEach(zoneId => search.append('zone_id', String(zoneId))); + query.camera_ids?.forEach(cameraId => search.append('camera_id', String(cameraId))); + + const result = search.toString(); + return result ? `?${result}` : ''; +} + +function legacySeriesQuery(query: LegacySeriesQuery = {}, view: 'series') { + return buildQuery({ + partner_id: query.partner_id, + zone_id: query.zone_id, + camera_id: query.camera_id, + from: query.from, + to: query.to, + granularity: query.granularity, + view + }); +} + +export const analyticsApi = { + async legacyOccupancySeries(query?: LegacySeriesQuery) { + return request('GET', `/occupancy${legacySeriesQuery(query, 'series')}`); + }, + + async legacyForecastSeries(query?: LegacySeriesQuery) { + return request('GET', `/forecasts${legacySeriesQuery(query, 'series')}`); + }, + + async summary(query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/summary${analyticsQuery(query)}`); + }, + + async updateFrequency(query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/update-frequency${analyticsQuery(query)}`); + }, + + async confidence(query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/confidence${analyticsQuery(query)}`); + }, + + async occupancyHistory(query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/occupancy-history${analyticsQuery(query)}`); + }, + + async occupancyForecast(query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/occupancy-forecast${analyticsQuery(query)}`); + }, + + async occupancyHeatmap(query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/occupancy-heatmap${analyticsQuery(query)}`); + }, + + async observationsRate(query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/observations-rate${analyticsQuery(query)}`); + }, + + async detectorHealth(query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/detector-health${analyticsQuery(query)}`); + }, + + async cameraDetections(cameraId: number, query?: AnalyticsQuery) { + return request('GET', `/admin/analytics/cameras/${encodeURIComponent(cameraId)}/detections${analyticsQuery(query)}`); + }, + + async detection(detectionRunId: number | string) { + return request('GET', `/admin/analytics/detections/${encodeURIComponent(detectionRunId)}`); + }, + + async createDetectionFeedback(detectionRunId: number | string, data: DetectionFeedbackRequest) { + return request('POST', `/admin/analytics/detections/${encodeURIComponent(detectionRunId)}/feedback`, data); + }, + + async detectionFeedback(detectionRunId: number | string) { + return request('GET', `/admin/analytics/detections/${encodeURIComponent(detectionRunId)}/feedback`); + }, + + async detectionFeedbackDetail(detectionRunId: number | string, feedbackId: number | string) { + return request('GET', `/admin/analytics/detections/${encodeURIComponent(detectionRunId)}/feedback/${encodeURIComponent(feedbackId)}`); + } +}; diff --git a/src/api/client.ts b/src/api/client.ts index bfac70d..29f3345 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,6 +2,7 @@ import { ParkingZone, Id, SessionUser } from '@/types'; import { apiConfig, request } from './http'; import { camerasApi, CameraListFilters } from './cameras'; import { zonesApi, ZoneListFilters } from './zones'; +import { analyticsApi } from './analytics'; // --- types (according to Swagger schema) --- @@ -33,6 +34,31 @@ export type { ZonePoint, ZoneView } from './zones'; +export type { + AnalyticsConfidence, + AnalyticsDetectorHealth, + AnalyticsDetectorHealthItem, + AnalyticsForecast, + AnalyticsGranularity, + AnalyticsHistory, + AnalyticsObservationPoint, + AnalyticsObservationsRate, + AnalyticsQuery, + AnalyticsSummary, + AnalyticsUpdateFrequency, + AnalyticsZoneSummary, + DetectionFeedback, + DetectionFeedbackErrorType, + DetectionFeedbackList, + DetectionFeedbackRating, + DetectionFeedbackRequest, + DetectionRunDetail, + DetectionRunList, + DetectionRunListItem, + LegacyForecastSeriesPoint, + LegacyOccupancySeriesPoint, + LegacySeriesQuery +} from './analytics'; export type HealthResponse = { status?: string; @@ -444,6 +470,8 @@ export const api = { } }, + analytics: analyticsApi, + // --- Parking Zones --- async listZones(cameraIdOrFilters?: number | ZoneListFilters) { if (typeof cameraIdOrFilters === 'number') { diff --git a/src/components/CamerasPage.tsx b/src/components/CamerasPage.tsx index 6edcfb6..be52b26 100644 --- a/src/components/CamerasPage.tsx +++ b/src/components/CamerasPage.tsx @@ -684,7 +684,14 @@ export default function CamerasPage() { -
+
{ + e.preventDefault(); + loadCameras(); + }} + > Неактивные - -
+ 0 ? parsed : null; -} - function isEditableTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; const tagName = target.tagName.toLowerCase(); @@ -290,7 +283,7 @@ export default function Sidebar() { мест: {z.capacity} • цена: {z.pay}
- партнёр: {z.partner_id ?? '—'} • {formatZoneLocationType(z.location_type)} • {z.is_active === false ? 'inactive' : 'active'} + {formatZoneLocationType(z.location_type)} • {z.is_active === false ? 'inactive' : 'active'}
))} @@ -318,13 +311,6 @@ export default function Sidebar() { s.updateZone(zone.id,{pay: parseInt(e.target.value||'0',10)})}/> - - s.updateZone(zone.id,{partner_id: parseOptionalPositiveInt(e.target.value)})} - placeholder={camera?.partner_id ? `Камера: #${camera.partner_id}` : 'Не задан'} - /> - onChange(prev => ({ ...prev, period: event.target.value as PeriodPreset }))} + > + + + + + + + + + {filters.period === 'custom' && ( + <> + + onChange(prev => ({ ...prev, from: event.target.value }))} /> + + + onChange(prev => ({ ...prev, to: event.target.value }))} /> + + + )} + + + + + + + + + + +
+ onChange(prev => ({ ...prev, zoneSearch: value }))} + selectedIds={filters.selectedZoneIds} + onSelectedIds={ids => onChange(prev => ({ ...prev, selectedZoneIds: ids }))} + items={zones.map(zone => ({ + id: String(zone.id), + label: `Зона #${zone.id}`, + meta: `камера #${zone.camera_id}` + }))} + emptyMessage={zoneError ?? 'Зоны не найдены'} + /> + onChange(prev => ({ ...prev, cameraSearch: value }))} + selectedIds={filters.selectedCameraIds} + onSelectedIds={ids => onChange(prev => ({ ...prev, selectedCameraIds: ids }))} + items={cameras.map(camera => ({ + id: String(camera.camera_id), + label: `#${camera.camera_id} · ${camera.title}`, + meta: camera.source + }))} + emptyMessage={cameraError ?? 'Камеры не найдены'} + /> +
+ + ); +} + +function MultiEntityPicker({ + title, + search, + selectedIds, + items, + emptyMessage, + onSearch, + onSelectedIds +}: { + title: string; + search: string; + selectedIds: string[]; + items: Array<{ id: string; label: string; meta?: string }>; + emptyMessage: string; + onSearch: (value: string) => void; + onSelectedIds: (ids: string[]) => void; +}) { + const normalizedSearch = search.trim().toLowerCase(); + const visibleItems = items.filter(item => { + if (!normalizedSearch) return true; + return `${item.id} ${item.label} ${item.meta ?? ''}`.toLowerCase().includes(normalizedSearch); + }); + const visibleIds = visibleItems.map(item => item.id); + const visibleSelected = visibleIds.filter(id => selectedIds.includes(id)); + const allVisibleSelected = visibleIds.length > 0 && visibleSelected.length === visibleIds.length; + + function toggle(id: string, checked: boolean) { + const next = new Set(selectedIds); + if (checked) next.add(id); + else next.delete(id); + onSelectedIds([...next]); + } + + function toggleVisible(checked: boolean) { + const next = new Set(selectedIds); + visibleIds.forEach(id => { + if (checked) next.add(id); + else next.delete(id); + }); + onSelectedIds([...next]); + } + + return ( +
+
+ {title} + выбрано: {selectedIds.length} +
+ onSearch(event.target.value)} placeholder="Поиск по id или названию" /> + +
+ {visibleItems.map(item => ( + + ))} + {!visibleItems.length &&
{emptyMessage}
} +
+
+ ); +} + +function KpiGrid({ + summary, + frequency, + confidence +}: { + summary: LoadState; + frequency: LoadState; + confidence: LoadState; +}) { + const cards = [ + { label: 'Активных зон', value: formatNumber(summary.data?.active_zones) }, + { label: 'Всего мест', value: formatNumber(summary.data?.total_capacity) }, + { label: 'Занято сейчас', value: formatNumber(summary.data?.occupied_now) }, + { label: 'Свободно сейчас', value: formatNumber(summary.data?.free_now) }, + { label: 'Средняя занятость', value: formatPercent(summary.data?.average_occupancy_percent) }, + { label: 'Самое свежее обновление', value: formatDateTime(summary.data?.newest_update_at ?? frequency.data?.newest_update_at) }, + { label: 'Самое старое обновление', value: formatDateTime(summary.data?.oldest_update_at ?? frequency.data?.oldest_update_at) }, + { label: 'Средняя частота', value: formatDuration(frequency.data?.average_interval_seconds) }, + { label: 'Макс. интервал', value: formatDuration(frequency.data?.max_interval_seconds) }, + { label: 'Уверенность модели', value: formatPercent(confidence.data?.average_confidence ?? summary.data?.average_confidence) } + ]; + + return ( +
+ {cards.map(card => ( +
+
{card.label}
+
{summary.loading || frequency.loading || confidence.loading ? '...' : card.value}
+
+ ))} +
+ ); +} + +function Block({ + title, + state, + children +}: { + title: string; + state: LoadState; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {state.loading && Загрузка...} +
+ {state.error ? ( +
Не удалось загрузить блок: {state.error}
+ ) : children} +
+ ); +} + +function AnalyticsBackendStub({ + title, + description +}: { + title: string; + description: string; +}) { + return ( +
+
+

{title}

+ backend pending +
+
{description}
+
+ ); +} + +function LineChart({ + series, + unit, + emptyMessage, + granularity, + xLabel = 'Время', + yLabel = unit ? `Значение, ${unit}` : 'Значение' +}: { + series: ChartSeries[]; + unit?: string; + emptyMessage: string; + granularity?: AnalyticsGranularity; + xLabel?: string; + yLabel?: string; +}) { + const [hidden, setHidden] = useState>(() => new Set()); + const [tooltip, setTooltip] = useState<{ + key: string; + x: number; + y: number; + boxX: number; + boxY: number; + width: number; + height: number; + lines: string[]; + color: string; + pinned: boolean; + } | undefined>(); + const visibleSeries = series + .filter(item => !hidden.has(item.key)) + .map(item => ({ ...item, points: compactLinePoints(item.points, MAX_CHART_POINTS, granularity) })); + const values = visibleSeries.flatMap(item => item.points.map(point => point.y).filter((value): value is number => typeof value === 'number')); + const maxValue = unit === '%' ? Math.max(100, ...values) : Math.max(1, ...values); + const minValue = unit === '%' ? 0 : Math.min(0, ...values); + const width = 720; + const height = 260; + const padding = { top: 24, right: 20, bottom: 54, left: 58 }; + const plotWidth = width - padding.left - padding.right; + const plotHeight = height - padding.top - padding.bottom; + const domain = chartTimeDomain(visibleSeries); + const longestSeries = visibleSeries.reduce( + (current, item) => !current || item.points.length > current.points.length ? item : current, + undefined + ); + const totalPointCount = visibleSeries.reduce((sum, item) => sum + item.points.length, 0); + const showMarkers = totalPointCount <= MAX_MARKER_POINTS; + + useEffect(() => { + setTooltip(undefined); + }, [series, granularity, hidden]); + + if (!series.length || !series.some(item => item.points.length)) { + return
{emptyMessage}
; + } + + const toX = (point: ChartPoint, pointIndex: number, total: number) => { + const timestamp = parsePointTime(point.x); + if (domain && timestamp !== null) { + return padding.left + ((timestamp - domain.min) / (domain.max - domain.min)) * plotWidth; + } + if (total <= 1) return padding.left; + return padding.left + (pointIndex / (total - 1)) * plotWidth; + }; + const toY = (value: number | null) => { + const safeValue = value ?? minValue; + return height - padding.bottom - ((safeValue - minValue) / (maxValue - minValue || 1)) * plotHeight; + }; + const yTicks = unit === '%' + ? [0, 25, 50, 75, 100] + : [minValue, minValue + (maxValue - minValue) / 2, maxValue]; + const xTicks = domain + ? [ + { x: padding.left, label: formatAxisDateTime(domain.min) }, + { x: padding.left + plotWidth / 2, label: formatAxisDateTime(domain.min + (domain.max - domain.min) / 2) }, + { x: padding.left + plotWidth, label: formatAxisDateTime(domain.max) } + ] + : [0, 0.5, 1].map(position => { + const points = longestSeries?.points ?? []; + const index = points.length <= 1 ? 0 : Math.round(position * (points.length - 1)); + return { + x: padding.left + position * plotWidth, + label: points[index]?.x ? formatAxisDateTime(points[index].x) : '' + }; + }).filter(tick => tick.label); + const tooltipValue = (value: number) => { + const digits = unit === '%' ? 1 : Math.abs(value) >= 10 ? 0 : 1; + return `${value.toFixed(digits)}${unit ?? ''}`; + }; + const pointTooltip = (item: ChartSeries, point: ChartPoint, index: number, pinned: boolean) => { + if (point.y === null) return undefined; + const pointX = toX(point, index, item.points.length); + const pointY = toY(point.y); + const lines = [item.label, formatAxisDateTime(point.x), tooltipValue(point.y)]; + const tooltipWidth = Math.min(230, Math.max(128, Math.max(...lines.map(line => line.length)) * 6.4 + 18)); + const tooltipHeight = 58; + let boxX = pointX + 12; + let boxY = pointY - tooltipHeight - 12; + if (boxX + tooltipWidth > width - padding.right) boxX = pointX - tooltipWidth - 12; + if (boxX < padding.left) boxX = padding.left; + if (boxY < padding.top) boxY = pointY + 12; + if (boxY + tooltipHeight > height - padding.bottom) boxY = height - padding.bottom - tooltipHeight; + return { + key: `${item.key}-${index}`, + x: pointX, + y: pointY, + boxX, + boxY, + width: tooltipWidth, + height: tooltipHeight, + lines, + color: item.color, + pinned + }; + }; + const showPointTooltip = (item: ChartSeries, point: ChartPoint, index: number, pinned = false) => { + const next = pointTooltip(item, point, index, pinned); + if (next) setTooltip(next); + }; + + return ( +
+ setTooltip(undefined)}> + {yTicks.map((tick, index) => { + const y = toY(tick); + return ( + + + + {formatAxisNumber(tick, unit)} + + + ); + })} + + + {visibleSeries.map(item => { + const points = item.points + .map((point, index) => point.y === null ? null : `${toX(point, index, item.points.length)},${toY(point.y)}`) + .filter(Boolean) + .join(' '); + return ( + + ); + })} + {showMarkers && visibleSeries.map(item => item.points.map((point, index) => { + if (point.y === null) return null; + return ( + + {`${item.label}\n${formatDateTime(point.x)}\n${point.y.toFixed(1)}${unit ?? ''}`} + + ); + }))} + {visibleSeries.map(item => item.points.map((point, index) => { + if (point.y === null) return null; + const pointX = toX(point, index, item.points.length); + const pointY = toY(point.y); + return ( + showPointTooltip(item, point, index)} + onFocus={() => showPointTooltip(item, point, index)} + onClick={(event) => { + event.stopPropagation(); + showPointTooltip(item, point, index, true); + }} + onMouseLeave={() => setTooltip(current => current?.pinned ? current : undefined)} + onBlur={() => setTooltip(current => current?.pinned ? current : undefined)} + /> + ); + }))} + {xTicks.map((tick, index) => ( + + {tick.label} + + ))} + + {xLabel} + + + {yLabel} + + {tooltip && ( + + + + + + + {tooltip.lines.map((line, index) => ( + + {line} + + ))} + + + + )} + + {granularity && ( +
Детализация: {GRANULARITY_LABELS[granularity]}
+ )} +
+ {series.map(item => ( + + ))} +
+
+ ); +} + +function BarChart({ + points, + emptyMessage, + granularity, + xLabel = 'Время', + yLabel = 'Количество' +}: { + points: ChartPoint[]; + emptyMessage: string; + granularity?: AnalyticsGranularity; + xLabel?: string; + yLabel?: string; +}) { + const chartPoints = compactBarPoints(points, MAX_CHART_POINTS, granularity); + const values = chartPoints.map(point => point.y).filter((value): value is number => typeof value === 'number'); + const maxValue = Math.max(1, ...values); + const width = 720; + const height = 260; + const padding = { top: 24, right: 20, bottom: 54, left: 58 }; + const plotWidth = width - padding.left - padding.right; + const plotHeight = height - padding.top - padding.bottom; + + if (!points.length || !values.length) { + return
{emptyMessage}
; + } + + const barStep = plotWidth / chartPoints.length; + const barWidth = Math.max(3, Math.min(18, barStep * 0.72)); + const yTicks = [0, maxValue / 2, maxValue]; + const xTicks = [0, 0.5, 1].map(position => { + const index = chartPoints.length <= 1 ? 0 : Math.round(position * (chartPoints.length - 1)); + return { + x: padding.left + position * plotWidth, + label: chartPoints[index]?.x ? formatAxisDateTime(chartPoints[index].x) : '' + }; + }).filter(tick => tick.label); + + return ( +
+ + {yTicks.map((tick, index) => { + const y = height - padding.bottom - (tick / maxValue) * plotHeight; + return ( + + + + {formatAxisNumber(tick)} + + + ); + })} + + + {chartPoints.map((point, index) => { + const value = point.y ?? 0; + const barHeight = (value / maxValue) * plotHeight; + const x = padding.left + index * barStep + (barStep - barWidth) / 2; + const y = height - padding.bottom - barHeight; + return ( + + {`${formatDateTime(point.x)}\n${formatNumber(value)}`} + + ); + })} + {xTicks.map((tick, index) => ( + + {tick.label} + + ))} + + {xLabel} + + + {yLabel} + + + {granularity && ( +
Детализация: {GRANULARITY_LABELS[granularity]}
+ )} +
+ ); +} + +function AnalyticsMap({ + zones, + cameras, + summary +}: { + zones: ParkingZone[]; + cameras: Camera[]; + summary?: AnalyticsSummary; +}) { + const mapRef = useRef(null); + const [selected, setSelected] = useState(null); + const center = useMemo(() => { + const camera = cameras.find(item => hasCoordinates(item.latitude, item.longitude)); + if (camera) return yandexPoint(camera.latitude, camera.longitude); + const zone = zones.map(zoneMapPoints).find(points => points.length > 0); + if (zone?.[0]) return zone[0]; + return yandexPoint(59.9386, 30.3141); + }, [cameras, zones]); + const { ymaps, map, loading, error } = useYandexMap(mapRef, { center, zoom: 12, syncView: false }); + + useEffect(() => { + if (!ymaps || !map) return; + const collection = new ymaps.GeoObjectCollection(); + const boundsPoints: YandexPoint[] = []; + const summaryByZone = new Map((summary?.zones ?? []).map(item => [String(item.zone_id), item])); + + zones.forEach(zone => { + const points = zoneMapPoints(zone); + if (points.length < 3) return; + boundsPoints.push(...points); + const zoneSummary = summaryByZone.get(String(zone.id)); + const color = occupancyColor(zoneSummary?.occupancy_percent, zoneSummary?.last_update_at ?? zone.occupancy_updated_at); + const polygon = new ymaps.Polygon( + [points], + { hintContent: `Зона #${String(zone.id)}` }, + { + strokeColor: color, + strokeOpacity: 0.95, + strokeWidth: 2, + fillColor: color, + fillOpacity: 0.2, + zIndex: 150 + } + ); + polygon.events.add('click', () => { + setSelected( + setAnalyticsRoute({ view: 'camera', cameraId: String(zone.camera_id) })], + ['Аналитика зоны', () => setAnalyticsRoute({ view: 'zone', zoneId: String(zone.id) })] + ]} + /> + ); + }); + collection.add(polygon); + }); + + cameras.forEach(camera => { + if (!hasCoordinates(camera.latitude, camera.longitude)) return; + const point = yandexPoint(camera.latitude, camera.longitude); + boundsPoints.push(point); + const placemark = new ymaps.Placemark( + point, + { hintContent: `Камера #${camera.camera_id}` }, + { + preset: 'islands#circleDotIcon', + iconColor: camera.is_active === false ? '#9ca3af' : '#128a45' + } + ); + placemark.events.add('click', () => { + setSelected( + setAnalyticsRoute({ view: 'camera', cameraId: String(camera.camera_id) })] + ]} + /> + ); + }); + collection.add(placemark); + }); + + map.geoObjects.add(collection); + if (boundsPoints.length) fitYandexMap(map, boundsPoints, 12); + return () => { + map.geoObjects.remove(collection); + }; + }, [ymaps, map, zones, cameras, summary]); + + return ( +
+
+ {loading &&
Загрузка Яндекс.Карт...
} + {error &&
{error}
} +
+
+ {selected ??
Выберите зону или камеру на карте.
} +
+
+ ); +} + +function MapDetails({ + title, + rows, + actions +}: { + title: string; + rows: Array<[string, React.ReactNode]>; + actions: Array<[string, () => void]>; +}) { + return ( +
+

{title}

+
+ {rows.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ {actions.map(([label, action]) => ( + + ))} +
+
+ ); +} + +function DetectorHealthTable({ items }: { items: AnalyticsDetectorHealthItem[] }) { + if (!items.length) { + return
У зоны нет свежих наблюдений.
; + } + + return ( +
+
+ Зона + Камера + Всего + Занято + Свободно + Занятость + Уверенность модели + Последнее обновление + Возраст + Средний интервал + Макс. интервал + Статус +
+
+ {items.map(item => ( + + ))} +
+
+ ); +} + +function formatMs(value?: number | null) { + if (typeof value !== 'number' || !Number.isFinite(value)) return '—'; + if (value < 1000) return `${Math.round(value)} мс`; + return `${(value / 1000).toFixed(2)} сек`; +} + +function zoneCapacity(zone?: ParkingZone, summary?: AnalyticsSummary) { + if (!zone) return undefined; + const zoneSummary = summary?.zones?.find(item => String(item.zone_id) === String(zone.id)); + return { + capacity: zoneSummary?.capacity ?? zone.capacity, + occupied: zoneSummary?.occupied ?? zone.occupied, + free: zoneSummary?.free ?? zone.free_count, + occupancy: zoneSummary?.occupancy_percent, + confidence: zoneSummary?.confidence ?? zone.confidence, + lastUpdate: zoneSummary?.last_update_at ?? zone.occupancy_updated_at, + status: zoneSummary?.status ?? (zone.is_active === false ? 'inactive' : 'active') + }; +} + +function zoneCoordinateRows(zone: ParkingZone) { + const geometryPoints = zoneMapPoints(zone); + if (geometryPoints.length) { + return geometryPoints.map((point, index) => `#${index + 1}: ${point[0].toFixed(6)}, ${point[1].toFixed(6)}`); + } + return zone.points.map((point, index) => `#${index + 1}: ${point.latitude ?? '—'}, ${point.longitude ?? '—'}`); +} + +function ZoneAnalyticsPage({ zoneId }: { zoneId: string }) { + const currentPartnerId = useSessionStore(state => state.currentPartnerId); + const [zone, setZone] = useState>(emptyState); + const [summary, setSummary] = useState>(emptyState); + const [history, setHistory] = useState>(emptyState); + const [forecast, setForecast] = useState>(emptyState); + const [confidence, setConfidence] = useState>(emptyState); + const [frequency, setFrequency] = useState>(emptyState); + const query = useMemo(() => ({ + partner_id: currentPartnerId, + zone_ids: [zoneId], + ...rangeForFilters({ ...defaultFilters(), period: '7d' }), + granularity: '1h' + }), [currentPartnerId, zoneId]); + + useEffect(() => { + let cancelled = false; + setZone({ loading: true }); + setSummary({ loading: true }); + setHistory({ loading: true }); + setForecast({ loading: true }); + setConfidence({ loading: true }); + setFrequency({ loading: true }); + + Promise.allSettled([ + api.getZone(zoneId), + api.analytics.summary(query), + api.analytics.occupancyHistory(query), + api.analytics.occupancyForecast(query), + api.analytics.confidence(query), + api.analytics.updateFrequency(query) + ]).then(results => { + if (cancelled) return; + const [zoneResult, summaryResult, historyResult, forecastResult, confidenceResult, frequencyResult] = results; + setZone(zoneResult.status === 'fulfilled' ? { loading: false, data: zoneResult.value } : { loading: false, error: blockError(zoneResult.reason) }); + setSummary(summaryResult.status === 'fulfilled' ? { loading: false, data: summaryResult.value } : { loading: false, error: blockError(summaryResult.reason) }); + setHistory(historyResult.status === 'fulfilled' ? { loading: false, data: historyResult.value } : { loading: false, error: blockError(historyResult.reason) }); + setForecast(forecastResult.status === 'fulfilled' ? { loading: false, data: forecastResult.value } : { loading: false, error: blockError(forecastResult.reason) }); + setConfidence(confidenceResult.status === 'fulfilled' ? { loading: false, data: confidenceResult.value } : { loading: false, error: blockError(confidenceResult.reason) }); + setFrequency(frequencyResult.status === 'fulfilled' ? { loading: false, data: frequencyResult.value } : { loading: false, error: blockError(frequencyResult.reason) }); + }); + + return () => { + cancelled = true; + }; + }, [zoneId, query]); + + const metrics = zoneCapacity(zone.data, summary.data); + const zonesForLabels = zone.data ? [zone.data] : []; + const occupancySeries = historyToOccupancySeries(history.data, zonesForLabels); + const occupiedFreeSeries = useMemo(() => { + const points = asItems(history.data).length + ? asItems(history.data) + : history.data?.series?.flatMap(series => series.points) ?? []; + return [ + { + key: 'occupied', + label: 'Занято', + color: '#dc2626', + points: points.map(point => ({ x: getPointTime(point), y: point.occupied ?? null, meta: point as Record })) + }, + { + key: 'free', + label: 'Свободно', + color: '#128a45', + points: points.map(point => ({ x: getPointTime(point), y: point.free ?? null, meta: point as Record })) + } + ]; + }, [history.data]); + + return ( +
+
+
+

Аналитика зоны #{zoneId}

+

Занятость, прогноз, геометрия и качество данных по одной парковочной зоне

+
+
+ + {zone.data && } +
+
+ + + {zone.data ? ( +
+ + + + + + + + + +
+ ) :
Зона не найдена.
} +
+ +
+ + {zone.data ? :
У зоны не задана геометрия.
} +
+ +
+ + + + +
+
+
+ +
+ + + + + + + + + + + + +
+
+ ); +} + +function ZoneGeometryPreview({ zone }: { zone: ParkingZone }) { + const points = zoneMapPoints(zone); + return ( +
+ {points.length >= 3 ? ( + + ) : ( +
У зоны не задана геометрия.
+ )} +
+ {zoneCoordinateRows(zone).map(row => {row})} +
+
+ ); +} + +function CameraAnalyticsPage({ cameraId }: { cameraId: string }) { + const currentPartnerId = useSessionStore(state => state.currentPartnerId); + const numericCameraId = Number(cameraId); + const [camera, setCamera] = useState>(emptyState); + const [zones, setZones] = useState>(emptyState); + const [health, setHealth] = useState>(emptyState); + const [frequency, setFrequency] = useState>(emptyState); + const [confidence, setConfidence] = useState>(emptyState); + const [observations, setObservations] = useState>(emptyState); + const [detections, setDetections] = useState>(emptyState); + const query = useMemo(() => ({ + partner_id: currentPartnerId, + camera_ids: [cameraId], + ...rangeForFilters({ ...defaultFilters(), period: '7d' }), + granularity: '1h', + top: 20 + }), [currentPartnerId, cameraId]); + + useEffect(() => { + let cancelled = false; + setCamera({ loading: true }); + setZones({ loading: true }); + setHealth({ loading: true }); + setFrequency({ loading: true }); + setConfidence({ loading: true }); + setObservations({ loading: true }); + setDetections({ loading: true }); + + Promise.allSettled([ + api.getCamera(numericCameraId), + api.listZones({ camera_id: numericCameraId, partner_id: currentPartnerId }), + api.analytics.detectorHealth(query), + api.analytics.updateFrequency(query), + api.analytics.confidence(query), + api.analytics.observationsRate(query), + api.analytics.cameraDetections(numericCameraId, query) + ]).then(results => { + if (cancelled) return; + const [cameraResult, zonesResult, healthResult, frequencyResult, confidenceResult, observationsResult, detectionsResult] = results; + setCamera(cameraResult.status === 'fulfilled' ? { loading: false, data: cameraResult.value } : { loading: false, error: blockError(cameraResult.reason) }); + setZones(zonesResult.status === 'fulfilled' ? { loading: false, data: zonesResult.value } : { loading: false, error: blockError(zonesResult.reason) }); + setHealth(healthResult.status === 'fulfilled' ? { loading: false, data: healthResult.value } : { loading: false, error: blockError(healthResult.reason) }); + setFrequency(frequencyResult.status === 'fulfilled' ? { loading: false, data: frequencyResult.value } : { loading: false, error: blockError(frequencyResult.reason) }); + setConfidence(confidenceResult.status === 'fulfilled' ? { loading: false, data: confidenceResult.value } : { loading: false, error: blockError(confidenceResult.reason) }); + setObservations(observationsResult.status === 'fulfilled' ? { loading: false, data: observationsResult.value } : { loading: false, error: blockError(observationsResult.reason) }); + setDetections(detectionsResult.status === 'fulfilled' ? { loading: false, data: detectionsResult.value } : { loading: false, error: blockError(detectionsResult.reason) }); + }); + + return () => { + cancelled = true; + }; + }, [numericCameraId, currentPartnerId, query]); + + return ( +
+
+
+

Аналитика камеры #{cameraId}

+

Снимки, наблюдения, интервалы обновления и здоровье связанных зон

+
+
+ +
+
+ + + {camera.data ? ( +
+ + + + + + + + +
+ ) :
Камера не найдена.
} +
+ + + + + +
+ + + + + + + + + + + + +
+ + + + +
+ ); +} + +function CameraSnapshots({ cameraId }: { cameraId: number }) { + const [tab, setTab] = useState<'snapshot' | 'raw' | 'annotated'>('snapshot'); + const [snapshot, setSnapshot] = useState>(emptyState); + const [fullscreenUrl, setFullscreenUrl] = useState(); + const options = tab === 'annotated' ? { annotated: true, fallback_to_raw: true } : { annotated: false, fallback_to_raw: true }; + + const load = useCallback(async () => { + setSnapshot({ loading: true }); + try { + const result = await api.getSnapshot(cameraId, options); + setSnapshot({ loading: false, data: result }); + } catch (error) { + setSnapshot({ loading: false, error: blockError(error) }); + } + }, [cameraId, tab]); + + useEffect(() => { + load(); + }, [load]); + + return ( +
+
+ + + +
+
+ Timestamp: {formatDateTime(snapshot.data?.captured_at)} +
+ + +
+
+ {snapshot.error &&
Снимок недоступен: {snapshot.error}
} + {snapshot.data?.image_url ? ( + Снимок камеры + ) : !snapshot.loading && !snapshot.error ? ( +
Снимок недоступен
+ ) : null} + {fullscreenUrl && ( +
+ + Снимок камеры +
+ )} +
+ ); +} + +function DetectionsTable({ detections }: { detections: DetectionRunList['items'] }) { + if (!detections.length) { + return
Распознавания не найдены.
; + } + + return ( +
+
+ Время + Статус + Обработка + Машин + Занято + Свободно + Уверенность модели + Оценка + +
+
+ {detections.map(item => ( + + ))} +
+
+ ); +} + +function DetectionAnalyticsPage({ detectionRunId }: { detectionRunId: string }) { + const isAdmin = useSessionStore(state => state.isAdmin()); + const notifySuccess = useFeedbackStore(state => state.success); + const [detail, setDetail] = useState>(emptyState); + const [feedback, setFeedback] = useState>(emptyState); + const [selectedFeedback, setSelectedFeedback] = useState>(emptyState); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + setDetail({ loading: true }); + setFeedback({ loading: isAdmin }); + try { + const nextDetail = await api.analytics.detection(detectionRunId); + setDetail({ loading: false, data: nextDetail }); + } catch (error) { + setDetail({ loading: false, error: blockError(error) }); + } + + if (isAdmin) { + try { + const nextFeedback = await api.analytics.detectionFeedback(detectionRunId); + setFeedback({ loading: false, data: nextFeedback }); + } catch (error) { + setFeedback({ loading: false, error: blockError(error) }); + } + } + }, [detectionRunId, isAdmin]); + + useEffect(() => { + load(); + }, [load]); + + async function saveFeedback(data: { + rating: DetectionFeedbackRating; + correct_occupied?: number | null; + correct_free?: number | null; + error_type?: DetectionFeedbackErrorType | null; + comment?: string | null; + }) { + setSaving(true); + try { + await api.analytics.createDetectionFeedback(detectionRunId, data); + notifySuccess('Оценка сохранена.'); + await load(); + } finally { + setSaving(false); + } + } + + async function openFeedback(feedbackId?: number | string) { + if (!feedbackId) return; + setSelectedFeedback({ loading: true }); + try { + const next = await api.analytics.detectionFeedbackDetail(detectionRunId, feedbackId); + setSelectedFeedback({ loading: false, data: next }); + } catch (error) { + setSelectedFeedback({ loading: false, error: blockError(error) }); + } + } + + const item = detail.data; + + return ( +
+
+
+

Распознавание #{detectionRunId}

+

Просмотр запуска detector-а и оценка качества распознавания

+
+
+ + {item?.camera_id && } +
+
+ + + {item ? ( +
+ + + + + + + + + + + + + + +
+ ) :
Распознавание не найдено.
} +
+ + {item && ( + +
+ + +
+
+ )} + + {item && ( + + {item.feedback && } + + + )} + + {isAdmin && ( + + + + )} +
+ ); +} + +function Detail({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function DetectionImage({ title, url }: { title: string; url?: string | null }) { + const [fullscreen, setFullscreen] = useState(false); + return ( +
+
+

{title}

+
+ + +
+
+ {url ? {title} :
Изображение недоступно
} + {fullscreen && url && ( +
+ + {title} +
+ )} +
+ ); +} + +function DetectionFeedbackForm({ + saving, + onSubmit +}: { + saving: boolean; + onSubmit: (data: { + rating: DetectionFeedbackRating; + correct_occupied?: number | null; + correct_free?: number | null; + error_type?: DetectionFeedbackErrorType | null; + comment?: string | null; + }) => Promise; +}) { + const [rating, setRating] = useState('correct'); + const [correctOccupied, setCorrectOccupied] = useState(''); + const [correctFree, setCorrectFree] = useState(''); + const [errorType, setErrorType] = useState(''); + const [comment, setComment] = useState(''); + const [error, setError] = useState(); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setError(undefined); + try { + await onSubmit({ + rating, + correct_occupied: correctOccupied ? Number(correctOccupied) : null, + correct_free: correctFree ? Number(correctFree) : null, + error_type: errorType || null, + comment: comment.trim() || null + }); + setComment(''); + } catch (submitError) { + setError(blockError(submitError)); + } + } + + return ( +
+ + + + + setCorrectOccupied(event.target.value)} /> + + + setCorrectFree(event.target.value)} /> + + + + + +