ExportAsCode: Use layout creator when exporting v1 dashboard as v2 (#115754)
* Alt to #115457 * fix tests * Remove exports * skip scene creation options for template route --------- Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
co-authored by
Dominik Prokop
parent
0aae7e01bc
commit
8f4fa9ed05
@@ -40,7 +40,11 @@ import { PanelEditor } from '../panel-edit/PanelEditor';
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from '../serialization/buildNewDashboardSaveModel';
|
||||
import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import {
|
||||
createV2RowsLayout,
|
||||
SceneCreationOptions,
|
||||
transformSaveModelToScene,
|
||||
} from '../serialization/transformSaveModelToScene';
|
||||
import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState';
|
||||
|
||||
import { processQueryParamsForDashboardLoad, updateNavModel } from './utils';
|
||||
@@ -106,6 +110,34 @@ interface DashboardScenePageStateManagerLike<T> {
|
||||
useState: () => DashboardScenePageState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates scene creation options with appropriate layout creator
|
||||
* based on feature flags and dashboard type.
|
||||
*/
|
||||
export function getSceneCreationOptions(
|
||||
loadOptions?: LoadDashboardOptions,
|
||||
meta?: { isSnapshot?: boolean }
|
||||
): SceneCreationOptions | undefined {
|
||||
const isReport = loadOptions?.route === DashboardRoutes.Report;
|
||||
const isTemplate = loadOptions?.route === DashboardRoutes.Template;
|
||||
const isSnapshot = meta?.isSnapshot ?? false;
|
||||
|
||||
// Don't use v2 layout for reports or snapshots
|
||||
if (isReport || isSnapshot || isTemplate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Use v2 layout creator when v2 API is enabled
|
||||
if (shouldForceV2API()) {
|
||||
return {
|
||||
createLayout: createV2RowsLayout,
|
||||
targetVersion: 'v2',
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
abstract class DashboardScenePageStateManagerBase<T>
|
||||
extends StateManagerBase<DashboardScenePageState>
|
||||
implements DashboardScenePageStateManagerLike<T>
|
||||
@@ -155,7 +187,7 @@ abstract class DashboardScenePageStateManagerBase<T>
|
||||
private async loadHomeDashboard(): Promise<DashboardScene | null> {
|
||||
const rsp = await this.fetchHomeDashboard();
|
||||
if (rsp) {
|
||||
return transformSaveModelToScene(rsp);
|
||||
return transformSaveModelToScene(rsp, undefined, getSceneCreationOptions());
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -441,7 +473,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
||||
}
|
||||
|
||||
if (rsp?.dashboard) {
|
||||
const scene = transformSaveModelToScene(rsp, options);
|
||||
const sceneCreationOptions = getSceneCreationOptions(options, rsp.meta);
|
||||
const scene = transformSaveModelToScene(rsp, options, sceneCreationOptions);
|
||||
|
||||
// Special handling for Template route - set up edit mode and dirty state
|
||||
if (
|
||||
@@ -474,7 +507,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
||||
throw new DashboardVersionError('v2beta1', 'Using legacy snapshot API to get a V2 dashboard');
|
||||
}
|
||||
|
||||
const scene = transformSaveModelToScene(rsp);
|
||||
// Snapshots should use default v1 layout
|
||||
const scene = transformSaveModelToScene(rsp, undefined, getSceneCreationOptions(undefined, { isSnapshot: true }));
|
||||
return scene;
|
||||
}
|
||||
|
||||
@@ -755,7 +789,8 @@ export class DashboardScenePageStateManager extends DashboardScenePageStateManag
|
||||
return;
|
||||
}
|
||||
|
||||
const scene = transformSaveModelToScene(rsp);
|
||||
const sceneCreationOptions = getSceneCreationOptions(undefined, rsp.meta);
|
||||
const scene = transformSaveModelToScene(rsp, undefined, sceneCreationOptions);
|
||||
|
||||
// we need to call and restore dashboard state on every reload that pulls a new dashboard version
|
||||
if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(uid)) {
|
||||
|
||||
+17
-8
@@ -27,6 +27,7 @@ import { createPanelSaveModel } from 'app/features/dashboard/state/__fixtures__/
|
||||
import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager';
|
||||
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
|
||||
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
|
||||
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
|
||||
@@ -822,10 +823,14 @@ describe('transformSaveModelToScene', () => {
|
||||
});
|
||||
|
||||
it('Should convert legacy rows to new rows', () => {
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO,
|
||||
meta: {},
|
||||
});
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO,
|
||||
meta: {},
|
||||
},
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const layout = scene.state.body as RowsLayoutManager;
|
||||
const row1 = layout.state.rows[0];
|
||||
@@ -857,10 +862,14 @@ describe('transformSaveModelToScene', () => {
|
||||
});
|
||||
|
||||
it('Should convert legacy rows to new rows with free panels before first row', () => {
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: rowsAfterFreePanels as DashboardDataDTO,
|
||||
meta: {},
|
||||
});
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: rowsAfterFreePanels as DashboardDataDTO,
|
||||
meta: {},
|
||||
},
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const layout = scene.state.body as RowsLayoutManager;
|
||||
const row1 = layout.state.rows[0];
|
||||
|
||||
@@ -29,11 +29,11 @@ import {
|
||||
} from 'app/features/dashboard/services/DashboardProfiler';
|
||||
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
|
||||
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
|
||||
import { DashboardDTO, DashboardDataDTO, DashboardRoutes } from 'app/types/dashboard';
|
||||
import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
|
||||
import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior';
|
||||
import { LoadDashboardOptions, shouldForceV2API } from '../pages/DashboardScenePageStateManager';
|
||||
import { LoadDashboardOptions } from '../pages/DashboardScenePageStateManager';
|
||||
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
|
||||
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
|
||||
import { DashboardControls } from '../scene/DashboardControls';
|
||||
@@ -76,11 +76,53 @@ export interface SaveModelToSceneOptions {
|
||||
isEmbedded?: boolean;
|
||||
}
|
||||
|
||||
export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashboardOptions): DashboardScene {
|
||||
type LayoutCreator = (panels: PanelModel[], preload?: boolean) => DashboardLayoutManager;
|
||||
|
||||
export interface SceneCreationOptions {
|
||||
/**
|
||||
* When provided, this function is used to create the dashboard body/layout instead of the default v1 behavior.
|
||||
* This allows callers to inject v2 layout strategy.
|
||||
*/
|
||||
createLayout?: LayoutCreator;
|
||||
/**
|
||||
* Determines how the dashboard scene is serialized.
|
||||
* @default 'v1'
|
||||
*/
|
||||
targetVersion?: 'v1' | 'v2';
|
||||
}
|
||||
|
||||
// Rows as SceneGridRow within the grid.
|
||||
const createDefaultGridLayout: LayoutCreator = (panels, preload) => {
|
||||
return new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
isLazy: getIsLazy(preload),
|
||||
children: createSceneObjectsForPanels(panels),
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* V2 layout creator - uses RowsLayoutManager when dashboard has rows.
|
||||
* This creates a layout that can be properly serialized to v2 format.
|
||||
*/
|
||||
export const createV2RowsLayout: LayoutCreator = (panels, preload) => {
|
||||
const hasRows = panels.some((p) => p.type === 'row');
|
||||
if (hasRows) {
|
||||
return createRowsFromPanels(panels);
|
||||
}
|
||||
// Fall back to default grid layout when no rows
|
||||
return createDefaultGridLayout(panels, preload);
|
||||
};
|
||||
|
||||
export function transformSaveModelToScene(
|
||||
rsp: DashboardDTO,
|
||||
options?: LoadDashboardOptions,
|
||||
sceneOptions?: SceneCreationOptions
|
||||
): DashboardScene {
|
||||
// Just to have migrations run
|
||||
const oldModel = new DashboardModel(rsp.dashboard, rsp.meta);
|
||||
|
||||
const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options);
|
||||
const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options, sceneOptions);
|
||||
// TODO: refactor createDashboardSceneFromDashboardModel to work on Dashboard schema model
|
||||
|
||||
const apiVersion = config.featureToggles.kubernetesDashboards
|
||||
@@ -92,7 +134,7 @@ export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashb
|
||||
return scene;
|
||||
}
|
||||
|
||||
export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager {
|
||||
function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager {
|
||||
const rowItems: RowItem[] = [];
|
||||
|
||||
let currentLegacyRow: PanelModel | null = null;
|
||||
@@ -143,7 +185,7 @@ export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager
|
||||
});
|
||||
}
|
||||
|
||||
export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] {
|
||||
function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] {
|
||||
// collects all panels and rows
|
||||
const panels: SceneGridItemLike[] = [];
|
||||
|
||||
@@ -259,14 +301,14 @@ function createRowItemFromLegacyRow(row: PanelModel, panels: DashboardGridItem[]
|
||||
export function createDashboardSceneFromDashboardModel(
|
||||
oldModel: DashboardModel,
|
||||
dto: DashboardDataDTO,
|
||||
options?: LoadDashboardOptions
|
||||
options?: LoadDashboardOptions,
|
||||
sceneOptions?: SceneCreationOptions
|
||||
) {
|
||||
let variables: SceneVariableSet | undefined;
|
||||
let annotationLayers: SceneDataLayerProvider[] = [];
|
||||
let alertStatesLayer: AlertStatesDataLayer | undefined;
|
||||
const uid = oldModel.uid;
|
||||
const isReport = options?.route === DashboardRoutes.Report;
|
||||
const serializerVersion = shouldForceV2API() && !oldModel.meta.isSnapshot && !isReport ? 'v2' : 'v1';
|
||||
const targetVersion = sceneOptions?.targetVersion ?? 'v1';
|
||||
|
||||
if (oldModel.meta.isSnapshot) {
|
||||
variables = createVariablesForSnapshot(oldModel);
|
||||
@@ -354,9 +396,11 @@ export function createDashboardSceneFromDashboardModel(
|
||||
|
||||
let body: DashboardLayoutManager;
|
||||
|
||||
if (serializerVersion === 'v2' && oldModel.panels.some((p) => p.type === 'row')) {
|
||||
body = createRowsFromPanels(oldModel.panels);
|
||||
if (sceneOptions?.createLayout) {
|
||||
// Use injected layout creator (allows callers to specify v2 or custom layout strategy)
|
||||
body = sceneOptions.createLayout(oldModel.panels, dto.preload);
|
||||
} else {
|
||||
// Default v1 layout: DefaultGridLayoutManager
|
||||
body = new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
isLazy: getIsLazy(dto.preload),
|
||||
@@ -404,7 +448,7 @@ export function createDashboardSceneFromDashboardModel(
|
||||
hideTimeControls: oldModel.timepicker.hidden,
|
||||
}),
|
||||
},
|
||||
serializerVersion
|
||||
targetVersion
|
||||
);
|
||||
|
||||
// Enable panel profiling for this dashboard using the composed SceneRenderProfiler
|
||||
|
||||
+40
-30
@@ -1,6 +1,8 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager';
|
||||
|
||||
import { normalizeBackendOutputForFrontendComparison } from './serialization-test-utils';
|
||||
import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from './transformSaveModelToScene';
|
||||
@@ -207,22 +209,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => {
|
||||
delete dashboardSpec.snapshot;
|
||||
|
||||
// Wrap in DashboardDTO structure that transformSaveModelToScene expects
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false);
|
||||
|
||||
@@ -279,22 +285,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => {
|
||||
delete dashboardSpec.snapshot;
|
||||
|
||||
// Wrap in DashboardDTO structure that transformSaveModelToScene expects
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false);
|
||||
|
||||
|
||||
+21
-15
@@ -6,6 +6,8 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
|
||||
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
|
||||
import { DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager';
|
||||
|
||||
import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from './transformSaveModelToScene';
|
||||
import { transformSceneToSaveModel } from './transformSceneToSaveModel';
|
||||
@@ -228,22 +230,26 @@ function removeMetadata(spec: Dashboard): Partial<Dashboard> {
|
||||
* identical processing.
|
||||
*/
|
||||
function loadAndSerializeV1SaveModel(dashboard: Dashboard): Dashboard {
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: dashboard as DashboardDataDTO,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: dashboard as DashboardDataDTO,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
return transformSceneToSaveModel(scene, false);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DashboardDataDTO } from 'app/types/dashboard';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
import { makeExportableV1, makeExportableV2 } from '../scene/export/exporters';
|
||||
import { createV2RowsLayout, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
|
||||
import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
|
||||
import { getVariablesCompatibility } from '../utils/getVariablesCompatibility';
|
||||
@@ -216,7 +217,27 @@ export class ShareExportTab extends SceneObjectBase<ShareExportTabState> impleme
|
||||
}
|
||||
|
||||
if (exportMode === ExportMode.V2Resource) {
|
||||
const spec = transformSceneToSaveModelSchemaV2(scene);
|
||||
let sceneForV2Export = scene;
|
||||
|
||||
// When exporting v1 dashboard as v2, we need to recreate the scene with v2 layout creator
|
||||
// to ensure rows are properly serialized. The v1 scene uses DefaultGridLayoutManager which
|
||||
// doesn't know about RowsLayoutManager structure needed for v2 serialization.
|
||||
if (initialSaveModelVersion === 'v1' && initialSaveModel && isV1ClassicDashboard(initialSaveModel)) {
|
||||
// Recreate scene with v2 layout creator to properly handle rows
|
||||
sceneForV2Export = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: { ...initialSaveModel, title: initialSaveModel.title ?? '', uid: initialSaveModel.uid ?? '' },
|
||||
meta: scene.state.meta,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
createLayout: createV2RowsLayout,
|
||||
targetVersion: 'v2',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const spec = transformSceneToSaveModelSchemaV2(sceneForV2Export);
|
||||
const specCopy = JSON.parse(JSON.stringify(spec));
|
||||
const statelessSpec = await makeExportableV2(specCopy, isSharingExternally);
|
||||
const exportableV2 = isSharingExternally ? statelessSpec : spec;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { getSceneCreationOptions } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
|
||||
import { normalizeBackendOutputForFrontendComparison } from 'app/features/dashboard-scene/serialization/serialization-test-utils';
|
||||
import { transformSaveModelSchemaV2ToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene';
|
||||
import { transformSaveModelToScene } from 'app/features/dashboard-scene/serialization/transformSaveModelToScene';
|
||||
@@ -193,22 +194,26 @@ describe('V1 to V2 Dashboard Transformation Comparison (ResponseTransformers)',
|
||||
delete dashboardSpec.snapshot;
|
||||
|
||||
// Wrap in DashboardDTO structure that transformSaveModelToScene expects
|
||||
const scene = transformSaveModelToScene({
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
const scene = transformSaveModelToScene(
|
||||
{
|
||||
dashboard: dashboardSpec,
|
||||
meta: {
|
||||
isNew: false,
|
||||
isFolder: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canDelete: false,
|
||||
canShare: false,
|
||||
canStar: false,
|
||||
canAdmin: false,
|
||||
isSnapshot: false,
|
||||
provisioned: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
getSceneCreationOptions()
|
||||
);
|
||||
|
||||
const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user