From 90de3c1ae2b61a74f5dacc18e48a8613040c1e8c Mon Sep 17 00:00:00 2001 From: 666mxvbee Date: Mon, 1 Jun 2026 07:34:21 +0300 Subject: [PATCH 01/15] feat: add analytics route and api client --- src/App.tsx | 7 +- src/api/analytics.ts | 311 ++++++++++++++++++++++++++++++++++++ src/api/client.ts | 25 +++ src/layout/AdminShell.tsx | 15 +- src/pages/AnalyticsPage.tsx | 16 ++ src/router/routes.ts | 1 + 6 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 src/api/analytics.ts create mode 100644 src/pages/AnalyticsPage.tsx 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..16e6a2e --- /dev/null +++ b/src/api/analytics.ts @@ -0,0 +1,311 @@ +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; +}; + +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}` : ''; +} + +export const analyticsApi = { + 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..5a18272 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,28 @@ 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 +} from './analytics'; export type HealthResponse = { status?: string; @@ -444,6 +467,8 @@ export const api = { } }, + analytics: analyticsApi, + // --- Parking Zones --- async listZones(cameraIdOrFilters?: number | ZoneListFilters) { if (typeof cameraIdOrFilters === 'number') { diff --git a/src/layout/AdminShell.tsx b/src/layout/AdminShell.tsx index b4aab33..996b09d 100644 --- a/src/layout/AdminShell.tsx +++ b/src/layout/AdminShell.tsx @@ -8,6 +8,7 @@ type NavItem = { route: AppRoute; label: string; permissions?: string[]; + analyticsAccess?: boolean; }; const navItems: NavItem[] = [ @@ -17,9 +18,18 @@ const navItems: NavItem[] = [ { route: 'sources', label: 'Источники', permissions: ['sources.view'] }, { route: 'users', label: 'Пользователи', permissions: ['admin.users.view', 'partner_members.view'] }, { route: 'partners', label: 'Партнёры', permissions: ['admin.partners.view'] }, + { route: 'analytics', label: 'Аналитика', analyticsAccess: true }, { route: 'profile', label: 'Профиль' } ]; +export function canViewAnalyticsSection(session: ReturnType) { + const activeMemberships = (session.user?.partner_memberships ?? []).filter(m => m.is_active !== false); + return session.isAdmin() + || session.hasPermission('admin.analytics.view') + || session.hasPermission('analytics.view') + || activeMemberships.length > 0; +} + export default function AdminShell({ route, children }: { route: AppRoute; children: React.ReactNode }) { const session = useSessionStore(); const canViewPartners = useSessionStore(state => state.hasPermission('admin.partners.view')); @@ -74,7 +84,10 @@ export default function AdminShell({ route, children }: { route: AppRoute; child