@skyhook-io/radar-app 1.13.3 → 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.
Files changed (45) hide show
  1. package/package.json +6 -6
  2. package/src/App.tsx +68 -23
  3. package/src/RadarApp.tsx +17 -1
  4. package/src/api/client.ts +28 -10
  5. package/src/api/diagnose.ts +61 -15
  6. package/src/components/ConnectionErrorView.test.tsx +30 -0
  7. package/src/components/ConnectionErrorView.tsx +31 -19
  8. package/src/components/diagnose/AgentCase.tsx +131 -0
  9. package/src/components/diagnose/DiagnoseContext.test.ts +95 -0
  10. package/src/components/diagnose/DiagnoseContext.tsx +402 -77
  11. package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
  12. package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
  13. package/src/components/diagnose/Home.test.tsx +23 -1
  14. package/src/components/diagnose/Home.tsx +49 -3
  15. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
  16. package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
  17. package/src/components/diagnose/InvestigationView.tsx +227 -5
  18. package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
  19. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  20. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  21. package/src/components/diagnose/investigationCase.ts +439 -0
  22. package/src/components/diagnose/investigationEvidence.test.ts +3525 -17
  23. package/src/components/diagnose/investigationEvidence.ts +2246 -166
  24. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  27. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  28. package/src/components/diagnose/investigationMetrics.ts +393 -0
  29. package/src/components/diagnose/investigationSourceFocus.ts +6 -0
  30. package/src/components/diagnose/investigationState.test.ts +322 -0
  31. package/src/components/diagnose/investigationState.ts +147 -25
  32. package/src/components/diagnose/parts.test.tsx +537 -1
  33. package/src/components/diagnose/parts.tsx +486 -42
  34. package/src/components/resource/HPACharts.render.test.tsx +77 -0
  35. package/src/components/resource/HPACharts.tsx +32 -25
  36. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  37. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  38. package/src/components/resources/PodFilePreview.tsx +394 -0
  39. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  40. package/src/components/resources/ResourcesView.tsx +27 -2
  41. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  42. package/src/context/DiagnoseCustomization.tsx +14 -2
  43. package/src/index.ts +8 -5
  44. package/src/utils/shell-safe.test.ts +25 -1
  45. package/src/utils/shell-safe.ts +16 -0
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { curateError, detectLanguage } from './PodFilePreview'
4
+
5
+ // Every backend error code becomes a curated screen. What matters here is
6
+ // that each code carries the right action affordances (Download vs Retry)
7
+ // and never leaks a raw stderr line as the primary description.
8
+
9
+ describe('curateError — action affordances per code', () => {
10
+ it('offers Download for file_too_large and names the actual size', () => {
11
+ const shape = curateError({
12
+ ok: false,
13
+ code: 'file_too_large',
14
+ message: 'File is 12582912 bytes; preview is limited to 1048576 bytes.',
15
+ size: 12 * 1024 * 1024,
16
+ })
17
+ expect(shape.actions).toEqual({ download: true, retry: false })
18
+ expect(shape.description).toMatch(/MiB|MB/i)
19
+ // The raw byte-count message must not be the primary description.
20
+ expect(shape.description).not.toContain('12582912')
21
+ })
22
+
23
+ it('offers Download for binary_file and names the detected mime type', () => {
24
+ const shape = curateError({
25
+ ok: false,
26
+ code: 'binary_file',
27
+ message: 'server-side detail',
28
+ mimeType: 'application/x-executable',
29
+ })
30
+ expect(shape.actions).toEqual({ download: true, retry: false })
31
+ expect(shape.description).toContain('application/x-executable')
32
+ expect(shape.description).not.toContain('server-side detail')
33
+ })
34
+
35
+ it('offers Retry for not_found — the file may have been rotated', () => {
36
+ const shape = curateError({ ok: false, code: 'not_found', message: 'x' })
37
+ expect(shape.actions.retry).toBe(true)
38
+ expect(shape.actions.download).toBe(false)
39
+ })
40
+
41
+ it('offers neither Download nor Retry for permission_denied', () => {
42
+ const shape = curateError({ ok: false, code: 'permission_denied', message: 'x' })
43
+ expect(shape.actions).toEqual({ download: false, retry: false })
44
+ })
45
+
46
+ it('offers neither for no_shell — the container cannot be read this way', () => {
47
+ const shape = curateError({ ok: false, code: 'no_shell', message: 'x' })
48
+ expect(shape.actions).toEqual({ download: false, retry: false })
49
+ expect(shape.description).toMatch(/distroless|scratch|no shell/i)
50
+ })
51
+
52
+ it('offers Retry for network_error and reveals details for diagnostics', () => {
53
+ const shape = curateError({
54
+ ok: false,
55
+ code: 'network_error',
56
+ message: 'Failed to fetch',
57
+ })
58
+ expect(shape.actions.retry).toBe(true)
59
+ expect(shape.details).toBe('Failed to fetch')
60
+ })
61
+
62
+ it('falls unknown/read_failed to a generic curated line with both actions', () => {
63
+ const shape = curateError({ ok: false, code: 'read_failed', message: 'stderr blob' })
64
+ expect(shape.actions).toEqual({ download: true, retry: true })
65
+ // The raw stderr belongs behind the details disclosure, not in the headline.
66
+ expect(shape.description).not.toContain('stderr blob')
67
+ expect(shape.details).toBe('stderr blob')
68
+ })
69
+ })
70
+
71
+ // The primary description is what an operator reads first. It must never be
72
+ // the raw server message on any curated code — that was the maintainer's
73
+ // specific concern ("curated error message rather than only dumping raw error").
74
+ describe('curateError — no raw messages in primary text', () => {
75
+ const rawStderr = 'sh: /etc/shadow: Permission denied — some raw text'
76
+ it.each([
77
+ 'file_too_large',
78
+ 'binary_file',
79
+ 'not_a_regular_file',
80
+ 'not_found',
81
+ 'permission_denied',
82
+ 'no_shell',
83
+ 'container_missing_tools',
84
+ 'read_failed',
85
+ ] as const)('%s does not leak the raw server message into the title or description', (code) => {
86
+ const shape = curateError({ ok: false, code, message: rawStderr })
87
+ expect(shape.title).not.toContain(rawStderr)
88
+ expect(shape.description).not.toContain(rawStderr)
89
+ })
90
+ })
91
+
92
+ describe('detectLanguage', () => {
93
+ it.each([
94
+ ['nginx.conf', 'ini'],
95
+ ['deployment.yaml', 'yaml'],
96
+ ['deployment.yml', 'yaml'],
97
+ ['schema.json', 'yaml'],
98
+ ['Dockerfile', 'dockerfile'],
99
+ ['index.html', 'html'],
100
+ ['README.md', 'markdown'],
101
+ ['app.log', 'plaintext'],
102
+ ['unknown', 'plaintext'],
103
+ ['UPPERCASE.YAML', 'yaml'],
104
+ ])('%s → %s', (name, want) => {
105
+ expect(detectLanguage(name)).toBe(want)
106
+ })
107
+ })
108
+
109
+ // The empty state and the loading state each render distinct content so an
110
+ // operator can tell them apart. These are the two states most likely to look
111
+ // like a bug if they collide.
112
+ describe('curated states are distinguishable', () => {
113
+ it('an empty file description is not confusable with a failure', () => {
114
+ const shape = curateError({ ok: false, code: 'read_failed', message: '' })
115
+ // The empty-file state is rendered separately (PreviewEmptyState) and does
116
+ // NOT come through curateError — a read_failed with an empty message must
117
+ // therefore never say "empty" in its curated description.
118
+ expect(shape.description.toLowerCase()).not.toContain('empty')
119
+ })
120
+ })
121
+
122
+ // Static markup smoke: the module imports at all and the exported pure helpers
123
+ // stay tree-shakeable / server-renderable. If curateError were to reach for
124
+ // browser-only globals this would trip.
125
+ describe('curated shapes render on the server', () => {
126
+ it('renders a title node without throwing in a server context', () => {
127
+ const shape = curateError({ ok: false, code: 'binary_file', message: 'x', mimeType: 'application/octet-stream' })
128
+ const html = renderToStaticMarkup(<span>{shape.title}</span>)
129
+ expect(html).toContain('Binary')
130
+ })
131
+ })
@@ -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
+