Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added ability to call analytics report manually #7805

Merged
merged 41 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
be394f1
Added button to request analytics report manually
bsekachev Apr 17, 2024
47b1118
Added file
bsekachev Apr 17, 2024
44de5e5
Updated code
bsekachev Apr 18, 2024
7f929a1
Updated permissions
bsekachev Apr 18, 2024
9a2f9e9
Request report after creation
bsekachev Apr 18, 2024
f684a5f
Merge branch 'develop' into bs/manual_analytics_report
bsekachev Apr 18, 2024
47e6c46
Merge branch 'develop' into bs/manual_analytics_report
bsekachev Apr 22, 2024
f0f6b88
Merge branch 'develop' into bs/manual_analytics_report
bsekachev Apr 23, 2024
8e28511
Minor refactoring
bsekachev Apr 23, 2024
7038a90
Refactoring
bsekachev Apr 23, 2024
96e72b6
Refactoring
bsekachev Apr 23, 2024
6fdf891
Optimized number of requests
bsekachev Apr 24, 2024
01a7e06
Minor fix
bsekachev Apr 24, 2024
750a60b
Optimzed report computing
bsekachev Apr 24, 2024
da2d97f
Merge branch 'develop' into bs/manual_analytics_report
bsekachev Apr 25, 2024
36de343
Removed extra changes
bsekachev Apr 25, 2024
3a82773
Aborted extra changes
bsekachev Apr 25, 2024
654a1da
Removed extra file
bsekachev Apr 25, 2024
faf3b91
Merged develop
bsekachev Apr 26, 2024
fabeb7b
Aborted extra changes
bsekachev Apr 26, 2024
cb7fa59
Added changelog entry
bsekachev Apr 26, 2024
4075228
Fixed linter
bsekachev Apr 26, 2024
42e04e8
Do not expose stacktrace
bsekachev Apr 26, 2024
db87236
Removed automatic scheduling
bsekachev Apr 26, 2024
6515264
Added changelog entry
bsekachev Apr 26, 2024
9f99561
Update 20240426_103258_boris_manual_analytics_report.md
bsekachev Apr 26, 2024
fde3c6f
Removed extra import
bsekachev Apr 26, 2024
fdc8bc3
Removed outdated code
bsekachev Apr 26, 2024
8454550
Do not create duplicated jobs, avoid removing failed jobs
bsekachev Apr 26, 2024
fa0de2e
Fixed imports
bsekachev Apr 26, 2024
a8e02bf
Updated report
bsekachev Apr 26, 2024
ccb4188
Applied comments
bsekachev Apr 29, 2024
4bb1870
Applied comments
bsekachev Apr 29, 2024
e8be765
Fixed tests
bsekachev Apr 29, 2024
380ca6b
Merge branch 'develop' into bs/manual_analytics_report
bsekachev Apr 30, 2024
6e2625d
Merged develop
bsekachev May 1, 2024
8c79fbf
Fixed unstable test
bsekachev May 1, 2024
82fb328
Fixed one more unstable test
bsekachev May 1, 2024
3f553f9
Applied comment
bsekachev May 2, 2024
3f01112
Removed outdated code
bsekachev May 3, 2024
f530537
Removed extra import
bsekachev May 3, 2024
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
7 changes: 4 additions & 3 deletions cvat-core/src/analytics-report.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Copyright (C) 2023 CVAT.ai Corporation
// Copyright (C) 2023-2024 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT

import {
SerializedAnalyticsEntry, SerializedAnalyticsReport, SerializedDataEntry, SerializedTransformationEntry,
SerializedAnalyticsEntry, SerializedAnalyticsReport,
SerializedDataEntry, SerializedTransformationEntry,
} from './server-response-types';
import { ArgumentError } from './exceptions';

Expand Down Expand Up @@ -126,7 +127,7 @@ export default class AnalyticsReport {
#statistics: AnalyticsEntry[];

constructor(initialData: SerializedAnalyticsReport) {
this.#id = initialData.id;
this.#id = initialData.job_id || initialData.task_id || initialData.project_id;
this.#target = initialData.target as AnalyticsReportTarget;
this.#createdDate = initialData.created_date;
this.#statistics = [];
Expand Down
36 changes: 30 additions & 6 deletions cvat-core/src/api-implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import CloudStorage from './cloud-storage';
import Organization, { Invitation } from './organization';
import Webhook from './webhook';
import { ArgumentError } from './exceptions';
import { SerializedAsset } from './server-response-types';
import {
AnalyticsReportFilter, QualityConflictsFilter, QualityReportsFilter,
QualitySettingsFilter, SerializedAsset,
} from './server-response-types';
import QualityReport from './quality-report';
import QualityConflict, { ConflictSeverity } from './quality-conflict';
import QualitySettings from './quality-settings';
Expand Down Expand Up @@ -403,7 +406,7 @@ export default function implementAPI(cvat: CVATCore): CVATCore {
return webhooks;
});

implementationMixin(cvat.analytics.quality.reports, async (filter) => {
implementationMixin(cvat.analytics.quality.reports, async (filter: QualityReportsFilter) => {
checkFilter(filter, {
page: isInteger,
pageSize: isPageSize,
Expand All @@ -426,7 +429,7 @@ export default function implementAPI(cvat: CVATCore): CVATCore {
);
return reports;
});
implementationMixin(cvat.analytics.quality.conflicts, async (filter) => {
implementationMixin(cvat.analytics.quality.conflicts, async (filter: QualityConflictsFilter) => {
checkFilter(filter, {
reportID: isInteger,
});
Expand Down Expand Up @@ -502,7 +505,7 @@ export default function implementAPI(cvat: CVATCore): CVATCore {

return mergedConflicts;
});
implementationMixin(cvat.analytics.quality.settings.get, async (filter) => {
implementationMixin(cvat.analytics.quality.settings.get, async (filter: QualitySettingsFilter) => {
checkFilter(filter, {
taskID: isInteger,
});
Expand All @@ -512,7 +515,7 @@ export default function implementAPI(cvat: CVATCore): CVATCore {
const settings = await serverProxy.analytics.quality.settings.get(params);
return new QualitySettings({ ...settings });
});
implementationMixin(cvat.analytics.performance.reports, async (filter) => {
implementationMixin(cvat.analytics.performance.reports, async (filter: AnalyticsReportFilter) => {
checkFilter(filter, {
jobID: isInteger,
taskID: isInteger,
Expand All @@ -527,9 +530,30 @@ export default function implementAPI(cvat: CVATCore): CVATCore {
const reportData = await serverProxy.analytics.performance.reports(params);
return new AnalyticsReport(reportData);
});
implementationMixin(cvat.analytics.performance.calculate, async (
body: Parameters<CVATCore['analytics']['performance']['calculate']>[0],
onUpdate: Parameters<CVATCore['analytics']['performance']['calculate']>[1],
) => {
checkFilter(body, {
jobID: isInteger,
taskID: isInteger,
projectID: isInteger,
});

checkExclusiveFields(body, ['jobID', 'taskID', 'projectID'], []);
if (!('jobID' in body || 'taskID' in body || 'projectID' in body)) {
throw new ArgumentError('One of "jobID", "taskID", "projectID" is required, but not provided');
}

const params = fieldsToSnakeCase(body);
await serverProxy.analytics.performance.calculate(params, onUpdate);
});
implementationMixin(cvat.frames.getMeta, async (type, id) => {
const result = await serverProxy.frames.getMeta(type, id);
return new FramesMetaData({ ...result });
return new FramesMetaData({
...result,
deleted_frames: Object.fromEntries(result.deleted_frames.map((_frame) => [_frame, true])),
});
});

return cvat;
Expand Down
8 changes: 8 additions & 0 deletions cvat-core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,14 @@ function build(): CVATCore {
const result = await PluginRegistry.apiWrapper(cvat.analytics.performance.reports, filter);
return result;
},
async calculate(body, onUpdate) {
const result = await PluginRegistry.apiWrapper(
cvat.analytics.performance.calculate,
body,
onUpdate,
);
return result;
},
},
quality: {
async reports(filter = {}) {
Expand Down
14 changes: 9 additions & 5 deletions cvat-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,11 @@ export default interface CVATCore {
projects: {
get: (
filter: {
id: number;
page: number;
search: string;
sort: string;
filter: string;
id?: number;
page?: number;
search?: string;
sort?: string;
filter?: string;
}
) => Promise<PaginatedResource<Project>>;
searchNames: any;
Expand Down Expand Up @@ -141,6 +141,10 @@ export default interface CVATCore {
};
performance: {
reports: (filter: AnalyticsReportFilter) => Promise<AnalyticsReport>;
calculate: (
body: { jobID?: number; taskID?: number; projectID?: number; },
onUpdate: (status: enums.RQStatus, progress: number, message: string) => void,
) => Promise<void>;
};
};
frames: {
Expand Down
128 changes: 109 additions & 19 deletions cvat-core/src/server-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// Copyright (C) 2019-2022 Intel Corporation
// Copyright (C) 2022-2023 CVAT.ai Corporation
// Copyright (C) 2022-2024 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT

import FormData from 'form-data';
import store from 'store';
import Axios, { AxiosError, AxiosResponse } from 'axios';
import { isNetworkError } from 'axios-retry';
import * as tus from 'tus-js-client';
import { ChunkQuality } from 'cvat-data';

Expand All @@ -16,8 +17,8 @@ import {
SerializedAbout, SerializedRemoteFile, SerializedUserAgreement,
SerializedRegister, JobsFilter, SerializedJob, SerializedGuide, SerializedAsset, SerializedAPISchema,
SerializedInvitationData, SerializedCloudStorage, SerializedFramesMetaData, SerializedCollection,
SerializedQualitySettingsData, ApiQualitySettingsFilter, SerializedQualityConflictData, ApiQualityConflictsFilter,
SerializedQualityReportData, ApiQualityReportsFilter, SerializedAnalyticsReport, ApiAnalyticsReportFilter,
SerializedQualitySettingsData, APIQualitySettingsFilter, SerializedQualityConflictData, APIQualityConflictsFilter,
SerializedQualityReportData, APIQualityReportsFilter, SerializedAnalyticsReport, APIAnalyticsReportFilter,
} from './server-response-types';
import { PaginatedResource } from './core-types';
import { Storage } from './storage';
Expand Down Expand Up @@ -1172,18 +1173,20 @@ async function restoreProject(storage: Storage, file: File | string) {
return wait();
}

const listenToCreateCallbacks: Record<number, {
promise: Promise<SerializedTask>;
type LongProcessListener<R> = Record<number, {
promise: Promise<R>;
onUpdate: ((state: string, progress: number, message: string) => void)[];
}> = {};
}>;

const listenToCreateTaskCallbacks: LongProcessListener<SerializedTask> = {};

function listenToCreateTask(
id, onUpdate: (state: RQStatus, progress: number, message: string) => void,
): Promise<SerializedTask> {
if (id in listenToCreateCallbacks) {
listenToCreateCallbacks[id].onUpdate.push(onUpdate);
if (id in listenToCreateTaskCallbacks) {
listenToCreateTaskCallbacks[id].onUpdate.push(onUpdate);
// to avoid extra status check requests we do not create any more promises
return listenToCreateCallbacks[id].promise;
return listenToCreateTaskCallbacks[id].promise;
}

const promise = new Promise<SerializedTask>((resolve, reject) => {
Expand All @@ -1195,7 +1198,7 @@ function listenToCreateTask(
const state = response.data.state?.toLowerCase();
if ([RQStatus.QUEUED, RQStatus.STARTED].includes(state)) {
// notify all the subscribtions when data status changed
listenToCreateCallbacks[id].onUpdate.forEach((callback) => {
listenToCreateTaskCallbacks[id].onUpdate.forEach((callback) => {
callback(
state,
response.data.progress || 0,
Expand All @@ -1210,14 +1213,14 @@ function listenToCreateTask(
resolve(createdTask);
} else if (state === RQStatus.FAILED) {
const failMessage = 'Images processing failed';
listenToCreateCallbacks[id].onUpdate.forEach((callback) => {
listenToCreateTaskCallbacks[id].onUpdate.forEach((callback) => {
callback(state, 0, failMessage);
});

reject(new ServerError(filterPythonTraceback(response.data.message), 400));
} else {
const failMessage = 'Unknown status received';
listenToCreateCallbacks[id].onUpdate.forEach((callback) => {
listenToCreateTaskCallbacks[id].onUpdate.forEach((callback) => {
callback(state || RQStatus.UNKNOWN, 0, failMessage);
});
reject(
Expand All @@ -1228,7 +1231,7 @@ function listenToCreateTask(
);
}
} catch (errorData) {
listenToCreateCallbacks[id].onUpdate.forEach((callback) => {
listenToCreateTaskCallbacks[id].onUpdate.forEach((callback) => {
callback('failed', 0, 'Server request failed');
});
reject(generateError(errorData));
Expand All @@ -1238,13 +1241,13 @@ function listenToCreateTask(
setTimeout(checkStatus, 100);
});

listenToCreateCallbacks[id] = {
listenToCreateTaskCallbacks[id] = {
promise,
onUpdate: [onUpdate],
};
promise.catch(() => {
// do nothing, avoid uncaught promise exceptions
}).finally(() => delete listenToCreateCallbacks[id]);
}).finally(() => delete listenToCreateTaskCallbacks[id]);
return promise;
}

Expand Down Expand Up @@ -2321,7 +2324,7 @@ async function createAsset(file: File, guideId: number): Promise<SerializedAsset
}

async function getQualitySettings(
filter: ApiQualitySettingsFilter,
filter: APIQualitySettingsFilter,
): Promise<SerializedQualitySettingsData> {
const { backendAPI } = config;

Expand Down Expand Up @@ -2357,7 +2360,7 @@ async function updateQualitySettings(
}

async function getQualityConflicts(
filter: ApiQualityConflictsFilter,
filter: APIQualityConflictsFilter,
): Promise<SerializedQualityConflictData[]> {
const params = enableOrganization();
const { backendAPI } = config;
Expand All @@ -2375,7 +2378,7 @@ async function getQualityConflicts(
}

async function getQualityReports(
filter: ApiQualityReportsFilter,
filter: APIQualityReportsFilter,
): Promise<PaginatedResource<SerializedQualityReportData>> {
const { backendAPI } = config;

Expand All @@ -2394,7 +2397,7 @@ async function getQualityReports(
}

async function getAnalyticsReports(
filter: ApiAnalyticsReportFilter,
filter: APIAnalyticsReportFilter,
): Promise<SerializedAnalyticsReport> {
const { backendAPI } = config;

Expand All @@ -2411,6 +2414,92 @@ async function getAnalyticsReports(
}
}

const listenToCreateAnalyticsReportCallbacks: {
job: LongProcessListener<void>;
task: LongProcessListener<void>;
project: LongProcessListener<void>;
} = {
job: {},
task: {},
project: {},
};

async function calculateAnalyticsReport(
body: {
job_id?: number;
task_id?: number;
project_id?: number;
},
onUpdate: (state: string, progress: number, message: string) => void,
): Promise<void> {
const id = body.job_id || body.task_id || body.project_id;
const { backendAPI } = config;
const params = enableOrganization();
let listenerStorage: LongProcessListener<void> = null;

if (Number.isInteger(body.job_id)) {
listenerStorage = listenToCreateAnalyticsReportCallbacks.job;
} else if (Number.isInteger(body.task_id)) {
listenerStorage = listenToCreateAnalyticsReportCallbacks.task;
} else if (Number.isInteger(body.project_id)) {
listenerStorage = listenToCreateAnalyticsReportCallbacks.project;
}

if (listenerStorage[id]) {
listenerStorage[id].onUpdate.push(onUpdate);
return listenerStorage[id].promise;
}

const promise = new Promise<void>((resolve, reject) => {
Axios.post(`${backendAPI}/analytics/reports`, {
...body,
...params,
}).then(({ data: { rq_id: rqID } }) => {
listenerStorage[id].onUpdate.forEach((_onUpdate) => _onUpdate('queued', 0, 'Analytics report request sent'));
const checkStatus = (): void => {
Axios.post(`${backendAPI}/analytics/reports`, {
...body,
...params,
}, { params: { rq_id: rqID } }).then((response) => {
// todo: rewrite server logic, now it returns 202, 201 codes instead of RQ statuses
if (response.status === 201) {
listenerStorage[id].onUpdate.forEach((_onUpdate) => _onUpdate('finished', 0, 'Done'));
resolve();
return;
}

listenerStorage[id].onUpdate.forEach((_onUpdate) => _onUpdate('queued', 0, 'Analytics report is in progress'));
bsekachev marked this conversation as resolved.
Show resolved Hide resolved
setTimeout(checkStatus, 10000);
}).catch((errorData) => {
if (!isNetworkError(errorData)) {
bsekachev marked this conversation as resolved.
Show resolved Hide resolved
// in case of network error, do nothing
// wait until connection established
reject(generateError(errorData));
} else {
listenerStorage[id].onUpdate.forEach((_onUpdate) => _onUpdate('error', 0, 'Could not establish connection to the server'));
setTimeout(checkStatus, 10000);
}
});
};

setTimeout(checkStatus, 1000);
}).catch((errorData) => {
reject(generateError(errorData));
});
});

listenerStorage[id] = {
promise,
onUpdate: [onUpdate],
};

promise.finally(() => {
delete listenerStorage[id];
});

return promise;
}

export default Object.freeze({
server: Object.freeze({
setAuthData,
Expand Down Expand Up @@ -2562,6 +2651,7 @@ export default Object.freeze({
analytics: Object.freeze({
performance: Object.freeze({
reports: getAnalyticsReports,
calculate: calculateAnalyticsReport,
}),
quality: Object.freeze({
reports: getQualityReports,
Expand Down
Loading
Loading