@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.
- package/package.json +6 -6
- package/src/App.tsx +68 -23
- package/src/RadarApp.tsx +17 -1
- package/src/api/client.ts +28 -10
- 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/DiagnoseContext.test.ts +95 -0
- package/src/components/diagnose/DiagnoseContext.tsx +402 -77
- package/src/components/diagnose/DiagnoseSurface.test.tsx +53 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +198 -286
- package/src/components/diagnose/Home.test.tsx +23 -1
- package/src/components/diagnose/Home.tsx +49 -3
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +1446 -5
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1373 -126
- package/src/components/diagnose/InvestigationView.tsx +227 -5
- package/src/components/diagnose/LocalDiagnoseAction.tsx +2 -3
- 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 +3525 -17
- package/src/components/diagnose/investigationEvidence.ts +2246 -166
- 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 +6 -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 +537 -1
- package/src/components/diagnose/parts.tsx +486 -42
- package/src/components/resource/HPACharts.render.test.tsx +77 -0
- package/src/components/resource/HPACharts.tsx +32 -25
- 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/components/resources/ResourcesView.tsx +27 -2
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +8 -5
- package/src/utils/shell-safe.test.ts +25 -1
- 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
|
+
|