@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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/RadarApp.tsx +5 -0
  3. package/src/api/client.ts +4 -0
  4. package/src/api/diagnose.ts +61 -15
  5. package/src/components/ConnectionErrorView.test.tsx +30 -0
  6. package/src/components/ConnectionErrorView.tsx +31 -19
  7. package/src/components/diagnose/AgentCase.tsx +131 -0
  8. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  9. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  10. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  11. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  12. package/src/components/diagnose/InvestigationView.tsx +222 -5
  13. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  14. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  15. package/src/components/diagnose/investigationCase.ts +439 -0
  16. package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
  17. package/src/components/diagnose/investigationEvidence.ts +831 -43
  18. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  19. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  20. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  21. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  22. package/src/components/diagnose/investigationMetrics.ts +393 -0
  23. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  24. package/src/components/diagnose/investigationState.test.ts +322 -0
  25. package/src/components/diagnose/investigationState.ts +147 -25
  26. package/src/components/diagnose/parts.test.tsx +482 -0
  27. package/src/components/diagnose/parts.tsx +418 -41
  28. package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
  29. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  30. package/src/components/resources/PodFilePreview.tsx +394 -0
  31. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  32. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  33. package/src/context/DiagnoseCustomization.tsx +14 -2
  34. package/src/index.ts +1 -0
  35. package/src/utils/shell-safe.test.ts +25 -1
  36. 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
+