@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.
- package/README.md +225 -164
- package/README.zh.md +225 -164
- package/assets/icon.png +0 -0
- package/lib/client.js +955 -2419
- package/lib/core.js +557 -86
- package/lib/index.js +1043 -51
- package/lib/types/src/client/api.d.ts +44 -0
- package/lib/types/src/client/components/AuditView.d.ts +2 -1
- package/lib/types/src/client/components/CompareView.d.ts +2 -1
- package/lib/types/src/client/components/DashboardView.d.ts +8 -0
- package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
- package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
- package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
- package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
- package/lib/types/src/client/components/TimelineView.d.ts +2 -1
- package/lib/types/src/client/export.d.ts +6 -1
- package/lib/types/src/client/i18n.d.ts +8 -1
- package/lib/types/src/client/pricing.d.ts +6 -0
- package/lib/types/src/core/audit.d.ts +18 -0
- package/lib/types/src/core/compare.d.ts +8 -3
- package/lib/types/src/core/cost.d.ts +21 -3
- package/lib/types/src/core/index.d.ts +1 -0
- package/lib/types/src/core/redact.d.ts +40 -0
- package/lib/types/src/core/timeline.d.ts +9 -0
- package/lib/types/src/index.d.ts +52 -1
- package/lib/viewer.js +282 -29
- package/package.json +118 -107
- package/scripts/build.mjs +8 -2
- package/src/client/api.ts +114 -10
- package/src/client/components/AuditView.tsx +89 -10
- package/src/client/components/CompareView.tsx +89 -13
- package/src/client/components/DashboardView.tsx +232 -0
- package/src/client/components/ExportDialog.tsx +136 -0
- package/src/client/components/ItemDetail.tsx +36 -4
- package/src/client/components/OverviewView.tsx +150 -34
- package/src/client/components/ReplayApp.tsx +207 -60
- package/src/client/components/ReplayModal.tsx +41 -6
- package/src/client/components/SessionPicker.tsx +55 -27
- package/src/client/components/TimelineView.tsx +106 -41
- package/src/client/export.ts +20 -4
- package/src/client/i18n.ts +266 -45
- package/src/client/index.ts +7 -5
- package/src/client/mascot-image.ts +1 -1
- package/src/client/pricing.ts +90 -0
- package/src/client/styles.css +250 -1
- package/src/client/viewer-entry.tsx +25 -4
- package/src/core/audit.ts +274 -67
- package/src/core/compare.ts +0 -0
- package/src/core/cost.ts +94 -16
- package/src/core/index.ts +1 -0
- package/src/core/redact.ts +182 -0
- package/src/core/timeline.ts +107 -12
- package/src/index.ts +290 -54
package/package.json
CHANGED
|
@@ -1,107 +1,118 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@mingozhou/dsh-replay",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"@deepseek-ai/dsh-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"
|
|
105
|
-
|
|
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(
|
|
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(
|
|
80
|
-
header:
|
|
81
|
-
title: this.titles.get(
|
|
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
|
-
|
|
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
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
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
|
-
{
|
|
29
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
42
|
-
<button
|
|
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-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
))
|