@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
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import Editor from '@monaco-editor/react'
|
|
3
|
+
import { AlertTriangle, Download, FileText, RotateCw } from 'lucide-react'
|
|
4
|
+
import { PaneLoader, ensureMonacoRuntime } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { formatBytes } from '../../utils/format'
|
|
6
|
+
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
7
|
+
|
|
8
|
+
// A curated inline viewer for text files inside a pod container, rendered in
|
|
9
|
+
// place of the file listing by PodFilesystemModal — never as its own dialog.
|
|
10
|
+
// Deliberately read-only for v1. Every error path is a named `code` from the
|
|
11
|
+
// backend so this file switches on intent, not on stderr wording.
|
|
12
|
+
|
|
13
|
+
// Mirrors podFilePreviewByteCap on the server. The listing already knows each
|
|
14
|
+
// file's size, so a file over the cap is marked unopenable up front instead
|
|
15
|
+
// of being clicked into a rejection.
|
|
16
|
+
export const PREVIEW_BYTE_CAP = 1 << 20
|
|
17
|
+
|
|
18
|
+
// Wire-side codes. Kept in sync with internal/server/copy.go.
|
|
19
|
+
export type PreviewErrorCode =
|
|
20
|
+
| 'file_too_large'
|
|
21
|
+
| 'binary_file'
|
|
22
|
+
| 'not_a_regular_file'
|
|
23
|
+
| 'not_found'
|
|
24
|
+
| 'permission_denied'
|
|
25
|
+
| 'no_shell'
|
|
26
|
+
| 'container_missing_tools'
|
|
27
|
+
| 'read_failed'
|
|
28
|
+
|
|
29
|
+
type PreviewSuccess = {
|
|
30
|
+
ok: true
|
|
31
|
+
content: string
|
|
32
|
+
size: number
|
|
33
|
+
mimeType: string
|
|
34
|
+
encoding: string
|
|
35
|
+
empty: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type PreviewError = {
|
|
39
|
+
ok: false
|
|
40
|
+
code: PreviewErrorCode | 'network_error'
|
|
41
|
+
message: string
|
|
42
|
+
size?: number
|
|
43
|
+
mimeType?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type PreviewResult = PreviewSuccess | PreviewError
|
|
47
|
+
|
|
48
|
+
async function fetchPodFilePreview(
|
|
49
|
+
namespace: string,
|
|
50
|
+
podName: string,
|
|
51
|
+
container: string,
|
|
52
|
+
filePath: string,
|
|
53
|
+
signal: AbortSignal,
|
|
54
|
+
): Promise<PreviewResult> {
|
|
55
|
+
const params = new URLSearchParams()
|
|
56
|
+
params.set('container', container)
|
|
57
|
+
params.set('path', filePath)
|
|
58
|
+
|
|
59
|
+
let response: Response
|
|
60
|
+
try {
|
|
61
|
+
response = await fetch(apiUrl(`/pods/${namespace}/${podName}/file?${params.toString()}`), {
|
|
62
|
+
credentials: getCredentialsMode(),
|
|
63
|
+
headers: getAuthHeaders(),
|
|
64
|
+
signal,
|
|
65
|
+
})
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if ((err as { name?: string })?.name === 'AbortError') throw err
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
code: 'network_error',
|
|
71
|
+
message: err instanceof Error ? err.message : 'Network request failed',
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const raw = await response.text()
|
|
76
|
+
let body: Record<string, unknown>
|
|
77
|
+
try {
|
|
78
|
+
body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {}
|
|
79
|
+
} catch {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
code: 'network_error',
|
|
83
|
+
message: `Unexpected non-JSON response (HTTP ${response.status}).`,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
code: (body.code as PreviewErrorCode) || 'read_failed',
|
|
91
|
+
message: (body.error as string) || `HTTP ${response.status}`,
|
|
92
|
+
size: typeof body.size === 'number' ? body.size : undefined,
|
|
93
|
+
mimeType: typeof body.mimeType === 'string' ? body.mimeType : undefined,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
content: (body.content as string) ?? '',
|
|
100
|
+
size: (body.size as number) ?? 0,
|
|
101
|
+
mimeType: (body.mimeType as string) ?? 'text/plain',
|
|
102
|
+
encoding: (body.encoding as string) ?? 'utf-8',
|
|
103
|
+
empty: body.code === 'empty_file',
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Map a filename to a Monaco language id. Only ids whose tokenizer the shared
|
|
108
|
+
// runtime registers (see monacoRuntime.ts) — anything else falls to plaintext.
|
|
109
|
+
// JSON goes to the YAML tokenizer: YAML 1.2 is a superset of JSON, and the
|
|
110
|
+
// JSON language would need a validation worker the runtime does not ship.
|
|
111
|
+
export function detectLanguage(fileName: string): string {
|
|
112
|
+
const lower = fileName.toLowerCase()
|
|
113
|
+
if (lower.endsWith('.json')) return 'yaml'
|
|
114
|
+
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'
|
|
115
|
+
if (lower.endsWith('.xml')) return 'xml'
|
|
116
|
+
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'html'
|
|
117
|
+
if (lower.endsWith('.css')) return 'css'
|
|
118
|
+
if (lower.endsWith('.js') || lower.endsWith('.mjs')) return 'javascript'
|
|
119
|
+
if (lower.endsWith('.ts')) return 'typescript'
|
|
120
|
+
if (lower.endsWith('.py')) return 'python'
|
|
121
|
+
if (lower.endsWith('.sh') || lower.endsWith('.bash')) return 'shell'
|
|
122
|
+
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'markdown'
|
|
123
|
+
if (lower === 'dockerfile' || lower.endsWith('.dockerfile')) return 'dockerfile'
|
|
124
|
+
if (lower.endsWith('.toml')) return 'ini'
|
|
125
|
+
if (lower.endsWith('.ini') || lower.endsWith('.conf') || lower.endsWith('.cfg')) return 'ini'
|
|
126
|
+
return 'plaintext'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function useMonacoTheme() {
|
|
130
|
+
const [dark, setDark] = useState(() =>
|
|
131
|
+
typeof document !== 'undefined' && document.documentElement.classList.contains('dark'),
|
|
132
|
+
)
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (typeof document === 'undefined') return
|
|
135
|
+
const observer = new MutationObserver(() => {
|
|
136
|
+
setDark(document.documentElement.classList.contains('dark'))
|
|
137
|
+
})
|
|
138
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
|
139
|
+
return () => observer.disconnect()
|
|
140
|
+
}, [])
|
|
141
|
+
return dark ? 'vs-dark' : 'vs'
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface PodFilePreviewProps {
|
|
145
|
+
namespace: string
|
|
146
|
+
podName: string
|
|
147
|
+
container: string
|
|
148
|
+
filePath: string
|
|
149
|
+
fileName: string
|
|
150
|
+
onDownload: () => void
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function PodFilePreview({
|
|
154
|
+
namespace,
|
|
155
|
+
podName,
|
|
156
|
+
container,
|
|
157
|
+
filePath,
|
|
158
|
+
fileName,
|
|
159
|
+
onDownload,
|
|
160
|
+
}: PodFilePreviewProps) {
|
|
161
|
+
const [result, setResult] = useState<PreviewResult | null>(null)
|
|
162
|
+
const [loading, setLoading] = useState(true)
|
|
163
|
+
const [reloadCount, setReloadCount] = useState(0)
|
|
164
|
+
const [runtime, setRuntime] = useState<'loading' | 'ready' | 'error'>('loading')
|
|
165
|
+
const theme = useMonacoTheme()
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const controller = new AbortController()
|
|
169
|
+
setLoading(true)
|
|
170
|
+
setResult(null)
|
|
171
|
+
fetchPodFilePreview(namespace, podName, container, filePath, controller.signal)
|
|
172
|
+
.then((r) => setResult(r))
|
|
173
|
+
.catch((err) => {
|
|
174
|
+
if ((err as { name?: string })?.name === 'AbortError') return
|
|
175
|
+
setResult({
|
|
176
|
+
ok: false,
|
|
177
|
+
code: 'network_error',
|
|
178
|
+
message: err instanceof Error ? err.message : 'Request failed',
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
.finally(() => setLoading(false))
|
|
182
|
+
return () => controller.abort()
|
|
183
|
+
}, [namespace, podName, container, filePath, reloadCount])
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
let active = true
|
|
187
|
+
ensureMonacoRuntime()
|
|
188
|
+
.then(() => active && setRuntime('ready'))
|
|
189
|
+
.catch(() => active && setRuntime('error'))
|
|
190
|
+
return () => {
|
|
191
|
+
active = false
|
|
192
|
+
}
|
|
193
|
+
}, [])
|
|
194
|
+
|
|
195
|
+
if (loading) return <PaneLoader label="Reading file…" className="flex-1" />
|
|
196
|
+
if (!result) return null
|
|
197
|
+
|
|
198
|
+
if (!result.ok) {
|
|
199
|
+
return (
|
|
200
|
+
<PreviewErrorState
|
|
201
|
+
result={result}
|
|
202
|
+
onDownload={onDownload}
|
|
203
|
+
onRetry={() => setReloadCount((n) => n + 1)}
|
|
204
|
+
/>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (result.empty) return <PreviewEmptyState fileName={fileName} />
|
|
209
|
+
|
|
210
|
+
if (runtime === 'loading') return <PaneLoader label="Loading editor…" className="flex-1" />
|
|
211
|
+
|
|
212
|
+
if (runtime === 'error') {
|
|
213
|
+
return (
|
|
214
|
+
<pre className="flex-1 min-h-0 overflow-auto p-4 font-mono text-xs text-theme-text-primary whitespace-pre">
|
|
215
|
+
{result.content}
|
|
216
|
+
</pre>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="flex-1 min-h-0">
|
|
222
|
+
<Editor
|
|
223
|
+
value={result.content}
|
|
224
|
+
language={detectLanguage(fileName)}
|
|
225
|
+
theme={theme}
|
|
226
|
+
options={{
|
|
227
|
+
readOnly: true,
|
|
228
|
+
domReadOnly: true,
|
|
229
|
+
minimap: { enabled: false },
|
|
230
|
+
scrollBeyondLastLine: false,
|
|
231
|
+
fontSize: 12,
|
|
232
|
+
lineNumbers: 'on',
|
|
233
|
+
wordWrap: 'off',
|
|
234
|
+
renderWhitespace: 'selection',
|
|
235
|
+
}}
|
|
236
|
+
loading={<PaneLoader label="Loading editor…" className="h-full" />}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ============================================================================
|
|
243
|
+
// Curated error / empty states
|
|
244
|
+
// ============================================================================
|
|
245
|
+
|
|
246
|
+
function PreviewEmptyState({ fileName }: { fileName: string }) {
|
|
247
|
+
return (
|
|
248
|
+
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
|
|
249
|
+
<FileText className="w-10 h-10 text-theme-text-tertiary mb-3" />
|
|
250
|
+
<div className="text-base font-medium text-theme-text-primary">{fileName} is empty</div>
|
|
251
|
+
<div className="text-sm text-theme-text-secondary mt-1">
|
|
252
|
+
This file has no content.
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function PreviewErrorState({
|
|
259
|
+
result,
|
|
260
|
+
onDownload,
|
|
261
|
+
onRetry,
|
|
262
|
+
}: {
|
|
263
|
+
result: PreviewError
|
|
264
|
+
onDownload: () => void
|
|
265
|
+
onRetry: () => void
|
|
266
|
+
}) {
|
|
267
|
+
const shape = curateError(result)
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<div className="flex-1 flex flex-col items-center justify-center text-center p-8 max-w-2xl mx-auto">
|
|
271
|
+
<AlertTriangle className={`w-10 h-10 mb-3 ${shape.severity === 'warning' ? 'text-amber-400' : 'text-red-400'}`} />
|
|
272
|
+
<div className="text-base font-medium text-theme-text-primary">{shape.title}</div>
|
|
273
|
+
<div className="text-sm text-theme-text-secondary mt-2 leading-relaxed">
|
|
274
|
+
{shape.description}
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<div className="flex items-center gap-2 mt-6">
|
|
278
|
+
{shape.actions.download && (
|
|
279
|
+
<button
|
|
280
|
+
onClick={onDownload}
|
|
281
|
+
className="flex items-center gap-2 px-3 py-1.5 rounded btn-brand text-sm"
|
|
282
|
+
>
|
|
283
|
+
<Download className="w-3.5 h-3.5" />
|
|
284
|
+
Download
|
|
285
|
+
</button>
|
|
286
|
+
)}
|
|
287
|
+
{shape.actions.retry && (
|
|
288
|
+
<button
|
|
289
|
+
onClick={onRetry}
|
|
290
|
+
className="flex items-center gap-2 px-3 py-1.5 rounded border border-theme-border hover:bg-theme-elevated text-sm text-theme-text-primary"
|
|
291
|
+
>
|
|
292
|
+
<RotateCw className="w-3.5 h-3.5" />
|
|
293
|
+
Retry
|
|
294
|
+
</button>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{shape.details && (
|
|
299
|
+
<details className="mt-4 text-xs text-theme-text-tertiary">
|
|
300
|
+
<summary className="cursor-pointer hover:text-theme-text-secondary">Technical details</summary>
|
|
301
|
+
<div className="mt-2 font-mono whitespace-pre-wrap text-left bg-theme-elevated/40 p-3 rounded max-w-xl">
|
|
302
|
+
{shape.details}
|
|
303
|
+
</div>
|
|
304
|
+
</details>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface CuratedShape {
|
|
311
|
+
title: string
|
|
312
|
+
description: string
|
|
313
|
+
severity: 'warning' | 'error'
|
|
314
|
+
actions: { download: boolean; retry: boolean }
|
|
315
|
+
details?: string
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function curateError(result: PreviewError): CuratedShape {
|
|
319
|
+
const size = result.size ? formatBytes(result.size) : ''
|
|
320
|
+
|
|
321
|
+
switch (result.code) {
|
|
322
|
+
case 'file_too_large':
|
|
323
|
+
return {
|
|
324
|
+
title: 'File too large to preview',
|
|
325
|
+
description: size
|
|
326
|
+
? `This file is ${size} — larger than the 1 MiB preview limit. Download to view the full contents.`
|
|
327
|
+
: 'This file is larger than the 1 MiB preview limit. Download to view.',
|
|
328
|
+
severity: 'warning',
|
|
329
|
+
actions: { download: true, retry: false },
|
|
330
|
+
}
|
|
331
|
+
case 'binary_file':
|
|
332
|
+
return {
|
|
333
|
+
title: 'Binary file — cannot preview',
|
|
334
|
+
description: result.mimeType
|
|
335
|
+
? `This file appears to be binary (detected type: ${result.mimeType}). Download it to view or open with an appropriate application.`
|
|
336
|
+
: 'This file is not valid text and cannot be shown inline. Download to view.',
|
|
337
|
+
severity: 'warning',
|
|
338
|
+
actions: { download: true, retry: false },
|
|
339
|
+
}
|
|
340
|
+
case 'not_a_regular_file':
|
|
341
|
+
return {
|
|
342
|
+
title: 'Not a regular file',
|
|
343
|
+
description: 'This entry is a directory, device, or pipe — Radar cannot preview its contents.',
|
|
344
|
+
severity: 'warning',
|
|
345
|
+
actions: { download: false, retry: false },
|
|
346
|
+
}
|
|
347
|
+
case 'not_found':
|
|
348
|
+
return {
|
|
349
|
+
title: 'File not found',
|
|
350
|
+
description: 'The file no longer exists in the container. It may have been rotated or deleted since the listing.',
|
|
351
|
+
severity: 'warning',
|
|
352
|
+
actions: { download: false, retry: true },
|
|
353
|
+
}
|
|
354
|
+
case 'permission_denied':
|
|
355
|
+
return {
|
|
356
|
+
title: 'Permission denied',
|
|
357
|
+
description: 'The container user cannot read this file. Try switching to a different container, or run Radar with an identity that has broader access.',
|
|
358
|
+
severity: 'warning',
|
|
359
|
+
actions: { download: false, retry: false },
|
|
360
|
+
}
|
|
361
|
+
case 'no_shell':
|
|
362
|
+
return {
|
|
363
|
+
title: 'Preview not available',
|
|
364
|
+
description: 'This container has no shell (likely distroless or scratch-based), so its filesystem cannot be read this way. Download is unavailable for the same reason.',
|
|
365
|
+
severity: 'warning',
|
|
366
|
+
actions: { download: false, retry: false },
|
|
367
|
+
}
|
|
368
|
+
case 'container_missing_tools':
|
|
369
|
+
return {
|
|
370
|
+
title: 'Container tools missing',
|
|
371
|
+
description: 'This container lacks the tools Radar needs to read files (tar and cat). Download may still work in a fallback mode.',
|
|
372
|
+
severity: 'warning',
|
|
373
|
+
actions: { download: true, retry: false },
|
|
374
|
+
}
|
|
375
|
+
case 'network_error':
|
|
376
|
+
return {
|
|
377
|
+
title: 'Network error',
|
|
378
|
+
description: 'Radar could not reach the cluster. Check your connection and try again.',
|
|
379
|
+
severity: 'error',
|
|
380
|
+
actions: { download: false, retry: true },
|
|
381
|
+
details: result.message,
|
|
382
|
+
}
|
|
383
|
+
case 'read_failed':
|
|
384
|
+
default:
|
|
385
|
+
return {
|
|
386
|
+
title: 'Could not read the file',
|
|
387
|
+
description: 'Radar reached the container but could not read the file. This may be transient — try again, or download instead.',
|
|
388
|
+
severity: 'error',
|
|
389
|
+
actions: { download: true, retry: true },
|
|
390
|
+
details: result.message,
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|