Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d7ec0f2
Merge pull request #43 from ParkTrack-Project/YandexMap
nawinds May 28, 2026
90de3c1
feat: add analytics route and api client
666mxvbee Jun 1, 2026
64018a9
feat: build analytics dashboard
666mxvbee Jun 1, 2026
5d299ce
feat: add analytics detail views
666mxvbee Jun 1, 2026
91a2cee
chore: document analytics stale threshold
666mxvbee Jun 1, 2026
deecb52
feat: show analytics charts while backend matures
666mxvbee Jun 1, 2026
3215c01
fix: keep active zone handles draggable above overlaps
666mxvbee Jun 1, 2026
9f231a5
fix: submit camera filters on enter
666mxvbee Jun 1, 2026
20f3727
fix: hide partner id from zone properties
666mxvbee Jun 1, 2026
df1651c
fix: remove partner id filter from zones
666mxvbee Jun 1, 2026
c9b528a
fix: clarify zone access filter label
666mxvbee Jun 1, 2026
33f78f3
fix: add horizontal scroll to sources table
666mxvbee Jun 1, 2026
a673208
fix: remove zone creation form from zones page
666mxvbee Jun 1, 2026
c150597
Merge pull request #44 from ParkTrack-Project/AnalyticsPanel
nawinds Jun 1, 2026
7e2de99
fix: make analytics charts readable
666mxvbee Jun 1, 2026
8c4e059
fix: apply analytics granularity to charts
666mxvbee Jun 1, 2026
27da70d
Merge pull request #45 from ParkTrack-Project/AnalyticsPanel
666mxvbee Jun 1, 2026
a2dabdd
feat: add interactive analytics chart tooltips
666mxvbee Jun 1, 2026
99b65cb
Merge pull request #46 from ParkTrack-Project/AnalyticsPanel
666mxvbee Jun 1, 2026
09b6035
Merge pull request #48 from ParkTrack-Project/main
666mxvbee Jun 1, 2026
5b7e3c3
fix: remove duplicate sidebar helper
666mxvbee Jun 1, 2026
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -155,8 +156,9 @@ export default function App() {
</AppErrorBoundary>
);
} 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 = (
<AdminShell route={route}>
<AccessStatePage
Expand Down Expand Up @@ -191,6 +193,7 @@ function renderRoute(route: AppRoute, viewMode: ViewMode) {
if (route === 'profile') return <ProfilePage />;
if (route === 'users') return <UsersAdminPage />;
if (route === 'partners') return <PartnersAdminPage />;
if (route === 'analytics') return <AnalyticsPage />;
if (route === 'zones') return <ZonesAdminPage />;
if (route === 'sources') return <SourcesPage />;
if (route === 'cameras') {
Expand Down
360 changes: 360 additions & 0 deletions src/api/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<number | string>;
camera_ids?: Array<number | string>;
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<LegacyOccupancySeriesPoint[]>('GET', `/occupancy${legacySeriesQuery(query, 'series')}`);
},

async legacyForecastSeries(query?: LegacySeriesQuery) {
return request<LegacyForecastSeriesPoint[]>('GET', `/forecasts${legacySeriesQuery(query, 'series')}`);
},

async summary(query?: AnalyticsQuery) {
return request<AnalyticsSummary>('GET', `/admin/analytics/summary${analyticsQuery(query)}`);
},

async updateFrequency(query?: AnalyticsQuery) {
return request<AnalyticsUpdateFrequency>('GET', `/admin/analytics/update-frequency${analyticsQuery(query)}`);
},

async confidence(query?: AnalyticsQuery) {
return request<AnalyticsConfidence>('GET', `/admin/analytics/confidence${analyticsQuery(query)}`);
},

async occupancyHistory(query?: AnalyticsQuery) {
return request<AnalyticsHistory>('GET', `/admin/analytics/occupancy-history${analyticsQuery(query)}`);
},

async occupancyForecast(query?: AnalyticsQuery) {
return request<AnalyticsForecast>('GET', `/admin/analytics/occupancy-forecast${analyticsQuery(query)}`);
},

async occupancyHeatmap(query?: AnalyticsQuery) {
return request<AnalyticsHistory>('GET', `/admin/analytics/occupancy-heatmap${analyticsQuery(query)}`);
},

async observationsRate(query?: AnalyticsQuery) {
return request<AnalyticsObservationsRate>('GET', `/admin/analytics/observations-rate${analyticsQuery(query)}`);
},

async detectorHealth(query?: AnalyticsQuery) {
return request<AnalyticsDetectorHealth>('GET', `/admin/analytics/detector-health${analyticsQuery(query)}`);
},

async cameraDetections(cameraId: number, query?: AnalyticsQuery) {
return request<DetectionRunList>('GET', `/admin/analytics/cameras/${encodeURIComponent(cameraId)}/detections${analyticsQuery(query)}`);
},

async detection(detectionRunId: number | string) {
return request<DetectionRunDetail>('GET', `/admin/analytics/detections/${encodeURIComponent(detectionRunId)}`);
},

async createDetectionFeedback(detectionRunId: number | string, data: DetectionFeedbackRequest) {
return request<DetectionFeedback>('POST', `/admin/analytics/detections/${encodeURIComponent(detectionRunId)}/feedback`, data);
},

async detectionFeedback(detectionRunId: number | string) {
return request<DetectionFeedbackList>('GET', `/admin/analytics/detections/${encodeURIComponent(detectionRunId)}/feedback`);
},

async detectionFeedbackDetail(detectionRunId: number | string, feedbackId: number | string) {
return request<DetectionFeedback>('GET', `/admin/analytics/detections/${encodeURIComponent(detectionRunId)}/feedback/${encodeURIComponent(feedbackId)}`);
}
};
Loading
Loading