@mingozhou/dsh-replay 0.3.3 → 0.4.2

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.
Files changed (53) hide show
  1. package/README.md +225 -164
  2. package/README.zh.md +225 -164
  3. package/assets/icon.png +0 -0
  4. package/lib/client.js +955 -2419
  5. package/lib/core.js +557 -86
  6. package/lib/index.js +1043 -51
  7. package/lib/types/src/client/api.d.ts +44 -0
  8. package/lib/types/src/client/components/AuditView.d.ts +2 -1
  9. package/lib/types/src/client/components/CompareView.d.ts +2 -1
  10. package/lib/types/src/client/components/DashboardView.d.ts +8 -0
  11. package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
  12. package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
  13. package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
  14. package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
  15. package/lib/types/src/client/components/TimelineView.d.ts +2 -1
  16. package/lib/types/src/client/export.d.ts +6 -1
  17. package/lib/types/src/client/i18n.d.ts +8 -1
  18. package/lib/types/src/client/pricing.d.ts +6 -0
  19. package/lib/types/src/core/audit.d.ts +18 -0
  20. package/lib/types/src/core/compare.d.ts +8 -3
  21. package/lib/types/src/core/cost.d.ts +21 -3
  22. package/lib/types/src/core/index.d.ts +1 -0
  23. package/lib/types/src/core/redact.d.ts +40 -0
  24. package/lib/types/src/core/timeline.d.ts +9 -0
  25. package/lib/types/src/index.d.ts +52 -1
  26. package/lib/viewer.js +282 -29
  27. package/package.json +118 -107
  28. package/scripts/build.mjs +8 -2
  29. package/src/client/api.ts +114 -10
  30. package/src/client/components/AuditView.tsx +89 -10
  31. package/src/client/components/CompareView.tsx +89 -13
  32. package/src/client/components/DashboardView.tsx +232 -0
  33. package/src/client/components/ExportDialog.tsx +136 -0
  34. package/src/client/components/ItemDetail.tsx +36 -4
  35. package/src/client/components/OverviewView.tsx +150 -34
  36. package/src/client/components/ReplayApp.tsx +207 -60
  37. package/src/client/components/ReplayModal.tsx +41 -6
  38. package/src/client/components/SessionPicker.tsx +55 -27
  39. package/src/client/components/TimelineView.tsx +106 -41
  40. package/src/client/export.ts +20 -4
  41. package/src/client/i18n.ts +266 -45
  42. package/src/client/index.ts +7 -5
  43. package/src/client/mascot-image.ts +1 -1
  44. package/src/client/pricing.ts +90 -0
  45. package/src/client/styles.css +250 -1
  46. package/src/client/viewer-entry.tsx +25 -4
  47. package/src/core/audit.ts +274 -67
  48. package/src/core/compare.ts +0 -0
  49. package/src/core/cost.ts +94 -16
  50. package/src/core/index.ts +1 -0
  51. package/src/core/redact.ts +182 -0
  52. package/src/core/timeline.ts +107 -12
  53. package/src/index.ts +290 -54
package/package.json CHANGED
@@ -1,107 +1,118 @@
1
- {
2
- "name": "@mingozhou/dsh-replay",
3
- "version": "0.3.3",
4
- "description": "Session replay, fork-tree, audit & compare visualization for DeepSeek Harness — time machine for your agent sessions",
5
- "main": "lib/index.js",
6
- "directories": {
7
- "test": "tests"
8
- },
9
- "scripts": {
10
- "build": "node scripts/build.mjs && tsc -p tsconfig.json",
11
- "test": "vitest run",
12
- "demo": "node demo/serve.mjs",
13
- "screenshots": "node scripts/screenshot.mjs",
14
- "prepare": "node scripts/build.mjs"
15
- },
16
- "keywords": [
17
- "deepseek-harness",
18
- "dsh-plugin",
19
- "dsh",
20
- "agent",
21
- "session-replay",
22
- "observability",
23
- "audit",
24
- "timeline",
25
- "visualization"
26
- ],
27
- "author": "MingoZhou",
28
- "license": "MIT",
29
- "type": "module",
30
- "devDependencies": {
31
- "@types/node": "^26.2.0",
32
- "@types/react": "^19.2.18",
33
- "@types/react-dom": "^19.2.4",
34
- "esbuild": "^0.28.2",
35
- "playwright": "^1.62.1",
36
- "react": "^19.2.8",
37
- "react-dom": "^19.2.8",
38
- "typescript": "^7.0.2",
39
- "vitest": "^4.1.10"
40
- },
41
- "types": "lib/types/src/index.d.ts",
42
- "exports": {
43
- ".": {
44
- "types": "./lib/types/src/index.d.ts",
45
- "default": "./lib/index.js"
46
- },
47
- "./client": {
48
- "types": "./lib/types/src/client/index.d.ts",
49
- "default": "./lib/client.js"
50
- },
51
- "./core": {
52
- "types": "./lib/types/src/core/index.d.ts",
53
- "default": "./lib/core.js"
54
- },
55
- "./cordis.patch.yml": "./cordis.patch.yml",
56
- "./package.json": "./package.json"
57
- },
58
- "files": [
59
- "lib",
60
- "cordis.patch.yml",
61
- "scripts/build.mjs",
62
- "src",
63
- "types.d.ts",
64
- "tsconfig.json"
65
- ],
66
- "dsh": {
67
- "bundle": {
68
- "patch": "./cordis.patch.yml"
69
- },
70
- "client": {
71
- "platform": "web",
72
- "inject": [
73
- "@deepseek-ai/dsh-client-runtime"
74
- ]
75
- }
76
- },
77
- "peerDependencies": {
78
- "@deepseek-ai/cordis": "*",
79
- "@deepseek-ai/dsh-host-webserver": "*",
80
- "@deepseek-ai/dsh-session-query": "*",
81
- "react": "^18.2.0"
82
- },
83
- "peerDependenciesMeta": {
84
- "@deepseek-ai/cordis": {
85
- "optional": true
86
- },
87
- "@deepseek-ai/dsh-host-webserver": {
88
- "optional": true
89
- },
90
- "@deepseek-ai/dsh-session-query": {
91
- "optional": true
92
- },
93
- "react": {
94
- "optional": true
95
- }
96
- },
97
- "repository": {
98
- "type": "git",
99
- "url": "https://github.com/MingoZhou/dsh-replay"
100
- },
101
- "homepage": "https://mingozhou.github.io/dsh-replay/",
102
- "bugs": "https://github.com/MingoZhou/dsh-replay/issues",
103
- "publishConfig": {
104
- "registry": "https://registry.npmjs.org/",
105
- "access": "public"
106
- }
107
- }
1
+ {
2
+ "name": "@mingozhou/dsh-replay",
3
+ "version": "0.4.2",
4
+ "description": "Read-only session review for DeepSeek Harness: cost, audit, fork tree, compare and shareable HTML exports",
5
+ "icon": "./assets/icon.png",
6
+ "main": "lib/index.js",
7
+ "directories": {
8
+ "test": "tests"
9
+ },
10
+ "scripts": {
11
+ "build": "node scripts/build.mjs && tsc -p tsconfig.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "test": "vitest run",
14
+ "demo": "node demo/serve.mjs",
15
+ "screenshots": "node scripts/screenshot.mjs",
16
+ "prepare": "node scripts/build.mjs"
17
+ },
18
+ "keywords": [
19
+ "deepseek-harness",
20
+ "dsh-plugin",
21
+ "dsh",
22
+ "agent",
23
+ "session-replay",
24
+ "session-review",
25
+ "observability",
26
+ "agent-observability",
27
+ "audit",
28
+ "cost",
29
+ "timeline",
30
+ "visualization",
31
+ "html-export"
32
+ ],
33
+ "author": "MingoZhou",
34
+ "license": "MIT",
35
+ "type": "module",
36
+ "engines": {
37
+ "node": ">=22",
38
+ "dsh": ">=0.2.0-rc.8"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.2.0",
42
+ "@types/react": "^19.2.18",
43
+ "@types/react-dom": "^19.2.4",
44
+ "esbuild": "^0.28.2",
45
+ "playwright": "^1.62.1",
46
+ "react": "^19.2.8",
47
+ "react-dom": "^19.2.8",
48
+ "typescript": "^7.0.2",
49
+ "vitest": "^4.1.10"
50
+ },
51
+ "types": "lib/types/src/index.d.ts",
52
+ "exports": {
53
+ ".": {
54
+ "types": "./lib/types/src/index.d.ts",
55
+ "default": "./lib/index.js"
56
+ },
57
+ "./client": {
58
+ "types": "./lib/types/src/client/index.d.ts",
59
+ "default": "./lib/client.js"
60
+ },
61
+ "./core": {
62
+ "types": "./lib/types/src/core/index.d.ts",
63
+ "default": "./lib/core.js"
64
+ },
65
+ "./cordis.patch.yml": "./cordis.patch.yml",
66
+ "./package.json": "./package.json"
67
+ },
68
+ "files": [
69
+ "lib",
70
+ "cordis.patch.yml",
71
+ "scripts/build.mjs",
72
+ "src",
73
+ "types.d.ts",
74
+ "tsconfig.json",
75
+ "assets/icon.png"
76
+ ],
77
+ "dsh": {
78
+ "bundle": {
79
+ "patch": "./cordis.patch.yml"
80
+ },
81
+ "client": {
82
+ "platform": "web",
83
+ "inject": [
84
+ "@deepseek-ai/dsh-client-runtime"
85
+ ]
86
+ }
87
+ },
88
+ "peerDependencies": {
89
+ "@deepseek-ai/cordis": "*",
90
+ "@deepseek-ai/dsh-host-webserver": "*",
91
+ "@deepseek-ai/dsh-session-query": "*",
92
+ "react": "^18.2.0"
93
+ },
94
+ "peerDependenciesMeta": {
95
+ "@deepseek-ai/cordis": {
96
+ "optional": true
97
+ },
98
+ "@deepseek-ai/dsh-host-webserver": {
99
+ "optional": true
100
+ },
101
+ "@deepseek-ai/dsh-session-query": {
102
+ "optional": true
103
+ },
104
+ "react": {
105
+ "optional": true
106
+ }
107
+ },
108
+ "repository": {
109
+ "type": "git",
110
+ "url": "https://github.com/MingoZhou/dsh-replay"
111
+ },
112
+ "homepage": "https://mingozhou.github.io/dsh-replay/",
113
+ "bugs": "https://github.com/MingoZhou/dsh-replay/issues",
114
+ "publishConfig": {
115
+ "registry": "https://registry.npmjs.org/",
116
+ "access": "public"
117
+ }
118
+ }
package/scripts/build.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  * demo/dist/demo.js — fully self-contained demo bundle (react inlined).
9
9
  */
10
10
  import { build } from 'esbuild'
11
- import { copyFile, mkdir, writeFile } from 'node:fs/promises'
11
+ import { copyFile, mkdir, stat, writeFile } from 'node:fs/promises'
12
12
 
13
13
  const cssAsText = { loader: { '.css': 'text' } }
14
14
 
@@ -78,6 +78,8 @@ const client = await build({
78
78
  jsx: 'automatic',
79
79
  ...cssAsText,
80
80
  external: ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/*'],
81
+ minify: true,
82
+ legalComments: 'none',
81
83
  write: false,
82
84
  })
83
85
  const cjs = client.outputFiles[0].text
@@ -91,7 +93,7 @@ ${cjs}
91
93
  return module.exports;
92
94
  };
93
95
  if (typeof window !== 'undefined' && window.__ModuleLoader__) {
94
- window.__ModuleLoader__.load({ id: 'dsh-replay', factory: factory });
96
+ window.__ModuleLoader__.load({ id: '@mingozhou/dsh-replay', factory: factory });
95
97
  } else if (typeof module !== 'undefined' && module.exports) {
96
98
  module.exports = factory(typeof require === 'function' ? require : function (id) {
97
99
  throw new Error('unresolvable external: ' + id);
@@ -129,3 +131,7 @@ await build({
129
131
  })
130
132
 
131
133
  console.log('build ok: lib/index.js, lib/core.js, lib/client.js, lib/viewer.js, demo/dist/demo.js')
134
+ for (const file of ['lib/index.js', 'lib/core.js', 'lib/client.js', 'lib/viewer.js']) {
135
+ const info = await stat(file)
136
+ console.log(` ${file.padEnd(16)} ${(info.size / 1024).toFixed(1)} KB`)
137
+ }
package/src/client/api.ts CHANGED
@@ -1,15 +1,58 @@
1
1
  /** Data access for the UI — one HTTP implementation, one in-memory demo implementation. */
2
+ import { auditSession } from '../core/audit.js'
2
3
  import { fromInspection, parseSessionJsonl } from '../core/decode.js'
4
+ import { buildTimeline } from '../core/timeline.js'
3
5
  import type { ParsedSession, SessionHeader, StorageRecord } from '../core/types.js'
4
6
 
5
7
  export interface SessionEntry {
6
8
  header: SessionHeader
7
9
  title?: string
10
+ live?: boolean
11
+ }
12
+
13
+ export interface SessionSummaryData {
14
+ events: number
15
+ turns: number
16
+ steps: number
17
+ toolCalls: number
18
+ toolErrors: number
19
+ durationMs: number
20
+ models: string[]
21
+ tokens: {
22
+ input: number
23
+ output: number
24
+ cacheRead: number
25
+ cacheWrite: number
26
+ reasoning: number
27
+ billedInput: number
28
+ }
29
+ modelTotals: Record<string, {
30
+ input: number
31
+ output: number
32
+ cacheRead: number
33
+ cacheWrite: number
34
+ reasoning: number
35
+ billedInput: number
36
+ }>
37
+ findings: number
38
+ critical: number
39
+ serious: number
40
+ warning: number
41
+ info: number
42
+ }
43
+
44
+ export interface SessionSummaryEntry {
45
+ header: SessionHeader
46
+ title?: string
47
+ live?: boolean
48
+ summary?: SessionSummaryData
49
+ error?: string
8
50
  }
9
51
 
10
52
  export interface ReplayApi {
11
53
  listSessions: () => Promise<SessionEntry[]>
12
54
  getSession: (id: string) => Promise<ParsedSession>
55
+ getSummaries?: (limit?: number) => Promise<SessionSummaryEntry[]>
13
56
  /** Source of the standalone viewer bundle, for HTML export (optional). */
14
57
  getViewerSource?: () => Promise<string>
15
58
  }
@@ -36,6 +79,13 @@ export class HttpReplayApi implements ReplayApi {
36
79
  return fromInspection(body.header, body.events)
37
80
  }
38
81
 
82
+ async getSummaries(limit = 30): Promise<SessionSummaryEntry[]> {
83
+ const body = (await this.fetchJson(`/summary?limit=${limit}`)) as {
84
+ sessions: SessionSummaryEntry[]
85
+ }
86
+ return body.sessions
87
+ }
88
+
39
89
  async getViewerSource(): Promise<string> {
40
90
  const response = await fetch(`${this.base}/viewer.js`)
41
91
  if (!response.ok) throw new Error(`viewer bundle: HTTP ${response.status}`)
@@ -59,7 +109,9 @@ export class StaticReplayApi implements ReplayApi {
59
109
  }
60
110
 
61
111
  /** Build directly from already-decoded sessions (exported HTML, tooling). */
62
- static fromParsed(list: { header: ParsedSession['header']; events: ParsedSession['events']; title?: string }[]): StaticReplayApi {
112
+ static fromParsed(
113
+ list: { header: ParsedSession['header']; events: ParsedSession['events']; title?: string }[],
114
+ ): StaticReplayApi {
63
115
  const api = new StaticReplayApi([])
64
116
  for (const { header, events, title } of list) {
65
117
  api.sessions.set(header.id, { header, events, malformed: [] })
@@ -76,9 +128,9 @@ export class StaticReplayApi implements ReplayApi {
76
128
  }
77
129
 
78
130
  async listSessions(): Promise<SessionEntry[]> {
79
- return [...this.sessions.values()].map(s => ({
80
- header: s.header,
81
- title: this.titles.get(s.header.id),
131
+ return [...this.sessions.values()].map(session => ({
132
+ header: session.header,
133
+ title: this.titles.get(session.header.id),
82
134
  }))
83
135
  }
84
136
 
@@ -87,6 +139,36 @@ export class StaticReplayApi implements ReplayApi {
87
139
  if (session === undefined) throw new Error(`unknown session ${id}`)
88
140
  return session
89
141
  }
142
+
143
+ async getSummaries(limit = 30): Promise<SessionSummaryEntry[]> {
144
+ const entries = await this.listSessions()
145
+ return entries.slice(0, limit).map(entry => {
146
+ const session = this.sessions.get(entry.header.id)
147
+ if (session === undefined) return { header: entry.header, title: entry.title }
148
+ const model = buildTimeline(session)
149
+ const report = auditSession(session)
150
+ return {
151
+ header: entry.header,
152
+ ...(entry.title !== undefined ? { title: entry.title } : {}),
153
+ summary: {
154
+ events: model.eventCount,
155
+ turns: model.turns.length,
156
+ steps: model.turns.reduce((sum, turn) => sum + turn.steps.length, 0),
157
+ toolCalls: model.toolCalls.length,
158
+ toolErrors: model.toolCalls.filter(call => call.error !== undefined).length,
159
+ durationMs: model.durationMs,
160
+ models: model.models,
161
+ tokens: model.totals,
162
+ modelTotals: model.modelTotals,
163
+ findings: report.findings.length,
164
+ critical: report.counts.critical,
165
+ serious: report.counts.serious,
166
+ warning: report.counts.warning,
167
+ info: report.counts.info,
168
+ },
169
+ }
170
+ })
171
+ }
90
172
  }
91
173
 
92
174
  export function formatDuration(ms: number): string {
@@ -96,7 +178,9 @@ export function formatDuration(ms: number): string {
96
178
  if (s < 60) return `${s.toFixed(s < 10 ? 1 : 0)}s`
97
179
  const m = Math.floor(s / 60)
98
180
  if (m < 60) return `${m}m ${Math.round(s % 60)}s`
99
- return `${Math.floor(m / 60)}h ${m % 60}m`
181
+ const h = Math.floor(m / 60)
182
+ if (h < 48) return `${h}h ${m % 60}m`
183
+ return `${Math.floor(h / 24)}d ${h % 24}h`
100
184
  }
101
185
 
102
186
  export function formatTokens(n: number): string {
@@ -106,12 +190,32 @@ export function formatTokens(n: number): string {
106
190
  return String(n)
107
191
  }
108
192
 
193
+ /** Clock offset from the session start: mm:ss, or h:mm:ss for long sessions. */
109
194
  export function formatClock(time: number, startTime: number): string {
110
- const ms = time - startTime
111
- const s = Math.floor(ms / 1000)
112
- const mm = String(Math.floor(s / 60)).padStart(2, '0')
113
- const ss = String(s % 60).padStart(2, '0')
114
- return `${mm}:${ss}`
195
+ const ms = Math.max(0, time - startTime)
196
+ const total = Math.floor(ms / 1000)
197
+ const h = Math.floor(total / 3600)
198
+ const mm = String(Math.floor((total % 3600) / 60)).padStart(2, '0')
199
+ const ss = String(total % 60).padStart(2, '0')
200
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
201
+ }
202
+
203
+ /** Human relative time for the session picker ("3 days ago", "2 months ago"). */
204
+ export function formatRelativeTime(time: number, now = Date.now(), lang: 'en' | 'zh' = 'en'): string {
205
+ if (!Number.isFinite(time) || time <= 0) return ''
206
+ const diff = time - now
207
+ const abs = Math.abs(diff)
208
+ const locale = lang === 'zh' ? 'zh-CN' : 'en'
209
+ const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
210
+ const minute = 60_000
211
+ const hour = 60 * minute
212
+ const day = 24 * hour
213
+ if (abs < minute) return lang === 'zh' ? '刚刚' : 'just now'
214
+ if (abs < hour) return formatter.format(Math.round(diff / minute), 'minute')
215
+ if (abs < day) return formatter.format(Math.round(diff / hour), 'hour')
216
+ if (abs < 30 * day) return formatter.format(Math.round(diff / day), 'day')
217
+ if (abs < 365 * day) return formatter.format(Math.round(diff / (30 * day)), 'month')
218
+ return formatter.format(Math.round(diff / (365 * day)), 'year')
115
219
  }
116
220
 
117
221
  export function shortId(id: string): string {
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import React, { useMemo, useState } from 'react'
2
2
  import type { AuditReport, Severity } from '../../core/audit.js'
3
3
  import type { TimelineModel } from '../../core/timeline.js'
4
4
  import { formatClock } from '../api.js'
@@ -12,42 +12,121 @@ const SEV_META: Record<Severity, { color: string; icon: string }> = {
12
12
  info: { color: 'var(--dshr-good)', icon: 'ℹ' },
13
13
  }
14
14
 
15
+ const SEVERITIES: Severity[] = ['critical', 'serious', 'warning', 'info']
16
+
17
+ function downloadText(text: string, filename: string): void {
18
+ const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' })
19
+ const url = URL.createObjectURL(blob)
20
+ const link = document.createElement('a')
21
+ link.href = url
22
+ link.download = filename
23
+ link.click()
24
+ setTimeout(() => URL.revokeObjectURL(url), 5000)
25
+ }
26
+
15
27
  export function AuditView({
16
28
  report,
17
29
  model,
18
30
  onJump,
31
+ sessionId,
19
32
  }: {
20
33
  report: AuditReport
21
34
  model: TimelineModel
22
35
  onJump: (seq: number) => void
36
+ sessionId?: string
23
37
  }): React.ReactElement {
24
38
  const { t } = useI18n()
39
+ const [severity, setSeverity] = useState<Severity>()
40
+
41
+ const findings = useMemo(
42
+ () => (severity === undefined ? report.findings : report.findings.filter(finding => finding.severity === severity)),
43
+ [report.findings, severity],
44
+ )
45
+
46
+ const exportReport = (): void => {
47
+ const lines = [
48
+ `# dsh-replay audit report`,
49
+ ``,
50
+ `- session: ${sessionId ?? 'unknown'}`,
51
+ `- generated: ${new Date().toISOString()}`,
52
+ `- scanned events: ${report.scanned}`,
53
+ `- rules: ${report.rules}`,
54
+ `- ignored baseline config events: ${report.suppressed}`,
55
+ ``,
56
+ `| severity | category | seq | time | finding | detail |`,
57
+ `| --- | --- | --- | --- | --- | --- |`,
58
+ ...report.findings.map(finding => {
59
+ const detail = (finding.detail ?? '').replaceAll('|', '\\|').replaceAll('\n', ' ')
60
+ return `| ${finding.severity} | ${finding.category ?? ''} | ${finding.seq} | ${formatClock(finding.time, model.startTime)} | ${localizeFinding(t, finding).replaceAll('|', '\\|')} | ${detail} |`
61
+ }),
62
+ ``,
63
+ `> ${t('audit.coverage')}`,
64
+ ``,
65
+ ]
66
+ downloadText(lines.join('\n'), `audit-${(sessionId ?? 'session').slice(0, 24)}.md`)
67
+ }
68
+
25
69
  return (
26
70
  <div className="dshr-audit">
71
+ <div className="dshr-audit-head">
72
+ <span className="dshr-audit-coverage">
73
+ {t('audit.scanned', { n: report.scanned, rules: report.rules })}
74
+ {report.suppressed > 0 ? ` · ${t('audit.baseline', { n: report.suppressed })}` : ''}
75
+ </span>
76
+ <span className="dshr-spacer" />
77
+ {report.findings.length > 0 && (
78
+ <button className="dshr-tab" onClick={exportReport}>
79
+ ⬇ {t('audit.export')}
80
+ </button>
81
+ )}
82
+ </div>
83
+
27
84
  <div className="dshr-audit-summary">
28
- {(Object.keys(SEV_META) as Severity[]).map(sev => (
29
- <div key={sev} className="dshr-audit-tile">
85
+ {SEVERITIES.map(sev => (
86
+ <button
87
+ key={sev}
88
+ className="dshr-audit-tile"
89
+ data-active={severity === sev || undefined}
90
+ onClick={() => setSeverity(current => (current === sev ? undefined : sev))}
91
+ title={t('audit.riskOnly')}
92
+ >
30
93
  <div className="dshr-count">{report.counts[sev]}</div>
31
94
  <div className="dshr-cap">
32
95
  <span className="dshr-sev-dot" style={{ background: SEV_META[sev].color }} />
33
96
  {SEV_META[sev].icon} {t(`sev.${sev}`)}
34
97
  </div>
35
- </div>
98
+ </button>
36
99
  ))}
37
100
  </div>
101
+
102
+ <p className="dshr-audit-note">{t('audit.coverage')}</p>
103
+
38
104
  {report.findings.length === 0 ? (
39
105
  <MascotState mood="happy" text={t('audit.empty')} />
106
+ ) : findings.length === 0 ? (
107
+ <MascotState mood="happy" text={t('audit.noRisk')} />
40
108
  ) : (
41
- report.findings.map((finding, i) => (
42
- <button key={i} className="dshr-finding" onClick={() => onJump(finding.seq)}>
109
+ findings.map((finding, index) => (
110
+ <button
111
+ key={`${finding.ruleId}-${finding.seq}-${index}`}
112
+ className="dshr-finding"
113
+ onClick={() => onJump(finding.seq)}
114
+ >
43
115
  <span className="dshr-sev">
44
116
  <span className="dshr-sev-dot" style={{ background: SEV_META[finding.severity].color }} />
45
117
  {SEV_META[finding.severity].icon} {t(`sev.${finding.severity}`)}
46
118
  </span>
47
- <span className="dshr-finding-title">{localizeFinding(t, finding)}</span>
48
- {finding.detail !== undefined && finding.detail !== '' && (
49
- <span className="dshr-finding-detail">{finding.detail}</span>
50
- )}
119
+ <span className="dshr-finding-body">
120
+ <span className="dshr-finding-title">
121
+ {localizeFinding(t, finding)}
122
+ {finding.category !== undefined && (
123
+ <span className="dshr-category">{t(`audit.category.${finding.category}`)}</span>
124
+ )}
125
+ </span>
126
+ {finding.detail !== undefined && finding.detail !== '' && (
127
+ <span className="dshr-finding-detail">{finding.detail}</span>
128
+ )}
129
+ </span>
51
130
  <span className="dshr-item-time">{formatClock(finding.time, model.startTime)}</span>
52
131
  </button>
53
132
  ))