
Привет, Хабр! Меня зовут Игорь Красавин, и я работаю frontend-разработчиком в компании VK. Сегодня хочу рассказать вам, как мы объединяли несколько BI-систем (DataLens, Superset и Redash) под одним UI, как решали проблемы со SPA-навигацией, историей браузера и различными стеками, на какие грабли наступили, и что нам, в итоге, это дало. Материал будет полезен frontend-разработчикам, которые могут столкнуться со схожей задачей в рамках своих проектах.
Как все начиналось…
Несколько лет назад каждая команда или бизнес-юнит VK самостоятельно выбирали BI-инструменты под свои задачи. Команды активно внедряли open-source Superset, Redash, DataLens. Каждый бизнес-юнит сам ставил BI, настраивал интеграции и согласовывал с отделом информационной безопасности.
Получилось несколько инсталляций каждого решения, а также кастомный способы построения отчётов и графиков. Все эти BI-инструменты требовали под себя команды для поддержки и разработки функциональности для решения одних и тех же проблем. После череды миграций данных и слияний осталось по одной инсталляции от каждой из систем: Superset, Redash и DataLens.
Почему бы не остановиться на этом этапе и жить счастливо на трёх BI-системах? Такой подход затрудняет совместную работу между командами: у каждой системы есть свои роли, процессы получения доступа и интерфейсы. Из-за этого становилось сложно собирать данные из разных бизнес-юнитов, делиться дашбордами и выстраивать единые отчёты по компании. Одна и та же пользовательская задача — создать дашборд и поделиться им с коллегами — решается тремя разными способами в трёх BI, из‑за чего возникает фрагментация и путаница в сценариях.
Наконец, для некоторых сценариев, например, получения доступа к дашборду для нового сотрудника, требовалось запросить роли в BI-системах, роли на источнике, получить одобрение от ИБ и владельца данных.
Примечание: воркбук — изолированное пространство, которое хранит подключения, датасеты, чарты и дашборды. Сущности внутри одного воркбука могут ссылаться только друг на друга. Термин пришёл из инструмента DataLens.
Прежде всего необходимо выбрать целевую систему, которая послужит основой для присоединения остальных. Выбор стоял между DataLens и Superset. DataLens имеет много преимуществ, но мы сосредоточимся на двух главных.
-
DataLens использует подход no-code для построения дашбордов.
-
В отличие от Superset с механизмом разграничения доступов по таблицам и через имперсонацию, DataLens использует метод RBAC (модель управления доступом на основе ролей) на уровне воркбуков.
Итак, если мы выбираем DataLens как основу, то как же нам объединить остальные BI-системы? Как фронтендеры, нам впервую очередь важна UI-часть приложения. Важно оценить клиентский слой: UX, интерактивность, производительность и то, насколько легко встраивать и кастомизировать визуализации.
Объединяем необъятное
Рассмотрим процесс визуализации двух вышеописанных систем, сравним их подходы, а также попытаемся найти что-то общее между ними.
Конфигурация, описывающая визуализацию в DataLens, хранится в сервисе хранения united-storage (далее — US) в виде JSON-документа. Ниже — упрощённый пример:
{ "shared": { "type": "datalens", // тип/формат конфигурации "version": "10", // версия схемы конфига "visualization": { "id": "flatTable", "type": "table" // тип визуализации (таблица/график и т. п.) }, "datasetsIds": ["<dataset-id>"], // привязка к источникам/датасетам "filters": [/* ... */], // фильтры, применяемые к данным "sort": [/* ... */], // сортировка "extraSettings": { // прочие настройки (например, лимиты/пагинация) "pagination": "on", "limit": 100 } }}
При открытии чарта браузер отправляет POST-запрос на /api/run, передавая ID чарта и параметры (например, значения фильтров). Сервер загружает конфигурацию из US, получает данные из реальных источников (через сервис datalens-data-api) и возвращает в ответе одновременно:
-
данные для визуализации;
-
готовую конфигурацию Highcharts (новая версия DataLens использует самописную библиотеку визуализации на основе d3.js), которую клиент может сразу отобразить.
Для сравнения, в Superset запрос на построение чарта оформляется как QueryContext и отправляется POST-запросом на /api/v1/chart/data через SupersetClient (обёртку над fetch с авторизацией и обработкой ошибок).
Упрощённый пример тела запроса для отображения:
{ datasource: { id, type }, force: false, queries: [QueryObject], // массив запросов с собственной моделью query-запросов result_format: 'json', result_type: 'full', }
На бэкенде Superset этот контекст преобразуется в набор параметризированных запросов к источнику данных: система собирает SQL (или иной запрос в зависимости от драйвера), выполняет его через слой подключения к БД и возвращает результат в формате, который затем потребляет фронтенд-визуализация. По умолчанию многие визуализации в Superset построены на ECharts (через систему плагинов).
Процесс работы Superset отличается от DataLens, преимущественно основываясь на SQL-first подходе. Дашборды, чарты, датасеты и другие сущности представлены в плоском списке и могут быть переиспользованы, главное, иметь доступ к данным. DataLens, напротив, изолирует сущности в рамках одного воркбука.
Системы используют разные версии React, React Router, разные библиотеки отображения, а также разные стратегии рендеринга: DataLens UI-node генерирует первичный HTML, а далее идёт чистое SPA. Superset использует гибридную систему роутинга: часть страниц рендерится на сервере через Flask, а часть работает как SPA с клиентским роутингом через React Router. Добавляем к этому разные подходы к развитию проектов: дизайн-системы (ANTD и Gravity-UI) и работу с Redux, а также организацию кода и архитектуру проектов.
В итоге мы не можем просто так встроить один проект в другой, ничего не сломав и не потратив кучу времени на устранение проблем при запуске.
Решение
Отбросив варианты с глубокой интеграцией, мы выбрали встраивание через iframe как наиболее практичный способ запустить несколько приложений внутри основного с минимальными изменениями.
Основными причинами стоит выделить:
-
Нам не нужно перестраивать сборку приложений, выносить общие зависимости и приводить проекты к единому рантайму.
-
Оба приложения активно используют
window,localStorageиsessionStorage. При совместном запуске в одном контексте это приводит к конфликтам (ключи, обработчики событий, глобальные синглтоны). Iframe даёт отдельную «песочницу», что снимает большую часть коллизий. -
Приложения используют разные подходы к роутингу и рендерингу; iframe позволяет не синхронизировать их внутренние механики и не «склеивать» маршрутизацию на уровне SPA.
Почему не пошли по пути:
-
Микрофронтенды — требовалась существенная переработка: согласование контрактов, выравнивание зависимостей и версий, настройка шаринга, решение конфликтов глобального состояния и роутинга.
-
Web Components + Shadow DOM — хорошо решают инкапсуляцию UI, но не изолируют JS-окружение: глобальные зависимости, побочный эффект window и storage-конфликты остаются.
Сам процесс встраивания происходит довольно просто: добавляем компонент-обёртку над тегом iframe с указанием URL-приложения. Это позволит нам открыть одно приложение внутри другого.
Следующим шагом нужно создать воркбуки со стороны Supeset. Для начала разложим все данные по воркбукам в Superset-системе: заведём дополнительные таблицы для маппинга сущностей с ID воркбуков, в существующих добавим внешние ключи.
За разложение сущностей по воркбукам отвечал коллега, поэтому дальше дам лишь верхнеуровневое описание, без деталей реализации. Поскольку воркбук — это изолированная группа связанных между собой сущностей, мы решили выделять воркбуки по связности. Для этого строили граф зависимостей между сущностями (дашборд, чарт, датасет, соединение) и считали каждый граф как отдельный воркбук. Если граф получался слишком большим, то дополнительно дробили его на несколько меньших.
Далее добавим пару эндпойтов для получения этих самых сущностей по идентификатору воркбука. И вот у нас есть воркбук для суперсета.
Также необходимо выделить отдельный роут https://onebi/sunset/* для работы со всеми сущностями Superset через iframe. (Примечание: sunset это новое название для Superset). Но основе проделанной работы мы получаем рабочий вариант для взаимодействия:
Для большего контроля над отображением заведём отдельный класс, который будет в зависимости от событий загрузки менять состояния UI, изменять CSS-классы обёртки над iframe.
Казалось бы, работа сделана и можно запускать продукт, но нет. Проблемы только начались.
Поддержка i18n и двунаправленная коммуникация
У Superset и DataLens есть поддержка нескольких языков при отображении, следовательно, теперь нужно организовать синхронизацию выбранного языка, а в дальнейшем и выбранной темы (светлая, тёмная, системная).
Для решения этой задачи я решил использовать один из API браузера: Post Message.
После установки канала соединения (аналог hand-shake) shell-приложение передаёт конфигурацию в виде темы и языка дочернему приложению, а оно принимает эти изменения. Также добавляется подписка на изменения и передачу этих атрибутов embedded app. Далее приложу диаграмму взаимодействия компонентов:
-
AppManager отслеживает статус загрузки iframe;
-
AppManager устанавливает соединение с embedded app через handshake;
-
AppManager отправляет текущие настройки пользователя (тема и язык);
-
AppManager отслеживает изменения интерфейса и отправляет новый
SettingsPayload.
Синхронизация роутинга дочернего приложения
Перед запуском требовалось решить ещё одну серьёзную задачу: научиться синхронизировать историю браузера iframe с родительским приложением.
Приложение должно обеспечить синхронизацию навигации между родительской страницей и контентом, отображаемым в iframe:
-
URL родительского приложения должен обновляться и содержать параметры, определяющие текущую страницу внутри iframe.
-
Приложение должно восстанавливать состояние по прямой ссылке: загружать в iframe нужную страницу.
-
При нажатии кнопок Back или Forward в браузере приложение должно корректно восстанавливать соответствующее состояние iframe.
Решением стало отслеживать текущее состояние location в дочернем приложении и отправлять его через PostMessage в родительское, подмешивая в текущий путь:
https://onebi.ru/sunset/XXX, где XXX — это путь до сущности, например, 93biyl2lzdjmt/superset/dashboard/8980. Итоговая ссылка будет
https://onebi.ru/sunset/93biyl2lzdjmt/superset/dashboard/8980. С данным подходом мы можем получить искомый путь из URL и поставить загрузку iframe до нужной страницы:
<iframe src=’https://superset.com/93biyl2lzdjmt/superset/dashboard/8980?workbookId=93biyl2lzdjmt’
Реализовали это мы следующим образом:
AppManager включает модуль PatchedHistory, выполняющий monkey‑patch методов History API, и метод handleHistoryChanged. Метод handleHistoryChanged подписывается на изменения history в родительском приложении и при каждом изменении передаёт актуальные данные маршрута во встроенное приложение в iframe для выполнения соответствующей навигации. В обратном направлении handleHistoryChanged принимает через channelWrapper события навигации из iframe и применяет их к history родительского приложения, обеспечивая двустороннюю синхронизацию URL и истории браузера.
Наименование вкладок
Далее пришёл новый запрос на доработку: при открытии встроенных сущностей обновлялось имя вкладки в браузере. Поскольку механизм уже отлажен, то остаётся только добавить дополнительное событие для отслеживания изменений в заголовке через MutationObserver и отправить эти данные по каналу связи.
Внешние ссылки внутри markdown
После запуска такого чудовища Франкенштейна мы столкнулись с новой проблемой: большинство BI-систем поддерживают markdown-виджеты, в которые пользователи любят добавлять ссылки на внешние ресурсы: таски в Jira, статьи в Confluence и так далее. Но при клике по ссылке сайт пытается загрузить в iframe, а не в основном приложении, что приводит к ошибкам в работе приложения. Решением данной проблемы станет простая функциональность перехвата события открытия ссылки, отмена события через preventDefault и передача ссылки через PostMessage в родительское приложение, которое уже откроет его.
Аналогичный подход мы использовали для передачи метаданных об открытой странице внутри iframe. Таким же образом нам пришлось решать и задачу аналитики. При отправке части событий во встроенном приложении не хватало контекста,поэтому мы собирали необходимые параметры в iframe, передавали их в родительское приложение для обогащения и уже оттуда отправляли события в сервис аналитики.
Ошибки доступа
Ещё одна проблема, с которой мы столкнулись, заключалась в корректном отображении ошибок доступа во встроенном приложении при редиректе. При отсутствии прав на конкретную сущность Superset должен перенаправлять пользователя на воркбук DataLens с инструкцией по получению прав доступа. Однако редирект выполняется внутри iframe, поэтому пользователь не видит ожидаемого результата: воркбук должен открываться в родительского приложения. Чтобы обеспечить корректную работу данного кейса, мы реализовали следующий решение:
const FORBIDDEN_PREFIXES = ['/workbooks/', '/collections'];export function useOpenAsIframeProtection() { useEffect(() => { const isForbidded = FORBIDDEN_PREFIXES.some((prefix) => window.location.pathname.startsWith(prefix), ); if (window.top && isIframe() && isForbidded) { window.top.location = window.location; } }, []);}
Поскольку оба приложения используют корпоративную систему авторизации (IDM), детали реализации SSO в этой статье не рассматриваются. Для интеграции важно, что приложения доверяют одному провайдеру, а iframe не создаёт отдельную пользовательскую сессию.
Дальнейшие успехи и единая библиотека
После успешной интеграции Superset мы приняли решение встроить Redash в качестве инструмента для оперативного написания запросов к источникам данных. Так как подход к интеграции уже был проверен на практике, нам оставалось вынести общий код в отдельную библиотеку и использовать её в Redash.
Для встраиваемых приложений мы собрали библиотеку, чтобы:
-
не дублировать общую функциональность;
-
подключать новые приложения без лишнего кода;
-
новые фичи можно было использовать, лишь подняв версию библиотеки в package.json.
Выше приведена схема данной библиотеки. В её функциональность входит:
-
создание канала связи;
-
отслеживание состояния соединения, основанного на
PostMessage; -
отслеживание истории встроенного приложения при установке соединения;
-
отслеживание изменений в History Stack браузера и передача (шаг назад или вперёд по истории) нужного пути встроенному приложению;
-
отправка метаданных от дочерних приложений к родительскому;
-
отправка аналитики;
-
отслеживание изменений document title.
Ниже приведён весь код для управления общением между shell и embedded приложениями:
Код
/* eslint-disable @typescript-eslint/member-ordering */import type {Theme} from '@gravity-ui/uikit';import {Disposer} from 'hz/shared/libs/disposer';import {PatchedHistory} from 'hz/shared/libs/patched-history';import {getCurrentUrl, parseAppUrl, stripRoutePrefix} from 'hz/shared/libs/url-parser';import logger from 'libs/logger';import {Language} from 'shared';import {isEqual, withoutHost} from 'ufo';import {DL} from 'ui';import {type DLStore, getStore} from 'ui/store';import {type TAnalyticsPayload} from './analytics.model';import {ChannelWrapper} from './channel-wrapper';import {metadataEventPayloadSchema} from './metadata.model';import {Metadata, type TMetadataEventSchemaPayload} from './metadata.model';import {restoreAppManager, setChannelStatus, setController, setIframeStatus} from './store';import {CHANNEL_STATUS, type ChildApp, IFRAME_STATUS} from './type';type TNavigate = { url: string; replace?: boolean; openInNewWindow?: boolean; state?: object;};export class AppManager { private history: PatchedHistory; private disposer = new Disposer(); private store: DLStore; private app: ChildApp; private metadata: Metadata<TMetadataEventSchemaPayload>; private id = 'app-manager'; static current: Maybe<AppManager>; channelWrapper = ChannelWrapper; private checkConnectionTimer: NodeJS.Timeout; private channelInitialized = false; // Кэш последних настроек для сравнения изменений private lastUserSettings: {theme: Theme; lang?: Language}; // Analytics subscribers private analyticsSubscribers: Set<(data: TAnalyticsPayload) => void> = new Set(); // Шаг 1.5: Что тут происходит? // Заменяем историю своим патчем; // Как только ifame смонтирован, тогда можно делать инициализацию канала (ChannelWrapper) // Делаем подписку на обновление истории с обработчиком handleHistoryChanged // Как только связь установлена, то необходимо отправить начальные настройки приложения (сейчас это тема и язык) // На этом считаем, что инициализации закончена constructor(app: ChildApp) { this.app = app; this.store = getStore(); this.history = new PatchedHistory(); this.metadata = new Metadata(metadataEventPayloadSchema); // Инициализируем кэш текущими настройками this.lastUserSettings = this.getCurrentUserSettings(); this.history.subscribe((data) => logger.log('History changed', { data, state: window.history.state ?? {}, url: getCurrentUrl(), }), ); let previousIframeStatus = this.store.getState().appManager.iframeStatus; // Подписка 1: Отслеживаем изменения iframeStatus для инициализации канала const unsubIframeStatus = this.store.subscribe(() => { const currentState = this.store.getState().appManager; const currentIframeStatus = currentState.iframeStatus; if ( currentIframeStatus === IFRAME_STATUS.RENDERED && previousIframeStatus === IFRAME_STATUS.NOT_EXIST ) { logger.log('Setup PostMessage communication'); // Update previousIframeStatus before calling initChannelWrapper to prevent re-entrancy previousIframeStatus = currentIframeStatus; this.initChannelWrapper(); } previousIframeStatus = currentIframeStatus; }); this.disposer.add(this.history.subscribe(this.handleHistoryChanged)); this.disposer.add(unsubIframeStatus); let previousChannelStatus = this.store.getState().appManager.channelStatus; // Подписка 2: Отслеживаем установку соединения для отправки начальных настроек const unsubChannelStatus = this.store.subscribe(() => { const currentChannelStatus = this.store.getState().appManager.channelStatus; // Отправляем настройки при установке соединения if ( currentChannelStatus === CHANNEL_STATUS.LISTEN && previousChannelStatus === CHANNEL_STATUS.IDLE ) { logger.log('Channel connected, sending initial appSettings'); this.sendCurrentAppSettings(); } previousChannelStatus = currentChannelStatus; }); // Подписка 3: Отслеживаем изменения user store (тема и язык) const unsubUserStore = this.store.subscribe(() => { const currentUserSettings = this.getCurrentUserSettings(); this.handleUserSettingsChange(currentUserSettings); }); this.disposer.add(unsubChannelStatus); this.disposer.add(unsubUserStore); this.checkConnectionTimer = setTimeout(() => { if (this.store.getState().appManager.iframeStatus !== IFRAME_STATUS.RENDERED) { this.store.dispatch(setIframeStatus(IFRAME_STATUS.NOT_LOADED)); } }, app.loadTime); this.disposer.add(() => { clearTimeout(this.checkConnectionTimer!); }); } dispose() { this.disposer.dispose(); this.history.dispose(); this.metadata.dispose(); } // Шаг 1: Инициализируем наш менеджер, после одного вызова constructor, // остальное смотреть constructor static init(app: ChildApp) { if (this.current) { return this.current; } logger.log('Init app manager'); this.current = new AppManager(app); return AppManager.current; } // Самое важное: чертов react и его rereder будут вызываться несколько раз в dev mode + // При переходе между роутами происходит разрыв channel, поэтому важно очищать все подписки!!!! static destroy() { logger.log('Destroy app manager'); this.current?.channelWrapper.destroy(); this.current?.dispose(); getStore().dispatch(restoreAppManager()); this.current = undefined; } // Получаем текущие настройки из user store private getCurrentUserSettings(): {theme: Theme; lang?: Language} { const userState = this.store.getState().user; return { theme: userState.theme, lang: DL.USER_LANG === Language.En ? Language.En : Language.Ru, }; } // Обрабатываем изменения user settings private handleUserSettingsChange(current: {theme: Theme; lang?: Language}) { const themeChanged = current.theme !== this.lastUserSettings.theme; const langChanged = current.lang !== this.lastUserSettings.lang; if (themeChanged || langChanged) { logger.log('User settings changed', { theme: {from: this.lastUserSettings.theme, to: current.theme}, lang: {from: this.lastUserSettings.lang, to: current.lang}, }); // Отправляем настройки только если канал активен if (this.store.getState().appManager.channelStatus === CHANNEL_STATUS.LISTEN) { this.sendCurrentAppSettings(); } } // Обновляем кэш this.lastUserSettings = current; } // Отправляем текущие настройки в embedded app private sendCurrentAppSettings() { const userSettings = this.getCurrentUserSettings(); if (userSettings.lang) { ChannelWrapper.sendAppSettings({ theme: userSettings.theme, lang: userSettings.lang, }); } } // Должен вызываться 1 РАЗ // Создаем новый канал для общения через PostMessage API и делаем подписки. // Передаем Controller в state, чтобы передать потом в ui. // Controller создается каждый раз, когда происходит mount страницы BI. // Как только handshake в Controller.Channel завершен, ты мы готовы начать общение. // Делаем подписку на настройки приложения (appSettings) и отправляем их при ините и любом изменении appSettings. // Делаем подписку на url от embedded app (то есть embedded будет отправлять нам свой url, а мы его будем менять) private initChannelWrapper() { if (this.channelInitialized) { return; } this.channelInitialized = true; const instance = this.channelWrapper.init(this.app); const currentController = this.store.getState().appManager.controller; if (!currentController) { this.store.dispatch(setController(instance.controller)); } instance.channel.onStatus(({status}) => { const currentChannelStatus = this.store.getState().appManager.channelStatus; const newStatus = status === 'connected' ? CHANNEL_STATUS.LISTEN : CHANNEL_STATUS.IDLE; if (currentChannelStatus !== newStatus) { this.store.dispatch(setChannelStatus(newStatus)); } }); this.listenEmbeddedAppUrl(); this.listenMetadataApp(); this.listenExternalLinks(); this.listenAnalytics(); this.listenDocumentTitle(); } private listenEmbeddedAppUrl() { if (!this.channelWrapper.current) { return; } if (this.app.disableHistory) { return; } const unsubHistoryHandle = this.channelWrapper.onGotoEvent((data) => { const parsed = parseAppUrl(); if (!parsed) { return undefined; } // Extract route prefix from current URL if configured const currentRoutePrefix = this.app.routePrefix ?? ''; const currentRestPath = stripRoutePrefix(parsed.restPath, currentRoutePrefix); // Защита от случайных переходов по истории const areEqualUrls = isEqual(currentRestPath, data.url, {leadingSlash: false}); logger.log('[app-manager] Are urls equal?', { areEqualUrls, current: currentRestPath, fromEmbedded: data.url, routePrefix: currentRoutePrefix, }); if (!areEqualUrls) { const historyStatePayload = { idx: new Date().getTime(), key: `key_${new Date().getTime()}`, usr: {current: data.url}, responsible: this.id, }; // Prepend route prefix to the URL from embedded app const urlFromEmbedded = currentRoutePrefix ? `/${this.app.name}/${currentRoutePrefix}${data.url}` : `/${this.app.name}${data.url}`; if (data.replace) { window.history.replaceState(historyStatePayload, '', urlFromEmbedded); } else { window.history.pushState(historyStatePayload, '', urlFromEmbedded); } } return undefined; }); if (unsubHistoryHandle) { this.disposer.add(unsubHistoryHandle); } } private listenMetadataApp() { if (!this.channelWrapper.current) { return; } const unsubMetadataListening = this.channelWrapper.onBiMetadataEvent((data) => { this.metadata.updateMetadata(data); return undefined; }); if (unsubMetadataListening) { this.disposer.add(unsubMetadataListening); } } private listenExternalLinks() { if (!this.channelWrapper.current) { return; } const unsubExternalLink = this.channelWrapper.onExternalLinkEvent((data) => { this.openExternalLink(data.url, data.openInNewWindow); return undefined; }); if (unsubExternalLink) { this.disposer.add(unsubExternalLink); } } private listenAnalytics() { if (!this.channelWrapper.current) { return; } const unsubAnalytics = this.channelWrapper.onAnalyticsEvent((data) => { this.handleAnalytics(data); return undefined; }); if (unsubAnalytics) { this.disposer.add(unsubAnalytics); } } private handleAnalytics(data: TAnalyticsPayload) { // Notify all subscribers this.analyticsSubscribers.forEach((subscriber) => { subscriber(data); }); } private listenDocumentTitle() { if (!this.channelWrapper.current) { return; } const unsubDocumentTitle = this.channelWrapper.onDocumentTitleEvent((data) => { document.title = `${data.title} - ${window.DL.serviceName}`; return undefined; }); if (unsubDocumentTitle) { this.disposer.add(unsubDocumentTitle); } } // https://github.com/remix-run/history/blob/dev/packages/history/index.ts#L1010C1-L1016C2 promptBeforeUnload = (event: BeforeUnloadEvent) => { // Cancel the event. event.preventDefault(); // Chrome (and legacy IE) requires returnValue to be set. event.returnValue = ''; }; subscribeMetadataInfo(fn: (data: TMetadataEventSchemaPayload) => void) { return this.metadata.subscribe(fn); } /** * Подписка на мету от BI системы. Каждая система сама решает, когда отправить данные, мы только слушаем. * Тип данных меняется в зависимости от biSystem и entryScope, * работает как {@link https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions discriminated-unions} * * @example Пример данных от BI * { * biMetadata: { * biSystem: 'superset', * entryMetadata: { * entryScope: EntryScope.Widget, * name: 'Chart 1', * workbookId: '123', * }, * }, * } * // покинули страницу, передаем null * { * biMetadata: { * biSystem: 'superset', * entryMetadata: null, * } * } **/ static subscribeMetadataInfo = (fn: (data: TMetadataEventSchemaPayload) => void) => { return AppManager.current?.subscribeMetadataInfo(fn); }; /** * Подписка на аналитику от BI системы. Каждая система сама решает, когда отправить данные, мы только слушаем. * * @example Пример данных от BI * { * biSystem: 'superset', * eventType: 'dashboard_view_finish_loading', * params: { * dashboard_id: '2', * time: 1770128891337, * have_error: false, * use_extract: false, * } * } */ subscribeAnalytics(fn: (data: TAnalyticsPayload) => void) { this.analyticsSubscribers.add(fn); return () => { this.analyticsSubscribers.delete(fn); }; } static subscribeAnalytics = (fn: (data: TAnalyticsPayload) => void) => { return AppManager.current?.subscribeAnalytics(fn); }; /** * Navigates to a specified URL with various options. Before use, you should answer the question: * Is it possible to solve the task via react-router useNavigate + Link Component? If yes — don't use navigate at all. * * @param {TNavigate} params - Navigation parameters * @param {string} params.url - The target URL to navigate to * @param {boolean} [params.replace=false] - Whether to replace the current history entry * @param {boolean} [params.openInNewWindow=false] - Whether to open in a new window/tab * @param {object} [params.state] - Optional state object to pass with navigation * * @description * - If a current instance exists, delegates navigation to that instance * - If openInNewWindow is true, opens URL in a new window/tab * - Otherwise, navigates to URL in the current window * - Falls back to window.open if no current instance is available */ static navigate(params: TNavigate) { const {url, openInNewWindow} = params; // нет инстанса, мы не на странице BI if (this.current) { this.current.navigate(params); return; } if (openInNewWindow) { window.open(url, '_blank'); return; } window.open(url, '_self'); } static readyToSubscribe() { return Boolean(AppManager.current); } navigate({url, replace, state, openInNewWindow = false}: TNavigate) { const parsed = parseAppUrl(withoutHost(url)); if (!parsed || openInNewWindow) { window.open(url, '_blank'); return; } // мы открыли страницу поиска/sunset, а ссылка у нас на daylight const canWeOpenUrlNow = parsed.appName === this.app.name; if (canWeOpenUrlNow) { const currentRoutePrefix = this.app.routePrefix ?? ''; const urlToEmbedded = stripRoutePrefix(parsed.restPath, currentRoutePrefix); this.channelWrapper.gotoTo({ url: urlToEmbedded, replace: false, }); return; } if (replace) { window.history.replaceState({responsible: this.id, ...state}, '', url); } else { window.history.pushState({responsible: this.id, ...state}, '', url); } } private openExternalLink(url: string, openInNewWindow = false) { try { const parsedUrl = new URL(url); if (!['http:', 'https:'].includes(parsedUrl.protocol)) { logger.logError('Unsupported protocol for external link', new Error(), {url}); return; } if (openInNewWindow) { window.open(url, '_blank'); } else { window.location.href = url; } } catch (error) { logger.logError('Failed to open external link', error as Error, {url}); } } // Обработка событий истории браузера, нам важно отправить iframe событие, // что пользователь хочет вернуться назад или вперед private handleHistoryChanged: Parameters<PatchedHistory['subscribe']>[0] = ({type, params}) => { const currentRoutePrefix = this.app.routePrefix ?? ''; switch (type) { case 'pop': case 'forward': case 'back': { const parsed = parseAppUrl(); if (!parsed || parsed.appName !== this.app.name) { break; } this.channelWrapper.gotoTo({ url: stripRoutePrefix(parsed.restPath, currentRoutePrefix), replace: true, }); break; } case 'pushState': { const parsed = parseAppUrl(); const currentState = params[0]; if ( !parsed || currentState?.responsible === this.id || parsed.appName !== this.app.name ) { break; } this.channelWrapper.gotoTo({ url: stripRoutePrefix(parsed.restPath, currentRoutePrefix), replace: false, }); break; } case 'replaceState': default: { break; } } };}
Что мы имеем на текущий момент
Нам удалось интегрировать Superset, встроив его в DataLens и сохранив ключевые возможности его движка визуализации. На основе этого решения мы достаточно быстро подключили Redash в качестве отдельного SQL-терминала. Система живёт в парадигме воркбуков с простым способом выдачи доступов к данным.
Iframe позволил быстро подключать новые сервисы, но принёс ряд проблем, связанных с:
-
взаимодействием между приложениями и передачей контекста;
-
сохранением состояния и истории браузера;
-
скоростью загрузки контента;
-
размером страницы после открытия тяжёлого дашборда во встроенном приложении.
За счёт доработок (AppManager и библиотека для встраиваемых приложений) мы закрыли часть этих ограничений: умеем синхронизировать workbook ID, тему и язык интерфейса при работе встроенного приложения; научились синхронизировать history браузера, обрабатывать внутренние редиректы и ошибки доступа.
Дальше сосредоточимся на стабилизации производительности на тяжёлых дашбордах и доработке API DataLens, который будет проксировать вызовы к API Superset при работе с сущностями. Если на старте проекта сущности были доступны только в read-only, то сейчас уже поддерживаются редактирование, перемещение и удаление внутри воркбука.
Отдельное ограничение iframe — это усложнение разработки сквозной функциональности: чтобы отлаживать сценарии, часто нужно поднимать оба сервиса, что даёт дополнительную нагрузку на рабочее окружение.
В итоге iframe оказался хорошим способом быстро запустить интеграцию и получить первый feedback от пользователей, но в проде за скорость приходится платить сложностью взаимодействия и производительностью, эти места ещё предстоит улучшить.
А как бы вы решали подобную задачу? Поделитесь мнением в комментариях.
ссылка на оригинал статьи https://habr.com/ru/articles/1039482/