diff --git a/CHANGELOG.md b/CHANGELOG.md index eb69400edd8..3f15c960877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0-beta] - Unreleased +## [1.0.0-alpha] - Unreleased ### Added - ### Changed -- VOC task export now does not use official label map by default, but takes one - from the source task to avoid primary-class and class part name - clashing ([#1275](https://github.com/opencv/cvat/issues/1275)) +- ### Deprecated - @@ -22,6 +20,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - +### Security +- + +## [0.6.1] - 2020-03-21 +### Changed +- VOC task export now does not use official label map by default, but takes one + from the source task to avoid primary-class and class part name + clashing ([#1275](https://github.com/opencv/cvat/issues/1275)) + +### Fixed +- File names in LabelMe format export are no longer truncated ([#1259](https://github.com/opencv/cvat/issues/1259)) +- `occluded` and `z_order` annotation attributes are now correctly passed to Datumaro ([#1271](https://github.com/opencv/cvat/pull/1271)) +- Annotation-less tasks now can be exported as empty datasets in COCO ([#1277](https://github.com/opencv/cvat/issues/1277)) +- Frame name matching for video annotations import - + allowed `frame_XXXXXX[.ext]` format ([#1274](https://github.com/opencv/cvat/pull/1274)) + ### Security - Bump acorn from 6.3.0 to 6.4.1 in /cvat-ui ([#1270](https://github.com/opencv/cvat/pull/1270)) @@ -48,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - React & Redux & Antd based dashboard - Yolov3 interpretation script fix and changes to mapping.json - YOLO format support ([#1151](https://github.com/opencv/cvat/pull/1151)) +- Added support for OpenVINO 2020 ### Fixed - Exception in Git plugin [#826](https://github.com/opencv/cvat/issues/826) diff --git a/cvat-canvas/README.md b/cvat-canvas/README.md index 2b9f9201ba7..7fe6049176e 100644 --- a/cvat-canvas/README.md +++ b/cvat-canvas/README.md @@ -37,6 +37,19 @@ Canvas itself handles: EXTREME_POINTS = 'By 4 points' } + enum Mode { + IDLE = 'idle', + DRAG = 'drag', + RESIZE = 'resize', + DRAW = 'draw', + EDIT = 'edit', + MERGE = 'merge', + SPLIT = 'split', + GROUP = 'group', + DRAG_CANVAS = 'drag_canvas', + ZOOM_CANVAS = 'zoom_canvas', + } + interface DrawData { enabled: boolean; shapeType?: string; @@ -70,6 +83,7 @@ Canvas itself handles: } interface Canvas { + mode(): Mode; html(): HTMLDivElement; setZLayer(zLayer: number | null): void; setup(frameData: any, objectStates: any[]): void; @@ -128,6 +142,10 @@ Standard JS events are used. - canvas.dragstop - canvas.zoomstart - canvas.zoomstop + - canvas.zoom + - canvas.fit + - canvas.dragshape => {id: number} + - canvas.resizeshape => {id: number} ``` ### WEB @@ -135,7 +153,8 @@ Standard JS events are used. // Create an instance of a canvas const canvas = new window.canvas.Canvas(); - console.log('Version', window.canvas.CanvasVersion); + console.log('Version ', window.canvas.CanvasVersion); + console.log('Current mode is ', window.canvas.mode()); // Put canvas to a html container htmlContainer.appendChild(canvas.html()); diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index f6f68d1f977..b372015fdb6 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -74,7 +74,7 @@ export class CanvasViewImpl implements CanvasView, Listener { return this.controller.mode; } - private onDrawDone(data: object, continueDraw?: boolean): void { + private onDrawDone(data: object | null, duration: number, continueDraw?: boolean): void { if (data) { const { zLayer } = this.controller; const event: CustomEvent = new CustomEvent('canvas.drawn', { @@ -87,6 +87,7 @@ export class CanvasViewImpl implements CanvasView, Listener { zOrder: zLayer || 0, }, continue: continueDraw, + duration, }, }); @@ -137,12 +138,13 @@ export class CanvasViewImpl implements CanvasView, Listener { this.mode = Mode.IDLE; } - private onMergeDone(objects: any[]): void { + private onMergeDone(objects: any[]| null, duration?: number): void { if (objects) { const event: CustomEvent = new CustomEvent('canvas.merged', { bubbles: false, cancelable: true, detail: { + duration, states: objects, }, }); @@ -708,6 +710,12 @@ export class CanvasViewImpl implements CanvasView, Listener { } else if ([UpdateReasons.IMAGE_ZOOMED, UpdateReasons.IMAGE_FITTED].includes(reason)) { this.moveCanvas(); this.transformCanvas(); + if (reason === UpdateReasons.IMAGE_FITTED) { + this.canvas.dispatchEvent(new CustomEvent('canvas.fit', { + bubbles: false, + cancelable: true, + })); + } } else if (reason === UpdateReasons.IMAGE_MOVED) { this.moveCanvas(); } else if ([UpdateReasons.OBJECTS_UPDATED, UpdateReasons.SET_Z_LAYER].includes(reason)) { @@ -1166,6 +1174,13 @@ export class CanvasViewImpl implements CanvasView, Listener { ).map((x: number): number => x - offset); this.drawnStates[state.clientID].points = points; + this.canvas.dispatchEvent(new CustomEvent('canvas.dragshape', { + bubbles: false, + cancelable: true, + detail: { + id: state.clientID, + }, + })); this.onEditDone(state, points); } }); @@ -1216,6 +1231,13 @@ export class CanvasViewImpl implements CanvasView, Listener { ).map((x: number): number => x - offset); this.drawnStates[state.clientID].points = points; + this.canvas.dispatchEvent(new CustomEvent('canvas.resizeshape', { + bubbles: false, + cancelable: true, + detail: { + id: state.clientID, + }, + })); this.onEditDone(state, points); } }); diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index 60745f9f3ec..4afe75ec259 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -31,7 +31,8 @@ export interface DrawHandler { export class DrawHandlerImpl implements DrawHandler { // callback is used to notify about creating new shape - private onDrawDone: (data: object, continueDraw?: boolean) => void; + private onDrawDone: (data: object | null, duration?: number, continueDraw?: boolean) => void; + private startTimestamp: number; private canvas: SVG.Container; private text: SVG.Container; private cursorPosition: { @@ -180,7 +181,7 @@ export class DrawHandlerImpl implements DrawHandler { this.onDrawDone({ shapeType, points: [xtl, ytl, xbr, ybr], - }); + }, Date.now() - this.startTimestamp); } }).on('drawupdate', (): void => { this.shapeSizeElement.update(this.drawInstance); @@ -213,7 +214,7 @@ export class DrawHandlerImpl implements DrawHandler { this.onDrawDone({ shapeType, points: [xtl, ytl, xbr, ybr], - }); + }, Date.now() - this.startTimestamp); } } }).on('undopoint', (): void => { @@ -300,7 +301,7 @@ export class DrawHandlerImpl implements DrawHandler { this.onDrawDone({ shapeType, points, - }); + }, Date.now() - this.startTimestamp); } else if (shapeType === 'polyline' && ((box.xbr - box.xtl) >= consts.SIZE_THRESHOLD || (box.ybr - box.ytl) >= consts.SIZE_THRESHOLD) @@ -308,13 +309,13 @@ export class DrawHandlerImpl implements DrawHandler { this.onDrawDone({ shapeType, points, - }); + }, Date.now() - this.startTimestamp); } else if (shapeType === 'points' && (e.target as any).getAttribute('points') !== '0,0') { this.onDrawDone({ shapeType, points, - }); + }, Date.now() - this.startTimestamp); } }); } @@ -365,7 +366,7 @@ export class DrawHandlerImpl implements DrawHandler { attributes: { ...this.drawData.initialState.attributes }, label: this.drawData.initialState.label, color: this.drawData.initialState.color, - }, e.detail.originalEvent.ctrlKey); + }, Date.now() - this.startTimestamp, e.detail.originalEvent.ctrlKey); }); } @@ -405,7 +406,7 @@ export class DrawHandlerImpl implements DrawHandler { attributes: { ...this.drawData.initialState.attributes }, label: this.drawData.initialState.label, color: this.drawData.initialState.color, - }, e.detail.originalEvent.ctrlKey); + }, Date.now() - this.startTimestamp, e.detail.originalEvent.ctrlKey); }); } @@ -583,14 +584,16 @@ export class DrawHandlerImpl implements DrawHandler { this.setupDrawEvents(); } + this.startTimestamp = Date.now(); this.initialized = true; } public constructor( - onDrawDone: (data: object, continueDraw?: boolean) => void, + onDrawDone: (data: object | null, duration?: number, continueDraw?: boolean) => void, canvas: SVG.Container, text: SVG.Container, ) { + this.startTimestamp = Date.now(); this.onDrawDone = onDrawDone; this.canvas = canvas; this.text = text; diff --git a/cvat-canvas/src/typescript/mergeHandler.ts b/cvat-canvas/src/typescript/mergeHandler.ts index efaa4ac09d7..cfb3f78c43d 100644 --- a/cvat-canvas/src/typescript/mergeHandler.ts +++ b/cvat-canvas/src/typescript/mergeHandler.ts @@ -15,8 +15,9 @@ export interface MergeHandler { export class MergeHandlerImpl implements MergeHandler { // callback is used to notify about merging end - private onMergeDone: (objects: any[]) => void; + private onMergeDone: (objects: any[] | null, duration?: number) => void; private onFindObject: (event: MouseEvent) => void; + private startTimestamp: number; private canvas: SVG.Container; private initialized: boolean; private statesToBeMerged: any[]; // are being merged @@ -57,6 +58,7 @@ export class MergeHandlerImpl implements MergeHandler { private initMerging(): void { this.canvas.node.addEventListener('click', this.onFindObject); + this.startTimestamp = Date.now(); this.initialized = true; } @@ -66,7 +68,7 @@ export class MergeHandlerImpl implements MergeHandler { this.release(); if (statesToBeMerged.length > 1) { - this.onMergeDone(statesToBeMerged); + this.onMergeDone(statesToBeMerged, Date.now() - this.startTimestamp); } else { this.onMergeDone(null); // here is a cycle @@ -77,12 +79,13 @@ export class MergeHandlerImpl implements MergeHandler { } public constructor( - onMergeDone: (objects: any[]) => void, + onMergeDone: (objects: any[] | null, duration?: number) => void, onFindObject: (event: MouseEvent) => void, canvas: SVG.Container, ) { this.onMergeDone = onMergeDone; this.onFindObject = onFindObject; + this.startTimestamp = Date.now(); this.canvas = canvas; this.statesToBeMerged = []; this.highlightedShapes = {}; diff --git a/cvat-core/package.json b/cvat-core/package.json index d07e81aa12a..8859adc59bd 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -34,6 +34,7 @@ "dependencies": { "axios": "^0.18.0", "browser-or-node": "^1.2.1", + "detect-browser": "^5.0.0", "error-stack-parser": "^2.0.2", "form-data": "^2.5.0", "jest-config": "^24.8.0", diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index 7f9ad9c7448..4eb4e99af00 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -14,7 +14,8 @@ function build() { const PluginRegistry = require('./plugins'); - const User = require('./user'); + const loggerStorage = require('./logger-storage'); + const Log = require('./log'); const ObjectState = require('./object-state'); const Statistics = require('./statistics'); const { Job, Task } = require('./session'); @@ -41,6 +42,7 @@ function build() { ServerError, } = require('./exceptions'); + const User = require('./user'); const pjson = require('../package.json'); const config = require('./config'); @@ -419,6 +421,53 @@ function build() { return result; }, }, + /** + * Namespace to working with logs + * @namespace logger + * @memberof module:API.cvat + */ + /** + * Method to logger configuration + * @method configure + * @memberof module:API.cvat.logger + * @param {function} isActiveChecker - callback to know if logger + * should increase working time or not + * @param {object} userActivityCallback - container for a callback
+ * Logger put here a callback to update user activity timer
+ * You can call it outside + * @instance + * @async + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + + /** + * Append log to a log collection
+ * Durable logs will have been added after "close" method is called for them
+ * Ignore rules exist for some logs (e.g. zoomImage, changeAttribute)
+ * Payload of ignored logs are shallowly combined to previous logs of the same type + * @method log + * @memberof module:API.cvat.logger + * @param {module:API.cvat.enums.LogType | string} type - log type + * @param {Object} [payload = {}] - any other data that will be appended to the log + * @param {boolean} [wait = false] - specifies if log is durable + * @returns {module:API.cvat.classes.Log} + * @instance + * @async + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + + /** + * Save accumulated logs on a server + * @method save + * @memberof module:API.cvat.logger + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + * @instance + * @async + */ + logger: loggerStorage, /** * Namespace contains some changeable configurations * @namespace config @@ -432,12 +481,6 @@ function build() { * @property {string} proxy Axios proxy settings. * For more details please read here * @memberof module:API.cvat.config - * @property {integer} taskID this value is displayed in a logs if available - * @memberof module:API.cvat.config - * @property {integer} jobID this value is displayed in a logs if available - * @memberof module:API.cvat.config - * @property {integer} clientID read only auto-generated - * value which is displayed in a logs * @memberof module:API.cvat.config */ get backendAPI() { @@ -452,21 +495,6 @@ function build() { set proxy(value) { config.proxy = value; }, - get taskID() { - return config.taskID; - }, - set taskID(value) { - config.taskID = value; - }, - get jobID() { - return config.jobID; - }, - set jobID(value) { - config.jobID = value; - }, - get clientID() { - return config.clientID; - }, }, /** * Namespace contains some library information e.g. api version @@ -524,6 +552,7 @@ function build() { Task, User, Job, + Log, Attribute, Label, Statistics, diff --git a/cvat-core/src/config.js b/cvat-core/src/config.js index e940a214c95..3b9eade8f34 100644 --- a/cvat-core/src/config.js +++ b/cvat-core/src/config.js @@ -6,7 +6,4 @@ module.exports = { backendAPI: 'http://localhost:7000/api/v1', proxy: false, - taskID: undefined, - jobID: undefined, - clientID: +Date.now().toString().substr(-6), }; diff --git a/cvat-core/src/enums.js b/cvat-core/src/enums.js index 2c34290876b..feef4825bdb 100644 --- a/cvat-core/src/enums.js +++ b/cvat-core/src/enums.js @@ -103,68 +103,76 @@ }); /** - * Event types - * @enum {number} + * Logger event types + * @enum {string} * @name LogType * @memberof module:API.cvat.enums - * @property {number} pasteObject 0 - * @property {number} changeAttribute 1 - * @property {number} dragObject 2 - * @property {number} deleteObject 3 - * @property {number} pressShortcut 4 - * @property {number} resizeObject 5 - * @property {number} sendLogs 6 - * @property {number} saveJob 7 - * @property {number} jumpFrame 8 - * @property {number} drawObject 9 - * @property {number} changeLabel 10 - * @property {number} sendTaskInfo 11 - * @property {number} loadJob 12 - * @property {number} moveImage 13 - * @property {number} zoomImage 14 - * @property {number} lockObject 15 - * @property {number} mergeObjects 16 - * @property {number} copyObject 17 - * @property {number} propagateObject 18 - * @property {number} undoAction 19 - * @property {number} redoAction 20 - * @property {number} sendUserActivity 21 - * @property {number} sendException 22 - * @property {number} changeFrame 23 - * @property {number} debugInfo 24 - * @property {number} fitImage 25 - * @property {number} rotateImage 26 + * @property {string} loadJob Load job + * @property {string} saveJob Save job + * @property {string} restoreJob Restore job + * @property {string} uploadAnnotations Upload annotations + * @property {string} sendUserActivity Send user activity + * @property {string} sendException Send exception + * @property {string} sendTaskInfo Send task info + + * @property {string} drawObject Draw object + * @property {string} pasteObject Paste object + * @property {string} copyObject Copy object + * @property {string} propagateObject Propagate object + * @property {string} dragObject Drag object + * @property {string} resizeObject Resize object + * @property {string} deleteObject Delete object + * @property {string} lockObject Lock object + * @property {string} mergeObjects Merge objects + * @property {string} changeAttribute Change attribute + * @property {string} changeLabel Change label + + * @property {string} changeFrame Change frame + * @property {string} moveImage Move image + * @property {string} zoomImage Zoom image + * @property {string} fitImage Fit image + * @property {string} rotateImage Rotate image + + * @property {string} undoAction Undo action + * @property {string} redoAction Redo action + + * @property {string} pressShortcut Press shortcut + * @property {string} debugInfo Debug info * @readonly */ - const LogType = { - pasteObject: 0, - changeAttribute: 1, - dragObject: 2, - deleteObject: 3, - pressShortcut: 4, - resizeObject: 5, - sendLogs: 6, - saveJob: 7, - jumpFrame: 8, - drawObject: 9, - changeLabel: 10, - sendTaskInfo: 11, - loadJob: 12, - moveImage: 13, - zoomImage: 14, - lockObject: 15, - mergeObjects: 16, - copyObject: 17, - propagateObject: 18, - undoAction: 19, - redoAction: 20, - sendUserActivity: 21, - sendException: 22, - changeFrame: 23, - debugInfo: 24, - fitImage: 25, - rotateImage: 26, - }; + const LogType = Object.freeze({ + loadJob: 'Load job', + saveJob: 'Save job', + restoreJob: 'Restore job', + uploadAnnotations: 'Upload annotations', + sendUserActivity: 'Send user activity', + sendException: 'Send exception', + sendTaskInfo: 'Send task info', + + drawObject: 'Draw object', + pasteObject: 'Paste object', + copyObject: 'Copy object', + propagateObject: 'Propagate object', + dragObject: 'Drag object', + resizeObject: 'Resize object', + deleteObject: 'Delete object', + lockObject: 'Lock object', + mergeObjects: 'Merge objects', + changeAttribute: 'Change attribute', + changeLabel: 'Change label', + + changeFrame: 'Change frame', + moveImage: 'Move image', + zoomImage: 'Zoom image', + fitImage: 'Fit image', + rotateImage: 'Rotate image', + + undoAction: 'Undo action', + redoAction: 'Redo action', + + pressShortcut: 'Press shortcut', + debugInfo: 'Debug info', + }); /** * Types of actions with annotations @@ -208,7 +216,6 @@ /** * Array of hex colors - * @type {module:API.cvat.classes.Loader[]} values * @name colors * @memberof module:API.cvat.enums * @type {string[]} diff --git a/cvat-core/src/log.js b/cvat-core/src/log.js new file mode 100644 index 00000000000..68cce5c5d5a --- /dev/null +++ b/cvat-core/src/log.js @@ -0,0 +1,252 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +/* global + require:false +*/ + +const { detect } = require('detect-browser'); +const PluginRegistry = require('./plugins'); +const { ArgumentError } = require('./exceptions'); +const { LogType } = require('./enums'); + +/** + * Class representing a single log + * @memberof module:API.cvat.classes + * @hideconstructor +*/ +class Log { + constructor(logType, payload) { + this.onCloseCallback = null; + + this.type = logType; + this.payload = { ...payload }; + this.time = new Date(); + } + + onClose(callback) { + this.onCloseCallback = callback; + } + + validatePayload() { + if (typeof (this.payload) !== 'object') { + throw new ArgumentError('Payload must be an object'); + } + + try { + JSON.stringify(this.payload); + } catch (error) { + const message = `Log payload must be JSON serializable. ${error.toString()}`; + throw new ArgumentError(message); + } + } + + dump() { + const payload = { ...this.payload }; + const body = { + name: this.type, + time: this.time.toISOString(), + }; + + for (const field of ['client_id', 'job_id', 'task_id', 'is_active']) { + if (field in payload) { + body[field] = payload[field]; + delete payload[field]; + } + } + + return { + ...body, + payload, + }; + } + + /** + * Method saves a durable log in a storage
+ * Note then you can call close() multiple times
+ * Log duration will be computed based on the latest call
+ * All payloads will be shallowly combined (all top level properties will exist) + * @method close + * @memberof module:API.cvat.classes.Log + * @param {object} [payload] part of payload can be added when close a log + * @readonly + * @instance + * @async + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + async close(payload = {}) { + const result = await PluginRegistry + .apiWrapper.call(this, Log.prototype.close, payload); + return result; + } +} + +Log.prototype.close.implementation = function (payload) { + this.payload.duration = Date.now() - this.time.getTime(); + this.payload = { ...this.payload, ...payload }; + + if (this.onCloseCallback) { + this.onCloseCallback(); + } +}; + +class LogWithCount extends Log { + validatePayload() { + Log.prototype.validatePayload.call(this); + if (!Number.isInteger(this.payload.count) || this.payload.count < 1) { + const message = `The field "count" is required for "${this.type}" log` + + 'It must be a positive integer'; + throw new ArgumentError(message); + } + } +} + +class LogWithObjectsInfo extends Log { + validatePayload() { + const generateError = (name, range) => { + const message = `The field "${name}" is required for "${this.type}" log. ${range}`; + throw new ArgumentError(message); + }; + + if (!Number.isInteger(this.payload['track count']) || this.payload['track count'] < 0) { + generateError('track count', 'It must be an integer not less than 0'); + } + + if (!Number.isInteger(this.payload['tag count']) || this.payload['tag count'] < 0) { + generateError('tag count', 'It must be an integer not less than 0'); + } + + if (!Number.isInteger(this.payload['object count']) || this.payload['object count'] < 0) { + generateError('object count', 'It must be an integer not less than 0'); + } + + if (!Number.isInteger(this.payload['frame count']) || this.payload['frame count'] < 1) { + generateError('frame count', 'It must be an integer not less than 1'); + } + + if (!Number.isInteger(this.payload['box count']) || this.payload['box count'] < 0) { + generateError('box count', 'It must be an integer not less than 0'); + } + + if (!Number.isInteger(this.payload['polygon count']) || this.payload['polygon count'] < 0) { + generateError('polygon count', 'It must be an integer not less than 0'); + } + + if (!Number.isInteger(this.payload['polyline count']) || this.payload['polyline count'] < 0) { + generateError('polyline count', 'It must be an integer not less than 0'); + } + + if (!Number.isInteger(this.payload['points count']) || this.payload['points count'] < 0) { + generateError('points count', 'It must be an integer not less than 0'); + } + } +} + +class LogWithWorkingTime extends Log { + validatePayload() { + Log.prototype.validatePayload.call(this); + + if (!('working_time' in this.payload) + || !typeof (this.payload.working_time) === 'number' + || this.payload.working_time < 0 + ) { + const message = `The field "working_time" is required for ${this.type} log. ` + + 'It must be a number not less than 0'; + throw new ArgumentError(message); + } + } +} + +class LogWithExceptionInfo extends Log { + validatePayload() { + Log.prototype.validatePayload.call(this); + + if (typeof (this.payload.message) !== 'string') { + const message = `The field "message" is required for ${this.type} log. ` + + 'It must be a string'; + throw new ArgumentError(message); + } + + if (typeof (this.payload.filename) !== 'string') { + const message = `The field "filename" is required for ${this.type} log. ` + + 'It must be a string'; + throw new ArgumentError(message); + } + + if (typeof (this.payload.line) !== 'number') { + const message = `The field "line" is required for ${this.type} log. ` + + 'It must be a number'; + throw new ArgumentError(message); + } + + if (typeof (this.payload.column) !== 'number') { + const message = `The field "column" is required for ${this.type} log. ` + + 'It must be a number'; + throw new ArgumentError(message); + } + + if (typeof (this.payload.stack) !== 'string') { + const message = `The field "stack" is required for ${this.type} log. ` + + 'It must be a string'; + throw new ArgumentError(message); + } + } + + dump() { + const payload = { ...this.payload }; + const client = detect(); + const body = { + client_id: payload.client_id, + name: this.type, + time: this.time.toISOString(), + message: payload.message, + filename: payload.filename, + line: payload.line, + column: payload.column, + stack: payload.stack, + system: client.os, + client: client.name, + version: client.version, + }; + + delete payload.client_id; + delete payload.message; + delete payload.filename; + delete payload.line; + delete payload.column; + delete payload.stack; + + return { + ...body, + payload, + }; + } +} + +function logFactory(logType, payload) { + const logsWithCount = [ + LogType.deleteObject, LogType.mergeObjects, LogType.copyObject, + LogType.undoAction, LogType.redoAction, + ]; + + if (logsWithCount.includes(logType)) { + return new LogWithCount(logType, payload); + } + if ([LogType.sendTaskInfo, LogType.loadJob, LogType.uploadAnnotations].includes(logType)) { + return new LogWithObjectsInfo(logType, payload); + } + + if (logType === LogType.sendUserActivity) { + return new LogWithWorkingTime(logType, payload); + } + + if (logType === LogType.sendException) { + return new LogWithExceptionInfo(logType, payload); + } + + return new Log(logType, payload); +} + +module.exports = logFactory; diff --git a/cvat-core/src/logger-storage.js b/cvat-core/src/logger-storage.js new file mode 100644 index 00000000000..ca6860af898 --- /dev/null +++ b/cvat-core/src/logger-storage.js @@ -0,0 +1,177 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +/* global + require:false +*/ + +const PluginRegistry = require('./plugins'); +const serverProxy = require('./server-proxy'); +const logFactory = require('./log'); +const { ArgumentError } = require('./exceptions'); +const { LogType } = require('./enums'); + +const WORKING_TIME_THRESHOLD = 100000; // ms, 1.66 min + +class LoggerStorage { + constructor() { + this.clientID = Date.now().toString().substr(-6); + this.lastLogTime = Date.now(); + this.workingTime = 0; + this.collection = []; + this.ignoreRules = {}; // by event + this.isActiveChecker = null; + + this.ignoreRules[LogType.zoomImage] = { + lastLog: null, + timeThreshold: 1000, + ignore(previousLog) { + return Date.now() - previousLog.time < this.timeThreshold; + }, + }; + + this.ignoreRules[LogType.changeAttribute] = { + lastLog: null, + ignore(previousLog, currentPayload) { + return currentPayload.object_id === previousLog.payload.object_id + && currentPayload.id === previousLog.payload.id; + }, + }; + } + + updateWorkingTime() { + if (!this.isActiveChecker || this.isActiveChecker()) { + const lastLogTime = Date.now(); + const diff = lastLogTime - this.lastLogTime; + this.workingTime += diff < WORKING_TIME_THRESHOLD ? diff : 0; + this.lastLogTime = lastLogTime; + } + } + + async configure(isActiveChecker, activityHelper) { + const result = await PluginRegistry + .apiWrapper.call( + this, LoggerStorage.prototype.configure, + isActiveChecker, activityHelper, + ); + return result; + } + + async log(logType, payload = {}, wait = false) { + const result = await PluginRegistry + .apiWrapper.call(this, LoggerStorage.prototype.log, logType, payload, wait); + return result; + } + + async save() { + const result = await PluginRegistry + .apiWrapper.call(this, LoggerStorage.prototype.save); + return result; + } +} + +LoggerStorage.prototype.configure.implementation = function ( + isActiveChecker, + userActivityCallback, +) { + if (typeof (isActiveChecker) !== 'function') { + throw new ArgumentError('isActiveChecker argument must be callable'); + } + + if (!Array.isArray(userActivityCallback)) { + throw new ArgumentError('userActivityCallback argument must be an array'); + } + + this.isActiveChecker = () => !!isActiveChecker(); + userActivityCallback.push(this.updateWorkingTime.bind(this)); +}; + +LoggerStorage.prototype.log.implementation = function (logType, payload, wait) { + if (typeof (payload) !== 'object') { + throw new ArgumentError('Payload must be an object'); + } + + if (typeof (wait) !== 'boolean') { + throw new ArgumentError('Payload must be an object'); + } + + if (logType in this.ignoreRules) { + const ignoreRule = this.ignoreRules[logType]; + const { lastLog } = ignoreRule; + if (lastLog && ignoreRule.ignore(lastLog, payload)) { + lastLog.payload = { + ...lastLog.payload, + ...payload, + }; + + this.updateWorkingTime(); + return ignoreRule.lastLog; + } + } + + const logPayload = { ...payload }; + logPayload.client_id = this.clientID; + if (this.isActiveChecker) { + logPayload.is_active = this.isActiveChecker(); + } + + const log = logFactory(logType, { ...logPayload }); + if (logType in this.ignoreRules) { + this.ignoreRules[logType].lastLog = log; + } + + const pushEvent = () => { + this.updateWorkingTime(); + log.validatePayload(); + log.onClose(null); + this.collection.push(log); + }; + + if (log.type === LogType.sendException) { + serverProxy.server.exception(log.dump()).catch(() => { + pushEvent(); + }); + + return log; + } + + if (wait) { + log.onClose(pushEvent); + } else { + pushEvent(); + } + + return log; +}; + +LoggerStorage.prototype.save.implementation = async function () { + const collectionToSend = [...this.collection]; + const lastLog = this.collection[this.collection.length - 1]; + + const logPayload = {}; + logPayload.client_id = this.clientID; + logPayload.working_time = this.workingTime; + if (this.isActiveChecker) { + logPayload.is_active = this.isActiveChecker(); + } + + if (lastLog && lastLog.type === LogType.sendTaskInfo) { + logPayload.job_id = lastLog.payload.job_id; + logPayload.task_id = lastLog.payload.task_id; + } + + const userActivityLog = logFactory(LogType.sendUserActivity, logPayload); + collectionToSend.push(userActivityLog); + + await serverProxy.logs.save(collectionToSend.map((log) => log.dump())); + + for (const rule of Object.values(this.ignoreRules)) { + rule.lastLog = null; + } + this.collection = []; + this.workingTime = 0; + this.lastLogTime = Date.now(); +}; + +module.exports = new LoggerStorage(); diff --git a/cvat-core/src/logging.js b/cvat-core/src/logging.js deleted file mode 100644 index f6b52c4ec32..00000000000 --- a/cvat-core/src/logging.js +++ /dev/null @@ -1,42 +0,0 @@ -/* -* Copyright (C) 2019 Intel Corporation -* SPDX-License-Identifier: MIT -*/ - -/* global - require:false -*/ - -(() => { - const PluginRegistry = require('./plugins'); - - /** - * Class describe scheme of a log object - * @memberof module:API.cvat.classes - * @hideconstructor - */ - class Log { - constructor(logType, continuous, details) { - this.type = logType; - this.continuous = continuous; - this.details = details; - } - - /** - * Method closes a continue log - * @method close - * @memberof module:API.cvat.classes.Log - * @readonly - * @instance - * @async - * @throws {module:API.cvat.exceptions.PluginError} - */ - async close() { - const result = await PluginRegistry - .apiWrapper.call(this, Log.prototype.close); - return result; - } - } - - module.exports = Log; -})(); diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js index da2675ce4cf..8251328223d 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -643,6 +643,21 @@ }); } + async function saveLogs(logs) { + const { backendAPI } = config; + + try { + await Axios.post(`${backendAPI}/server/logs`, JSON.stringify(logs), { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + } + Object.defineProperties(this, Object.freeze({ server: { value: Object.freeze({ @@ -705,6 +720,13 @@ }), writable: false, }, + + logs: { + value: Object.freeze({ + save: saveLogs, + }), + writable: false, + }, })); } } diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index 0adfbcf072d..7d66fac9f41 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -9,6 +9,7 @@ (() => { const PluginRegistry = require('./plugins'); + const loggerStorage = require('./logger-storage'); const serverProxy = require('./server-proxy'); const { getFrame, getRanges, getPreview } = require('./frames'); const { ArgumentError } = require('./exceptions'); @@ -130,16 +131,11 @@ }, writable: true, }), - logs: Object.freeze({ + logger: Object.freeze({ value: { - async put(logType, details) { + async log(logType, payload = {}, wait = false) { const result = await PluginRegistry - .apiWrapper.call(this, prototype.logs.put, logType, details); - return result; - }, - async save(onUpdate) { - const result = await PluginRegistry - .apiWrapper.call(this, prototype.logs.save, onUpdate); + .apiWrapper.call(this, prototype.logger.log, logType, payload, wait); return result; }, }, @@ -451,33 +447,28 @@ /** * Namespace is used for an interaction with logs - * @namespace logs + * @namespace logger * @memberof Session */ /** - * Append log to a log collection. - * Continue logs will have been added after "close" method is called - * @method put - * @memberof Session.logs - * @param {module:API.cvat.enums.LogType} type a type of a log - * @param {boolean} continuous log is a continuous log - * @param {Object} details any others data which will be append to log data + * Create a log and add it to a log collection
+ * Durable logs will be added after "close" method is called for them
+ * The fields "task_id" and "job_id" automatically added when add logs + * throught a task or a job
+ * Ignore rules exist for some logs (e.g. zoomImage, changeAttribute)
+ * Payload of ignored logs are shallowly combined to previous logs of the same type + * @method log + * @memberof Session.logger + * @param {module:API.cvat.enums.LogType | string} type - log type + * @param {Object} [payload = {}] - any other data that will be appended to the log + * @param {boolean} [wait = false] - specifies if log is durable * @returns {module:API.cvat.classes.Log} * @instance * @async * @throws {module:API.cvat.exceptions.PluginError} * @throws {module:API.cvat.exceptions.ArgumentError} */ - /** - * Save accumulated logs on a server - * @method save - * @memberof Session.logs - * @throws {module:API.cvat.exceptions.PluginError} - * @throws {module:API.cvat.exceptions.ServerError} - * @instance - * @async - */ /** * Namespace is used for an interaction with actions @@ -718,6 +709,10 @@ ranges: Object.getPrototypeOf(this).frames.ranges.bind(this), preview: Object.getPrototypeOf(this).frames.preview.bind(this), }; + + this.logger = { + log: Object.getPrototypeOf(this).logger.log.bind(this), + }; } /** @@ -1266,6 +1261,10 @@ ranges: Object.getPrototypeOf(this).frames.ranges.bind(this), preview: Object.getPrototypeOf(this).frames.preview.bind(this), }; + + this.logger = { + log: Object.getPrototypeOf(this).logger.log.bind(this), + }; } /** @@ -1518,6 +1517,11 @@ return result; }; + Job.prototype.logger.log.implementation = async function (logType, payload, wait) { + const result = await this.task.logger.log(logType, { ...payload, job_id: this.id }, wait); + return result; + }; + Task.prototype.save.implementation = async function saveTaskImplementation(onUpdate) { // TODO: Add ability to change an owner and an assignee if (typeof (this.id) !== 'undefined') { @@ -1756,4 +1760,9 @@ const result = getActions(this); return result; }; + + Task.prototype.logger.log.implementation = async function (logType, payload, wait) { + const result = await loggerStorage.log(logType, { ...payload, task_id: this.id }, wait); + return result; + }; })(); diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index 5ee86576af3..5c0d7ae1715 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -3900,6 +3900,14 @@ "is-arrayish": "^0.2.1" } }, + "error-stack-parser": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.6.tgz", + "integrity": "sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ==", + "requires": { + "stackframe": "^1.1.1" + } + }, "es-abstract": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.0.tgz", @@ -11291,6 +11299,11 @@ "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", "dev": true }, + "stackframe": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.1.1.tgz", + "integrity": "sha512-0PlYhdKh6AfFxRyK/v+6/k+/mMfyiEBbTM5L94D0ZytQnJ166wuwoTYLHFWGbs2dpA8Rgq763KGWmN1EQEYHRQ==" + }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -12075,9 +12088,9 @@ "dev": true }, "ua-parser-js": { - "version": "0.7.20", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.20.tgz", - "integrity": "sha512-8OaIKfzL5cpx8eCMAhhvTlft8GYF8b2eQr6JkCyVdrgjcytyOmPCXrqXFcUnhonRpLlh5yxEZVohm6mzaowUOw==" + "version": "0.7.21", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz", + "integrity": "sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==" }, "uglify-js": { "version": "3.4.10", diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 9204b37a6a5..6b6907d2e9f 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -59,6 +59,7 @@ "antd": "^3.25.2", "copy-to-clipboard": "^3.2.0", "dotenv-webpack": "^1.7.0", + "error-stack-parser": "^2.0.6", "moment": "^2.24.0", "prop-types": "^15.7.2", "react": "^16.9.0", diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 8040d5e05aa..0080d48538f 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -22,6 +22,7 @@ import { } from 'reducers/interfaces'; import getCore from 'cvat-core'; +import logger, { LogType } from 'cvat-logger'; import { RectDrawingMethod } from 'cvat-canvas'; import { getCVATStore } from 'cvat-store'; @@ -77,10 +78,14 @@ function receiveAnnotationsParameters(): AnnotationsParameters { }; } -function computeZRange(states: any[]): number[] { +export function computeZRange(states: any[]): number[] { let minZ = states.length ? states[0].zOrder : 0; let maxZ = states.length ? states[0].zOrder : 0; states.forEach((state: any): void => { + if (state.objectType === ObjectType.TAG) { + return; + } + minZ = Math.min(minZ, state.zOrder); maxZ = Math.max(maxZ, state.zOrder); }); @@ -88,6 +93,23 @@ function computeZRange(states: any[]): number[] { return [minZ, maxZ]; } +async function jobInfoGenerator(job: any): Promise> { + const { total } = await job.annotations.statistics(); + return { + 'frame count': job.stopFrame - job.startFrame + 1, + 'track count': total.rectangle.shape + total.rectangle.track + + total.polygon.shape + total.polygon.track + + total.polyline.shape + total.polyline.track + + total.points.shape + total.points.track, + 'object count': total.total, + 'box count': total.rectangle.shape + total.rectangle.track, + 'polygon count': total.polygon.shape + total.polygon.track, + 'polyline count': total.polyline.shape + total.polyline.track, + 'points count': total.points.shape + total.points.track, + 'tag count': total.tags, + }; +} + export enum AnnotationActionTypes { GET_JOB = 'GET_JOB', GET_JOB_SUCCESS = 'GET_JOB_SUCCESS', @@ -165,6 +187,28 @@ export enum AnnotationActionTypes { ADD_Z_LAYER = 'ADD_Z_LAYER', SEARCH_ANNOTATIONS_FAILED = 'SEARCH_ANNOTATIONS_FAILED', CHANGE_WORKSPACE = 'CHANGE_WORKSPACE', + SAVE_LOGS_SUCCESS = 'SAVE_LOGS_SUCCESS', + SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED', +} + +export function saveLogsAsync(): +ThunkAction, {}, {}, AnyAction> { + return async (dispatch: ActionCreator) => { + try { + await logger.save(); + dispatch({ + type: AnnotationActionTypes.SAVE_LOGS_SUCCESS, + payload: {}, + }); + } catch (error) { + dispatch({ + type: AnnotationActionTypes.SAVE_LOGS_FAILED, + payload: { + error, + }, + }); + } + }; } export function changeWorkspace(workspace: Workspace): AnyAction { @@ -192,8 +236,7 @@ export function switchZLayer(cur: number): AnyAction { }; } -export function fetchAnnotationsAsync(): -ThunkAction, {}, {}, AnyAction> { +export function fetchAnnotationsAsync(): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { const { @@ -250,14 +293,21 @@ export function undoActionAsync(sessionInstance: any, frame: number): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { + const state = getStore().getState(); const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters(); // TODO: use affected IDs as an optimization + const [undoName] = state.annotation.annotations.history.undo.slice(-1); + const undoLog = await sessionInstance.logger.log(LogType.undoAction, { + name: undoName, + count: 1, + }, true); await sessionInstance.actions.undo(); const history = await sessionInstance.actions.get(); const states = await sessionInstance.annotations .get(frame, showAllInterpolationTracks, filters); const [minZ, maxZ] = computeZRange(states); + await undoLog.close(); dispatch({ type: AnnotationActionTypes.UNDO_ACTION_SUCCESS, @@ -283,14 +333,21 @@ export function redoActionAsync(sessionInstance: any, frame: number): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { + const state = getStore().getState(); const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters(); // TODO: use affected IDs as an optimization + const [redoName] = state.annotation.annotations.history.redo.slice(-1); + const redoLog = await sessionInstance.logger.log(LogType.redoAction, { + name: redoName, + count: 1, + }, true); await sessionInstance.actions.redo(); const history = await sessionInstance.actions.get(); const states = await sessionInstance.annotations .get(frame, showAllInterpolationTracks, filters); const [minZ, maxZ] = computeZRange(states); + await redoLog.close(); dispatch({ type: AnnotationActionTypes.REDO_ACTION_SUCCESS, @@ -373,6 +430,12 @@ ThunkAction, {}, {}, AnyAction> { const frame = state.annotation.player.frame.number; await job.annotations.upload(file, loader); + await job.logger.log( + LogType.uploadAnnotations, { + ...(await jobInfoGenerator(job)), + }, + ); + // One more update to escape some problems // in canvas when shape with the same // clientID has different type (polygon, rectangle) for example @@ -499,6 +562,9 @@ export function propagateObjectAsync( frame: from, }; + await sessionInstance.logger.log( + LogType.propagateObject, { count: to - from + 1 }, + ); const states = []; for (let frame = from; frame <= to; frame++) { copy.frame = frame; @@ -549,6 +615,7 @@ export function removeObjectAsync(sessionInstance: any, objectState: any, force: ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { try { + await sessionInstance.logger.log(LogType.deleteObject, { count: 1 }); const removed = await objectState.delete(force); const history = await sessionInstance.actions.get(); @@ -584,6 +651,9 @@ export function editShape(enabled: boolean): AnyAction { } export function copyShape(objectState: any): AnyAction { + const job = getStore().getState().annotation.job.instance; + job.logger.log(LogType.copyObject, { count: 1 }); + return { type: AnnotationActionTypes.COPY_SHAPE, payload: { @@ -687,6 +757,12 @@ ThunkAction, {}, {}, AnyAction> { payload: {}, }); + await job.logger.log( + LogType.changeFrame, { + from: frame, + to: toFrame, + }, + ); const data = await job.frames.get(toFrame, fillBuffer, frameStep); const states = await job.annotations.get(toFrame, showAllInterpolationTracks, filters); const [minZ, maxZ] = computeZRange(states); @@ -707,6 +783,7 @@ ThunkAction, {}, {}, AnyAction> { } const delay = Math.max(0, Math.round(1000 / frameSpeed) - currentTime + (state.annotation.player.frame.changeTime as number)); + dispatch({ type: AnnotationActionTypes.CHANGE_FRAME_SUCCESS, payload: { @@ -734,14 +811,33 @@ ThunkAction, {}, {}, AnyAction> { export function rotateCurrentFrame(rotation: Rotation): AnyAction { const state: CombinedState = getStore().getState(); - const { number: frameNumber } = state.annotation.player.frame; - const { startFrame } = state.annotation.job.instance; - const { frameAngles } = state.annotation.player; - const { rotateAll } = state.settings.player; + const { + annotation: { + player: { + frame: { + number: frameNumber, + }, + frameAngles, + }, + job: { + instance: job, + instance: { + startFrame, + }, + }, + }, + settings: { + player: { + rotateAll, + }, + }, + } = state; const frameAngle = (frameAngles[frameNumber - startFrame] + (rotation === Rotation.CLOCKWISE90 ? 90 : 270)) % 360; + job.logger.log(LogType.rotateImage, { angle: frameAngle }); + return { type: AnnotationActionTypes.ROTATE_FRAME, payload: { @@ -791,11 +887,6 @@ export function getJobAsync( initialFilters: string[], ): ThunkAction, {}, {}, AnyAction> { return async (dispatch: ActionCreator): Promise => { - dispatch({ - type: AnnotationActionTypes.GET_JOB, - payload: {}, - }); - try { const state: CombinedState = getStore().getState(); const filters = initialFilters; @@ -808,6 +899,18 @@ export function getJobAsync( }); } + dispatch({ + type: AnnotationActionTypes.GET_JOB, + payload: {}, + }); + + const loadJobEvent = await logger.log( + LogType.loadJob, { + task_id: tid, + job_id: jid, + }, true, + ); + // Check state if the task is already there let task = state.tasks.current .filter((_task: Task) => _task.instance.id === tid) @@ -832,6 +935,8 @@ export function getJobAsync( const [minZ, maxZ] = computeZRange(states); const colors = [...cvat.enums.colors]; + loadJobEvent.close(await jobInfoGenerator(job)); + dispatch({ type: AnnotationActionTypes.GET_JOB_SUCCESS, payload: { @@ -865,6 +970,10 @@ ThunkAction, {}, {}, AnyAction> { }); try { + const saveJobEvent = await sessionInstance.logger.log( + LogType.saveJob, {}, true, + ); + await sessionInstance.annotations.save((status: string) => { dispatch({ type: AnnotationActionTypes.SAVE_UPDATE_ANNOTATIONS_STATUS, @@ -874,6 +983,13 @@ ThunkAction, {}, {}, AnyAction> { }); }); + await saveJobEvent.close(); + await sessionInstance.logger.log( + LogType.sendTaskInfo, + await jobInfoGenerator(sessionInstance), + ); + dispatch(saveLogsAsync()); + dispatch({ type: AnnotationActionTypes.SAVE_ANNOTATIONS_SUCCESS, payload: {}, @@ -889,6 +1005,7 @@ ThunkAction, {}, {}, AnyAction> { }; } +// used to reproduce the latest drawing (in case of tags just creating) by using N export function rememberObject( objectType: ObjectType, labelID: number, diff --git a/cvat-ui/src/actions/boundaries-actions.ts b/cvat-ui/src/actions/boundaries-actions.ts new file mode 100644 index 00000000000..fa2faba0308 --- /dev/null +++ b/cvat-ui/src/actions/boundaries-actions.ts @@ -0,0 +1,88 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import { + ActionUnion, + createAction, + ThunkAction, + ThunkDispatch, +} from 'utils/redux'; +import getCore from 'cvat-core'; +import { LogType } from 'cvat-logger'; +import { computeZRange } from './annotation-actions'; + +const cvat = getCore(); + +export enum BoundariesActionTypes { + RESET_AFTER_ERROR = 'RESET_AFTER_ERROR', + THROW_RESET_ERROR = 'THROW_RESET_ERROR', +} + +export const boundariesActions = { + resetAfterError: ( + job: any, + states: any[], + frameNumber: number, + frameData: any | null, + minZ: number, + maxZ: number, + colors: string[], + ) => createAction(BoundariesActionTypes.RESET_AFTER_ERROR, { + job, + states, + frameNumber, + frameData, + minZ, + maxZ, + colors, + }), + throwResetError: () => createAction(BoundariesActionTypes.THROW_RESET_ERROR), +}; + +export function resetAfterErrorAsync(): ThunkAction { + return async (dispatch: ThunkDispatch, getState): Promise => { + try { + const state = getState(); + const job = state.annotation.job.instance; + + if (job) { + const currentFrame = state.annotation.player.frame.number; + const { showAllInterpolationTracks } = state.settings.workspace; + const frameNumber = Math.max(Math.min(job.stopFrame, currentFrame), job.startFrame); + + const states = await job.annotations + .get(frameNumber, showAllInterpolationTracks, []); + const frameData = await job.frames.get(frameNumber); + const [minZ, maxZ] = computeZRange(states); + const colors = [...cvat.enums.colors]; + + await job.logger.log(LogType.restoreJob); + + dispatch(boundariesActions.resetAfterError( + job, + states, + frameNumber, + frameData, + minZ, + maxZ, + colors, + )); + } else { + dispatch(boundariesActions.resetAfterError( + null, + [], + 0, + null, + 0, + 0, + [], + )); + } + } catch (error) { + dispatch(boundariesActions.throwResetError()); + } + }; +} + +export type boundariesActions = ActionUnion; diff --git a/cvat-ui/src/components/annotation-page/annotation-page.tsx b/cvat-ui/src/components/annotation-page/annotation-page.tsx index a096afd2fde..db5b6500e9b 100644 --- a/cvat-ui/src/components/annotation-page/annotation-page.tsx +++ b/cvat-ui/src/components/annotation-page/annotation-page.tsx @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import './styles.scss'; -import React from 'react'; +import React, { useEffect } from 'react'; import { Layout, @@ -21,18 +21,24 @@ interface Props { job: any | null | undefined; fetching: boolean; getJob(): void; + saveLogs(): void; workspace: Workspace; } - export default function AnnotationPageComponent(props: Props): JSX.Element { const { job, fetching, getJob, + saveLogs, workspace, } = props; + useEffect(() => { + saveLogs(); + return saveLogs; + }, []); + if (job === null) { if (!fetching) { getJob(); diff --git a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx index d19b94ef9de..74cd0beca38 100644 --- a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx +++ b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx @@ -5,12 +5,15 @@ import React, { useState, useEffect } from 'react'; import { GlobalHotKeys, KeyMap } from 'react-hotkeys'; import { connect } from 'react-redux'; +import { Action } from 'redux'; +import { ThunkDispatch } from 'redux-thunk'; import Layout, { SiderProps } from 'antd/lib/layout'; import { SelectValue } from 'antd/lib/select'; import { CheckboxChangeEvent } from 'antd/lib/checkbox'; import { Row, Col } from 'antd/lib/grid'; import Text from 'antd/lib/typography/Text'; +import { LogType } from 'cvat-logger'; import { activateObject as activateObjectAction, updateAnnotationsAsync, @@ -28,6 +31,7 @@ interface StateToProps { activatedAttributeID: number | null; states: any[]; labels: any[]; + jobInstance: any; } interface DispatchToProps { @@ -48,12 +52,14 @@ function mapStateToProps(state: CombinedState): StateToProps { states, }, job: { + instance: jobInstance, labels, }, }, } = state; return { + jobInstance, labels, activatedStateID, activatedAttributeID, @@ -61,7 +67,7 @@ function mapStateToProps(state: CombinedState): StateToProps { }; } -function mapDispatchToProps(dispatch: any): DispatchToProps { +function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps { return { activateObject(clientID: number, attrID: number): void { dispatch(activateObjectAction(clientID, attrID)); @@ -78,6 +84,7 @@ function AttributeAnnotationSidebar(props: StateToProps & DispatchToProps): JSX. states, activatedStateID, activatedAttributeID, + jobInstance, updateAnnotations, activateObject, } = props; @@ -267,6 +274,13 @@ function AttributeAnnotationSidebar(props: StateToProps & DispatchToProps): JSX. currentValue={activeObjectState.attributes[activeAttribute.id]} onChange={(value: string) => { const { attributes } = activeObjectState; + jobInstance.logger.log( + LogType.changeAttribute, { + id: activeAttribute.id, + object_id: activeObjectState.clientID, + value, + }, + ); attributes[activeAttribute.id] = value; activeObjectState.attributes = attributes; updateAnnotations([activeObjectState]); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx index 91a2e20e698..16bc2e3ec14 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx @@ -9,6 +9,7 @@ import Layout from 'antd/lib/layout'; import Icon from 'antd/lib/icon'; import Tooltip from 'antd/lib/tooltip'; +import { LogType } from 'cvat-logger'; import { Canvas } from 'cvat-canvas'; import getCore from 'cvat-core'; import { @@ -214,6 +215,10 @@ export default class CanvasWrapperComponent extends React.PureComponent { canvasInstance.html().removeEventListener('canvas.deactivated', this.onCanvasShapeDeactivated); canvasInstance.html().removeEventListener('canvas.moved', this.onCanvasCursorMoved); + canvasInstance.html().removeEventListener('canvas.zoom', this.onCanvasZoomChanged); + canvasInstance.html().removeEventListener('canvas.fit', this.onCanvasImageFitted); + canvasInstance.html().removeEventListener('canvas.dragshape', this.onCanvasShapeDragged); + canvasInstance.html().removeEventListener('canvas.resizeshape', this.onCanvasShapeResized); canvasInstance.html().removeEventListener('canvas.clicked', this.onCanvasShapeClicked); canvasInstance.html().removeEventListener('canvas.drawn', this.onCanvasShapeDrawn); canvasInstance.html().removeEventListener('canvas.merged', this.onCanvasObjectsMerged); @@ -237,20 +242,18 @@ export default class CanvasWrapperComponent extends React.PureComponent { onShapeDrawn(); } - const { state } = event.detail; - if (!state.objectType) { - state.objectType = activeObjectType; - } - - if (!state.label) { - [state.label] = jobInstance.task.labels - .filter((label: any) => label.id === activeLabelID); - } - - if (typeof (state.occluded) === 'undefined') { - state.occluded = false; + const { state, duration } = event.detail; + const isDrawnFromScratch = !state.label; + if (isDrawnFromScratch) { + jobInstance.logger.log(LogType.drawObject, { count: 1, duration }); + } else { + jobInstance.logger.log(LogType.pasteObject, { count: 1, duration }); } + state.objectType = state.objectType || activeObjectType; + state.label = state.label || jobInstance.task.labels + .filter((label: any) => label.id === activeLabelID)[0]; + state.occluded = state.occluded || false; state.frame = frame; const objectState = new cvat.classes.ObjectState(state); onCreateAnnotations(jobInstance, frame, [objectState]); @@ -266,7 +269,11 @@ export default class CanvasWrapperComponent extends React.PureComponent { onMergeObjects(false); - const { states } = event.detail; + const { states, duration } = event.detail; + jobInstance.logger.log(LogType.mergeObjects, { + duration, + count: states.length, + }); onMergeAnnotations(jobInstance, frame, states); }; @@ -324,6 +331,28 @@ export default class CanvasWrapperComponent extends React.PureComponent { onUpdateContextMenu(activatedStateID !== null, e.clientX, e.clientY); }; + private onCanvasShapeDragged = (e: any): void => { + const { jobInstance } = this.props; + const { id } = e.detail; + jobInstance.logger.log(LogType.dragObject, { id }); + }; + + private onCanvasShapeResized = (e: any): void => { + const { jobInstance } = this.props; + const { id } = e.detail; + jobInstance.logger.log(LogType.resizeObject, { id }); + }; + + private onCanvasImageFitted = (): void => { + const { jobInstance } = this.props; + jobInstance.logger.log(LogType.fitImage); + }; + + private onCanvasZoomChanged = (): void => { + const { jobInstance } = this.props; + jobInstance.logger.log(LogType.zoomImage); + }; + private onCanvasShapeClicked = (e: any): void => { const { clientID } = e.detail.state; const sidebarItem = window.document @@ -581,6 +610,10 @@ export default class CanvasWrapperComponent extends React.PureComponent { canvasInstance.html().addEventListener('canvas.deactivated', this.onCanvasShapeDeactivated); canvasInstance.html().addEventListener('canvas.moved', this.onCanvasCursorMoved); + canvasInstance.html().addEventListener('canvas.zoom', this.onCanvasZoomChanged); + canvasInstance.html().addEventListener('canvas.fit', this.onCanvasImageFitted); + canvasInstance.html().addEventListener('canvas.dragshape', this.onCanvasShapeDragged); + canvasInstance.html().addEventListener('canvas.resizeshape', this.onCanvasShapeResized); canvasInstance.html().addEventListener('canvas.clicked', this.onCanvasShapeClicked); canvasInstance.html().addEventListener('canvas.drawn', this.onCanvasShapeDrawn); canvasInstance.html().addEventListener('canvas.merged', this.onCanvasObjectsMerged); diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index 91795797980..c168a0288ee 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -8,13 +8,11 @@ import React from 'react'; import { Switch, Route, Redirect } from 'react-router'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { GlobalHotKeys, KeyMap, configure } from 'react-hotkeys'; +import Spin from 'antd/lib/spin'; +import Layout from 'antd/lib/layout'; +import notification from 'antd/lib/notification'; -import { - Spin, - Layout, - notification, -} from 'antd'; - +import GlobalErrorBoundary from 'components/global-error-boundary/global-error-boundary'; import ShorcutsDialog from 'components/shortcuts-dialog/shortcuts-dialog'; import SettingsPageContainer from 'containers/settings-page/settings-page'; import TasksPageContainer from 'containers/tasks-page/tasks-page'; @@ -27,6 +25,7 @@ import LoginPageContainer from 'containers/login-page/login-page'; import RegisterPageContainer from 'containers/register-page/register-page'; import HeaderContainer from 'containers/header/header'; +import getCore from 'cvat-core'; import { NotificationsState } from 'reducers/interfaces'; interface CVATAppProps { @@ -39,6 +38,7 @@ interface CVATAppProps { resetMessages: () => void; switchShortcutsDialog: () => void; userInitialized: boolean; + userFetching: boolean; pluginsInitialized: boolean; pluginsFetching: boolean; formatsInitialized: boolean; @@ -56,18 +56,29 @@ interface CVATAppProps { class CVATApplication extends React.PureComponent { public componentDidMount(): void { + const core = getCore(); const { verifyAuthorized } = this.props; configure({ ignoreRepeatedEventsWhenKeyHeldDown: false }); + + // Logger configuration + const userActivityCallback: (() => void)[] = []; + window.addEventListener('click', () => { + userActivityCallback.forEach((handler) => handler()); + }); + core.logger.configure(() => window.document.hasFocus, userActivityCallback); + verifyAuthorized(); } public componentDidUpdate(): void { const { + verifyAuthorized, loadFormats, loadUsers, loadAbout, initPlugins, userInitialized, + userFetching, formatsInitialized, formatsFetching, usersInitialized, @@ -82,8 +93,12 @@ class CVATApplication extends React.PureComponent - - - - - - - - - - - { withModels - && } - { installedAutoAnnotation - && } - - - - {/* eslint-disable-next-line */} - - - + + + + + + + + + + + + + {withModels + && } + {installedAutoAnnotation + && } + + + + {/* eslint-disable-next-line */} + + + + ); } return ( - - - - - + + + + + + + ); } diff --git a/cvat-ui/src/components/global-error-boundary/global-error-boundary.tsx b/cvat-ui/src/components/global-error-boundary/global-error-boundary.tsx new file mode 100644 index 00000000000..3c132fd9f9b --- /dev/null +++ b/cvat-ui/src/components/global-error-boundary/global-error-boundary.tsx @@ -0,0 +1,223 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; +import React from 'react'; +import { connect } from 'react-redux'; +import { Action } from 'redux'; +import { ThunkDispatch } from 'redux-thunk'; +import Result from 'antd/lib/result'; +import Text from 'antd/lib/typography/Text'; +import Paragraph from 'antd/lib/typography/Paragraph'; +import Collapse from 'antd/lib/collapse'; +import TextArea from 'antd/lib/input/TextArea'; +import Tooltip from 'antd/lib/tooltip'; +import copy from 'copy-to-clipboard'; +import ErrorStackParser from 'error-stack-parser'; + +import { resetAfterErrorAsync } from 'actions/boundaries-actions'; +import { CombinedState } from 'reducers/interfaces'; +import logger, { LogType } from 'cvat-logger'; + +interface StateToProps { + job: any | null; + serverVersion: string; + coreVersion: string; + canvasVersion: string; + uiVersion: string; +} + +interface DispatchToProps { + restore(): void; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +function mapStateToProps(state: CombinedState): StateToProps { + const { + annotation: { + job: { + instance: job, + }, + }, + about: { + server, + packageVersion, + }, + } = state; + + return { + job, + serverVersion: server.version as string, + coreVersion: packageVersion.core, + canvasVersion: packageVersion.canvas, + uiVersion: packageVersion.ui, + }; +} + +function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps { + return { + restore(): void { + dispatch(resetAfterErrorAsync()); + }, + }; +} + + +type Props = StateToProps & DispatchToProps; +class GlobalErrorBoundary extends React.PureComponent { + public constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + }; + } + + static getDerivedStateFromError(error: Error): State { + return { + hasError: true, + error, + }; + } + + public componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + const { job } = this.props; + const parsed = ErrorStackParser.parse(error); + + const logPayload = { + filename: parsed[0].fileName, + line: parsed[0].lineNumber, + message: error.message, + column: parsed[0].columnNumber, + stack: error.stack, + componentStack: errorInfo.componentStack, + }; + + if (job) { + job.logger.log(LogType.sendException, logPayload); + } else { + logger.log(LogType.sendException, logPayload); + } + } + + public render(): React.ReactNode { + const { + restore, + job, + serverVersion, + coreVersion, + canvasVersion, + uiVersion, + } = this.props; + + const { hasError, error } = this.state; + + const restoreGlobalState = (): void => { + this.setState({ + error: null, + hasError: false, + }); + + restore(); + }; + + if (hasError && error) { + const message = `${error.name}\n${error.message}\n\n${error.stack}`; + return ( +
+ +
+ + What has happened? + Program error has just occured + + + +