@skyhook-io/radar-app 1.13.4 → 1.13.5
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/package.json +1 -1
- package/src/RadarApp.tsx +5 -0
- package/src/api/client.ts +4 -0
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
- package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
- package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
- package/src/components/diagnose/InvestigationView.tsx +222 -5
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
- package/src/components/diagnose/investigationEvidence.ts +831 -43
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +2 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +482 -0
- package/src/components/diagnose/parts.tsx +418 -41
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +1 -0
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createElement, useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { X, File, Link2, ChevronRight, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
|
|
3
|
+
import { X, File, Link2, ChevronRight, ArrowLeft, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
|
|
4
4
|
import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
|
|
5
5
|
import { clsx } from 'clsx'
|
|
6
6
|
import type { FileNode } from '../../types'
|
|
@@ -11,6 +11,7 @@ import { isDesktopApp } from '../../utils/desktop-download'
|
|
|
11
11
|
import { openFile, openFolder } from '../../utils/desktop-open-folder'
|
|
12
12
|
import { useToast } from '../ui/Toast'
|
|
13
13
|
import { Tooltip } from '../ui/Tooltip'
|
|
14
|
+
import { PodFilePreview, PREVIEW_BYTE_CAP } from './PodFilePreview'
|
|
14
15
|
|
|
15
16
|
interface PodFilesystem {
|
|
16
17
|
root: FileNode
|
|
@@ -68,6 +69,56 @@ async function savePodFileToDisk(
|
|
|
68
69
|
return body.path
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
type PodFileToast = Pick<ReturnType<typeof useToast>, 'showSuccess' | 'showError'>
|
|
73
|
+
|
|
74
|
+
// One download path for the listing row and the preview header. Desktop saves
|
|
75
|
+
// straight from the backend; the browser gets a blob.
|
|
76
|
+
async function downloadPodFile(
|
|
77
|
+
namespace: string,
|
|
78
|
+
podName: string,
|
|
79
|
+
container: string,
|
|
80
|
+
node: Pick<FileNode, 'path' | 'name'>,
|
|
81
|
+
{ showSuccess, showError }: PodFileToast,
|
|
82
|
+
) {
|
|
83
|
+
try {
|
|
84
|
+
if (await isDesktopApp()) {
|
|
85
|
+
const savedPath = await savePodFileToDisk(namespace, podName, container, node.path)
|
|
86
|
+
showSuccess(
|
|
87
|
+
'File saved',
|
|
88
|
+
savedPath,
|
|
89
|
+
{
|
|
90
|
+
label: 'Show in Finder',
|
|
91
|
+
icon: createElement(FolderOpen, { className: 'w-3.5 h-3.5' }),
|
|
92
|
+
onClick: () => openFolder(savedPath),
|
|
93
|
+
},
|
|
94
|
+
() => openFile(savedPath),
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const params = new URLSearchParams()
|
|
100
|
+
params.set('container', container)
|
|
101
|
+
params.set('path', node.path)
|
|
102
|
+
|
|
103
|
+
const response = await fetch(apiUrl(`/pods/${namespace}/${podName}/files/download?${params.toString()}`), {
|
|
104
|
+
credentials: getCredentialsMode(),
|
|
105
|
+
headers: getAuthHeaders(),
|
|
106
|
+
})
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const err = await response.json().catch(() => ({ error: 'Download failed' }))
|
|
109
|
+
throw new Error(err.error || `HTTP ${response.status}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const blob = await response.blob()
|
|
113
|
+
await downloadBlob(blob, node.name)
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
116
|
+
if (message !== 'cancelled') {
|
|
117
|
+
showError(`Could not download ${node.name}`, message)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
71
122
|
interface PodFilesystemModalProps {
|
|
72
123
|
open: boolean
|
|
73
124
|
onClose: () => void
|
|
@@ -94,8 +145,12 @@ export function PodFilesystemModal({
|
|
|
94
145
|
const [filesystem, setFilesystem] = useState<PodFilesystem | null>(null)
|
|
95
146
|
const [isLoading, setIsLoading] = useState(false)
|
|
96
147
|
const [error, setError] = useState<string | null>(null)
|
|
148
|
+
// The file being viewed in place of the listing; null shows the directory.
|
|
149
|
+
const [previewFile, setPreviewFile] = useState<FileNode | null>(null)
|
|
150
|
+
const toast = useToast()
|
|
97
151
|
|
|
98
152
|
const loadDirectory = useCallback(async (dirPath: string) => {
|
|
153
|
+
setPreviewFile(null)
|
|
99
154
|
setIsLoading(true)
|
|
100
155
|
setError(null)
|
|
101
156
|
try {
|
|
@@ -124,19 +179,24 @@ export function PodFilesystemModal({
|
|
|
124
179
|
setError(null)
|
|
125
180
|
setIsLoading(false)
|
|
126
181
|
setCurrentPath('/')
|
|
182
|
+
setPreviewFile(null)
|
|
127
183
|
setSelectedContainer(initialContainer || containers[0] || '')
|
|
128
184
|
}
|
|
129
185
|
}, [open, initialContainer, containers])
|
|
130
186
|
|
|
131
|
-
//
|
|
187
|
+
// ESC steps back one level: out of a file to its listing, then out of the
|
|
188
|
+
// dialog. One listener owns both so the two can never race.
|
|
132
189
|
useEffect(() => {
|
|
133
190
|
if (!open) return
|
|
134
191
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
135
|
-
if (e.key
|
|
192
|
+
if (e.key !== 'Escape') return
|
|
193
|
+
e.stopPropagation()
|
|
194
|
+
if (previewFile) setPreviewFile(null)
|
|
195
|
+
else onClose()
|
|
136
196
|
}
|
|
137
197
|
document.addEventListener('keydown', handleKeyDown, true)
|
|
138
198
|
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
139
|
-
}, [open, onClose])
|
|
199
|
+
}, [open, onClose, previewFile])
|
|
140
200
|
|
|
141
201
|
// Focus trap
|
|
142
202
|
useEffect(() => {
|
|
@@ -149,10 +209,19 @@ export function PodFilesystemModal({
|
|
|
149
209
|
|
|
150
210
|
const showFilesystem = filesystem && filesystem.root
|
|
151
211
|
|
|
152
|
-
//
|
|
153
|
-
|
|
212
|
+
// Breadcrumb: the directory path, plus the file when one is open. Clicking
|
|
213
|
+
// the current directory while viewing a file just returns to its listing.
|
|
214
|
+
const breadcrumbPath = previewFile ? previewFile.path : currentPath
|
|
215
|
+
const pathSegments = breadcrumbPath === '/'
|
|
154
216
|
? ['/']
|
|
155
|
-
: ['/', ...
|
|
217
|
+
: ['/', ...breadcrumbPath.split('/').filter(Boolean)]
|
|
218
|
+
const goToDirectory = (dirPath: string) => {
|
|
219
|
+
if (dirPath === currentPath) setPreviewFile(null)
|
|
220
|
+
else loadDirectory(dirPath)
|
|
221
|
+
}
|
|
222
|
+
const downloadPreviewFile = () => {
|
|
223
|
+
if (previewFile) downloadPodFile(namespace, podName, selectedContainer, previewFile, toast)
|
|
224
|
+
}
|
|
156
225
|
|
|
157
226
|
return createPortal(
|
|
158
227
|
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
|
@@ -163,19 +232,47 @@ export function PodFilesystemModal({
|
|
|
163
232
|
<div
|
|
164
233
|
ref={dialogRef}
|
|
165
234
|
tabIndex={-1}
|
|
166
|
-
className="relative dialog w-full max-w-4xl mx-4
|
|
235
|
+
className="relative dialog w-full max-w-4xl mx-4 h-[85vh] flex flex-col outline-none"
|
|
167
236
|
>
|
|
168
237
|
{/* Header */}
|
|
169
238
|
<div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
239
|
+
{previewFile ? (
|
|
240
|
+
<>
|
|
241
|
+
<Tooltip content="Back to files">
|
|
242
|
+
<button
|
|
243
|
+
onClick={() => setPreviewFile(null)}
|
|
244
|
+
className="p-2 mr-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
245
|
+
>
|
|
246
|
+
<ArrowLeft className="w-5 h-5" />
|
|
247
|
+
</button>
|
|
248
|
+
</Tooltip>
|
|
249
|
+
<div className="flex-1 min-w-0">
|
|
250
|
+
<h3 className="text-lg font-semibold text-theme-text-primary truncate">{previewFile.name}</h3>
|
|
251
|
+
<p className="text-sm text-theme-text-secondary truncate mt-0.5">
|
|
252
|
+
{namespace}/{podName} · {selectedContainer}
|
|
253
|
+
{previewFile.size !== undefined ? ` · ${formatBytes(previewFile.size)}` : null}
|
|
254
|
+
</p>
|
|
255
|
+
</div>
|
|
256
|
+
<Tooltip content="Download file">
|
|
257
|
+
<button
|
|
258
|
+
onClick={downloadPreviewFile}
|
|
259
|
+
className="p-2 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
|
|
260
|
+
>
|
|
261
|
+
<Download className="w-5 h-5" />
|
|
262
|
+
</button>
|
|
263
|
+
</Tooltip>
|
|
264
|
+
</>
|
|
265
|
+
) : (
|
|
266
|
+
<div className="flex-1 min-w-0">
|
|
267
|
+
<h3 className="text-lg font-semibold text-theme-text-primary">Pod Files</h3>
|
|
268
|
+
<p className="text-sm text-theme-text-secondary truncate mt-0.5">
|
|
269
|
+
{namespace}/{podName}
|
|
270
|
+
</p>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
176
273
|
|
|
177
274
|
{/* Container selector */}
|
|
178
|
-
{containers.length > 1 && (
|
|
275
|
+
{!previewFile && containers.length > 1 && (
|
|
179
276
|
<select
|
|
180
277
|
value={selectedContainer}
|
|
181
278
|
onChange={(e) => setSelectedContainer(e.target.value)}
|
|
@@ -206,7 +303,7 @@ export function PodFilesystemModal({
|
|
|
206
303
|
<span key={segmentPath} className="flex items-center gap-1">
|
|
207
304
|
{i > 0 && <ChevronRight className="w-3 h-3 text-theme-text-tertiary shrink-0" />}
|
|
208
305
|
<button
|
|
209
|
-
onClick={() => !isLast &&
|
|
306
|
+
onClick={() => !isLast && goToDirectory(segmentPath)}
|
|
210
307
|
className={clsx(
|
|
211
308
|
'truncate',
|
|
212
309
|
isLast
|
|
@@ -222,7 +319,7 @@ export function PodFilesystemModal({
|
|
|
222
319
|
</div>
|
|
223
320
|
|
|
224
321
|
{/* Search */}
|
|
225
|
-
{showFilesystem && (
|
|
322
|
+
{showFilesystem && !previewFile && (
|
|
226
323
|
<div className="relative flex-1 min-w-[200px]">
|
|
227
324
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
|
|
228
325
|
<Input
|
|
@@ -236,9 +333,22 @@ export function PodFilesystemModal({
|
|
|
236
333
|
</div>
|
|
237
334
|
|
|
238
335
|
{/* Content */}
|
|
239
|
-
|
|
336
|
+
{previewFile ? (
|
|
337
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
338
|
+
<PodFilePreview
|
|
339
|
+
key={previewFile.path}
|
|
340
|
+
namespace={namespace}
|
|
341
|
+
podName={podName}
|
|
342
|
+
container={selectedContainer}
|
|
343
|
+
filePath={previewFile.path}
|
|
344
|
+
fileName={previewFile.name}
|
|
345
|
+
onDownload={downloadPreviewFile}
|
|
346
|
+
/>
|
|
347
|
+
</div>
|
|
348
|
+
) : (
|
|
349
|
+
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
|
240
350
|
{/* Loading */}
|
|
241
|
-
{isLoading && <PaneLoader label="Loading files…" className="h-
|
|
351
|
+
{isLoading && <PaneLoader label="Loading files…" className="h-full" />}
|
|
242
352
|
|
|
243
353
|
{/* Error */}
|
|
244
354
|
{error && !isLoading && (
|
|
@@ -262,9 +372,11 @@ export function PodFilesystemModal({
|
|
|
262
372
|
podName={podName}
|
|
263
373
|
container={selectedContainer}
|
|
264
374
|
onNavigate={loadDirectory}
|
|
375
|
+
onOpenFile={setPreviewFile}
|
|
265
376
|
/>
|
|
266
377
|
)}
|
|
267
378
|
</div>
|
|
379
|
+
)}
|
|
268
380
|
|
|
269
381
|
{/* Footer with stats */}
|
|
270
382
|
<div className="p-3 border-t border-theme-border text-xs text-theme-text-tertiary flex items-center gap-4 shrink-0">
|
|
@@ -300,9 +412,10 @@ interface PodFileTreeViewProps {
|
|
|
300
412
|
podName: string
|
|
301
413
|
container: string
|
|
302
414
|
onNavigate: (path: string) => void
|
|
415
|
+
onOpenFile: (node: FileNode) => void
|
|
303
416
|
}
|
|
304
417
|
|
|
305
|
-
function PodFileTreeView({ root, searchQuery, namespace, podName, container, onNavigate }: PodFileTreeViewProps) {
|
|
418
|
+
function PodFileTreeView({ root, searchQuery, namespace, podName, container, onNavigate, onOpenFile }: PodFileTreeViewProps) {
|
|
306
419
|
const filteredRoot = useMemo(() => {
|
|
307
420
|
if (!searchQuery.trim()) return root
|
|
308
421
|
return filterTree(root, searchQuery.toLowerCase())
|
|
@@ -326,6 +439,7 @@ function PodFileTreeView({ root, searchQuery, namespace, podName, container, onN
|
|
|
326
439
|
podName={podName}
|
|
327
440
|
container={container}
|
|
328
441
|
onNavigate={onNavigate}
|
|
442
|
+
onOpenFile={onOpenFile}
|
|
329
443
|
/>
|
|
330
444
|
))}
|
|
331
445
|
</div>
|
|
@@ -338,72 +452,48 @@ interface PodFileTreeNodeProps {
|
|
|
338
452
|
podName: string
|
|
339
453
|
container: string
|
|
340
454
|
onNavigate: (path: string) => void
|
|
455
|
+
onOpenFile: (node: FileNode) => void
|
|
341
456
|
}
|
|
342
457
|
|
|
343
|
-
function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: PodFileTreeNodeProps) {
|
|
458
|
+
function PodFileTreeNode({ node, namespace, podName, container, onNavigate, onOpenFile }: PodFileTreeNodeProps) {
|
|
344
459
|
const [downloading, setDownloading] = useState(false)
|
|
345
|
-
const
|
|
460
|
+
const toast = useToast()
|
|
346
461
|
const isDir = node.type === 'dir'
|
|
347
462
|
const isSymlink = node.type === 'symlink'
|
|
348
|
-
|
|
463
|
+
// Size is the one preview rejection the listing can see coming. Whether a
|
|
464
|
+
// file is binary or readable is only known once its bytes are read.
|
|
465
|
+
const tooLarge = !isDir && (node.size ?? 0) > PREVIEW_BYTE_CAP
|
|
349
466
|
|
|
350
467
|
const handleDownload = async (e: React.MouseEvent) => {
|
|
351
468
|
e.stopPropagation()
|
|
352
469
|
if (downloading) return
|
|
353
|
-
|
|
354
470
|
setDownloading(true)
|
|
355
471
|
try {
|
|
356
|
-
|
|
357
|
-
const savedPath = await savePodFileToDisk(namespace, podName, container, node.path)
|
|
358
|
-
showSuccess(
|
|
359
|
-
'File saved',
|
|
360
|
-
savedPath,
|
|
361
|
-
{
|
|
362
|
-
label: 'Show in Finder',
|
|
363
|
-
icon: createElement(FolderOpen, { className: 'w-3.5 h-3.5' }),
|
|
364
|
-
onClick: () => openFolder(savedPath),
|
|
365
|
-
},
|
|
366
|
-
() => openFile(savedPath),
|
|
367
|
-
)
|
|
368
|
-
return
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const params = new URLSearchParams()
|
|
372
|
-
params.set('container', container)
|
|
373
|
-
params.set('path', node.path)
|
|
374
|
-
|
|
375
|
-
const response = await fetch(apiUrl(`/pods/${namespace}/${podName}/files/download?${params.toString()}`), {
|
|
376
|
-
credentials: getCredentialsMode(),
|
|
377
|
-
headers: getAuthHeaders(),
|
|
378
|
-
})
|
|
379
|
-
if (!response.ok) {
|
|
380
|
-
const err = await response.json().catch(() => ({ error: 'Download failed' }))
|
|
381
|
-
throw new Error(err.error || `HTTP ${response.status}`)
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const blob = await response.blob()
|
|
385
|
-
await downloadBlob(blob, node.name)
|
|
386
|
-
} catch (err) {
|
|
387
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
388
|
-
if (message !== 'cancelled') {
|
|
389
|
-
showError(`Could not download ${node.name}`, message)
|
|
390
|
-
}
|
|
472
|
+
await downloadPodFile(namespace, podName, container, node, toast)
|
|
391
473
|
} finally {
|
|
392
474
|
setDownloading(false)
|
|
393
475
|
}
|
|
394
476
|
}
|
|
395
477
|
|
|
478
|
+
// Directories open in place; anything else opens in the viewer, which
|
|
479
|
+
// reports a symlink to a directory as "not a regular file" itself.
|
|
396
480
|
const handleClick = () => {
|
|
397
|
-
if (isDir)
|
|
398
|
-
|
|
399
|
-
}
|
|
481
|
+
if (isDir) onNavigate(node.path)
|
|
482
|
+
else if (!tooLarge) onOpenFile(node)
|
|
400
483
|
}
|
|
401
484
|
|
|
402
485
|
return (
|
|
486
|
+
<Tooltip
|
|
487
|
+
content={`Too large to preview (${formatBytes(node.size ?? 0)}) — download instead`}
|
|
488
|
+
disabled={!tooLarge}
|
|
489
|
+
wrapperClassName="flex w-full"
|
|
490
|
+
position="bottom"
|
|
491
|
+
>
|
|
403
492
|
<div
|
|
404
493
|
className={clsx(
|
|
405
|
-
'flex items-center gap-1 py-0.5 px-1 rounded hover:bg-theme-elevated',
|
|
406
|
-
isDir && 'font-medium
|
|
494
|
+
'flex flex-1 min-w-0 items-center gap-1 py-0.5 px-1 rounded hover:bg-theme-elevated',
|
|
495
|
+
isDir && 'font-medium',
|
|
496
|
+
tooLarge ? 'cursor-default' : 'cursor-pointer'
|
|
407
497
|
)}
|
|
408
498
|
onClick={handleClick}
|
|
409
499
|
>
|
|
@@ -415,7 +505,7 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
|
|
|
415
505
|
<File className="w-4 h-4 text-theme-text-tertiary shrink-0" />
|
|
416
506
|
)}
|
|
417
507
|
|
|
418
|
-
<span className=
|
|
508
|
+
<span className={clsx('truncate flex-1', tooLarge ? 'text-theme-text-secondary' : 'text-theme-text-primary')}>{node.name}</span>
|
|
419
509
|
|
|
420
510
|
{isSymlink && node.linkTarget && (
|
|
421
511
|
<span className="text-xs text-cyan-400 truncate max-w-48">
|
|
@@ -435,7 +525,7 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
|
|
|
435
525
|
</span>
|
|
436
526
|
)}
|
|
437
527
|
|
|
438
|
-
{
|
|
528
|
+
{!isDir && (
|
|
439
529
|
<Tooltip content="Download file" wrapperClassName="ml-1">
|
|
440
530
|
<button
|
|
441
531
|
onClick={handleDownload}
|
|
@@ -451,6 +541,6 @@ function PodFileTreeNode({ node, namespace, podName, container, onNavigate }: Po
|
|
|
451
541
|
</Tooltip>
|
|
452
542
|
)}
|
|
453
543
|
</div>
|
|
544
|
+
</Tooltip>
|
|
454
545
|
)
|
|
455
546
|
}
|
|
456
|
-
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { DiagnoseCustomizationProvider, useDiagnoseCustomization } from './DiagnoseCustomization';
|
|
4
|
+
import type { RunSummary } from '../api/diagnose';
|
|
5
|
+
|
|
6
|
+
const run = { id: 'r1' } as RunSummary;
|
|
7
|
+
const onRunUpdated = vi.fn();
|
|
8
|
+
function Actions() {
|
|
9
|
+
const { renderRunActions } = useDiagnoseCustomization();
|
|
10
|
+
return <>{renderRunActions?.({ run, onRunUpdated })}</>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('investigation action slot', () => {
|
|
14
|
+
it('has no default host actions in OSS', () => {
|
|
15
|
+
expect(renderToStaticMarkup(<Actions />)).toBe('');
|
|
16
|
+
});
|
|
17
|
+
it('passes the run and update callback to the host', () => {
|
|
18
|
+
const renderRunActions = vi.fn(() => <button>Host action</button>);
|
|
19
|
+
expect(renderToStaticMarkup(
|
|
20
|
+
<DiagnoseCustomizationProvider value={undefined} renderRunActions={renderRunActions}>
|
|
21
|
+
<Actions />
|
|
22
|
+
</DiagnoseCustomizationProvider>,
|
|
23
|
+
)).toContain('Host action');
|
|
24
|
+
expect(renderRunActions).toHaveBeenCalledWith({ run, onRunUpdated });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
// agent-free.
|
|
12
12
|
import { createContext, useContext, useMemo } from "react";
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
|
+
import type { RunSummary } from "../api/diagnose";
|
|
15
|
+
|
|
16
|
+
/** Optional host controls beside shared run actions. Return a component element
|
|
17
|
+
* if hooks are needed: this callback is invoked conditionally. Call onRunUpdated
|
|
18
|
+
* after a mutation to refresh the shared run summary. */
|
|
19
|
+
export type RenderInvestigationRunActions = (props: {
|
|
20
|
+
run: RunSummary;
|
|
21
|
+
onRunUpdated: (run: RunSummary) => void;
|
|
22
|
+
}) => ReactNode;
|
|
14
23
|
|
|
15
24
|
/** Render prop for the resource-level Investigate action. */
|
|
16
25
|
export type RenderDiagnoseAction = (ctx: {
|
|
@@ -52,6 +61,7 @@ export type DiagnoseConsentCopy = {
|
|
|
52
61
|
// set once at mount, so per-value re-render isolation buys nothing.
|
|
53
62
|
export interface DiagnoseCustomization {
|
|
54
63
|
renderAction: RenderDiagnoseAction | undefined;
|
|
64
|
+
renderRunActions?: RenderInvestigationRunActions;
|
|
55
65
|
consentCopy: DiagnoseConsentCopy | undefined;
|
|
56
66
|
// undefined = default (CustomEvent → Radar's own Settings dialog);
|
|
57
67
|
// null = hide the settings affordances.
|
|
@@ -70,19 +80,21 @@ const DiagnoseCustomizationContext =
|
|
|
70
80
|
export function DiagnoseCustomizationProvider({
|
|
71
81
|
value,
|
|
72
82
|
consentCopy,
|
|
83
|
+
renderRunActions,
|
|
73
84
|
onOpenSettings,
|
|
74
85
|
children,
|
|
75
86
|
}: {
|
|
76
87
|
value: RenderDiagnoseAction | undefined;
|
|
77
88
|
consentCopy?: DiagnoseConsentCopy;
|
|
89
|
+
renderRunActions?: RenderInvestigationRunActions;
|
|
78
90
|
/** Where "AI settings" affordances lead. Omit for Radar's own Settings
|
|
79
91
|
* dialog; pass `null` to hide them. */
|
|
80
92
|
onOpenSettings?: (() => void) | null;
|
|
81
93
|
children: ReactNode;
|
|
82
94
|
}) {
|
|
83
95
|
const ctx = useMemo(
|
|
84
|
-
() => ({ renderAction: value, consentCopy, onOpenSettings }),
|
|
85
|
-
[value, consentCopy, onOpenSettings],
|
|
96
|
+
() => ({ renderAction: value, consentCopy, onOpenSettings, renderRunActions }),
|
|
97
|
+
[value, consentCopy, onOpenSettings, renderRunActions],
|
|
86
98
|
);
|
|
87
99
|
return (
|
|
88
100
|
<DiagnoseCustomizationContext.Provider value={ctx}>
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import { allShellSafe, isShellSafeValue } from './shell-safe'
|
|
3
|
+
import { allShellSafe, isShellSafeAWSProfile, isShellSafeValue } from './shell-safe'
|
|
4
4
|
|
|
5
5
|
describe('isShellSafeValue', () => {
|
|
6
6
|
it('accepts the values real provider context names produce', () => {
|
|
@@ -53,3 +53,27 @@ describe('allShellSafe', () => {
|
|
|
53
53
|
expect(allShellSafe('prod', null)).toBe(false)
|
|
54
54
|
})
|
|
55
55
|
})
|
|
56
|
+
|
|
57
|
+
describe('isShellSafeAWSProfile', () => {
|
|
58
|
+
it('accepts simple and slash-separated profile names', () => {
|
|
59
|
+
expect(isShellSafeAWSProfile('default')).toBe(true)
|
|
60
|
+
expect(isShellSafeAWSProfile('myorg/my-account/my-role')).toBe(true)
|
|
61
|
+
expect(isShellSafeAWSProfile('prod-account')).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('rejects shell metacharacters', () => {
|
|
65
|
+
expect(isShellSafeAWSProfile('prod; id')).toBe(false)
|
|
66
|
+
expect(isShellSafeAWSProfile('prod$(id)')).toBe(false)
|
|
67
|
+
expect(isShellSafeAWSProfile('prod profile')).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('rejects leading dash', () => {
|
|
71
|
+
expect(isShellSafeAWSProfile('-rf')).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('rejects empty and absent values', () => {
|
|
75
|
+
expect(isShellSafeAWSProfile('')).toBe(false)
|
|
76
|
+
expect(isShellSafeAWSProfile(null)).toBe(false)
|
|
77
|
+
expect(isShellSafeAWSProfile(undefined)).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
})
|
package/src/utils/shell-safe.ts
CHANGED
|
@@ -19,3 +19,19 @@ export function isShellSafeValue(value: string | null | undefined): value is str
|
|
|
19
19
|
export function allShellSafe(...values: (string | null | undefined)[]): boolean {
|
|
20
20
|
return values.every(isShellSafeValue)
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
// AWS SSO profile names may contain `/` as a path separator
|
|
24
|
+
// (e.g. "myorg/my-account/my-role") — safe in shell arguments since `/`
|
|
25
|
+
// has no special meaning to the shell itself.
|
|
26
|
+
const SHELL_SAFE_AWS_PROFILE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/
|
|
27
|
+
|
|
28
|
+
export function isShellSafeAWSProfile(value: string | null | undefined): value is string {
|
|
29
|
+
return typeof value === 'string' && SHELL_SAFE_AWS_PROFILE.test(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Returns ` --profile <name>` for embedding in an aws CLI hint, or '' when no
|
|
33
|
+
// profile is pinned or the name fails the allowlist — the hint then falls
|
|
34
|
+
// back to the ambient profile rather than offering nothing.
|
|
35
|
+
export function awsProfileFlag(profile: string | null | undefined): string {
|
|
36
|
+
return isShellSafeAWSProfile(profile) ? ` --profile ${profile}` : ''
|
|
37
|
+
}
|