From 11cec4ccb05b14b0089b925f04380a4d2b8e25b8 Mon Sep 17 00:00:00 2001 From: Kirill Lakhov Date: Thu, 15 Feb 2024 16:31:53 +0300 Subject: [PATCH 1/3] Migrate quality UI changes (#7441) --- ...0240206_125854_klakhov_quality_bugfixes.md | 4 + cvat-core/src/analytics-report.ts | 36 +- cvat-core/src/api-implementation.ts | 85 ++-- cvat-core/src/api.ts | 8 +- cvat-core/src/common.ts | 15 +- cvat-core/src/index.ts | 17 +- cvat-core/src/quality-conflict.ts | 20 +- cvat-core/src/quality-report.ts | 54 +-- cvat-core/src/server-proxy.ts | 30 +- cvat-core/src/server-response-types.ts | 126 ++++- cvat-core/src/type-utils.ts | 15 + cvat-ui/src/actions/analytics-actions.ts | 107 ---- cvat-ui/src/actions/annotation-actions.ts | 4 +- .../analytics-page/analytics-page.tsx | 456 +++++++----------- .../analytics-page/analytics-performance.tsx | 13 +- .../analytics-page/quality/common.ts | 24 - .../analytics-page/quality/mean-quality.tsx | 99 ---- .../quality/quality-settings-modal.tsx | 368 -------------- .../quality/task-quality-component.tsx | 71 --- .../shared/quality-settings-modal.tsx | 97 ++++ .../src/components/analytics-page/styles.scss | 55 +-- .../{quality => task-quality}/empty-job.tsx | 6 +- .../gt-conflicts.tsx | 18 +- .../{quality => task-quality}/issues.tsx | 2 +- .../{quality => task-quality}/job-list.tsx | 37 +- .../task-quality/mean-quality.tsx | 96 ++++ .../task-quality/quality-settings-form.tsx | 316 ++++++++++++ .../task-quality/task-quality-component.tsx | 251 ++++++++++ .../analytics-page/utils/text-formatting.ts | 39 ++ cvat-ui/src/reducers/analytics-reducer.ts | 110 ----- cvat-ui/src/reducers/index.ts | 24 +- cvat-ui/src/reducers/notifications-reducer.ts | 54 +-- cvat-ui/src/reducers/root-reducer.ts | 2 - .../cypress/e2e/features/ground_truth_jobs.js | 8 +- 34 files changed, 1278 insertions(+), 1389 deletions(-) create mode 100644 changelog.d/20240206_125854_klakhov_quality_bugfixes.md create mode 100644 cvat-core/src/type-utils.ts delete mode 100644 cvat-ui/src/actions/analytics-actions.ts delete mode 100644 cvat-ui/src/components/analytics-page/quality/common.ts delete mode 100644 cvat-ui/src/components/analytics-page/quality/mean-quality.tsx delete mode 100644 cvat-ui/src/components/analytics-page/quality/quality-settings-modal.tsx delete mode 100644 cvat-ui/src/components/analytics-page/quality/task-quality-component.tsx create mode 100644 cvat-ui/src/components/analytics-page/shared/quality-settings-modal.tsx rename cvat-ui/src/components/analytics-page/{quality => task-quality}/empty-job.tsx (89%) rename cvat-ui/src/components/analytics-page/{quality => task-quality}/gt-conflicts.tsx (86%) rename cvat-ui/src/components/analytics-page/{quality => task-quality}/issues.tsx (96%) rename cvat-ui/src/components/analytics-page/{quality => task-quality}/job-list.tsx (89%) create mode 100644 cvat-ui/src/components/analytics-page/task-quality/mean-quality.tsx create mode 100644 cvat-ui/src/components/analytics-page/task-quality/quality-settings-form.tsx create mode 100644 cvat-ui/src/components/analytics-page/task-quality/task-quality-component.tsx create mode 100644 cvat-ui/src/components/analytics-page/utils/text-formatting.ts delete mode 100644 cvat-ui/src/reducers/analytics-reducer.ts diff --git a/changelog.d/20240206_125854_klakhov_quality_bugfixes.md b/changelog.d/20240206_125854_klakhov_quality_bugfixes.md new file mode 100644 index 000000000000..95d33f7c7969 --- /dev/null +++ b/changelog.d/20240206_125854_klakhov_quality_bugfixes.md @@ -0,0 +1,4 @@ +### Fixed + +- On quality page for a task, only the first page with jobs has quality report metrics + () diff --git a/cvat-core/src/analytics-report.ts b/cvat-core/src/analytics-report.ts index 8d390046d4ab..4d3637c7ad67 100644 --- a/cvat-core/src/analytics-report.ts +++ b/cvat-core/src/analytics-report.ts @@ -2,41 +2,11 @@ // // SPDX-License-Identifier: MIT +import { + SerializedAnalyticsEntry, SerializedAnalyticsReport, SerializedDataEntry, SerializedTransformationEntry, +} from './server-response-types'; import { ArgumentError } from './exceptions'; -export interface SerializedDataEntry { - date?: string; - value?: number | Record -} - -export interface SerializedTransformBinaryOp { - left: string; - operator: string; - right: string; -} - -export interface SerializedTransformationEntry { - name: string; - binary?: SerializedTransformBinaryOp; -} - -export interface SerializedAnalyticsEntry { - name?: string; - title?: string; - description?: string; - granularity?: string; - default_view?: string; - data_series?: Record; - transformations?: SerializedTransformationEntry[]; -} - -export interface SerializedAnalyticsReport { - id?: number; - target?: string; - created_date?: string; - statistics?: SerializedAnalyticsEntry[]; -} - export enum AnalyticsReportTarget { JOB = 'job', TASK = 'task', diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index 8c3388055cf3..443edf7a91aa 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -1,5 +1,5 @@ // Copyright (C) 2019-2022 Intel Corporation -// Copyright (C) 2022-2023 CVAT.ai Corporation +// Copyright (C) 2022-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -13,10 +13,12 @@ import { isBoolean, isInteger, isString, + isPageSize, checkFilter, checkExclusiveFields, checkObjectType, filterFieldsToSnakeCase, + fieldsToSnakeCase, } from './common'; import User from './user'; @@ -402,34 +404,36 @@ export default function implementAPI(cvat: CVATCore): CVATCore { }); implementationMixin(cvat.analytics.quality.reports, async (filter) => { - let updatedParams: Record = {}; - if ('taskId' in filter) { - updatedParams = { - task_id: filter.taskId, - sort: '-created_date', - target: filter.target, - }; - } - if ('jobId' in filter) { - updatedParams = { - job_id: filter.jobId, - sort: '-created_date', - target: filter.target, - }; - } - const reportsData = await serverProxy.analytics.quality.reports(updatedParams); + checkFilter(filter, { + page: isInteger, + pageSize: isPageSize, + parentID: isInteger, + projectID: isInteger, + taskID: isInteger, + jobID: isInteger, + target: isString, + filter: isString, + search: isString, + sort: isString, + }); + + const params = fieldsToSnakeCase({ ...filter, sort: '-created_date' }); - return reportsData.map((report) => new QualityReport({ ...report })); + const reportsData = await serverProxy.analytics.quality.reports(params); + const reports = Object.assign( + reportsData.map((report) => new QualityReport({ ...report })), + { count: reportsData.count }, + ); + return reports; }); implementationMixin(cvat.analytics.quality.conflicts, async (filter) => { - let updatedParams: Record = {}; - if ('reportId' in filter) { - updatedParams = { - report_id: filter.reportId, - }; - } + checkFilter(filter, { + reportID: isInteger, + }); + + const params = fieldsToSnakeCase(filter); - const conflictsData = await serverProxy.analytics.quality.conflicts(updatedParams); + const conflictsData = await serverProxy.analytics.quality.conflicts(params); const conflicts = conflictsData.map((conflict) => new QualityConflict({ ...conflict })); const frames = Array.from(new Set(conflicts.map((conflict) => conflict.frame))) .sort((a, b) => a - b); @@ -498,8 +502,14 @@ export default function implementAPI(cvat: CVATCore): CVATCore { return mergedConflicts; }); - implementationMixin(cvat.analytics.quality.settings.get, async (taskID: number) => { - const settings = await serverProxy.analytics.quality.settings.get(taskID); + implementationMixin(cvat.analytics.quality.settings.get, async (filter) => { + checkFilter(filter, { + taskID: isInteger, + }); + + const params = fieldsToSnakeCase(filter); + + const settings = await serverProxy.analytics.quality.settings.get(params); return new QualitySettings({ ...settings }); }); implementationMixin(cvat.analytics.performance.reports, async (filter) => { @@ -513,25 +523,8 @@ export default function implementAPI(cvat: CVATCore): CVATCore { checkExclusiveFields(filter, ['jobID', 'taskID', 'projectID'], ['startDate', 'endDate']); - const updatedParams: Record = {}; - - if ('taskID' in filter) { - updatedParams.task_id = filter.taskID; - } - if ('jobID' in filter) { - updatedParams.job_id = filter.jobID; - } - if ('projectID' in filter) { - updatedParams.project_id = filter.projectID; - } - if ('startDate' in filter) { - updatedParams.start_date = filter.startDate; - } - if ('endDate' in filter) { - updatedParams.end_date = filter.endDate; - } - - const reportData = await serverProxy.analytics.performance.reports(updatedParams); + const params = fieldsToSnakeCase(filter); + const reportData = await serverProxy.analytics.performance.reports(params); return new AnalyticsReport(reportData); }); implementationMixin(cvat.frames.getMeta, async (type, id) => { diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index a4482f58f82d..1e24db9eda77 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -350,17 +350,17 @@ function build(): CVATCore { }, }, quality: { - async reports(filter: any) { + async reports(filter = {}) { const result = await PluginRegistry.apiWrapper(cvat.analytics.quality.reports, filter); return result; }, - async conflicts(filter: any) { + async conflicts(filter = {}) { const result = await PluginRegistry.apiWrapper(cvat.analytics.quality.conflicts, filter); return result; }, settings: { - async get(taskID: number) { - const result = await PluginRegistry.apiWrapper(cvat.analytics.quality.settings.get, taskID); + async get(filter = {}) { + const result = await PluginRegistry.apiWrapper(cvat.analytics.quality.settings.get, filter); return result; }, }, diff --git a/cvat-core/src/common.ts b/cvat-core/src/common.ts index 1c09aef7ea22..b0e5f78ee4ce 100644 --- a/cvat-core/src/common.ts +++ b/cvat-core/src/common.ts @@ -1,8 +1,9 @@ // Copyright (C) 2019-2022 Intel Corporation -// Copyright (C) 2022-2023s CVAT.ai Corporation +// Copyright (C) 2022-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT +import { snakeCase } from 'lodash'; import { ArgumentError } from './exceptions'; export function isBoolean(value): boolean { @@ -145,3 +146,15 @@ export function filterFieldsToSnakeCase(filter: Record, keysToSn export function isResourceURL(url: string): boolean { return /\/([0-9]+)$/.test(url); } + +export function isPageSize(value: number | 'all'): boolean { + return isInteger(value) || value === 'all'; +} + +export function fieldsToSnakeCase(params: Record): Record { + const result = {}; + for (const [k, v] of Object.entries(params)) { + result[snakeCase(k)] = v; + } + return result; +} diff --git a/cvat-core/src/index.ts b/cvat-core/src/index.ts index cbce9cb0ba5e..ae282385a023 100644 --- a/cvat-core/src/index.ts +++ b/cvat-core/src/index.ts @@ -2,6 +2,9 @@ // // SPDX-License-Identifier: MIT +import { + AnalyticsReportFilter, QualityConflictsFilter, QualityReportsFilter, QualitySettingsFilter, +} from './server-response-types'; import PluginRegistry from './plugins'; import serverProxy from './server-proxy'; import lambdaManager from './lambda-manager'; @@ -24,6 +27,10 @@ import { FrameData } from './frames'; import CloudStorage from './cloud-storage'; import Organization, { Invitation } from './organization'; import Webhook from './webhook'; +import QualityReport from './quality-report'; +import QualityConflict from './quality-conflict'; +import QualitySettings from './quality-settings'; +import AnalyticsReport from './analytics-report'; import AnnotationGuide from './guide'; import BaseSingleFrameAction, { listActions, registerAction, runActions } from './annotations-actions'; import { @@ -126,12 +133,14 @@ export default interface CVATCore { }; analytics: { quality: { - reports: any; - conflicts: any; - settings: any; + reports: (filter: QualityReportsFilter) => Promise>; + conflicts: (filter: QualityConflictsFilter) => Promise; + settings: { + get: (filter: QualitySettingsFilter) => Promise; + }; }; performance: { - reports: any; + reports: (filter: AnalyticsReportFilter) => Promise; }; }; frames: { diff --git a/cvat-core/src/quality-conflict.ts b/cvat-core/src/quality-conflict.ts index e9db6d012aca..3d7252f37e87 100644 --- a/cvat-core/src/quality-conflict.ts +++ b/cvat-core/src/quality-conflict.ts @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: MIT +import { SerializedAnnotationConflictData, SerializedQualityConflictData } from './server-response-types'; import { ObjectType } from './enums'; export enum QualityConflictType { @@ -15,25 +16,6 @@ export enum ConflictSeverity { WARNING = 'warning', } -export interface SerializedQualityConflictData { - id?: number; - frame?: number; - type?: string; - annotation_ids?: SerializedAnnotationConflictData[]; - data?: string; - severity?: string; - description?: string; -} - -export interface SerializedAnnotationConflictData { - job_id?: number; - obj_id?: number; - type?: ObjectType; - shape_type?: string | null; - conflict_type?: string; - severity?: string; -} - export class AnnotationConflict { #jobID: number; #serverID: number; diff --git a/cvat-core/src/quality-report.ts b/cvat-core/src/quality-report.ts index e174d1ff9e75..652241527e86 100644 --- a/cvat-core/src/quality-report.ts +++ b/cvat-core/src/quality-report.ts @@ -2,35 +2,7 @@ // // SPDX-License-Identifier: MIT -export interface SerializedQualityReportData { - id?: number; - parent_id?: number; - task_id?: number; - job_id?: number; - target: string; - created_date?: string; - gt_last_updated?: string; - summary?: { - frame_count: number, - frame_share: number, - conflict_count: number, - valid_count: number, - ds_count: number, - gt_count: number, - error_count: number, - warning_count: number, - conflicts_by_type: { - extra_annotation: number, - missing_annotation: number, - mismatching_label: number, - low_overlap: number, - mismatching_direction: number, - mismatching_attributes: number, - mismatching_groups: number, - covered_annotation: number, - } - }; -} +import { SerializedQualityReportData } from './server-response-types'; export interface QualitySummary { frameCount: number; @@ -58,9 +30,9 @@ export interface QualitySummary { export default class QualityReport { #id: number; - #parentId: number; - #taskId: number; - #jobId: number; + #parentID: number; + #taskID: number; + #jobID: number; #target: string; #createdDate: string; #gtLastUpdated: string; @@ -68,9 +40,9 @@ export default class QualityReport { constructor(initialData: SerializedQualityReportData) { this.#id = initialData.id; - this.#parentId = initialData.parent_id; - this.#taskId = initialData.task_id; - this.#jobId = initialData.job_id; + this.#parentID = initialData.parent_id; + this.#taskID = initialData.task_id; + this.#jobID = initialData.job_id; this.#target = initialData.target; this.#gtLastUpdated = initialData.gt_last_updated; this.#createdDate = initialData.created_date; @@ -81,16 +53,16 @@ export default class QualityReport { return this.#id; } - get parentId(): number { - return this.#parentId; + get parentID(): number { + return this.#parentID; } - get taskId(): number { - return this.#taskId; + get taskID(): number { + return this.#taskID; } - get jobId(): number { - return this.#jobId; + get jobID(): number { + return this.#jobID; } get target(): string { diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index b440e81e8e60..c258ff4be42e 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -14,18 +14,17 @@ import { SerializedLabel, SerializedAnnotationFormats, ProjectsFilter, SerializedProject, SerializedTask, TasksFilter, SerializedUser, SerializedOrganization, SerializedAbout, SerializedRemoteFile, SerializedUserAgreement, - SerializedRegister, JobsFilter, SerializedJob, SerializedGuide, SerializedAsset, - SerializedQualitySettingsData, SerializedInvitationData, SerializedCloudStorage, - SerializedFramesMetaData, SerializedCollection, SerializedAPISchema, + SerializedRegister, JobsFilter, SerializedJob, SerializedGuide, SerializedAsset, SerializedAPISchema, + SerializedInvitationData, SerializedCloudStorage, SerializedFramesMetaData, SerializedCollection, + SerializedQualitySettingsData, ApiQualitySettingsFilter, SerializedQualityConflictData, ApiQualityConflictsFilter, + SerializedQualityReportData, ApiQualityReportsFilter, SerializedAnalyticsReport, ApiAnalyticsReportFilter, } from './server-response-types'; -import { SerializedQualityReportData } from './quality-report'; -import { SerializedAnalyticsReport } from './analytics-report'; +import { PaginatedResource } from './core-types'; import { Storage } from './storage'; import { RQStatus, StorageLocation, WebhookSourceType } from './enums'; import { isEmail, isResourceURL } from './common'; import config from './config'; import { ServerError } from './exceptions'; -import { SerializedQualityConflictData } from './quality-conflict'; type Params = { org: number | string, @@ -2321,13 +2320,15 @@ async function createAsset(file: File, guideId: number): Promise { +async function getQualitySettings( + filter: ApiQualitySettingsFilter, +): Promise { const { backendAPI } = config; try { const response = await Axios.get(`${backendAPI}/quality/settings`, { params: { - task_id: taskID, + ...filter, }, }); @@ -2355,7 +2356,9 @@ async function updateQualitySettings( } } -async function getQualityConflicts(filter): Promise { +async function getQualityConflicts( + filter: ApiQualityConflictsFilter, +): Promise { const params = enableOrganization(); const { backendAPI } = config; @@ -2371,7 +2374,9 @@ async function getQualityConflicts(filter): Promise { +async function getQualityReports( + filter: ApiQualityReportsFilter, +): Promise> { const { backendAPI } = config; try { @@ -2381,13 +2386,16 @@ async function getQualityReports(filter): Promise }, }); + response.data.results.count = response.data.count; return response.data.results; } catch (errorData) { throw generateError(errorData); } } -async function getAnalyticsReports(filter): Promise { +async function getAnalyticsReports( + filter: ApiAnalyticsReportFilter, +): Promise { const { backendAPI } = config; try { diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index d5fff1e7ce9b..5a0f070fb11b 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -7,8 +7,9 @@ import { DimensionType, JobStage, JobState, JobType, ProjectStatus, ShapeType, StorageLocation, LabelType, ShareFileType, Source, TaskMode, TaskStatus, - CloudStorageCredentialsType, CloudStorageProviderType, + CloudStorageCredentialsType, CloudStorageProviderType, ObjectType, } from './enums'; +import { Camelized } from './type-utils'; export interface SerializedAnnotationImporter { name: string; @@ -24,12 +25,19 @@ export interface SerializedAnnotationFormats { importers: SerializedAnnotationImporter[]; exporters: SerializedAnnotationExporter[]; } -export interface ProjectsFilter { + +export interface ApiCommonFilterParams { page?: number; - id?: number; + page_size?: number | 'all'; + filter?: string; sort?: string; + org_id?: number; + org?: string; search?: string; - filter?: string; +} + +export interface ProjectsFilter extends ApiCommonFilterParams { + id?: number; } export interface SerializedUser { @@ -225,6 +233,11 @@ export interface SerializedOrganization { contact?: SerializedOrganizationContact, } +export interface ApiQualitySettingsFilter extends ApiCommonFilterParams { + task_id?: number; +} +export type QualitySettingsFilter = Camelized; + export interface SerializedQualitySettingsData { id?: number; task?: number; @@ -242,6 +255,111 @@ export interface SerializedQualitySettingsData { compare_attributes?: boolean; } +export interface ApiQualityConflictsFilter extends ApiCommonFilterParams { + report_id?: number; +} +export type QualityConflictsFilter = Camelized; + +export interface SerializedAnnotationConflictData { + job_id?: number; + obj_id?: number; + type?: ObjectType; + shape_type?: string | null; + conflict_type?: string; + severity?: string; +} + +export interface SerializedQualityConflictData { + id?: number; + frame?: number; + type?: string; + annotation_ids?: SerializedAnnotationConflictData[]; + data?: string; + severity?: string; + description?: string; +} + +export interface ApiQualityReportsFilter extends ApiCommonFilterParams { + parent_id?: number; + peoject_id?: number; + task_id?: number; + job_id?: number; + target?: string; +} +export type QualityReportsFilter = Camelized; + +export interface SerializedQualityReportData { + id?: number; + parent_id?: number; + task_id?: number; + job_id?: number; + target: string; + created_date?: string; + gt_last_updated?: string; + summary?: { + frame_count: number; + frame_share: number; + conflict_count: number; + valid_count: number; + ds_count: number; + gt_count: number; + error_count: number; + warning_count: number; + conflicts_by_type: { + extra_annotation: number; + missing_annotation: number; + mismatching_label: number; + low_overlap: number; + mismatching_direction: number; + mismatching_attributes: number; + mismatching_groups: number; + covered_annotation: number; + } + }; +} + +export interface SerializedDataEntry { + date?: string; + value?: number | Record +} + +export interface SerializedTransformBinaryOp { + left: string; + operator: string; + right: string; +} + +export interface SerializedTransformationEntry { + name: string; + binary?: SerializedTransformBinaryOp; +} + +export interface SerializedAnalyticsEntry { + name?: string; + title?: string; + description?: string; + granularity?: string; + default_view?: string; + data_series?: Record; + transformations?: SerializedTransformationEntry[]; +} + +export interface ApiAnalyticsReportFilter extends ApiCommonFilterParams { + project_id?: number; + task_id?: number; + job_id?: number; + start_date?: string; + end_date?: string; +} +export type AnalyticsReportFilter = Camelized; + +export interface SerializedAnalyticsReport { + id?: number; + target?: string; + created_date?: string; + statistics?: SerializedAnalyticsEntry[]; +} + export interface SerializedInvitationData { created_date: string; key: string; diff --git a/cvat-core/src/type-utils.ts b/cvat-core/src/type-utils.ts new file mode 100644 index 000000000000..1b8423e8d1a7 --- /dev/null +++ b/cvat-core/src/type-utils.ts @@ -0,0 +1,15 @@ +// Copyright (C) 2024 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +type CamelizeString = +T extends string ? string extends T ? string : + T extends `${infer F}_${infer R}` ? + CamelizeString, `${C}${F}`> : (T extends 'Id' ? `${C}${'ID'}` : `${C}${T}`) : T; + +// https://stackoverflow.com/a/63715429 +// Use https://stackoverflow.com/a/64933956 for snake-ization +/** + * Returns the input type with fields in CamelCase + */ +export type Camelized = { [K in keyof T as CamelizeString]: T[K] }; diff --git a/cvat-ui/src/actions/analytics-actions.ts b/cvat-ui/src/actions/analytics-actions.ts deleted file mode 100644 index 3c95e7476dbc..000000000000 --- a/cvat-ui/src/actions/analytics-actions.ts +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (C) 2023 CVAT.ai Corporation -// -// SPDX-License-Identifier: MIT - -import { - getCore, QualityReport, QualitySettings, Task, -} from 'cvat-core-wrapper'; -import { Dispatch, ActionCreator } from 'redux'; -import { QualityQuery } from 'reducers'; -import { ActionUnion, createAction, ThunkAction } from 'utils/redux'; - -const cvat = getCore(); - -export enum AnalyticsActionsTypes { - GET_QUALITY_REPORTS = 'GET_QUALITY_REPORTS', - GET_QUALITY_REPORTS_SUCCESS = 'GET_QUALITY_REPORTS_SUCCESS', - GET_QUALITY_REPORTS_FAILED = 'GET_QUALITY_REPORTS_FAILED', - SWITCH_QUALITY_SETTINGS_VISIBLE = 'SWITCH_QUALITY_SETTINGS_VISIBLE', - GET_QUALITY_SETTINGS = 'GET_QUALITY_SETTINS', - GET_QUALITY_SETTINGS_SUCCESS = 'GET_QUALITY_SETTINGS_SUCCESS', - GET_QUALITY_SETTINGS_FAILED = 'GET_QUALITY_SETTINGS_FAILED', - UPDATE_QUALITY_SETTINGS = 'UPDATE_QUALITY_SETTINGS', - UPDATE_QUALITY_SETTINGS_SUCCESS = 'UPDATE_QUALITY_SETTINGS_SUCCESS', - UPDATE_QUALITY_SETTINGS_FAILED = 'UPDATE_QUALITY_SETTINGS_FAILED', -} - -export const analyticsActions = { - getQualityReports: (task: Task, query: QualityQuery) => ( - createAction(AnalyticsActionsTypes.GET_QUALITY_REPORTS, { query }) - ), - getQualityReportsSuccess: (tasksReports: QualityReport[], jobsReports: QualityReport[]) => createAction( - AnalyticsActionsTypes.GET_QUALITY_REPORTS_SUCCESS, { tasksReports, jobsReports }, - ), - getQualityReportsFailed: (error: any) => createAction(AnalyticsActionsTypes.GET_QUALITY_REPORTS_FAILED, { error }), - switchQualitySettingsVisible: (visible: boolean) => ( - createAction(AnalyticsActionsTypes.SWITCH_QUALITY_SETTINGS_VISIBLE, { visible }) - ), - getQualitySettings: (settingsID: number) => ( - createAction(AnalyticsActionsTypes.GET_QUALITY_SETTINGS, { settingsID }) - ), - getQualitySettingsSuccess: (settings: QualitySettings) => ( - createAction(AnalyticsActionsTypes.GET_QUALITY_SETTINGS_SUCCESS, { settings }) - ), - getQualitySettingsFailed: (error: any) => ( - createAction(AnalyticsActionsTypes.GET_QUALITY_SETTINGS_FAILED, { error }) - ), - updateQualitySettings: (settings: QualitySettings) => ( - createAction(AnalyticsActionsTypes.UPDATE_QUALITY_SETTINGS, { settings }) - ), - updateQualitySettingsSuccess: (settings: QualitySettings) => ( - createAction(AnalyticsActionsTypes.UPDATE_QUALITY_SETTINGS_SUCCESS, { settings }) - ), - updateQualitySettingsFailed: (error: any) => ( - createAction(AnalyticsActionsTypes.UPDATE_QUALITY_SETTINGS_FAILED, { error }) - ), -}; - -export const getQualityReportsAsync = (task: Task, query: QualityQuery): ThunkAction => ( - async (dispatch: ActionCreator): Promise => { - dispatch(analyticsActions.getQualityReports(task, query)); - - try { - // reports are returned in order -created_date - const [taskReport] = await cvat.analytics.quality.reports({ taskId: task.id, target: 'task' }); - const jobReports = await cvat.analytics.quality.reports({ taskId: task.id, target: 'job' }); - const jobIds = task.jobs.map((job) => job.id); - const relevantReports: QualityReport[] = []; - jobIds.forEach((jobId: number) => { - const report = jobReports.find((_report: QualityReport) => _report.jobId === jobId); - if (report) relevantReports.push(report); - }); - - dispatch(analyticsActions.getQualityReportsSuccess(taskReport ? [taskReport] : [], relevantReports)); - } catch (error) { - dispatch(analyticsActions.getQualityReportsFailed(error)); - } - } -); - -export const getQualitySettingsAsync = (task: Task): ThunkAction => ( - async (dispatch: ActionCreator): Promise => { - dispatch(analyticsActions.getQualitySettings(task.id)); - try { - const qualitySettings = await cvat.analytics.quality.settings.get(task.id); - - dispatch(analyticsActions.getQualitySettingsSuccess(qualitySettings)); - } catch (error) { - dispatch(analyticsActions.getQualityReportsFailed(error)); - } - } -); - -export const updateQualitySettingsAsync = (qualitySettings: QualitySettings): ThunkAction => ( - async (dispatch: ActionCreator): Promise => { - dispatch(analyticsActions.updateQualitySettings(qualitySettings)); - - try { - const updatedQualitySettings = await qualitySettings.save(); - dispatch(analyticsActions.updateQualitySettingsSuccess(updatedQualitySettings)); - } catch (error) { - dispatch(analyticsActions.updateQualitySettingsFailed(error)); - throw error; - } - } -); - -export type AnalyticsActions = ActionUnion; diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index ac3be64331b7..f1534b071ee8 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -946,9 +946,9 @@ export function getJobAsync({ let conflicts: QualityConflict[] = []; if (gtJob) { - const [report] = await cvat.analytics.quality.reports({ jobId: job.id, target: 'job' }); + const [report] = await cvat.analytics.quality.reports({ jobID: job.id, target: 'job' }); if (report) { - conflicts = await cvat.analytics.quality.conflicts({ reportId: report.id }); + conflicts = await cvat.analytics.quality.conflicts({ reportID: report.id }); } } diff --git a/cvat-ui/src/components/analytics-page/analytics-page.tsx b/cvat-ui/src/components/analytics-page/analytics-page.tsx index 8da7ff731574..8284130052f9 100644 --- a/cvat-ui/src/components/analytics-page/analytics-page.tsx +++ b/cvat-ui/src/components/analytics-page/analytics-page.tsx @@ -1,13 +1,14 @@ -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT +import './styles.scss'; + import React, { useCallback, useEffect, useState } from 'react'; import { useLocation, useParams } from 'react-router'; import { Link } from 'react-router-dom'; import { Row, Col } from 'antd/lib/grid'; import Tabs from 'antd/lib/tabs'; -import Text from 'antd/lib/typography/Text'; import Title from 'antd/lib/typography/Title'; import notification from 'antd/lib/notification'; import { useIsMounted } from 'utils/hooks'; @@ -17,10 +18,20 @@ import moment from 'moment'; import CVATLoadingSpinner from 'components/common/loading-spinner'; import GoBackButton from 'components/common/go-back-button'; import AnalyticsOverview, { DateIntervals } from './analytics-performance'; -import TaskQualityComponent from './quality/task-quality-component'; +import TaskQualityComponent from './task-quality/task-quality-component'; const core = getCore(); +enum AnalyticsTabs { + OVERVIEW = 'overview', + QUALITY = 'quality', +} + +function getTabFromHash(): AnalyticsTabs { + const tab = window.location.hash.slice(1) as AnalyticsTabs; + return Object.values(AnalyticsTabs).includes(tab) ? tab : AnalyticsTabs.OVERVIEW; +} + function handleTimePeriod(interval: DateIntervals): [string, string] { const now = moment.utc(); switch (interval) { @@ -42,321 +53,218 @@ function handleTimePeriod(interval: DateIntervals): [string, string] { } } +type InstanceType = 'project' | 'task' | 'job'; + function AnalyticsPage(): JSX.Element { const location = useLocation(); - let instanceType = ''; - if (location.pathname.includes('projects')) { - instanceType = 'project'; - } else if (location.pathname.includes('jobs')) { - instanceType = 'job'; - } else { - instanceType = 'task'; - } - const [fetching, setFetching] = useState(true); + const requestedInstanceType: InstanceType = (() => { + if (location.pathname.includes('projects')) { + return 'project'; + } + if (location.pathname.includes('jobs')) { + return 'job'; + } + return 'task'; + })(); + + const requestedInstanceID: number = (() => { + if (requestedInstanceType === 'project') { + return +useParams<{ pid: string }>().pid; + } + if (requestedInstanceType === 'job') { + return +useParams<{ jid: string }>().jid; + } + return +useParams<{ tid: string }>().tid; + })(); + + const [activeTab, setTab] = useState(getTabFromHash()); + + const [instanceType, setInstanceType] = useState(null); const [instance, setInstance] = useState(null); - const [analyticsReportInstance, setAnalyticsReportInstance] = useState(null); + const [analyticsReport, setAnalyticsReport] = useState(null); + const [timePeriod, setTimePeriod] = useState(DateIntervals.LAST_WEEK); + const [fetching, setFetching] = useState(true); const isMounted = useIsMounted(); - let instanceID: number | null = null; - let reportRequestID: number | null = null; - switch (instanceType) { - case 'project': { - instanceID = +useParams<{ pid: string }>().pid; - reportRequestID = +useParams<{ pid: string }>().pid; - break; + const receiveInstance = (type: InstanceType, id: number): Promise => { + if (type === 'project') { + return core.projects.get({ id }); } - case 'task': { - instanceID = +useParams<{ tid: string }>().tid; - reportRequestID = +useParams<{ tid: string }>().tid; - break; - } - case 'job': { - instanceID = +useParams<{ jid: string }>().jid; - reportRequestID = +useParams<{ jid: string }>().jid; - break; + + if (type === 'task') { + return core.tasks.get({ id }); } - default: { - throw new Error(`Unsupported instance type ${instanceType}`); + + return core.jobs.get({ jobID: id }); + }; + + const receiveReport = (timeInterval: DateIntervals, type: InstanceType, id: number): Promise => { + const [endDate, startDate] = handleTimePeriod(timeInterval); + if (type === 'project') { + return core.analytics.performance.reports({ + projectID: id, + endDate, + startDate, + }); } - } - const receieveInstance = (): void => { - let instanceRequest = null; - switch (instanceType) { - case 'project': { - instanceRequest = core.projects.get({ id: instanceID }); - break; - } - case 'task': { - instanceRequest = core.tasks.get({ id: instanceID }); - break; - } - case 'job': - { - instanceRequest = core.jobs.get({ jobID: instanceID }); - break; - } - default: { - throw new Error(`Unsupported instance type ${instanceType}`); - } + if (type === 'task') { + return core.analytics.performance.reports({ + taskID: id, + endDate, + startDate, + }); } - if (Number.isInteger(instanceID)) { - instanceRequest - .then(([_instance]: Task[] | Project[] | Job[]) => { - if (isMounted() && _instance) { - setInstance(_instance); + return core.analytics.performance.reports({ + jobID: id, + endDate, + startDate, + }); + }; + + useEffect(() => { + setFetching(true); + + if (Number.isInteger(requestedInstanceID) && ['project', 'task', 'job'].includes(requestedInstanceType)) { + Promise.all([ + receiveInstance(requestedInstanceType, requestedInstanceID), + receiveReport(timePeriod, requestedInstanceType, requestedInstanceID), + ]) + .then(([instanceResponse, report]) => { + const receivedInstance: Task | Project | Job = instanceResponse[0]; + if (receivedInstance && isMounted()) { + setInstance(receivedInstance); + setInstanceType(requestedInstanceType); } - }).catch((error: Error) => { - if (isMounted()) { - notification.error({ - message: 'Could not receive the requested instance from the server', - description: error.toString(), - }); + if (report && isMounted()) { + setAnalyticsReport(report); } - }).finally(() => { + }) + .catch((error: Error) => { + notification.error({ + message: 'Could not receive requested resources', + description: `${error.toString()}`, + }); + }) + .finally(() => { if (isMounted()) { setFetching(false); } }); } else { notification.error({ - message: 'Could not receive the requested task from the server', - description: `Requested "${instanceID}" is not valid`, + message: 'Could not load this page', + description: `Not valid resource ${requestedInstanceType} #${requestedInstanceID}`, }); - setFetching(false); - } - }; - - const receieveReport = (timeInterval: DateIntervals): void => { - if (Number.isInteger(instanceID) && Number.isInteger(reportRequestID)) { - let reportRequest = null; - const [endDate, startDate] = handleTimePeriod(timeInterval); - - switch (instanceType) { - case 'project': { - reportRequest = core.analytics.performance.reports({ - projectID: reportRequestID, - endDate, - startDate, - }); - break; - } - case 'task': { - reportRequest = core.analytics.performance.reports({ - taskID: reportRequestID, - endDate, - startDate, - }); - break; - } - case 'job': { - reportRequest = core.analytics.performance.reports({ - jobID: reportRequestID, - endDate, - startDate, - }); - break; - } - default: { - throw new Error(`Unsupported instance type ${instanceType}`); - } - } - - reportRequest - .then((report: AnalyticsReport) => { - if (isMounted() && report) { - setAnalyticsReportInstance(report); - } - }).catch((error: Error) => { - if (isMounted()) { - notification.error({ - message: 'Could not receive the requested report from the server', - description: error.toString(), - }); - } - }); } - }; - useEffect((): void => { - Promise.all([receieveInstance(), receieveReport(DateIntervals.LAST_WEEK)]).finally(() => { + return () => { if (isMounted()) { - setFetching(false); + setInstance(null); + setAnalyticsReport(null); } - }); - }, []); + }; + }, [requestedInstanceType, requestedInstanceID, timePeriod]); const onJobUpdate = useCallback((job: Job): void => { setFetching(true); - job.save().then(() => { - if (isMounted()) { - receieveInstance(); - } - }).catch((error: Error) => { - if (isMounted()) { + + job.save() + .catch((error: Error) => { notification.error({ message: 'Could not update the job', description: error.toString(), }); - } - }).finally(() => { - if (isMounted()) { - setFetching(false); - } - }); + }) + .finally(() => { + if (isMounted()) { + setFetching(false); + } + }); }, []); - const onAnalyticsTimePeriodChange = useCallback((val: DateIntervals): void => { - receieveReport(val); + useEffect(() => { + window.addEventListener('hashchange', () => { + const hash = getTabFromHash(); + setTab(hash); + }); }, []); + const onTabKeyChange = (key: string): void => { + setTab(key as AnalyticsTabs); + }; + + useEffect(() => { + window.location.hash = activeTab; + }, [activeTab]); + let backNavigation: JSX.Element | null = null; let title: JSX.Element | null = null; let tabs: JSX.Element | null = null; - if (instance) { - switch (instanceType) { - case 'project': { - backNavigation = ( - - - - ); - title = ( - - - Analytics for - {' '} - <Link to={`/projects/${instance.id}`}>{`Project #${instance.id}`}</Link> - - - ); - tabs = ( - - - Performance - - )} - key='Overview' - > - - - - ); - break; - } - case 'task': { - backNavigation = ( - - - - ); - title = ( - - - Analytics for - {' '} - <Link to={`/tasks/${instance.id}`}>{`Task #${instance.id}`}</Link> - - - ); - tabs = ( - - - Performance - - )} - key='overview' - > - - - - Quality - - )} - key='quality' - > - - - - ); - break; - } - case 'job': - { - backNavigation = ( - - - - ); - title = ( - - - Analytics for - {' '} - <Link to={`/tasks/${instance.taskId}/jobs/${instance.id}`}>{`Job #${instance.id}`}</Link> - - - ); - tabs = ( - - - Performance - - )} - key='overview' - > - - - - ); - break; - } - default: { - throw new Error(`Unsupported instance type ${instanceType}`); - } + if (instanceType && instance) { + backNavigation = ( + + + + ); + + let analyticsFor: JSX.Element | null = {`Project #${instance.id}`}; + if (instanceType === 'task') { + analyticsFor = {`Task #${instance.id}`}; + } else if (instanceType === 'job') { + analyticsFor = {`Job #${instance.id}`}; } + title = ( + + + Analytics for + {' '} + {analyticsFor} + + + ); + + tabs = ( + + + + + {instanceType === 'task' && ( + + + + )} + + ); } return (
- { - fetching ? ( -
- -
- ) : ( - - {backNavigation} - - {title} - {tabs} - - - ) - } + {fetching ? ( +
+ +
+ ) : ( + + {backNavigation} + + {title} + {tabs} + + + )}
); } diff --git a/cvat-ui/src/components/analytics-page/analytics-performance.tsx b/cvat-ui/src/components/analytics-page/analytics-performance.tsx index 2116c08f82ac..164504f7816c 100644 --- a/cvat-ui/src/components/analytics-page/analytics-performance.tsx +++ b/cvat-ui/src/components/analytics-page/analytics-performance.tsx @@ -1,9 +1,7 @@ -// Copyright (C) 2023 CVAT.ai Corporation +// Copyright (C) 2023-2024 CVAT.ai Corporation // // SPDX-License-Identifier: MIT -import './styles.scss'; - import React from 'react'; import moment from 'moment'; import RGL, { WidthProvider } from 'react-grid-layout'; @@ -26,6 +24,7 @@ export enum DateIntervals { interface Props { report: AnalyticsReport | null; + timePeriod: DateIntervals; onTimePeriodChange: (val: DateIntervals) => void; } @@ -38,7 +37,7 @@ const colors = [ ]; function AnalyticsOverview(props: Props): JSX.Element | null { - const { report, onTimePeriodChange } = props; + const { report, timePeriod, onTimePeriodChange } = props; if (!report) return null; const layout: any = []; @@ -144,15 +143,13 @@ function AnalyticsOverview(props: Props): JSX.Element | null { - Created -   - {report?.createdDate ? moment(report?.createdDate).fromNow() : ''} + {`Created ${report?.createdDate ? moment(report?.createdDate).fromNow() : ''}`}