diff --git a/src/pages/logExplorer/components/LogsViewer/components/OriginSettings.tsx b/src/pages/logExplorer/components/LogsViewer/components/OriginSettings.tsx index e0fd41e77e..c1d9b40fd6 100644 --- a/src/pages/logExplorer/components/LogsViewer/components/OriginSettings.tsx +++ b/src/pages/logExplorer/components/LogsViewer/components/OriginSettings.tsx @@ -25,13 +25,14 @@ export default forwardRef(function OriginSettings( showPageLoadMode?: boolean; showJSONSettings?: boolean; showTopNSettings?: boolean; + showFlattenSettings?: boolean; organizeFields?: string[]; setOrganizeFields?: (value?: string[]) => void; }, ref, ) { const { t } = useTranslation(NAME_SPACE); - const { options, updateOptions, fields, showDateField, showMoreSettings, showJSONSettings, showPageLoadMode, showTopNSettings } = props; + const { options, updateOptions, fields, showDateField, showMoreSettings, showJSONSettings, showPageLoadMode, showTopNSettings, showFlattenSettings } = props; const [organizeFieldsModalVisible, setOrganizeFieldsModalVisible] = useState(false); const [organizeFields, setOrganizeFields] = useState(props.organizeFields); @@ -48,6 +49,9 @@ export default forwardRef(function OriginSettings( const [topNSettingsModalVisible, setTopNSettingsModalVisible] = useState(false); const [topNumber, setTopNumber] = useState(options.topNumber ?? 5); + const [flattenSettingsModalVisible, setFlattenSettingsModalVisible] = useState(false); + const [flattenDepth, setFlattenDepth] = useState(options.flattenDepth ?? 1); + useEffect(() => { setJsonSettings({ jsonDisplaType: options.jsonDisplaType, @@ -55,6 +59,7 @@ export default forwardRef(function OriginSettings( }); setPageLoadMode(options.pageLoadMode || 'pagination'); setTopNumber(options.topNumber || 5); + setFlattenDepth(options.flattenDepth ?? 1); }, [JSON.stringify(options)]); useEffect(() => { @@ -184,6 +189,22 @@ export default forwardRef(function OriginSettings( }, ] : [], + showFlattenSettings + ? [ + { + key: 'flattenSettings', + label: ( + { + setFlattenSettingsModalVisible(true); + }} + > + {t('logs.settings.flattenSettings.title')} + + ), + }, + ] + : [], )} /> } @@ -335,6 +356,40 @@ export default forwardRef(function OriginSettings( + { + updateOptions( + { + flattenDepth, + }, + true, + ); + setFlattenSettingsModalVisible(false); + }} + onCancel={() => { + setFlattenDepth(options.flattenDepth ?? 1); + setFlattenSettingsModalVisible(false); + }} + > +
+ {t('logs.settings.flattenSettings.extra')}}> + { + if (typeof val === 'number') { + setFlattenDepth(val); + } + }} + /> + +
+
); }); diff --git a/src/pages/logExplorer/components/LogsViewer/index.tsx b/src/pages/logExplorer/components/LogsViewer/index.tsx index fd82a60cd4..fb2c22da04 100644 --- a/src/pages/logExplorer/components/LogsViewer/index.tsx +++ b/src/pages/logExplorer/components/LogsViewer/index.tsx @@ -73,6 +73,7 @@ interface Props { showPageLoadMode?: boolean; showJSONSettings?: boolean; showTopNSettings?: boolean; + showFlattenSettings?: boolean; showLogMode?: boolean; addonBefore?: React.ReactNode; timeColumnWidth?: number; @@ -166,6 +167,7 @@ export default function LogsViewer(props: Props) { showPageLoadMode, showJSONSettings, showTopNSettings, + showFlattenSettings, showLogMode = true, addonBefore, timeColumnWidth, @@ -378,6 +380,7 @@ export default function LogsViewer(props: Props) { showPageLoadMode={showPageLoadMode} showJSONSettings={showJSONSettings} showTopNSettings={showTopNSettings} + showFlattenSettings={showFlattenSettings} organizeFields={organizeFields} setOrganizeFields={setOrganizeFields} /> diff --git a/src/pages/logExplorer/components/LogsViewer/types.ts b/src/pages/logExplorer/components/LogsViewer/types.ts index 3849200c36..084c24055f 100644 --- a/src/pages/logExplorer/components/LogsViewer/types.ts +++ b/src/pages/logExplorer/components/LogsViewer/types.ts @@ -9,6 +9,7 @@ export interface OptionsType { jsonExpandLevel?: number | null; pageLoadMode?: 'pagination' | 'infiniteScroll'; // 默认 pagination topNumber?: number; // 默认 5 + flattenDepth?: number; // 默认 1,0 = 不 parse、不拍平 } export type FieldValueType = string | number | boolean | null; diff --git a/src/pages/logExplorer/components/LogsViewer/utils/flatten.test.ts b/src/pages/logExplorer/components/LogsViewer/utils/flatten.test.ts new file mode 100644 index 0000000000..db2421d45c --- /dev/null +++ b/src/pages/logExplorer/components/LogsViewer/utils/flatten.test.ts @@ -0,0 +1,49 @@ +import flatten from './flatten'; + +describe('flatten maxDepth', () => { + test('maxDepth=1 does not recurse, keeps nested object as object', () => { + const result = flatten({ a: { b: 1 } }, { maxDepth: 1 }); + expect(result).toEqual({ a: { b: 1 } }); + expect(result['a.b']).toBeUndefined(); + }); + + test('maxDepth=2 expands one level and keeps deeper parts whole', () => { + const result = flatten({ a: { b: { c: 1 } } }, { maxDepth: 2 }); + expect(result).toEqual({ 'a.b': { c: 1 } }); + }); + + test('maxDepth=2 expands all sibling nested fields, not just the first', () => { + expect(flatten({ labels: { a: 1 }, meta: { b: 2 } }, { maxDepth: 2 })).toEqual({ 'labels.a': 1, 'meta.b': 2 }); + }); + + test('sibling expansion is correct at deeper levels too', () => { + expect(flatten({ a: { b: { c: 1 }, d: { e: 2 } } }, { maxDepth: 3 })).toEqual({ 'a.b.c': 1, 'a.d.e': 2 }); + }); + + test('no opts expands to unlimited depth (legacy behavior)', () => { + expect(flatten({ a: { b: { c: { d: 1 } } } })).toEqual({ 'a.b.c.d': 1 }); + }); + + test('flattenDepth=1 convention maps to maxDepth=2 (labels.rule_note)', () => { + // 调用方约定:flattenDepth = N 对应 flatten 的 maxDepth = N + 1 + expect(flatten({ labels: { rule_note: 'value' } }, { maxDepth: 2 })).toEqual({ 'labels.rule_note': 'value' }); + }); + + test('explicit maxDepth wins over internal default 3', () => { + const deep = { a: { b: { c: { d: 1 } } } }; + expect(flatten(deep, { maxDepth: 2 })).toEqual({ 'a.b': { c: { d: 1 } } }); + expect(flatten(deep, { maxDepth: 4 })).toEqual({ 'a.b.c.d': 1 }); + }); + + test('nested empty object is stringified', () => { + expect(flatten({ a: {} }, { maxDepth: 1 })).toEqual({ a: '{}' }); + }); + + test('arrays are stringified', () => { + expect(flatten({ a: [1, 2] }, { maxDepth: 1 })).toEqual({ a: '[1,2]' }); + }); + + test('flat values pass through untouched', () => { + expect(flatten({ a: 'x', b: 1, c: null }, { maxDepth: 1 })).toEqual({ a: 'x', b: 1, c: null }); + }); +}); diff --git a/src/pages/logExplorer/components/LogsViewer/utils/flatten.ts b/src/pages/logExplorer/components/LogsViewer/utils/flatten.ts index 32c610df37..4dc6e60a01 100644 --- a/src/pages/logExplorer/components/LogsViewer/utils/flatten.ts +++ b/src/pages/logExplorer/components/LogsViewer/utils/flatten.ts @@ -5,11 +5,11 @@ export default function flatten(target: object, opts?: { delimiter?: any; maxDep opts = opts || {}; const delimiter = opts.delimiter || '.'; - let maxDepth = opts.maxDepth || 3; - let currentDepth = 1; + // 未显式指定 maxDepth(含传 0 的 falsy 场景)时不限深;depth 随递归入参传递,回溯天然正确 + const maxDepth = opts.maxDepth || Infinity; const output: any = {}; - function step(object: any, prev: string | null) { + function step(object: any, prev: string | null, depth: number) { Object.keys(object).forEach((key) => { const value = object[key]; const isarray = opts?.safe && Array.isArray(value); @@ -18,13 +18,8 @@ export default function flatten(target: object, opts?: { delimiter?: any; maxDep const newKey = prev ? prev + delimiter + key : key; - if (!opts?.maxDepth) { - maxDepth = currentDepth + 1; - } - - if (!isarray && isobject && Object.keys(value).length && currentDepth < maxDepth) { - ++currentDepth; - return step(value, newKey); + if (!isarray && isobject && Object.keys(value).length && depth < maxDepth) { + return step(value, newKey, depth + 1); } if (isobject && Object.keys(value).length === 0) { @@ -35,7 +30,7 @@ export default function flatten(target: object, opts?: { delimiter?: any; maxDep }); } - step(target, null); + step(target, null, 1); return output; } diff --git a/src/pages/logExplorer/constants.ts b/src/pages/logExplorer/constants.ts index 48aea6e9cd..78d83b2005 100644 --- a/src/pages/logExplorer/constants.ts +++ b/src/pages/logExplorer/constants.ts @@ -28,6 +28,8 @@ export const TYPE_MAP: Record = { boolean: 'boolean', }; +export const DEFAULT_FLATTEN_DEPTH = 1; + export const DEFAULT_OPTIONS: OptionsType = { logMode: 'origin', lineBreak: 'false', @@ -37,4 +39,5 @@ export const DEFAULT_OPTIONS: OptionsType = { jsonDisplaType: 'string', jsonExpandLevel: 1, topNumber: 5, + flattenDepth: DEFAULT_FLATTEN_DEPTH, }; diff --git a/src/pages/logExplorer/locale/en_US.ts b/src/pages/logExplorer/locale/en_US.ts index a858fd9e8d..4066b8cb60 100644 --- a/src/pages/logExplorer/locale/en_US.ts +++ b/src/pages/logExplorer/locale/en_US.ts @@ -126,6 +126,10 @@ const en_US = { topNSettings: { title: 'Top N values settings', }, + flattenSettings: { + title: 'Flatten depth', + extra: '0: no parsing or flattening, field values are displayed as raw log strings\nN (>=1): nested JSON is expanded up to N levels, deeper parts are displayed as a whole', + }, }, fieldLabelTip: 'Field statistics not enabled, unable to perform statistical analysis', filterAnd: 'Add "{{token}}" to current query', diff --git a/src/pages/logExplorer/locale/zh_CN.ts b/src/pages/logExplorer/locale/zh_CN.ts index 715fa43b65..e81c6cb3d2 100644 --- a/src/pages/logExplorer/locale/zh_CN.ts +++ b/src/pages/logExplorer/locale/zh_CN.ts @@ -125,6 +125,10 @@ const zh_CN = { topNSettings: { title: '前 N 个值设置', }, + flattenSettings: { + title: '字段拍平层级', + extra: '0: 不解析、不拍平,字段值按日志原始字符串展示\nN(≥1): 嵌套 JSON 最多展开 N 层,超出部分整体展示', + }, }, fieldLabelTip: '字段未开启统计,无法进行统计分析', filterAnd: '添加 "{{token}}" 到本次检索', diff --git a/src/plugins/clickHouse/ExplorerNG/Main/Query/index.tsx b/src/plugins/clickHouse/ExplorerNG/Main/Query/index.tsx index 95fa39acf4..daaee9c945 100644 --- a/src/plugins/clickHouse/ExplorerNG/Main/Query/index.tsx +++ b/src/plugins/clickHouse/ExplorerNG/Main/Query/index.tsx @@ -7,7 +7,7 @@ import { useRequest, useGetState } from 'ahooks'; import { DatasourceCateEnum, IS_PLUS } from '@/utils/constant'; import { parseRange } from '@/components/TimeRangePicker'; -import { NAME_SPACE as logExplorerNS } from '@/pages/logExplorer/constants'; +import { NAME_SPACE as logExplorerNS, DEFAULT_FLATTEN_DEPTH } from '@/pages/logExplorer/constants'; import LogsViewer from '@/pages/logExplorer/components/LogsViewer'; import calcColWidthByData from '@/pages/logExplorer/components/LogsViewer/utils/calcColWidthByData'; import flatten from '@/pages/logExplorer/components/LogsViewer/utils/flatten'; @@ -103,6 +103,12 @@ export default function index(props: Props) { }; setOptions(mergedOptions); setOptionsToLocalstorage(NG_QUERY_LOGS_OPTIONS_CACHE_KEY, mergedOptions); + // flattenDepth 需随视图保存,同步写入表单隐藏字段 + form.setFieldsValue({ + query: { + flattenDepth: mergedOptions.flattenDepth, + }, + }); // 只有在修改了 pageLoadMode 时才重置分页参数 if (reload) { setServiceParams({ @@ -114,6 +120,16 @@ export default function index(props: Props) { } }; + // 视图恢复时 form 中的 flattenDepth 即时最新,同步回 options(不触发重查,重查由 refreshFlag 自身触发) + useEffect(() => { + if (typeof queryValues?.flattenDepth === 'number' && queryValues.flattenDepth !== options.flattenDepth) { + setOptions({ + ...options, + flattenDepth: queryValues.flattenDepth, + }); + } + }, [queryValues?.flattenDepth, options.flattenDepth]); + // 分页时的时间范围不变 const fixedRangeRef = useRef(false); const loadTimeRef = useRef(null); @@ -122,6 +138,8 @@ export default function index(props: Props) { const service = () => { const queryValues = form.getFieldValue('query'); // 实时获取最新的查询条件 + // 优先 form(视图恢复时 form 即时最新),其次 options,最后默认值 + const flattenDepth = typeof queryValues?.flattenDepth === 'number' ? queryValues.flattenDepth : options.flattenDepth ?? DEFAULT_FLATTEN_DEPTH; if (refreshFlag && datasourceValue && queryValues?.database && queryValues?.table && queryValues?.time_field && queryValues.range) { const range = parseRange(queryValues.range); let timeParams = @@ -160,9 +178,19 @@ export default function index(props: Props) { loadTimeRef.current = Date.now() - queryStart; } const newLogs = _.map(res.list, (item) => { + if (flattenDepth === 0) { + // 不 parse、不拍平,行数据 = 原始字段值(剔除内部高亮字段,与 depth>0 的 ___raw___ 语义一致) + const rawItem = _.omit(item, [HIGHLIGHT_FIELD]); + return { + ...rawItem, + ___raw___: rawItem, + ___id___: _.uniqueId('log_id_'), + }; + } const normalizedItem = normalizeLogStructures(_.omit(item, [HIGHLIGHT_FIELD])); return { - ...(flatten(normalizedItem) || {}), + // 层级语义:flattenDepth = N 表示嵌套 JSON 展开 N 层,对应 flatten 的 maxDepth = N + 1 + ...(flatten(normalizedItem, { maxDepth: flattenDepth + 1 }) || {}), ___raw___: normalizedItem, ___id___: _.uniqueId('log_id_'), }; @@ -463,6 +491,7 @@ export default function index(props: Props) { } showPageLoadMode + showFlattenSettings onOptionsChange={updateOptions} onAddToQuery={handleValueFilter} onRangeChange={(range) => { diff --git a/src/plugins/clickHouse/ExplorerNG/Main/SQL/Table.tsx b/src/plugins/clickHouse/ExplorerNG/Main/SQL/Table.tsx index aaba961b90..e45217086f 100644 --- a/src/plugins/clickHouse/ExplorerNG/Main/SQL/Table.tsx +++ b/src/plugins/clickHouse/ExplorerNG/Main/SQL/Table.tsx @@ -8,7 +8,6 @@ import { useTranslation, Trans } from 'react-i18next'; import { parseRange } from '@/components/TimeRangePicker'; import { NAME_SPACE as logExplorerNS } from '@/pages/logExplorer/constants'; -import flatten from '@/pages/logExplorer/components/LogsViewer/utils/flatten'; import getFieldsFromTableData from '@/pages/logExplorer/components/LogsViewer/utils/getFieldsFromTableData'; import LogsViewer from '@/pages/logExplorer/components/LogsViewer'; import calcColWidthByData from '@/pages/logExplorer/components/LogsViewer/utils/calcColWidthByData'; @@ -108,7 +107,7 @@ export default function Table(props: IProps) { loadTimeRef.current = Date.now() - queryStart; const newLogs = _.map(res.list, (item) => { return { - ...(flatten(item) || {}), + ...item, ___raw___: item, ___id___: _.uniqueId('log_id_'), }; diff --git a/src/plugins/clickHouse/ExplorerNG/index.tsx b/src/plugins/clickHouse/ExplorerNG/index.tsx index 0c7358c873..ec0af4432c 100644 --- a/src/plugins/clickHouse/ExplorerNG/index.tsx +++ b/src/plugins/clickHouse/ExplorerNG/index.tsx @@ -147,6 +147,9 @@ export default function index(props: Props) {