@skyhook-io/radar-app 1.14.0 → 1.14.2

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 (63) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +6 -4
  3. package/src/api/client.ts +3 -0
  4. package/src/api/diagnose.ts +31 -1
  5. package/src/components/CloudConnectFlow.tsx +173 -13
  6. package/src/components/CloudFunnelButton.tsx +4 -2
  7. package/src/components/ConnectionErrorView.tsx +9 -10
  8. package/src/components/DebugOverlay.tsx +10 -6
  9. package/src/components/cloudConnectHandoff.test.ts +109 -4
  10. package/src/components/cloudConnectHandoff.ts +154 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +19 -26
  12. package/src/components/diagnose/AgentCase.tsx +18 -5
  13. package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
  14. package/src/components/diagnose/AnalysisStory.tsx +564 -0
  15. package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
  16. package/src/components/diagnose/DiagnoseContext.tsx +25 -8
  17. package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
  18. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
  19. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
  20. package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
  21. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
  22. package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
  23. package/src/components/diagnose/InvestigationView.tsx +493 -529
  24. package/src/components/diagnose/investigationCase.test.tsx +267 -129
  25. package/src/components/diagnose/investigationCase.ts +122 -77
  26. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
  27. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
  28. package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
  29. package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
  30. package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
  31. package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
  32. package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
  33. package/src/components/diagnose/investigationEvidence/types.ts +2 -0
  34. package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
  35. package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
  36. package/src/components/diagnose/investigationState.test.ts +316 -58
  37. package/src/components/diagnose/investigationState.ts +257 -44
  38. package/src/components/diagnose/investigationStory.test.ts +161 -0
  39. package/src/components/diagnose/investigationStory.ts +207 -0
  40. package/src/components/diagnose/parts.test.tsx +9 -6
  41. package/src/components/diagnose/parts.tsx +977 -193
  42. package/src/components/diagnose/storyParts.test.tsx +426 -0
  43. package/src/components/diagnose/toolCallLabel.test.ts +51 -0
  44. package/src/components/diagnose/toolCallLabel.ts +135 -0
  45. package/src/components/diagnose/useDisclosureReveal.ts +10 -11
  46. package/src/components/helm/HelmCompareRoute.tsx +18 -4
  47. package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
  48. package/src/components/helm/InstallWizard.tsx +8 -3
  49. package/src/components/home/MCPSetupDialog.tsx +15 -9
  50. package/src/components/portforward/PortForwardManager.tsx +6 -13
  51. package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
  52. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
  53. package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
  54. package/src/components/resources/PodFilePreview.tsx +3 -3
  55. package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
  56. package/src/components/settings/SettingsDialog.tsx +4 -2
  57. package/src/components/ui/CommandPalette.tsx +10 -6
  58. package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
  59. package/src/components/ui/Disclosure.tsx +47 -0
  60. package/src/components/ui/Markdown.tsx +23 -11
  61. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
  62. package/src/components/ui/UpdateNotification.tsx +18 -2
  63. package/src/index.css +19 -6
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { ApiError, type CloudInstallAttempted } from '../api/client'
3
- import { exitFor, handoffForBlocked, handoffForPrepareError, isHandoffOutcome, signupUrlFor } from './cloudConnectHandoff'
3
+ import { composeAdminNote, composeFailureNote, exitFor, needsAdminHandoff, handoffForBlocked, handoffForPrepareError, isHandoffOutcome, signupUrlFor } from './cloudConnectHandoff'
4
4
 
5
5
  const APP = 'https://app.test.example'
6
6
  const UTM = 'utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
@@ -35,13 +35,15 @@ describe('signupUrlFor', () => {
35
35
  })
36
36
 
37
37
  describe('exitFor — every blocked card has one exit, chosen by what Radar established', () => {
38
- it('adopts a release Radar found, pinned to the Helm tab', () => {
38
+ it('adopts a release Radar found, pinned to the Helm tab, named after the context', () => {
39
39
  const h = handoffForBlocked('preflight', attempted({ mode: 'adopt', namespace: 'monitoring', release: 'radar-prod', stage: 'inspect' }))
40
- const exit = exitFor(APP, CARD, h)
40
+ const exit = exitFor(APP, CARD, h, 'prod-east')
41
41
  expect(exit).toMatchObject({ install: true, label: 'Get the install command' })
42
42
  expect(exit.href).toBe(
43
- `${APP}/install?existing=1&ns=monitoring&release=radar-prod&method=helm&${UTM}&utm_content=${CARD}&radar_outcome=blocked_preflight_checks_failed`,
43
+ `${APP}/install?name=prod-east&existing=1&ns=monitoring&release=radar-prod&method=helm&${UTM}&utm_content=${CARD}&radar_outcome=blocked_preflight_checks_failed`,
44
44
  )
45
+ // Without a context there is simply no name to suggest.
46
+ expect(exitFor(APP, CARD, h).href).not.toContain('name=')
45
47
  })
46
48
 
47
49
  it('offers a fresh install only from a complete plan whose discovery saw the whole cluster', () => {
@@ -138,3 +140,106 @@ describe('isHandoffOutcome', () => {
138
140
  }
139
141
  })
140
142
  })
143
+
144
+ describe('composeAdminNote — an ask, then the link, then the evidence', () => {
145
+ const where = { context: 'prod-east', cluster: 'arn:aws:eks:us-east-1:1:cluster/prod-east' }
146
+
147
+ it('asks for a permissions block, naming the identity and trimming the refusal to what and why', () => {
148
+ const blocked = {
149
+ reason: 'preflight' as const,
150
+ cause: 'permissions' as const,
151
+ identity: 'system:serviceaccount:default:limited',
152
+ message: '',
153
+ attempted: attempted({ stage: 'inspect', releaseUnread: true }),
154
+ blocking: [
155
+ 'inspect Helm release "radar" in namespace "radar": failed to inspect existing release: query: failed to query with labels: secrets is forbidden: User "system:serviceaccount:default:limited" cannot list resource "secrets" in API group "" in the namespace "radar"',
156
+ ],
157
+ }
158
+ const exit = exitFor(APP, CARD, handoffForBlocked('preflight', blocked.attempted), where.context)
159
+ const note = composeAdminNote(blocked, exit, where)
160
+ expect(note).toMatch(/^Request to connect cluster prod-east to Radar Cloud\n/)
161
+ expect(note).toContain("the identity in use (system:serviceaccount:default:limited) doesn't have the permissions to install it")
162
+ expect(note).toContain('Nothing in the cluster was changed. Could someone with cluster access connect it?')
163
+ // The link a person reads carries only what the page needs — no attribution.
164
+ // The link a person reads keeps what the page needs to land prefilled,
165
+ // plus one marker that the arrival came by handoff — no utm noise.
166
+ expect(note).toContain('Open Radar Cloud to get the install command (sign in, confirm the cluster name, pick Helm / Argo CD / Flux):\n' + `${APP}/install?name=prod-east&method=helm&via=admin_handoff`)
167
+ expect(note).not.toContain('utm_')
168
+ expect(note).not.toContain('radar_outcome')
169
+ expect(note).toContain("Details: No Radar install was found in the cluster; the check stopped while reading Helm's release records.")
170
+ expect(note).toContain('inspect Helm release "radar" in namespace "radar" — User "system:serviceaccount:default:limited" cannot list resource "secrets" in API group "" in the namespace "radar".')
171
+ expect(note).toContain('Please confirm nothing is already installed before a fresh install.')
172
+ // Never the card's second person.
173
+ expect(note).not.toMatch(/\byour\b/i)
174
+ })
175
+
176
+ it('asks for a GitOps values patch from the verified tool', () => {
177
+ const blocked = { reason: 'gitops' as const, message: 'managed by Flux', attempted: attempted({ mode: 'gitops', stage: 'inspect', method: 'flux' }) }
178
+ const exit = exitFor(APP, CARD, handoffForBlocked('gitops', blocked.attempted))
179
+ const note = composeAdminNote(blocked, exit, where)
180
+ expect(note).toContain('the install is managed by Flux, so connecting it is a values change in the repository')
181
+ expect(note).toContain('Open Radar Cloud to get the values patch for Flux (sign in, confirm the cluster name')
182
+ expect(note).toContain(`${APP}/install?existing=1&ns=radar&release=radar&method=flux&via=admin_handoff`)
183
+ expect(note).toContain('Details: The release radar in namespace radar is managed by Flux.')
184
+ })
185
+
186
+ it('quotes the refusal and sends the admin to Radar Cloud when Radar had no target', () => {
187
+ const blocked = { reason: 'unsupported' as const, message: 'Multiple Radar installations were found in this cluster. Use `radar cloud install` to pick one.' }
188
+ const exit = exitFor(APP, CARD, handoffForBlocked('unsupported'))
189
+ const note = composeAdminNote(blocked, exit, { cluster: 'kind-dev' })
190
+ expect(note).toContain('Request to connect cluster kind-dev to Radar Cloud')
191
+ expect(note).toContain('Radar reported: Multiple Radar installations were found in this cluster. Use `radar cloud install` to pick one. Nothing in the cluster was changed.')
192
+ expect(note).not.toContain('..')
193
+ expect(note).toContain('Could someone with cluster access connect it from Radar Cloud?')
194
+ expect(note).toContain('Open Radar Cloud:\n' + `${APP}/signup?via=admin_handoff`)
195
+ })
196
+
197
+ it('keeps the whole line when a refusal has no error chain to trim', () => {
198
+ const blocked = {
199
+ reason: 'preflight' as const,
200
+ cause: 'cluster' as const,
201
+ message: '',
202
+ attempted: attempted({}),
203
+ blocking: ['create Deployment "radar" in namespace "radar": an object already exists but is not owned by the current Helm release'],
204
+ }
205
+ const note = composeAdminNote(blocked, exitFor(APP, CARD, handoffForBlocked('preflight', blocked.attempted)))
206
+ expect(note).toContain('the cluster refused part of the install — a policy, or something already there (details below)')
207
+ expect(note).toContain('Details: No Radar install was found in the cluster; the dry run of the install stopped. create Deployment "radar" in namespace "radar": an object already exists but is not owned by the current Helm release.')
208
+ })
209
+ })
210
+
211
+ describe('composeFailureNote — finishing a connection the Hub already approved', () => {
212
+ const guidance = {
213
+ summary: 'Hub cluster "abc123" already exists. Do not rerun the installer; first inspect the existing attempt.',
214
+ lines: ['The Helm upgrade did not start, so there was no rollback to verify.'],
215
+ inspect: ['helm status radar -n radar', 'kubectl -n radar get secret/radar-cloud-config'],
216
+ clusterUrl: 'https://app.radarhq.io/c/abc123?utm_source=x',
217
+ }
218
+
219
+ it('is offered only after the Hub approved and Helm ran', () => {
220
+ expect(needsAdminHandoff({ kind: 'helm_provision_failed', message: 'x', retrySafe: false })).toBe(true)
221
+ expect(needsAdminHandoff({ kind: 'installed_but_tunnel_not_confirmed', message: 'x', retrySafe: false })).toBe(true)
222
+ for (const kind of ['hub_connect_request_failed', 'approval_rejected_in_browser', 'approved_but_credential_pickup_expired', 'canceled_after_approval']) {
223
+ expect(needsAdminHandoff({ kind, message: 'x', retrySafe: false })).toBe(false)
224
+ }
225
+ expect(needsAdminHandoff(undefined)).toBe(false)
226
+ })
227
+
228
+ it('asks to finish the install, links the cluster page, and carries the operator guidance', () => {
229
+ const note = composeFailureNote(
230
+ { kind: 'helm_provision_failed', message: 'helm install failed: timed out waiting for the condition', retrySafe: false, guidance },
231
+ { context: 'prod-east' },
232
+ )
233
+ expect(note).toMatch(/^Request to finish connecting cluster prod-east to Radar Cloud\n/)
234
+ expect(note).toContain('the Helm install of Radar failed. helm install failed: timed out waiting for the condition Could someone with cluster access take it from here?')
235
+ expect(note).toContain('The cluster in Radar Cloud (its page shows the recovery options):\nhttps://app.radarhq.io/c/abc123?via=admin_handoff')
236
+ expect(note).toContain('Details: Hub cluster "abc123" already exists. Do not rerun the installer; first inspect the existing attempt. The Helm upgrade did not start, so there was no rollback to verify.')
237
+ expect(note).toContain('To inspect:\n helm status radar -n radar\n kubectl -n radar get secret/radar-cloud-config')
238
+ })
239
+
240
+ it('names the tunnel case for what it is', () => {
241
+ const note = composeFailureNote({ kind: 'installed_but_tunnel_not_confirmed', message: 'No tunnel within 5 minutes.', retrySafe: false }, {})
242
+ expect(note).toContain('Radar was installed by Helm, but its connection to Radar Cloud could not be confirmed. No tunnel within 5 minutes.')
243
+ expect(note).toContain('cluster this cluster to Radar Cloud')
244
+ })
245
+ })
@@ -1,4 +1,4 @@
1
- import { ApiError, type CloudInstallAttempted, type CloudInstallBlocked } from '../api/client'
1
+ import { ApiError, type CloudInstallAttempted, type CloudInstallBlocked, type CloudInstallFailure } from '../api/client'
2
2
 
3
3
  // The Cloud dialog's links into the Hub. utm_content names the link that was
4
4
  // clicked. After an in-app connect attempt that did not end connected, the
@@ -84,7 +84,10 @@ export interface BlockedExit {
84
84
  install: boolean
85
85
  }
86
86
 
87
- export function exitFor(appUrl: string, content: string, handoff: Handoff | null | undefined): BlockedExit {
87
+ // clusterName is the kubeconfig context, offered to the Hub as the cluster's
88
+ // name (`name=`) so the install page opens with its form already filled in;
89
+ // the person can still change it there.
90
+ export function exitFor(appUrl: string, content: string, handoff: Handoff | null | undefined, clusterName?: string): BlockedExit {
88
91
  const generic = { href: signupUrlFor(appUrl, content, handoff), label: 'Open Radar Cloud', install: false }
89
92
  const t = handoff?.target
90
93
  if (!t) return generic
@@ -93,6 +96,7 @@ export function exitFor(appUrl: string, content: string, handoff: Handoff | null
93
96
  // happens to know is not an invitation to install over it.
94
97
  if (handoff?.outcome === BLOCKED_OUTCOMES.unsupported) return generic
95
98
  const params = new URLSearchParams()
99
+ if (clusterName) params.set('name', clusterName)
96
100
  switch (t.mode) {
97
101
  case 'adopt':
98
102
  params.set('existing', '1')
@@ -122,3 +126,151 @@ function installHref(appUrl: string, content: string, handoff: Handoff | null |
122
126
  const url = `${appUrl}/install?${params.toString()}&${SIGNUP_QUERY.slice(1)}&utm_content=${content}`
123
127
  return handoff && isHandoffOutcome(handoff.outcome) ? `${url}&radar_outcome=${handoff.outcome}` : url
124
128
  }
129
+
130
+ // The note a blocked person hands to whoever administers the cluster. An
131
+ // ask, not a diagnostic: the request first, the link second, the evidence
132
+ // last — and written about the attempt rather than in the reader's or the
133
+ // sender's voice, so it can be pasted as-is or trimmed to a sentence.
134
+ export interface AdminNoteContext {
135
+ context?: string
136
+ cluster?: string
137
+ }
138
+
139
+ export function composeAdminNote(blocked: CloudInstallBlocked, exit: BlockedExit, where: AdminNoteContext = {}): string {
140
+ const a = blocked.attempted
141
+ const name = where.context || where.cluster || 'this cluster'
142
+ const identity = blocked.identity ? ` (${blocked.identity})` : ''
143
+ const tool = a?.method === 'argocd' ? 'Argo CD' : a?.method === 'flux' ? 'Flux' : 'a GitOps controller'
144
+
145
+ let because: string
146
+ switch (blocked.reason) {
147
+ case 'gitops':
148
+ because = `the install is managed by ${tool}, so connecting it is a values change in the repository — Radar Cloud generates the patch`
149
+ break
150
+ case 'unsupported':
151
+ // The server's message ends its own sentence; the clause adds the period.
152
+ because = `Radar reported: ${blocked.message.replace(/\s+/g, ' ').trim().replace(/\.$/, '')}`
153
+ break
154
+ default:
155
+ switch (blocked.cause) {
156
+ case 'permissions':
157
+ because = `the identity in use${identity} doesn't have the permissions to install it`
158
+ break
159
+ case 'verification':
160
+ because = 'the Radar version in use can\'t install this chart version from there'
161
+ break
162
+ default:
163
+ because = 'the cluster refused part of the install — a policy, or something already there (details below)'
164
+ }
165
+ }
166
+
167
+ const ask = exit.install
168
+ ? 'Could someone with cluster access connect it?'
169
+ : 'Could someone with cluster access connect it from Radar Cloud?'
170
+ const linkLabel = !exit.install
171
+ ? 'Open Radar Cloud:'
172
+ : blocked.reason === 'gitops'
173
+ ? `Open Radar Cloud to get the values patch for ${tool} (sign in, confirm the cluster name; it also shows the one command that creates the token Secret):`
174
+ : 'Open Radar Cloud to get the install command (sign in, confirm the cluster name, pick Helm / Argo CD / Flux):'
175
+
176
+ // Observations, not Radar narrating itself: what was found, where it stopped.
177
+ const details: string[] = []
178
+ if (a) {
179
+ const target = `release ${a.release} in namespace ${a.namespace}`
180
+ const stage =
181
+ a.stage === 'inspect'
182
+ ? "the check stopped while reading Helm's release records"
183
+ : a.stage === 'prepare'
184
+ ? 'the check stopped while preparing the chart'
185
+ : 'the dry run of the install stopped'
186
+ details.push(
187
+ a.mode === 'gitops'
188
+ ? `The ${target} is managed by ${tool}.`
189
+ : a.mode === 'adopt'
190
+ ? `An existing ${target} was found; ${stage}.`
191
+ : a.partialScan
192
+ ? `Only namespace ${a.namespace} could be checked for an existing install; ${stage}.`
193
+ : `No Radar install was found in the cluster; ${stage}.`,
194
+ )
195
+ } else if (blocked.reason === 'preflight') {
196
+ details.push('The check for an existing Radar install did not complete.')
197
+ }
198
+ for (const line of blocked.blocking ?? []) details.push(trimRefusal(line))
199
+ if (a?.releaseUnread) details.push('Please confirm nothing is already installed before a fresh install.')
200
+ if (a?.partialScan) details.push('Please check the rest of the cluster for an existing install first.')
201
+
202
+ return [
203
+ `Request to connect cluster ${name} to Radar Cloud`,
204
+ '',
205
+ `Connecting it from Radar was blocked: ${because}. Nothing in the cluster was changed. ${ask}`,
206
+ '',
207
+ linkLabel,
208
+ plainLink(exit.href),
209
+ ...(details.length ? ['', `Details: ${details.join(' ')}`] : []),
210
+ ].join('\n')
211
+ }
212
+
213
+ // The note is text a person reads and forwards, so its link carries only what
214
+ // the page needs to open in the right place (an existing release to adopt,
215
+ // the install method) plus one short marker saying it arrived by handoff;
216
+ // the card's own button keeps the full attribution params. The Hub stashes
217
+ // the target and method across sign-in, so the admin still lands on the
218
+ // prefilled install page.
219
+ export const HANDOFF_VIA = 'admin_handoff'
220
+
221
+ function plainLink(href: string): string {
222
+ try {
223
+ const url = new URL(href)
224
+ const keep = new URLSearchParams()
225
+ for (const key of ['name', 'existing', 'ns', 'release', 'method']) {
226
+ const v = url.searchParams.get(key)
227
+ if (v) keep.set(key, v)
228
+ }
229
+ keep.set('via', HANDOFF_VIA)
230
+ url.search = keep.toString()
231
+ return url.toString()
232
+ } catch {
233
+ return href
234
+ }
235
+ }
236
+
237
+ // A refusal line is a Go error chain — "create Namespace "radar": namespaces
238
+ // "radar" is forbidden: ValidatingAdmissionPolicy … denied request: <reason>".
239
+ // Keep what was attempted and why it was refused; drop the wrapping between.
240
+ function trimRefusal(line: string): string {
241
+ const parts = line.split(': ').map((p) => p.trim()).filter(Boolean)
242
+ if (parts.length <= 2) return line.trim().replace(/\.?$/, '.')
243
+ return `${parts[0]} — ${parts[parts.length - 1]}`.replace(/\.?$/, '.')
244
+ }
245
+
246
+ // Failures after the Hub approved — Helm ran, or ran and the tunnel never
247
+ // came up — are the ones whose guidance is written for an operator: inspect
248
+ // commands, "keep this Hub cluster, don't rerun". A person who cannot act
249
+ // on that hands it over the same way a blocked one does, as a request to
250
+ // finish the connection rather than start one.
251
+ export const HANDOFF_FAILURE_KINDS = new Set(['helm_provision_failed', 'installed_but_tunnel_not_confirmed'])
252
+
253
+ export function needsAdminHandoff(failure: CloudInstallFailure | undefined): boolean {
254
+ return !!failure && HANDOFF_FAILURE_KINDS.has(failure.kind)
255
+ }
256
+
257
+ export function composeFailureNote(failure: CloudInstallFailure, where: AdminNoteContext = {}): string {
258
+ const name = where.context || where.cluster || 'this cluster'
259
+ const g = failure.guidance
260
+ const stage =
261
+ failure.kind === 'installed_but_tunnel_not_confirmed'
262
+ ? 'Radar was installed by Helm, but its connection to Radar Cloud could not be confirmed'
263
+ : 'the Helm install of Radar failed'
264
+ const lines: string[] = [
265
+ `Request to finish connecting cluster ${name} to Radar Cloud`,
266
+ '',
267
+ `Connecting it from Radar got as far as the install: ${stage}. ${failure.message.replace(/\s+/g, ' ').trim()} Could someone with cluster access take it from here?`,
268
+ ]
269
+ if (g?.clusterUrl) lines.push('', 'The cluster in Radar Cloud (its page shows the recovery options):', plainLink(g.clusterUrl))
270
+ const details: string[] = []
271
+ if (g?.summary && g.summary !== failure.message) details.push(g.summary)
272
+ for (const l of g?.lines ?? []) details.push(l)
273
+ if (details.length) lines.push('', `Details: ${details.join(' ')}`)
274
+ if (g?.inspect?.length) lines.push('', 'To inspect:', ...g.inspect.map((c) => ` ${c}`))
275
+ return lines.join('\n')
276
+ }
@@ -1,12 +1,13 @@
1
1
  import { useState, useEffect, useLayoutEffect, useRef } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { useMutation } from '@tanstack/react-query'
4
- import { Activity, Loader2, X, ChevronDown, Maximize2, Copy, Check } from 'lucide-react'
4
+ import { Activity, Loader2, X, Maximize2, Copy, Check } from 'lucide-react'
5
5
  import { clsx } from 'clsx'
6
6
  import { apiFetch } from '../../api/client'
7
7
  import { apiUrl } from '../../api/config'
8
8
  import { Tooltip } from '../ui/Tooltip'
9
9
  import { Input } from '@skyhook-io/k8s-ui'
10
+ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
10
11
 
11
12
  // A port is "curl-able" only if it plausibly speaks HTTP — probing a raw TCP
12
13
  // port (Postgres, Redis) with a GET returns noise, so we don't offer it there
@@ -135,7 +136,7 @@ export function CurlButton({ active, onClick }: { active: boolean; onClick: () =
135
136
  >
136
137
  Curl
137
138
  {/* 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
+ <CollapseChevron open={active} className="w-3 h-3" />
139
140
  </button>
140
141
  </Tooltip>
141
142
  )
@@ -163,7 +164,7 @@ function VerdictLine({
163
164
  onClick={onToggleHeaders}
164
165
  className="ml-auto flex items-center gap-1 text-theme-text-secondary hover:text-theme-text-primary"
165
166
  >
166
- Headers <ChevronDown className={clsx('w-3 h-3 transition-transform', showHeaders && 'rotate-180')} />
167
+ Headers <CollapseChevron open={showHeaders} className="w-3 h-3" />
167
168
  </button>
168
169
  </div>
169
170
  )
@@ -224,13 +225,11 @@ function CurlResponseDialog({
224
225
  <VerdictLine result={result} showHeaders={showHeaders} onToggleHeaders={() => setShowHeaders((v) => !v)} />
225
226
  </div>
226
227
 
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>
228
+ <Collapse open={showHeaders} className="mx-4">
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
+ </Collapse>
234
233
 
235
234
  {result.error ? (
236
235
  <div className="m-4 text-sm text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2">
@@ -317,8 +316,7 @@ export function CurlPanel({
317
316
  }, [peek?.text, open])
318
317
 
319
318
  return (
320
- <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: mounted && open ? '1fr' : '0fr' }}>
321
- <div className="overflow-hidden">
319
+ <Collapse open={mounted && open}>
322
320
  <div className="mt-3 pt-3 border-t border-theme-border space-y-2" onClick={(e) => e.stopPropagation()}>
323
321
  <div className="flex items-center justify-between">
324
322
  <span className="flex items-center gap-1.5 text-xs font-medium text-theme-text-secondary">
@@ -370,9 +368,8 @@ export function CurlPanel({
370
368
  </div>
371
369
  )}
372
370
 
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">
371
+ {/* Reveal the response with the same disclosure motion as the panel itself. */}
372
+ <Collapse open={!!result}>
376
373
  {result && (
377
374
  <div className="space-y-2 pt-0.5">
378
375
  <VerdictLine result={result} showHeaders={showHeaders} onToggleHeaders={() => setShowHeaders((v) => !v)} />
@@ -383,13 +380,11 @@ export function CurlPanel({
383
380
  </div>
384
381
  )}
385
382
 
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>
383
+ <Collapse open={showHeaders}>
384
+ <pre className="text-xs bg-theme-base rounded p-2 overflow-auto max-h-32 text-theme-text-secondary font-mono whitespace-pre">
385
+ {Object.entries(result.headers).map(([k, v]) => `${k}: ${v}`).join('\n') || '(no headers)'}
386
+ </pre>
387
+ </Collapse>
393
388
 
394
389
  {peek && (
395
390
  <>
@@ -425,8 +420,7 @@ export function CurlPanel({
425
420
  )}
426
421
  </div>
427
422
  )}
428
- </div>
429
- </div>
423
+ </Collapse>
430
424
 
431
425
  {sheetOpen && result && (
432
426
  <CurlResponseDialog
@@ -439,7 +433,6 @@ export function CurlPanel({
439
433
  />
440
434
  )}
441
435
  </div>
442
- </div>
443
- </div>
436
+ </Collapse>
444
437
  )
445
438
  }
@@ -1,5 +1,4 @@
1
1
  import { clsx } from "clsx";
2
- import { Sparkles } from "lucide-react";
3
2
  import { Badge } from "@skyhook-io/k8s-ui";
4
3
 
5
4
  import type { DiagnosisEvidenceRole } from "../../api/diagnose";
@@ -11,7 +10,7 @@ export const AGENT_ROLE_LABELS: Readonly<
11
10
  cause: "Cause",
12
11
  symptom: "Symptom",
13
12
  context: "Context",
14
- benign: "Not a problem",
13
+ benign: "Not a live problem",
15
14
  demoted: "Less relevant",
16
15
  rules_out: "Rules out",
17
16
  };
@@ -29,8 +28,11 @@ export function AgentRoleChip({ role }: { role: DiagnosisEvidenceRole }) {
29
28
  // and the badge's own baseline comes from the sparkle, landing 1px high.
30
29
  wrapperClassName="shrink-0 align-[-1px]"
31
30
  >
32
- <Badge tone="agent" size="sm">
33
- <Sparkles className="h-2.5 w-2.5 shrink-0" aria-hidden />
31
+ <Badge
32
+ tone="structural"
33
+ size="sm"
34
+ className="border-theme-border-light font-medium text-theme-text-primary"
35
+ >
34
36
  {AGENT_ROLE_LABELS[role]}
35
37
  </Badge>
36
38
  </Tooltip>
@@ -55,10 +57,13 @@ export function AgentClaimNote({
55
57
  role,
56
58
  excludes,
57
59
  subject,
60
+ gap,
58
61
  className,
59
62
  }: {
60
63
  claim: string;
61
64
  role?: DiagnosisEvidenceRole;
65
+ /** What the agent says this result does not cover; shown beside its reading of it. */
66
+ gap?: string;
62
67
  /**
63
68
  * The hypothesis this item excludes. Rendered only for `rules_out`, where
64
69
  * the chip frames it as rejected: a hypothesis is a claim the agent
@@ -69,7 +74,7 @@ export function AgentClaimNote({
69
74
  subject?: string;
70
75
  className?: string;
71
76
  }) {
72
- if (!claim && !role) return null;
77
+ if (!claim && !role && !gap) return null;
73
78
  return (
74
79
  <p
75
80
  data-agent-claim
@@ -102,6 +107,14 @@ export function AgentClaimNote({
102
107
  {renderClaim(claim)}
103
108
  </>
104
109
  ) : null}
110
+ {gap ? (
111
+ <span
112
+ data-agent-gap
113
+ className="block text-[11px] text-theme-text-tertiary"
114
+ >
115
+ Not shown: {gap}
116
+ </span>
117
+ ) : null}
105
118
  </span>
106
119
  </p>
107
120
  );