@skyhook-io/radar-app 1.8.2 → 1.8.3

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.
@@ -15,10 +15,9 @@ interface NamespaceSwitcherProps {
15
15
  }
16
16
 
17
17
  /**
18
- * NamespaceSwitcher is a per-user multi-select view filter for the cluster
19
- * view. It does NOT reshape the shared informer cache — picks are saved
20
- * server-side per user and intersected with the user's RBAC-allowed
21
- * namespaces on each read.
18
+ * NamespaceSwitcher is normally a per-user multi-select view filter. When the
19
+ * backend reports cacheScoped=true, it becomes a single-namespace cache scope
20
+ * control; local sessions may rebuild the cache for a different namespace.
22
21
  *
23
22
  * Three states reflect what the backend reports:
24
23
  * - cluster-wide: empty trigger label "All namespaces", picker lets the
@@ -71,6 +70,7 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
71
70
  const applySelection = useCallback((next: Set<string>) => {
72
71
  if (!scope) return
73
72
  const nextArr = Array.from(next).sort()
73
+ if (scope.cacheScoped && nextArr.length !== 1) return
74
74
  if (nextArr.join(',') === activesKey) return
75
75
  setActive.mutate({ namespaces: nextArr })
76
76
  }, [activesKey, scope, setActive])
@@ -120,6 +120,10 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
120
120
  if (!scope) return null
121
121
 
122
122
  const toggle = (ns: string) => {
123
+ if (scope.cacheScoped) {
124
+ setDraft(new Set([ns]))
125
+ return
126
+ }
123
127
  const next = new Set(draft)
124
128
  if (next.has(ns)) next.delete(ns)
125
129
  else next.add(ns)
@@ -127,6 +131,7 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
127
131
  }
128
132
 
129
133
  const clearAll = () => {
134
+ if (scope.cacheScoped) return
130
135
  setDraft(new Set())
131
136
  setIsOpen(false)
132
137
  setSearch('')
@@ -150,11 +155,16 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
150
155
  activeCount === 0 ? 'All namespaces' : activeCount === 1 ? scopeActives[0] : `${activeCount} namespaces`
151
156
  const isClusterWide = activeCount === 0
152
157
  const restrictedHint = scope.mode === 'restricted'
153
- const isDisabled = disabled || isLoading || setActive.isPending
158
+ const cacheScopeLocked = scope.cacheScoped && !scope.namespaceRescope
159
+ const isDisabled = disabled || isLoading || setActive.isPending || cacheScopeLocked
154
160
  const canClearAll = scope.canClearNamespace || activeCount === 0
155
161
  const tooltipContent = disabled && disabledTooltip
156
162
  ? disabledTooltip
157
- : restrictedHint
163
+ : scope.cacheScoped
164
+ ? scope.namespaceRescope
165
+ ? `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} to stay fast on large clusters. Pick another namespace to re-point it (takes a moment; closes open terminals).`
166
+ : `Radar is watching only ${scope.cacheScopeNamespace || triggerLabel} on this cluster.`
167
+ : restrictedHint
158
168
  ? 'Limited namespace visibility — only namespaces granted by your RBAC are shown.'
159
169
  : isClusterWide
160
170
  ? 'Currently viewing all namespaces. Click to narrow the view.'
@@ -213,28 +223,37 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
213
223
  </div>
214
224
  )}
215
225
 
216
- <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
217
- <button
218
- onClick={canClearAll ? clearAll : undefined}
219
- disabled={!canClearAll || activeCount === 0}
220
- className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
221
- aria-label="Clear namespace selection"
222
- >
223
- <X className="w-3 h-3" />
224
- Clear all
225
- </button>
226
- <button
227
- onClick={allVisibleSelected ? clearVisible : selectAllVisible}
228
- disabled={filteredItems.length === 0}
229
- className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
230
- >
231
- {allVisibleSelected
232
- ? `Clear ${filteredItems.length} visible`
233
- : search.trim()
234
- ? `Select ${filteredItems.length} visible`
235
- : 'Select all'}
236
- </button>
237
- </div>
226
+ {scope.cacheScoped ? (
227
+ <div className="px-3 py-1.5 border-b border-theme-border text-[11px] leading-snug text-theme-text-secondary">
228
+ Radar is watching one namespace to stay fast on large clusters.
229
+ {scope.namespaceRescope
230
+ ? ' Pick another to re-point it takes a moment and closes open terminals.'
231
+ : ' This instance is locked to its startup namespace.'}
232
+ </div>
233
+ ) : (
234
+ <div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
235
+ <button
236
+ onClick={canClearAll ? clearAll : undefined}
237
+ disabled={!canClearAll || activeCount === 0}
238
+ className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
239
+ aria-label="Clear namespace selection"
240
+ >
241
+ <X className="w-3 h-3" />
242
+ Clear all
243
+ </button>
244
+ <button
245
+ onClick={allVisibleSelected ? clearVisible : selectAllVisible}
246
+ disabled={filteredItems.length === 0}
247
+ className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
248
+ >
249
+ {allVisibleSelected
250
+ ? `Clear ${filteredItems.length} visible`
251
+ : search.trim()
252
+ ? `Select ${filteredItems.length} visible`
253
+ : 'Select all'}
254
+ </button>
255
+ </div>
256
+ )}
238
257
 
239
258
  <ul className="max-h-80 overflow-y-auto py-1">
240
259
  {filteredItems.length === 0 && (
@@ -253,7 +272,8 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
253
272
  >
254
273
  <span className="flex items-center gap-2 min-w-0">
255
274
  <input
256
- type="checkbox"
275
+ type={scope.cacheScoped ? 'radio' : 'checkbox'}
276
+ name={scope.cacheScoped ? 'namespace-cache-scope' : undefined}
257
277
  checked={isChecked}
258
278
  onChange={() => toggle(ns)}
259
279
  className="shrink-0 accent-current"
@@ -273,7 +293,9 @@ export const NamespaceSwitcher = forwardRef<NamespaceSwitcherHandle, NamespaceSw
273
293
 
274
294
  <div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
275
295
  <span>
276
- {draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
296
+ {scope.cacheScoped
297
+ ? (draft.size === 1 ? Array.from(draft)[0] : 'Select a namespace')
298
+ : draft.size === 0 ? 'All namespaces' : `${draft.size} selected`}
277
299
  </span>
278
300
  <button
279
301
  onClick={closeAndApply}
@@ -0,0 +1,445 @@
1
+ import { useState, useEffect, useLayoutEffect, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { useMutation } from '@tanstack/react-query'
4
+ import { Activity, Loader2, X, ChevronDown, Maximize2, Copy, Check } from 'lucide-react'
5
+ import { clsx } from 'clsx'
6
+ import { apiFetch } from '../../api/client'
7
+ import { apiUrl } from '../../api/config'
8
+ import { Tooltip } from '../ui/Tooltip'
9
+
10
+ // A port is "curl-able" only if it plausibly speaks HTTP — probing a raw TCP
11
+ // port (Postgres, Redis) with a GET returns noise, so we don't offer it there
12
+ // (that's the local-client TCP path's job). Heuristic over name/appProtocol/number.
13
+ const HTTP_PORT_NUMBERS = new Set([80, 443, 8080, 8443, 8000, 8081, 3000, 5000, 9090, 9091, 9093, 9100, 15000, 15090])
14
+ const HTTP_NAME_RE = /(^|[-_])(http|https|web|ui|console|dashboard|metrics|api|admin)([-_]|$)/i
15
+
16
+ // Common metrics port numbers — used to decide which quick-path chips make sense.
17
+ const METRICS_PORT_NUMBERS = new Set([9090, 9091, 9093, 9100, 9153, 2112, 8888])
18
+
19
+ function isMetricsPort(port: number, name?: string, appProtocol?: string): boolean {
20
+ if ((appProtocol || '').toLowerCase().includes('metric')) return true
21
+ if (name && /metric/i.test(name)) return true
22
+ return METRICS_PORT_NUMBERS.has(port)
23
+ }
24
+
25
+ // Default request path for a port. Only metrics ports get a non-root default —
26
+ // /metrics is a near-deterministic convention there. We deliberately don't
27
+ // pre-fill or suggest health paths (/healthz etc.): those are genuine guesses
28
+ // (apps vary: /health, /actuator/health, /-/healthy …) and a suggestion that
29
+ // 404s reads as the tool being wrong. The honest one-click-health feature is to
30
+ // derive paths from the backing pod's liveness/readiness probes — a follow-up.
31
+ export function defaultPathForPort(port: number, name?: string, appProtocol?: string): string {
32
+ return isMetricsPort(port, name, appProtocol) ? '/metrics' : '/'
33
+ }
34
+
35
+ export function isHttpishPort(port: number, name?: string, appProtocol?: string, protocol?: string): boolean {
36
+ // HTTP rides TCP — a UDP port is never a GET target (e.g. statsd "metrics-udp").
37
+ if ((protocol || '').toUpperCase() === 'UDP') return false
38
+ const proto = (appProtocol || '').toLowerCase()
39
+ if (proto === 'http' || proto === 'https' || proto === 'http2') return true
40
+ if (proto && proto !== 'tcp') {
41
+ // explicit non-HTTP appProtocol (grpc, redis, postgres, …) → not a GET target
42
+ return false
43
+ }
44
+ if (name && HTTP_NAME_RE.test(name)) return true
45
+ return HTTP_PORT_NUMBERS.has(port)
46
+ }
47
+
48
+ export function defaultScheme(port: number, name?: string, appProtocol?: string): 'http' | 'https' {
49
+ if ((appProtocol || '').toLowerCase() === 'https') return 'https'
50
+ if (port === 443 || port === 8443) return 'https'
51
+ if (name && /https/i.test(name)) return 'https'
52
+ return 'http'
53
+ }
54
+
55
+ interface CurlResult {
56
+ status: number
57
+ statusText: string
58
+ durationMs: number
59
+ headers: Record<string, string>
60
+ body: string
61
+ truncated: boolean
62
+ bodyBytes: number
63
+ error?: string
64
+ }
65
+
66
+ function statusTextTone(status: number): string {
67
+ if (status >= 200 && status < 300) return 'text-emerald-400'
68
+ if (status >= 300 && status < 400) return 'text-blue-400'
69
+ if (status >= 400 && status < 500) return 'text-amber-400'
70
+ return 'text-red-400'
71
+ }
72
+
73
+ function statusDotTone(status: number): string {
74
+ if (status >= 200 && status < 300) return 'bg-emerald-400'
75
+ if (status >= 300 && status < 400) return 'bg-blue-400'
76
+ if (status >= 400 && status < 500) return 'bg-amber-400'
77
+ return 'bg-red-400'
78
+ }
79
+
80
+ // Make the body readable per content type: pretty-print JSON, label everything
81
+ // else (HTML / Prometheus / XML / …) so the operator knows what they're looking at.
82
+ function formatBody(result: CurlResult): { text: string; label: string } {
83
+ const body = result.body
84
+ const ct = (result.headers['Content-Type'] || result.headers['content-type'] || '').toLowerCase()
85
+ const looksJson = ct.includes('json') || /^\s*[[{]/.test(body)
86
+ if (looksJson) {
87
+ let text = body
88
+ if (!result.truncated) {
89
+ try { text = JSON.stringify(JSON.parse(body), null, 2) } catch { /* leave raw */ }
90
+ }
91
+ return { text, label: 'JSON' }
92
+ }
93
+ if (ct.includes('html')) return { text: body, label: 'HTML' }
94
+ if (body.startsWith('# HELP') || body.startsWith('# TYPE') || ct.includes('openmetrics')) {
95
+ return { text: body, label: 'Prometheus' }
96
+ }
97
+ if (ct.includes('xml')) return { text: body, label: 'XML' }
98
+ const short = ct ? (ct.split(';')[0].split('/').pop() || 'text') : 'text'
99
+ return { text: body, label: short }
100
+ }
101
+
102
+ function CopyButton({ text, className }: { text: string; className?: string }) {
103
+ const [copied, setCopied] = useState(false)
104
+ return (
105
+ <button
106
+ type="button"
107
+ onClick={(e) => {
108
+ e.stopPropagation()
109
+ navigator.clipboard?.writeText(text).then(() => {
110
+ setCopied(true)
111
+ setTimeout(() => setCopied(false), 1500)
112
+ }).catch(() => {})
113
+ }}
114
+ className={clsx('inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary', className)}
115
+ >
116
+ {copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
117
+ {copied ? 'Copied' : 'Copy'}
118
+ </button>
119
+ )
120
+ }
121
+
122
+ // Small toggle button rendered in a port row's action slot. The panel itself
123
+ // renders inline within the port card (see CurlPanel), not as an overlay.
124
+ export function CurlButton({ active, onClick }: { active: boolean; onClick: () => void }) {
125
+ return (
126
+ <Tooltip content="Curl this endpoint — GET from inside the cluster">
127
+ <button
128
+ onClick={(e) => { e.stopPropagation(); onClick() }}
129
+ aria-expanded={active}
130
+ className={clsx(
131
+ 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors',
132
+ active ? 'bg-accent-muted text-blue-400' : 'bg-theme-elevated hover:bg-accent-muted',
133
+ )}
134
+ >
135
+ Curl
136
+ {/* Disclosure caret: signals this expands an inline panel rather than firing a request. */}
137
+ <ChevronDown className={clsx('w-3 h-3 transition-transform', active && 'rotate-180')} />
138
+ </button>
139
+ </Tooltip>
140
+ )
141
+ }
142
+
143
+ function VerdictLine({
144
+ result,
145
+ showHeaders,
146
+ onToggleHeaders,
147
+ }: {
148
+ result: CurlResult
149
+ showHeaders: boolean
150
+ onToggleHeaders: () => void
151
+ }) {
152
+ return (
153
+ <div className="flex items-center gap-3 text-xs">
154
+ <span className={clsx('flex items-center gap-1.5 font-mono font-semibold', statusTextTone(result.status))}>
155
+ <span className={clsx('w-1.5 h-1.5 rounded-full', statusDotTone(result.status))} />
156
+ {result.status}{result.statusText ? ` ${result.statusText}` : ''}
157
+ </span>
158
+ <span className="text-theme-text-tertiary">{result.durationMs} ms</span>
159
+ <span className="text-theme-text-tertiary">{result.bodyBytes.toLocaleString()} bytes{result.truncated ? ' (truncated)' : ''}</span>
160
+ <button
161
+ type="button"
162
+ onClick={onToggleHeaders}
163
+ className="ml-auto flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary"
164
+ >
165
+ Headers <ChevronDown className={clsx('w-3 h-3 transition-transform', showHeaders && 'rotate-180')} />
166
+ </button>
167
+ </div>
168
+ )
169
+ }
170
+
171
+ // Roomy response viewer. The narrow drawer can't show a 27 KB /metrics body
172
+ // readably (wide lines wrap into mush), so the full body opens in a centered
173
+ // dialog — wide, tall, monospace, no-wrap with its own scroll (decoupled from the
174
+ // drawer, so there's no nested-scroll). Triggered on demand; the request + verdict
175
+ // stay inline in the port card. Matches the kubectl copy-command dialog pattern.
176
+ function CurlResponseDialog({
177
+ serviceName,
178
+ port,
179
+ scheme,
180
+ path,
181
+ result,
182
+ onClose,
183
+ }: {
184
+ serviceName: string
185
+ port: number
186
+ scheme: string
187
+ path: string
188
+ result: CurlResult
189
+ onClose: () => void
190
+ }) {
191
+ const [showHeaders, setShowHeaders] = useState(false)
192
+ const { text, label } = formatBody(result)
193
+ useEffect(() => {
194
+ // Capture + stopPropagation so Escape closes only this dialog, not the drawer
195
+ // behind it (its Escape shortcut listens in the bubble phase).
196
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } }
197
+ document.addEventListener('keydown', onKey, true)
198
+ return () => document.removeEventListener('keydown', onKey, true)
199
+ }, [onClose])
200
+ // Portal to <body>: the drawer is a transformed ancestor, which would otherwise
201
+ // trap this position:fixed dialog inside the drawer instead of centering it on
202
+ // the viewport.
203
+ return createPortal(
204
+ <div className="fixed inset-0 z-50 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
205
+ <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
206
+ <div className="relative dialog w-full max-w-4xl mx-4 max-h-[85vh] flex flex-col outline-none">
207
+ <div className="flex items-center justify-between gap-3 p-4 border-b border-theme-border">
208
+ <div className="min-w-0">
209
+ <div className="flex items-center gap-2">
210
+ <Activity className="w-4 h-4 text-blue-400 shrink-0" />
211
+ <h3 className="text-sm font-semibold text-theme-text-primary truncate">Response</h3>
212
+ </div>
213
+ <div className="text-xs text-theme-text-tertiary font-mono mt-0.5 truncate">
214
+ GET {scheme}://{serviceName}:{port}{path}
215
+ </div>
216
+ </div>
217
+ <button onClick={onClose} aria-label="Close" className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0">
218
+ <X className="w-5 h-5" />
219
+ </button>
220
+ </div>
221
+
222
+ <div className="px-4 py-2 border-b border-theme-border">
223
+ <VerdictLine result={result} showHeaders={showHeaders} onToggleHeaders={() => setShowHeaders((v) => !v)} />
224
+ </div>
225
+
226
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out mx-4" style={{ gridTemplateRows: showHeaders ? '1fr' : '0fr' }}>
227
+ <div className="overflow-hidden">
228
+ <pre className="text-xs bg-theme-base mt-4 rounded p-3 overflow-auto max-h-48 text-theme-text-secondary font-mono whitespace-pre">
229
+ {Object.entries(result.headers).map(([k, v]) => `${k}: ${v}`).join('\n') || '(no headers)'}
230
+ </pre>
231
+ </div>
232
+ </div>
233
+
234
+ {result.error ? (
235
+ <div className="m-4 text-sm text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2">
236
+ {result.error}
237
+ </div>
238
+ ) : (
239
+ <div className="flex flex-col min-h-0 flex-1 m-4">
240
+ <div className="flex items-center justify-between mb-1.5">
241
+ <span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{label}</span>
242
+ {result.body && <CopyButton text={text} />}
243
+ </div>
244
+ <pre className="flex-1 text-xs bg-theme-base rounded p-3 overflow-auto text-theme-text-primary font-mono whitespace-pre">
245
+ {text || '(empty response body)'}
246
+ </pre>
247
+ </div>
248
+ )}
249
+ </div>
250
+ </div>,
251
+ document.body,
252
+ )
253
+ }
254
+
255
+ // Inline curl: request form + verdict + a short body peek, rendered in the
256
+ // drawer flow (inside the port card). The full body opens in CurlResponseDialog
257
+ // so a large response never bloats the drawer.
258
+ export function CurlPanel({
259
+ namespace,
260
+ serviceName,
261
+ port,
262
+ initialScheme,
263
+ initialPath,
264
+ open,
265
+ onClose,
266
+ }: {
267
+ namespace: string
268
+ serviceName: string
269
+ port: number
270
+ initialScheme: 'http' | 'https'
271
+ initialPath: string
272
+ // Host-controlled: false triggers the collapse animation before the host unmounts.
273
+ open: boolean
274
+ onClose: () => void
275
+ }) {
276
+ const [scheme, setScheme] = useState<'http' | 'https'>(initialScheme)
277
+ // Stored WITHOUT the leading slash — the "/" is a fixed, non-deletable prefix
278
+ // glued to the input. Typed/pasted leading slashes are swallowed on change.
279
+ const [path, setPath] = useState(() => initialPath.replace(/^\/+/, ''))
280
+ const fullPath = '/' + path
281
+ const [showHeaders, setShowHeaders] = useState(false)
282
+ const [sheetOpen, setSheetOpen] = useState(false)
283
+ // What was actually sent — so the sheet header / re-renders reflect the response.
284
+ const [sent, setSent] = useState<{ scheme: 'http' | 'https'; path: string }>({ scheme: initialScheme, path: initialPath })
285
+ // Enter animation: mount collapsed, then expand next tick (radar's grid 0fr↔1fr).
286
+ // Combined with the host-controlled `open` prop this gives a symmetric reveal:
287
+ // expand on mount, collapse when the host sets open=false (before it unmounts).
288
+ const [mounted, setMounted] = useState(false)
289
+ useEffect(() => { setMounted(true) }, [])
290
+
291
+ const curl = useMutation<CurlResult, Error, { scheme: 'http' | 'https'; path: string }>({
292
+ mutationFn: async (vars) => {
293
+ setSent(vars)
294
+ const res = await apiFetch(apiUrl('/curl/service'), {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({ namespace, name: serviceName, port: String(port), scheme: vars.scheme, path: vars.path }),
298
+ })
299
+ const data = await res.json().catch(() => ({}))
300
+ if (!res.ok) throw new Error(data?.error || `Request failed (${res.status})`)
301
+ return data as CurlResult
302
+ },
303
+ })
304
+
305
+ const result = curl.data
306
+ const peek = result && !result.error ? formatBody(result) : null
307
+
308
+ // Only fade + offer "View full response" when the body actually overflows the
309
+ // peek box — a small body that fits has nothing more to show, and a fade over
310
+ // it reads as a rendering glitch.
311
+ const peekRef = useRef<HTMLPreElement>(null)
312
+ const [peekOverflows, setPeekOverflows] = useState(false)
313
+ useLayoutEffect(() => {
314
+ const el = peekRef.current
315
+ setPeekOverflows(!!el && el.scrollHeight > el.clientHeight + 1)
316
+ }, [peek?.text, open])
317
+
318
+ return (
319
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: mounted && open ? '1fr' : '0fr' }}>
320
+ <div className="overflow-hidden">
321
+ <div className="mt-3 pt-3 border-t border-theme-border space-y-2" onClick={(e) => e.stopPropagation()}>
322
+ <div className="flex items-center justify-between">
323
+ <span className="flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary">
324
+ <Activity className="w-3.5 h-3.5 text-blue-400" />
325
+ Curl — GET from inside the cluster
326
+ </span>
327
+ <button
328
+ onClick={onClose}
329
+ aria-label="Close"
330
+ className="p-0.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
331
+ >
332
+ <X className="w-3.5 h-3.5" />
333
+ </button>
334
+ </div>
335
+
336
+ <form className="flex items-stretch gap-2" onSubmit={(e) => { e.preventDefault(); curl.mutate({ scheme, path: fullPath }) }}>
337
+ <select
338
+ value={scheme}
339
+ onChange={(e) => setScheme(e.target.value as 'http' | 'https')}
340
+ className="bg-theme-base border border-theme-border rounded px-2 py-1 text-xs text-theme-text-primary font-mono"
341
+ aria-label="Scheme"
342
+ >
343
+ <option value="http">http</option>
344
+ <option value="https">https</option>
345
+ </select>
346
+ <div className="flex-1 min-w-0 flex items-center bg-theme-base border border-theme-border rounded px-2 focus-within:border-blue-500">
347
+ <span className="text-xs text-theme-text-tertiary font-mono select-none pointer-events-none">/</span>
348
+ <input
349
+ type="text"
350
+ value={path}
351
+ onChange={(e) => setPath(e.target.value.replace(/^\/+/, ''))}
352
+ placeholder="healthz"
353
+ aria-label="Request path"
354
+ className="flex-1 min-w-0 bg-transparent border-0 outline-none pl-0.5 py-1 text-xs text-theme-text-primary font-mono"
355
+ />
356
+ </div>
357
+ <button
358
+ type="submit"
359
+ disabled={curl.isPending}
360
+ className="shrink-0 px-3 py-1 btn-brand text-xs rounded-lg flex items-center gap-1.5 disabled:opacity-50"
361
+ >
362
+ {curl.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Activity className="w-3.5 h-3.5" />}
363
+ Send
364
+ </button>
365
+ </form>
366
+
367
+ {curl.isError && (
368
+ <div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded px-2 py-1.5">
369
+ {(curl.error as Error).message}
370
+ </div>
371
+ )}
372
+
373
+ {/* Reveal the response with the same grid transition as the panel itself. */}
374
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: result ? '1fr' : '0fr' }}>
375
+ <div className="overflow-hidden">
376
+ {result && (
377
+ <div className="space-y-2 pt-0.5">
378
+ <VerdictLine result={result} showHeaders={showHeaders} onToggleHeaders={() => setShowHeaders((v) => !v)} />
379
+
380
+ {result.error && (
381
+ <div className="text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-2 py-1.5">
382
+ {result.error}
383
+ </div>
384
+ )}
385
+
386
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: showHeaders ? '1fr' : '0fr' }}>
387
+ <div className="overflow-hidden">
388
+ <pre className="text-xs bg-theme-base rounded p-2 overflow-auto max-h-32 text-theme-text-secondary font-mono whitespace-pre">
389
+ {Object.entries(result.headers).map(([k, v]) => `${k}: ${v}`).join('\n') || '(no headers)'}
390
+ </pre>
391
+ </div>
392
+ </div>
393
+
394
+ {peek && (
395
+ <>
396
+ {result.body && (
397
+ <div className="flex items-center justify-between">
398
+ <span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{peek.label}</span>
399
+ <CopyButton text={peek.text} />
400
+ </div>
401
+ )}
402
+ {/* Short peek — a bounded teaser, not a scroll surface (the full body
403
+ has its own scrollable dialog). When the body overflows, a bottom
404
+ fade signals "more below"; when it fits, no fade and no "view full"
405
+ (there's nothing more to see). */}
406
+ <div className="relative">
407
+ <pre ref={peekRef} className="text-xs bg-theme-base rounded p-2 overflow-hidden max-h-24 text-theme-text-primary font-mono whitespace-pre-wrap break-words">
408
+ {peek.text || '(empty response body)'}
409
+ </pre>
410
+ {result.body && peekOverflows && (
411
+ <div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 rounded-b bg-gradient-to-t from-theme-base to-transparent" />
412
+ )}
413
+ </div>
414
+ {result.body && peekOverflows && (
415
+ <button
416
+ type="button"
417
+ onClick={() => setSheetOpen(true)}
418
+ className="flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300"
419
+ >
420
+ <Maximize2 className="w-3 h-3" />
421
+ View full response
422
+ </button>
423
+ )}
424
+ </>
425
+ )}
426
+ </div>
427
+ )}
428
+ </div>
429
+ </div>
430
+
431
+ {sheetOpen && result && (
432
+ <CurlResponseDialog
433
+ serviceName={serviceName}
434
+ port={port}
435
+ scheme={sent.scheme}
436
+ path={sent.path}
437
+ result={result}
438
+ onClose={() => setSheetOpen(false)}
439
+ />
440
+ )}
441
+ </div>
442
+ </div>
443
+ </div>
444
+ )
445
+ }
@@ -446,15 +446,6 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
446
446
  const isFlux = tool === 'flux'
447
447
  const isArgoApp = kind === 'applications'
448
448
 
449
- // Set the browser tab title so users with multiple resource tabs open can
450
- // tell which is which without focusing each tab. Restore on unmount so a
451
- // stray "Radar — argocd/foo" doesn't outlive its page.
452
- useEffect(() => {
453
- const previous = document.title
454
- document.title = `${name} — Radar`
455
- return () => { document.title = previous }
456
- }, [name])
457
-
458
449
  // Detail-page shortcuts. Skip when a modal is already open so a stray "s"
459
450
  // in an input field doesn't pop another sync dialog.
460
451
  const shortcutsEnabled = !syncDialogOpen && !rollbackTarget
@@ -645,7 +636,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
645
636
  search: params.toString(),
646
637
  })
647
638
  } : undefined}
648
- manageDocumentTitle={false /* OSS handles it via the in-effect-above */}
639
+ manageDocumentTitle={false /* title handled centrally in App's radarPageTitle */}
649
640
  renderTabBarCounts={({ tab }) => (
650
641
  tab === 'topology' && tree ? <TopologyCounts tree={tree} /> : null
651
642
  )}