diff --git a/public/image/logos/jsonapi.png b/public/image/logos/jsonapi.png new file mode 100644 index 0000000000..b66ec2295b Binary files /dev/null and b/public/image/logos/jsonapi.png differ diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/index.test.tsx b/src/components/DatasourceSelect/DatasourceSelectV3/index.test.tsx new file mode 100644 index 0000000000..3489abf10e --- /dev/null +++ b/src/components/DatasourceSelect/DatasourceSelectV3/index.test.tsx @@ -0,0 +1,51 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +jest.mock('@/App', () => ({ + __esModule: true, + CommonStateContext: React.createContext({ + datasourceList: [], + datasourceCateOptions: [], + isPlus: false, + }), +})); +jest.mock('react-i18next', () => ({ + __esModule: true, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'zh_CN' } }), +})); +jest.mock('@/components/AdvancedWrap/utils', () => ({ + __esModule: true, + getCateDisplayLabel: () => undefined, +})); +jest.mock('../EmptyDatasourcePopover', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import DatasourceSelectV3 from './index'; + +describe('DatasourceSelectV3', () => { + it('keeps an injected datasource variable value instead of marking it as deleted', () => { + render( + [ + { + id: '${datasource}', + name: '${datasource}', + plugin_type: 'prometheus', + }, + ]} + showEmptyDatasourcePopover={false} + />, + ); + + expect(screen.getByText('${datasource}')).toBeInTheDocument(); + expect(screen.queryByText(/deleted/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx index 0076f62001..e6ec6ab5f7 100644 --- a/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx +++ b/src/components/DatasourceSelect/DatasourceSelectV3/index.tsx @@ -1,6 +1,8 @@ import React, { useContext } from 'react'; +import { WarningOutlined } from '@ant-design/icons'; import { Select, Space, Tag } from 'antd'; import { SelectProps } from 'antd/lib/select'; +import classNames from 'classnames'; import _ from 'lodash'; import { useTranslation } from 'react-i18next'; @@ -11,65 +13,177 @@ import EmptyDatasourcePopover from '../EmptyDatasourcePopover'; import './style.less'; -interface DatasourceItem { +export interface DatasourceItem { id: number | string; name: string; plugin_type: string; - is_default: boolean; + plugin_type_name?: string; + is_default?: boolean; identifier?: string; + weight?: number; } interface Props { type?: 'metric' | 'logging'; - datasourceCateList: Cate[]; + datasourceList?: DatasourceItem[]; + datasourceCateList?: Cate[]; ajustDatasourceList?: (list: DatasourceItem[]) => DatasourceItem[]; - onChange?: (value: string | number, datasourceCate: string) => void; + onChange?: (value: string | number | Array, datasourceCate: string) => void; onClear?: () => void; additionalOptions?: SelectProps['options']; + filterKey?: string; + showHost?: boolean; + renderOptionIcon?: (item: DatasourceItem) => React.ReactNode; + isOptionDisabled?: (item: DatasourceItem) => boolean; + renderOptionExtra?: (item: DatasourceItem) => React.ReactNode; + showEmptyDatasourcePopover?: boolean; +} + +type DatasourceValue = string | number; + +function getRawValue(value: DatasourceValue | { value: DatasourceValue }): DatasourceValue { + return typeof value === 'object' ? value.value : value; } export default function index(props: SelectProps & Props) { - const { type, datasourceCateList, ajustDatasourceList, onChange, onClear, additionalOptions } = props; - const { i18n } = useTranslation(); - const { datasourceList } = useContext(CommonStateContext); - const currentDatasourceList = ajustDatasourceList ? ajustDatasourceList(datasourceList) : datasourceList; + const { + type, + datasourceList: providedDatasourceList, + datasourceCateList, + ajustDatasourceList, + onChange, + onClear, + additionalOptions, + filterKey, + showHost, + renderOptionIcon, + isOptionDisabled, + renderOptionExtra, + showEmptyDatasourcePopover = true, + className, + } = props; + const { t, i18n } = useTranslation('datasourceSelect'); + const { datasourceList: contextDatasourceList = [], datasourceCateOptions = [], isPlus } = useContext(CommonStateContext); + const resolvedDatasourceList: DatasourceItem[] = providedDatasourceList ?? contextDatasourceList; + const resolvedDatasourceCateList = datasourceCateList ?? datasourceCateOptions; + // 调整后的列表可能包含仪表盘注入的数据源变量选项,不能只用原始列表判断当前值是否已删除。 + const adjustedDatasourceList = ajustDatasourceList ? ajustDatasourceList(resolvedDatasourceList) : resolvedDatasourceList; + const currentDatasourceList = adjustedDatasourceList.filter((item) => { + if (!filterKey) { + return true; + } + const datasourceCate = _.find(resolvedDatasourceCateList, { value: item.plugin_type }); + const supported = (datasourceCate as any)?.[filterKey]; + return isPlus ? supported : supported && !datasourceCate?.alertPro; + }); + const orderedDatasourceList = providedDatasourceList ? currentDatasourceList : _.orderBy(currentDatasourceList, ['is_default', 'plugin_type', 'weight'], ['desc', 'asc', 'asc']); + const additionalOptionValues = (additionalOptions ?? []) + .map((option) => option?.value) + .filter((value): value is DatasourceValue => typeof value === 'string' || typeof value === 'number'); + const selectedValue = props.value ?? props.defaultValue; + const hasSelectedValue = Array.isArray(selectedValue) ? selectedValue.length > 0 : selectedValue != null; + // additionalOptions 中的伪值(如 mixed)并非数据源,回填时保留其原有展示。 + const preservedOptionValues = new Set([...additionalOptionValues, ...(showHost ? [-999] : [])]); + const normalizeSelectedValue = (value: SelectProps['value']) => { + if (value == null) return undefined; - return ( - - { + // 根据空格分词进行过滤,取交集 + const keywords = _.filter(_.split(inputValue, ' '), (kw) => kw) as string[]; + return _.every(keywords, (kw) => _.includes(_.toLower(option?.filter), _.toLower(kw))); + }} + options={[ + ...(additionalOptions ?? []), + ...(showHost + ? [ + { + filter: 'host', + label: ( - {displayLabel} - {item.name} + Host + Host - - ), - label: ( -
+ ), + optionLabel: ( - {displayLabel} - {item.name} - {item.is_default && default} + Host + Host + ), + value: -999, + }, + ] + : []), + ..._.map(orderedDatasourceList, (item) => { + const datasourceCate = _.find(resolvedDatasourceCateList, { value: item.plugin_type }); + const displayLabel = getCateDisplayLabel(datasourceCate, i18n.language) || item.plugin_type_name || item.plugin_type; + const optionIcon = renderOptionIcon?.(item) ?? (datasourceCate?.logo ? {displayLabel} : null); + return { + filter: [item.plugin_type, item.plugin_type_name, displayLabel, item.name].filter(Boolean).join(' '), + originLabel: item.name, + optionLabel: ( +
+ {optionIcon} + {item.name} +
+ ), + label: ( +
+ + {optionIcon} + {item.name} + + + {item.is_default && {t('default')}} {displayLabel} -
- ), - value: item.id, - }; - }), - ]} - onChange={(value) => { - if (onChange) { - const curCate = _.find(currentDatasourceList, { id: value })?.plugin_type; - onChange(value, curCate ?? (value === 'mixed' ? 'mixed' : '')); - } - }} - onClear={onClear} - allowClear={!!onClear} - /> + {renderOptionExtra?.(item)} + +
+ ), + value: item.id, + disabled: isOptionDisabled?.(item), + }; + }), + ]} + onChange={(value) => { + if (onChange) { + const selectedValue = Array.isArray(value) ? value.map(getRawValue) : getRawValue(value); + const curValue = Array.isArray(selectedValue) ? _.last(selectedValue) : selectedValue; + const curCate = _.find(currentDatasourceList, { id: curValue })?.plugin_type; + onChange(selectedValue, curCate ?? (selectedValue === 'mixed' ? 'mixed' : '')); + } + }} + onClear={onClear} + allowClear={!!onClear} + /> + ); + + if (!showEmptyDatasourcePopover) return select; + + return ( + + {select} ); } diff --git a/src/components/DatasourceSelect/DatasourceSelectV3/style.less b/src/components/DatasourceSelect/DatasourceSelectV3/style.less index 90e24c3b67..bd60f51c27 100644 --- a/src/components/DatasourceSelect/DatasourceSelectV3/style.less +++ b/src/components/DatasourceSelect/DatasourceSelectV3/style.less @@ -1,9 +1,35 @@ .n9e-datasource-select-v3 { + &-default-tag.ant-tag { + margin-right: 0; + color: var(--fc-violet-11); + background-color: var(--fc-violet-3); + border: none; + border-radius: 4px; + } + .ant-select-selection-search-input { + text-indent: 0 !important; + } + + &-has-value .ant-select-selection-search-input { text-indent: 24px !important; } .ant-select-selector .ant-select-selection-item { transition: unset !important; } -} \ No newline at end of file + + &.ant-select-open .ant-select-selection-item img { + opacity: 0.45; + transition: opacity 0.3s; + } + + &-deleted-value { + color: var(--fc-red-11); + text-decoration: line-through; + + .anticon { + margin-right: 4px; + } + } +} diff --git a/src/components/DatasourceSelect/index.tsx b/src/components/DatasourceSelect/index.tsx index c3fef6492a..2e09c23bff 100644 --- a/src/components/DatasourceSelect/index.tsx +++ b/src/components/DatasourceSelect/index.tsx @@ -6,6 +6,8 @@ import DatasourceSelect from './DatasourceSelect'; import DatasourceCateSelectV2 from './DatasourceCateSelectV2'; import DatasourceSelectV2 from './DatasourceSelectV2'; import DatasourceSelectV3 from './DatasourceSelectV3'; +import type { DatasourceItem } from './DatasourceSelectV3'; import './locale'; export { DatasourceCateSelect, ProSvg, ProdSelect, DatasourceSelect, DatasourceCateSelectV2, DatasourceSelectV2, DatasourceSelectV3 }; +export type { DatasourceItem }; diff --git a/src/components/DatasourceSelect/locale/en_US.ts b/src/components/DatasourceSelect/locale/en_US.ts index 2e83f4cdd7..da438a6bb8 100644 --- a/src/components/DatasourceSelect/locale/en_US.ts +++ b/src/components/DatasourceSelect/locale/en_US.ts @@ -2,5 +2,7 @@ const en_US = { type: 'Data source type', id: 'Data source', allCates: 'All types', + deleted: 'Deleted', + default: 'Default', }; export default en_US; diff --git a/src/components/DatasourceSelect/locale/ja_JP.ts b/src/components/DatasourceSelect/locale/ja_JP.ts index 0856098148..b57e4aaa3f 100644 --- a/src/components/DatasourceSelect/locale/ja_JP.ts +++ b/src/components/DatasourceSelect/locale/ja_JP.ts @@ -2,5 +2,7 @@ const ja_JP = { type: 'データソースタイプ', id: 'データソースID', allCates: 'すべてのタイプ', + deleted: '削除済み', + default: 'デフォルト', }; export default ja_JP; diff --git a/src/components/DatasourceSelect/locale/ru_RU.ts b/src/components/DatasourceSelect/locale/ru_RU.ts index 58a105ee20..f5216f6e02 100644 --- a/src/components/DatasourceSelect/locale/ru_RU.ts +++ b/src/components/DatasourceSelect/locale/ru_RU.ts @@ -2,6 +2,8 @@ const ru_RU = { type: 'Тип источника данных', id: 'Связанный источник данных', allCates: 'Все типы', + deleted: 'Удален', + default: 'По умолчанию', }; export default ru_RU; diff --git a/src/components/DatasourceSelect/locale/zh_CN.ts b/src/components/DatasourceSelect/locale/zh_CN.ts index 4583c7c27a..1465f3d36d 100644 --- a/src/components/DatasourceSelect/locale/zh_CN.ts +++ b/src/components/DatasourceSelect/locale/zh_CN.ts @@ -2,5 +2,7 @@ const zh_CN = { type: '数据源类型', id: '关联数据源', allCates: '全部类型', + deleted: '已删除', + default: '默认', }; export default zh_CN; diff --git a/src/components/DatasourceSelect/locale/zh_HK.ts b/src/components/DatasourceSelect/locale/zh_HK.ts index 498937d404..f1fcd2e8a5 100644 --- a/src/components/DatasourceSelect/locale/zh_HK.ts +++ b/src/components/DatasourceSelect/locale/zh_HK.ts @@ -2,6 +2,8 @@ const zh_HK = { type: '數據源類型', id: '關聯數據源', allCates: '全部類型', + deleted: '已刪除', + default: '預設', }; export default zh_HK; diff --git a/src/components/DatasourceSelect/style.less b/src/components/DatasourceSelect/style.less index 8f7fb3a0de..1bc0eb38fc 100644 --- a/src/components/DatasourceSelect/style.less +++ b/src/components/DatasourceSelect/style.less @@ -8,7 +8,7 @@ font-size: 12px; font-weight: bolder; line-height: 1; - transform: scale(0.8) + transform: scale(0.8); } .n9e-pro-tag-selected { border: 1px solid #fff; @@ -34,4 +34,4 @@ .n9e-db-cate-grid-item-selected { border: 1px solid var(--fc-primary-color); } -} \ No newline at end of file +} diff --git a/src/pages/alertRules/Form/components/DatasourceValueSelect/index.tsx b/src/pages/alertRules/Form/components/DatasourceValueSelect/index.tsx index a9344be6e2..e6e9a785f5 100644 --- a/src/pages/alertRules/Form/components/DatasourceValueSelect/index.tsx +++ b/src/pages/alertRules/Form/components/DatasourceValueSelect/index.tsx @@ -14,14 +14,16 @@ * limitations under the License. * */ -import React, { useEffect, useState } from 'react'; -import { Form, Select } from 'antd'; -import { WarningOutlined } from '@ant-design/icons'; +import React, { useContext, useEffect, useState } from 'react'; +import { Form } from 'antd'; +import { DatabaseOutlined, WarningOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import _ from 'lodash'; import { Link } from 'react-router-dom'; import { getDatasourceBriefList } from '@/services/common'; import DatasourceSelectExtra from '@/pages/alertRules/Form/components/DatasourceSelectExtra'; +import { CommonStateContext } from '@/App'; +import { DatasourceSelectV3 } from '@/components/DatasourceSelect'; export const DATASOURCE_ALL = 0; @@ -52,6 +54,7 @@ const getInvalidDatasourceIds = (ids: number | number[], datasourceList: { id: n export default function index({ setFieldsValue, cate, datasourceList, mode, required = true, disabled, showExtra }: IProps) { const { t } = useTranslation('alertRules'); + const { datasourceCateOptions } = useContext(CommonStateContext); const [fullDatasourceList, setFullDatasourceList] = useState([]); const datasourceIds = Form.useWatch('datasource_ids'); const invalidDatasourceIds = getInvalidDatasourceIds(datasourceIds, datasourceList, fullDatasourceList); @@ -60,18 +63,6 @@ export default function index({ setFieldsValue, cate, datasourceList, mode, requ setFullDatasourceList(res); }); }; - let curDatasourceList = datasourceList; - - if (cate === 'prometheus' || cate === 'loki') { - curDatasourceList = [ - { - id: DATASOURCE_ALL, - name: '$all', - }, - ...datasourceList, - ]; - } - useEffect(() => { fetchDatasourceList(); }, []); @@ -150,8 +141,32 @@ export default function index({ setFieldsValue, cate, datasourceList, mode, requ }, ]} > - + /> {showExtra && ( diff --git a/src/pages/alertRules/FormNG/components/DatasourceValueSelect/index.tsx b/src/pages/alertRules/FormNG/components/DatasourceValueSelect/index.tsx index 4792d72437..8b2fbe7cb1 100644 --- a/src/pages/alertRules/FormNG/components/DatasourceValueSelect/index.tsx +++ b/src/pages/alertRules/FormNG/components/DatasourceValueSelect/index.tsx @@ -25,12 +25,13 @@ import { Link } from 'react-router-dom'; import { getDatasourceBriefList } from '@/services/common'; import { IS_ENT } from '@/utils/constant'; import DatasourceSelectExtra from '@/pages/alertRules/Form/components/DatasourceSelectExtra'; +import { DatasourceSelectV3, DatasourceItem } from '@/components/DatasourceSelect'; import { getDatasourcesByQueries } from './services'; import './style.less'; interface IProps { - datasourceList: { id: number; name: string }[]; + datasourceList: DatasourceItem[]; reloadGroupedDatasourceList: () => void; datasourceCate?: string; names?: string[]; @@ -154,19 +155,23 @@ function Query({ idx, names, field, remove, invalidDatasourceIds, datasourceList }, ]} > - { + return { + value: item.id, + label: item.name, + }; + })} + optionFilterProp='label' + /> + )} diff --git a/src/pages/alertRules/List/ListNG.tsx b/src/pages/alertRules/List/ListNG.tsx index 5edf7737e2..2d4a7070ae 100644 --- a/src/pages/alertRules/List/ListNG.tsx +++ b/src/pages/alertRules/List/ListNG.tsx @@ -12,7 +12,7 @@ import { CommonStateContext } from '@/App'; import { updateAlertRules, deleteStrategy } from '@/services/warning'; import { allCates, getCateDisplayLabel } from '@/components/AdvancedWrap/utils'; import RefreshIcon from '@/components/RefreshIcon'; -import DatasourceSelect from '@/components/DatasourceSelect/DatasourceSelect'; +import { DatasourceSelectV3 } from '@/components/DatasourceSelect'; import TableColumnSelect, { getDefaultColumnsConfigs, setDefaultColumnsConfigs, buildColumnOptions } from '@/components/TableColumnSelect'; import usePagination from '@/components/usePagination'; import Tags from '@/components/TableTags/Tags'; @@ -63,7 +63,7 @@ interface Props { export default function AlertRules(props: Props) { const { t, i18n } = useTranslation('alertRules'); - const { busiGroups, datasourceList, feats } = useContext(CommonStateContext); + const { busiGroups, datasourceList, datasourceCateOptions, feats } = useContext(CommonStateContext); const { hideBusinessGroupColumn, showRowSelection, readonly, headerExtra, data, loading, setRefreshFlag, linkTarget, emptyGuide, gids, groupSwitchCount } = props; const history = useHistory(); const location = useLocation(); @@ -411,11 +411,14 @@ export default function AlertRules(props: Props) { fetchData(); }} /> - { const newFilter = { @@ -424,6 +427,12 @@ export default function AlertRules(props: Props) { }; handleFilterChange(newFilter); }} + onClear={() => { + handleFilterChange({ + ...filter, + datasourceIds: undefined, + }); + }} /> - { setFilter({ @@ -305,6 +309,12 @@ const Event = (props: Props) => { datasource_ids: val, }); }} + onClear={() => { + setFilter({ + ...filter, + datasource_ids: undefined, + }); + }} /> - { const { t, i18n } = useTranslation('alertSubscribes'); const history = useHistory(); const location = useLocation(); - const { datasourceList, busiGroups, darkMode } = useContext(CommonStateContext); + const { datasourceList, datasourceCateOptions, busiGroups, darkMode } = useContext(CommonStateContext); const { hideBusinessGroupColumn, readonly, canCreate, headerExtra, data, loading, setRefreshFlag, linkTarget, gids, groupSwitchCount } = props; const [visibleColumns, setVisibleColumns] = useState(() => getDefaultColumnsConfigs(defaultColumnsConfigs, LOCAL_STORAGE_KEY)); const columnOptions = buildColumnOptions(defaultColumnsConfigs, t); @@ -366,9 +366,13 @@ const Subscribe = (props: Props) => { refreshList(); }} /> - { setDatasourceIds(val); @@ -376,6 +380,12 @@ const Subscribe = (props: Props) => { saveState({ datasourceIds: val }); history.replace({ pathname: location.pathname, search: setPageInSearch(location.search, 1) }); }} + onClear={() => { + setDatasourceIds(undefined); + setCurrent(1); + saveState({ datasourceIds: undefined }); + history.replace({ pathname: location.pathname, search: setPageInSearch(location.search, 1) }); + }} />