@lievclouds/dsh-cortex 0.4.4 → 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 +45 -14
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lievclouds/dsh-cortex",
|
|
3
|
-
"version": "0.
|
|
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
|
|
@@ -123,7 +124,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
123
124
|
const [raw, setRaw] = useState('')
|
|
124
125
|
const [step, setStep] = useState(0)
|
|
125
126
|
const [sending, setSending] = useState(false)
|
|
126
|
-
const [bodyView, setBodyView] = useState<'raw' | 'html'>('raw')
|
|
127
|
+
const [bodyView, setBodyView] = useState<'raw' | 'pretty' | 'html'>('raw')
|
|
127
128
|
const [error, setError] = useState<string | null>(null)
|
|
128
129
|
const [result, setResult] = useState<RepeaterSendResult | null>(null)
|
|
129
130
|
|
|
@@ -156,6 +157,13 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
156
157
|
const rawBytes = useMemo(() => new TextEncoder().encode(raw).byteLength, [raw])
|
|
157
158
|
const responseIsRedirect = result !== null && isRedirectResponse(result)
|
|
158
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
|
+
)
|
|
159
167
|
const htmlPreview = useMemo(
|
|
160
168
|
() => (result === null || vuln === null ? '' : buildHtmlPreview(result.response.body, vuln.target_url)),
|
|
161
169
|
[result, vuln]
|
|
@@ -306,7 +314,7 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
306
314
|
{t('retest.repeater.followRedirect')}
|
|
307
315
|
</Button>
|
|
308
316
|
) : null}
|
|
309
|
-
{responseIsHtml ? (
|
|
317
|
+
{formattedJson !== null || responseIsHtml ? (
|
|
310
318
|
<div className="ml-auto flex items-center gap-0.5 rounded-md border border-border bg-muted p-0.5">
|
|
311
319
|
<button
|
|
312
320
|
type="button"
|
|
@@ -320,18 +328,34 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
320
328
|
>
|
|
321
329
|
{t('retest.repeater.viewRaw')}
|
|
322
330
|
</button>
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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}
|
|
335
359
|
</div>
|
|
336
360
|
) : null}
|
|
337
361
|
</>
|
|
@@ -356,6 +380,13 @@ export function RepeaterPanel({ vuln, loading }: RepeaterPanelProps) {
|
|
|
356
380
|
srcDoc={htmlPreview}
|
|
357
381
|
className="h-[min(60vh,520px)] min-h-[320px] w-full rounded-md border border-border bg-white"
|
|
358
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>
|
|
359
390
|
) : (
|
|
360
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%)]">
|
|
361
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
|
}
|
|
@@ -254,16 +254,17 @@ export function VulnDetail({
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
const handleExport = async (outputDir?: string) => {
|
|
257
|
+
const handleExport = async (outputDir?: string, format?: 'md' | 'docx' | 'pdf') => {
|
|
258
258
|
if (!detail || loading) return
|
|
259
259
|
const api = getApi()
|
|
260
260
|
if (!api) {
|
|
261
261
|
toast.error(apiUnavailableMessage(t))
|
|
262
|
+
setExportOpen(false)
|
|
262
263
|
return
|
|
263
264
|
}
|
|
264
265
|
setExporting(true)
|
|
265
266
|
try {
|
|
266
|
-
const result = await api.exportVulnerabilities([detail.id], outputDir)
|
|
267
|
+
const result = await api.exportVulnerabilities([detail.id], { outputDir, format })
|
|
267
268
|
if (!result.cancelled) {
|
|
268
269
|
const savedPath = result.savedPaths?.[0]
|
|
269
270
|
toast.success(
|
|
@@ -287,7 +288,18 @@ export function VulnDetail({
|
|
|
287
288
|
if (!next) onClose()
|
|
288
289
|
}}
|
|
289
290
|
>
|
|
290
|
-
<SheetContent
|
|
291
|
+
<SheetContent
|
|
292
|
+
data-testid="vuln-sheet"
|
|
293
|
+
onInteractOutside={(event) => {
|
|
294
|
+
// Nested dialogs (export/build path, delete confirm) are portaled
|
|
295
|
+
// outside this sheet in the DOM; without this the sheet treats a
|
|
296
|
+
// click inside them as an outside click and closes itself.
|
|
297
|
+
if (exportOpen || confirmOpen) event.preventDefault()
|
|
298
|
+
}}
|
|
299
|
+
onEscapeKeyDown={(event) => {
|
|
300
|
+
if (exportOpen || confirmOpen) event.preventDefault()
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
291
303
|
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-6">
|
|
292
304
|
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
|
293
305
|
{t('vuln.detail.eyebrow')}
|
|
@@ -629,7 +641,7 @@ export function VulnDetail({
|
|
|
629
641
|
count={1}
|
|
630
642
|
pending={exporting}
|
|
631
643
|
onOpenChange={setExportOpen}
|
|
632
|
-
onConfirm={(outputDir) => void handleExport(outputDir)}
|
|
644
|
+
onConfirm={(outputDir, format) => void handleExport(outputDir, format)}
|
|
633
645
|
/>
|
|
634
646
|
</>
|
|
635
647
|
)
|