diff --git a/scripts/check-dashboard-types/index.js b/scripts/check-dashboard-types/index.js index 665f033cec..62aa2b59f3 100644 --- a/scripts/check-dashboard-types/index.js +++ b/scripts/check-dashboard-types/index.js @@ -5,11 +5,13 @@ const ts = require('typescript'); const configPath = path.resolve(__dirname, 'tsconfig.json'); const config = ts.readConfigFile(configPath, ts.sys.readFile); if (config.error) { - console.error(ts.formatDiagnostic(config.error, { - getCanonicalFileName: (fileName) => fileName, - getCurrentDirectory: ts.sys.getCurrentDirectory, - getNewLine: () => '\n', - })); + console.error( + ts.formatDiagnostic(config.error, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => '\n', + }), + ); process.exit(1); } @@ -18,7 +20,7 @@ const program = ts.createProgram(parsed.fileNames, parsed.options); const dashboardRoot = path.resolve(__dirname, '../../src/pages/dashboard') + path.sep; const excludedPathSegments = [ `${path.sep}VariableConfig${path.sep}`, - `${path.sep}updateSchema.ts`, + `${path.sep}grafanaImport${path.sep}`, `${path.sep}transformations${path.sep}AddFieldFromCalculationTransformation${path.sep}`, `${path.sep}transformations${path.sep}ConcatenateFieldsTransformation${path.sep}`, `${path.sep}transformations${path.sep}ConfigFromQueryResultsTransformation${path.sep}`, @@ -68,10 +70,7 @@ const diagnostics = ts .getPreEmitDiagnostics(program) .filter( (diagnostic) => - diagnostic.file && - isActiveDashboardFile(diagnostic.file.fileName) && - implicitAnyCodes.has(diagnostic.code) && - !isIgnoredPosition(diagnostic.file, diagnostic.start ?? 0), + diagnostic.file && isActiveDashboardFile(diagnostic.file.fileName) && implicitAnyCodes.has(diagnostic.code) && !isIgnoredPosition(diagnostic.file, diagnostic.start ?? 0), ); const anyDiagnostics = []; @@ -92,11 +91,13 @@ for (const sourceFile of program.getSourceFiles()) { } if (diagnostics.length) { - console.error(ts.formatDiagnosticsWithColorAndContext(diagnostics, { - getCanonicalFileName: (fileName) => fileName, - getCurrentDirectory: ts.sys.getCurrentDirectory, - getNewLine: () => '\n', - })); + console.error( + ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: ts.sys.getCurrentDirectory, + getNewLine: () => '\n', + }), + ); process.exitCode = 1; } diff --git a/src/pages/dashboard/List/Import/ImportGrafanaReport.tsx b/src/pages/dashboard/List/Import/ImportGrafanaReport.tsx new file mode 100644 index 0000000000..0dc28669e7 --- /dev/null +++ b/src/pages/dashboard/List/Import/ImportGrafanaReport.tsx @@ -0,0 +1,173 @@ +/* + * Grafana 导入两步式:转换报告视图。 + * 展示转换结果概览、迁移台账、不支持项,并提供「确认导入 / 返回修改 / 复制 Markdown 报告」。 + */ +import React from 'react'; +import { Alert, Button, Collapse, Descriptions, Space, Table, Tag, message } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import type { ConvertResult } from '@/pages/dashboard/utils/grafanaImport'; + +interface Props { + result: ConvertResult; + /** 保存(createDashboard)失败的错误信息;存在时展示在顶部并允许重试 */ + error?: string; + saving: boolean; + onConfirm: () => void; + onBack: () => void; +} + +const actionColors: Record = { + dropped: 'red', + downgraded: 'orange', + defaulted: 'default', +}; +const statusColors: Record = { + applied: 'green', + 'not-applicable': 'default', + skipped: 'orange', +}; + +/** 序列化为 skill 风格的 Markdown 报告(便于反馈排障) */ +export function buildMarkdownReport(result: ConvertResult): string { + const { report } = result; + const lines: string[] = []; + lines.push('## Migration ledger'); + lines.push(`- input schemaVersion: ${report.migration.inputSchemaVersion} -> target: ${report.migration.targetSchemaVersion}`); + for (const m of report.migration.migrations) { + lines.push(`- v${m.version}: ${m.status} - ${m.reason}`); + } + lines.push(''); + lines.push('## Unsupported items'); + if (report.unsupportedItems.length === 0) { + lines.push('No unsupported configuration was found'); + } else { + lines.push('| scope | path | action | reason |'); + lines.push('| --- | --- | --- | --- |'); + for (const item of report.unsupportedItems) { + lines.push(`| ${item.scope} | ${item.path || '-'} | ${item.action} | ${item.reason} |`); + } + } + return lines.join('\n'); +} + +export default function ImportGrafanaReport(props: Props) { + const { t } = useTranslation('dashboard'); + const { result, error, saving, onConfirm, onBack } = props; + const { dashboard, report } = result; + const summary = report.summary; + const hasUnsupported = report.unsupportedItems.length > 0; + + const handleCopy = async () => { + try { + await navigator.clipboard?.writeText(buildMarkdownReport(result)); + message.success(t('batch.import_grafana_report.copied')); + } catch (e) { + message.error(t('batch.import_grafana_report.copy')); + } + }; + + return ( +
+ {error ? ( + + {error} + + } + style={{ marginBottom: 12 }} + /> + ) : ( + + + {t('batch.import_grafana_report.panels')}: {summary?.convertedPanels}/{summary?.panels} + + + {t('batch.import_grafana_report.targets')}: {summary?.targets} + + + {t('batch.import_grafana_report.variables')}: {summary?.variables} + + {(summary?.droppedPanels ?? 0) > 0 && ( + + {t('batch.import_grafana_report.dropped')} {summary?.droppedPanels} + + )} + {(summary?.droppedTargets ?? 0) > 0 && ( + + {t('batch.import_grafana_report.dropped')} {summary?.droppedTargets} + + )} + {(summary?.droppedVariables ?? 0) > 0 && ( + + {t('batch.import_grafana_report.dropped')} {summary?.droppedVariables} + + )} + + } + style={{ marginBottom: 12 }} + /> + )} + + + + {report.unsupportedItems.length === 0 ? ( + {t('batch.import_grafana_report.unsupported_empty')} + ) : ( + `${r.scope}-${r.path}-${r.action}-${r.reason}`} + dataSource={report.unsupportedItems} + columns={[ + { title: t('batch.import_grafana_report.scope'), dataIndex: 'scope', width: 90 }, + { title: t('batch.import_grafana_report.path'), dataIndex: 'path', ellipsis: true }, + { title: t('batch.import_grafana_report.action'), dataIndex: 'action', width: 110, render: (v: string) => {v} }, + { title: t('batch.import_grafana_report.reason'), dataIndex: 'reason' }, + ]} + scroll={{ y: 220 }} + /> + )} + + + + + {report.migration.inputSchemaVersion} → {report.migration.targetSchemaVersion} + + +
{v} }, + { title: t('batch.import_grafana_report.reason'), dataIndex: 'reason' }, + ]} + scroll={{ y: 200 }} + /> + + + + + + + + + + ); +} diff --git a/src/pages/dashboard/List/Import/index.tsx b/src/pages/dashboard/List/Import/index.tsx index 83c096f011..b7b346e6de 100644 --- a/src/pages/dashboard/List/Import/index.tsx +++ b/src/pages/dashboard/List/Import/index.tsx @@ -14,16 +14,20 @@ * limitations under the License. * */ -import React, { useState } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import _ from 'lodash'; import { useTranslation, Trans } from 'react-i18next'; import { Modal, Input, Tabs, Form, Button, Alert, message, Select, Space } from 'antd'; import Icon from '@ant-design/icons'; +import { CommonStateContext } from '@/App'; import { HelpLink } from '@/components/pageLayout'; import { createDashboard } from '@/services/dashboardV2'; import { DASHBOARD_VERSION } from '@/pages/dashboard/config'; -import { getValidImportData, convertDashboardGrafanaToN9E, JSONParse, checkGrafanaDashboardVersion } from '../utils'; +import type { ConvertResult } from '@/pages/dashboard/utils/grafanaImport'; +import { convertDashboardGrafanaToN9EWithReport } from '@/pages/dashboard/utils/grafanaImport'; +import { getValidImportData, JSONParse } from '../utils'; import ImportBuiltinContent from './ImportBuiltinContent'; +import ImportGrafanaReport from './ImportGrafanaReport'; export type ModalType = 'Import' | 'ImportGrafana' | 'ImportBuiltin' | 'ImportGrafanaURL'; interface IProps { @@ -59,23 +63,63 @@ const BetaIcon = (props: React.ComponentProps) => (); const [form] = Form.useForm(); const [importLoading, setImportLoading] = useState(false); - const importGrafanaFunc = (json: unknown) => { - const data = convertDashboardGrafanaToN9E(json); + // Grafana 导入两步式:先转换并展示报告,确认后才保存 + const [grafanaReport, setGrafanaReport] = useState(); + const [grafanaError, setGrafanaError] = useState(); + const [saveError, setSaveError] = useState(); + + // 关闭弹窗时清理 Grafana 导入状态(对齐仓库弹窗清理约定) + useEffect(() => { + if (!visible) { + setGrafanaReport(undefined); + setGrafanaError(undefined); + setSaveError(undefined); + form.resetFields(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]); + + // 切换离开 ImportGrafana tab 时清理 + useEffect(() => { + if (modalType !== 'ImportGrafana') { + setGrafanaReport(undefined); + setGrafanaError(undefined); + setSaveError(undefined); + } + }, [modalType]); + + const doConvert = (vals: { import: string }) => { + setGrafanaError(undefined); + setSaveError(undefined); + const json = JSONParse(vals.import); + try { + const result = convertDashboardGrafanaToN9EWithReport(json, { datasourceList: groupedDatasourceList }); + setGrafanaReport(result); + } catch (e) { + setGrafanaError(e instanceof Error ? e.message : String(e)); + } + }; + + const doSave = (dashboard: ConvertResult['dashboard']) => { + setSaveError(undefined); + setImportLoading(true); createDashboard(busiId, { - ...data, - tags: '', - configs: JSON.stringify(data.configs), + ...dashboard, + configs: JSON.stringify(dashboard.configs), }) .then(() => { message.success(t('common:success.import')); onOk(); }) + .catch((e) => { + setSaveError(e instanceof Error ? e.message : t('batch.import_grafana_report.save_error')); + }) .finally(() => { - setCheckedVerisonResult(undefined); + setImportLoading(false); }); }; @@ -164,57 +208,56 @@ export default function Import(props: IProps) { /> ) : null} {modalType === 'ImportGrafana' ? ( -
{ - const json = JSONParse(vals.import); - const checkedVerisonResult = checkGrafanaDashboardVersion(json); - setCheckedVerisonResult(checkedVerisonResult); - if (checkedVerisonResult === 2) { - importGrafanaFunc(json); - } - }} - > -
- }} />} - type='info' - /> -
- - - - {checkedVerisonResult === 0 && } - {checkedVerisonResult === 1 && } - - {checkedVerisonResult === undefined && ( + grafanaReport ? ( + doSave(grafanaReport.dashboard)} + onBack={() => { + setGrafanaReport(undefined); + setSaveError(undefined); + }} + /> + ) : ( + +
+ }} />} + type='info' + /> +
+ + + + {grafanaError && ( + + {grafanaError} + + } + style={{ margin: '10px 0' }} + /> + )} + - )} - {checkedVerisonResult === 1 && ( - - )} - - +
+ + ) ) : null} {modalType === 'ImportGrafanaURL' ? (
feedback', - import_grafana_tip_version_error: 'Importing dashboard configs older than v7 is not supported', - import_grafana_tip_version_warning: 'The imported dashboard config is older than v8; some panels may not display properly. Continue importing?', import_grafana_url: 'Import Grafana dashboard URL', import_grafana_url_label: 'Grafana dashboard URL', - continueToImport: 'Continue to import', noSelected: 'Please select at least one dashboard', + import_grafana_report: { + title: 'Conversion report', + panels: 'Panels', + targets: 'Targets', + variables: 'Variables', + dropped: 'Dropped', + unsupported: 'Unsupported items', + unsupported_empty: 'No unsupported configuration was found', + ledger: 'Migration ledger', + schema: 'Schema version', + status: 'Status', + version: 'Version', + reason: 'Reason', + scope: 'Scope', + path: 'Path', + action: 'Action', + confirm: 'Confirm import', + back: 'Back to edit', + copy: 'Copy Markdown report', + copied: 'Report copied', + convert_error: 'Conversion failed', + save_error: 'Save failed', + }, import_builtin: 'Import built-in dashboard', import_builtin_board: 'Built-in dashboard', clone: { diff --git a/src/pages/dashboard/locale/ja_JP.ts b/src/pages/dashboard/locale/ja_JP.ts index c216b64162..dd5882a8c9 100644 --- a/src/pages/dashboard/locale/ja_JP.ts +++ b/src/pages/dashboard/locale/ja_JP.ts @@ -95,13 +95,32 @@ const ja_JP = { import_grafana: 'Grafanaダッシュボードをインポート(非推奨)', import_grafana_tip: 'Prometheus タイプのデータ ソースをインポートするダッシュボードだけがサポートされており、ナイチンゲールでサポートされているチャート タイプと関数もサポートされています 問題フィードバック', - import_grafana_tip_version_error: 'v7未満のバージョンのダッシュボード設定をインポートすることはできません', - import_grafana_tip_version_warning: - 'v8未満のバージョンのダッシュボード設定をインポートする場合、部分のグラフがサポートされていない、またはグラフが正常にレンダリングされない問題が発生する可能性があります', import_grafana_url: 'Grafana ダッシュボードリンク(推奨)', import_grafana_url_label: 'Grafana ダッシュボードリンク', - continueToImport: 'インポートを続行', noSelected: 'ダッシュボードを選択してください', + import_grafana_report: { + title: '変換レポート', + panels: 'パネル', + targets: 'クエリターゲット', + variables: '変数', + dropped: '破棄', + unsupported: 'サポート対象外', + unsupported_empty: 'サポート対象外の設定は見つかりませんでした', + ledger: '移行台帳', + schema: 'スキーマバージョン', + status: 'ステータス', + version: 'バージョン', + reason: '理由', + scope: '範囲', + path: 'パス', + action: '処理', + confirm: 'インポートを確認', + back: '編集に戻る', + copy: 'Markdown レポートをコピー', + copied: 'レポートがコピーされました', + convert_error: '変換に失敗しました', + save_error: '保存に失敗しました', + }, import_builtin: '内蔵ダッシュボードをインポート', import_builtin_board: '内蔵ダッシュボード', clone: { diff --git a/src/pages/dashboard/locale/ru_RU.ts b/src/pages/dashboard/locale/ru_RU.ts index 166cd27075..5d1d4fdea4 100644 --- a/src/pages/dashboard/locale/ru_RU.ts +++ b/src/pages/dashboard/locale/ru_RU.ts @@ -94,12 +94,32 @@ const ru_RU = { label: 'Панель JSON', import_grafana: 'Импорт панели Grafana (не рекомендуется)', import_grafana_tip: 'Поддерживается импорт панелей только из Prometheus типа источников данных, а также диаграммы и функции, поддерживаемые Nightingale Отзывы', - import_grafana_tip_version_error: 'Не поддерживается импорт панелей конфигурации версии меньше v7', - import_grafana_tip_version_warning: 'Импорт панелей конфигурации версии меньше v8, возможны частичные не поддерживаемые диаграммы и проблемы с рендерингом', import_grafana_url: 'Ссылка на панель Grafana (рекомендуется)', import_grafana_url_label: 'Ссылка на панель Grafana', - continueToImport: 'Продолжить импорт', noSelected: 'Пожалуйста, выберите панель', + import_grafana_report: { + title: 'Отчет о преобразовании', + panels: 'Панели', + targets: 'Запросы', + variables: 'Переменные', + dropped: 'Отброшено', + unsupported: 'Неподдерживаемые элементы', + unsupported_empty: 'Неподдерживаемые конфигурации не найдены', + ledger: 'Журнал миграции', + schema: 'Версия схемы', + status: 'Статус', + version: 'Версия', + reason: 'Причина', + scope: 'Область', + path: 'Путь', + action: 'Действие', + confirm: 'Подтвердить импорт', + back: 'Вернуться к редактированию', + copy: 'Скопировать Markdown-отчет', + copied: 'Отчет скопирован', + convert_error: 'Ошибка преобразования', + save_error: 'Ошибка сохранения', + }, import_builtin: 'Импорт встроенной панели', import_builtin_board: 'Встроенная панель', clone: { diff --git a/src/pages/dashboard/locale/zh_CN.ts b/src/pages/dashboard/locale/zh_CN.ts index e295bb5067..8e6a58e342 100644 --- a/src/pages/dashboard/locale/zh_CN.ts +++ b/src/pages/dashboard/locale/zh_CN.ts @@ -94,12 +94,32 @@ const zh_CN = { label: '仪表盘 JSON', import_grafana: '导入 Grafana 仪表盘(不推荐)', import_grafana_tip: '只支持导入 Prometheus 类型数据源的仪表盘,以及夜莺支持的图表类型和功能 问题反馈', - import_grafana_tip_version_error: '不支持导入小于 v7 版本的仪表盘配置', - import_grafana_tip_version_warning: '导入小于 v8 版本的仪表盘配置,可能会有部分图表不支持,以及图表无法正常渲染问题', import_grafana_url: 'Grafana 仪表盘链接(推荐)', import_grafana_url_label: 'Grafana 仪表盘链接', - continueToImport: '继续导入', noSelected: '请选择仪表盘', + import_grafana_report: { + title: '转换报告', + panels: '面板', + targets: '查询目标', + variables: '变量', + dropped: '丢弃', + unsupported: '不支持项', + unsupported_empty: '未发现不支持配置', + ledger: '迁移台账', + schema: 'schema 版本', + status: '状态', + version: '版本', + reason: '原因', + scope: '范围', + path: '路径', + action: '处理', + confirm: '确认导入', + back: '返回修改', + copy: '复制 Markdown 报告', + copied: '报告已复制', + convert_error: '转换失败', + save_error: '保存失败', + }, import_builtin: '导入内置仪表盘', import_builtin_board: '内置仪表盘', clone: { diff --git a/src/pages/dashboard/locale/zh_HK.ts b/src/pages/dashboard/locale/zh_HK.ts index 84a7d6ad43..e2c463313e 100644 --- a/src/pages/dashboard/locale/zh_HK.ts +++ b/src/pages/dashboard/locale/zh_HK.ts @@ -94,12 +94,32 @@ const zh_HK = { label: '儀表盤 JSON', import_grafana: '匯入 Grafana 儀表盤(不推薦)', import_grafana_tip: '只支援導入 Prometheus 類型資料來源的儀表盤,以及夜鶯支援的圖表類型和功能 問題反饋', - import_grafana_tip_version_error: '不支援匯入小於 v7 版本的儀表盤配置', - import_grafana_tip_version_warning: '匯入的儀表盤配置版本小於 v8,部分圖表可能無法正常顯示,是否繼續匯入?', import_grafana_url: 'Grafana 儀表盤地址(建議)', import_grafana_url_label: 'Grafana 儀表盤地址', - continueToImport: '繼續匯入', noSelected: '請選擇儀表盤', + import_grafana_report: { + title: '轉換報告', + panels: '面板', + targets: '查詢目標', + variables: '變數', + dropped: '丟棄', + unsupported: '不支援項', + unsupported_empty: '未發現不支援配置', + ledger: '遷移台帳', + schema: 'schema 版本', + status: '狀態', + version: '版本', + reason: '原因', + scope: '範圍', + path: '路徑', + action: '處理', + confirm: '確認匯入', + back: '返回修改', + copy: '複製 Markdown 報告', + copied: '報告已複製', + convert_error: '轉換失敗', + save_error: '儲存失敗', + }, import_builtin: '匯入內置儀表盤', import_builtin_board: '內置儀表盤', clone: { diff --git a/src/pages/dashboard/updateSchema.ts b/src/pages/dashboard/updateSchema.ts deleted file mode 100644 index 5da5df4b7e..0000000000 --- a/src/pages/dashboard/updateSchema.ts +++ /dev/null @@ -1,1073 +0,0 @@ -// @ts-nocheck -/** - * Copyright 2014-2021 Grafana Labs - */ - -import _ from 'lodash'; - -const MIXED_DATASOURCE_NAME = '-- Mixed --'; -export const GRID_CELL_HEIGHT = 30; -export const GRID_CELL_VMARGIN = 8; -export const GRID_COLUMN_COUNT = 24; -export const DEFAULT_PANEL_SPAN = 4; -export const DEFAULT_ROW_HEIGHT = 250; -export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3; -const defaultDatasource = null; -enum VariableHide { - dontHide, - hideLabel, - hideVariable, -} -enum MappingType { - ValueToText = 'value', // was 1 - RangeToText = 'range', // was 2 - RegexToText = 'regex', - SpecialValue = 'special', -} -enum SpecialValueMatch { - True = 'true', - False = 'false', - Null = 'null', - NaN = 'nan', - NullAndNaN = 'null+nan', - Empty = 'empty', -} -const colors = ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)']; - -export default function updateSchema(old: any) { - let i, j, k, n; - const dashboard = old; - const oldVersion = old.schemaVersion; - const panelUpgrades: any[] = []; - // this.dashboard.schemaVersion = 37; - - if (oldVersion >= 37) { - return dashboard; - } - - // version 2 schema changes - if (oldVersion < 2) { - // if (old.services) { - // if (old.services.filter) { - // this.dashboard.time = old.services.filter.time; - // this.dashboard.templating.list = old.services.filter.list || []; - // } - // } - - panelUpgrades.push((panel: any) => { - // rename panel type - if (panel.type === 'graphite') { - panel.type = 'graph'; - } - - if (panel.type !== 'graph') { - return panel; - } - - if (_.isBoolean(panel.legend)) { - panel.legend = { show: panel.legend }; - } - - if (panel.grid) { - if (panel.grid.min) { - panel.grid.leftMin = panel.grid.min; - delete panel.grid.min; - } - - if (panel.grid.max) { - panel.grid.leftMax = panel.grid.max; - delete panel.grid.max; - } - } - - if (panel.y_format) { - if (!panel.y_formats) { - panel.y_formats = []; - } - panel.y_formats[0] = panel.y_format; - delete panel.y_format; - } - - if (panel.y2_format) { - if (!panel.y_formats) { - panel.y_formats = []; - } - panel.y_formats[1] = panel.y2_format; - delete panel.y2_format; - } - - return panel; - }); - } - - // schema version 3 changes - if (oldVersion < 3) { - // ensure panel IDs - panelUpgrades.push((panel: any) => { - if (!panel.id) { - panel.id = _.uniqueId('panel_'); - } - return panel; - }); - } - - // schema version 4 changes - if (oldVersion < 4) { - // move aliasYAxis changes - panelUpgrades.push((panel: any) => { - if (panel.type !== 'graph') { - return panel; - } - - _.forEach(panel.aliasYAxis, (value, key) => { - panel.seriesOverrides = [{ alias: key, yaxis: value }]; - }); - - delete panel.aliasYAxis; - - return panel; - }); - } - - if (oldVersion < 6) { - // update template variables - for (i = 0; i < dashboard.templating.list.length; i++) { - const variable = dashboard.templating.list[i]; - if (variable.datasource === void 0) { - variable.datasource = null; - } - if (variable.type === 'filter') { - variable.type = 'query'; - } - if (variable.type === void 0) { - variable.type = 'query'; - } - if (variable.allFormat === void 0) { - variable.allFormat = 'glob'; - } - } - } - - if (oldVersion < 7) { - if (old.nav && old.nav.length) { - dashboard.timepicker = old.nav[0]; - } - - // ensure query refIds - panelUpgrades.push((panel: any) => { - _.forEach(panel.targets, (target) => { - if (!target.refId) { - target.refId = panel.getNextQueryLetter && panel.getNextQueryLetter(); - } - }); - - return panel; - }); - } - - if (oldVersion < 8) { - panelUpgrades.push((panel: any) => { - _.forEach(panel.targets, (target) => { - // update old influxdb query schema - if (target.fields && target.tags && target.groupBy) { - if (target.rawQuery) { - delete target.fields; - delete target.fill; - } else { - target.select = _.map(target.fields, (field) => { - const parts: any[] = []; - parts.push({ type: 'field', params: [field.name] }); - parts.push({ type: field.func, params: [] }); - if (field.mathExpr) { - parts.push({ type: 'math', params: [field.mathExpr] }); - } - if (field.asExpr) { - parts.push({ type: 'alias', params: [field.asExpr] }); - } - return parts; - }); - delete target.fields; - _.forEach(target.groupBy, (part) => { - if (part.type === 'time' && part.interval) { - part.params = [part.interval]; - delete part.interval; - } - if (part.type === 'tag' && part.key) { - part.params = [part.key]; - delete part.key; - } - }); - - if (target.fill) { - target.groupBy.push({ type: 'fill', params: [target.fill] }); - delete target.fill; - } - } - } - }); - - return panel; - }); - } - - // schema version 9 changes - if (oldVersion < 9) { - // move aliasYAxis changes - panelUpgrades.push((panel: any) => { - if (panel.type !== 'singlestat' && panel.thresholds !== '') { - return panel; - } - - if (panel.thresholds) { - const k = panel.thresholds.split(','); - - if (k.length >= 3) { - k.shift(); - panel.thresholds = k.join(','); - } - } - - return panel; - }); - } - - // schema version 10 changes - if (oldVersion < 10) { - // move aliasYAxis changes - panelUpgrades.push((panel: any) => { - if (panel.type !== 'table') { - return panel; - } - - _.forEach(panel.styles, (style) => { - if (style.thresholds && style.thresholds.length >= 3) { - const k = style.thresholds; - k.shift(); - style.thresholds = k; - } - }); - - return panel; - }); - } - - if (oldVersion < 12) { - // update template variables - // _.forEach(this.dashboard.getVariables(), (templateVariable: any) => { - // if (templateVariable.refresh) { - // templateVariable.refresh = 1; - // } - // if (!templateVariable.refresh) { - // templateVariable.refresh = 0; - // } - // if (templateVariable.hideVariable) { - // templateVariable.hide = 2; - // } else if (templateVariable.hideLabel) { - // templateVariable.hide = 1; - // } - // }); - } - - if (oldVersion < 12) { - // update graph yaxes changes - panelUpgrades.push((panel: any) => { - if (panel.type !== 'graph') { - return panel; - } - if (!panel.grid) { - return panel; - } - - if (!panel.yaxes) { - panel.yaxes = [ - { - show: panel['y-axis'], - min: panel.grid.leftMin, - max: panel.grid.leftMax, - logBase: panel.grid.leftLogBase, - format: panel.y_formats[0], - label: panel.leftYAxisLabel, - }, - { - show: panel['y-axis'], - min: panel.grid.rightMin, - max: panel.grid.rightMax, - logBase: panel.grid.rightLogBase, - format: panel.y_formats[1], - label: panel.rightYAxisLabel, - }, - ]; - - panel.xaxis = { - show: panel['x-axis'], - }; - - delete panel.grid.leftMin; - delete panel.grid.leftMax; - delete panel.grid.leftLogBase; - delete panel.grid.rightMin; - delete panel.grid.rightMax; - delete panel.grid.rightLogBase; - delete panel.y_formats; - delete panel.leftYAxisLabel; - delete panel.rightYAxisLabel; - delete panel['y-axis']; - delete panel['x-axis']; - } - - return panel; - }); - } - - if (oldVersion < 13) { - // update graph yaxes changes - panelUpgrades.push((panel: any) => { - if (panel.type !== 'graph') { - return panel; - } - if (!panel.grid) { - return panel; - } - - if (!panel.thresholds) { - panel.thresholds = []; - } - const t1: any = {}, - t2: any = {}; - - if (panel.grid.threshold1 !== null) { - t1.value = panel.grid.threshold1; - if (panel.grid.thresholdLine) { - t1.line = true; - t1.lineColor = panel.grid.threshold1Color; - t1.colorMode = 'custom'; - } else { - t1.fill = true; - t1.fillColor = panel.grid.threshold1Color; - t1.colorMode = 'custom'; - } - } - - if (panel.grid.threshold2 !== null) { - t2.value = panel.grid.threshold2; - if (panel.grid.thresholdLine) { - t2.line = true; - t2.lineColor = panel.grid.threshold2Color; - t2.colorMode = 'custom'; - } else { - t2.fill = true; - t2.fillColor = panel.grid.threshold2Color; - t2.colorMode = 'custom'; - } - } - - if (_.isNumber(t1.value)) { - if (_.isNumber(t2.value)) { - if (t1.value > t2.value) { - t1.op = t2.op = 'lt'; - panel.thresholds.push(t1); - panel.thresholds.push(t2); - } else { - t1.op = t2.op = 'gt'; - panel.thresholds.push(t1); - panel.thresholds.push(t2); - } - } else { - t1.op = 'gt'; - panel.thresholds.push(t1); - } - } - - delete panel.grid.threshold1; - delete panel.grid.threshold1Color; - delete panel.grid.threshold2; - delete panel.grid.threshold2Color; - delete panel.grid.thresholdLine; - - return panel; - }); - } - - if (oldVersion < 14) { - dashboard.graphTooltip = old.sharedCrosshair ? 1 : 0; - } - - if (oldVersion < 16) { - upgradeToGridLayout(old, dashboard); - } - - if (oldVersion < 17) { - panelUpgrades.push((panel: any) => { - if (panel.minSpan) { - const max = GRID_COLUMN_COUNT / panel.minSpan; - const factors = getFactors(GRID_COLUMN_COUNT); - // find the best match compared to factors - // (ie. [1,2,3,4,6,12,24] for 24 columns) - panel.maxPerRow = - factors[ - _.findIndex(factors, (o) => { - return o > max; - }) - 1 - ]; - } - - delete panel.minSpan; - - return panel; - }); - } - - if (oldVersion < 18) { - // migrate change to gauge options - panelUpgrades.push((panel: any) => { - if (panel['options-gauge']) { - panel.options = panel['options-gauge']; - panel.options.valueOptions = { - unit: panel.options.unit, - stat: panel.options.stat, - decimals: panel.options.decimals, - prefix: panel.options.prefix, - suffix: panel.options.suffix, - }; - - // correct order - if (panel.options.thresholds) { - panel.options.thresholds.reverse(); - } - - // this options prop was due to a bug - delete panel.options.options; - delete panel.options.unit; - delete panel.options.stat; - delete panel.options.decimals; - delete panel.options.prefix; - delete panel.options.suffix; - delete panel['options-gauge']; - } - - return panel; - }); - } - - if (oldVersion < 19) { - } - - if (oldVersion < 20) { - } - - if (oldVersion < 21) { - const updateLinks = (link: any) => { - return { - ...link, - url: link.url.replace(/__series.labels/g, '__field.labels'), - }; - }; - panelUpgrades.push((panel: any) => { - // For graph panel - if (panel.options && panel.options.dataLinks && _.isArray(panel.options.dataLinks)) { - panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); - } - - // For panel with fieldOptions - if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { - if (panel.options.fieldOptions.defaults.links && _.isArray(panel.options.fieldOptions.defaults.links)) { - panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); - } - } - - return panel; - }); - } - - if (oldVersion < 22) { - panelUpgrades.push((panel: any) => { - if (panel.type !== 'table') { - return panel; - } - - _.forEach(panel.styles, (style) => { - style.align = 'auto'; - }); - - return panel; - }); - } - - if (oldVersion < 23) { - for (const variable of dashboard.templating.list) { - if (!isMulti(variable)) { - continue; - } - const { multi, current } = variable; - variable.current = alignCurrentWithMulti(current, multi); - } - } - - if (oldVersion < 24) { - // 7.0 - // - migrate existing tables to 'table-old' - panelUpgrades.push((panel: any) => { - const wasAngularTable = panel.type === 'table'; - if (wasAngularTable && !panel.styles) { - return panel; // styles are missing so assumes default settings - } - const wasReactTable = panel.table === 'table2'; - if (!wasAngularTable || wasReactTable) { - return panel; - } - panel.type = wasAngularTable ? 'table-old' : 'table'; - return panel; - }); - } - - if (oldVersion < 25) { - } - - if (oldVersion < 26) { - panelUpgrades.push((panel: any) => { - const wasReactText = panel.type === 'text2'; - if (!wasReactText) { - return panel; - } - - panel.type = 'text'; - delete panel.options.angular; - return panel; - }); - } - - if (oldVersion < 27) { - dashboard.templating.list = dashboard.templating.list.map((variable) => { - if (!isConstant(variable)) { - return variable; - } - - const newVariable: any = { - ...variable, - }; - - newVariable.current = { selected: true, text: newVariable.query ?? '', value: newVariable.query ?? '' }; - newVariable.options = [newVariable.current]; - - if (newVariable.hide === VariableHide.dontHide || newVariable.hide === VariableHide.hideLabel) { - return { - ...newVariable, - type: 'textbox', - }; - } - - return newVariable; - }); - } - - if (oldVersion < 28) { - panelUpgrades.push((panel: any) => { - if (panel.type === 'singlestat') { - return migrateSinglestat(panel); - } - - return panel; - }); - - for (const variable of dashboard.templating.list) { - if (variable.tags) { - delete variable.tags; - } - - if (variable.tagsQuery) { - delete variable.tagsQuery; - } - - if (variable.tagValuesQuery) { - delete variable.tagValuesQuery; - } - - if (variable.useTags) { - delete variable.useTags; - } - } - } - - if (oldVersion < 29) { - for (const variable of dashboard.templating.list) { - if (variable.type !== 'query') { - continue; - } - - if (variable.refresh !== 1 && variable.refresh !== 2) { - variable.refresh = 1; - } - - if (variable.options?.length) { - variable.options = []; - } - } - } - - if (oldVersion < 30) { - panelUpgrades.push(upgradeValueMappingsForPanel); - panelUpgrades.push(migrateTooltipOptions); - } - - if (oldVersion < 31) { - } - - if (oldVersion < 32) { - } - - if (oldVersion < 33) { - } - - if (oldVersion < 34) { - panelUpgrades.push((panel: any) => { - // this.migrateCloudWatchQueries(panel); - return panel; - }); - - // this.migrateCloudWatchAnnotationQuery(); - } - - if (oldVersion < 35) { - } - - if (oldVersion < 36) { - } - - if (oldVersion < 37) { - panelUpgrades.push((panel: any) => { - if ( - panel.options?.legend && - // There were two ways to hide the legend, this normalizes to `legend.showLegend` - (panel.options.legend.displayMode === 'hidden' || panel.options.legend.showLegend === false) - ) { - panel.options.legend.displayMode = 'list'; - panel.options.legend.showLegend = false; - } else if (panel.options?.legend) { - panel.options.legend = { ...panel.options?.legend, showLegend: true }; - } - return panel; - }); - } - - if (panelUpgrades.length === 0) { - return; - } - - for (j = 0; j < dashboard.panels.length; j++) { - for (k = 0; k < panelUpgrades.length; k++) { - dashboard.panels[j] = panelUpgrades[k](dashboard.panels[j]); - const rowPanels = dashboard.panels[j].panels; - if (rowPanels) { - for (n = 0; n < rowPanels.length; n++) { - rowPanels[n] = panelUpgrades[k](rowPanels[n]); - } - } - } - } - return dashboard; -} - -function upgradeToGridLayout(old: any, dashboard: any) { - dashboard.panels = dashboard.panels || []; - let yPos = 0; - const widthFactor = GRID_COLUMN_COUNT / 12; - - const maxPanelId = _.max( - _.flattenDeep( - _.map(old.rows, (row) => { - return _.map(row.panels, 'id'); - }), - ), - ); - let nextRowId = maxPanelId + 1; - - if (!old.rows) { - return; - } - - // Add special "row" panels if even one row is collapsed, repeated or has visible title - const showRows = _.some(old.rows, (row) => row.collapse || row.showTitle || row.repeat); - - for (const row of old.rows) { - if (row.repeatIteration) { - continue; - } - - const height: any = row.height || DEFAULT_ROW_HEIGHT; - const rowGridHeight = getGridHeight(height); - - const rowPanel: any = {}; - let rowPanelModel: any; - - if (showRows) { - // add special row panel - rowPanel.id = nextRowId; - rowPanel.type = 'row'; - rowPanel.title = row.title; - rowPanel.collapsed = row.collapse; - rowPanel.repeat = row.repeat; - rowPanel.panels = []; - rowPanel.gridPos = { - x: 0, - y: yPos, - w: GRID_COLUMN_COUNT, - h: 1, - }; - rowPanelModel = rowPanel; - // rowPanelModel = new PanelModel(rowPanel); - nextRowId++; - yPos++; - } - - const rowArea = new RowArea(rowGridHeight, GRID_COLUMN_COUNT, yPos); - - for (const panel of row.panels) { - panel.span = panel.span || DEFAULT_PANEL_SPAN; - if (panel.minSpan) { - panel.minSpan = Math.min(GRID_COLUMN_COUNT, (GRID_COLUMN_COUNT / 12) * panel.minSpan); - } - const panelWidth = Math.floor(panel.span) * widthFactor; - const panelHeight = panel.height ? getGridHeight(panel.height) : rowGridHeight; - - const panelPos = rowArea.getPanelPosition(panelHeight, panelWidth); - yPos = rowArea.yPos; - panel.gridPos = { - x: panelPos.x, - y: yPos + panelPos.y, - w: panelWidth, - h: panelHeight, - }; - rowArea.addPanel(panel.gridPos); - - delete panel.span; - - if (rowPanelModel && rowPanel.collapsed) { - rowPanelModel.panels?.push(panel); - } else { - dashboard.panels.push(panel); - } - } - - if (rowPanelModel) { - dashboard.panels.push(rowPanelModel); - } - - if (!(rowPanelModel && rowPanel.collapsed)) { - yPos += rowGridHeight; - } - } -} - -function getGridHeight(height: number) { - if (_.isString(height)) { - height = parseInt(_.replace(height, 'px', ''), 10); - } - - if (height < MIN_PANEL_HEIGHT) { - height = MIN_PANEL_HEIGHT; - } - - const gridHeight = Math.ceil(height / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN)); - return gridHeight; -} - -function getFactors(num: number): number[] { - return Array.from(new Array(num + 1), (_, i) => i).filter((i) => num % i === 0); -} - -function upgradeValueMappingsForPanel(panel: any) { - const fieldConfig = panel.fieldConfig; - if (!fieldConfig) { - return panel; - } - - if (fieldConfig.defaults && fieldConfig.defaults.mappings) { - fieldConfig.defaults.mappings = upgradeValueMappings(fieldConfig.defaults.mappings, fieldConfig.defaults.thresholds); - } - - // Protect against no overrides - if (Array.isArray(fieldConfig.overrides)) { - for (const override of fieldConfig.overrides) { - for (const prop of override.properties) { - if (prop.id === 'mappings') { - prop.value = upgradeValueMappings(prop.value); - } - } - } - } - - return panel; -} - -function upgradeValueMappings(oldMappings: any, thresholds?: any): any[] | undefined { - if (!oldMappings) { - return undefined; - } - - const valueMaps: any = { type: MappingType.ValueToText, options: {} }; - const newMappings: any[] = []; - - for (const old of oldMappings) { - // when migrating singlestat to stat/gauge, mappings are handled by panel type change handler used in that migration - if (old.type && old.options) { - // collect al value->text mappings in a single value map object. These are migrated by panel change handler as a separate value maps - if (old.type === MappingType.ValueToText) { - valueMaps.options = { - ...valueMaps.options, - ...old.options, - }; - } else { - newMappings.push(old); - } - continue; - } - - // Use the color we would have picked from thesholds - let color: string | undefined = undefined; - const numeric = parseFloat(old.text); - if (thresholds && !isNaN(numeric)) { - const level = getActiveThreshold(numeric, thresholds.steps); - if (level && level.color) { - color = level.color; - } - } - - switch (old.type) { - case 1: // MappingType.ValueToText: - if (old.value != null) { - if (old.value === 'null') { - newMappings.push({ - type: MappingType.SpecialValue, - options: { - match: SpecialValueMatch.Null, - result: { text: old.text, color }, - }, - }); - } else { - valueMaps.options[String(old.value)] = { - text: old.text, - color, - }; - } - } - break; - case 2: // MappingType.RangeToText: - newMappings.push({ - type: MappingType.RangeToText, - options: { - from: +old.from, - to: +old.to, - result: { text: old.text, color }, - }, - }); - break; - } - } - - if (Object.keys(valueMaps.options).length > 0) { - newMappings.unshift(valueMaps); - } - - return newMappings; -} - -function migrateTooltipOptions(panel: any) { - if (panel.type === 'timeseries' || panel.type === 'xychart') { - if (panel.options.tooltipOptions) { - panel.options = { - ...panel.options, - tooltip: panel.options.tooltipOptions, - }; - delete panel.options.tooltipOptions; - } - } - - return panel; -} - -class RowArea { - area: number[]; - yPos: number; - height: number; - - constructor(height: number, width = GRID_COLUMN_COUNT, rowYPos = 0) { - this.area = new Array(width).fill(0); - this.yPos = rowYPos; - this.height = height; - } - - reset() { - this.area.fill(0); - } - - /** - * Update area after adding the panel. - */ - addPanel(gridPos: any) { - for (let i = gridPos.x; i < gridPos.x + gridPos.w; i++) { - if (!this.area[i] || gridPos.y + gridPos.h - this.yPos > this.area[i]) { - this.area[i] = gridPos.y + gridPos.h - this.yPos; - } - } - return this.area; - } - - /** - * Calculate position for the new panel in the row. - */ - getPanelPosition(panelHeight: number, panelWidth: number, callOnce = false): any { - let startPlace, endPlace; - let place; - for (let i = this.area.length - 1; i >= 0; i--) { - if (this.height - this.area[i] > 0) { - if (endPlace === undefined) { - endPlace = i; - } else { - if (i < this.area.length - 1 && this.area[i] <= this.area[i + 1]) { - startPlace = i; - } else { - break; - } - } - } else { - break; - } - } - - if (startPlace !== undefined && endPlace !== undefined && endPlace - startPlace >= panelWidth - 1) { - const yPos = _.max(this.area.slice(startPlace)); - place = { - x: startPlace, - y: yPos, - }; - } else if (!callOnce) { - // wrap to next row - this.yPos += this.height; - this.reset(); - return this.getPanelPosition(panelHeight, panelWidth, true); - } else { - return null; - } - - return place; - } -} - -const isMulti = (model: any): model is any => { - return 'multi' in model; -}; - -const alignCurrentWithMulti = (current: any, value: boolean): any => { - if (!current) { - return current; - } - - if (value && !Array.isArray(current.value)) { - return { - ...current, - value: convertToMulti(current.value), - text: convertToMulti(current.text), - }; - } - - if (!value && Array.isArray(current.value)) { - return { - ...current, - value: convertToSingle(current.value), - text: convertToSingle(current.text), - }; - } - - return current; -}; - -const convertToSingle = (value: string | string[]): string => { - if (!Array.isArray(value)) { - return value; - } - - if (value.length > 0) { - return value[0]; - } - - return ''; -}; - -const convertToMulti = (value: string | string[]): string[] => { - if (Array.isArray(value)) { - return value; - } - return [value]; -}; - -const isConstant = (model: any): model is any => { - return model.type === 'constant'; -}; - -function getActiveThreshold(value: number, thresholds: any[] | undefined): any { - if (!thresholds || thresholds.length === 0) { - return { value: 0, color: 'gray' }; - } - - let active = thresholds[0]; - - for (const threshold of thresholds) { - if (value >= threshold.value) { - active = threshold; - } else { - break; - } - } - - return active; -} - -function migrateSinglestat(panel: any) { - panel.options = { - colorMode: 'value', - graphMode: 'none', - justifyMode: 'auto', - orientation: 'horizontal', - reduceOptions: { - calcs: ['lastNotNull'], - fields: '', - values: false, - }, - textMode: 'auto', - }; - const thresholds = _.split(panel.thresholds, ','); - if (_.compact(thresholds)?.length > 0) { - panel.fieldConfig = { - defaults: { - color: { - mode: 'thresholds', - }, - thresholds: { - mode: 'absolute', - steps: _.concat( - [ - { - color: panel.colors?.[0] || colors[0], - value: null as any, - }, - ], - _.map(thresholds, (threshold, idx) => { - const colorIndex = idx + 1; - return { - color: panel.colors?.[colorIndex] || colors[colorIndex % colors.length], - value: _.toNumber(threshold), - }; - }), - ), - }, - unit: panel.format, - }, - }; - } - if ((panel as any).gauge?.show) { - panel.type = 'gauge'; - } else { - panel.type = 'stat'; - } - return panel; -} diff --git a/src/pages/dashboard/utils/grafanaImport/convert.test.ts b/src/pages/dashboard/utils/grafanaImport/convert.test.ts new file mode 100644 index 0000000000..e556ce60fe --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/convert.test.ts @@ -0,0 +1,548 @@ +import { convertDashboardGrafanaToN9E, convertDashboardGrafanaToN9EWithReport } from './convert'; +import { formatDatasource } from '../../Variables/utils/formatString'; +import { validateDashboardConfig } from '../validateDashboardConfig'; +import dashboardMigrator from '../../Detail/utils/dashboardMigrator'; + +// plus: 在 jest 下是存根,这里虚拟 mock addPrefixToTableNames 验证 postgres 接线 +jest.mock( + 'plus:/utils/convertDashboardGrafanaToN9E/addPrefixToTableNames', + () => ({ + __esModule: true, + default: (sql: string) => `PREFIXED:${sql}`, + }), + { virtual: true }, +); + +describe('convertDashboardGrafanaToN9EWithReport', () => { + it('converts a modern prometheus dashboard to true 4.0.0', () => { + const input = { + schemaVersion: 42, + title: 'Prom Demo', + tags: ['a', 'b'], + panels: [ + { + id: 1, + type: 'timeseries', + title: 'CPU', + gridPos: { h: 8, w: 12, x: 0, y: 0 }, + datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }, + targets: [{ refId: 'A', expr: 'up', legendFormat: '{{job}}', instant: false }], + fieldConfig: { + defaults: { + unit: 'percent', + min: 0, + max: 100, + decimals: 2, + thresholds: { + mode: 'absolute', + steps: [ + { color: 'green', value: null }, + { color: 'red', value: 80 }, + ], + }, + custom: { drawStyle: 'line', lineInterpolation: 'smooth', fillOpacity: 50, stacking: { mode: 'normal' } }, + }, + }, + }, + ], + templating: { list: [{ name: 'DS_PROMETHEUS', type: 'datasource', query: 'prometheus', hide: 0 }] }, + }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + + expect(dashboard.name).toBe('Prom Demo'); + expect(dashboard.tags).toBe('a b'); + expect(dashboard.configs.version).toBe('4.0.0'); + expect(dashboard.configs.graphTooltip).toBe('default'); + expect(dashboard.configs.graphZoom).toBe('default'); + + const panel = dashboard.configs.panels[0]; + expect(panel.id).toBe('1'); + expect(panel.layout.i).toBe('1'); + expect(panel.type).toBe('timeseries'); + expect(panel.datasourceCate).toBe('prometheus'); + expect(panel.datasourceValue).toBe('${DS_PROMETHEUS}'); + expect(panel.targets[0]).toMatchObject({ + refId: 'A', + expr: 'up', + legend: '{{job}}', + kind: 'query', + resultType: 'time_series', + }); + // 单一数据源 → target 不带 datasource + expect(panel.targets[0].datasource).toBeUndefined(); + // unit 用 unit(非 util typo)、thresholds 颜色转 hex、base 标记 + expect(panel.options.standardOptions.unit).toBe('percent'); + expect(panel.options.thresholds.steps[0].color).toBe('#73BF69'); + expect(panel.options.thresholds.steps[0].type).toBe('base'); + // custom + expect(panel.custom.drawStyle).toBe('lines'); + expect(panel.custom.fillOpacity).toBe(0.5); + expect(panel.custom.stack).toBe('normal'); + // 变量:templating datasource 变量保留 + expect(dashboard.configs.var).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'DS_PROMETHEUS', type: 'datasource', definition: 'prometheus' })])); + // 无 unsupported + expect(report.unsupportedItems).toEqual([]); + }); + + it('keeps prometheus query conditions (expr unchanged) and emits queryable target', () => { + const input = { + schemaVersion: 42, + panels: [ + { id: 2, type: 'timeseries', title: 'Q', gridPos: { h: 6, w: 12, x: 0, y: 0 }, datasource: null, targets: [{ refId: 'A', expr: 'rate(up[5m])', legendFormat: '{{job}}' }] }, + ], + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const target = dashboard.configs.panels[0].targets[0]; + expect(target.expr).toBe('rate(up[5m])'); + expect(target.legend).toBe('{{job}}'); + expect(target.kind).toBe('query'); + expect(target.resultType).toBe('time_series'); + }); + + it('keeps unsupported panel types as unknown (renderer shows placeholder) and reports downgrade', () => { + const input = { schemaVersion: 42, panels: [{ id: 3, type: 'alertlist', title: 'alerts', gridPos: { h: 4, w: 12, x: 0, y: 0 } }] }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + // 面板保留为 unknown 类型,而不是被丢弃 + expect(dashboard.configs.panels).toHaveLength(1); + expect(dashboard.configs.panels[0].type).toBe('unknown'); + expect(dashboard.configs.panels[0].id).toBe('3'); + expect(report.unsupportedItems).toEqual(expect.arrayContaining([expect.objectContaining({ scope: 'panel', action: 'downgraded', path: '$.panels[id=3]' })])); + }); + + it('handles mixed datasource panels with target-level datasource', () => { + const input = { + schemaVersion: 42, + panels: [ + { + id: 4, + type: 'timeseries', + title: 'M', + gridPos: { h: 6, w: 12, x: 0, y: 0 }, + datasource: { uid: '-- Mixed --' }, + targets: [ + { refId: 'A', expr: 'up', datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' } }, + { refId: 'B', expr: 'down', datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS_2}' } }, + ], + }, + ], + templating: { + list: [ + { name: 'DS_PROMETHEUS', type: 'datasource', query: 'prometheus', hide: 0 }, + { name: 'DS_PROMETHEUS_2', type: 'datasource', query: 'prometheus', hide: 0 }, + ], + }, + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const panel = dashboard.configs.panels[0]; + expect(panel.datasourceCate).toBe('mixed'); + expect(panel.datasourceValue).toBe('mixed'); + expect(panel.targets[0].datasource).toEqual({ cate: 'prometheus', id: '${DS_PROMETHEUS}' }); + expect(panel.targets[1].datasource).toEqual({ cate: 'prometheus', id: '${DS_PROMETHEUS_2}' }); + }); + + it('resolves $datasource to the first __inputs datasource variable', () => { + const input = { + schemaVersion: 42, + __inputs: [{ name: 'DS_PROMETHEUS', type: 'datasource', pluginId: 'prometheus' }], + panels: [{ id: 5, type: 'stat', title: 'S', gridPos: { h: 4, w: 6, x: 0, y: 0 }, datasource: '$datasource', targets: [{ refId: 'A', expr: 'up' }] }], + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const panel = dashboard.configs.panels[0]; + expect(panel.datasourceCate).toBe('prometheus'); + expect(panel.datasourceValue).toBe('${DS_PROMETHEUS}'); + expect(dashboard.configs.var).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'DS_PROMETHEUS', type: 'datasource', definition: 'prometheus' })])); + }); + + it('creates separate datasource variables per cate for prometheus + postgres mix', () => { + const input = { + schemaVersion: 42, + panels: [ + { id: 6, type: 'timeseries', title: 'P', gridPos: { h: 6, w: 12, x: 0, y: 0 }, datasource: { type: 'prometheus', uid: 'abc' }, targets: [{ refId: 'A', expr: 'up' }] }, + { id: 7, type: 'timeseries', title: 'PG', gridPos: { h: 6, w: 12, x: 0, y: 12 }, datasource: { type: 'postgres' }, targets: [{ refId: 'A', rawSql: 'select 1 from t' }] }, + ], + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const dsVars = dashboard.configs.var.filter((v: any) => v.type === 'datasource'); + expect(dsVars.map((v: any) => v.definition)).toEqual(expect.arrayContaining(['prometheus', 'pgsql'])); + const promPanel = dashboard.configs.panels.find((p: any) => p.id === '6'); + const pgsqlPanel = dashboard.configs.panels.find((p: any) => p.id === '7'); + expect(promPanel.datasourceCate).toBe('prometheus'); + expect(promPanel.datasourceValue).toBe('${datasource}'); + expect(pgsqlPanel.datasourceCate).toBe('pgsql'); + expect(pgsqlPanel.datasourceValue).toBe('${datasource_pgsql}'); + expect(pgsqlPanel.targets[0]).toMatchObject({ kind: 'query', resultType: 'time_series' }); + expect(pgsqlPanel.targets[0].query.sql).toBe('PREFIXED:select 1 from t'); + }); + + it('drops the All option (downgraded) when includeAll is set without multi, keeping single-select', () => { + const input = { + schemaVersion: 42, + templating: { + list: [ + { name: 'DS_PROMETHEUS', type: 'datasource', query: 'prometheus', hide: 0 }, + { + name: 'h', + type: 'query', + datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }, + query: 'label_values(up, host)', + includeAll: true, + multi: false, + regex: '', + hide: 0, + }, + ], + }, + panels: [], + }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + const qvar = dashboard.configs.var.find((v: any) => v.name === 'h'); + // 保持单选,移除 All 选项 + expect(qvar.allOption).toBe(false); + expect(qvar.multi).toBe(false); + expect(qvar.datasource).toEqual({ cate: 'prometheus', value: '${DS_PROMETHEUS}' }); + const item = report.unsupportedItems.find((i) => i.scope === 'variable' && i.path === '$.templating.list[name=h]'); + expect(item?.action).toBe('downgraded'); + }); + + it('round-trips datasource reference through the runtime formatter', () => { + const input = { + schemaVersion: 42, + panels: [{ id: 8, type: 'timeseries', title: 'R', gridPos: { h: 6, w: 12, x: 0, y: 0 }, datasource: null, targets: [{ refId: 'A', expr: 'up' }] }], + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const panel = dashboard.configs.panels[0]; + expect(panel.datasourceValue).toBe('${datasource}'); + // 运行时 replaceDatasourceVariables → formatDatasource:变量值(数字 id) 替换后转 number + expect(formatDatasource(panel.datasourceValue, { datasource: 7 })).toBe(7); + }); + + it('keeps exported dashboard shape for the legacy entry', () => { + const dashboard = convertDashboardGrafanaToN9E({ + schemaVersion: 42, + title: 'T', + panels: [{ id: 1, type: 'timeseries', gridPos: { h: 4, w: 12, x: 0, y: 0 }, targets: [{ refId: 'A', expr: 'up' }] }], + }); + expect(dashboard.name).toBe('T'); + expect(dashboard.configs.version).toBe('4.0.0'); + expect(dashboard.configs.panels[0].targets[0]).toMatchObject({ kind: 'query', resultType: 'time_series' }); + }); + + it('rejects non-classic payloads', () => { + expect(() => convertDashboardGrafanaToN9E({ elements: {}, layout: {} })).toThrow(); + expect(() => convertDashboardGrafanaToN9E({})).toThrow(); + }); + + it('falls back to default datasource variable when templated uid has no matching variable', () => { + const input = { + schemaVersion: 42, + panels: [{ id: 9, type: 'timeseries', gridPos: { h: 4, w: 12, x: 0, y: 0 }, datasource: { type: 'prometheus', uid: '${DS_PROM}' }, targets: [{ refId: 'A', expr: 'up' }] }], + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const panel = dashboard.configs.panels[0]; + expect(panel.datasourceValue).toBe('${datasource}'); + expect(dashboard.configs.var).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'datasource', type: 'datasource', definition: 'prometheus' })])); + }); + + it('handles datasource type with different casing (e.g. query "Prometheus") and keeps targets', () => { + const input = { + schemaVersion: 42, + templating: { list: [{ name: 'DS_PROMETHEUS', type: 'datasource', query: 'Prometheus', hide: 0 }] }, + panels: [ + { id: 10, type: 'timeseries', gridPos: { h: 4, w: 12, x: 0, y: 0 }, datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }, targets: [{ refId: 'A', expr: 'up' }] }, + ], + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const panel = dashboard.configs.panels[0]; + expect(panel.targets).toHaveLength(1); + expect(panel.datasourceCate).toBe('prometheus'); + expect(panel.datasourceValue).toBe('${DS_PROMETHEUS}'); + expect(dashboard.configs.var.find((v: any) => v.name === 'DS_PROMETHEUS').definition).toBe('prometheus'); + }); + + it('end-to-end: migrates a legacy schema 6 fixture to 42 and converts to N9E', () => { + const input = { + title: 'Core migration fixture', + tags: ['ops', 'schema42'], + schemaVersion: 6, + sharedCrosshair: true, + refresh: true, + timepicker: { time_options: ['5m'] }, + templating: { + list: [{ name: 'job', type: 'filter', query: 'label_values(up, job)', hideVariable: true, allFormat: null, tags: ['legacy'], refresh: false, options: [{ text: 'old' }] }], + }, + panels: [ + { + id: 7, + type: 'timeseries', + title: 'Up', + gridPos: { h: 8, w: 12, x: 0, y: 0 }, + datasource: { type: 'prometheus', uid: '${DS_PROM}' }, + targets: [{ refId: 'A', expr: 'up', legendFormat: '{{instance}}' }], + fieldConfig: { defaults: { unit: 'bits', thresholds: { mode: 'absolute', steps: [{ color: 'green', value: null }] }, mappings: [] } }, + options: { legend: { displayMode: 'hidden' } }, + transformations: [{ id: 'organize', options: {} }], + }, + ], + }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + const configs = dashboard.configs; + + expect(dashboard.tags).toBe('ops schema42'); + expect(configs.graphTooltip).toBe('default'); + expect(configs.version).toBe('4.0.0'); + + const panel = configs.panels[0]; + expect(panel.id).toBe('7'); + expect(panel.datasourceCate).toBe('prometheus'); + // ${DS_PROM} 无对应变量 → 回退默认 prom 数据源变量 + expect(panel.datasourceValue).toBe('${datasource}'); + expect(panel.targets[0]).toMatchObject({ refId: 'A', expr: 'up', legend: '{{instance}}', kind: 'query', resultType: 'time_series' }); + expect(panel.options.standardOptions.unit).toBe('bitsIEC'); + expect(panel.options.legend).toEqual({ displayMode: 'hidden', placement: 'bottom' }); + + // 变量:schemaVersion 6 时 v6 迁移(filter→query)不生效,filter 变量被丢弃并报告; + // 兜底 prom 数据源变量存在 + expect(configs.var.find((v: any) => v.name === 'job')).toBeUndefined(); + expect(configs.var.some((v: any) => v.type === 'datasource' && v.definition === 'prometheus')).toBe(true); + expect(report.unsupportedItems.some((i) => i.scope === 'variable' && i.action === 'dropped')).toBe(true); + + // transformations 被报告(scope custom + 面板 path) + expect(report.unsupportedItems.some((i) => i.scope === 'custom' && i.path === '$.panels[id=7]')).toBe(true); + }); + + it('output is valid 4.0.0 and needs no dashboardMigrator migration (no implicit fallback)', () => { + const input = { + schemaVersion: 42, + title: 'RT', + panels: [ + { + id: 1, + type: 'timeseries', + title: 'P1', + gridPos: { h: 8, w: 12, x: 0, y: 0 }, + datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }, + targets: [{ refId: 'A', expr: 'up', legendFormat: '{{job}}' }], + }, + { + id: 2, + type: 'row', + title: 'Row', + collapsed: true, + gridPos: { h: 1, w: 24, x: 0, y: 8 }, + panels: [{ id: 3, type: 'stat', title: 'S', gridPos: { h: 4, w: 6, x: 0, y: 9 }, targets: [{ refId: 'A', expr: 'down' }] }], + }, + ], + templating: { list: [{ name: 'DS_PROMETHEUS', type: 'datasource', query: 'prometheus', hide: 0 }] }, + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const configs = dashboard.configs; + + const validation = validateDashboardConfig(configs); + expect(validation.valid).toBe(true); + + const migrated = dashboardMigrator(configs); + expect(migrated.version).toBe('4.0.0'); + // 已是真正的 4.0.0(target 带 kind/resultType、非 mixed 面板级 datasource),不应再发生迁移 + expect(migrated.panels).toEqual(configs.panels); + }); + + it('keeps variables visible when hide is undefined or 1, hides only when hide === 2', () => { + const input = { + schemaVersion: 42, + templating: { + list: [ + { name: 'a', type: 'query', datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }, query: 'label_values(up, a)' }, + { name: 'b', type: 'query', datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }, query: 'label_values(up, b)', hide: 1 }, + { name: 'c', type: 'query', datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }, query: 'label_values(up, c)', hide: 2 }, + { name: 'DS_PROMETHEUS', type: 'datasource', query: 'prometheus' }, + ], + }, + panels: [], + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const byName = (n: string) => dashboard.configs.var.find((v: any) => v.name === n); + expect(byName('a').hide).toBe(false); + expect(byName('b').hide).toBe(false); + expect(byName('c').hide).toBe(true); + expect(byName('DS_PROMETHEUS').hide).toBe(false); + }); + + it('fills default value for datasource variables from the provided datasource list', () => { + const input = { + schemaVersion: 42, + templating: { list: [{ name: 'DS_PROMETHEUS', type: 'datasource', query: 'prometheus' }] }, + panels: [ + { id: 1, type: 'timeseries', gridPos: { h: 4, w: 12, x: 0, y: 0 }, datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }, targets: [{ refId: 'A', expr: 'up' }] }, + ], + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input, { + datasourceList: { + prometheus: [ + { id: 11, is_default: true }, + { id: 22, is_default: false }, + ], + }, + }); + const dsVar = dashboard.configs.var.find((v: any) => v.name === 'DS_PROMETHEUS'); + expect(dsVar.defaultValue).toBe(11); + }); + + it('reports unsupported items with JSON path', () => { + const input = { + schemaVersion: 42, + panels: [ + { + id: 1, + type: 'timeseries', + gridPos: { h: 4, w: 12, x: 0, y: 0 }, + datasource: { type: 'prometheus' }, + targets: [{ refId: 'A', expr: 'up' }], + fieldConfig: { defaults: { unit: 'currencyUSD' } }, + }, + ], + }; + const { report } = convertDashboardGrafanaToN9EWithReport(input); + const unitItem = report.unsupportedItems.find((i) => i.action === 'downgraded'); + expect(unitItem).toBeDefined(); + expect(unitItem?.path).toBe('$.panels[id=1]'); + }); + + it('does not report downgrade for unit "none" (N9E supports it)', () => { + const input = { + schemaVersion: 42, + panels: [ + { + id: 1, + type: 'timeseries', + gridPos: { h: 4, w: 12, x: 0, y: 0 }, + datasource: { type: 'prometheus' }, + targets: [{ refId: 'A', expr: 'up' }], + fieldConfig: { defaults: { unit: 'none' } }, + }, + ], + }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + expect(dashboard.configs.panels[0].options.standardOptions.unit).toBe('none'); + expect(report.unsupportedItems.some((i) => i.scope === 'option' && i.action === 'downgraded')).toBe(false); + }); + + it('downgrades percent stacking to off with a clear report', () => { + const input = { + schemaVersion: 42, + panels: [ + { + id: 1, + type: 'timeseries', + gridPos: { h: 4, w: 12, x: 0, y: 0 }, + datasource: { type: 'prometheus' }, + targets: [{ refId: 'A', expr: 'up' }], + fieldConfig: { defaults: { custom: { stacking: { mode: 'percent' } } } }, + }, + ], + }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + expect(dashboard.configs.panels[0].custom.stack).toBe('off'); + const item = report.unsupportedItems.find((i) => i.scope === 'option' && i.action === 'downgraded'); + expect(item).toBeDefined(); + expect(item?.path).toBe('$.panels[id=1]'); + expect(item?.reason).toContain('百分比'); + }); + + it('drops all overrides with a single report entry and empty overrides output', () => { + const input = { + schemaVersion: 42, + panels: [ + { + id: 1, + type: 'timeseries', + gridPos: { h: 4, w: 12, x: 0, y: 0 }, + datasource: { type: 'prometheus' }, + targets: [{ refId: 'A', expr: 'up' }], + fieldConfig: { + overrides: [ + { matcher: { id: 'byName', value: 'x' }, properties: [{ id: 'unit', value: 'percent' }] }, + { matcher: { id: 'byFrameRefID', value: 'A' }, properties: [{ id: 'width', value: 120 }] }, + ], + }, + }, + ], + }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + expect(dashboard.configs.panels[0].overrides).toEqual([]); + const overrideItems = report.unsupportedItems.filter((i) => i.scope === 'custom' && i.path === '$.panels[id=1]' && i.reason.includes('overrides')); + expect(overrideItems).toHaveLength(1); + }); + + it('drops dashboards-type links with a single aggregate report', () => { + const input = { + schemaVersion: 42, + title: 'L', + links: [ + { type: 'link', title: 'A', url: 'http://a' }, + { type: 'dashboards', title: 'OS', tags: ['OS'] }, + { type: 'dashboards', title: 'MySQL', tags: ['MySQL'] }, + ], + panels: [], + }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + // 仅普通链接保留,dashboards 链接丢弃 + expect(dashboard.configs.links).toHaveLength(1); + expect(dashboard.configs.links[0]).toMatchObject({ type: 'link', title: 'A', url: 'http://a' }); + // dashboards 类型整体只报告一条(不按条数逐条报) + const linkItems = report.unsupportedItems.filter((i) => i.path === '$.links' && i.reason.includes('dashboards')); + expect(linkItems).toHaveLength(1); + expect(linkItems[0].action).toBe('dropped'); + }); + + it('keeps panel links without a type field (DataLink style)', () => { + const input = { + schemaVersion: 42, + panels: [ + { + id: 13, + type: 'timeseries', + gridPos: { h: 4, w: 12, x: 0, y: 0 }, + datasource: { type: 'prometheus' }, + targets: [{ refId: 'A', expr: 'up' }], + links: [{ title: 'Drill', url: '/dashboards/1' }], + }, + ], + }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + expect(dashboard.configs.panels[0].links).toEqual([{ type: 'link', title: 'Drill', url: '/dashboards/1' }]); + expect(report.unsupportedItems.some((i) => i.path === '$.panels[id=13].links[0]')).toBe(false); + }); + + it('drops unknown link types with indexed paths', () => { + const input = { + schemaVersion: 42, + title: 'L', + links: [ + { type: 'link', title: 'A', url: 'http://a' }, + { type: 'weird', title: 'B' }, + ], + panels: [], + }; + const { dashboard, report } = convertDashboardGrafanaToN9EWithReport(input); + expect(dashboard.configs.links).toHaveLength(1); + const dropped = report.unsupportedItems.filter((i) => i.scope === 'custom' && i.action === 'dropped'); + expect(dropped).toHaveLength(1); + expect(dropped[0].path).toBe('$.links[1]'); + }); + + it('deduplicates datasource variables declared in both __inputs and templating', () => { + const input = { + schemaVersion: 42, + __inputs: [{ name: 'DS_PROMETHEUS', type: 'datasource', pluginId: 'prometheus' }], + templating: { list: [{ name: 'DS_PROMETHEUS', type: 'datasource', query: 'prometheus', hide: 0 }] }, + panels: [ + { id: 1, type: 'timeseries', gridPos: { h: 4, w: 12, x: 0, y: 0 }, datasource: { type: 'prometheus', uid: '${DS_PROMETHEUS}' }, targets: [{ refId: 'A', expr: 'up' }] }, + ], + }; + const { dashboard } = convertDashboardGrafanaToN9EWithReport(input); + const dsVars = dashboard.configs.var.filter((v: any) => v.type === 'datasource' && v.name === 'DS_PROMETHEUS'); + expect(dsVars).toHaveLength(1); + // 面板仍引用该变量 + expect(dashboard.configs.panels[0].datasourceValue).toBe('${DS_PROMETHEUS}'); + }); +}); diff --git a/src/pages/dashboard/utils/grafanaImport/convert.ts b/src/pages/dashboard/utils/grafanaImport/convert.ts new file mode 100644 index 0000000000..cd289943e3 --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/convert.ts @@ -0,0 +1,115 @@ +/* + * Grafana dashboard → N9E dashboard 转换公共入口。 + * + * convertDashboardGrafanaToN9E —— 返回 N9E dashboard JSON(兼容既有调用方) + * convertDashboardGrafanaToN9EWithReport —— 返回 { dashboard, report }(导入页两步式展示用) + */ +import _ from 'lodash'; + +import { migrateDashboardSchema, DASHBOARD_SCHEMA_VERSION } from './migrate'; +import { convertVariablesGrafanaToN9E, type GroupedDatasourceList } from './variables'; +import { convertPanelsGrafanaToN9E } from './panels'; +import { convertLinksGrafanaToN9E, type ReportFn } from './options'; +import { DASHBOARD_VERSION, type ConvertResult, type UnsupportedItem } from './types'; + +/** 转换选项:datasourceList 用于给数据源变量补充默认值 */ +export interface ConvertOptions { + datasourceList?: GroupedDatasourceList; +} + +/** 校验输入是否是可转换的 classic dashboard */ +function validateDashboard(source: unknown): asserts source is Record { + if (typeof source !== 'object' || source === null) { + throw new Error('非法的 Grafana dashboard JSON,请输入 classic dashboard 配置'); + } + const obj = source as Record; + if ('elements' in obj || 'layout' in obj) { + throw new Error('暂不支持 Grafana v2 的 elements/layout 结构,仅支持 classic panels 结构'); + } + if (!Array.isArray(obj.panels)) { + throw new Error('缺少 panels 数组,不是可导入的 classic Grafana dashboard'); + } +} + +/** 转换并返回报告(含迁移台账与不支持项) */ +export function convertDashboardGrafanaToN9EWithReport(source: unknown, options?: ConvertOptions): ConvertResult { + validateDashboard(source); + // grafana 导出常为 { dashboard: {...} } 包裹,解包后处理 + const root: Record = 'dashboard' in (source as any) && _.isPlainObject((source as any).dashboard) ? (source as any).dashboard : (source as Record); + + const unsupportedItems: UnsupportedItem[] = []; + const report: ReportFn = (item) => { + unsupportedItems.push({ + scope: item.scope, + path: item.path ?? '', + action: item.action, + reason: item.reason, + }); + }; + + // 1. schema 迁移(到 42) + const { dashboard, migrations } = migrateDashboardSchema(root); + const inputSchemaVersion = typeof root?.schemaVersion === 'number' ? root.schemaVersion : 0; + + // 2. 映射 + const { vars, context } = convertVariablesGrafanaToN9E(dashboard, report, { datasourceList: options?.datasourceList }); + const links = convertLinksGrafanaToN9E(dashboard.links, report, '$.links'); + const panels = convertPanelsGrafanaToN9E(dashboard.panels, context, report); + + // 3. 组装输出(真 4.0.0 结构) + const tags = Array.isArray(dashboard.tags) ? dashboard.tags.filter((t): t is string => typeof t === 'string').join(' ') : ''; + const output = { + name: dashboard.title ?? '', + tags, + configs: { + version: DASHBOARD_VERSION, + links, + var: vars, + panels, + graphTooltip: 'default' as const, + graphZoom: 'default' as const, + }, + }; + + // 4. 统计摘要 + // 仅统计真正被丢弃的项(action==='dropped'),downgraded/defaulted 的项仍保留在输出中, + // 不重复计入 dropped,避免与 convertedPanels 双重计数。 + const countDropped = (scope: UnsupportedItem['scope']) => unsupportedItems.filter((i) => i.scope === scope && i.action === 'dropped').length; + const convertedPanels = panels.filter((p) => p.type !== 'row').length; + const droppedPanels = countDropped('panel'); + const summary = { + panels: convertedPanels + droppedPanels, + convertedPanels, + droppedPanels, + targets: panels.reduce((sum, p) => sum + (Array.isArray(p.targets) ? p.targets.length : 0), 0), + droppedTargets: countDropped('target'), + variables: vars.length, + droppedVariables: countDropped('variable'), + }; + + return { + dashboard: output, + report: { + migration: { + inputSchemaVersion, + targetSchemaVersion: DASHBOARD_SCHEMA_VERSION, + migrations, + }, + unsupportedItems, + summary, + }, + }; +} + +/** 转换 Grafana dashboard → N9E dashboard JSON(不含报告,兼容既有调用方) */ +export function convertDashboardGrafanaToN9E(data: unknown, options?: ConvertOptions): any { + return convertDashboardGrafanaToN9EWithReport(data, options).dashboard; +} + +/** + * 检测 Grafana dashboard 版本。 + * 迁移已覆盖 <42 的全部 classic 版本,统一放行(保留导出兼容既有调用方)。 + */ +export function checkGrafanaDashboardVersion(_data: unknown): number { + return 2; +} diff --git a/src/pages/dashboard/utils/grafanaImport/datasource.ts b/src/pages/dashboard/utils/grafanaImport/datasource.ts new file mode 100644 index 0000000000..0b0f1c7bf6 --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/datasource.ts @@ -0,0 +1,127 @@ +/* + * Grafana datasource 引用 → N9E datasourceCate / datasourceValue 的解析。 + * + * N9E 运行时解析模型:面板 datasourceValue(或 target.datasource.id)必须是数字 id 或 `${变量名}`, + * 经 replaceDatasourceVariables 解析为数字 id,失败则查询被跳过;type:'datasource' 变量的 definition=cate, + * 运行时自动从对应 cate 的数据源列表生成选项并选中。grafana uid 无法映射到 N9E 数字 id, + * 因此一律归一化为变量引用,绝不输出字面 uid。 + */ +import _ from 'lodash'; + +import type { GrafanaPanel } from './types'; + +export interface DatasourceVarRef { + name: string; + cate: string; +} + +export interface ResolvedDatasource { + cate: string; + value: string; // '${变量名}' + supported: boolean; +} + +/** 当前转换支持的数据源 cate(prometheus + postgres) */ +export const SUPPORTED_CATES = ['prometheus', 'pgsql']; + +export function isSupportedCate(cate: string): boolean { + return SUPPORTED_CATES.includes(cate); +} + +/** grafana 插件 type → N9E cate(大小写不敏感) */ +export function normalizeCate(type: unknown): string { + if (typeof type !== 'string' || !type) return 'unknown'; + const t = type.trim().toLowerCase(); + if (t === 'postgres' || t === 'postgresql') return 'pgsql'; + if (t === 'prometheus') return 'prometheus'; + return t; +} + +/** 取某 cate 的默认数据源变量引用 */ +function defaultVarRefFor(cate: string, vars: DatasourceVarRef[]): string { + const found = vars.find((v) => v.cate === cate); + return found ? `\${${found.name}}` : '${datasource}'; +} + +export interface ResolveContext { + /** 已生成的数据源变量(name + cate) */ + datasourceVars: DatasourceVarRef[]; + /** $datasource 解析时优先使用的 __inputs 数据源变量名 */ + inputsFallbackName?: string; +} + +/** 解析单个 grafana datasource 引用(panel.datasource / target.datasource) */ +export function resolveDatasourceRef(ds: unknown, ctx: ResolveContext): ResolvedDatasource { + const vars = ctx.datasourceVars; + const promRef = defaultVarRefFor('prometheus', vars); + + if (_.isPlainObject(ds)) { + const type = (ds as any).type; + const uid = (ds as any).uid; + const cate = normalizeCate(type); + // templated uid:${DS_X} → 若 vars 中存在 cate 可识别的同名数据源变量则复用该引用; + // 否则(变量不存在 / cate 不可识别)回退该 cate 默认变量,避免悬空引用或误判丢弃 + if (typeof uid === 'string' && /^\$\{([^}]+)\}$/.test(uid)) { + const varName = uid.slice(2, -1); + const dsVar = vars.find((v) => v.name === varName); + if (dsVar && isSupportedCate(dsVar.cate)) { + return { cate: dsVar.cate, value: uid, supported: true }; + } + return { cate, value: defaultVarRefFor(cate, vars), supported: isSupportedCate(cate) }; + } + // 字面 uid 或无 uid → 引用该 cate 的默认变量 + return { cate, value: defaultVarRefFor(cate, vars), supported: isSupportedCate(cate) }; + } + + if (typeof ds === 'string') { + const trimmed = ds.trim(); + if (trimmed === '$datasource' || trimmed === '${datasource}') { + // grafana 导出的隐藏 constant,优先解析到 __inputs 数据源变量 + const name = ctx.inputsFallbackName || 'datasource'; + const dsVar = vars.find((v) => v.name === name); + const cate = dsVar?.cate ?? 'prometheus'; + return { cate, value: `\${${name}}`, supported: isSupportedCate(cate) }; + } + const match = trimmed.match(/^\$\{([^}]+)\}$/); + if (match) { + const dsVar = vars.find((v) => v.name === match[1]); + const cate = dsVar?.cate ?? 'prometheus'; + return { cate, value: trimmed, supported: isSupportedCate(cate) }; + } + if (trimmed.startsWith('$')) { + const varName = trimmed.slice(1); + const dsVar = vars.find((v) => v.name === varName); + const cate = dsVar?.cate ?? 'prometheus'; + return { cate, value: `\${${varName}}`, supported: isSupportedCate(cate) }; + } + // 旧名字符串(如 'Prometheus',< v33 未迁移)→ 默认 prom 数据源变量引用 + return { cate: 'prometheus', value: promRef, supported: true }; + } + + // null / undefined → 默认 prom 数据源变量引用 + return { cate: 'prometheus', value: promRef, supported: true }; +} + +/** 收集面板中实际使用的数据源 cate 集合(用于兜底创建数据源变量) */ +export function collectPanelCates(panels: GrafanaPanel[] | undefined): string[] { + const cates = new Set(); + const visit = (ref: unknown) => { + if (_.isPlainObject(ref)) { + const cate = normalizeCate((ref as any).type); + if (cate !== 'unknown') cates.add(cate); + } + }; + const walk = (list: GrafanaPanel[] | undefined) => { + for (const panel of list || []) { + visit(panel.datasource); + for (const target of panel.targets || []) { + visit(target.datasource); + } + if (panel.type === 'row' && Array.isArray(panel.panels)) { + walk(panel.panels); + } + } + }; + walk(panels); + return Array.from(cates); +} diff --git a/src/pages/dashboard/utils/grafanaImport/index.ts b/src/pages/dashboard/utils/grafanaImport/index.ts new file mode 100644 index 0000000000..3f1a2329a5 --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/index.ts @@ -0,0 +1,15 @@ +/* + * Grafana dashboard 导入转换模块(迁移 + 映射 + 报告)。 + */ +export { + migrateDashboardSchema, + DASHBOARD_SCHEMA_VERSION, + GRID_CELL_HEIGHT, + GRID_CELL_VMARGIN, + GRID_COLUMN_COUNT, + DEFAULT_PANEL_SPAN, + DEFAULT_ROW_HEIGHT, + MIN_PANEL_HEIGHT, +} from './migrate'; +export { convertDashboardGrafanaToN9E, convertDashboardGrafanaToN9EWithReport, checkGrafanaDashboardVersion } from './convert'; +export type { MigrationLedgerEntry, UnsupportedItem, ConversionReport, ConvertResult, MigrateResult, GrafanaDashboard, GrafanaPanel } from './types'; diff --git a/src/pages/dashboard/utils/grafanaImport/migrate.test.ts b/src/pages/dashboard/utils/grafanaImport/migrate.test.ts new file mode 100644 index 0000000000..fb98a2d62a --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/migrate.test.ts @@ -0,0 +1,106 @@ +import { migrateDashboardSchema, DASHBOARD_SCHEMA_VERSION } from './migrate'; + +describe('migrateDashboardSchema', () => { + it('migrates a schema 37 dashboard to 42 and writes back schemaVersion', () => { + const input = { + schemaVersion: 37, + title: 'test', + panels: [ + { + id: 1, + type: 'timeseries', + options: { legend: { displayMode: 'hidden' } }, + fieldConfig: { overrides: [{ properties: [{ id: 'custom.hideFrom', value: { viz: true } }] }] }, + }, + ], + refresh: true, + timepicker: { time_options: ['5m', '15m'] }, + }; + const { dashboard, migrations } = migrateDashboardSchema(input); + + expect(dashboard.schemaVersion).toBe(42); + expect(dashboard.refresh).toBe(''); + expect(dashboard.timepicker.time_options).toBeUndefined(); + // v42:override 中 hideFrom.viz === true → 补 tooltip(与 grafana 源码一致,只处理 overrides) + expect(dashboard.panels[0].fieldConfig.overrides[0].properties[0].value.tooltip).toBe(true); + // 台账覆盖 38-42 + const versions = migrations.map((m) => m.version); + expect(versions).toEqual(expect.arrayContaining([38, 39, 40, 41, 42])); + expect(migrations.find((m) => m.version === 40)?.status).toBe('applied'); + }); + + it('does not mutate the input and is idempotent', () => { + const input: any = { + schemaVersion: 30, + title: 'x', + panels: [{ id: 1, type: 'stat' }], + refresh: false, + }; + const before = JSON.stringify(input); + const first = migrateDashboardSchema(input).dashboard; + expect(JSON.stringify(input)).toBe(before); + const second = migrateDashboardSchema(first).dashboard; + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + }); + + it('migrates v38 displayMode to cellOptions in defaults and overrides', () => { + const input: any = { + schemaVersion: 37, + panels: [ + { + id: 1, + type: 'table', + fieldConfig: { + defaults: { custom: { displayMode: 'color-background' } }, + overrides: [{ properties: [{ id: 'custom.displayMode', value: 'basic' }] }], + }, + }, + ], + }; + const { dashboard } = migrateDashboardSchema(input); + const fieldConfig = dashboard.panels[0].fieldConfig; + expect(fieldConfig.defaults.custom.cellOptions).toEqual({ type: 'color-background', mode: 'gradient' }); + expect(fieldConfig.defaults.custom.displayMode).toBeUndefined(); + expect(fieldConfig.overrides[0].properties[0]).toEqual({ id: 'custom.cellOptions', value: { type: 'gauge', mode: 'basic' } }); + }); + + it('migrates v39 timeSeriesTable refIdToStat', () => { + const input: any = { + schemaVersion: 38, + panels: [ + { + id: 1, + type: 'timeseries', + transformations: [{ id: 'timeSeriesTable', options: { refIdToStat: { A: 'mean', B: 'max' } } }], + }, + ], + }; + const { dashboard } = migrateDashboardSchema(input); + expect(dashboard.panels[0].transformations[0].options).toEqual({ A: { stat: 'mean' }, B: { stat: 'max' } }); + }); + + it('migrates v16 rows to grid layout', () => { + const input: any = { + schemaVersion: 15, + rows: [{ title: 'r1', panels: [{ id: 1, type: 'stat', span: 6, height: '200px' }] }], + }; + const { dashboard } = migrateDashboardSchema(input); + expect(dashboard.rows).toBeUndefined(); + expect(Array.isArray(dashboard.panels)).toBe(true); + expect(dashboard.panels.length).toBeGreaterThan(0); + expect(dashboard.panels[0].gridPos).toBeDefined(); + }); + + it('records skipped migrations for runtime-dependent versions', () => { + const { migrations } = migrateDashboardSchema({ schemaVersion: 0, panels: [] }); + const skipped = migrations.filter((m) => m.status === 'skipped').map((m) => m.version); + expect(skipped).toEqual(expect.arrayContaining([4, 9, 13, 31, 33, 34, 35, 36])); + }); + + it('returns early without changes when already at target version', () => { + const input = { schemaVersion: DASHBOARD_SCHEMA_VERSION, panels: [] }; + const { dashboard, migrations } = migrateDashboardSchema(input); + expect(migrations).toEqual([]); + expect(dashboard.schemaVersion).toBe(42); + }); +}); diff --git a/src/pages/dashboard/utils/grafanaImport/migrate.ts b/src/pages/dashboard/utils/grafanaImport/migrate.ts new file mode 100644 index 0000000000..3a67dbdaa6 --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/migrate.ts @@ -0,0 +1,1074 @@ +/* + * Grafana dashboard schema 迁移(重建自 grafana main public/app/features/dashboard/state/DashboardMigrator.ts)。 + * + * - DASHBOARD_SCHEMA_VERSION = 42 是 grafana v1 dashboard API 的最终 schema 版本。 + * - 迁移按版本升序应用,仅当 oldVersion < version <= target 时执行。 + * - 纯函数:入参被 cloneDeep 保护,不污染原始引用;迁移后写回 schemaVersion = target。 + * - 返回迁移台账(applied / not-applicable / skipped),供导入结果报告展示。 + * + * 离线适配说明(与 grafana 源码的差异): + * - grafana 把部分迁移委托给插件自动迁移(v2 graphite、v4 graph、v9 singlestat、v13 graph、 + * v24 table 自动迁移、v33/v36 datasource service、v34 CloudWatch、v31 transformer、v35 面板插件)。 + * 离线环境没有插件注册表 / datasource service / transformer registry,这类迁移记录为 skipped; + * singlestat(v28)与 table 分类(v24)使用本仓库既有的确定性离线实现。 + */ +import _ from 'lodash'; + +import type { GrafanaDashboard, GrafanaPanel, MigrationLedgerEntry, MigrateResult } from './types'; + +export const DASHBOARD_SCHEMA_VERSION = 42; + +export const GRID_CELL_HEIGHT = 30; +export const GRID_CELL_VMARGIN = 8; +export const GRID_COLUMN_COUNT = 24; +export const DEFAULT_PANEL_SPAN = 4; +export const DEFAULT_ROW_HEIGHT = 250; +export const MIN_PANEL_HEIGHT = GRID_CELL_HEIGHT * 3; + +const defaultColors = ['rgba(245, 54, 54, 0.9)', 'rgba(237, 129, 40, 0.89)', 'rgba(50, 172, 45, 0.97)']; + +enum VariableHide { + dontHide, + hideLabel, + hideVariable, +} +enum MappingType { + ValueToText = 'value', + RangeToText = 'range', + SpecialValue = 'special', +} +enum SpecialValueMatch { + True = 'true', + False = 'false', + Null = 'null', + NaN = 'nan', + NullAndNaN = 'null+nan', + Empty = 'empty', +} + +/** 递归遍历所有面板(含 row 子面板) */ +function* walkPanels(dashboard: GrafanaDashboard): Generator { + for (const panel of _.isArray(dashboard.panels) ? dashboard.panels : []) { + if (!_.isPlainObject(panel)) continue; + yield panel; + if (panel.type === 'row' && _.isArray(panel.panels)) { + yield* walkPanels({ panels: panel.panels } as GrafanaDashboard); + } + } +} + +/** 规范化 templating.list,返回变量数组(空时初始化空数组) */ +function variablesOf(dashboard: GrafanaDashboard): any[] { + if (!_.isPlainObject(dashboard.templating)) { + dashboard.templating = { list: [] }; + } + const templating = dashboard.templating as { list: any[] }; + if (!Array.isArray(templating.list)) { + templating.list = []; + } + return templating.list; +} + +// --------------------------------------------------------------------------- +// 迁移定义 +// --------------------------------------------------------------------------- + +type DashboardMigration = (dashboard: GrafanaDashboard) => boolean; + +interface Migration { + version: number; + /** 迁移函数,返回是否真正产生了修改(用于台账 applied/not-applicable) */ + migrate?: DashboardMigration; + /** 存在该字段表示离线环境跳过,reason 为跳过原因 */ + skippedReason?: string; +} + +const MIGRATIONS: Migration[] = [ + { + version: 2, + migrate(dashboard) { + // grafana 中 graphite 面板由插件自动迁移,离线直接重命名以保留旧面板 + let changed = false; + for (const panel of walkPanels(dashboard)) { + if (panel.type === 'graphite') { + panel.type = 'graph'; + changed = true; + } + } + return changed; + }, + }, + { + version: 3, + migrate(dashboard) { + // grafana 由 DashboardModel.ensurePanelsHaveUniqueIds 分配 id,离线按位置确定性补 id + let changed = false; + let index = 0; + for (const panel of walkPanels(dashboard)) { + if (panel.id === undefined || panel.id === null) { + panel.id = index; + changed = true; + } + index += 1; + } + return changed; + }, + }, + { version: 4, skippedReason: 'graph 面板迁移依赖面板插件注册表,离线跳过(graph 形状在映射阶段处理)' }, + { version: 5, skippedReason: 'grafana 未定义该版本的迁移' }, + { + version: 6, + migrate(dashboard) { + let changed = false; + const annotations = _.find(dashboard.pulldowns, { type: 'annotations' }); + if (annotations) { + dashboard.annotations = { list: annotations.annotations || [] }; + changed = true; + } + for (const variable of variablesOf(dashboard)) { + if (variable.datasource === undefined) { + variable.datasource = null; + changed = true; + } + if (variable.type === 'filter') { + variable.type = 'query'; + changed = true; + } + if (variable.type === undefined) { + variable.type = 'query'; + changed = true; + } + if ('allFormat' in variable) { + delete variable.allFormat; + changed = true; + } + } + return changed; + }, + }, + { + version: 7, + migrate(dashboard) { + if (_.isArray(dashboard.nav) && dashboard.nav.length > 0) { + dashboard.timepicker = dashboard.nav[0]; + return true; + } + return false; + }, + }, + { + version: 8, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + for (const target of _.isArray(panel.targets) ? panel.targets : []) { + if (!_.isPlainObject(target)) continue; + if (target.fields && target.tags && target.groupBy) { + if (target.rawQuery) { + delete target.fields; + delete target.fill; + } else { + target.select = _.map(target.fields, (field: any) => { + const parts: any[] = []; + parts.push({ type: 'field', params: [field.name] }); + parts.push({ type: field.func, params: [] }); + if (field.mathExpr) parts.push({ type: 'math', params: [field.mathExpr] }); + if (field.asExpr) parts.push({ type: 'alias', params: [field.asExpr] }); + return parts; + }); + delete target.fields; + _.forEach(target.groupBy, (part: any) => { + if (part.type === 'time' && part.interval) { + part.params = [part.interval]; + delete part.interval; + } + if (part.type === 'tag' && part.key) { + part.params = [part.key]; + delete part.key; + } + }); + if (target.fill) { + target.groupBy.push({ type: 'fill', params: [target.fill] }); + delete target.fill; + } + } + changed = true; + } + } + } + return changed; + }, + }, + { + version: 9, + skippedReason: 'singlestat 面板迁移依赖面板插件注册表,离线跳过(singlestat 在 v28 有确定性离线实现)', + }, + { + version: 10, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + if (panel.type !== 'table') continue; + for (const style of _.isArray(panel.styles) ? panel.styles : []) { + if (_.isArray(style.thresholds) && style.thresholds.length >= 3) { + style.thresholds = style.thresholds.slice(1); + changed = true; + } + } + } + return changed; + }, + }, + { version: 11, skippedReason: 'grafana 未定义该版本的迁移' }, + { + version: 12, + migrate(dashboard) { + let changed = false; + for (const variable of variablesOf(dashboard)) { + if ('refresh' in variable) { + variable.refresh = variable.refresh ? 1 : 0; + changed = true; + } + if ('hideVariable' in variable && variable.hideVariable) { + variable.hide = 2; + changed = true; + } else if ('hideLabel' in variable && variable.hideLabel) { + variable.hide = 1; + changed = true; + } + } + return changed; + }, + }, + { + version: 13, + skippedReason: 'graph 面板自动迁移(barchart/bargauge/histogram/timeseries)依赖面板插件注册表,离线跳过', + }, + { + version: 14, + migrate(dashboard) { + dashboard.graphTooltip = dashboard.sharedCrosshair ? 1 : 0; + return true; + }, + }, + { version: 15, skippedReason: 'grafana 未定义该版本的迁移' }, + { + version: 16, + migrate(dashboard) { + return upgradeToGridLayout(dashboard); + }, + }, + { + version: 17, + migrate(dashboard) { + let changed = false; + const factors = getFactors(GRID_COLUMN_COUNT); + for (const panel of walkPanels(dashboard)) { + if (panel.minSpan) { + const max = GRID_COLUMN_COUNT / panel.minSpan; + panel.maxPerRow = factors[_.findIndex(factors, (o) => o > max) - 1]; + delete panel.minSpan; + changed = true; + } + } + return changed; + }, + }, + { + version: 18, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + if (panel['options-gauge']) { + panel.options = panel['options-gauge']; + panel.options.valueOptions = { + unit: panel.options.unit, + stat: panel.options.stat, + decimals: panel.options.decimals, + prefix: panel.options.prefix, + suffix: panel.options.suffix, + }; + if (panel.options.thresholds) { + panel.options.thresholds.reverse(); + } + delete panel.options.options; + delete panel.options.unit; + delete panel.options.stat; + delete panel.options.decimals; + delete panel.options.prefix; + delete panel.options.suffix; + delete panel['options-gauge']; + changed = true; + } + } + return changed; + }, + }, + { + version: 19, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + if (_.isArray(panel.links)) { + panel.links = panel.links.map(upgradePanelLink); + changed = true; + } + } + return changed; + }, + }, + { + version: 20, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + const updateLinks = (link: any) => { + if (_.isString(link.url) && legacyVariableNamesRegex.test(link.url)) { + link.url = updateVariablesSyntax(link.url); + changed = true; + } + return link; + }; + if (panel.options && _.isArray(panel.options.dataLinks)) { + panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); + } + if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { + if (_.isArray(panel.options.fieldOptions.defaults.links)) { + panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); + } + if (panel.options.fieldOptions.defaults.title) { + panel.options.fieldOptions.defaults.title = updateVariablesSyntax(panel.options.fieldOptions.defaults.title); + } + } + } + return changed; + }, + }, + { + version: 21, + migrate(dashboard) { + let changed = false; + const updateLinks = (link: any) => { + if (_.isString(link.url) && _.includes(link.url, '__series.labels')) { + link.url = link.url.replace(/__series\.labels/g, '__field.labels'); + changed = true; + } + return link; + }; + for (const panel of walkPanels(dashboard)) { + if (panel.options && _.isArray(panel.options.dataLinks)) { + panel.options.dataLinks = panel.options.dataLinks.map(updateLinks); + } + if (panel.options && panel.options.fieldOptions && panel.options.fieldOptions.defaults) { + if (_.isArray(panel.options.fieldOptions.defaults.links)) { + panel.options.fieldOptions.defaults.links = panel.options.fieldOptions.defaults.links.map(updateLinks); + } + } + } + return changed; + }, + }, + { + version: 22, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + if (panel.type !== 'table') continue; + for (const style of _.isArray(panel.styles) ? panel.styles : []) { + style.align = 'auto'; + changed = true; + } + } + return changed; + }, + }, + { + version: 23, + migrate(dashboard) { + let changed = false; + for (const variable of variablesOf(dashboard)) { + if (!isMulti(variable)) continue; + const { multi, current } = variable; + if (_.isEmpty(current)) continue; + variable.current = alignCurrentWithMulti(current, multi); + changed = true; + } + return changed; + }, + }, + { + version: 24, + migrate(dashboard) { + // 7.0:迁移旧 table 到 table-old(离线不做插件自动迁移) + let changed = false; + for (const panel of walkPanels(dashboard)) { + const wasAngularTable = panel.type === 'table'; + if (wasAngularTable && !panel.styles) continue; + const wasReactTable = panel.table === 'table2'; + if (!wasAngularTable || wasReactTable) continue; + panel.type = 'table-old'; + changed = true; + } + return changed; + }, + }, + { version: 25, skippedReason: 'grafana 未定义该版本的迁移(tags 在 v28 移除)' }, + { + version: 26, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + if (panel.type === 'text2') { + panel.type = 'text'; + if (panel.options) { + delete panel.options.angular; + } + changed = true; + } + } + return changed; + }, + }, + { + version: 27, + migrate(dashboard) { + let changed = false; + // 移除重复面板残留(与 grafana removeRepeatedPanels 一致) + const retained: GrafanaPanel[] = []; + for (const panel of _.isArray(dashboard.panels) ? dashboard.panels : []) { + if (panel.repeatPanelId || panel.repeatByRow) { + changed = true; + continue; + } + if (panel.type === 'row' && _.isArray(panel.panels)) { + const before = panel.panels.length; + panel.panels = panel.panels.filter((x) => !x.repeatPanelId); + if (panel.panels.length !== before) changed = true; + } + retained.push(panel); + } + dashboard.panels = retained; + // constant 变量迁移 + for (const variable of variablesOf(dashboard)) { + if (!isConstant(variable)) continue; + const newVariable: any = { ...variable }; + newVariable.current = { selected: true, text: newVariable.query ?? '', value: newVariable.query ?? '' }; + newVariable.options = [newVariable.current]; + if (newVariable.hide === VariableHide.dontHide || newVariable.hide === VariableHide.hideLabel) { + newVariable.type = 'textbox'; + } + Object.assign(variable, newVariable); + changed = true; + } + return changed; + }, + }, + { + version: 28, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + if (panel.type === 'singlestat') { + migrateSinglestat(panel); + changed = true; + } + } + for (const variable of variablesOf(dashboard)) { + for (const key of ['tags', 'tagsQuery', 'tagValuesQuery', 'useTags']) { + if (key in variable) { + delete variable[key]; + changed = true; + } + } + } + return changed; + }, + }, + { + version: 29, + migrate(dashboard) { + let changed = false; + for (const variable of variablesOf(dashboard)) { + if (variable.type !== 'query') continue; + if (variable.refresh !== 1 && variable.refresh !== 2) { + variable.refresh = 1; + changed = true; + } + if (variable.options?.length) { + variable.options = []; + changed = true; + } + } + return changed; + }, + }, + { + version: 30, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + if (upgradeValueMappingsForPanel(panel)) changed = true; + if (migrateTooltipOptions(panel)) changed = true; + } + return changed; + }, + }, + { + version: 31, + skippedReason: 'labelsToFields 后的 merge transformer 依赖 Grafana transformer registry,离线跳过', + }, + { version: 32, skippedReason: 'grafana 未定义该版本的迁移(CloudWatch 迁移移到 v34)' }, + { version: 33, skippedReason: 'datasource 名称转 {uid,type} 引用依赖 datasource service,离线跳过' }, + { version: 34, skippedReason: 'CloudWatch 查询迁移依赖数据源插件,离线跳过' }, + { version: 35, skippedReason: 'x 轴可见性迁移依赖面板插件,离线跳过' }, + { version: 36, skippedReason: '默认数据源转引用依赖 datasource service,离线跳过' }, + { + version: 37, + migrate(dashboard) { + let changed = false; + for (const panel of walkPanels(dashboard)) { + const legend = panel.options?.legend; + if (legend && typeof legend === 'object') { + if (legend.displayMode === 'hidden' || legend.showLegend === false) { + legend.displayMode = 'list'; + legend.showLegend = false; + } else { + panel.options.legend = { ...legend, showLegend: true }; + } + changed = true; + } + } + return changed; + }, + }, + { + version: 38, + migrate(dashboard) { + // table custom.displayMode → custom.cellOptions(含 overrides) + let changed = false; + for (const panel of walkPanels(dashboard)) { + if (panel.type === 'table' && panel.fieldConfig !== undefined) { + const defaults = panel.fieldConfig.defaults; + if (defaults?.custom && defaults.custom.displayMode !== undefined) { + defaults.custom.cellOptions = migrateTableDisplayModeToCellOptions(defaults.custom.displayMode); + delete defaults.custom.displayMode; + changed = true; + } + if (_.isArray(panel.fieldConfig.overrides)) { + for (const override of panel.fieldConfig.overrides) { + if (!_.isArray(override.properties)) continue; + for (const property of override.properties) { + if (property.id === 'custom.displayMode') { + property.id = 'custom.cellOptions'; + property.value = migrateTableDisplayModeToCellOptions(property.value); + changed = true; + } + } + } + } + } + } + return changed; + }, + }, + { + version: 39, + migrate(dashboard) { + // timeSeriesTable transformation:refIdToStat[refId] = stat → { stat } + let changed = false; + for (const panel of walkPanels(dashboard)) { + for (const transformation of _.isArray(panel.transformations) ? panel.transformations : []) { + if (!_.isPlainObject(transformation)) continue; + const options = transformation.options; + if (transformation.id === 'timeSeriesTable' && options !== undefined && options.refIdToStat !== undefined) { + const tableTransformOptions: Record = {}; + for (const [refId, stat] of Object.entries(options.refIdToStat)) { + tableTransformOptions[refId] = { stat }; + } + transformation.options = tableTransformOptions; + changed = true; + } + } + } + return changed; + }, + }, + { + version: 40, + migrate(dashboard) { + if (typeof dashboard.refresh !== 'string') { + dashboard.refresh = ''; + return true; + } + return false; + }, + }, + { + version: 41, + migrate(dashboard) { + if (dashboard.timepicker && 'time_options' in dashboard.timepicker) { + delete dashboard.timepicker.time_options; + return true; + } + return false; + }, + }, + { + version: 42, + migrate(dashboard) { + // hideFrom.viz === true 时补 tooltip = true + let changed = false; + for (const panel of walkPanels(dashboard)) { + const overrides = panel.fieldConfig?.overrides; + if (!_.isArray(overrides)) continue; + for (const override of overrides) { + if (!_.isArray(override.properties)) continue; + for (const property of override.properties) { + if (property.id === 'custom.hideFrom' && property.value?.viz === true) { + property.value.tooltip = true; + changed = true; + } + } + } + } + return changed; + }, + }, +]; + +/** 迁移入口:把 classic dashboard 迁移到指定 schema 版本(默认 42),返回迁移后的副本与台账。 */ +export function migrateDashboardSchema(source: unknown, targetVersion: number = DASHBOARD_SCHEMA_VERSION): MigrateResult { + const dashboard = _.cloneDeep(source) as GrafanaDashboard; + const oldVersion = typeof dashboard?.schemaVersion === 'number' ? dashboard.schemaVersion : 0; + const migrations: MigrationLedgerEntry[] = []; + + if (oldVersion >= targetVersion) { + return { dashboard, migrations }; + } + + for (const migration of MIGRATIONS) { + if (oldVersion >= migration.version || migration.version > targetVersion) continue; + if (migration.skippedReason) { + migrations.push({ version: migration.version, status: 'skipped', reason: migration.skippedReason }); + continue; + } + const changed = migration.migrate?.(dashboard) ?? false; + migrations.push({ + version: migration.version, + status: changed ? 'applied' : 'not-applicable', + reason: changed ? defaultReason(migration.version) : '该版本无适用的数据,无需迁移', + }); + } + + dashboard.schemaVersion = targetVersion; + return { dashboard, migrations }; +} + +function defaultReason(version: number): string { + const reasons: Record = { + 2: 'graphite 面板重命名为 graph', + 3: '为缺失 id 的面板补充确定性 id', + 6: '规范化模板变量并迁移 pulldowns→annotations', + 7: 'nav[0] 迁移到 timepicker', + 8: '迁移旧 InfluxDB target 结构', + 10: '规范化 table thresholds', + 12: '规范化变量 refresh / hide', + 14: 'sharedCrosshair 迁移到 graphTooltip', + 16: 'rows 布局迁移为 grid 布局', + 17: 'minSpan 迁移为 maxPerRow', + 18: '迁移遗留 gauge options', + 19: '规范化面板链接 url', + 20: '更新内置变量语法(__series.name 等)', + 21: '__series.labels 更新为 __field.labels', + 22: 'table 样式设置 align=auto', + 23: '多值变量 current 对齐', + 24: '旧 table 迁移为 table-old', + 26: 'text2 迁移为 text', + 27: '移除重复面板残留并迁移 constant 变量', + 28: 'singlestat 离线迁移并移除变量遗留 tags', + 29: '规范化 query 变量的 refresh / options', + 30: '升级 valueMappings 与 tooltip 配置', + 37: '规范化 legend 显示配置', + 38: 'table custom.displayMode 迁移为 custom.cellOptions', + 39: '迁移 timeSeriesTable transformation 配置', + 40: 'dashboard refresh 规范化', + 41: '移除 timepicker.time_options', + 42: '补齐 hideFrom.tooltip', + }; + return reasons[version] || '已应用该版本迁移'; +} + +// --------------------------------------------------------------------------- +// 迁移辅助函数 +// --------------------------------------------------------------------------- + +const legacyVariableNamesRegex = /(__series_name)|(\$__series_name)|(__value_time)|(__field_name)|(\$__field_name)/g; + +function updateVariablesSyntax(text: string) { + return String(text).replace(legacyVariableNamesRegex, (match, seriesName: string, seriesName1: string, valueTime: string, fieldName: string, fieldName1: string) => { + if (seriesName) return '__series.name'; + if (seriesName1) return '${__series.name}'; + if (valueTime) return '__value.time'; + if (fieldName) return '__field.name'; + if (fieldName1) return '${__field.name}'; + return match; + }); +} + +function upgradePanelLink(link: any) { + let url = link.url; + if (!url && link.dashboard) { + url = `dashboard/db/${slugify(link.dashboard)}`; + } + if (!url && link.dashUri) { + url = `dashboard/${link.dashUri}`; + } + if (!url) { + url = '/'; + } + if (link.keepTime) { + url = appendQueryToUrl(url, '${__from}'); + } + if (link.includeVars) { + url = appendQueryToUrl(url, '${__all_variables}'); + } + if (link.params) { + url = appendQueryToUrl(url, link.params); + } + return { + url, + title: link.title, + targetBlank: link.targetBlank, + }; +} + +function slugify(value: string) { + return String(value) + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function appendQueryToUrl(url: string, param: string) { + const separator = _.includes(url, '?') ? '&' : '?'; + return `${url}${separator}${param}`; +} + +function isMulti(model: any): boolean { + return 'multi' in model; +} + +function isConstant(model: any): boolean { + return model.type === 'constant'; +} + +function alignCurrentWithMulti(current: any, value: boolean) { + if (!current) return current; + if (value && !Array.isArray(current.value)) { + return { ...current, value: convertToMulti(current.value), text: convertToMulti(current.text) }; + } + if (!value && Array.isArray(current.value)) { + return { ...current, value: convertToSingle(current.value), text: convertToSingle(current.text) }; + } + return current; +} + +function convertToSingle(value: string | string[]): string { + if (!Array.isArray(value)) return value; + if (value.length > 0) return value[0]; + return ''; +} + +function convertToMulti(value: string | string[]): string[] { + if (Array.isArray(value)) return value; + return [value]; +} + +function upgradeValueMappingsForPanel(panel: GrafanaPanel): boolean { + const fieldConfig = panel.fieldConfig; + if (!fieldConfig) return false; + let changed = false; + if (fieldConfig.defaults && fieldConfig.defaults.mappings) { + fieldConfig.defaults.mappings = upgradeValueMappings(fieldConfig.defaults.mappings, fieldConfig.defaults.thresholds); + changed = true; + } + if (Array.isArray(fieldConfig.overrides)) { + for (const override of fieldConfig.overrides) { + for (const prop of override.properties || []) { + if (prop.id === 'mappings') { + prop.value = upgradeValueMappings(prop.value); + changed = true; + } + } + } + } + return changed; +} + +function upgradeValueMappings(oldMappings: any, thresholds?: any): any[] | undefined { + if (!oldMappings) return undefined; + const valueMaps: any = { type: MappingType.ValueToText, options: {} }; + const newMappings: any[] = []; + + for (const old of oldMappings) { + if (old.type && old.options) { + if (old.type === MappingType.ValueToText) { + valueMaps.options = { ...valueMaps.options, ...old.options }; + } else { + newMappings.push(old); + } + continue; + } + + let color: string | undefined; + const numeric = parseFloat(old.text); + if (thresholds && !isNaN(numeric)) { + const level = getActiveThreshold(numeric, thresholds.steps); + if (level && level.color) color = level.color; + } + + switch (old.type) { + case 1: // ValueToText + if (old.value != null) { + if (old.value === 'null') { + newMappings.push({ type: MappingType.SpecialValue, options: { match: SpecialValueMatch.Null, result: { text: old.text, color } } }); + } else { + valueMaps.options[String(old.value)] = { text: old.text, color }; + } + } + break; + case 2: // RangeToText + newMappings.push({ type: MappingType.RangeToText, options: { from: +old.from, to: +old.to, result: { text: old.text, color } } }); + break; + } + } + + if (Object.keys(valueMaps.options).length > 0) { + newMappings.unshift(valueMaps); + } + return newMappings; +} + +function getActiveThreshold(value: number, thresholds: any[] | undefined): any { + if (!thresholds || thresholds.length === 0) return { value: 0, color: 'gray' }; + let active = thresholds[0]; + for (const threshold of thresholds) { + if (value >= threshold.value) { + active = threshold; + } else { + break; + } + } + return active; +} + +function migrateTooltipOptions(panel: GrafanaPanel): boolean { + if ((panel.type === 'timeseries' || panel.type === 'xychart') && panel.options?.tooltipOptions) { + panel.options = { ...panel.options, tooltip: panel.options.tooltipOptions }; + delete panel.options.tooltipOptions; + return true; + } + return false; +} + +function migrateSinglestat(panel: GrafanaPanel) { + panel.options = { + colorMode: 'value', + graphMode: 'none', + justifyMode: 'auto', + orientation: 'horizontal', + reduceOptions: { calcs: ['lastNotNull'], fields: '', values: false }, + textMode: 'auto', + }; + const thresholds = _.split(panel.thresholds, ','); + if (_.compact(thresholds)?.length > 0) { + panel.fieldConfig = { + defaults: { + color: { mode: 'thresholds' }, + thresholds: { + mode: 'absolute', + steps: _.concat( + [{ color: panel.colors?.[0] || defaultColors[0], value: null as number | null }], + _.map(thresholds, (threshold, idx) => ({ + color: panel.colors?.[idx + 1] || defaultColors[(idx + 1) % defaultColors.length], + value: _.toNumber(threshold), + })), + ), + }, + unit: panel.format, + }, + }; + } + panel.type = panel.gauge?.show ? 'gauge' : 'stat'; +} + +/** grafana migrateTableDisplayModeToCellOptions 的离线确定性实现 */ +function migrateTableDisplayModeToCellOptions(displayMode: string): Record { + switch (displayMode) { + case 'basic': + case 'gradient-gauge': + case 'lcd-gauge': { + let mode = 'basic'; + if (displayMode === 'gradient-gauge') mode = 'gradient'; + else if (displayMode === 'lcd-gauge') mode = 'lcd'; + return { type: 'gauge', mode }; + } + case 'color-background': + case 'color-background-solid': { + let mode = 'basic'; + if (displayMode === 'color-background') mode = 'gradient'; + return { type: 'color-background', mode }; + } + default: + return { type: displayMode }; + } +} + +// --------------------------------------------------------------------------- +// v16:rows → grid 布局(离线实现) +// --------------------------------------------------------------------------- + +class RowArea { + area: number[]; + yPos: number; + height: number; + + constructor(height: number, width = GRID_COLUMN_COUNT, rowYPos = 0) { + this.area = new Array(width).fill(0); + this.yPos = rowYPos; + this.height = height; + } + + reset() { + this.area.fill(0); + } + + addPanel(gridPos: any) { + for (let i = gridPos.x; i < gridPos.x + gridPos.w; i++) { + if (!this.area[i] || gridPos.y + gridPos.h - this.yPos > this.area[i]) { + this.area[i] = gridPos.y + gridPos.h - this.yPos; + } + } + return this.area; + } + + getPanelPosition(panelHeight: number, panelWidth: number, callOnce = false): any { + let startPlace: number | undefined; + let endPlace: number | undefined; + for (let i = this.area.length - 1; i >= 0; i--) { + if (this.height - this.area[i] > 0) { + if (endPlace === undefined) { + endPlace = i; + } else if (i < this.area.length - 1 && this.area[i] <= this.area[i + 1]) { + startPlace = i; + } else { + break; + } + } else { + break; + } + } + + if (startPlace !== undefined && endPlace !== undefined && endPlace - startPlace >= panelWidth - 1) { + const yPos = _.max(this.area.slice(startPlace)); + return { x: startPlace, y: yPos }; + } + if (!callOnce) { + this.yPos += this.height; + this.reset(); + return this.getPanelPosition(panelHeight, panelWidth, true); + } + return null; + } +} + +function getGridHeight(height: number | string) { + let h = height; + if (_.isString(h)) { + h = parseInt(_.replace(h, 'px', ''), 10); + } + if (h < MIN_PANEL_HEIGHT) { + h = MIN_PANEL_HEIGHT; + } + return Math.ceil(h / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN)); +} + +function getFactors(num: number): number[] { + return Array.from(new Array(num + 1), (_, i) => i).filter((i) => num % i === 0); +} + +function upgradeToGridLayout(dashboard: GrafanaDashboard): boolean { + if (!_.isArray(dashboard.rows)) return false; + const oldRows = dashboard.rows; + dashboard.panels = dashboard.panels || []; + delete dashboard.rows; + let yPos = 0; + const widthFactor = GRID_COLUMN_COUNT / 12; + + const maxPanelId = _.max(_.flattenDeep(_.map(oldRows, (row) => _.map(row.panels, 'id')))); + let nextRowId = (maxPanelId ?? -1) + 1; + + // 只要有 row 折叠/显示标题/重复,就生成特殊 row 面板 + const showRows = _.some(oldRows, (row) => row.collapse || row.showTitle || row.repeat); + + for (const row of oldRows) { + if (row.repeatIteration) continue; + + const height: any = row.height || DEFAULT_ROW_HEIGHT; + const rowGridHeight = getGridHeight(height); + + const rowPanel: any = {}; + let rowPanelModel: any; + + if (showRows) { + rowPanel.id = nextRowId; + rowPanel.type = 'row'; + rowPanel.title = row.title; + rowPanel.collapsed = row.collapse; + rowPanel.repeat = row.repeat; + rowPanel.panels = []; + rowPanel.gridPos = { x: 0, y: yPos, w: GRID_COLUMN_COUNT, h: 1 }; + rowPanelModel = rowPanel; + nextRowId++; + yPos++; + } + + const rowArea = new RowArea(rowGridHeight, GRID_COLUMN_COUNT, yPos); + + for (const panel of row.panels) { + panel.span = panel.span || DEFAULT_PANEL_SPAN; + if (panel.minSpan) { + panel.minSpan = Math.min(GRID_COLUMN_COUNT, (GRID_COLUMN_COUNT / 12) * panel.minSpan); + } + const panelWidth = Math.floor(panel.span) * widthFactor; + const panelHeight = panel.height ? getGridHeight(panel.height) : rowGridHeight; + + const panelPos = rowArea.getPanelPosition(panelHeight, panelWidth); + yPos = rowArea.yPos; + panel.gridPos = { + x: panelPos.x, + y: yPos + panelPos.y, + w: panelWidth, + h: panelHeight, + }; + rowArea.addPanel(panel.gridPos); + + delete panel.span; + + if (rowPanelModel && row.collapsed) { + rowPanelModel.panels?.push(panel); + } else { + dashboard.panels.push(panel); + } + } + + if (rowPanelModel) { + dashboard.panels.push(rowPanelModel); + } + + if (!(rowPanelModel && row.collapsed)) { + yPos += rowGridHeight; + } + } + return true; +} diff --git a/src/pages/dashboard/utils/grafanaImport/options.ts b/src/pages/dashboard/utils/grafanaImport/options.ts new file mode 100644 index 0000000000..434f123cf1 --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/options.ts @@ -0,0 +1,216 @@ +/* + * Grafana 面板共享配置 → N9E options 的映射(thresholds / standardOptions / valueMappings / + * tooltip / legend / links / overrides),依据 skill mapping-common.md。 + */ +import _ from 'lodash'; + +import { grafanaColorNameToHex, mapUnitToN9E } from './units'; +import type { UnsupportedItem } from './types'; + +export type ReportFn = (item: Omit & { path?: string }) => void; + +export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; + +/** Grafana fieldConfig.defaults → N9E options.thresholds */ +export function convertThresholdsGrafanaToN9E(config: any) { + const thresholds = config?.thresholds; + return { + mode: thresholds?.mode ?? 'absolute', + steps: _.map(thresholds?.steps, (step, idx: number) => ({ + ...step, + color: grafanaColorNameToHex(step?.color), + type: step?.value === null && idx === 0 ? 'base' : undefined, + })), + }; +} + +/** Grafana fieldConfig.defaults → N9E options.standardOptions(unit 纠正为 unit 而非 util) */ +export function convertStandardOptions(config: any, report?: ReportFn, path?: string): Record { + const mapped = mapUnitToN9E(config?.unit); + if (mapped.downgraded && config?.unit) { + report?.({ + scope: 'option', + action: 'downgraded', + path, + reason: `单位 ${config.unit} 无 N9E 对应,降级为 ${mapped.unit}`, + }); + } + return { + unit: mapped.unit, + min: config?.min ?? null, + max: config?.max ?? null, + decimals: config?.decimals ?? null, + dateFormat: DEFAULT_DATE_FORMAT, + }; +} + +/** + * Grafana typed valueMappings(v30 迁移后的 fieldConfig.defaults.mappings) + * → N9E IValueMapping[](textValue / range / specialValue 三种形态)。 + */ +export function convertValueMappingsGrafanaToN9E(mappings: unknown, report?: ReportFn, path?: string): any[] { + if (!Array.isArray(mappings)) return []; + const out: any[] = []; + for (const mapping of mappings) { + if (!_.isPlainObject(mapping)) continue; + if (mapping.type === 'value') { + // type:"value" 的 options 每个 key 展开为一条 textValue + for (const [key, val] of Object.entries(mapping.options || {})) { + const option = val as any; + out.push({ + type: 'textValue', + match: { textValue: String(key) }, + result: { text: option?.text ?? '', color: grafanaColorNameToHex(option?.color) }, + }); + } + } else if (mapping.type === 'range') { + out.push({ + type: 'range', + match: { from: mapping.options?.from, to: mapping.options?.to }, + result: { text: mapping.options?.result?.text ?? '', color: grafanaColorNameToHex(mapping.options?.result?.color) }, + }); + } else if (mapping.type === 'special') { + const match = mapping.options?.match; + if (match === 'null' || match === 'empty') { + out.push({ + type: 'specialValue', + match: { specialValue: match }, + result: { text: mapping.options?.result?.text ?? '', color: grafanaColorNameToHex(mapping.options?.result?.color) }, + }); + } else { + report?.({ + scope: 'option', + action: 'dropped', + path, + reason: `special 匹配 ${String(match)} 无 N9E specialValue 对应,已丢弃`, + }); + } + } else { + report?.({ + scope: 'option', + action: 'dropped', + path, + reason: `未知的 valueMapping 类型 ${String(mapping.type)},已丢弃`, + }); + } + } + return out; +} + +/** Grafana options.tooltip → N9E options.tooltip(新式直接映射,旧式 graph 用 shared/sort) */ +export function convertTooltipGrafanaToN9E(panel: any): Record | undefined { + const tooltip = panel?.options?.tooltip; + if (!tooltip || typeof tooltip !== 'object') return undefined; + if (tooltip.mode === 'single' || tooltip.mode === 'all') { + const sort = tooltip.sort === 'asc' || tooltip.sort === 'desc' ? tooltip.sort : 'none'; + return { mode: tooltip.mode, sort }; + } + // legacy graph:tooltip.shared + tooltip.sort(0|1|2) + return { + mode: tooltip.shared ? 'all' : 'single', + sort: tooltip.sort === 1 ? 'asc' : tooltip.sort === 2 ? 'desc' : 'none', + }; +} + +/** Grafana options.legend → N9E options.legend(N9E 用 displayMode 控制显隐,无 showLegend 字段) */ +export function convertLegendGrafanaToN9E(panel: any): Record | undefined { + const legend = panel?.options?.legend; + if (!legend || typeof legend !== 'object') return undefined; + // 新式(schema 37 后):displayMode / showLegend + if ('displayMode' in legend || 'showLegend' in legend) { + let displayMode = legend.displayMode; + if (displayMode !== 'table' && displayMode !== 'hidden') displayMode = 'list'; + if (legend.showLegend === false) displayMode = 'hidden'; + return { displayMode, placement: legend.placement === 'right' ? 'right' : 'bottom' }; + } + // 旧式 graph legend:show / values / alignAsTable / rightSide + let displayMode = 'list'; + if (legend.show === false) { + displayMode = 'hidden'; + } else if (legend.alignAsTable || legend.values) { + displayMode = 'table'; + } + return { displayMode, placement: legend.rightSide ? 'right' : 'bottom' }; +} + +/** + * Grafana links → N9E links。 + * - type:'link' 或未声明 type(面板级 DataLink 通常只有 title/url):保留为普通链接。 + * - type:'dashboards':Grafana 基于 tags 动态筛选,无法映射到 N9E 的具体仪表盘,整体丢弃并报告一次。 + * - 其他类型:丢弃并报告(路径带索引以区分)。 + */ +export function convertLinksGrafanaToN9E(links: unknown, report?: ReportFn, path?: string): any[] { + if (!Array.isArray(links)) return []; + const out: any[] = []; + let dashboardsCount = 0; + links.forEach((item, index) => { + const type = item?.type; + if (type === 'link' || type === undefined || type === null) { + out.push({ type: 'link', title: item.title, url: item.url, targetBlank: item.targetBlank }); + } else if (type === 'dashboards') { + dashboardsCount += 1; + } else { + report?.({ + scope: 'custom', + action: 'dropped', + path: `${path}[${index}]`, + reason: `链接类型 ${String(type)} 不支持,已丢弃`, + }); + } + }); + if (dashboardsCount > 0) { + report?.({ + scope: 'custom', + action: 'dropped', + path, + reason: `存在 ${dashboardsCount} 个 dashboards 类型链接(Grafana 基于 tags 筛选),无法映射到 N9E 具体仪表盘,已丢弃`, + }); + } + return out; +} + +/** + * Grafana fieldConfig.overrides → N9E overrides。 + * N9E 对 field overrides 的映射规则不完备,整体视为不支持:输出空 overrides,每个面板报告一条。 + */ +export function convertOverridesGrafanaToN9E(fieldConfig: any, report?: ReportFn, path?: string): any[] { + const overrides = fieldConfig?.overrides; + if (!Array.isArray(overrides) || overrides.length === 0) return []; + report?.({ + scope: 'custom', + action: 'dropped', + path, + reason: '面板 fieldConfig.overrides 不在 N9E 映射子集内,已整体丢弃', + }); + return []; +} + +/** + * 组装面板共享 options:standardOptions / valueMappings / thresholds / thresholdsStyle / tooltip / legend。 + * defaultThresholdsStyle:timeseries 系面板(graph/timeseries/barchart)默认 line。 + */ +export function buildSharedOptions(panel: any, report?: ReportFn, defaultThresholdsStyle = false, path?: string): Record { + const config = panel?.fieldConfig?.defaults; + const shared: Record = {}; + + if (config) { + shared.standardOptions = convertStandardOptions(config, report, path); + if (Array.isArray(config.mappings) && config.mappings.length > 0) { + shared.valueMappings = convertValueMappingsGrafanaToN9E(config.mappings, report, path); + } + if (Array.isArray(config.thresholds?.steps) && config.thresholds.steps.length > 0) { + shared.thresholds = convertThresholdsGrafanaToN9E(config); + } + } + // thresholdsStyle:折线图系列面板由 grafana custom.thresholdsStyle.mode 决定,缺省 line + if (defaultThresholdsStyle) { + shared.thresholdsStyle = { mode: config?.custom?.thresholdsStyle?.mode || 'line' }; + } + + const tooltip = convertTooltipGrafanaToN9E(panel); + if (tooltip) shared.tooltip = tooltip; + const legend = convertLegendGrafanaToN9E(panel); + if (legend) shared.legend = legend; + + return shared; +} diff --git a/src/pages/dashboard/utils/grafanaImport/panels.ts b/src/pages/dashboard/utils/grafanaImport/panels.ts new file mode 100644 index 0000000000..223acac131 --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/panels.ts @@ -0,0 +1,293 @@ +/* + * Grafana 面板 → N9E IPanel 的映射(类型映射、targets、datasource、custom、row 布局)。 + * 依据 skill mapping-panels.md / mapping-targets.md 与 N9E 运行时消费。 + */ +import _ from 'lodash'; + +import { normalizeCalc } from './units'; +import { buildSharedOptions, convertLinksGrafanaToN9E, convertOverridesGrafanaToN9E, type ReportFn } from './options'; +import { resolveDatasourceRef, type ResolveContext } from './datasource'; +import { DASHBOARD_VERSION, type GrafanaPanel } from './types'; + +// @ts-ignore plus 包:postgres SQL 表名加前缀 +import addPrefixToTableNames from 'plus:/utils/convertDashboardGrafanaToN9E/addPrefixToTableNames'; + +/** Grafana 面板类型 → N9E 类型;不支持返回 null(丢弃 + 报告,绝不输出 unknown) */ +function mapPanelType(panel: GrafanaPanel): string | null { + switch (panel.type) { + case 'graph': + case 'timeseries': + case 'barchart': + return 'timeseries'; + case 'piechart': + return 'pie'; + case 'gauge': + return 'gauge'; + case 'singlestat': + return panel.gauge?.show ? 'gauge' : 'stat'; + case 'stat': + return 'stat'; + case 'bargauge': + return 'barGauge'; + case 'text': + return 'text'; + default: + return null; + } +} + +/** 转换单个 target → N9E ITarget;不支持返回 null(已报告) */ +function convertTarget(target: any, panel: GrafanaPanel, ctx: ResolveContext, report: ReportFn, path: string, index: number): any | null { + if (!_.isPlainObject(target)) { + report({ scope: 'target', action: 'dropped', path: `${path}.targets[${index}]`, reason: 'target 非对象,已丢弃' }); + return null; + } + const refId = typeof target.refId === 'string' && target.refId ? target.refId : String.fromCharCode(65 + index); + const ds = resolveDatasourceRef(target.datasource ?? panel.datasource, ctx); + if (!ds.supported) { + report({ scope: 'target', action: 'dropped', path: `${path}.targets[${index}]`, reason: `target ${refId} 数据源 ${ds.cate} 不支持,已丢弃` }); + return null; + } + const base = { refId, datasource: { cate: ds.cate, id: ds.value } }; + if (ds.cate === 'prometheus') { + if (typeof target.expr !== 'string' || !target.expr.trim()) { + report({ scope: 'target', action: 'dropped', path: `${path}.targets[${index}]`, reason: `target ${refId} 缺少 expr,已丢弃` }); + return null; + } + return { + ...base, + expr: target.expr, + legend: target.legendFormat ?? '', + instant: !!target.instant, + hide: !!target.hide, + kind: 'query', + resultType: 'time_series', + }; + } + if (ds.cate === 'pgsql') { + return { + ...base, + query: { mode: 'timeSeries', sql: addPrefixToTableNames(target.rawSql ?? '', '${dbName}', 'public') }, + kind: 'query', + resultType: 'time_series', + }; + } + return null; +} + +// --------------------------------------------------------------------------- +// 各面板类型 custom 转换 +// --------------------------------------------------------------------------- + +function convertTimeseriesCustom(panel: GrafanaPanel, report: ReportFn, path?: string): Record { + const custom = panel.fieldConfig?.defaults?.custom; + const version = DASHBOARD_VERSION; + if (custom) { + const stackingMode = custom.stacking?.mode; + if (stackingMode === 'percent') { + report({ scope: 'option', action: 'downgraded', path, reason: 'Grafana 百分比堆叠在 N9E 未实现,降级为 off' }); + } + return { + version, + drawStyle: panel.type === 'barchart' || custom.drawStyle === 'bars' ? 'bars' : 'lines', + lineInterpolation: custom.lineInterpolation === 'smooth' ? 'smooth' : 'linear', + fillOpacity: custom.fillOpacity ? custom.fillOpacity / 100 : 0, + stack: stackingMode === 'normal' ? 'normal' : 'off', + ...(custom.scaleDistribution ? { scaleDistribution: custom.scaleDistribution } : {}), + ...(custom.spanNulls !== undefined ? { spanNulls: custom.spanNulls } : {}), + ...(custom.lineWidth !== undefined ? { lineWidth: custom.lineWidth } : {}), + ...(custom.gradientMode ? { gradientMode: custom.gradientMode } : {}), + ...(custom.showPoints ? { showPoints: custom.showPoints } : {}), + ...(custom.pointSize ? { pointSize: custom.pointSize } : {}), + }; + } + // legacy graph:无 fieldConfig.custom,从面板级字段映射 + return { + version, + drawStyle: panel.bars ? 'bars' : 'lines', + lineInterpolation: 'linear', + fillOpacity: panel.fill ? panel.fill / 10 : 0, + stack: panel.stack ? 'normal' : 'off', + }; +} + +function convertStatCustom(panel: GrafanaPanel): Record { + return { + version: DASHBOARD_VERSION, + textMode: panel.options?.textMode ?? 'value', + colorMode: panel.options?.colorMode ?? 'value', + calc: normalizeCalc(panel.options?.reduceOptions?.calcs?.[0]), + }; +} + +function convertGaugeCustom(panel: GrafanaPanel): Record { + return { + version: DASHBOARD_VERSION, + textMode: panel.options?.textMode ?? 'value', + colorMode: panel.options?.colorMode ?? 'value', + calc: normalizeCalc(panel.options?.reduceOptions?.calcs?.[0]), + }; +} + +function convertPieCustom(panel: GrafanaPanel): Record { + const legend = panel.options?.legend; + let legengPosition = 'right'; + if (legend?.showLegend === false) { + legengPosition = 'hidden'; + } else if (legend?.placement) { + legengPosition = legend.placement; + } + return { + version: DASHBOARD_VERSION, + calc: normalizeCalc(panel.options?.reduceOptions?.calcs?.[0]), + legengPosition, + ...(panel.options?.pieType === 'donut' ? { donut: true } : {}), + }; +} + +function convertBarGaugeCustom(panel: GrafanaPanel, report: ReportFn, path?: string): Record { + const displayMode = panel.options?.displayMode; + if (displayMode === 'gradient') { + report({ scope: 'option', action: 'downgraded', path, reason: 'barGauge gradient 展示降级为 basic' }); + } + return { + version: DASHBOARD_VERSION, + calc: normalizeCalc(panel.options?.reduceOptions?.calcs?.[0]), + ...(displayMode ? { displayMode: displayMode === 'gradient' ? 'basic' : displayMode } : {}), + }; +} + +function convertTextCustom(panel: GrafanaPanel): Record { + return { + version: DASHBOARD_VERSION, + content: panel.options?.content ?? '', + }; +} + +function convertCustomByType(panel: GrafanaPanel, n9eType: string, report: ReportFn, path?: string): Record { + switch (n9eType) { + case 'timeseries': + return convertTimeseriesCustom(panel, report, path); + case 'pie': + return convertPieCustom(panel); + case 'stat': + return convertStatCustom(panel); + case 'gauge': + return convertGaugeCustom(panel); + case 'barGauge': + return convertBarGaugeCustom(panel, report, path); + case 'text': + return convertTextCustom(panel); + default: + return { version: DASHBOARD_VERSION }; + } +} + +/** 转换单个非 row 面板;类型不支持返回 null */ +function convertNonRowPanel(panel: GrafanaPanel, ctx: ResolveContext, report: ReportFn, index: number): any | null { + const id = panel.id !== undefined && panel.id !== null ? String(panel.id) : `panel-${index}`; + const path = `$.panels[id=${id}]`; + // 不支持的面板类型:保留为 unknown(渲染器会显示“无效的面板类型”占位),不粗暴丢弃 + const n9eType = mapPanelType(panel) ?? 'unknown'; + if (n9eType === 'unknown') { + report({ scope: 'panel', action: 'downgraded', path, reason: `不支持的面板类型 ${String(panel.type)},保留为 unknown 类型(渲染器显示无效类型占位)` }); + } + + // targets + const targets: any[] = []; + for (const [ti, target] of (panel.targets ?? []).entries()) { + const converted = convertTarget(target, panel, ctx, report, path, ti); + if (converted) targets.push(converted); + } + + // datasource:mixed 面板 / 多 target 数据源 → target 级;否则收敛到面板级 + const isMixedPanel = panel.datasource?.uid === '-- Mixed --'; + const targetDsKeys = _.uniq(targets.map((t) => (t.datasource ? `${t.datasource.cate}:${t.datasource.id}` : '')).filter(Boolean)); + const isMixed = isMixedPanel || targetDsKeys.length > 1; + let datasourceCate: string; + let datasourceValue: string; + if (isMixed) { + datasourceCate = 'mixed'; + datasourceValue = 'mixed'; + } else { + const ds = targets[0]?.datasource ?? resolveDatasourceRef(panel.datasource, ctx); + datasourceCate = ds.cate; + // target 上的数据源键为 id,resolveDatasourceRef 的键为 value,兼容两者 + datasourceValue = ds.id ?? ds.value; + for (const t of targets) { + delete t.datasource; + } + } + + const isTimeseries = n9eType === 'timeseries'; + const options = buildSharedOptions(panel, report, isTimeseries, path); + const custom = convertCustomByType(panel, n9eType, report, path); + const overrides = convertOverridesGrafanaToN9E(panel.fieldConfig, report, path); + const links = convertLinksGrafanaToN9E(panel.links, report, `${path}.links`); + if (Array.isArray(panel.transformations) && panel.transformations.length > 0) { + report({ scope: 'custom', action: 'dropped', path, reason: 'transformations 不在映射子集内,已丢弃' }); + } + if (targets.length === 0 && (panel.targets?.length ?? 0) > 0) { + report({ scope: 'panel', action: 'dropped', path, reason: '所有 target 均不支持,保留空 targets' }); + } + + return { + version: DASHBOARD_VERSION, + id, + type: n9eType, + name: panel.title ?? '', + description: panel.description ?? '', + links, + layout: { ...(panel.gridPos ?? {}), i: id }, + datasourceCate, + datasourceValue, + targets, + options, + custom, + overrides, + maxPerRow: panel.maxPerRow, + repeat: panel.repeat, + }; +} + +/** + * 转换面板列表为扁平的 N9E panels 数组。 + * - row:N9E collapsed = !grafana collapsed;展开行子面板放顶层(row 之后),折叠行子面板缓存进 row.panels。 + */ +export function convertPanelsGrafanaToN9E(panels: GrafanaPanel[] | undefined, ctx: ResolveContext, report: ReportFn): any[] { + const out: any[] = []; + appendPanels(panels, ctx, report, out); + return out; +} + +function appendPanels(panels: GrafanaPanel[] | undefined, ctx: ResolveContext, report: ReportFn, out: any[]) { + for (const [index, panel] of (panels ?? []).entries()) { + if (!_.isPlainObject(panel)) continue; + + if (panel.type === 'row') { + const id = panel.id !== undefined && panel.id !== null ? String(panel.id) : `panel-${index}`; + const row: any = { + version: DASHBOARD_VERSION, + id, + type: 'row', + name: panel.title ?? '', + collapsed: !panel.collapsed, + layout: { ...(panel.gridPos ?? {}), i: id }, + }; + out.push(row); + if (panel.collapsed) { + row.panels = []; + appendPanels(panel.panels, ctx, report, row.panels); + } else { + appendPanels(panel.panels, ctx, report, out); + } + if (panel.repeat) { + report({ scope: 'panel', action: 'dropped', path: `$.panels[id=${id}]`, reason: '按变量重复的 row 不支持,已丢弃' }); + } + continue; + } + + const converted = convertNonRowPanel(panel, ctx, report, index); + if (converted) out.push(converted); + } +} diff --git a/src/pages/dashboard/utils/grafanaImport/types.ts b/src/pages/dashboard/utils/grafanaImport/types.ts new file mode 100644 index 0000000000..a4c9c132da --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/types.ts @@ -0,0 +1,102 @@ +/* + * Grafana 导入相关的内部类型定义。 + * + * Grafana classic dashboard JSON 结构松散,这里用带索引签名的宽松接口描述, + * 供迁移与映射模块使用;N9E 侧输出仍以 @/pages/dashboard/types 的正式类型为准。 + */ + +/** N9E dashboard 版本(与 @/pages/dashboard/config 的 DASHBOARD_VERSION 保持一致) */ +export const DASHBOARD_VERSION = '4.0.0'; + +/** Grafana 查询 target(宽松) */ +export interface GrafanaTarget { + refId?: string; + expr?: string; + legendFormat?: string; + datasource?: unknown; + [key: string]: any; +} + +/** Grafana 面板(宽松) */ +export interface GrafanaPanel { + id?: number; + type?: string; + title?: string; + collapsed?: boolean; + panels?: GrafanaPanel[]; + gridPos?: { + h?: number; + w?: number; + x?: number; + y?: number; + }; + targets?: GrafanaTarget[]; + fieldConfig?: any; + options?: any; + [key: string]: any; +} + +/** Grafana classic dashboard(宽松) */ +export interface GrafanaDashboard { + schemaVersion?: number; + title?: string; + panels?: GrafanaPanel[]; + templating?: { + list?: any[]; + }; + timepicker?: any; + [key: string]: any; +} + +/** 迁移台账条目状态 */ +export type MigrationStatus = 'applied' | 'not-applicable' | 'skipped'; + +/** 迁移台账单条记录 */ +export interface MigrationLedgerEntry { + version: number; + status: MigrationStatus; + reason: string; +} + +/** 不支持项报告条目(对齐 skill 的 unsupported-report-format) */ +export interface UnsupportedItem { + scope: 'dashboard' | 'variable' | 'panel' | 'target' | 'option' | 'custom'; + path: string; + action: 'dropped' | 'downgraded' | 'defaulted'; + reason: string; +} + +/** 转换报告:迁移台账 + 不支持项 + 统计摘要 */ +export interface ConversionReport { + migration: { + inputSchemaVersion: number; + targetSchemaVersion: number; + migrations: MigrationLedgerEntry[]; + }; + unsupportedItems: UnsupportedItem[]; + summary?: { + panels: number; + convertedPanels: number; + droppedPanels: number; + targets: number; + droppedTargets: number; + variables: number; + droppedVariables: number; + }; +} + +/** 迁移结果 */ +export interface MigrateResult { + dashboard: GrafanaDashboard; + migrations: MigrationLedgerEntry[]; +} + +/** 转换结果:N9E dashboard + 报告 */ +export interface ConvertResult { + dashboard: { + name: string; + tags: string; + configs: any; + }; + report: ConversionReport; +} diff --git a/src/pages/dashboard/utils/grafanaImport/units.ts b/src/pages/dashboard/utils/grafanaImport/units.ts new file mode 100644 index 0000000000..556fe57a5d --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/units.ts @@ -0,0 +1,158 @@ +/* + * Grafana → N9E 的单位、颜色、宏映射表(依据 skill mapping-common.md / mapping-targets.md)。 + */ +import _ from 'lodash'; + +/** Grafana 内置颜色名 → hex(thresholds 等处的颜色直接输出 hex) */ +export const grafanaBuiltinColors: { color: string; name: string }[] = [ + { color: '#FFA6B0', name: 'super-light-red' }, + { color: '#FF7383', name: 'light-red' }, + { color: '#F2495C', name: 'red' }, + { color: '#E02F44', name: 'semi-dark-red' }, + { color: '#C4162A', name: 'dark-red' }, + { color: '#FFCB7D', name: 'super-light-orange' }, + { color: '#FFB357', name: 'light-orange' }, + { color: '#FF9830', name: 'orange' }, + { color: '#FF780A', name: 'semi-dark-orange' }, + { color: '#FA6400', name: 'dark-orange' }, + { color: '#FFF899', name: 'super-light-yellow' }, + { color: '#FFEE52', name: 'light-yellow' }, + { color: '#FADE2A', name: 'yellow' }, + { color: '#F2CC0C', name: 'semi-dark-yellow' }, + { color: '#E0B400', name: 'dark-yellow' }, + { color: '#C8F2C2', name: 'super-light-green' }, + { color: '#96D98D', name: 'light-green' }, + { color: '#73BF69', name: 'green' }, + { color: '#56A64B', name: 'semi-dark-green' }, + { color: '#37872D', name: 'dark-green' }, + { color: '#C0D8FF', name: 'super-light-blue' }, + { color: '#8AB8FF', name: 'light-blue' }, + { color: '#5794F2', name: 'blue' }, + { color: '#3274D9', name: 'semi-dark-blue' }, + { color: '#1F60C4', name: 'dark-blue' }, + { color: '#DEB6F2', name: 'super-light-purple' }, + { color: '#CA95E5', name: 'light-purple' }, + { color: '#B877D9', name: 'purple' }, + { color: '#A352CC', name: 'semi-dark-purple' }, + { color: '#8F3BB8', name: 'dark-purple' }, +]; + +/** Grafana 颜色名 → hex;非内置颜色(自定义 hex / rgba)原样返回 */ +export function grafanaColorNameToHex(color: unknown): string { + if (typeof color !== 'string' || !color) return ''; + return _.find(grafanaBuiltinColors, { name: color })?.color || color; +} + +/** Grafana 单位 id → N9E 单位(mapping-common.md §4) */ +export const grafanaUnitToN9E: Record = { + none: 'none', + s: 'seconds', + ms: 'milliseconds', + µs: 'microseconds', + ns: 'nanoseconds', + percent: 'percent', + percentunit: 'percentUnit', + bytes: 'bytesIEC', + bits: 'bitsIEC', + decbytes: 'bytesSI', + decbits: 'bitsSI', + short: 'short', + sishort: 'sishort', + count: 'count', + dateTimeAsIso: 'datetimeMilliseconds', + dateTimeAsLocal: 'datetimeMilliseconds', + dateTimeAsSystem: 'datetimeMilliseconds', + // N9E 无 duration 展示单位,降级为 seconds(需报告) + dtdurations: 'seconds', + celsius: 'celsius', + fahrenheit: 'fahrenheit', + kelvin: 'kelvin', + dBm: 'dBm', + pps: 'packetsSec', + Bps: 'bytesSecSI', + binBps: 'bytesSecIEC', + bps: 'bitsSecSI', + binbps: 'bitsSecIEC', + cps: 'cps', + ops: 'ops', + reqps: 'reqps', + rps: 'rps', + wps: 'wps', + iops: 'iops', + eps: 'eps', + mps: 'mps', + recps: 'recps', + rowsps: 'rowsps', + cpm: 'cpm', + opm: 'opm', + reqpm: 'reqpm', + rpm: 'rpm', + wpm: 'wpm', + iopm: 'iopm', + epm: 'epm', + mpm: 'mpm', + recpm: 'recpm', + rowspm: 'rowspm', + lengthmm: 'millimeter', + lengthcm: 'centimeter', + lengthin: 'inch', + lengthft: 'foot', + lengthm: 'meter', + lengthkm: 'kilometer', + lengthmi: 'mile', + kbytes: 'kibibytes', + deckbytes: 'kilobytes', + mbytes: 'mebibytes', + decmbytes: 'megabytes', + gbytes: 'gibibytes', + decgbytes: 'gigabytes', + tbytes: 'tebibytes', + dectbytes: 'terabytes', + pbytes: 'pebibytes', + decpbytes: 'petabytes', +}; + +/** 是否发生了降级(dtdurations → seconds) */ +const DOWNGRADED_UNITS = new Set(['dtdurations']); + +/** Grafana 单位 → N9E 单位;无法映射 → 'none'(downgraded) */ +export function mapUnitToN9E(unit: unknown): { unit: string; downgraded: boolean } { + if (typeof unit !== 'string' || !unit) { + return { unit: 'none', downgraded: false }; + } + const mapped = grafanaUnitToN9E[unit]; + if (mapped) { + return { unit: mapped, downgraded: DOWNGRADED_UNITS.has(unit) }; + } + return { unit: 'none', downgraded: true }; +} + +/** 聚合计算类型规范化(mapping-common.md §6) */ +const SUPPORTED_CALCS = ['lastNotNull', 'last', 'firstNotNull', 'first', 'min', 'max', 'sum', 'count', 'avg']; + +export function normalizeCalc(calc: unknown): string { + if (typeof calc !== 'string') return 'lastNotNull'; + if (calc === 'mean') return 'avg'; + if (SUPPORTED_CALCS.includes(calc)) return calc; + return 'lastNotNull'; +} + +/** 内置宏规范化表(varWithUnitMap,mapping-targets.md §3) */ +export const varWithUnitMap: Record = { + '${__interval}s': '${__interval}', + '${__interval_ms}ms': '${__interval}', + '${__rate_interval}s': '${__rate_interval}', + '${__range}s': '${__range}', + '${__range_s}s': '${__range_s}', + '${__range_ms}ms': '${__range_ms}', +}; + +/** 对字符串应用宏规范化(主要用于变量 definition;expr 内宏由运行时处理,不改写) */ +export function normalizeMacros(value: unknown): string { + if (typeof value !== 'string') return ''; + let out = value; + for (const [key, replacement] of Object.entries(varWithUnitMap)) { + out = _.replace(out, key, replacement); + } + return out; +} diff --git a/src/pages/dashboard/utils/grafanaImport/variables.ts b/src/pages/dashboard/utils/grafanaImport/variables.ts new file mode 100644 index 0000000000..e75f9d4bb5 --- /dev/null +++ b/src/pages/dashboard/utils/grafanaImport/variables.ts @@ -0,0 +1,212 @@ +/* + * Grafana 模板变量 → N9E configs.var 的映射(依据 skill mapping-variables.md)。 + */ +import _ from 'lodash'; + +import type { GrafanaDashboard } from './types'; +import { normalizeMacros } from './units'; +import { collectPanelCates, normalizeCate, resolveDatasourceRef, type DatasourceVarRef, type ResolveContext } from './datasource'; +import type { ReportFn } from './options'; + +// @ts-ignore plus 包:postgres SQL 表名加前缀 +import addPrefixToTableNames from 'plus:/utils/convertDashboardGrafanaToN9E/addPrefixToTableNames'; + +/** N9E 变量持久化结构(宽松,type/hide 等为运行时字段) */ +export interface N9eVariable { + type: 'query' | 'custom' | 'constant' | 'datasource' | 'textbox'; + name: string; + label?: string; + definition?: string; + defaultValue?: string | number; + allValue?: string | null; + allOption?: boolean; + multi?: boolean; + reg?: string; + hide?: boolean; + datasource?: { cate: string; value: string }; + [key: string]: any; +} + +/** 按 cate 分组的数据源列表(来自 CommonStateContext.groupedDatasourceList) */ +export type GroupedDatasourceList = Record; + +/** 取某 cate 的默认数据源 id(优先 is_default,否则第一个) */ +function defaultDatasourceId(cate: string, list?: GroupedDatasourceList): number | undefined { + const items = list?.[cate]; + if (!Array.isArray(items) || items.length === 0) return undefined; + return items.find((d) => d.is_default)?.id ?? items[0]?.id; +} + +/** + * N9E 的 All 选项要求多选。Grafana 允许 includeAll + 非多选(All 作为单选普通项), + * 盲目标 multi=true 会改变变量单选/多选语义,因此降级为:移除 All 选项、保持单选。 + */ +function adaptAllOption(v: any, allOption: boolean, multi: boolean, report: ReportFn, path?: string): { allOption: boolean; multi: boolean } { + if (allOption && !multi) { + report({ + scope: 'variable', + action: 'downgraded', + path, + reason: `变量 ${String(v?.name)}:N9E 的 All 选项要求多选,已移除 All 选项(保持单选)`, + }); + return { allOption: false, multi: false }; + } + return { allOption, multi }; +} + +function toHide(hide: unknown): boolean { + // grafana: 0=不隐藏、1=隐藏 label(仍显示控件)、2=隐藏整个变量;未定义等同 0。 + // N9E hide 为布尔,仅 hide=2(或 true)时视为隐藏,避免未定义 hide 的变量被误隐藏 + if (hide === true) return true; + if (typeof hide === 'number') return hide === 2; + return false; +} + +/** 转换单个 templating 变量;不支持类型返回 null(已报告) */ +function convertTemplatingVariable(v: any, ctx: ResolveContext, report: ReportFn, path?: string): N9eVariable | null { + if (!_.isPlainObject(v)) return null; + const hide = toHide(v.hide); + const { allOption, multi } = adaptAllOption(v, !!v.includeAll, !!v.multi, report, path); + + switch (v.type) { + case 'query': { + const resolved = resolveDatasourceRef(v.datasource, ctx); + if (!resolved.supported) { + report({ scope: 'variable', action: 'dropped', path, reason: `query 变量 ${String(v.name)} 的数据源 ${resolved.cate} 不支持,已丢弃` }); + return null; + } + let definition = typeof v.query === 'string' ? v.query : typeof v.query?.query === 'string' ? v.query.query : typeof v.definition === 'string' ? v.definition : ''; + definition = normalizeMacros(definition); + const out: N9eVariable = { + type: 'query', + name: v.name, + label: v.label, + allValue: v.allValue ?? null, + allOption, + multi, + reg: v.regex ?? '', + hide, + definition, + datasource: { cate: resolved.cate, value: resolved.value }, + }; + if (resolved.cate === 'pgsql') { + out.definition = addPrefixToTableNames(out.definition ?? '', '${dbName}', 'public'); + out.datasource = { cate: 'pgsql', value: resolved.value }; + } + return out; + } + case 'custom': { + return { + type: 'custom', + name: v.name, + definition: normalizeMacros(v.query ?? ''), + allValue: v.allValue ?? null, + allOption, + multi, + hide, + }; + } + case 'constant': { + return { type: 'constant', name: v.name, definition: v.query ?? '', hide }; + } + case 'textbox': { + return { type: 'textbox', name: v.name, defaultValue: v.query ?? '', hide }; + } + case 'interval': { + const definition = normalizeMacros(v.query ?? ''); + if (!definition) { + report({ scope: 'variable', action: 'defaulted', path, reason: `interval 变量 ${String(v.name)} 无定义,使用默认间隔列表` }); + return { + type: 'custom', + name: v.name, + definition: '1s,5s,1m,5m,1h,6h,1d', + allValue: v.allValue ?? null, + allOption, + multi, + hide, + }; + } + return { type: 'custom', name: v.name, definition, allValue: v.allValue ?? null, allOption, multi, hide }; + } + default: + report({ scope: 'variable', action: 'dropped', path, reason: `不支持的变量类型 ${String(v.type)},已丢弃` }); + return null; + } +} + +/** + * 转换 dashboard 的变量为 N9E configs.var。 + * 数据源变量来源:__inputs(最前)→ templating datasource 变量 → 按面板使用 cate 兜底。 + * 同时返回面板转换所需的解析上下文(数据源变量引用 + __inputs 兜底名)。 + * datasourceList:按 cate 分组的数据源列表,用于给数据源变量补充默认值。 + */ +export function convertVariablesGrafanaToN9E( + dashboard: GrafanaDashboard, + report: ReportFn, + options?: { datasourceList?: GroupedDatasourceList }, +): { vars: N9eVariable[]; context: ResolveContext } { + const { datasourceList } = options || {}; + const templatingList = dashboard.templating?.list; + const templating = Array.isArray(templatingList) ? templatingList : []; + const inputs = Array.isArray(dashboard.__inputs) ? dashboard.__inputs : []; + + const datasourceVars: DatasourceVarRef[] = []; + const vars: N9eVariable[] = []; + // 按名称去重:Grafana 导出常在 __inputs 与 templating 同时声明同名数据源变量 + const hasVar = (name: string) => vars.some((v) => v.name === name); + + // 1. __inputs datasource 变量(最前) + for (const item of inputs) { + if (item?.type !== 'datasource' || typeof item.name !== 'string') continue; + if (hasVar(item.name)) continue; + const cate = normalizeCate(item.pluginId); + vars.push({ type: 'datasource', name: item.name, definition: cate, hide: false, defaultValue: defaultDatasourceId(cate, datasourceList) }); + if (cate !== 'unknown' && !datasourceVars.some((v) => v.name === item.name)) { + datasourceVars.push({ name: item.name, cate }); + } + } + + // 2. templating 的 datasource 变量 + for (const v of templating) { + if (v?.type !== 'datasource') continue; + if (hasVar(v.name)) continue; + const cate = normalizeCate(v.query); + vars.push({ type: 'datasource', name: v.name, definition: cate, hide: toHide(v.hide), defaultValue: defaultDatasourceId(cate, datasourceList) }); + if (cate !== 'unknown' && !datasourceVars.some((x) => x.name === v.name)) { + datasourceVars.push({ name: v.name, cate }); + } + } + + // 3. 按面板实际使用 cate 兜底数据源变量(prometheus 优先占位 'datasource',其余 datasource_) + const usedCates = collectPanelCates(dashboard.panels).sort((a, b) => (a === 'prometheus' ? -1 : 1) - (b === 'prometheus' ? -1 : 1)); + let primaryAssigned = datasourceVars.length > 0; + for (const cate of usedCates) { + if (datasourceVars.some((v) => v.cate === cate)) continue; + let name = primaryAssigned ? `datasource_${cate}` : 'datasource'; + primaryAssigned = true; + // 避免与已有变量(任意类型)重名 + let suffix = 1; + while (hasVar(name)) { + name = `datasource_${cate}_${suffix}`; + suffix += 1; + } + vars.push({ type: 'datasource', name, definition: cate, hide: false, defaultValue: defaultDatasourceId(cate, datasourceList) }); + datasourceVars.push({ name, cate }); + } + + // 4. postgres 使用时的 dbName 隐藏常量(置顶) + if (usedCates.includes('pgsql') && !vars.some((v) => v.name === 'dbName')) { + vars.unshift({ type: 'constant', name: 'dbName', label: '数据库名', hide: true, definition: 'dbName' }); + } + + // 5. 其余变量 + const inputsFallbackName = inputs.find((i) => i?.type === 'datasource')?.name; + const ctx: ResolveContext = { datasourceVars, inputsFallbackName }; + for (const v of templating) { + if (v?.type === 'datasource') continue; + const converted = convertTemplatingVariable(v, ctx, report, `$.templating.list[name=${String(v?.name)}]`); + if (converted) vars.push(converted); + } + + return { vars, context: ctx }; +} diff --git a/src/pages/dashboard/utils/index.ts b/src/pages/dashboard/utils/index.ts index c8bcbf2544..0e4754485b 100644 --- a/src/pages/dashboard/utils/index.ts +++ b/src/pages/dashboard/utils/index.ts @@ -15,22 +15,8 @@ * */ import _ from 'lodash'; -import { v4 as uuidv4 } from 'uuid'; import moment from 'moment'; import { IRawTimeRange, parseRange } from '@/components/TimeRangePicker'; -import { DASHBOARD_VERSION } from '@/pages/dashboard/config'; -import { IDashboardConfig, IVariable } from '../types'; -import { defaultValues, calcsOptions } from '../Editor/config'; -import updateSchema from '../updateSchema'; - -// @ts-ignore -import convertVariableQuery from 'plus:/utils/convertDashboardGrafanaToN9E/convertVariableQuery'; -// @ts-ignore -import convertVariableDefault from 'plus:/utils/convertDashboardGrafanaToN9E/convertVariableDefault'; -// @ts-ignore -import convertPanleTarget from 'plus:/utils/convertDashboardGrafanaToN9E/convertPanleTarget'; -// @ts-ignore -import convertDatasource from 'plus:/utils/convertDashboardGrafanaToN9E/convertDatasource'; export function JSONParse(str: string): unknown { if (str) { @@ -57,446 +43,6 @@ export function getDefaultStepByTime( return Math.max(Math.floor((end - start) / maxDataPoints), 1); } -const grafanaBuiltinColors = [ - { color: '#FFA6B0', name: 'super-light-red' }, - { color: '#FF7383', name: 'light-red' }, - { color: '#F2495C', name: 'red' }, - { color: '#E02F44', name: 'semi-dark-red' }, - { color: '#C4162A', name: 'dark-red' }, - { color: '#FFCB7D', name: 'super-light-orange' }, - { color: '#FFB357', name: 'light-orange' }, - { color: '#FF9830', name: 'orange' }, - { color: '#FF780A', name: 'semi-dark-orange' }, - { color: '#FA6400', name: 'dark-orange' }, - { color: '#FFF899', name: 'super-light-yellow' }, - { color: '#FFEE52', name: 'light-yellow' }, - { color: '#FADE2A', name: 'yellow' }, - { color: '#F2CC0C', name: 'semi-dark-yellow' }, - { color: '#E0B400', name: 'dark-yellow' }, - { color: '#C8F2C2', name: 'super-light-green' }, - { color: '#96D98D', name: 'light-green' }, - { color: '#73BF69', name: 'green' }, - { color: '#56A64B', name: 'semi-dark-green' }, - { color: '#37872D', name: 'dark-green' }, - { color: '#C0D8FF', name: 'super-light-blue' }, - { color: '#8AB8FF', name: 'light-blue' }, - { color: '#5794F2', name: 'blue' }, - { color: '#3274D9', name: 'semi-dark-blue' }, - { color: '#1F60C4', name: 'dark-blue' }, - { color: '#DEB6F2', name: 'super-light-purple' }, - { color: '#CA95E5', name: 'light-purple' }, - { color: '#B877D9', name: 'purple' }, - { color: '#A352CC', name: 'semi-dark-purple' }, - { color: '#8F3BB8', name: 'dark-purple' }, -]; - -function normalizeCalc(calc: string) { - if (calc === 'mean') { - return 'avg'; - } - if (calcsOptions[calc]) { - return calc; - } - return 'lastNotNull'; -} - -// dashboard-any-ignore-start: legacy Grafana import conversion is outside the active dashboard runtime scope. -function convertThresholdsGrafanaToN9E(config: any) { - return { - mode: config.thresholds?.mode, // mode 目前是不支持的 - style: config.custom?.thresholdsStyle?.mode || 'line', // 目前只有固定的 line 风格,但是这个只用于折线图 - steps: _.map(config.thresholds?.steps, (step, idx: number) => { - return { - ...step, - color: _.find(grafanaBuiltinColors, { name: step.color })?.color || step.color, // grafana 的 color 是 name,需要转换成 hex - type: step.value === null && idx === 0 ? 'base' : undefined, // 没有值并且是第一个,就是 base - }; - }), - }; -} - -const varWithUnitMap = { - '${__interval}s': '${__interval}', - '${__interval_ms}ms': '${__interval}', - '${__rate_interval}s': '${__rate_interval}', - '${__range}s': '${__range}', - '${__range_s}s': '${__range_s}', - '${__range_ms}ms': '${__range_ms}', -}; - -function convertVariablesGrafanaToN9E(templates: any, __inputs: any[], data: any) { - const vars = _.chain(templates.list) - .filter((item) => { - // 3.0.0 版本只支持 query / custom / textbox / constant 类型的变量 - return item.type === 'query' || item.type === 'custom' || item.type === 'textbox' || item.type === 'constant' || item.type === 'datasource' || item.type === 'interval'; - }) - .map((item) => { - if (item.type === 'query') { - const varObj: any = { - type: 'query', - name: item.name, - label: item.label, - allValue: item.allValue, - allOption: item.includeAll, - multi: item.multi, - reg: item.regex, - hide: item.hide === 0 ? false : true, - }; - if (item.datasource?.type === 'prometheus') { - varObj.definition = item.definition; - if (typeof item.query === 'string') { - varObj.definition = item.query; - } else if (typeof item.query?.query === 'string') { - varObj.definition = item.query.query; - } - const datasource = convertDatasourceGrafanaToN9E(item, templates.list); - varObj.datasource = { - cate: datasource.datasourceCate, - value: datasource.datasourceValue, - }; - } else { - const { definition, datasource } = convertVariableQuery(item); - varObj.definition = definition; - varObj.datasource = datasource; - } - // 转换一些内置变量 - _.forEach(varWithUnitMap, (val, key) => { - varObj.definition = _.replace(varObj.definition, key, val); - }); - return varObj; - } else if (item.type === 'custom') { - return { - type: 'custom', - name: item.name, - definition: item.query, - allValue: item.allValue, - allOption: item.includeAll, - multi: item.multi, - hide: item.hide === 0 ? false : true, - }; - } else if (item.type === 'constant') { - return { - type: 'constant', - name: item.name, - definition: item.query, - hide: item.hide === 0 ? false : true, - }; - } else if (item.type === 'datasource') { - return { - type: 'datasource', - name: item.name, - definition: item.query, - hide: item.hide === 0 ? false : true, - }; - } else if (item.type === 'interval') { - return { - type: 'custom', - name: item.name, - definition: '1s,5s,1m,5m,1h,6h,1d', - allValue: item.allValue, - allOption: item.includeAll, - multi: item.multi, - hide: item.hide === 0 ? false : true, - }; - } - return { - type: 'textbox', - name: item.name, - defaultValue: item.query, - hide: item.hide === 0 ? false : true, - }; - }) - .value(); - // 检查是否内置默认数据源 - _.forEach(__inputs, (item) => { - if (item.type === 'datasource') { - vars.unshift({ - type: 'datasource', - name: item.name, - definition: item.pluginId, - }); - } - }); - // 检查是否有数据源变量,没有的话就以第一个 panel 的数据源作为默认数据源 - if (!_.some(vars, { type: 'datasource' })) { - const panels = _.filter(data.panels, (panel) => { - return panel.type !== 'row'; - }); - const headPanel = _.head(panels); - if (headPanel?.datasource?.type === 'prometheus') { - vars.unshift({ - type: 'datasource', - name: 'datasource', - definition: 'prometheus', - }); - } else { - convertVariableDefault(headPanel, vars); - } - } - return vars; -} - -function convertLinksGrafanaToN9E(links: any) { - return _.chain(links) - .filter((item) => { - // 3.0.0 版本只支持 link 类型的链接设置 - return item.type === 'link'; - }) - .map((item) => { - return { - title: item.title, - url: item.url, - targetBlank: item.targetBlank, // TODO: 待验证 - }; - }) - .value(); -} - -function convertOptionsGrafanaToN9E(panel: any) { - if (panel.type === 'graph') { - // 旧版本的 Graph 不转换 options - return defaultValues.options; - } - const { fieldConfig, options } = panel; - const config = fieldConfig?.defaults; - if (!config) return {}; - const unitMap = { - percent: 'percent', - percentunit: 'percentUnit', - bytes: 'bytesIEC', - bits: 'bytesIEC', - decbytes: 'bytesSI', - decbits: 'bitsSI', - s: 'seconds', - ms: 'milliseconds', - }; - // 这里有 default 和 overrides 区别,目前 n9e 暂不支持 overrides - return { - valueMappings: config?.mappings, - thresholds: convertThresholdsGrafanaToN9E(config), - standardOptions: { - util: unitMap[config.unit] ? unitMap[config.unit] : 'none', - min: config.min, - max: config.max, - decimals: config.decimals, - }, - legend: { - displayMode: options?.legend?.displayMode === 'hidden' ? 'hidden' : 'list', - placement: options?.legend?.placement, - }, - tooltip: { - mode: options?.tooltip === 'single' || options?.tooltip?.mode === 'single' ? 'single' : 'all', - sort: options?.tooltip?.sort ? options?.tooltip?.sort : 'none', - }, - }; -} - -function convertTimeseriesGrafanaToN9E(panel: any) { - const lineInterpolation = _.get(panel, 'fieldConfig.defaults.custom.lineInterpolation'); - const fillOpacity = _.get(panel, 'fieldConfig.defaults.custom.fillOpacity'); - const stack = _.get(panel, 'fieldConfig.defaults.custom.stacking.mode'); - return { - version: DASHBOARD_VERSION, - drawStyle: panel.type === 'barchart' ? 'bars' : 'lines', - lineInterpolation: lineInterpolation === 'smooth' ? 'smooth' : 'linear', - fillOpacity: fillOpacity ? fillOpacity / 100 : 0, - stack: stack === 'normal' ? 'normal' : 'off', - }; -} - -function convertPieGrafanaToN9E(panel: any) { - return { - version: DASHBOARD_VERSION, - calc: normalizeCalc(_.get(panel, 'options.reduceOptions.calcs[0]')), - legengPosition: 'hidden', - }; -} - -function convertStatGrafanaToN9E(panel: any) { - return { - version: DASHBOARD_VERSION, - textMode: 'value', - calc: normalizeCalc(_.get(panel, 'options.reduceOptions.calcs[0]')), - colorMode: 'value', - }; -} +// 旧 Grafana 导入转换逻辑已迁移至 ./grafanaImport(迁移到 schema 42 + 真 4.0.0 映射 + 报告)。 -function convertGaugeGrafanaToN9E(panel: any) { - return { - version: DASHBOARD_VERSION, - textMode: 'value', - calc: normalizeCalc(_.get(panel, 'options.reduceOptions.calcs[0]')), - colorMode: 'value', - }; -} - -function convertBarGaugeGrafanaToN9E(panel: any) { - return { - version: DASHBOARD_VERSION, - calc: normalizeCalc(_.get(panel, 'options.reduceOptions.calcs[0]')), - }; -} - -function convertTextGrafanaToN9E(panel: any) { - return { - version: DASHBOARD_VERSION, - content: _.get(panel, 'options.content'), - }; -} - -function convertDatasourceGrafanaToN9E(panel: any, vars: any[]) { - const firstDatasource = _.find(vars, { type: 'datasource' }); - let defaultDatasourceValue = '${datasource}'; - if (firstDatasource && firstDatasource.name) { - defaultDatasourceValue = `\${${firstDatasource.name}}`; - } - const reg = /^\${[0-9a-zA-Z_]+}$/; - // 兼容旧版本 - if (typeof panel.datasource === 'string' && reg.test(panel.datasource)) { - return { - datasourceCate: 'prometheus', - datasourceValue: panel.datasource, - }; - } - if (_.toLower(panel?.datasource?.type) === 'prometheus') { - return { - datasourceCate: 'prometheus', - datasourceValue: reg.test(panel.datasource.uid) ? panel.datasource.uid : defaultDatasourceValue, - }; - } else { - return convertDatasource(panel); - } -} - -function convertPanlesGrafanaToN9E(panels: any, vars: any) { - const chartsMap = { - graph: { - // 旧版本的时间序列折线图 - type: 'timeseries', - fn: convertTimeseriesGrafanaToN9E, - }, - timeseries: { - type: 'timeseries', - fn: convertTimeseriesGrafanaToN9E, - }, - barchart: { - type: 'timeseries', - fn: convertTimeseriesGrafanaToN9E, - }, - piechart: { - type: 'pie', - fn: convertPieGrafanaToN9E, - }, - gauge: { - type: 'gauge', - fn: convertGaugeGrafanaToN9E, - }, - singlestat: { - type: 'gauge', - fn: convertStatGrafanaToN9E, - }, - stat: { - type: 'stat', - fn: convertStatGrafanaToN9E, - }, - bargauge: { - type: 'barGauge', - fn: convertBarGaugeGrafanaToN9E, - }, - text: { - type: 'text', - fn: convertTextGrafanaToN9E, - }, - }; - return _.chain(panels) - .map((item) => { - const uid = uuidv4(); - if (item.type === 'row') { - return { - version: DASHBOARD_VERSION, - id: uid, - type: 'row', - name: item.title, - collapsed: !item.collapsed, - layout: { - ...item.gridPos, - i: uid, - }, - panels: convertPanlesGrafanaToN9E(item.panels, vars), - }; - } - return { - version: DASHBOARD_VERSION, - id: uid, - type: chartsMap[item.type] ? chartsMap[item.type].type : 'unknown', - name: item.title, - description: item.description, - links: convertLinksGrafanaToN9E(item.links), - layout: { - ...item.gridPos, - i: uid, - }, - targets: _.chain(item.targets) - .filter((targetItem) => { - if (item.datasource?.uid === '-- Mixed --') { - // 暂不支持混合数据源,这里直接过滤掉 - return false; - } - // 暂不支持 prometheus 和 postgres 以外的数据源 - return _.includes(['prometheus', 'postgres'], targetItem.datasource?.type); - }) - .map((targetItem) => { - if (targetItem.datasource?.type === 'prometheus') { - return { - refId: targetItem.refId, - expr: targetItem.expr, - legend: targetItem.legendFormat, - }; - } else { - return convertPanleTarget(targetItem); - } - }) - .value(), - options: convertOptionsGrafanaToN9E(item), - custom: chartsMap[item.type] ? chartsMap[item.type].fn(item) : {}, - maxPerRow: item.maxPerRow || 4, - repeat: item.repeat, - ...convertDatasourceGrafanaToN9E(item, vars), - }; - }) - .value(); -} - -export function convertDashboardGrafanaToN9E(data) { - data = updateSchema(data); - const dashboard: { - name: string; - configs: IDashboardConfig; - } = { - name: data.title, - configs: { - version: DASHBOARD_VERSION, - links: convertLinksGrafanaToN9E(data.links), - var: convertVariablesGrafanaToN9E(data.templating, data.__inputs, data) as IVariable[], - panels: convertPanlesGrafanaToN9E(data.panels, data.templating?.list), - } as IDashboardConfig, - }; - return dashboard; -} - -/** - * 检测 Grafana Dashboard 版本 - * 0: 不支持 < v7 // 2023-08-29 启用 grafana update schema 功能后,不再禁止 < v7 - * 1: 兼容 >= v7 < v8 - * 2: 支持 >= v8 - */ -export function checkGrafanaDashboardVersion(data) { - // if (data.schemaVersion < 25) { - // return 0; - // } - if (data.schemaVersion < 30) { - return 1; - } - return 2; -} -// dashboard-any-ignore-end +export { convertDashboardGrafanaToN9E, convertDashboardGrafanaToN9EWithReport, checkGrafanaDashboardVersion } from './grafanaImport';