Перейти к содержанию

Разработка пользовательских графиков (плагинов)

Контракт плагина

Плагин представляет собой один JavaScript-файл, который при выполнении регистрирует объект GlarusChart в глобальной области window:

window.GlarusChart = {
  render(root: HTMLElement, props: RenderProps): () => void;
};

Функция render вызывается при каждой перерисовке — при первом монтировании, а также при изменении data, settings, theme или size. Она должна возвращать функцию очистки, которую родительское приложение вызовет перед следующей отрисовкой или при размонтировании компонента.

type RenderProps = {
  data: {
    cols: Column[];      // метаданные колонок результата запроса
    rows: unknown[][];   // строки результата
  };
  settings: Record<string, unknown>;            // значения из manifest.settingsSchema
  theme: {
    mode: "light" | "dark";
    tokens: Record<string, string>;             // CSS-токены темы оформления
  };
  size: { width: number; height: number };
  events: {
    onClick(payload: ClickPayload): void;       // см. §8
    onHover(payload: HoverPayload | null): void;
  };
  /** Сохранённое между отрисовками состояние плагина (см. ниже). */
  state?: Record<string, unknown>;
  /** Программный интерфейс родительского приложения для побочных эффектов. */
  host?: {
    /** Сохранить состояние; будет возвращено в `props.state` при следующем вызове render(). */
    setState(state: Record<string, unknown>): void;
  };
};

type Column = {
  name: string;            // программное имя колонки (используется в payload клика, см. §8)
  display_name?: string;   // отображаемое имя
  base_type?: string;      // type/Text, type/Integer, type/Float, type/DateTime и т.п.
  semantic_type?: string | null;
  // прочие поля метаданных колонки
};

Жизненный цикл

host:init (iframe загружен)
  → render(root, props)        ← плагин отрисовывает первый кадр
host:update (изменились data, settings или size)
  → выполняется функция очистки предыдущей отрисовки
  → render(root, newProps)
host:dispose (карточка удалена, выполнена навигация)
  → выполняется функция очистки последней отрисовки

Сохраняемое состояние

Плагин может запросить у родительского приложения сохранение произвольного сериализуемого объекта — например, ширины колонок, выбранные цвета серий, развёрнутые узлы дерева:

props.host.setState({ widths: { col0: 120, col1: 80 } });
// при следующем render():
//   props.state = { widths: { col0: 120, col1: 80 } }

Состояние хранится в локальном хранилище браузера на стороне родительского приложения. Ключ хранения формируется из идентификатора плагина, идентификатора карточки и пользователя браузера. Изолированный iframe не имеет прямого доступа к локальному хранилищу — операция выполняется родительским приложением по сообщению plugin:state-set.

Пример манифеста:

{
  "slug": "my-radar",    // ^[a-z][a-z0-9-]{2,62}$, уникален на экземпляре
  "name": "My Radar",    // название в выборе плагина визуализации
  "version": "0.1.0",    // свободная строка (рекомендуется формат semver)
  "icon": "data:image/svg+xml;...", // необяз. data:URI или HTTPS-ссылка
  "defaultSize": { "width": 8, "height": 6 },
  "settingsSchema": {    // JSON-Schema (object); генерирует виджеты настроек
    "type": "object",
    "properties": {
      "fillOpacity": { "type": "number", "default": 0.25, "title": "Fill opacity" },
      "showGrid":    { "type": "boolean", "default": true, "title": "Show grid" }
    }
  },
  "dataShape": {          // требования к форме данных
    "dimensions": { "min": 1, "max": 1 },
    "metrics":    { "min": 3 }
  }
}

settingsSchema – в виджеты

Адаптер jsonSchemaToWidgets поддерживает четыре примитивных типа: - текстовое поле {"type": "string"}; - выпадающий список {"type": "string", "enum": [...]}; - числовое поле {"type": "number"} или integer; - переключатель {"type": "boolean"}.

Расширение x-glarus-widget: - column: выбор одной колонки результата (целочисленный индекс); - column-list: переупорядочиваемый список колонок (массив индексов).

Остальные конструкции JSON-Schema игнорируются; значения по умолчанию следует задавать внутри плагина.

Пример:

"properties": {
  "seriesColumn": {
    "type": "integer",
    "x-glarus-widget": "column",
    "default": 0,
    "title": "Series identifier"
  },
  "metricColumns": {
    "type": "array",
    "items": { "type": "integer" },
    "x-glarus-widget": "column-list",
    "default": ["1", "2", "3"],
    "title": "Axes (metrics)"
  }
}

dataShape — форма данных

"dataShape": {
  "dimensions": { "min": 1, "max": 1 }, // ровно одно измерение
  "metrics":    { "min": 3 }            // не менее трёх метрик, без верхней границы
}

Измерения dimensions и метрики metrics определяются по типам столбцов в result_metadata:

  • измерение — столбец категорий (type/Text, type/Boolean, type/Date* без агрегации);
  • метрика — числовой столбец (type/Integer, type/Float) либо явный агрегат.

Если запрос не соответствует объявленной форме данных, плагин всё равно получит данные «как есть». Валидация остаётся на стороне плагина (например, отображение сообщения «требуется не менее трёх метрик»).

Протокол обмена «родительское приложение — iframe»

Связь между родительским окном и изолированным iframe осуществляется по типизированному протоколу на базе postMessage. Полный исходный код описания протокола приведён ниже:

/**
 * Host → iframe: `init`, `update`, `dispose`
 * iframe → host: `ready`, `event`, `error`, `state-set`
 *
 * Data and settings flow only host → iframe; iframe may only emit click
 * and hover events and request the host to persist state. Card-state
 * mutation happens only on the host side.
 */

export type ThemeMode = "light" | "dark";

export type HostInitMessage = {
  type: "host:init";
  pluginId: string;
  data: unknown;
  settings: Record<string, unknown>;
  theme: { mode: ThemeMode; tokens: Record<string, string> };
  size: { width: number; height: number };
  /** Previously-persisted plugin state (column widths, etc.). */
  state?: Record<string, unknown>;
};

export type HostUpdateMessage = {
  type: "host:update";
  data?: unknown;
  settings?: Record<string, unknown>;
  theme?: { mode: ThemeMode; tokens: Record<string, string> };
  size?: { width: number; height: number };
};

export type HostDisposeMessage = { type: "host:dispose" };

export type HostMessage = HostInitMessage | HostUpdateMessage | HostDisposeMessage;

export type PluginReadyMessage = { type: "plugin:ready"; pluginId: string };

/**
 * Optional column reference inside `PluginEventMessage.payload`. Plugin
 * passes the column's string name from `data.cols[].name`; the host
 * resolves it to the corresponding `DatasetColumn` before invoking
 * the platform callback.
 */
export type PluginEventColumnRef = { column: string; value: unknown };

export type PluginEventPayload = {
  x: number;
  y: number;
  datum?: unknown;
  /** Top-level value associated with the click (e.g. clicked table cell). */
  value?: PluginEventColumnRef;
  /** Dimension cross that pinpoints the data point (e.g. axes of a bar chart). */
  dimensions?: PluginEventColumnRef[];
};

export type PluginEventMessage = {
  type: "plugin:event";
  pluginId: string;
  event: "click" | "hover";
  payload: PluginEventPayload;
};

export type PluginErrorMessage = {
  type: "plugin:error";
  pluginId: string;
  message: string;
};

/**
 * Plugin asks the host to persist arbitrary serialisable state. The
 * sandboxed iframe cannot reach localStorage by itself. The host stores
 * state scoped to plugin × card × browser-user, and returns it via
 * `host:init.state` on the next render.
 */
export type PluginStateSetMessage = {
  type: "plugin:state-set";
  pluginId: string;
  state: Record<string, unknown>;
};

export type PluginMessage =
  | PluginReadyMessage
  | PluginEventMessage
  | PluginErrorMessage
  | PluginStateSetMessage;

Плагин не работает с этими сообщениями напрямую: статическая обёртка iframe-wrapper.js (предоставляется родительским приложением по адресу /api/glarus/custom-charts/iframe-wrapper.js) преобразует их в синхронный программный интерфейс через window.GlarusChart.render и props. Описание протокола требуется только при написании собственной обёртки или при отладке.

Изоляция и модель безопасности в плагинах

Принцип: даже скомпрометированный плагин не должен получить доступ к данным других карточек, к токенам пользователя или к cookies родительского приложения.

Механизм реализации:

  1. Изоляция iframe. Пакет выполняется в <iframe sandbox="allow-scripts"> без allow-same-origin. Это присваивает плагину непрозрачное «пустое» происхождение:

    • document.cookie недоступен;
    • localStorage и sessionStorage индивидуальны для каждого iframe и невидимы из родительского окна;
    • HTTP-запросы к API родительского приложения отправляются с заголовком Origin: null без cookies; серверная часть такие запросы отклоняет;
    • доступ к DOM родительского окна отсутствует.
  2. Политика безопасности контента (CSP). Glarus BI отдаёт заголовок Content-Security-Policy: script-src 'self'. Встроенные скрипты внутри iframe были бы заблокированы, поэтому iframe-wrapper.js и пакет плагина загружаются как внешние <script src> с конечных точек серверной части.

  3. Изоляция протокола. Родительское приложение проверяет event.source === iframeRef.current?.contentWindow перед обработкой любого сообщения postMessage. Сообщения из других фреймов отбрасываются.

  4. Экранирование последовательностей </script> в пакете при подстановке в srcdoc — предотвращает выход за пределы блока <script>.

  5. Хэш SHA-256. Фиксируется при загрузке, сохраняется в БД, возвращается в ETag, отображается в интерфейсе администратора. Защищает от незаметной модификации содержимого в базе данных.

  6. Ограничение размера — 2 Мбайт на пакет. Защита от отказа в обслуживании.

  7. Загружать плагины могут только администраторы. Обычные пользователи могут только использовать ранее установленные плагины.

Ограничения, накладываемые на плагин:

  1. Невозможно прочитать cookies пользователя (document.cookie пуст).

  2. Невозможно выполнить авторизованные запросы к API Glarus BI (Origin: null, отсутствие cookies).

  3. Невозможно обратиться к данным или коду соседних плагинов в других карточках (каждый iframe имеет независимое происхождение).

  4. Невозможно напрямую записать данные в локальное хранилище родительского приложения (только через props.host.setState).

  5. Невозможно открыть всплывающее окно, выполнить навигацию на другую страницу родительского приложения или инициировать скачивание файла на стороне пользователя.

Возможности плагина:

  • Загрузка библиотек со сторонних CDN (fetch('https://cdn.jsdelivr.net/...') или <script src="https://...">). Использование сторонних CDN сопряжено с рисками: внешний поставщик может фиксировать факт обращения. Для повышенных требований к безопасности рекомендуется ограничить CSP до connect-src 'self'.

  • Использование WebGL, Canvas, SVG, WebAssembly, Web Workers.

  • Сохранение состояния в локальном хранилище родительского приложения через props.host.setState.

Детализация и поведение по нажатию

Плагины могут участвовать в стандартной системе детализации (Drill Through) Glarus BI: нажатие на точку данных открывает контекстное меню с действиями или применяет настроенное на карточке дашборда поведение по нажатию – например, кросс-фильтр или переход по ссылке.

Действия плагина

При нажатии на элемент диаграммы плагин должен вызвать props.events.onClick(payload) со следующей структурой полезной нагрузки:

type ClickPayload = {
  x: number;                              // координаты в пикселях окна для позиционирования меню
  y: number;
  /** Значение точки. Колонка указывается по имени из data.cols[].name. */
  value?: { column: string; value: unknown };
  /** Срез измерений, однозначно определяющий точку. */
  dimensions?: Array<{ column: string; value: unknown }>;
};

Родительское приложение преобразует строковое имя column в полный объект DatasetColumn, формирует объект щелчка в формате платформы и передаёт его в стандартный конвейер действий по щелчку.

Пример. В радарной диаграмме каждая вершина соответствует кортежу (серия × ось × значение). При нажатии плагин отправляет:

props.events.onClick({
  x: e.clientX,
  y: e.clientY,
  value: { column: "TOTAL", value: 42.5 },                        // активная метрика
  dimensions: [{ column: "PRODUCT_CATEGORY", value: "Widget" }],  // в каком разрезе
});

Этого достаточно для следующих сценариев:

  1. На странице запроса открывается стандартное меню детализации («=», «≠», «Приблизить», «Умный анализ»).

  2. На дашборде, если у карточки настроено click_behavior: crossfilter, обновляется значение параметра — например, на Widget.

  3. На дашборде, если настроено click_behavior: link (внешний URL, дашборд или запрос), выполняется переход с подстановкой значения.

Поведение при отсутствии value и dimensions

Если оба поля отсутствуют, родительское приложение классифицирует такое нажатие как «фоновое» и не выполняет никаких действий. Это позволяет корректно обрабатывать случайные нажатия на пустые области диаграммы.

Наведение курсора

Метод props.events.onHover(payload) имеет аналогичную структуру, но не вызывает контекстных действий. Используется родительским приложением для подсветки связанных карточек на дашборде (кросс-фильтр подсветки).

Для снятия подсветки (например, при mouseleave диаграммы) следует вызвать props.events.onHover(null).

Сборка собственного плагина

Простейший плагин — немедленно вызываемое функциональное выражение без зависимостей на чистом JavaScript.

Для более сложных плагинов рекомендуется использовать сборщик, выдающий формат немедленно вызываемого функционального выражения без выражений ESM import в результирующем файле.

esbuild:

npx esbuild plugin.ts \
  --bundle \
  --format=iife \
  --target=es2018 \
  --outfile=plugin.js

rollup:

// rollup.config.js
export default {
  input: 'src/plugin.ts',
  output: { file: 'plugin.js', format: 'iife', name: '_glarus_plugin' },
  plugins: [require('@rollup/plugin-typescript')()],
};

Требования:

  1. Формат iife (не esm и не cjs): изолированный iframe не выполняет ES-модули в обычном <script> без атрибута type="module", а type=module блокируется политикой безопасности контента.

  2. Целевая версия es2018 или выше. Современные браузеры (Android Chrome, iOS Safari) поддерживают; ES2020+ (optional chaining, BigInt) также допустим.

  3. Запрещены eval, new Function, import() — все блокируются политикой безопасности контента.

  4. Внешние библиотеки с CDN можно подключить через <script src> в начале пакета:

(function () {
  var s = document.createElement('script');
  s.src = 'https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js';
  s.onload = function () {
    window.GlarusChart = { render: function (root, props) {
      var chart = echarts.init(root);
      chart.setOption({ /* ... */ });
      return function cleanup() { chart.dispose(); };
    }};
  };
  document.head.appendChild(s);
})();
Максимальный размер пакета — 2 Мбайта.

Ограничения и известные особенности

  1. История версий и откат не предусмотрены. Для обновления плагина следует удалить предыдущую версию и загрузить новую с тем же идентификатором (slug должен быть уникальным).

  2. Проверка пакета при загрузке ограничена. Контролируется размер (не более 2 Мбайт) и корректность парсинга JavaScript.

  3. Ошибки времени выполнения (бесконечный цикл, некорректные операции с DOM) обнаруживаются только в браузере как сообщения в iframe.

  4. Детализация ограничена. Нажатия в плагине обрабатываются как стандартные объекты click и активируют стандартное меню детализации либо настроенное поведение по щелчку.

  5. Многоуровневая навигация внутри плагина реализуется самим плагином.

  6. Каталог расширений (marketplace) не предусмотрен. Плагины распространяются через корпоративный репозиторий, электронную почту или другие каналы по усмотрению организации.

  7. Загрузка доступна только администратором системы.

  8. Зависимости от внешних CDN — на стороне плагина. При недоступности внешнего ресурса плагин не работает. Для автономной работы все зависимости следует встраивать в пакет.

  9. Ограничение размера пакета — 2 Мбайта. Для пакетов с echarts, d3 и аналогичными библиотеками рекомендуется минификация и сжатие либо загрузка зависимостей с внешнего CDN.

Примеры кода

См. таблицу с плагинами в "Управление плагинами".

Дополнительная информация