@skyhook-io/radar-app 0.2.2 → 0.3.1

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 (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -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
+ import { Input } from '@skyhook-io/k8s-ui'
10
+
11
+ // A port is "curl-able" only if it plausibly speaks HTTP — probing a raw TCP
12
+ // port (Postgres, Redis) with a GET returns noise, so we don't offer it there
13
+ // (that's the local-client TCP path's job). Heuristic over name/appProtocol/number.
14
+ const HTTP_PORT_NUMBERS = new Set([80, 443, 8080, 8443, 8000, 8081, 3000, 5000, 9090, 9091, 9093, 9100, 15000, 15090])
15
+ const HTTP_NAME_RE = /(^|[-_])(http|https|web|ui|console|dashboard|metrics|api|admin)([-_]|$)/i
16
+
17
+ // Common metrics port numbers — used to decide which quick-path chips make sense.
18
+ const METRICS_PORT_NUMBERS = new Set([9090, 9091, 9093, 9100, 9153, 2112, 8888])
19
+
20
+ function isMetricsPort(port: number, name?: string, appProtocol?: string): boolean {
21
+ if ((appProtocol || '').toLowerCase().includes('metric')) return true
22
+ if (name && /metric/i.test(name)) return true
23
+ return METRICS_PORT_NUMBERS.has(port)
24
+ }
25
+
26
+ // Default request path for a port. Only metrics ports get a non-root default —
27
+ // /metrics is a near-deterministic convention there. We deliberately don't
28
+ // pre-fill or suggest health paths (/healthz etc.): those are genuine guesses
29
+ // (apps vary: /health, /actuator/health, /-/healthy …) and a suggestion that
30
+ // 404s reads as the tool being wrong. The honest one-click-health feature is to
31
+ // derive paths from the backing pod's liveness/readiness probes — a follow-up.
32
+ export function defaultPathForPort(port: number, name?: string, appProtocol?: string): string {
33
+ return isMetricsPort(port, name, appProtocol) ? '/metrics' : '/'
34
+ }
35
+
36
+ export function isHttpishPort(port: number, name?: string, appProtocol?: string, protocol?: string): boolean {
37
+ // HTTP rides TCP — a UDP port is never a GET target (e.g. statsd "metrics-udp").
38
+ if ((protocol || '').toUpperCase() === 'UDP') return false
39
+ const proto = (appProtocol || '').toLowerCase()
40
+ if (proto === 'http' || proto === 'https' || proto === 'http2') return true
41
+ if (proto && proto !== 'tcp') {
42
+ // explicit non-HTTP appProtocol (grpc, redis, postgres, …) → not a GET target
43
+ return false
44
+ }
45
+ if (name && HTTP_NAME_RE.test(name)) return true
46
+ return HTTP_PORT_NUMBERS.has(port)
47
+ }
48
+
49
+ export function defaultScheme(port: number, name?: string, appProtocol?: string): 'http' | 'https' {
50
+ if ((appProtocol || '').toLowerCase() === 'https') return 'https'
51
+ if (port === 443 || port === 8443) return 'https'
52
+ if (name && /https/i.test(name)) return 'https'
53
+ return 'http'
54
+ }
55
+
56
+ interface CurlResult {
57
+ status: number
58
+ statusText: string
59
+ durationMs: number
60
+ headers: Record<string, string>
61
+ body: string
62
+ truncated: boolean
63
+ bodyBytes: number
64
+ error?: string
65
+ }
66
+
67
+ function statusTextTone(status: number): string {
68
+ if (status >= 200 && status < 300) return 'text-emerald-400'
69
+ if (status >= 300 && status < 400) return 'text-blue-400'
70
+ if (status >= 400 && status < 500) return 'text-amber-400'
71
+ return 'text-red-400'
72
+ }
73
+
74
+ function statusDotTone(status: number): string {
75
+ if (status >= 200 && status < 300) return 'bg-emerald-400'
76
+ if (status >= 300 && status < 400) return 'bg-blue-400'
77
+ if (status >= 400 && status < 500) return 'bg-amber-400'
78
+ return 'bg-red-400'
79
+ }
80
+
81
+ // Make the body readable per content type: pretty-print JSON, label everything
82
+ // else (HTML / Prometheus / XML / …) so the operator knows what they're looking at.
83
+ function formatBody(result: CurlResult): { text: string; label: string } {
84
+ const body = result.body
85
+ const ct = (result.headers['Content-Type'] || result.headers['content-type'] || '').toLowerCase()
86
+ const looksJson = ct.includes('json') || /^\s*[[{]/.test(body)
87
+ if (looksJson) {
88
+ let text = body
89
+ if (!result.truncated) {
90
+ try { text = JSON.stringify(JSON.parse(body), null, 2) } catch { /* leave raw */ }
91
+ }
92
+ return { text, label: 'JSON' }
93
+ }
94
+ if (ct.includes('html')) return { text: body, label: 'HTML' }
95
+ if (body.startsWith('# HELP') || body.startsWith('# TYPE') || ct.includes('openmetrics')) {
96
+ return { text: body, label: 'Prometheus' }
97
+ }
98
+ if (ct.includes('xml')) return { text: body, label: 'XML' }
99
+ const short = ct ? (ct.split(';')[0].split('/').pop() || 'text') : 'text'
100
+ return { text: body, label: short }
101
+ }
102
+
103
+ function CopyButton({ text, className }: { text: string; className?: string }) {
104
+ const [copied, setCopied] = useState(false)
105
+ return (
106
+ <button
107
+ type="button"
108
+ onClick={(e) => {
109
+ e.stopPropagation()
110
+ navigator.clipboard?.writeText(text).then(() => {
111
+ setCopied(true)
112
+ setTimeout(() => setCopied(false), 1500)
113
+ }).catch(() => {})
114
+ }}
115
+ className={clsx('inline-flex items-center gap-1 text-xs text-theme-text-secondary hover:text-theme-text-primary', className)}
116
+ >
117
+ {copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
118
+ {copied ? 'Copied' : 'Copy'}
119
+ </button>
120
+ )
121
+ }
122
+
123
+ // Small toggle button rendered in a port row's action slot. The panel itself
124
+ // renders inline within the port card (see CurlPanel), not as an overlay.
125
+ export function CurlButton({ active, onClick }: { active: boolean; onClick: () => void }) {
126
+ return (
127
+ <Tooltip content="Curl this endpoint — GET from inside the cluster">
128
+ <button
129
+ onClick={(e) => { e.stopPropagation(); onClick() }}
130
+ aria-expanded={active}
131
+ className={clsx(
132
+ 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs transition-colors',
133
+ active ? 'bg-accent-muted text-blue-400' : 'bg-theme-elevated hover:bg-accent-muted',
134
+ )}
135
+ >
136
+ Curl
137
+ {/* Disclosure caret: signals this expands an inline panel rather than firing a request. */}
138
+ <ChevronDown className={clsx('w-3 h-3 transition-transform', active && 'rotate-180')} />
139
+ </button>
140
+ </Tooltip>
141
+ )
142
+ }
143
+
144
+ function VerdictLine({
145
+ result,
146
+ showHeaders,
147
+ onToggleHeaders,
148
+ }: {
149
+ result: CurlResult
150
+ showHeaders: boolean
151
+ onToggleHeaders: () => void
152
+ }) {
153
+ return (
154
+ <div className="flex items-center gap-3 text-xs">
155
+ <span className={clsx('flex items-center gap-1.5 font-mono font-semibold', statusTextTone(result.status))}>
156
+ <span className={clsx('w-1.5 h-1.5 rounded-full', statusDotTone(result.status))} />
157
+ {result.status}{result.statusText ? ` ${result.statusText}` : ''}
158
+ </span>
159
+ <span className="text-theme-text-tertiary">{result.durationMs} ms</span>
160
+ <span className="text-theme-text-tertiary">{result.bodyBytes.toLocaleString()} bytes{result.truncated ? ' (truncated)' : ''}</span>
161
+ <button
162
+ type="button"
163
+ onClick={onToggleHeaders}
164
+ className="ml-auto flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary"
165
+ >
166
+ Headers <ChevronDown className={clsx('w-3 h-3 transition-transform', showHeaders && 'rotate-180')} />
167
+ </button>
168
+ </div>
169
+ )
170
+ }
171
+
172
+ // Roomy response viewer. The narrow drawer can't show a 27 KB /metrics body
173
+ // readably (wide lines wrap into mush), so the full body opens in a centered
174
+ // dialog — wide, tall, monospace, no-wrap with its own scroll (decoupled from the
175
+ // drawer, so there's no nested-scroll). Triggered on demand; the request + verdict
176
+ // stay inline in the port card. Matches the kubectl copy-command dialog pattern.
177
+ function CurlResponseDialog({
178
+ serviceName,
179
+ port,
180
+ scheme,
181
+ path,
182
+ result,
183
+ onClose,
184
+ }: {
185
+ serviceName: string
186
+ port: number
187
+ scheme: string
188
+ path: string
189
+ result: CurlResult
190
+ onClose: () => void
191
+ }) {
192
+ const [showHeaders, setShowHeaders] = useState(false)
193
+ const { text, label } = formatBody(result)
194
+ useEffect(() => {
195
+ // Capture + stopPropagation so Escape closes only this dialog, not the drawer
196
+ // behind it (its Escape shortcut listens in the bubble phase).
197
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onClose() } }
198
+ document.addEventListener('keydown', onKey, true)
199
+ return () => document.removeEventListener('keydown', onKey, true)
200
+ }, [onClose])
201
+ // Portal to <body>: the drawer is a transformed ancestor, which would otherwise
202
+ // trap this position:fixed dialog inside the drawer instead of centering it on
203
+ // the viewport.
204
+ return createPortal(
205
+ <div className="fixed inset-0 z-50 flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
206
+ <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
207
+ <div className="relative dialog w-full max-w-4xl mx-4 max-h-[85vh] flex flex-col outline-none">
208
+ <div className="flex items-center justify-between gap-3 p-4 border-b border-theme-border">
209
+ <div className="min-w-0">
210
+ <div className="flex items-center gap-2">
211
+ <Activity className="w-4 h-4 text-blue-400 shrink-0" />
212
+ <h3 className="text-sm font-semibold text-theme-text-primary truncate">Response</h3>
213
+ </div>
214
+ <div className="text-xs text-theme-text-tertiary font-mono mt-0.5 truncate">
215
+ GET {scheme}://{serviceName}:{port}{path}
216
+ </div>
217
+ </div>
218
+ <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">
219
+ <X className="w-5 h-5" />
220
+ </button>
221
+ </div>
222
+
223
+ <div className="px-4 py-2 border-b border-theme-border">
224
+ <VerdictLine result={result} showHeaders={showHeaders} onToggleHeaders={() => setShowHeaders((v) => !v)} />
225
+ </div>
226
+
227
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out mx-4" style={{ gridTemplateRows: showHeaders ? '1fr' : '0fr' }}>
228
+ <div className="overflow-hidden">
229
+ <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">
230
+ {Object.entries(result.headers).map(([k, v]) => `${k}: ${v}`).join('\n') || '(no headers)'}
231
+ </pre>
232
+ </div>
233
+ </div>
234
+
235
+ {result.error ? (
236
+ <div className="m-4 text-sm text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2">
237
+ {result.error}
238
+ </div>
239
+ ) : (
240
+ <div className="flex flex-col min-h-0 flex-1 m-4">
241
+ <div className="flex items-center justify-between mb-1.5">
242
+ <span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{label}</span>
243
+ {result.body && <CopyButton text={text} />}
244
+ </div>
245
+ <pre className="flex-1 text-xs bg-theme-base rounded p-3 overflow-auto text-theme-text-primary font-mono whitespace-pre">
246
+ {text || '(empty response body)'}
247
+ </pre>
248
+ </div>
249
+ )}
250
+ </div>
251
+ </div>,
252
+ document.body,
253
+ )
254
+ }
255
+
256
+ // Inline curl: request form + verdict + a short body peek, rendered in the
257
+ // drawer flow (inside the port card). The full body opens in CurlResponseDialog
258
+ // so a large response never bloats the drawer.
259
+ export function CurlPanel({
260
+ namespace,
261
+ serviceName,
262
+ port,
263
+ initialScheme,
264
+ initialPath,
265
+ open,
266
+ onClose,
267
+ }: {
268
+ namespace: string
269
+ serviceName: string
270
+ port: number
271
+ initialScheme: 'http' | 'https'
272
+ initialPath: string
273
+ // Host-controlled: false triggers the collapse animation before the host unmounts.
274
+ open: boolean
275
+ onClose: () => void
276
+ }) {
277
+ const [scheme, setScheme] = useState<'http' | 'https'>(initialScheme)
278
+ // Stored WITHOUT the leading slash — the "/" is a fixed, non-deletable prefix
279
+ // glued to the input. Typed/pasted leading slashes are swallowed on change.
280
+ const [path, setPath] = useState(() => initialPath.replace(/^\/+/, ''))
281
+ const fullPath = '/' + path
282
+ const [showHeaders, setShowHeaders] = useState(false)
283
+ const [sheetOpen, setSheetOpen] = useState(false)
284
+ // What was actually sent — so the sheet header / re-renders reflect the response.
285
+ const [sent, setSent] = useState<{ scheme: 'http' | 'https'; path: string }>({ scheme: initialScheme, path: initialPath })
286
+ // Enter animation: mount collapsed, then expand next tick (radar's grid 0fr↔1fr).
287
+ // Combined with the host-controlled `open` prop this gives a symmetric reveal:
288
+ // expand on mount, collapse when the host sets open=false (before it unmounts).
289
+ const [mounted, setMounted] = useState(false)
290
+ useEffect(() => { setMounted(true) }, [])
291
+
292
+ const curl = useMutation<CurlResult, Error, { scheme: 'http' | 'https'; path: string }>({
293
+ mutationFn: async (vars) => {
294
+ setSent(vars)
295
+ const res = await apiFetch(apiUrl('/curl/service'), {
296
+ method: 'POST',
297
+ headers: { 'Content-Type': 'application/json' },
298
+ body: JSON.stringify({ namespace, name: serviceName, port: String(port), scheme: vars.scheme, path: vars.path }),
299
+ })
300
+ const data = await res.json().catch(() => ({}))
301
+ if (!res.ok) throw new Error(data?.error || `Request failed (${res.status})`)
302
+ return data as CurlResult
303
+ },
304
+ })
305
+
306
+ const result = curl.data
307
+ const peek = result && !result.error ? formatBody(result) : null
308
+
309
+ // Only fade + offer "View full response" when the body actually overflows the
310
+ // peek box — a small body that fits has nothing more to show, and a fade over
311
+ // it reads as a rendering glitch.
312
+ const peekRef = useRef<HTMLPreElement>(null)
313
+ const [peekOverflows, setPeekOverflows] = useState(false)
314
+ useLayoutEffect(() => {
315
+ const el = peekRef.current
316
+ setPeekOverflows(!!el && el.scrollHeight > el.clientHeight + 1)
317
+ }, [peek?.text, open])
318
+
319
+ return (
320
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: mounted && open ? '1fr' : '0fr' }}>
321
+ <div className="overflow-hidden">
322
+ <div className="mt-3 pt-3 border-t border-theme-border space-y-2" onClick={(e) => e.stopPropagation()}>
323
+ <div className="flex items-center justify-between">
324
+ <span className="flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary">
325
+ <Activity className="w-3.5 h-3.5 text-blue-400" />
326
+ Curl — GET from inside the cluster
327
+ </span>
328
+ <button
329
+ onClick={onClose}
330
+ aria-label="Close"
331
+ className="p-0.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
332
+ >
333
+ <X className="w-3.5 h-3.5" />
334
+ </button>
335
+ </div>
336
+
337
+ <form className="flex items-stretch gap-2" onSubmit={(e) => { e.preventDefault(); curl.mutate({ scheme, path: fullPath }) }}>
338
+ <select
339
+ value={scheme}
340
+ onChange={(e) => setScheme(e.target.value as 'http' | 'https')}
341
+ className="bg-theme-base border border-theme-border rounded px-2 py-1 text-xs text-theme-text-primary font-mono"
342
+ aria-label="Scheme"
343
+ >
344
+ <option value="http">http</option>
345
+ <option value="https">https</option>
346
+ </select>
347
+ <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">
348
+ <span className="text-xs text-theme-text-tertiary font-mono select-none pointer-events-none">/</span>
349
+ <Input
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
+ }
@@ -0,0 +1,147 @@
1
+ // The agent/model/effort controls + a clear-history action for the Settings
2
+ // "AI diagnose" tab. Controlled by the dialog: it edits a STAGED draft committed
3
+ // on Save (like the rest of Settings), not on every keystroke. The heading,
4
+ // description, and Save button live in the dialog so this tab matches the other
5
+ // Settings tabs' layout — this renders only the controls (no card, no heading).
6
+ import { useState } from "react";
7
+ import { Trash2 } from "lucide-react";
8
+ import { clearHistory, type AgentInfo } from "../../api/diagnose";
9
+ import { AgentControls } from "./parts";
10
+
11
+ export interface AIDraft {
12
+ agent: string;
13
+ isolated: boolean;
14
+ model: string;
15
+ effort: string;
16
+ }
17
+
18
+ // ClearHistoryRow is an immediate action (not part of the staged draft): a
19
+ // two-step confirm button that wipes finished investigations from the local
20
+ // history DB. Live investigations survive.
21
+ function ClearHistoryRow({
22
+ hosted,
23
+ agentLabel,
24
+ onCleared,
25
+ }: {
26
+ hosted: boolean;
27
+ agentLabel: string;
28
+ onCleared: () => void;
29
+ }) {
30
+ const [confirming, setConfirming] = useState(false);
31
+ const [state, setState] = useState<"idle" | "busy" | "done" | "error">(
32
+ "idle",
33
+ );
34
+ const run = () => {
35
+ setState("busy");
36
+ clearHistory()
37
+ .then(() => {
38
+ setState("done");
39
+ setConfirming(false);
40
+ onCleared();
41
+ })
42
+ .catch(() => setState("error"));
43
+ };
44
+ return (
45
+ <div className="mt-3 flex items-center justify-between gap-2 border-t border-theme-border/60 pt-3">
46
+ <p className="text-[11px] leading-snug text-theme-text-tertiary">
47
+ {hosted ? (
48
+ `Investigation transcripts are stored by ${agentLabel} so history survives restarts.`
49
+ ) : (
50
+ <>
51
+ Investigation transcripts are kept on this machine (
52
+ <code className="font-mono">~/.radar</code>) so history survives
53
+ restarts.
54
+ </>
55
+ )}
56
+ {state === "done" && (
57
+ <span className="ml-1 font-medium text-theme-text-secondary">
58
+ History cleared.
59
+ </span>
60
+ )}
61
+ {state === "error" && (
62
+ <span className="ml-1 font-medium text-red-400">
63
+ Couldn&apos;t clear history.
64
+ </span>
65
+ )}
66
+ </p>
67
+ {confirming ? (
68
+ <div className="flex shrink-0 items-center gap-1.5">
69
+ <button
70
+ onClick={() => setConfirming(false)}
71
+ className="rounded-md border border-theme-border px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover"
72
+ >
73
+ Cancel
74
+ </button>
75
+ <button
76
+ onClick={run}
77
+ disabled={state === "busy"}
78
+ className="rounded-md border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs font-medium text-red-400 hover:bg-red-500/20 disabled:opacity-50"
79
+ >
80
+ Clear all history
81
+ </button>
82
+ </div>
83
+ ) : (
84
+ <button
85
+ onClick={() => {
86
+ setState("idle");
87
+ setConfirming(true);
88
+ }}
89
+ className="flex shrink-0 items-center gap-1 rounded-md border border-theme-border px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
90
+ >
91
+ <Trash2 className="h-3 w-3" />
92
+ Clear history…
93
+ </button>
94
+ )}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function AISettingsSection({
100
+ available,
101
+ agents,
102
+ hosted,
103
+ agentLabel,
104
+ draft,
105
+ onChange,
106
+ onHistoryCleared,
107
+ }: {
108
+ available: boolean;
109
+ agents: AgentInfo[];
110
+ hosted: boolean;
111
+ agentLabel: string;
112
+ draft: AIDraft;
113
+ onChange: (patch: Partial<AIDraft>) => void;
114
+ onHistoryCleared: () => void;
115
+ }) {
116
+ if (!available || agents.length === 0) return null;
117
+ return (
118
+ <>
119
+ {hosted ? (
120
+ // The agent, its model, and how it runs are all fixed by the host — none
121
+ // of the local BYO-agent knobs apply, so there's nothing to configure.
122
+ <p className="text-xs leading-snug text-theme-text-tertiary">
123
+ {agentLabel} manages the model and how it runs — there&apos;s nothing to
124
+ configure here.
125
+ </p>
126
+ ) : (
127
+ <AgentControls
128
+ agents={agents}
129
+ selectedAgent={draft.agent}
130
+ // Model + effort are agent-specific; reset them when the agent changes.
131
+ onSelectAgent={(a) => onChange({ agent: a, model: "", effort: "" })}
132
+ isolated={draft.isolated}
133
+ onSetIsolated={(v) => onChange({ isolated: v })}
134
+ model={draft.model}
135
+ onSetModel={(v) => onChange({ model: v })}
136
+ effort={draft.effort}
137
+ onSetEffort={(v) => onChange({ effort: v })}
138
+ />
139
+ )}
140
+ <ClearHistoryRow
141
+ hosted={hosted}
142
+ agentLabel={agentLabel}
143
+ onCleared={onHistoryCleared}
144
+ />
145
+ </>
146
+ );
147
+ }