dsh-my-observability 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -68,6 +68,25 @@ const strings = {
68
68
  agentUnknown: () => (isZh() ? '未知' : 'unknown'),
69
69
  toolOk: () => (isZh() ? '成功' : 'ok'),
70
70
  toolFail: () => (isZh() ? '失败' : 'failed'),
71
+ // 审计日志搜索 / 过滤 / 导出 / 统计
72
+ searchPlaceholder: () => (isZh() ? '搜索工具名/参数/错误…' : 'Search tool/args/error…'),
73
+ filterAllResult: () => (isZh() ? '全部结果' : 'All results'),
74
+ filterSuccess: () => (isZh() ? '成功' : 'Ok'),
75
+ filterFail: () => (isZh() ? '失败' : 'Failed'),
76
+ timeStartLabel: () => (isZh() ? '开始' : 'Start'),
77
+ timeEndLabel: () => (isZh() ? '结束' : 'End'),
78
+ clearFilters: () => (isZh() ? '清除' : 'Clear'),
79
+ exportJson: () => (isZh() ? '导出 JSON' : 'Export JSON'),
80
+ exportCsv: () => (isZh() ? '导出 CSV' : 'Export CSV'),
81
+ scopeSession: () => (isZh() ? '当前会话' : 'This session'),
82
+ scopeAll: () => (isZh() ? '全部会话' : 'All sessions'),
83
+ statsTitle: () => (isZh() ? '工具统计' : 'Tool stats'),
84
+ statsTool: () => (isZh() ? '工具' : 'Tool'),
85
+ statsCalls: () => (isZh() ? '调用' : 'Calls'),
86
+ statsFailRate: () => (isZh() ? '失败率' : 'Fail rate'),
87
+ statsEmpty: () => (isZh() ? '暂无工具调用数据' : 'No tool call data'),
88
+ noMatches: () => (isZh() ? '无匹配事件' : 'No matching events'),
89
+ exportFileName: () => (isZh() ? 'dsh-observability-审计导出' : 'dsh-observability-audit-export'),
71
90
  // Git 面板
72
91
  repoLabel: () => (isZh() ? '仓库路径' : 'Repo path'),
73
92
  repoPlaceholder: () => (isZh() ? '如 /path/to/project' : 'e.g. /path/to/project'),
@@ -252,6 +271,27 @@ const icon = {
252
271
  ],
253
272
  size,
254
273
  ),
274
+ // 下载(issue #85 新增):箭头入托盘,图表导出按钮(dsh-mermaid-render
275
+ // 卡片下载 PNG/SVG),stroke=currentColor 风格与其余图标一致。
276
+ download: (size = 16) =>
277
+ iconSvg(
278
+ [
279
+ createElement('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' }),
280
+ createElement('polyline', { points: '7 10 12 15 17 10' }),
281
+ createElement('line', { x1: 12, y1: 15, x2: 12, y2: 3 }),
282
+ ],
283
+ size,
284
+ ),
285
+ // 复制(issue #85 新增):双层矩形,复制源码按钮(dsh-mermaid-render
286
+ // 卡片复制代码),stroke=currentColor 风格与其余图标一致。
287
+ copy: (size = 16) =>
288
+ iconSvg(
289
+ [
290
+ createElement('rect', { x: 9, y: 9, width: 13, height: 13, rx: 2 }),
291
+ createElement('path', { d: 'M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1' }),
292
+ ],
293
+ size,
294
+ ),
255
295
  }
256
296
 
257
297
  // Common-language / file-type badges (issue #24): brand fill + contrast
@@ -408,8 +448,266 @@ const fileIconByExt = (ext, size = 14) => {
408
448
  return spec === undefined ? icon.file(size) : badgeIcon(spec, size)
409
449
  }
410
450
 
451
+ /**
452
+ * dsh-my-observability — audit log view helpers (pure functions).
453
+ *
454
+ * 轨迹回放面板的搜索 / 组合过滤 / 导出(JSON/CSV)/ 统计的纯逻辑,无副作用、
455
+ * 不依赖 React 与 cordis(可被 vitest 直接导入单测),仅供 client 端在已加载
456
+ * 的审计事件数据上做视图变换。DSH ModuleLoader 不支持相对路径 require,client
457
+ * 侧经 scripts/build.mjs 把本文件(剥离 `export` 前缀)作为片段拼接进
458
+ * lib/client.js 的 factory 作用域,因此本文件约定:
459
+ * - 只用 `export function`(单行形式),不用 export 块 / export default;
460
+ * - 顶层没有 import / 副作用;
461
+ * - 不读取 strings —— 涉及界面文案的默认值集中在此,client 如需 i18n 覆盖
462
+ * 通过参数传入。
463
+ */
464
+
465
+ /** 事件类型 → 中文标签(CSV 默认;client 可传 labels 覆盖)。非导出常量。 */
466
+ const DEFAULT_CSV_LABELS = Object.freeze({
467
+ time: '时间',
468
+ type: '类型',
469
+ tool: '工具',
470
+ result: '结果',
471
+ typeMap: Object.freeze({
472
+ agent_status: 'agent 状态',
473
+ llm_stream: '模型流',
474
+ tool_call: '工具调用',
475
+ tool_result: '工具结果',
476
+ }),
477
+ ok: '成功',
478
+ fail: '失败',
479
+ error: '错误',
480
+ })
481
+
482
+ const MAX_STATS_TOP = 50
483
+
484
+ /** 两位补零。 */
485
+ function pad2(n) {
486
+ return String(n).padStart(2, '0')
487
+ }
488
+
489
+ /** 毫秒时间戳 → `YYYY-MM-DD HH:MM:SS`(本地时区);非法输入返回空串。 */
490
+ function formatTime(time) {
491
+ if (typeof time !== 'number' || !Number.isFinite(time)) return ''
492
+ const d = new Date(time)
493
+ if (Number.isNaN(d.getTime())) return ''
494
+ const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
495
+ const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
496
+ return `${date} ${clock}`
497
+ }
498
+
499
+ /** agent 状态事件的搜索片段。 */
500
+ function agentStatusParts(data) {
501
+ return [data.status, data.agentType].filter((part) => typeof part === 'string')
502
+ }
503
+
504
+ /** 模型流事件的搜索片段(含错误消息)。 */
505
+ function llmParts(data) {
506
+ const parts = [data.phase]
507
+ if (typeof data.message === 'string' && data.message !== '') parts.push(data.message)
508
+ return parts
509
+ }
510
+
511
+ /** 工具调用事件的搜索片段(工具名 + 参数键 + 参数摘要)。 */
512
+ function toolCallParts(data) {
513
+ const parts = []
514
+ if (typeof data.name === 'string') parts.push(data.name)
515
+ if (Array.isArray(data.args?.keys)) parts.push(...data.args.keys)
516
+ if (typeof data.args?.summary === 'string' && data.args.summary !== '') parts.push(data.args.summary)
517
+ return parts
518
+ }
519
+
520
+ /** 工具结果事件的搜索片段(工具名 + 成败)。 */
521
+ function toolResultParts(data) {
522
+ const parts = []
523
+ if (typeof data.name === 'string') parts.push(data.name)
524
+ parts.push(data.ok === false ? '失败' : '成功')
525
+ return parts
526
+ }
527
+
528
+ /** 事件类型 → 搜索片段收集函数(查表消分支)。 */
529
+ const PARTS_COLLECTORS = {
530
+ agent_status: agentStatusParts,
531
+ llm_stream: llmParts,
532
+ tool_call: toolCallParts,
533
+ tool_result: toolResultParts,
534
+ }
535
+
536
+ /** 提取事件可用于关键词匹配的文本(工具名/参数摘要/错误信息/状态/阶段等)。 */
537
+ function searchableText(event) {
538
+ const data = event && event.data ? event.data : {}
539
+ const parts = [event?.type, event?.sessionId]
540
+ const collector = PARTS_COLLECTORS[event?.type]
541
+ if (collector !== undefined) parts.push(...collector(data))
542
+ return parts
543
+ .filter((part) => typeof part === 'string')
544
+ .join(' ')
545
+ .toLowerCase()
546
+ }
547
+
548
+ /** 事件是否命中关键词(不区分大小写;空关键词视为命中全部)。 */
549
+ function matchesKeyword(event, keyword) {
550
+ const kw = String(keyword ?? '')
551
+ .trim()
552
+ .toLowerCase()
553
+ if (kw === '') return true
554
+ return searchableText(event).includes(kw)
555
+ }
556
+
557
+ /** 返回 `true` 表示事件具备失败语义(工具失败 / 模型流出错)。 */
558
+ function isFailEvent(event) {
559
+ if (event?.type === 'tool_result') return event.data?.ok === false
560
+ if (event?.type === 'llm_stream') return event.data?.phase === 'error'
561
+ return false
562
+ }
563
+
564
+ /** 归一化过滤条件(时间转为闭区间数值;空值透传)。 */
565
+ function normalizeCriteria(criteria) {
566
+ const start =
567
+ typeof criteria.timeStart === 'number' && Number.isFinite(criteria.timeStart) ? criteria.timeStart : undefined
568
+ const end = typeof criteria.timeEnd === 'number' && Number.isFinite(criteria.timeEnd) ? criteria.timeEnd : undefined
569
+ return { type: criteria.type ?? '', keyword: criteria.keyword ?? '', result: criteria.result ?? '', start, end }
570
+ }
571
+
572
+ /** 类型过滤('tool' 表示 tool_call + tool_result)。 */
573
+ function passType(type, filterType) {
574
+ if (filterType === '') return true
575
+ if (filterType === 'tool') return type === 'tool_call' || type === 'tool_result'
576
+ return type === filterType
577
+ }
578
+
579
+ /** 时间范围闭区间。 */
580
+ function passTime(time, start, end) {
581
+ if (start !== undefined && time < start) return false
582
+ if (end !== undefined && time > end) return false
583
+ return true
584
+ }
585
+
586
+ /** 成功/失败过滤:只作用于有成败语义的事件,其余事件透传。 */
587
+ function passResult(event, result) {
588
+ if (result === '') return true
589
+ if (result === 'success') return !isFailEvent(event)
590
+ if (result === 'fail') return isFailEvent(event)
591
+ return true
592
+ }
593
+
594
+ /** 组合过滤:类型(tool 表示 tool_call+tool_result)+ 时间范围 + 成功/失败 + 关键词。
595
+ * criteria: { type, timeStart, timeEnd, result, keyword } */
596
+ function applyAuditFilter(events, criteria = {}) {
597
+ const ctx = normalizeCriteria(criteria)
598
+ return (events ?? []).filter(
599
+ (event) =>
600
+ passType(event.type, ctx.type) &&
601
+ passTime(event.time, ctx.start, ctx.end) &&
602
+ passResult(event, ctx.result) &&
603
+ matchesKeyword(event, ctx.keyword),
604
+ )
605
+ }
606
+
607
+ /** CSV 单元格转义:含逗号/引号/换行时用双引号包裹并转义内嵌引号。 */
608
+ function csvCell(value) {
609
+ const s = value === null || value === undefined ? '' : String(value)
610
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
611
+ }
612
+
613
+ /** 事件的工具名(仅 tool_call/tool_result;否则空)。 */
614
+ function toolNameOf(event) {
615
+ if (event?.type === 'tool_call' || event?.type === 'tool_result') return String(event.data?.name ?? '')
616
+ return ''
617
+ }
618
+
619
+ /** 事件的结果摘要(成功/失败/错误;其余空)。 */
620
+ function resultTextOf(event, labels = DEFAULT_CSV_LABELS) {
621
+ if (event?.type === 'tool_result') return event.data?.ok === false ? labels.fail : labels.ok
622
+ if (event?.type === 'llm_stream' && event.data?.phase === 'error') return labels.error
623
+ return ''
624
+ }
625
+
626
+ /** 生成 CSV 摘要(表头:时间/类型/工具/结果)。labels 可覆盖默认中文。
627
+ * 返回不含换行结尾符的 CSV 文本。 */
628
+ function auditToCsv(events, labels = DEFAULT_CSV_LABELS) {
629
+ const typeMap = labels.typeMap ?? {}
630
+ const header = [labels.time, labels.type, labels.tool, labels.result]
631
+ const lines = [header.map(csvCell).join(',')]
632
+ for (const event of events ?? []) {
633
+ const typeLabel = typeMap[event.type] ?? String(event.type)
634
+ const row = [formatTime(event.time), typeLabel, toolNameOf(event), resultTextOf(event, labels)]
635
+ lines.push(row.map(csvCell).join(','))
636
+ }
637
+ return lines.join('\n')
638
+ }
639
+
640
+ /** 生成 JSON 完整数据(缩进默认 2)。 */
641
+ function auditToJson(events, space = 2) {
642
+ return JSON.stringify(events ?? [], null, space)
643
+ }
644
+
645
+ /** 事件是否为工具类(tool_call / tool_result)。 */
646
+ function isToolEvent(event) {
647
+ return event?.type === 'tool_call' || event?.type === 'tool_result'
648
+ }
649
+
650
+ /** 把单条工具事件计入聚合(调用次数 / 失败次数)。 */
651
+ function bumpTool(byTool, event, name) {
652
+ const entry = byTool.get(name) ?? { tool: name, calls: 0, fails: 0 }
653
+ if (event.type === 'tool_call') entry.calls += 1
654
+ if (event.type === 'tool_result' && event.data?.ok === false) entry.fails += 1
655
+ byTool.set(name, entry)
656
+ }
657
+
658
+ /** 按工具名聚合调用次数与失败次数。 */
659
+ function aggregateToolStats(events) {
660
+ const byTool = new Map()
661
+ for (const event of events ?? []) {
662
+ if (!isToolEvent(event)) continue
663
+ const name = String(event.data?.name ?? '')
664
+ if (name === '') continue
665
+ bumpTool(byTool, event, name)
666
+ }
667
+ return byTool
668
+ }
669
+
670
+ /** 聚合结果 → 排序 + 失败率列表。 */
671
+ function rankTools(byTool) {
672
+ return [...byTool.values()]
673
+ .map((entry) => ({ ...entry, failRate: entry.calls > 0 ? entry.fails / entry.calls : 0 }))
674
+ .sort((a, b) => b.calls - a.calls || b.fails - a.fails || a.tool.localeCompare(b.tool))
675
+ }
676
+
677
+ /** 工具调用统计:每个工具调用次数 + 失败率(topN 截断,默认 5)。
678
+ * 返回 [{ tool, calls, fails, failRate }] 按调用次数降序。 */
679
+ function computeToolStats(events, topN = 5) {
680
+ const n = typeof topN === 'number' && topN > 0 ? Math.min(topN, MAX_STATS_TOP) : 5
681
+ return rankTools(aggregateToolStats(events)).slice(0, n)
682
+ }
683
+
684
+ /** 把 text 按 keyword 切成 [ { text, hit } ] 分段(用于命中关键词高亮)。
685
+ * 空关键词返回整段未命中。 */
686
+ function highlightSegments(text, keyword) {
687
+ const raw = String(text ?? '')
688
+ const kw = String(keyword ?? '')
689
+ .trim()
690
+ .toLowerCase()
691
+ if (kw === '') return [{ text: raw, hit: false }]
692
+ const lower = raw.toLowerCase()
693
+ const out = []
694
+ let cursor = 0
695
+ for (;;) {
696
+ const idx = lower.indexOf(kw, cursor)
697
+ if (idx === -1) {
698
+ if (cursor < raw.length) out.push({ text: raw.slice(cursor), hit: false })
699
+ break
700
+ }
701
+ if (idx > cursor) out.push({ text: raw.slice(cursor, idx), hit: false })
702
+ out.push({ text: raw.slice(idx, idx + kw.length), hit: true })
703
+ cursor = idx + kw.length
704
+ }
705
+ return out.length === 0 ? [{ text: raw, hit: false }] : out
706
+ }
707
+
411
708
  // ── 轨迹回放面板(时间轴)──────────────────────────────────────────
412
- const REPLAY_POLL_MS = 5000
709
+ // 拆分的审计视图组件(搜索/组合过滤/导出/统计/高亮/hook)位于
710
+ // replay-ext.js 片段(见 client.src.js 占位符顺序)。
413
711
 
414
712
  /** 请求插件 API(非 2xx 抛错;返回响应 JSON 的 value 字段)。 */
415
713
  function apiJson(path, options) {
@@ -515,8 +813,9 @@ function eventMeta(event) {
515
813
  return ''
516
814
  }
517
815
 
518
- /** 单条事件行:节点圆点 + 类型图标 + 徽标/时间 + 摘要(hover/active 反馈)。 */
519
- function EventRow({ event }) {
816
+ /** 单条事件行:节点圆点 + 类型图标 + 徽标/时间 + 摘要(hover/active 反馈)。
817
+ * 摘要命中关键词时以 mark 高亮。 */
818
+ function EventRow({ event, keyword }) {
520
819
  const meta = eventMeta(event)
521
820
  const kind = typeKind(event)
522
821
  return createElement(
@@ -541,7 +840,13 @@ function EventRow({ event }) {
541
840
  ),
542
841
  createElement('span', { className: 'dsh-my-observability-time' }, timeText(event.time)),
543
842
  ),
544
- meta !== '' ? createElement('span', { className: 'dsh-my-observability-event-meta' }, meta) : null,
843
+ meta !== ''
844
+ ? createElement(
845
+ 'span',
846
+ { className: 'dsh-my-observability-event-meta' },
847
+ createElement(HighlightText, { text: meta, keyword }),
848
+ )
849
+ : null,
545
850
  ),
546
851
  )
547
852
  }
@@ -573,14 +878,6 @@ function TypeFilter({ filter, onFilter }) {
573
878
  )
574
879
  }
575
880
 
576
- /** 按过滤条件筛选事件(tool = tool_call + tool_result)。 */
577
- function filterEvents(events, filter) {
578
- if (filter === '') return events
579
- return events.filter((event) =>
580
- filter === 'tool' ? event.type === 'tool_call' || event.type === 'tool_result' : event.type === filter,
581
- )
582
- }
583
-
584
881
  /** 拉取会话列表与事件(选中为空时自动选当前/首个会话)。 */
585
882
  async function loadReplayData(selected, currentSession, setters) {
586
883
  try {
@@ -681,13 +978,324 @@ function ErrorState({ message, onRetry }) {
681
978
  )
682
979
  }
683
980
 
684
- /** 轨迹回放主面板:会话选择 + 类型过滤 + 时间轴(可见时轮询)。 */
981
+ /** 轨迹回放主面板:会话选择 + 类型过滤 + 搜索/组合过滤 + 导出 + 统计 + 时间轴。
982
+ * 状态与派生值集中在 replay-ext.js 的 useReplayState;本组件只拼装视图。
983
+ * 面板可见时轮询、隐藏时暂停。 */
685
984
  function ReplayPanel(props) {
686
- const currentSession = props.scope?.sessionId || ''
687
- const visible = props.visible !== false
985
+ const s = useReplayState(props)
986
+ return createElement(
987
+ 'div',
988
+ { className: 'dsh-my-observability-panel' },
989
+ createElement(ReplayToolbar, {
990
+ sessions: s.sessions,
991
+ selected: s.selected,
992
+ onSelect: s.setSelected,
993
+ filter: s.filter,
994
+ onFilter: s.setFilter,
995
+ onRefresh: s.retry,
996
+ }),
997
+ createElement(SearchFilterBar, {
998
+ keyword: s.keyword,
999
+ onKeyword: s.setKeyword,
1000
+ timeStart: s.timeStart,
1001
+ onTimeStart: s.setTimeStart,
1002
+ timeEnd: s.timeEnd,
1003
+ onTimeEnd: s.setTimeEnd,
1004
+ result: s.result,
1005
+ onResult: s.setResult,
1006
+ onClear: s.clearFilters,
1007
+ }),
1008
+ createElement(ExportBar, {
1009
+ scope: s.scope,
1010
+ onScope: s.setScope,
1011
+ onExportJson: () => void s.onExport('json'),
1012
+ onExportCsv: () => void s.onExport('csv'),
1013
+ showStats: s.showStats,
1014
+ onToggleStats: () => s.setShowStats((value) => !value),
1015
+ disabled: !s.canExport,
1016
+ }),
1017
+ s.error !== '' ? createElement(ErrorState, { message: s.error, onRetry: s.retry }) : null,
1018
+ s.loading && s.error === '' ? createElement(LoadingState, null) : null,
1019
+ !s.loading && s.error === '' && s.filtered.length === 0
1020
+ ? s.hasFilter
1021
+ ? createElement(NoMatchesState, null)
1022
+ : createElement(EmptyState, null)
1023
+ : null,
1024
+ s.showStats ? createElement(StatsPanel, { events: s.filtered }) : null,
1025
+ createElement('div', { className: 'dsh-my-observability-timeline' }, s.rows),
1026
+ )
1027
+ }
1028
+
1029
+ // ── 审计视图扩展:搜索 / 组合过滤 / 导出 / 统计 / 高亮(replay.js 拆出)────
1030
+ // 依赖 replay.js(REPLAY_POLL_MS 等常量与 loadReplayData/EventRow)与
1031
+ // audit-view.js 纯函数(applyAuditFilter 等)。始终以 function 声明提升。
1032
+
1033
+ const REPLAY_POLL_MS = 5000
1034
+ const ALL_SESSIONS = '*'
1035
+ const EXPORT_LIMIT_ALL = 20000
1036
+
1037
+ /** `datetime-local` 值 → 毫秒时间戳(空/非法返回 undefined)。 */
1038
+ function datetimeToMs(value) {
1039
+ if (typeof value !== 'string' || value.trim() === '') return undefined
1040
+ const ms = new Date(value).getTime()
1041
+ return Number.isFinite(ms) ? ms : undefined
1042
+ }
1043
+
1044
+ /** 是否有活动过滤条件(决定无匹配/空状态展示)。 */
1045
+ function hasActiveFilter(criteria) {
1046
+ return (
1047
+ criteria.keyword !== '' ||
1048
+ criteria.result !== '' ||
1049
+ criteria.timeStart !== undefined ||
1050
+ criteria.timeEnd !== undefined ||
1051
+ criteria.type !== ''
1052
+ )
1053
+ }
1054
+
1055
+ /** 命中关键词高亮(`mark` 包裹命中段;无关键词时原样文本)。 */
1056
+ function HighlightText({ text, keyword }) {
1057
+ const segments = highlightSegments(text, keyword)
1058
+ return createElement(
1059
+ 'span',
1060
+ null,
1061
+ segments.map((seg, index) =>
1062
+ seg.hit ? createElement('mark', { key: index, className: 'dsh-my-observability-mark' }, seg.text) : seg.text,
1063
+ ),
1064
+ )
1065
+ }
1066
+
1067
+ /** 搜索 / 组合过滤栏:关键词 + 时间范围 + 成功/失败。 */
1068
+ /** 搜索框行(含清除按钮)。 */
1069
+ function SearchInput({ keyword, onKeyword, onClear }) {
1070
+ return createElement(
1071
+ 'div',
1072
+ { className: 'dsh-my-observability-search-row' },
1073
+ createElement('input', {
1074
+ className: 'dsh-my-observability-input',
1075
+ type: 'search',
1076
+ value: keyword,
1077
+ placeholder: strings.searchPlaceholder(),
1078
+ onChange: (e) => onKeyword(e.target.value),
1079
+ }),
1080
+ keyword !== ''
1081
+ ? createElement(
1082
+ 'button',
1083
+ {
1084
+ type: 'button',
1085
+ className: 'dsh-my-observability-iconbtn',
1086
+ 'aria-label': strings.clearFilters(),
1087
+ title: strings.clearFilters(),
1088
+ onClick: onClear,
1089
+ },
1090
+ icon.close(15),
1091
+ )
1092
+ : null,
1093
+ )
1094
+ }
1095
+
1096
+ /** 时间范围行(开始/结束,datetime-local)。 */
1097
+ function TimeRangeInput({ timeStart, onTimeStart, timeEnd, onTimeEnd }) {
1098
+ return createElement(
1099
+ 'div',
1100
+ { className: 'dsh-my-observability-time-row' },
1101
+ createElement('label', { className: 'dsh-my-observability-time-label' }, strings.timeStartLabel()),
1102
+ createElement('input', {
1103
+ className: 'dsh-my-observability-input dsh-my-observability-time-input',
1104
+ type: 'datetime-local',
1105
+ value: timeStart,
1106
+ onChange: (e) => onTimeStart(e.target.value),
1107
+ }),
1108
+ createElement('label', { className: 'dsh-my-observability-time-label' }, strings.timeEndLabel()),
1109
+ createElement('input', {
1110
+ className: 'dsh-my-observability-input dsh-my-observability-time-input',
1111
+ type: 'datetime-local',
1112
+ value: timeEnd,
1113
+ onChange: (e) => onTimeEnd(e.target.value),
1114
+ }),
1115
+ )
1116
+ }
1117
+
1118
+ /** 成功/失败结果过滤组。 */
1119
+ function ResultFilter({ result, onResult }) {
1120
+ const options = [
1121
+ ['', strings.filterAllResult()],
1122
+ ['success', strings.filterSuccess()],
1123
+ ['fail', strings.filterFail()],
1124
+ ]
1125
+ return createElement(
1126
+ 'div',
1127
+ { className: 'dsh-my-observability-filters' },
1128
+ options.map(([value, label]) =>
1129
+ createElement(
1130
+ 'button',
1131
+ {
1132
+ key: value,
1133
+ type: 'button',
1134
+ className: `dsh-my-observability-chip${result === value ? ' dsh-my-observability-chip-active' : ''}`,
1135
+ 'aria-pressed': result === value,
1136
+ onClick: () => onResult(value),
1137
+ },
1138
+ label,
1139
+ ),
1140
+ ),
1141
+ )
1142
+ }
1143
+
1144
+ /** 搜索 / 组合过滤栏:关键词 + 时间范围 + 成功/失败。 */
1145
+ function SearchFilterBar({
1146
+ keyword,
1147
+ onKeyword,
1148
+ timeStart,
1149
+ onTimeStart,
1150
+ timeEnd,
1151
+ onTimeEnd,
1152
+ result,
1153
+ onResult,
1154
+ onClear,
1155
+ }) {
1156
+ return createElement(
1157
+ 'div',
1158
+ { className: 'dsh-my-observability-toolbar' },
1159
+ createElement(SearchInput, { keyword, onKeyword, onClear }),
1160
+ createElement(TimeRangeInput, { timeStart, onTimeStart, timeEnd, onTimeEnd }),
1161
+ createElement(ResultFilter, { result, onResult }),
1162
+ )
1163
+ }
1164
+
1165
+ /** 导出栏:导出范围选择(当前会话/全部会话)+ JSON/CSV 按钮 + 统计开关。 */
1166
+ function ExportBar({ scope, onScope, onExportJson, onExportCsv, showStats, onToggleStats, disabled }) {
1167
+ return createElement(
1168
+ 'div',
1169
+ { className: 'dsh-my-observability-export' },
1170
+ createElement(
1171
+ 'div',
1172
+ { className: 'dsh-my-observability-toolbar-row' },
1173
+ createElement(
1174
+ 'select',
1175
+ {
1176
+ className: 'dsh-my-observability-select',
1177
+ value: scope,
1178
+ disabled,
1179
+ onChange: (e) => onScope(e.target.value),
1180
+ },
1181
+ createElement('option', { value: 'session' }, strings.scopeSession()),
1182
+ createElement('option', { value: 'all' }, strings.scopeAll()),
1183
+ ),
1184
+ createElement(
1185
+ 'button',
1186
+ { type: 'button', className: 'dsh-my-observability-btn', disabled, onClick: onExportJson },
1187
+ strings.exportJson(),
1188
+ ),
1189
+ createElement(
1190
+ 'button',
1191
+ { type: 'button', className: 'dsh-my-observability-btn', disabled, onClick: onExportCsv },
1192
+ strings.exportCsv(),
1193
+ ),
1194
+ createElement(
1195
+ 'button',
1196
+ {
1197
+ type: 'button',
1198
+ className: 'dsh-my-observability-btn',
1199
+ 'aria-pressed': showStats,
1200
+ onClick: onToggleStats,
1201
+ },
1202
+ strings.statsTitle(),
1203
+ ),
1204
+ ),
1205
+ )
1206
+ }
1207
+
1208
+ /** 工具调用统计视图(Top N 调用次数 + 失败率)。 */
1209
+ function StatsPanel({ events }) {
1210
+ const stats = computeToolStats(events, 5)
1211
+ if (stats.length === 0)
1212
+ return createElement('div', { className: 'dsh-my-observability-stats-empty' }, strings.statsEmpty())
1213
+ return createElement(
1214
+ 'div',
1215
+ { className: 'dsh-my-observability-stats' },
1216
+ createElement('div', { className: 'dsh-my-observability-stats-title' }, strings.statsTitle()),
1217
+ createElement(
1218
+ 'table',
1219
+ { className: 'dsh-my-observability-stats-table' },
1220
+ createElement(
1221
+ 'thead',
1222
+ null,
1223
+ createElement(
1224
+ 'tr',
1225
+ null,
1226
+ createElement('th', null, strings.statsTool()),
1227
+ createElement('th', null, strings.statsCalls()),
1228
+ createElement('th', null, strings.statsFailRate()),
1229
+ ),
1230
+ ),
1231
+ createElement(
1232
+ 'tbody',
1233
+ null,
1234
+ stats.map((s) =>
1235
+ createElement(
1236
+ 'tr',
1237
+ { key: s.tool },
1238
+ createElement('td', null, s.tool),
1239
+ createElement('td', null, String(s.calls)),
1240
+ createElement('td', null, `${(s.failRate * 100).toFixed(1)}%`),
1241
+ ),
1242
+ ),
1243
+ ),
1244
+ ),
1245
+ )
1246
+ }
1247
+
1248
+ /** 无匹配状态(搜索/过滤条件命中 0 条)。 */
1249
+ function NoMatchesState() {
1250
+ return createElement(
1251
+ 'div',
1252
+ { className: 'dsh-my-observability-empty' },
1253
+ createElement('span', { className: 'dsh-my-observability-empty-icon' }, icon.search(20)),
1254
+ createElement('span', null, strings.noMatches()),
1255
+ )
1256
+ }
1257
+
1258
+ /** 触发浏览器下载(Blob + a[download])。 */
1259
+ function downloadAudit(content, format) {
1260
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
1261
+ const filename = `${strings.exportFileName()}-${stamp}.${format}`
1262
+ const mime = format === 'json' ? 'application/json' : 'text/csv;charset=utf-8'
1263
+ const blob = new Blob([content], { type: mime })
1264
+ const url = URL.createObjectURL(blob)
1265
+ const a = document.createElement('a')
1266
+ a.href = url
1267
+ a.download = filename
1268
+ document.body.appendChild(a)
1269
+ a.click()
1270
+ a.remove()
1271
+ URL.revokeObjectURL(url)
1272
+ }
1273
+
1274
+ /** 拉取全部会话事件(导出范围「全部会话」用),并应用当前过滤条件。 */
1275
+ async function allSessionEvents(criteria) {
1276
+ const all = await apiJson(
1277
+ `/observability/api/events?sessionId=${encodeURIComponent(ALL_SESSIONS)}&limit=${EXPORT_LIMIT_ALL}`,
1278
+ )
1279
+ return applyAuditFilter(all, criteria)
1280
+ }
1281
+
1282
+ /** 导出:依据范围(当前会话=过滤后结果 / 全部会话=拉取后过滤)生成并下载。 */
1283
+ async function runExport(format, scope, filtered, criteria, onError) {
1284
+ try {
1285
+ const dataEvents = scope === 'session' ? filtered : await allSessionEvents(criteria)
1286
+ downloadAudit(format === 'json' ? auditToJson(dataEvents) : auditToCsv(dataEvents), format)
1287
+ } catch (err) {
1288
+ onError(err instanceof Error ? err.message : String(err))
1289
+ }
1290
+ }
1291
+
1292
+ /** 轨迹回放面板的数据状态:会话列表 + 选中 + 事件 + 轮询 + 加载/错误。
1293
+ * 隐藏时暂停轮询,可见时按 REPLAY_POLL_MS 拉取并自动选当前会话。 */
1294
+ function useReplayDataState(props) {
1295
+ const currentSession = props?.scope?.sessionId || ''
1296
+ const visible = props?.visible !== false
688
1297
  const [sessions, setSessions] = useState([])
689
1298
  const [selected, setSelected] = useState('')
690
- const [filter, setFilter] = useState('')
691
1299
  const [events, setEvents] = useState([])
692
1300
  const [loading, setLoading] = useState(true)
693
1301
  const [error, setError] = useState('')
@@ -714,25 +1322,84 @@ function ReplayPanel(props) {
714
1322
  setReloadTick((tick) => tick + 1)
715
1323
  }
716
1324
 
717
- const filtered = filterEvents(events, filter)
718
- const rows = filtered.map((event, index) => createElement(EventRow, { key: event.id ?? index, event }))
1325
+ return {
1326
+ currentSession,
1327
+ sessions,
1328
+ selected,
1329
+ events,
1330
+ loading,
1331
+ error,
1332
+ setSessions,
1333
+ setSelected,
1334
+ setEvents,
1335
+ setLoading,
1336
+ setError,
1337
+ retry,
1338
+ }
1339
+ }
719
1340
 
720
- return createElement(
721
- 'div',
722
- { className: 'dsh-my-observability-panel' },
723
- createElement(ReplayToolbar, {
724
- sessions,
725
- selected,
726
- onSelect: setSelected,
727
- filter,
728
- onFilter: setFilter,
729
- onRefresh: retry,
730
- }),
731
- error !== '' ? createElement(ErrorState, { message: error, onRetry: retry }) : null,
732
- loading && error === '' ? createElement(LoadingState, null) : null,
733
- !loading && error === '' && filtered.length === 0 ? createElement(EmptyState, null) : null,
734
- createElement('div', { className: 'dsh-my-observability-timeline' }, rows),
735
- )
1341
+ /** 轨迹回放面板的视图状态:过滤条件(类型/关键词/时间/成功失败)+ 导出范围 +
1342
+ * 统计开关,及派生值(过滤结果 / 高亮行 / 可导出)。 */
1343
+ function useReplayState(props) {
1344
+ const data = useReplayDataState(props)
1345
+ const [filter, setFilter] = useState('')
1346
+ const [keyword, setKeyword] = useState('')
1347
+ const [timeStart, setTimeStart] = useState('')
1348
+ const [timeEnd, setTimeEnd] = useState('')
1349
+ const [result, setResult] = useState('')
1350
+ const [scope, setScope] = useState('session')
1351
+ const [showStats, setShowStats] = useState(false)
1352
+
1353
+ const clearFilters = () => {
1354
+ setKeyword('')
1355
+ setTimeStart('')
1356
+ setTimeEnd('')
1357
+ setResult('')
1358
+ setFilter('')
1359
+ }
1360
+
1361
+ const criteria = {
1362
+ type: filter,
1363
+ keyword,
1364
+ timeStart: datetimeToMs(timeStart),
1365
+ timeEnd: datetimeToMs(timeEnd),
1366
+ result,
1367
+ }
1368
+ const filtered = applyAuditFilter(data.events, criteria)
1369
+ const hasFilter = hasActiveFilter(criteria)
1370
+ const rows = filtered.map((event, index) => createElement(EventRow, { key: event.id ?? index, event, keyword }))
1371
+ const canExport = !data.loading && data.events.length > 0
1372
+ const onExport = (format) => void runExport(format, scope, filtered, criteria, data.setError)
1373
+
1374
+ return {
1375
+ sessions: data.sessions,
1376
+ selected: data.selected,
1377
+ events: data.events,
1378
+ loading: data.loading,
1379
+ error: data.error,
1380
+ filter,
1381
+ keyword,
1382
+ timeStart,
1383
+ timeEnd,
1384
+ result,
1385
+ scope,
1386
+ showStats,
1387
+ setSelected: data.setSelected,
1388
+ setFilter,
1389
+ setKeyword,
1390
+ setTimeStart,
1391
+ setTimeEnd,
1392
+ setResult,
1393
+ setScope,
1394
+ setShowStats,
1395
+ retry: data.retry,
1396
+ clearFilters,
1397
+ filtered,
1398
+ hasFilter,
1399
+ rows,
1400
+ canExport,
1401
+ onExport,
1402
+ }
736
1403
  }
737
1404
 
738
1405
  // ── Git 工具 + 增量 diff 审查面板 ──────────────────────────────────
@@ -1108,6 +1775,23 @@ const STYLES = `
1108
1775
  .dsh-my-observability-error-text{flex:1;min-width:0}
1109
1776
  @keyframes dsh-my-observability-row-in{from{opacity:0;transform:translateY(1px)}to{opacity:1;transform:none}}
1110
1777
  @keyframes dsh-my-observability-spin{to{transform:rotate(360deg)}}
1778
+ /* ── 审计视图:搜索 / 组合过滤 / 导出 / 统计 / 高亮 ── */
1779
+ .dsh-my-observability-search-row{display:flex;align-items:center;gap:6px}
1780
+ .dsh-my-observability-search-row .dsh-my-observability-input{flex:1}
1781
+ .dsh-my-observability-time-row{display:flex;align-items:center;gap:6px}
1782
+ .dsh-my-observability-time-label{flex:none;font:var(--dsw-font-xxxs-11);color:var(--dsw-alias-label-tertiary)}
1783
+ .dsh-my-observability-time-input{flex:1;min-width:0}
1784
+ .dsh-my-observability-mark{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary) 30%, transparent);
1785
+ color:var(--dsw-alias-label-primary);border-radius:2px;padding:0 1px}
1786
+ .dsh-my-observability-export{display:flex;flex-direction:column;gap:6px}
1787
+ .dsh-my-observability-stats{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l2);
1788
+ border-radius:6px;padding:8px}
1789
+ .dsh-my-observability-stats-title{font:var(--dsw-font-xs-strong-13);color:var(--dsw-alias-label-primary)}
1790
+ .dsh-my-observability-stats-table{width:100%;border-collapse:collapse;font:var(--dsw-font-xxs-12)}
1791
+ .dsh-my-observability-stats-table th,.dsh-my-observability-stats-table td{text-align:left;padding:3px 6px;
1792
+ border-bottom:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary)}
1793
+ .dsh-my-observability-stats-table th{font:var(--dsw-font-xxxs-strong-11);color:var(--dsw-alias-label-tertiary)}
1794
+ .dsh-my-observability-stats-empty{font:var(--dsw-font-xxs-12);color:var(--dsw-alias-label-tertiary);padding:6px 2px}
1111
1795
  /* ── Git 面板 ── */
1112
1796
  .dsh-my-observability-status{font:var(--dsw-font-xxs-strong-12);color:var(--dsw-alias-label-secondary)}
1113
1797
  .dsh-my-observability-actions{display:flex;gap:8px;flex-wrap:wrap}