@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.14.0",
3
+ "version": "1.14.2",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -41,7 +41,7 @@
41
41
  "yaml": "^2.9.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "@skyhook-io/k8s-ui": ">=1.14.2",
44
+ "@skyhook-io/k8s-ui": ">=1.14.7",
45
45
  "@tanstack/react-query": ">=5",
46
46
  "@xyflow/react": ">=12.0.0",
47
47
  "clsx": ">=2",
package/src/App.tsx CHANGED
@@ -30,7 +30,7 @@ import { ApplicationsView } from './components/applications/ApplicationsView'
30
30
  import { HelmReleaseDrawer } from './components/helm/HelmReleaseDrawer'
31
31
  import { PortForwardProvider, PortForwardIndicator, PortForwardPanel } from './components/portforward/PortForwardManager'
32
32
  import { DockProvider, BottomDock, useDock, useDockReservedHeight, useOpenLocalTerminal } from './components/dock'
33
- import { DURATION_DOCK } from '@skyhook-io/k8s-ui/utils/animation'
33
+ import { DURATION_DOCK, overlayExitMs } from '@skyhook-io/k8s-ui/utils/animation'
34
34
  import { ContextSwitcher } from './components/ContextSwitcher'
35
35
  import { NamespaceSwitcher, type NamespaceSwitcherHandle } from './components/NamespaceSwitcher'
36
36
  import { CloudFunnelButton } from './components/CloudFunnelButton'
@@ -680,9 +680,11 @@ function AppInner({ manageDocumentTitle = false, documentTitleSuffix, onClusterL
680
680
  // Animation hooks for smooth mount/unmount transitions
681
681
  const resourceDrawer = useAnimatedUnmount(!!routeSelectedResource, 300)
682
682
  const helmDrawer = useAnimatedUnmount(!!(mainView === 'helm' && selectedHelmRelease), 300)
683
- const helpOverlay = useAnimatedUnmount(showHelp, 300)
684
- const commandPaletteAnim = useAnimatedUnmount(showCommandPalette, 300)
685
- const diagnosticsOverlay = useAnimatedUnmount(showDiagnostics, 300)
683
+ // Dialog-kind overlays wait their exit duration (shorter than the entrance);
684
+ // drawers keep the symmetric slide.
685
+ const helpOverlay = useAnimatedUnmount(showHelp, overlayExitMs('dialog'))
686
+ const commandPaletteAnim = useAnimatedUnmount(showCommandPalette, overlayExitMs('dialog'))
687
+ const diagnosticsOverlay = useAnimatedUnmount(showDiagnostics, overlayExitMs('dialog'))
686
688
 
687
689
  // Hold last valid values so drawers can animate out before data disappears
688
690
  const lastResourceRef = useRef(routeSelectedResource)
package/src/api/client.ts CHANGED
@@ -1913,6 +1913,9 @@ export interface CloudInstallAttempted {
1913
1913
 
1914
1914
  export interface CloudInstallBlocked {
1915
1915
  reason: 'gitops' | 'preflight' | 'unsupported'
1916
+ // The Kubernetes user the attempt acted as; absent when the API server
1917
+ // would not say.
1918
+ identity?: string
1916
1919
  // Preflight only: what would unblock it, and the Helm operation that was
1917
1920
  // dry-run — the plan card never renders when preflight blocks, so the
1918
1921
  // blocked card states what Radar tried itself.
@@ -72,7 +72,8 @@ export interface DiagnosisEvidenceSubject {
72
72
  group?: string;
73
73
  kind: string;
74
74
  namespace?: string;
75
- name: string;
75
+ /** Absent when the subject is a listing cited for what it does not contain. */
76
+ name?: string;
76
77
  container?: string;
77
78
  stream?: "current" | "previous";
78
79
  /** Evidence kind (resource, logs, events, changes, metrics, …). */
@@ -85,6 +86,8 @@ export interface DiagnosisEvidenceItem {
85
86
  ref?: string;
86
87
  role?: DiagnosisEvidenceRole;
87
88
  claim?: string;
89
+ /** The agent's own statement of what this result does not cover. */
90
+ gap?: string;
88
91
  subject?: DiagnosisEvidenceSubject;
89
92
  }
90
93
 
@@ -94,10 +97,35 @@ export interface DiagnosisRuledOut {
94
97
  evidenceIndex: number;
95
98
  }
96
99
 
100
+ export type DiagnosisCertainty = "established" | "likely" | "suspected";
101
+ export type DiagnosisStepKind = "mitigate" | "verify" | "investigate";
102
+
103
+ /** One typed next step; `remediation` mirrors `steps[].text` when steps are present. */
104
+ export interface DiagnosisStep {
105
+ text: string;
106
+ kind: DiagnosisStepKind;
107
+ /** The condition under which this step is the right one, when the agent named one. */
108
+ precondition?: string;
109
+ }
110
+
97
111
  export interface Diagnosis {
98
112
  healthy?: boolean;
99
113
  inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
100
114
  rootCause: string;
115
+ /**
116
+ * The plain-language headline; `rootCause` stays the technical one-liner.
117
+ * Present only on turns from a backend that emits the story contract; its
118
+ * absence selects the previous rendering.
119
+ */
120
+ summary?: string;
121
+ /** The agent's own word for how sure it is; rendered in the agent tone, never as a Radar mark. */
122
+ certainty?: DiagnosisCertainty;
123
+ /** What would change the answer or could not be verified. Rendered above the story fold. */
124
+ unresolved?: string[];
125
+ /** A follow-up answer that replaces the assessment on screen (server-validated as a complete verdict). */
126
+ revisesAssessment?: boolean;
127
+ /** Typed next steps; absent on older runs, where `remediation` is the only list. */
128
+ steps?: DiagnosisStep[];
101
129
  rootCauseEvidence?: RootCauseEvidence;
102
130
  /** The agent's case over Radar's evidence; absent from hosted backends and older runs. */
103
131
  evidence?: DiagnosisEvidenceItem[];
@@ -107,6 +135,8 @@ export interface Diagnosis {
107
135
  evidenceMalformed?: boolean;
108
136
  ruledOut?: DiagnosisRuledOut[];
109
137
  report: string;
138
+ /** The agent's evidence ledger, written before its verdict block; shown in Activity, never in Findings. */
139
+ notes?: string;
110
140
  remediation: string[];
111
141
  recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
112
142
  recommendedReason?: string; // why the recommended step is the safe pick
@@ -1,7 +1,8 @@
1
- import { type ReactNode, useRef, useState } from 'react'
1
+ import { type ReactNode, useEffect, useRef, useState } from 'react'
2
2
  import { useMutation } from '@tanstack/react-query'
3
- import { AlertTriangle, ArrowUpRight, Check, ExternalLink, GitBranch, Info, Loader2, ShieldAlert, X } from 'lucide-react'
4
- import type { BlockedExit } from './cloudConnectHandoff'
3
+ import { AlertTriangle, ArrowUpRight, Check, Copy, ExternalLink, GitBranch, Info, Loader2, ShieldAlert, X } from 'lucide-react'
4
+ import { type AdminNoteContext, type BlockedExit, composeAdminNote, composeFailureNote, needsAdminHandoff } from './cloudConnectHandoff'
5
+ import { copyText } from '@skyhook-io/k8s-ui/utils/clipboard'
5
6
  import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
6
7
  import {
7
8
  ApiError,
@@ -21,19 +22,22 @@ export function CloudConnectFlow({
21
22
  status,
22
23
  blocked,
23
24
  exit,
25
+ where,
24
26
  onStatus,
25
27
  onExit,
26
28
  }: {
27
29
  status: CloudInstallStatus
28
30
  blocked: CloudInstallBlocked | null
29
31
  exit: BlockedExit
32
+ // Which cluster this is, for the note a blocked person hands to an admin.
33
+ where?: AdminNoteContext
30
34
  // Push a mutation's status response into the shared query state.
31
35
  onStatus: (st: CloudInstallStatus) => void
32
36
  // Leave the flow view (back to the pitch, or close after dismiss).
33
37
  onExit: () => void
34
38
  }) {
35
39
  if (blocked) {
36
- return <BlockedView blocked={blocked} exit={exit} onExit={onExit} />
40
+ return <BlockedView blocked={blocked} exit={exit} where={where} onExit={onExit} />
37
41
  }
38
42
 
39
43
  switch (status.state) {
@@ -59,7 +63,7 @@ export function CloudConnectFlow({
59
63
  case 'connected':
60
64
  return <ConnectedCard status={status} onStatus={onStatus} onExit={onExit} />
61
65
  case 'failed':
62
- return <FailedCard status={status} onStatus={onStatus} onExit={onExit} />
66
+ return <FailedCard status={status} where={where} onStatus={onStatus} onExit={onExit} />
63
67
  default:
64
68
  return null
65
69
  }
@@ -68,13 +72,20 @@ export function CloudConnectFlow({
68
72
  function BlockedView({
69
73
  blocked,
70
74
  exit,
75
+ where,
71
76
  onExit,
72
77
  }: {
73
78
  blocked: CloudInstallBlocked
74
79
  exit: BlockedExit
80
+ where?: AdminNoteContext
75
81
  onExit: () => void
76
82
  }) {
77
83
  const copy = blockedCopy(blocked, exit)
84
+ // The person who can act is usually not the one reading this card. What
85
+ // they need is an ask with the link and the evidence — not the card's
86
+ // second-person explanation — so the note is composed on its own; the
87
+ // CopyForAdmin action previews exactly what will be copied.
88
+ const note = composeAdminNote(blocked, exit, where)
78
89
  const icon =
79
90
  blocked.reason === 'gitops' ? (
80
91
  <GitBranch className="w-4 h-4 shrink-0 mt-0.5 text-emerald-600 dark:text-emerald-400" />
@@ -97,7 +108,7 @@ function BlockedView({
97
108
  <BlockedSection label="What to do">{copy.next}</BlockedSection>
98
109
  </div>
99
110
  </div>
100
- <div className="mt-4 flex items-center gap-4">
111
+ <div className="relative mt-4 flex items-center gap-4">
101
112
  <a
102
113
  href={exit.href}
103
114
  target="_blank"
@@ -106,6 +117,7 @@ function BlockedView({
106
117
  >
107
118
  {exit.label}
108
119
  </a>
120
+ <CopyForAdmin note={note} />
109
121
  <button
110
122
  onClick={onExit}
111
123
  className="ml-auto text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
@@ -117,6 +129,132 @@ function BlockedView({
117
129
  )
118
130
  }
119
131
 
132
+ // The person who can act is usually not the one reading a stop card. This
133
+ // hands them the composed note: a preview panel on hover or focus (card-wide,
134
+ // on the elevated surface, sized to the note — a block of text laid over the
135
+ // card, not a hint), and when the clipboard is refused the panel pins open
136
+ // with the note selected so copying is one keystroke.
137
+ function CopyForAdmin({ note }: { note: string }) {
138
+ const [copied, setCopied] = useState<'idle' | 'done' | 'failed'>('idle')
139
+ const [previewing, setPreviewing] = useState(false)
140
+ const [pinned, setPinned] = useState(false)
141
+ const noteRef = useRef<HTMLPreElement | null>(null)
142
+ const panelRef = useRef<HTMLDivElement | null>(null)
143
+ // The panel is anchored above the action row inside the dialog's scroll
144
+ // area. A hover panel taller than the room visible above the row is only
145
+ // partly on screen, and scrolling to see the rest moves it out from under
146
+ // the pointer and closes it. So the ceiling is measured — the row's
147
+ // distance from the visible top of the scroller — rather than guessed. A
148
+ // normal note fits and shows whole; only a longer one scrolls inside.
149
+ const [maxHeight, setMaxHeight] = useState<number>()
150
+ const shown = previewing || pinned
151
+ useEffect(() => {
152
+ if (!shown) return
153
+ const wrapper = panelRef.current
154
+ // The positioned ancestor the panel is anchored to — the action row.
155
+ const row = wrapper?.offsetParent as HTMLElement | null | undefined
156
+ const scroller = row?.closest<HTMLElement>('.overflow-y-auto')
157
+ if (!wrapper || !row || !scroller) return
158
+ const visibleRoom = row.getBoundingClientRect().top - scroller.getBoundingClientRect().top
159
+ const gap = wrapper.getBoundingClientRect().bottom - (wrapper.firstElementChild?.getBoundingClientRect().bottom ?? 0)
160
+ setMaxHeight(Math.max(96, Math.floor(visibleRoom - gap - 8)))
161
+ }, [shown])
162
+ // Leaving closes on a short delay that entering the panel cancels, so the
163
+ // pointer can cross the row's own space on its way up into the panel.
164
+ const closeTimer = useRef<number | undefined>(undefined)
165
+ const enter = () => {
166
+ window.clearTimeout(closeTimer.current)
167
+ setPreviewing(true)
168
+ }
169
+ const leave = () => {
170
+ window.clearTimeout(closeTimer.current)
171
+ closeTimer.current = window.setTimeout(() => setPreviewing(false), 200)
172
+ }
173
+ useEffect(() => () => window.clearTimeout(closeTimer.current), [])
174
+ const copy = () => {
175
+ // copyText also serves Radar on a plain-HTTP non-loopback address, where
176
+ // the async Clipboard API is missing and the legacy command still works.
177
+ void copyText(note).then((ok) => {
178
+ setCopied(ok ? 'done' : 'failed')
179
+ if (ok) setTimeout(() => setCopied('idle'), 2000)
180
+ else setPinned(true)
181
+ })
182
+ }
183
+ // The panel may not be mounted when the clipboard refuses (the pointer has
184
+ // left the button), so the selection waits for the pinned render.
185
+ useEffect(() => {
186
+ if (!pinned) return
187
+ const pre = noteRef.current
188
+ const selection = window.getSelection()
189
+ if (!pre || !selection) return
190
+ selection.removeAllRanges()
191
+ const range = document.createRange()
192
+ range.selectNodeContents(pre)
193
+ selection.addRange(range)
194
+ }, [pinned])
195
+ return (
196
+ // Hover is tracked on this wrapper, which contains the panel, and the
197
+ // gap between button and panel is padding rather than margin, so moving
198
+ // the pointer up into the panel (to scroll a long note) never counts as
199
+ // leaving.
200
+ <span className="contents" onMouseEnter={enter} onMouseLeave={leave}>
201
+ {shown && (
202
+ <div ref={panelRef} className="absolute inset-x-0 bottom-full z-20 pb-3">
203
+ <div
204
+ id="admin-note-preview"
205
+ role="tooltip"
206
+ style={maxHeight ? { maxHeight } : undefined}
207
+ className="flex flex-col rounded-xl border border-theme-border bg-theme-elevated p-4 shadow-theme-lg"
208
+ >
209
+ <div className="mb-1.5 flex items-center justify-between text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
210
+ <span>{pinned ? 'Select and copy' : 'What gets copied'}</span>
211
+ {pinned && (
212
+ <button
213
+ type="button"
214
+ onClick={() => {
215
+ setPinned(false)
216
+ setCopied('idle')
217
+ }}
218
+ aria-label="Close"
219
+ className="rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
220
+ >
221
+ <X className="w-3 h-3" />
222
+ </button>
223
+ )}
224
+ </div>
225
+ <pre
226
+ ref={noteRef}
227
+ className="min-h-0 select-text overflow-y-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-theme-text-primary"
228
+ >
229
+ {note}
230
+ </pre>
231
+ </div>
232
+ </div>
233
+ )}
234
+ <button
235
+ type="button"
236
+ onClick={copy}
237
+ onFocus={() => setPreviewing(true)}
238
+ onBlur={() => setPreviewing(false)}
239
+ aria-describedby={shown ? 'admin-note-preview' : undefined}
240
+ className="inline-flex items-center gap-1.5 text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary transition-colors"
241
+ >
242
+ {copied === 'done' ? (
243
+ <>
244
+ <Check className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" /> Copied
245
+ </>
246
+ ) : copied === 'failed' ? (
247
+ 'Couldn’t copy — the note is selected above, press ⌘C / Ctrl+C'
248
+ ) : (
249
+ <>
250
+ <Copy className="w-3.5 h-3.5" /> Copy for a cluster admin
251
+ </>
252
+ )}
253
+ </button>
254
+ </span>
255
+ )
256
+ }
257
+
120
258
  function BlockedSection({ label, children }: { label: string; children: ReactNode }) {
121
259
  return (
122
260
  <div>
@@ -186,12 +324,12 @@ function blockedCopy(blocked: CloudInstallBlocked, exit: BlockedExit): { title:
186
324
  </>
187
325
  )
188
326
 
189
- // Where the exit is an install command, Radar Cloud asks for a cluster name
327
+ // Where the exit is an install command, Radar Cloud confirms the cluster name (prefilled from the context)
190
328
  // first, then shows the instructions — say so, rather than promising a
191
329
  // command on the next screen.
192
330
  const installNext = (
193
331
  <>
194
- Have an admin with cluster access get the install command from Radar Cloud: it asks for a cluster name, then
332
+ Have an admin with cluster access get the install command from Radar Cloud: it confirms the cluster name, then
195
333
  shows the Helm, Argo CD or Flux instructions to review before running.
196
334
  </>
197
335
  )
@@ -223,7 +361,7 @@ function blockedCopy(blocked: CloudInstallBlocked, exit: BlockedExit): { title:
223
361
  why: blocked.message,
224
362
  next: exit.install ? (
225
363
  <>
226
- Have an admin with repo access get the values patch from Radar Cloud: it asks for a cluster name, then shows
364
+ Have an admin with repo access get the values patch from Radar Cloud: it confirms the cluster name, then shows
227
365
  the patch for your controller and the one command that creates the token Secret.
228
366
  </>
229
367
  ) : (
@@ -237,8 +375,9 @@ function blockedCopy(blocked: CloudInstallBlocked, exit: BlockedExit): { title:
237
375
  // Fresh offered on "nothing running" alone: say what was not confirmed.
238
376
  const unconfirmedNext = (
239
377
  <>
240
- Radar found no Radar running in this cluster but couldn’t read Helm’s release records, so an admin with cluster
241
- access should confirm nothing is installed before running a fresh install. {installNext}
378
+ An admin with cluster access should confirm nothing is installed — Radar couldn’t read Helm’s release records —
379
+ then get the install command from Radar Cloud: it confirms the cluster name, then shows the Helm, Argo CD or Flux
380
+ instructions to review before running.
242
381
  </>
243
382
  )
244
383
  const next = exit.install ? (a?.releaseUnread ? unconfirmedNext : installNext) : unknownNext
@@ -262,7 +401,15 @@ function blockedCopy(blocked: CloudInstallBlocked, exit: BlockedExit): { title:
262
401
  title: 'The cluster blocked part of this install',
263
402
  tried,
264
403
  why: 'The cluster refused: something already there conflicts with the install, or a policy rejects it. More permission wouldn’t change that.',
265
- next: exit.install ? <>After the refusals above are resolved: {installNext}</> : next,
404
+ next: exit.install ? (
405
+ <>
406
+ Once the refusals above are resolved, have an admin with cluster access get the install command from Radar
407
+ Cloud: it confirms the cluster name, then shows the Helm, Argo CD or Flux instructions to review before
408
+ running.
409
+ </>
410
+ ) : (
411
+ next
412
+ ),
266
413
  }
267
414
  }
268
415
  }
@@ -644,16 +791,22 @@ function ConnectedCard({
644
791
 
645
792
  function FailedCard({
646
793
  status,
794
+ where,
647
795
  onStatus,
648
796
  onExit,
649
797
  }: {
650
798
  status: CloudInstallStatus
799
+ where?: AdminNoteContext
651
800
  onStatus: (st: CloudInstallStatus) => void
652
801
  onExit: () => void
653
802
  }) {
654
803
  const dismiss = useDismiss(status, onStatus, onExit)
655
804
  const failure = status.failure
656
805
  if (!failure) return null
806
+ // After the Hub approved, the guidance is written for an operator — inspect
807
+ // commands, keep this Hub cluster, don't rerun. Hand it over the same way a
808
+ // blocked card does, as a request to finish the connection.
809
+ const handoff = needsAdminHandoff(failure)
657
810
  return (
658
811
  <div className="px-8 pt-6 pb-5">
659
812
  <div className="flex items-start gap-2.5 mb-3">
@@ -666,13 +819,20 @@ function FailedCard({
666
819
  showSummary={failure.guidance.summary !== failure.message}
667
820
  />
668
821
  )}
669
- <div className="mt-4 flex items-center gap-4">
822
+ <div className="relative mt-4 flex items-center gap-4">
670
823
  <button
671
824
  onClick={dismiss}
672
825
  className="px-4 py-1.5 rounded-[10px] bg-theme-elevated hover:bg-theme-hover border border-theme-border text-[12.5px] font-semibold text-theme-text-primary transition-colors"
673
826
  >
674
827
  {failure.retrySafe ? 'Start over' : 'Close'}
675
828
  </button>
829
+ {handoff && (
830
+ <CopyForAdmin
831
+ // The flow's own record of the cluster: a context switch after
832
+ // the install started must not relabel the note for another.
833
+ note={composeFailureNote(failure, { context: status.plan?.contextName ?? where?.context, cluster: where?.cluster })}
834
+ />
835
+ )}
676
836
  </div>
677
837
  </div>
678
838
  )
@@ -234,7 +234,8 @@ export function CloudFunnelButton() {
234
234
  // relabel the CTA (and the link) for cluster B, and a blocked view left
235
235
  // armed would reopen on B with A's refusal, feeding it back into the
236
236
  // outcome on Back.
237
- const contextName = useClusterInfo().data?.context
237
+ const clusterInfo = useClusterInfo()
238
+ const contextName = clusterInfo.data?.context
238
239
  useEffect(() => {
239
240
  setHandoff(null)
240
241
  setBlocked(null)
@@ -306,7 +307,8 @@ export function CloudFunnelButton() {
306
307
  <CloudConnectFlow
307
308
  status={flowForView}
308
309
  blocked={blocked}
309
- exit={exitFor(appUrl, 'driver-blocked-card-browser-link', outcomeOf(flowForView))}
310
+ exit={exitFor(appUrl, 'driver-blocked-card-browser-link', outcomeOf(flowForView), clusterInfo.data?.context)}
311
+ where={{ context: clusterInfo.data?.context, cluster: clusterInfo.data?.cluster }}
310
312
  onStatus={applyStatus}
311
313
  onExit={() => exitFlow(outcomeOf(flowForView))}
312
314
  />
@@ -1,4 +1,4 @@
1
- import { ServerOff, RefreshCw, Loader2, Copy, Check, TerminalSquare, ChevronRight } from 'lucide-react'
1
+ import { ServerOff, RefreshCw, Loader2, Copy, Check, TerminalSquare } from 'lucide-react'
2
2
  import { useEffect, useState } from 'react'
3
3
  import type { ConnectionState } from '../context/ConnectionContext'
4
4
  import { ContextSwitcher } from './ContextSwitcher'
@@ -6,6 +6,7 @@ import { parseContextName } from '../utils/context-name'
6
6
  import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
7
7
  import { useAuthMe, useContexts } from '../api/client'
8
8
  import { Tooltip } from './ui/Tooltip'
9
+ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
9
10
  import { allShellSafe, awsProfileFlag } from '../utils/shell-safe'
10
11
  import { apiUrl } from '../api/config'
11
12
  import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
@@ -459,18 +460,16 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
459
460
  onClick={() => setShowRawError((open) => !open)}
460
461
  className="flex items-center gap-1 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary transition-colors"
461
462
  >
462
- <ChevronRight className={`h-3.5 w-3.5 transition-transform duration-200 ${showRawError ? 'rotate-90' : ''}`} />
463
+ <CollapseChevron open={showRawError} className="h-3.5 w-3.5" />
463
464
  Raw error
464
465
  </button>
465
- <div className={`issue-details-motion ${showRawError ? 'issue-details-motion-open' : ''}`}>
466
- <div className="overflow-hidden">
467
- <div id="connection-raw-error" className="mt-2 bg-theme-elevated border border-theme-border rounded-md p-3 overflow-auto max-h-32">
468
- <code className="text-xs text-theme-text-tertiary font-mono whitespace-pre-wrap break-words">
469
- {connection.error}
470
- </code>
471
- </div>
466
+ <Collapse open={showRawError} id="connection-raw-error">
467
+ <div className="mt-2 bg-theme-elevated border border-theme-border rounded-md p-3 overflow-auto max-h-32">
468
+ <code className="text-xs text-theme-text-tertiary font-mono whitespace-pre-wrap break-words">
469
+ {connection.error}
470
+ </code>
472
471
  </div>
473
- </div>
472
+ </Collapse>
474
473
  </div>
475
474
  )}
476
475
  </div>
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'react'
2
- import { Bug, X, ChevronDown, ChevronUp } from 'lucide-react'
2
+ import { Bug, X } from 'lucide-react'
3
+ import { Collapse, CollapseChevron, useDisclosure } from '@skyhook-io/k8s-ui/components/ui/Collapse'
3
4
  import { useRuntimeStats } from '../api/client'
4
5
  import { Tooltip } from './ui/Tooltip'
5
6
 
@@ -14,6 +15,7 @@ function formatUptime(seconds: number): string {
14
15
  export function DebugOverlay() {
15
16
  const [visible, setVisible] = useState(true)
16
17
  const [expanded, setExpanded] = useState(false)
18
+ const disclosure = useDisclosure(expanded)
17
19
  const { data } = useRuntimeStats(visible)
18
20
 
19
21
  if (!visible) {
@@ -40,9 +42,11 @@ export function DebugOverlay() {
40
42
  <div className="flex-1" />
41
43
  <button
42
44
  onClick={() => setExpanded(!expanded)}
45
+ {...disclosure.buttonProps}
46
+ aria-label={expanded ? 'Collapse debug stats' : 'Expand debug stats'}
43
47
  className="p-0.5 text-theme-text-tertiary hover:text-theme-text-secondary"
44
48
  >
45
- {expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
49
+ <CollapseChevron open={expanded} className="w-3 h-3" />
46
50
  </button>
47
51
  <button
48
52
  onClick={() => setVisible(false)}
@@ -60,8 +64,8 @@ export function DebugOverlay() {
60
64
  <span className="text-theme-text-tertiary">Heap</span>
61
65
  <span className="text-theme-text-primary">{runtime.heapMB.toFixed(1)} MB</span>
62
66
  </div>
63
- {expanded && (
64
- <>
67
+ <Collapse open={expanded} id={disclosure.panelId}>
68
+ <div className="space-y-0.5">
65
69
  <div className="flex justify-between gap-4">
66
70
  <span className="text-theme-text-tertiary">Objects</span>
67
71
  <span className="text-theme-text-primary">{runtime.heapObjectsK.toFixed(1)}K</span>
@@ -84,8 +88,8 @@ export function DebugOverlay() {
84
88
  <span className="text-theme-text-tertiary">Resources</span>
85
89
  <span className="text-theme-text-primary">{data?.resourceCount ?? '-'}</span>
86
90
  </div>
87
- </>
88
- )}
91
+ </div>
92
+ </Collapse>
89
93
  </>
90
94
  ) : (
91
95
  <span className="text-theme-text-tertiary">Loading…</span>