diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 1fa9da8a9634..6f75c038359c 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -13,6 +13,7 @@ jobs: cvat-cli/**/*.py tests/python/**/*.py cvat/apps/quality_control/**/*.py + cvat/apps/analytics_report/**/*.py dir_names: true - name: Run checks diff --git a/.github/workflows/isort.yml b/.github/workflows/isort.yml index 87e2d9957b28..4ed521885111 100644 --- a/.github/workflows/isort.yml +++ b/.github/workflows/isort.yml @@ -13,6 +13,7 @@ jobs: cvat-cli/**/*.py tests/python/**/*.py cvat/apps/quality_control/**/*.py + cvat/apps/analytics_report/**/*.py dir_names: true - name: Run checks diff --git a/.stylelintrc.json b/.stylelintrc.json index 44333abf546d..86f582a6960e 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,5 +1,5 @@ { - "extends": "stylelint-config-standard", + "extends": "stylelint-config-standard-scss", "rules": { "indentation": 4, "value-keyword-case": null, @@ -16,7 +16,9 @@ { "ignoreTypes": ["first-child"] } - ] + ], + "scss/comment-no-empty": null, + "scss/at-extend-no-missing-placeholder": null }, "ignoreFiles": ["**/*.js", "**/*.ts", "**/*.py"] } diff --git a/.vscode/launch.json b/.vscode/launch.json index a9e3d672c97b..366bd49a3fa1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -104,6 +104,26 @@ ], "justMyCode": false, }, + { + "name": "REST API tests: Attach to RQ analytics reports worker", + "type": "python", + "request": "attach", + "connect": { + "host": "127.0.0.1", + "port": 9095 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "/home/django/" + }, + { + "localRoot": "${workspaceFolder}/.env", + "remoteRoot": "/opt/venv", + } + ], + "justMyCode": false, + }, { "type": "pwa-chrome", "request": "launch", @@ -240,6 +260,28 @@ }, "console": "internalConsole" }, + { + "name": "server: RQ - analytics reports", + "type": "python", + "request": "launch", + "stopOnEntry": false, + "justMyCode": false, + "python": "${command:python.interpreterPath}", + "program": "${workspaceRoot}/manage.py", + "args": [ + "rqworker", + "analytics_reports", + "--worker-class", + "cvat.rqworker.SimpleWorker", + ], + "django": true, + "cwd": "${workspaceFolder}", + "env": { + "DJANGO_LOG_SERVER_HOST": "localhost", + "DJANGO_LOG_SERVER_PORT": "8282" + }, + "console": "internalConsole" + }, { "name": "server: RQ - scheduler", "type": "python", @@ -499,6 +541,7 @@ "server: RQ - webhooks", "server: RQ - scheduler", "server: RQ - quality reports", + "server: RQ - analytics reports", "server: RQ - cleaning", "server: git", ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 74834154a5b6..5b4400e71998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 () - \[SDK\] `cvat_sdk.datasets`, a framework-agnostic equivalent of `cvat_sdk.pytorch` () +- Analytics for Jobs, Tasks and Projects () ### Changed - TDB diff --git a/cvat-canvas/src/typescript/shared.ts b/cvat-canvas/src/typescript/shared.ts index 75d69be5ac75..9e89616a86f5 100644 --- a/cvat-canvas/src/typescript/shared.ts +++ b/cvat-canvas/src/typescript/shared.ts @@ -41,7 +41,7 @@ export interface DrawnState { occluded?: boolean; hidden?: boolean; lock: boolean; - source: 'AUTO' | 'SEMI-AUTO' | 'MANUAL'; + source: 'AUTO' | 'SEMI-AUTO' | 'MANUAL' | 'FILE'; shapeType: string; points?: number[]; rotation: number; diff --git a/cvat-core/package.json b/cvat-core/package.json index 5aca1d1ab763..d8970e107443 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "9.2.1", + "version": "9.3.0", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "src/api.ts", "scripts": { diff --git a/cvat-core/src/analytics-report.ts b/cvat-core/src/analytics-report.ts new file mode 100644 index 000000000000..8d390046d4ab --- /dev/null +++ b/cvat-core/src/analytics-report.ts @@ -0,0 +1,183 @@ +// Copyright (C) 2023 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +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', + PROJECT = 'project', +} + +export enum AnalyticsEntryViewType { + HISTOGRAM = 'histogram', + NUMERIC = 'numeric', +} + +export class AnalyticsEntry { + #name: string; + #title: string; + #description: string; + #granularity: string; + #defaultView: AnalyticsEntryViewType; + #dataSeries: Record; + #transformations: SerializedTransformationEntry[]; + + constructor(initialData: SerializedAnalyticsEntry) { + this.#name = initialData.name; + this.#title = initialData.title; + this.#description = initialData.description; + this.#granularity = initialData.granularity; + this.#defaultView = initialData.default_view as AnalyticsEntryViewType; + this.#transformations = initialData.transformations; + this.#dataSeries = this.applyTransformations(initialData.data_series); + } + + get name(): string { + return this.#name; + } + + get title(): string { + return this.#title; + } + + get description(): string { + return this.#description; + } + + // Probably need to create enum for this + get granularity(): string { + return this.#granularity; + } + + get defaultView(): AnalyticsEntryViewType { + return this.#defaultView; + } + + get dataSeries(): Record { + return this.#dataSeries; + } + + get transformations(): SerializedTransformationEntry[] { + return this.#transformations; + } + + private applyTransformations( + dataSeries: Record, + ): Record { + this.#transformations.forEach((transform) => { + if (transform.binary) { + let operator: (left: number, right: number) => number; + switch (transform.binary.operator) { + case '+': { + operator = (left: number, right: number) => left + right; + break; + } + case '-': { + operator = (left: number, right: number) => left - right; + break; + } + case '*': { + operator = (left: number, right: number) => left * right; + break; + } + case '/': { + operator = (left: number, right: number) => (right !== 0 ? left / right : 0); + break; + } + default: { + throw new ArgumentError( + `Cannot apply transformation: got unsupported operator type ${transform.binary.operator}.`, + ); + } + } + + const leftName = transform.binary.left; + const rightName = transform.binary.right; + dataSeries[transform.name] = dataSeries[leftName].map((left, i) => { + const right = dataSeries[rightName][i]; + if (typeof left.value === 'number' && typeof right.value === 'number') { + return { + value: operator(left.value, right.value), + date: left.date, + }; + } + return { + value: 0, + date: left.date, + }; + }); + delete dataSeries[leftName]; + delete dataSeries[rightName]; + } + }); + return dataSeries; + } +} + +export default class AnalyticsReport { + #id: number; + #target: AnalyticsReportTarget; + #createdDate: string; + #statistics: AnalyticsEntry[]; + + constructor(initialData: SerializedAnalyticsReport) { + this.#id = initialData.id; + this.#target = initialData.target as AnalyticsReportTarget; + this.#createdDate = initialData.created_date; + this.#statistics = []; + for (const analyticsEntry of initialData.statistics) { + this.#statistics.push(new AnalyticsEntry(analyticsEntry)); + } + } + + get id(): number { + return this.#id; + } + + get target(): AnalyticsReportTarget { + return this.#target; + } + + get createdDate(): string { + return this.#createdDate; + } + + get statistics(): AnalyticsEntry[] { + return this.#statistics; + } +} diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index 6e17a98b4e9d..56e8c1f69701 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -32,6 +32,7 @@ import QualityReport from './quality-report'; import QualityConflict from './quality-conflict'; import QualitySettings from './quality-settings'; import { FramesMetaData } from './frames'; +import AnalyticsReport from './analytics-report'; export default function implementAPI(cvat) { cvat.plugins.list.implementation = PluginRegistry.list; @@ -404,5 +405,38 @@ export default function implementAPI(cvat) { return new FramesMetaData({ ...result }); }; + cvat.analytics.performance.reports.implementation = async (filter) => { + checkFilter(filter, { + jobID: isInteger, + taskID: isInteger, + projectID: isInteger, + startDate: isString, + endDate: isString, + }); + + 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); + return new AnalyticsReport(reportData); + }; + return cvat; } diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index 72075a2cd97b..4ff47db6ad12 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -279,6 +279,12 @@ function build() { }, }, analytics: { + performance: { + async reports(filter = {}) { + const result = await PluginRegistry.apiWrapper(cvat.analytics.performance.reports, filter); + return result; + }, + }, quality: { async reports(filter: any) { const result = await PluginRegistry.apiWrapper(cvat.analytics.quality.reports, filter); diff --git a/cvat-core/src/enums.ts b/cvat-core/src/enums.ts index 10d381a56e4e..1de28c95237a 100644 --- a/cvat-core/src/enums.ts +++ b/cvat-core/src/enums.ts @@ -86,6 +86,7 @@ export enum Source { MANUAL = 'manual', SEMI_AUTO = 'semi-auto', AUTO = 'auto', + FILE = 'file', } export enum LogType { diff --git a/cvat-core/src/object-state.ts b/cvat-core/src/object-state.ts index 45b652b32b3d..4f7e78ad61c9 100644 --- a/cvat-core/src/object-state.ts +++ b/cvat-core/src/object-state.ts @@ -466,7 +466,7 @@ export default class ObjectState { }), ); - if ([Source.MANUAL, Source.SEMI_AUTO, Source.AUTO].includes(serialized.source)) { + if ([Source.MANUAL, Source.SEMI_AUTO, Source.AUTO, Source.FILE].includes(serialized.source)) { data.source = serialized.source; } if (typeof serialized.zOrder === 'number') { diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index 057d1c3ed8ef..6b438d6ca168 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -15,6 +15,7 @@ import { } from 'server-response-types'; import { SerializedQualityReportData } from 'quality-report'; import { SerializedQualitySettingsData } from 'quality-settings'; +import { SerializedAnalyticsReport } from './analytics-report'; import { Storage } from './storage'; import { StorageLocation, WebhookSourceType } from './enums'; import { isEmail, isResourceURL } from './common'; @@ -2317,6 +2318,22 @@ async function getQualityReports(filter): Promise } } +async function getAnalyticsReports(filter): Promise { + const { backendAPI } = config; + + try { + const response = await Axios.get(`${backendAPI}/analytics/reports`, { + params: { + ...filter, + }, + }); + + return response.data; + } catch (errorData) { + throw generateError(errorData); + } +} + export default Object.freeze({ server: Object.freeze({ setAuthData, @@ -2474,6 +2491,9 @@ export default Object.freeze({ }), analytics: Object.freeze({ + performance: Object.freeze({ + reports: getAnalyticsReports, + }), quality: Object.freeze({ reports: getQualityReports, conflicts: getQualityConflicts, diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 43dc9dfc4320..0274e31bb68e 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.53.4", + "version": "1.54.0", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { @@ -37,6 +37,7 @@ "@types/resize-observer-browser": "^0.1.6", "@uiw/react-md-editor": "^3.22.0", "antd": "~4.18.9", + "chart.js": "^4.3.0", "copy-to-clipboard": "^3.3.1", "cvat-canvas": "link:./../cvat-canvas", "cvat-canvas3d": "link:./../cvat-canvas3d", @@ -53,6 +54,7 @@ "prop-types": "^15.7.2", "react": "^16.14.0", "react-awesome-query-builder": "^4.5.1", + "react-chartjs-2": "^5.2.0", "react-color": "^2.19.3", "react-cookie": "^4.0.3", "react-dom": "^16.14.0", diff --git a/cvat-ui/src/components/analytics-page/analytics-page.tsx b/cvat-ui/src/components/analytics-page/analytics-page.tsx new file mode 100644 index 000000000000..bb0a54d98888 --- /dev/null +++ b/cvat-ui/src/components/analytics-page/analytics-page.tsx @@ -0,0 +1,364 @@ +// Copyright (C) 2023 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +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'; +import { Project, Task } from 'reducers'; +import { AnalyticsReport, Job, getCore } from 'cvat-core-wrapper'; +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'; + +const core = getCore(); + +function handleTimePeriod(interval: DateIntervals): [string, string] { + const now = moment.utc(); + switch (interval) { + case DateIntervals.LAST_WEEK: { + return [now.format(), now.subtract(7, 'd').format()]; + } + case DateIntervals.LAST_MONTH: { + return [now.format(), now.subtract(30, 'd').format()]; + } + case DateIntervals.LAST_QUARTER: { + return [now.format(), now.subtract(90, 'd').format()]; + } + case DateIntervals.LAST_YEAR: { + return [now.format(), now.subtract(365, 'd').format()]; + } + default: { + throw Error(`Date interval is not supported: ${interval}`); + } + } +} + +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 [instance, setInstance] = useState(null); + const [analyticsReportInstance, setAnalyticsReportInstance] = useState(null); + 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; + } + 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; + } + default: { + throw new Error(`Unsupported instance type ${instanceType}`); + } + } + + 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 (Number.isInteger(instanceID)) { + instanceRequest + .then(([_instance]: Task[] | Project[] | Job[]) => { + if (isMounted() && _instance) { + setInstance(_instance); + } + }).catch((error: Error) => { + if (isMounted()) { + notification.error({ + message: 'Could not receive the requested instance from the server', + 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`, + }); + 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(() => { + if (isMounted()) { + setFetching(false); + } + }); + }, []); + + const onJobUpdate = useCallback((job: Job): void => { + setFetching(true); + job.save().then(() => { + if (isMounted()) { + receieveInstance(); + } + }).catch((error: Error) => { + if (isMounted()) { + notification.error({ + message: 'Could not update the job', + description: error.toString(), + }); + } + }).finally(() => { + if (isMounted()) { + setFetching(false); + } + }); + }, []); + + const onAnalyticsTimePeriodChange = useCallback((val: DateIntervals): void => { + receieveReport(val); + }, []); + + 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}`); + } + } + } + + return ( +
+ { + fetching ? ( +
+ +
+ ) : ( + + {backNavigation} + + {title} + {tabs} + + + ) + } +
+ ); +} + +export default React.memo(AnalyticsPage); diff --git a/cvat-ui/src/components/analytics-page/analytics-performance.tsx b/cvat-ui/src/components/analytics-page/analytics-performance.tsx new file mode 100644 index 000000000000..061486039cfb --- /dev/null +++ b/cvat-ui/src/components/analytics-page/analytics-performance.tsx @@ -0,0 +1,194 @@ +// Copyright (C) 2023 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'; +import Text from 'antd/lib/typography/Text'; +import Select from 'antd/lib/select'; +import Notification from 'antd/lib/notification'; +import { AnalyticsReport, AnalyticsEntryViewType } from 'cvat-core-wrapper'; +import { Col, Row } from 'antd/lib/grid'; +import HistogramView from './views/histogram-view'; +import AnalyticsCard from './views/analytics-card'; + +const ReactGridLayout = WidthProvider(RGL); + +export enum DateIntervals { + LAST_WEEK = 'Last 7 days', + LAST_MONTH = 'Last 30 days', + LAST_QUARTER = 'Last 90 days', + LAST_YEAR = 'Last year', +} + +interface Props { + report: AnalyticsReport | null; + onTimePeriodChange: (val: DateIntervals) => void; +} + +const colors = [ + 'rgba(255, 99, 132, 0.5)', + 'rgba(53, 162, 235, 0.5)', + 'rgba(170, 83, 85, 0.5)', + 'rgba(44, 70, 94, 0.5)', + 'rgba(28, 66, 98, 0.5)', +]; + +function AnalyticsOverview(props: Props): JSX.Element | null { + const { report, onTimePeriodChange } = props; + + if (!report) return null; + const layout: any = []; + let histogramCount = 0; + let numericCount = 0; + const views: any = []; + report.statistics.forEach((entry) => { + const tooltip = ( +
+ + {entry.description} + +
+ ); + switch (entry.defaultView) { + case AnalyticsEntryViewType.NUMERIC: { + layout.push({ + i: entry.name, + w: 2, + h: 1, + x: 2, + y: numericCount, + }); + numericCount += 1; + const { value } = entry.dataSeries[Object.keys(entry.dataSeries)[0]][0]; + + views.push({ + view: ( + + ), + key: entry.name, + }); + break; + } + case AnalyticsEntryViewType.HISTOGRAM: { + const firstDataset = Object.keys(entry.dataSeries)[0]; + const dateLabels = entry.dataSeries[firstDataset].map((dataEntry) => ( + moment.utc(dataEntry.date).local().format('YYYY-MM-DD') + )); + + const { dataSeries } = entry; + let colorIndex = -1; + const datasets = Object.entries(dataSeries).map(([key, series]) => { + let label = key.split('_').join(' '); + label = label.charAt(0).toUpperCase() + label.slice(1); + + const data: number[] = series.map((s) => { + if (typeof s.value === 'number') { + return s.value as number; + } + + if (typeof s.value === 'object') { + return Object.keys(s.value).reduce((acc, k) => acc + s.value[k], 0); + } + + return 0; + }); + + colorIndex = colorIndex >= colors.length - 1 ? 0 : colorIndex + 1; + return { + label, + data, + backgroundColor: colors[colorIndex], + }; + }); + layout.push({ + i: entry.name, + h: 1, + w: 2, + x: 0, + y: histogramCount, + }); + histogramCount += 1; + views.push({ + view: ( + + ), + key: entry.name, + }); + break; + } + default: { + Notification.warning({ + message: `Cannot display analytics view with view type ${entry.defaultView}`, + }); + } + } + }); + return ( +
+ + + + Created + {report?.createdDate ? moment(report?.createdDate).fromNow() : ''} + + + +