@lievclouds/dsh-cortex 0.4.3 → 0.5.4
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.
Potentially problematic release.
This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.
- package/client.js +327 -327
- package/package.json +5 -1
- package/src/cortex-ui/dsh/api.ts +5 -2
- package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +64 -15
- package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
- package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
- package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
- package/src/cortex-ui/renderer/lib/retestResponse.ts +17 -0
- package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
- package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
- package/src/cortex-ui/shared/ipc.ts +3 -4
- package/src/cortex-ui/shared/types.ts +9 -0
- package/src/host/cortex/fs.js +72 -0
- package/src/host/cortex/index.js +7 -0
- package/src/host/cortex/settings.js +54 -0
- package/src/host/cortex/vulns.js +36 -13
- package/src/host/reports-office.js +341 -0
- package/src/host/retest.js +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lievclouds/dsh-cortex",
|
|
3
|
-
"version": "0.4
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Cortex workbench for DeepSeek Harness: the original Cortex UI (vulnerabilities, assets, internal operations, Memory Nebula, snapshots, retest) as a native DSH panel, plus 12 native agent tools so DSH conversations use the local Cortex SQLite without MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -102,5 +102,9 @@
|
|
|
102
102
|
"tailwindcss": "3.4.19",
|
|
103
103
|
"three": "^0.187.0",
|
|
104
104
|
"zod": "4.6.7"
|
|
105
|
+
},
|
|
106
|
+
"dependencies": {
|
|
107
|
+
"docx": "^9.15.3",
|
|
108
|
+
"pdfkit": "^0.20.2"
|
|
105
109
|
}
|
|
106
110
|
}
|
package/src/cortex-ui/dsh/api.ts
CHANGED
|
@@ -112,10 +112,13 @@ export function createCortexApi(): CortexApi {
|
|
|
112
112
|
patch(`/vulns/${encodeURIComponent(input.id)}/status`, { status: input.status }),
|
|
113
113
|
deleteVulnerability: (id) => del(`/vulns/${encodeURIComponent(id)}`),
|
|
114
114
|
deleteVulnerabilities: (ids) => post('/vulns/delete-many', { ids }),
|
|
115
|
-
exportVulnerabilities: async (ids,
|
|
115
|
+
exportVulnerabilities: async (ids, options) => {
|
|
116
|
+
const outputDir = options?.outputDir?.trim()
|
|
117
|
+
const format = options?.format
|
|
116
118
|
const response = await post('/vulns/export', {
|
|
117
119
|
ids,
|
|
118
|
-
...(outputDir
|
|
120
|
+
...(outputDir ? { outputDir } : {}),
|
|
121
|
+
...(format ? { format } : {}),
|
|
119
122
|
})
|
|
120
123
|
const files = Array.isArray(response?.files) ? response.files : []
|
|
121
124
|
const savedPaths = Array.isArray(response?.savedPaths) ? response.savedPaths : []
|
|
@@ -397,7 +397,15 @@ export function AssetDetail({
|
|
|
397
397
|
if (!next) onClose()
|
|
398
398
|
}}
|
|
399
399
|
>
|
|
400
|
-
<SheetContent
|
|
400
|
+
<SheetContent
|
|
401
|
+
data-testid="asset-sheet"
|
|
402
|
+
onInteractOutside={(event) => {
|
|
403
|
+
if (deleteOpen) event.preventDefault()
|
|
404
|
+
}}
|
|
405
|
+
onEscapeKeyDown={(event) => {
|
|
406
|
+
if (deleteOpen) event.preventDefault()
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
401
409
|
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-6">
|
|
402
410
|
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
|
403
411
|
{t('asset.page.eyebrow')}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
extractRepeaterRequests,
|
|
12
12
|
resolveRepeaterUrl
|
|
13
13
|
} from '@renderer/lib/retestRequest'
|
|
14
|
+
import { prettyJsonBody } from '@renderer/lib/retestResponse'
|
|
14
15
|
|
|
15
16
|
interface RepeaterPanelProps {
|
|
16
17
|
vuln: Vulnerability | null
|
|
@@ -24,6 +25,21 @@ interface ParsedRawRequest {
|
|
|
24
25
|
body: string
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Protocol-level headers copied from a report are re-derived by the outbound
|
|
30
|
+
* request (Host from the URL, length/connection by the HTTP client). Dropping
|
|
31
|
+
* them here keeps raw requests sendable without a validation error.
|
|
32
|
+
*/
|
|
33
|
+
const DERIVED_HEADERS = new Set([
|
|
34
|
+
'host',
|
|
35
|
+
'content-length',
|
|
36
|
+
'connection',
|
|
37
|
+
'transfer-encoding',
|
|
38
|
+
'upgrade',
|
|
39
|
+
'keep-alive',
|
|
40
|
+
'proxy-connection'
|
|
41
|
+
])
|
|
42
|
+
|
|
27
43
|
/** Parse a Burp-like raw request into the typed repeater payload. */
|
|
28
44
|
function parseRawRequest(text: string): ParsedRawRequest {
|
|
29
45
|
const lines = text.replace(/\r\n/g, '\n').split('\n')
|
|
@@ -108,7 +124,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
108
124
|
const [raw, setRaw] = useState('')
|
|
109
125
|
const [step, setStep] = useState(0)
|
|
110
126
|
const [sending, setSending] = useState(false)
|
|
111
|
-
const [bodyView, setBodyView] = useState<'raw' | 'html'>('raw')
|
|
127
|
+
const [bodyView, setBodyView] = useState<'raw' | 'pretty' | 'html'>('raw')
|
|
112
128
|
const [error, setError] = useState<string | null>(null)
|
|
113
129
|
const [result, setResult] = useState<RepeaterSendResult | null>(null)
|
|
114
130
|
|
|
@@ -141,6 +157,13 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
141
157
|
const rawBytes = useMemo(() => new TextEncoder().encode(raw).byteLength, [raw])
|
|
142
158
|
const responseIsRedirect = result !== null && isRedirectResponse(result)
|
|
143
159
|
const responseIsHtml = result !== null && isHtmlResponse(result)
|
|
160
|
+
const formattedJson = useMemo(
|
|
161
|
+
() =>
|
|
162
|
+
result === null
|
|
163
|
+
? null
|
|
164
|
+
: prettyJsonBody(result.response.body, headerValue(result.response.headers, 'content-type')),
|
|
165
|
+
[result]
|
|
166
|
+
)
|
|
144
167
|
const htmlPreview = useMemo(
|
|
145
168
|
() => (result === null || vuln === null ? '' : buildHtmlPreview(result.response.body, vuln.target_url)),
|
|
146
169
|
[result, vuln]
|
|
@@ -157,11 +180,14 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
157
180
|
setError(null)
|
|
158
181
|
try {
|
|
159
182
|
const parsed = parseRawRequest(raw)
|
|
183
|
+
const headers = Object.fromEntries(
|
|
184
|
+
Object.entries(parsed.headers).filter(([name]) => !DERIVED_HEADERS.has(name.toLowerCase()))
|
|
185
|
+
)
|
|
160
186
|
const response = await api.sendRepeaterRequest({
|
|
161
187
|
vulnId: vuln.id,
|
|
162
188
|
method: parsed.method,
|
|
163
189
|
url: resolveRepeaterUrl(parsed.url, vuln.target_url),
|
|
164
|
-
headers
|
|
190
|
+
headers,
|
|
165
191
|
...(parsed.body === '' ? {} : { body: parsed.body }),
|
|
166
192
|
followRedirects
|
|
167
193
|
})
|
|
@@ -288,7 +314,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
288
314
|
{t('retest.repeater.followRedirect')}
|
|
289
315
|
</Button>
|
|
290
316
|
) : null}
|
|
291
|
-
{responseIsHtml ? (
|
|
317
|
+
{formattedJson !== null || responseIsHtml ? (
|
|
292
318
|
<div className="ml-auto flex items-center gap-0.5 rounded-md border border-border bg-muted p-0.5">
|
|
293
319
|
<button
|
|
294
320
|
type="button"
|
|
@@ -302,18 +328,34 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
302
328
|
>
|
|
303
329
|
{t('retest.repeater.viewRaw')}
|
|
304
330
|
</button>
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
331
|
+
{formattedJson !== null ? (
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
data-testid="retest-repeater-view-json"
|
|
335
|
+
onClick={() => setBodyView('pretty')}
|
|
336
|
+
className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
|
|
337
|
+
bodyView === 'pretty'
|
|
338
|
+
? 'bg-background font-medium text-foreground shadow-sm'
|
|
339
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
340
|
+
}`}
|
|
341
|
+
>
|
|
342
|
+
{t('retest.repeater.formatJson')}
|
|
343
|
+
</button>
|
|
344
|
+
) : null}
|
|
345
|
+
{responseIsHtml ? (
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
data-testid="retest-repeater-view-html"
|
|
349
|
+
onClick={() => setBodyView('html')}
|
|
350
|
+
className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
|
|
351
|
+
bodyView === 'html'
|
|
352
|
+
? 'bg-background font-medium text-foreground shadow-sm'
|
|
353
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
354
|
+
}`}
|
|
355
|
+
>
|
|
356
|
+
{t('retest.repeater.viewHtml')}
|
|
357
|
+
</button>
|
|
358
|
+
) : null}
|
|
317
359
|
</div>
|
|
318
360
|
) : null}
|
|
319
361
|
</>
|
|
@@ -338,6 +380,13 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
338
380
|
srcDoc={htmlPreview}
|
|
339
381
|
className="h-[min(60vh,520px)] min-h-[320px] w-full rounded-md border border-border bg-white"
|
|
340
382
|
/>
|
|
383
|
+
) : formattedJson !== null && bodyView === 'pretty' ? (
|
|
384
|
+
<pre
|
|
385
|
+
data-testid="retest-repeater-json"
|
|
386
|
+
className="whitespace-pre-wrap break-words rounded-md border border-border bg-[hsl(var(--code))] px-3 py-2.5 font-mono text-[11px] leading-relaxed text-[hsl(220_14%_92%)]"
|
|
387
|
+
>
|
|
388
|
+
{formattedJson}
|
|
389
|
+
</pre>
|
|
341
390
|
) : (
|
|
342
391
|
<pre className="whitespace-pre-wrap break-words rounded-md border border-border bg-[hsl(var(--code))] px-3 py-2.5 font-mono text-[11px] leading-relaxed text-[hsl(220_14%_92%)]">
|
|
343
392
|
{result.response.body.slice(0, 200_000)}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
2
|
+
import { ArrowUp, Folder, FolderCheck, Loader2 } from 'lucide-react'
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import { Button } from '@renderer/components/ui/button'
|
|
5
|
+
import { useI18n } from '@renderer/i18n'
|
|
6
|
+
import { cn } from '@renderer/lib/utils'
|
|
7
|
+
import { getPortalContainer } from '../../../dsh/portal'
|
|
8
|
+
|
|
9
|
+
interface DirectoryListing {
|
|
10
|
+
path: string
|
|
11
|
+
parent: string | null
|
|
12
|
+
directories: Array<{ name: string; path: string }>
|
|
13
|
+
home: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DirectoryPickerDialogProps {
|
|
17
|
+
open: boolean
|
|
18
|
+
initialPath?: string
|
|
19
|
+
onOpenChange: (open: boolean) => void
|
|
20
|
+
onSelect: (path: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function listDirectories(path: string): Promise<DirectoryListing> {
|
|
24
|
+
const query = path.trim() === '' ? '' : `?path=${encodeURIComponent(path.trim())}`
|
|
25
|
+
const response = await fetch(`/cortex-api/cortex/fs/list${query}`)
|
|
26
|
+
const payload = (await response.json().catch(() => null)) as
|
|
27
|
+
| (DirectoryListing & { error?: string })
|
|
28
|
+
| null
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(payload?.error || `HTTP ${response.status}`)
|
|
31
|
+
}
|
|
32
|
+
return payload as DirectoryListing
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Server-backed folder picker: the browser cannot reveal absolute directory
|
|
37
|
+
* paths for a text export dialog, so the Host lists folders and this dialog
|
|
38
|
+
* returns the chosen absolute path.
|
|
39
|
+
*/
|
|
40
|
+
export function DirectoryPickerDialog({
|
|
41
|
+
open,
|
|
42
|
+
initialPath = '',
|
|
43
|
+
onOpenChange,
|
|
44
|
+
onSelect
|
|
45
|
+
}: DirectoryPickerDialogProps) {
|
|
46
|
+
const { t } = useI18n()
|
|
47
|
+
const [current, setCurrent] = useState('')
|
|
48
|
+
const [parent, setParent] = useState<string | null>(null)
|
|
49
|
+
const [directories, setDirectories] = useState<Array<{ name: string; path: string }>>([])
|
|
50
|
+
const [manual, setManual] = useState('')
|
|
51
|
+
const [loading, setLoading] = useState(false)
|
|
52
|
+
const [error, setError] = useState<string | null>(null)
|
|
53
|
+
|
|
54
|
+
const load = useCallback(
|
|
55
|
+
async (target: string) => {
|
|
56
|
+
setLoading(true)
|
|
57
|
+
setError(null)
|
|
58
|
+
try {
|
|
59
|
+
const listing = await listDirectories(target)
|
|
60
|
+
setCurrent(listing.path)
|
|
61
|
+
setParent(listing.parent)
|
|
62
|
+
setDirectories(listing.directories)
|
|
63
|
+
setManual(listing.path)
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false)
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
[]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!open) return
|
|
75
|
+
void load(initialPath)
|
|
76
|
+
// Only reload when the dialog opens; user navigation is handled locally.
|
|
77
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
78
|
+
}, [open])
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
|
|
82
|
+
<DialogPrimitive.Portal container={getPortalContainer()}>
|
|
83
|
+
<DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[80] bg-[hsl(224_18%_10%/0.3)]" />
|
|
84
|
+
<DialogPrimitive.Content
|
|
85
|
+
data-testid="directory-picker-dialog"
|
|
86
|
+
className="fixed left-1/2 top-1/2 z-[80] flex w-[min(620px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border border-border bg-background p-5 shadow-panel focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
87
|
+
>
|
|
88
|
+
<DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
|
|
89
|
+
{t('vuln.dirPicker.title')}
|
|
90
|
+
</DialogPrimitive.Title>
|
|
91
|
+
|
|
92
|
+
<div className="mt-3 flex items-center gap-1.5">
|
|
93
|
+
<input
|
|
94
|
+
data-testid="directory-picker-path"
|
|
95
|
+
value={manual}
|
|
96
|
+
spellCheck={false}
|
|
97
|
+
onChange={(event) => setManual(event.target.value)}
|
|
98
|
+
onKeyDown={(event) => {
|
|
99
|
+
if (event.key === 'Enter') void load(manual)
|
|
100
|
+
}}
|
|
101
|
+
className="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2.5 font-mono text-[11.5px] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
102
|
+
/>
|
|
103
|
+
<Button
|
|
104
|
+
variant="outline"
|
|
105
|
+
size="xs"
|
|
106
|
+
onClick={() => void load(manual)}
|
|
107
|
+
disabled={loading}
|
|
108
|
+
className="h-8 gap-1 px-2.5"
|
|
109
|
+
>
|
|
110
|
+
{t('vuln.dirPicker.go')}
|
|
111
|
+
</Button>
|
|
112
|
+
<Button
|
|
113
|
+
variant="outline"
|
|
114
|
+
size="xs"
|
|
115
|
+
data-testid="directory-picker-up"
|
|
116
|
+
onClick={() => {
|
|
117
|
+
if (parent !== null) void load(parent)
|
|
118
|
+
}}
|
|
119
|
+
disabled={loading || parent === null}
|
|
120
|
+
className="h-8 gap-1 px-2.5"
|
|
121
|
+
>
|
|
122
|
+
<ArrowUp aria-hidden="true" className="h-3 w-3" />
|
|
123
|
+
{t('vuln.dirPicker.up')}
|
|
124
|
+
</Button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="mt-3 max-h-[320px] min-h-[180px] overflow-y-auto rounded-lg border border-border">
|
|
128
|
+
{loading ? (
|
|
129
|
+
<p className="flex h-[180px] items-center justify-center gap-2 text-[11.5px] text-muted-foreground">
|
|
130
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
131
|
+
{t('vuln.dirPicker.loading')}
|
|
132
|
+
</p>
|
|
133
|
+
) : error !== null ? (
|
|
134
|
+
<p className="p-3 text-[11.5px] text-red-700">
|
|
135
|
+
{t('vuln.dirPicker.error', { error })}
|
|
136
|
+
</p>
|
|
137
|
+
) : directories.length === 0 ? (
|
|
138
|
+
<p className="flex h-[180px] items-center justify-center text-[11.5px] text-muted-foreground">
|
|
139
|
+
{t('vuln.dirPicker.empty')}
|
|
140
|
+
</p>
|
|
141
|
+
) : (
|
|
142
|
+
<ul className="divide-y divide-border">
|
|
143
|
+
{directories.map((entry) => (
|
|
144
|
+
<li key={entry.path}>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
data-testid="directory-picker-entry"
|
|
148
|
+
onClick={() => void load(entry.path)}
|
|
149
|
+
className={cn(
|
|
150
|
+
'flex w-full items-center gap-2 px-3 py-2 text-left transition-colors',
|
|
151
|
+
'hover:bg-sidebar focus-visible:bg-accent-soft focus-visible:outline-none'
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
<Folder aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
|
|
155
|
+
<span className="truncate text-[12px] text-foreground">{entry.name}</span>
|
|
156
|
+
</button>
|
|
157
|
+
</li>
|
|
158
|
+
))}
|
|
159
|
+
</ul>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div className="mt-4 flex items-center justify-between gap-2">
|
|
164
|
+
<span className="min-w-0 truncate font-mono text-[10.5px] text-muted-foreground" title={current}>
|
|
165
|
+
{current}
|
|
166
|
+
</span>
|
|
167
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
168
|
+
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
|
169
|
+
{t('common.cancel')}
|
|
170
|
+
</Button>
|
|
171
|
+
<Button
|
|
172
|
+
size="sm"
|
|
173
|
+
data-testid="directory-picker-select"
|
|
174
|
+
disabled={loading || error !== null || current === ''}
|
|
175
|
+
onClick={() => onSelect(current)}
|
|
176
|
+
className="gap-1.5"
|
|
177
|
+
>
|
|
178
|
+
<FolderCheck aria-hidden="true" className="h-3.5 w-3.5" />
|
|
179
|
+
{t('vuln.dirPicker.select')}
|
|
180
|
+
</Button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</DialogPrimitive.Content>
|
|
184
|
+
</DialogPrimitive.Portal>
|
|
185
|
+
</DialogPrimitive.Root>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
2
2
|
import { Download, FolderOpen, Loader2 } from 'lucide-react'
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
|
+
import type { ExportReportFormat } from '@shared/types'
|
|
4
5
|
import { Button } from '@renderer/components/ui/button'
|
|
5
6
|
import { useI18n } from '@renderer/i18n'
|
|
7
|
+
import { cn } from '@renderer/lib/utils'
|
|
6
8
|
import { getPortalContainer } from '../../../dsh/portal'
|
|
9
|
+
import { DirectoryPickerDialog } from './DirectoryPickerDialog'
|
|
7
10
|
|
|
8
11
|
const STORAGE_KEY = 'cortex.export.dir'
|
|
12
|
+
const FORMAT_KEY = 'cortex.export.format'
|
|
9
13
|
|
|
10
14
|
function isAbsoluteLike(value: string): boolean {
|
|
11
15
|
const trimmed = value.trim()
|
|
@@ -21,7 +25,7 @@ interface ExportReportDialogProps {
|
|
|
21
25
|
count: number
|
|
22
26
|
pending: boolean
|
|
23
27
|
onOpenChange: (open: boolean) => void
|
|
24
|
-
onConfirm: (outputDir: string | undefined) => void
|
|
28
|
+
onConfirm: (outputDir: string | undefined, format: ExportReportFormat) => void
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
/**
|
|
@@ -38,13 +42,49 @@ export function ExportReportDialog({
|
|
|
38
42
|
}: ExportReportDialogProps) {
|
|
39
43
|
const { t } = useI18n()
|
|
40
44
|
const [directory, setDirectory] = useState('')
|
|
45
|
+
const [format, setFormat] = useState<ExportReportFormat>('md')
|
|
41
46
|
const [invalid, setInvalid] = useState(false)
|
|
47
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
42
48
|
|
|
43
49
|
useEffect(() => {
|
|
44
50
|
if (!open) return
|
|
45
|
-
|
|
51
|
+
let alive = true
|
|
52
|
+
let stored = ''
|
|
53
|
+
try {
|
|
54
|
+
stored = window.localStorage.getItem(STORAGE_KEY) ?? ''
|
|
55
|
+
} catch {
|
|
56
|
+
stored = ''
|
|
57
|
+
}
|
|
58
|
+
const storedFormat = (() => {
|
|
59
|
+
try {
|
|
60
|
+
return window.localStorage.getItem(FORMAT_KEY)
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
})()
|
|
46
65
|
setDirectory(stored)
|
|
66
|
+
setFormat(storedFormat === 'docx' || storedFormat === 'pdf' ? storedFormat : 'md')
|
|
47
67
|
setInvalid(false)
|
|
68
|
+
// The Host remembers the last successful export directory; prefer it over
|
|
69
|
+
// any stale per-browser value so a fresh WebView still defaults correctly.
|
|
70
|
+
void fetch('/cortex-api/cortex/export/settings')
|
|
71
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
72
|
+
.then((payload) => {
|
|
73
|
+
if (!alive) return
|
|
74
|
+
const hostDir = payload && typeof payload.outputDir === 'string' ? payload.outputDir.trim() : ''
|
|
75
|
+
if (hostDir.length > 0) {
|
|
76
|
+
setDirectory(hostDir)
|
|
77
|
+
try {
|
|
78
|
+
window.localStorage.setItem(STORAGE_KEY, hostDir)
|
|
79
|
+
} catch {
|
|
80
|
+
/* ignore */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
.catch(() => {})
|
|
85
|
+
return () => {
|
|
86
|
+
alive = false
|
|
87
|
+
}
|
|
48
88
|
}, [open])
|
|
49
89
|
|
|
50
90
|
const confirm = (): void => {
|
|
@@ -53,68 +93,142 @@ export function ExportReportDialog({
|
|
|
53
93
|
setInvalid(true)
|
|
54
94
|
return
|
|
55
95
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
96
|
+
try {
|
|
97
|
+
if (trimmed !== '') window.localStorage.setItem(STORAGE_KEY, trimmed)
|
|
98
|
+
else window.localStorage.removeItem(STORAGE_KEY)
|
|
99
|
+
window.localStorage.setItem(FORMAT_KEY, format)
|
|
100
|
+
} catch {
|
|
101
|
+
/* localStorage may be unavailable; the Host remembers the directory */
|
|
102
|
+
}
|
|
103
|
+
onConfirm(trimmed === '' ? undefined : trimmed, format)
|
|
59
104
|
}
|
|
60
105
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
data-testid="export-report-dialog"
|
|
67
|
-
className="fixed left-1/2 top-1/2 z-[70] w-[min(480px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background p-5 shadow-panel focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
68
|
-
>
|
|
69
|
-
<DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
|
|
70
|
-
{t('vuln.export.title')}
|
|
71
|
-
</DialogPrimitive.Title>
|
|
72
|
-
<DialogPrimitive.Description className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
|
|
73
|
-
{t('vuln.export.description', { count })}
|
|
74
|
-
</DialogPrimitive.Description>
|
|
106
|
+
const formats: Array<{ value: ExportReportFormat; label: string; testId: string }> = [
|
|
107
|
+
{ value: 'md', label: t('vuln.export.formatMd'), testId: 'export-format-md' },
|
|
108
|
+
{ value: 'docx', label: t('vuln.export.formatDocx'), testId: 'export-format-docx' },
|
|
109
|
+
{ value: 'pdf', label: t('vuln.export.formatPdf'), testId: 'export-format-pdf' },
|
|
110
|
+
]
|
|
75
111
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
spellCheck={false}
|
|
88
|
-
disabled={pending}
|
|
89
|
-
placeholder={t('vuln.export.dirPlaceholder')}
|
|
90
|
-
onChange={(event) => {
|
|
91
|
-
setDirectory(event.target.value)
|
|
92
|
-
if (invalid) setInvalid(false)
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
|
|
115
|
+
<DialogPrimitive.Portal container={getPortalContainer()}>
|
|
116
|
+
<DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[70] bg-[hsl(224_18%_10%/0.3)]" />
|
|
117
|
+
<DialogPrimitive.Content
|
|
118
|
+
data-testid="export-report-dialog"
|
|
119
|
+
onInteractOutside={(event) => {
|
|
120
|
+
// The directory picker is a sibling Radix layer; keep this dialog
|
|
121
|
+
// open while the user browses folders.
|
|
122
|
+
if (pickerOpen) event.preventDefault()
|
|
93
123
|
}}
|
|
94
|
-
|
|
95
|
-
if (event.
|
|
124
|
+
onEscapeKeyDown={(event) => {
|
|
125
|
+
if (pickerOpen) event.preventDefault()
|
|
96
126
|
}}
|
|
97
|
-
className="
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
127
|
+
className="fixed left-1/2 top-1/2 z-[70] w-[min(480px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background p-5 shadow-panel focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
128
|
+
>
|
|
129
|
+
<DialogPrimitive.Title className="text-[16px] font-medium tracking-tight text-foreground">
|
|
130
|
+
{t('vuln.export.title')}
|
|
131
|
+
</DialogPrimitive.Title>
|
|
132
|
+
<DialogPrimitive.Description className="sr-only">
|
|
133
|
+
{t('vuln.export.title')}
|
|
134
|
+
</DialogPrimitive.Description>
|
|
135
|
+
|
|
136
|
+
<div className="mt-4">
|
|
137
|
+
<p className="text-[11.5px] font-medium text-foreground">{t('vuln.export.formatLabel')}</p>
|
|
138
|
+
<div
|
|
139
|
+
role="radiogroup"
|
|
140
|
+
aria-label={t('vuln.export.formatLabel')}
|
|
141
|
+
className="mt-1.5 inline-flex rounded-md border border-border bg-muted p-0.5"
|
|
142
|
+
>
|
|
143
|
+
{formats.map((item) => (
|
|
144
|
+
<button
|
|
145
|
+
key={item.value}
|
|
146
|
+
type="button"
|
|
147
|
+
role="radio"
|
|
148
|
+
aria-checked={format === item.value}
|
|
149
|
+
data-testid={item.testId}
|
|
150
|
+
disabled={pending}
|
|
151
|
+
onClick={() => setFormat(item.value)}
|
|
152
|
+
className={cn(
|
|
153
|
+
'h-7 rounded px-2.5 text-[11.5px] transition-colors',
|
|
154
|
+
format === item.value
|
|
155
|
+
? 'bg-background font-medium text-foreground shadow-sm'
|
|
156
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
{item.label}
|
|
160
|
+
</button>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<label
|
|
166
|
+
htmlFor="export-output-dir"
|
|
167
|
+
className="mt-4 flex items-center gap-1.5 text-[11.5px] font-medium text-foreground"
|
|
168
|
+
>
|
|
169
|
+
<FolderOpen aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
|
|
170
|
+
{t('vuln.export.dirLabel')}
|
|
171
|
+
</label>
|
|
172
|
+
<div className="mt-1.5 flex items-center gap-1.5">
|
|
173
|
+
<input
|
|
174
|
+
id="export-output-dir"
|
|
175
|
+
data-testid="export-output-dir"
|
|
176
|
+
value={directory}
|
|
177
|
+
spellCheck={false}
|
|
178
|
+
disabled={pending}
|
|
179
|
+
placeholder={t('vuln.export.dirPlaceholder')}
|
|
180
|
+
onChange={(event) => {
|
|
181
|
+
setDirectory(event.target.value)
|
|
182
|
+
if (invalid) setInvalid(false)
|
|
183
|
+
}}
|
|
184
|
+
onKeyDown={(event) => {
|
|
185
|
+
if (event.key === 'Enter') confirm()
|
|
186
|
+
}}
|
|
187
|
+
className="h-9 min-w-0 flex-1 rounded-md border border-border bg-background px-2.5 font-mono text-[11.5px] text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
188
|
+
/>
|
|
189
|
+
<Button
|
|
190
|
+
variant="outline"
|
|
191
|
+
size="sm"
|
|
192
|
+
data-testid="export-browse-dir"
|
|
193
|
+
disabled={pending}
|
|
194
|
+
onClick={() => setPickerOpen(true)}
|
|
195
|
+
className="h-9 shrink-0 gap-1.5"
|
|
196
|
+
>
|
|
197
|
+
<FolderOpen aria-hidden="true" className="h-3.5 w-3.5" />
|
|
198
|
+
{t('vuln.export.browse')}
|
|
199
|
+
</Button>
|
|
200
|
+
</div>
|
|
201
|
+
{invalid ? (
|
|
202
|
+
<p className="mt-1.5 text-[10.5px] text-red-700">{t('vuln.export.dirInvalid')}</p>
|
|
203
|
+
) : null}
|
|
204
|
+
|
|
205
|
+
<div className="mt-5 flex items-center justify-end gap-2">
|
|
206
|
+
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={pending}>
|
|
207
|
+
{t('common.cancel')}
|
|
208
|
+
</Button>
|
|
209
|
+
<Button size="sm" onClick={confirm} disabled={pending} className="gap-1.5">
|
|
210
|
+
{pending ? (
|
|
211
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
212
|
+
) : (
|
|
213
|
+
<Download aria-hidden="true" className="h-3.5 w-3.5" />
|
|
214
|
+
)}
|
|
215
|
+
{pending ? t('vuln.detail.exporting') : t('vuln.export.confirm')}
|
|
216
|
+
</Button>
|
|
217
|
+
</div>
|
|
218
|
+
</DialogPrimitive.Content>
|
|
219
|
+
</DialogPrimitive.Portal>
|
|
220
|
+
</DialogPrimitive.Root>
|
|
102
221
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
</Button>
|
|
115
|
-
</div>
|
|
116
|
-
</DialogPrimitive.Content>
|
|
117
|
-
</DialogPrimitive.Portal>
|
|
118
|
-
</DialogPrimitive.Root>
|
|
222
|
+
<DirectoryPickerDialog
|
|
223
|
+
open={pickerOpen}
|
|
224
|
+
initialPath={directory}
|
|
225
|
+
onOpenChange={setPickerOpen}
|
|
226
|
+
onSelect={(path) => {
|
|
227
|
+
setDirectory(path)
|
|
228
|
+
setInvalid(false)
|
|
229
|
+
setPickerOpen(false)
|
|
230
|
+
}}
|
|
231
|
+
/>
|
|
232
|
+
</>
|
|
119
233
|
)
|
|
120
234
|
}
|