@skyhook-io/radar-app 1.14.2 → 1.14.4

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 (83) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +32 -0
  3. package/src/api/diagnose.ts +7 -1
  4. package/src/components/CloudConnectFlow.tsx +21 -7
  5. package/src/components/CloudFunnelButton.tsx +85 -8
  6. package/src/components/cloudConnectHandoff.test.ts +10 -1
  7. package/src/components/cloudConnectHandoff.ts +15 -1
  8. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  9. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  10. package/src/components/diagnose/AgentControls.tsx +603 -0
  11. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  12. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  13. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  14. package/src/components/diagnose/AssessmentCard.tsx +1579 -0
  15. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  16. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  17. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  18. package/src/components/diagnose/Home.test.tsx +1 -3
  19. package/src/components/diagnose/Home.tsx +2 -1
  20. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  21. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  22. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  23. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  24. package/src/components/diagnose/InvestigationView.tsx +39 -51
  25. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  26. package/src/components/diagnose/assessmentCopy.ts +169 -0
  27. package/src/components/diagnose/investigationCase.test.tsx +658 -2
  28. package/src/components/diagnose/investigationCase.ts +135 -26
  29. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
  31. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
  32. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  34. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  41. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  47. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  48. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  49. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  50. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  51. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  52. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  53. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  54. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  55. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  57. package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
  58. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  59. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  60. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  61. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  62. package/src/components/diagnose/investigationState.test.ts +2 -2
  63. package/src/components/diagnose/investigationState.ts +2 -2
  64. package/src/components/diagnose/investigationStory.ts +1 -1
  65. package/src/components/diagnose/parts.test.tsx +15 -1
  66. package/src/components/diagnose/parts.tsx +26 -3904
  67. package/src/components/diagnose/target.ts +9 -3
  68. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  69. package/src/components/diagnose/toolCallLabel.ts +41 -2
  70. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  71. package/src/components/home/MCPSetupDialog.tsx +1 -1
  72. package/src/components/home/mcpToolCatalog.ts +27 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  74. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  75. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  76. package/src/components/resources/PodFilePreview.tsx +1 -1
  77. package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
  78. package/src/components/ui/CommandPalette.tsx +12 -8
  79. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  80. package/src/components/ui/Omnibar.tsx +96 -60
  81. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  82. package/src/components/workload/WorkloadView.tsx +7 -1
  83. package/src/components/ui/Disclosure.tsx +0 -47
@@ -1,6 +1,9 @@
1
1
  import { resourceKey } from "@skyhook-io/k8s-ui";
2
2
 
3
- import { kindToPluralWithGroup, knownKindForPluralWithGroup } from "../../utils/navigation";
3
+ import {
4
+ kindToPluralWithGroup,
5
+ knownKindForPluralWithGroup,
6
+ } from "../../utils/navigation";
4
7
 
5
8
  export interface InvestigationTargetIdentity {
6
9
  kind: string;
@@ -31,7 +34,10 @@ export function runTargetKey(
31
34
  export function formatInvestigationTarget(
32
35
  target: InvestigationTargetIdentity,
33
36
  ): string {
34
- const canonicalKind = knownKindForPluralWithGroup(target.kind, target.group) ?? target.kind;
35
- const kind = target.group ? `${canonicalKind}.${target.group}` : canonicalKind;
37
+ const canonicalKind =
38
+ knownKindForPluralWithGroup(target.kind, target.group) ?? target.kind;
39
+ const kind = target.group
40
+ ? `${canonicalKind}.${target.group}`
41
+ : canonicalKind;
36
42
  return `${kind} ${target.namespace ? `${target.namespace}/` : ""}${target.name}`;
37
43
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { describeToolCall } from "./toolCallLabel";
2
+ import { describeToolCall, investigationRunningLabel } from "./toolCallLabel";
3
3
 
4
4
  describe("describeToolCall", () => {
5
5
  it("never invents a singular from a kind", () => {
@@ -49,3 +49,49 @@ describe("describeToolCall", () => {
49
49
  expect(describeToolCall("some_new_tool", undefined)).toBe("Some New Tool");
50
50
  });
51
51
  });
52
+
53
+ describe("investigationRunningLabel", () => {
54
+ const read = {
55
+ tool: "get_resource",
56
+ status: "done",
57
+ summary: JSON.stringify({
58
+ kind: "deployment",
59
+ namespace: "shop",
60
+ name: "api",
61
+ }),
62
+ };
63
+ it("names the call in flight, keeps a finished one briefly, then thinks", () => {
64
+ expect(
65
+ investigationRunningLabel({
66
+ lastTool: { ...read, status: "running" },
67
+ reads: 1,
68
+ quietFor: 20000,
69
+ elapsedSeconds: 9,
70
+ }),
71
+ ).toEqual({
72
+ current: "Reading Deployment shop/api…",
73
+ meta: "1 read · 9 s",
74
+ });
75
+ expect(
76
+ investigationRunningLabel({
77
+ lastTool: read,
78
+ reads: 3,
79
+ quietFor: 7999,
80
+ elapsedSeconds: 30,
81
+ }).current,
82
+ ).toBe("Reading Deployment shop/api…");
83
+ expect(
84
+ investigationRunningLabel({
85
+ lastTool: read,
86
+ reads: 3,
87
+ quietFor: 8000,
88
+ elapsedSeconds: 30,
89
+ }),
90
+ ).toEqual({ current: "Thinking…", meta: "3 reads · 30 s" });
91
+ });
92
+ it("says Investigating with nothing read yet and omits a timer under two seconds", () => {
93
+ expect(
94
+ investigationRunningLabel({ reads: 0, quietFor: 0, elapsedSeconds: 1 }),
95
+ ).toEqual({ current: "Investigating…", meta: "" });
96
+ });
97
+ });
@@ -1,5 +1,3 @@
1
- import { prettyTool } from "./parts";
2
-
3
1
  // One readable sentence for a tool call in flight, from the call's arguments:
4
2
  // "Reading logs for pod shop/api-7d4 / api" tells the reader what the agent
5
3
  // is looking at; "Get Pod Logs" tells them which tool ran.
@@ -133,3 +131,44 @@ function pluralLabel(kind: string): string {
133
131
  const base = kindLabel(kind) ?? kind;
134
132
  return base.endsWith("s") ? base : `${base}s`;
135
133
  }
134
+
135
+ export function prettyTool(tool: string): string {
136
+ return tool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
137
+ }
138
+
139
+ const THINKING_AFTER_MS = 8000;
140
+
141
+ /**
142
+ * The Assessment card's running row: the call in flight or just finished on
143
+ * the left, the read count and the timer on the right. A finished call keeps
144
+ * its name for a few quiet seconds, then the label admits the agent is
145
+ * thinking rather than showing a stale read.
146
+ */
147
+ export function investigationRunningLabel({
148
+ lastTool,
149
+ reads,
150
+ quietFor,
151
+ elapsedSeconds,
152
+ }: {
153
+ lastTool?: { tool?: string; status?: string; summary?: string };
154
+ reads: number;
155
+ quietFor: number;
156
+ elapsedSeconds: number;
157
+ }): { current: string; meta: string } {
158
+ let current = "Investigating";
159
+ if (lastTool?.tool) {
160
+ current =
161
+ lastTool.status === "running" || quietFor < THINKING_AFTER_MS
162
+ ? describeToolCall(lastTool.tool, lastTool.summary)
163
+ : "Thinking";
164
+ }
165
+ return {
166
+ current: `${current}…`,
167
+ meta: [
168
+ reads > 0 ? `${reads} ${reads === 1 ? "read" : "reads"}` : undefined,
169
+ elapsedSeconds >= 2 ? `${elapsedSeconds} s` : undefined,
170
+ ]
171
+ .filter(Boolean)
172
+ .join(" · "),
173
+ };
174
+ }
@@ -26,7 +26,7 @@ import { ValuesViewer } from './ValuesViewer'
26
26
  import { OwnedResources } from './OwnedResources'
27
27
  import { TrackChartSourceDialog } from './TrackChartSourceDialog'
28
28
  import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
29
- import { Disclosure } from '../ui/Disclosure'
29
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure'
30
30
 
31
31
  interface HelmReleaseDrawerProps {
32
32
  release: SelectedHelmRelease
@@ -3,7 +3,7 @@ import { X, Copy, Check, Radio, Terminal, MessageSquare, Code2, Pin } from 'luci
3
3
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
4
4
  import { MCP_TOOL_CATALOG } from './mcpToolCatalog'
5
5
  import { Tooltip } from '../ui/Tooltip'
6
- import { Disclosure } from '../ui/Disclosure'
6
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure'
7
7
 
8
8
  interface MCPSetupDialogProps {
9
9
  open: boolean
@@ -256,6 +256,33 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
256
256
  { arg: 'limit', desc: 'max rules returned (default 50, max 200)' },
257
257
  ],
258
258
  },
259
+ {
260
+ name: 'get_cost',
261
+ desc: 'Cluster spend from OpenCost or Kubecost — cluster and per-namespace totals, per-workload and per-node breakdowns, and spend over time. Hourly rates plus the same monthly projection the Costs page shows.',
262
+ params: [
263
+ { arg: 'view', desc: 'summary (default), workloads, nodes (cluster-wide), or trend' },
264
+ { arg: 'namespace', desc: 'required for view=workloads; filters summary and trend' },
265
+ { arg: 'kind', desc: 'view=workloads: target one workload instead of the top spenders; needs name' },
266
+ { arg: 'name', desc: 'workloads: exact workload name (needs kind); nodes: exact node name' },
267
+ { arg: 'range', desc: 'trend only: 6h, 24h (default), or 7d' },
268
+ { arg: 'include_points', desc: 'trend only: include raw points (default false); summaries are always returned' },
269
+ { arg: 'limit', desc: 'max rows (default 20, max 100)' },
270
+ ],
271
+ },
272
+ {
273
+ name: 'get_rightsizing',
274
+ desc: 'CPU/memory request recommendations, with current limits as context, from 7 days of observed usage, with a fit verdict and confidence tier per container. Scans can take 45 seconds; inspect confidence and missing evidence. Request reductions do not directly imply bill savings.',
275
+ params: [
276
+ { arg: 'scope', required: true, desc: 'workload (needs kind/name/namespace), namespace, or cluster' },
277
+ { arg: 'kind', desc: 'scope=workload: Deployment, StatefulSet, or DaemonSet' },
278
+ { arg: 'name', desc: 'scope=workload: the workload name' },
279
+ { arg: 'namespace', desc: 'required for scope=workload; for scope=namespace unless namespaces is set' },
280
+ { arg: 'namespaces', desc: 'scope=namespace: scan several namespaces in one call' },
281
+ { arg: 'include_all', desc: 'include balanced and unevidenced rows (default false); workload scope always returns all rows' },
282
+ { arg: 'classification', desc: 'scan only: reduction, increase, review, need_data, or in_range; returns all rows of matching workloads' },
283
+ { arg: 'limit', desc: 'max workloads returned by a namespace or cluster scan (default 20, max 100)' },
284
+ ],
285
+ },
259
286
  {
260
287
  name: 'get_workload_logs',
261
288
  desc: 'Aggregated logs across all pods of a workload. Without grep, filters for diagnostic relevance; grep returns only matching timestamp-prefixed lines instead.',
@@ -1,7 +1,7 @@
1
1
  import { useMemo } from "react";
2
2
  import { useSearchParams } from "react-router-dom";
3
3
  import { Loader2, Wifi, WifiOff } from "lucide-react";
4
- import { Disclosure } from "../ui/Disclosure";
4
+ import { Disclosure } from "@skyhook-io/k8s-ui/components/ui/Disclosure";
5
5
  import {
6
6
  AreaChart,
7
7
  SeriesLegend,
@@ -1,7 +1,7 @@
1
1
  import { useLayoutEffect, useId, useRef } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { ExternalLink, X } from 'lucide-react'
4
- import { Disclosure } from '../ui/Disclosure'
4
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure'
5
5
  import type { WorkloadMetrics } from '../../api/workloadMetrics'
6
6
  import type { PrometheusTimeRange } from '../../api/client'
7
7
 
@@ -18,7 +18,7 @@ import {
18
18
  } from "../../api/workloadMetrics";
19
19
  import { latestWorkloadValue, workloadPodValues } from "./workloadMetricValues";
20
20
  import { WorkloadMetricsHelpDialog } from './WorkloadMetricsHelpDialog';
21
- import { Disclosure } from '../ui/Disclosure';
21
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure';
22
22
 
23
23
  interface Props {
24
24
  kind: string;
@@ -3,7 +3,7 @@ import Editor from '@monaco-editor/react'
3
3
  import { AlertTriangle, Download, FileText, RotateCw } from 'lucide-react'
4
4
  import { PaneLoader, ensureMonacoRuntime } from '@skyhook-io/k8s-ui'
5
5
  import { formatBytes } from '../../utils/format'
6
- import { Disclosure } from '../ui/Disclosure'
6
+ import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure'
7
7
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
8
8
 
9
9
  // A curated inline viewer for text files inside a pod container, rendered in
@@ -8,6 +8,7 @@ import {
8
8
  PageHeader,
9
9
  SearchBox,
10
10
  SelectMenu,
11
+ pluralize,
11
12
  } from '@skyhook-io/k8s-ui'
12
13
  import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
13
14
  import {
@@ -335,7 +336,13 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
335
336
  onSelect={(value) => setFilter('rfClass', value === 'actions' ? undefined : value)}
336
337
  />
337
338
  <ScanNotices result={result} rows={rows} />
338
- {result.coverage.workloadsDiscovered === 0 || rows.length === 0 ? (
339
+ {result.reason === 'only_daemonsets_without_nodes' ? (
340
+ <EmptyState
341
+ variant="card"
342
+ headline="Only DaemonSets with no nodes in this scope"
343
+ body="Every workload here is a DaemonSet that runs on no node right now, so none was scanned. Open one from Resources to see recommendations from its retained history."
344
+ />
345
+ ) : result.coverage.workloadsDiscovered === 0 || rows.length === 0 ? (
339
346
  <EmptyState
340
347
  variant="card"
341
348
  headline="No supported workloads in this scope"
@@ -537,6 +544,18 @@ function ScanNotices({ result, rows }: { result: ScanResult; rows: RightsizingSc
537
544
  notices.push('Some workload kinds or namespaces were excluded by your Kubernetes access.')
538
545
  if ((result.coverage.unavailableKinds?.length ?? 0) > 0)
539
546
  notices.push('Some workload kinds could not be evaluated with the available ownership data.')
547
+ // Distinct from the partial notice above: these workloads were analyzed
548
+ // completely, the cache simply never held the other namespaces.
549
+ if ((result.coverage.partiallyCachedKinds?.length ?? 0) > 0)
550
+ notices.push(
551
+ 'Radar is caching only some namespaces, so this scan covered a narrower scope than the whole cluster.',
552
+ )
553
+ const daemonSetsWithoutNodes = result.coverage.daemonSetsWithoutNodes ?? 0
554
+ // The DaemonSet-only empty state already says this.
555
+ if (daemonSetsWithoutNodes > 0 && result.reason !== 'only_daemonsets_without_nodes')
556
+ notices.push(
557
+ `${pluralize(daemonSetsWithoutNodes, 'DaemonSet')} run on no node right now and ${daemonSetsWithoutNodes === 1 ? 'is' : 'are'} not listed. Open one from Resources to see recommendations from its retained history.`,
558
+ )
540
559
  for (const warning of result.warnings ?? []) notices.push(warningMessage(warning.code))
541
560
  if (rows.length > 0 && rows.every((row) => row.classification === 'need_data'))
542
561
  notices.push('There is not enough recent history to recommend request changes yet.')
@@ -23,13 +23,17 @@ export function CommandPalette({ onClose, isOpen = true, ...callbacks }: Command
23
23
  // with the standalone omnibar via useCommandItems so the two never drift.
24
24
  const items = useCommandItems(callbacks)
25
25
 
26
- // Focus input on mount
26
+ // Focus the input once logically open. Not on mount: the presence hook
27
+ // mounts the palette two frames before `isOpen` flips, and the root is
28
+ // `inert` until then, so a mount-time focus would be refused.
27
29
  useEffect(() => {
28
- inputRef.current?.focus()
29
- }, [])
30
+ if (isOpen) inputRef.current?.focus()
31
+ }, [isOpen])
30
32
 
31
- // Close on Escape (capture phase to beat the shortcut system)
33
+ // Close on Escape (capture phase to beat the shortcut system) — only while
34
+ // logically open: the palette stays mounted through its exit.
32
35
  useEffect(() => {
36
+ if (!isOpen) return
33
37
  const handler = (e: KeyboardEvent) => {
34
38
  if (e.key === 'Escape') {
35
39
  e.preventDefault()
@@ -39,7 +43,7 @@ export function CommandPalette({ onClose, isOpen = true, ...callbacks }: Command
39
43
  }
40
44
  document.addEventListener('keydown', handler, true)
41
45
  return () => document.removeEventListener('keydown', handler, true)
42
- }, [onClose])
46
+ }, [isOpen, onClose])
43
47
 
44
48
  // Filter and rank results
45
49
  const filteredItems = useMemo(() => {
@@ -122,16 +126,16 @@ export function CommandPalette({ onClose, isOpen = true, ...callbacks }: Command
122
126
  let flatIndex = 0
123
127
 
124
128
  return (
125
- <div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]">
129
+ <div className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]" inert={!isOpen || undefined}>
126
130
  {/* Backdrop */}
127
131
  <div
128
132
  className={clsx(
129
133
  'absolute inset-0 bg-theme-base/60 backdrop-blur-sm',
130
134
  TRANSITION_BACKDROP,
131
- isOpen ? 'opacity-100' : 'opacity-0'
135
+ isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
132
136
  )}
133
137
  style={overlayTransitionStyle(isOpen, 'dialog')}
134
- onClick={onClose}
138
+ onClick={isOpen ? onClose : undefined}
135
139
  />
136
140
 
137
141
  {/* Panel */}
@@ -17,8 +17,10 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
17
17
  const [copied, setCopied] = useState<'json' | 'formatted' | null>(null)
18
18
  const [reportOpened, setReportOpened] = useState(false)
19
19
 
20
- // Close on Escape (capture phase)
20
+ // Close on Escape (capture phase) — only while logically open: the overlay
21
+ // stays mounted through its exit.
21
22
  useEffect(() => {
23
+ if (!isOpen) return
22
24
  const handler = (e: KeyboardEvent) => {
23
25
  if (e.key === 'Escape') {
24
26
  e.preventDefault()
@@ -28,7 +30,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
28
30
  }
29
31
  document.addEventListener('keydown', handler, true)
30
32
  return () => document.removeEventListener('keydown', handler, true)
31
- }, [onClose])
33
+ }, [isOpen, onClose])
32
34
 
33
35
  const copyToClipboard = useCallback(async (type: 'json' | 'formatted') => {
34
36
  if (!data) return
@@ -64,16 +66,16 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
64
66
  }, [data])
65
67
 
66
68
  return (
67
- <div className="fixed inset-0 z-[100] flex items-start justify-center pt-[8vh]">
69
+ <div className="fixed inset-0 z-[100] flex items-start justify-center pt-[8vh]" inert={!isOpen || undefined}>
68
70
  {/* Backdrop */}
69
71
  <div
70
72
  className={clsx(
71
73
  'absolute inset-0 bg-theme-base/60 backdrop-blur-sm',
72
74
  TRANSITION_BACKDROP,
73
- isOpen ? 'opacity-100' : 'opacity-0'
75
+ isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
74
76
  )}
75
77
  style={overlayTransitionStyle(isOpen, 'dialog')}
76
- onClick={onClose}
78
+ onClick={isOpen ? onClose : undefined}
77
79
  />
78
80
 
79
81
  {/* Panel */}
@@ -1,7 +1,9 @@
1
- import { useState, useMemo, useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'
1
+ import { useState, useMemo, useRef, useEffect, useCallback, forwardRef, useImperativeHandle, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { Search, CornerDownLeft, Loader2, AlertTriangle } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
6
+ import { TRANSITION_BACKDROP, TRANSITION_MENU, TW_EASE_UI, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
5
7
  import { SearchPillInput, type SearchModifier } from '@skyhook-io/k8s-ui'
6
8
  import { getResourceIcon } from '../../utils/resource-icons'
7
9
  import type { SearchHit, SearchMatchedField } from '../../api/client'
@@ -386,8 +388,10 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
386
388
  }, [open])
387
389
 
388
390
  // Track the input's position so the portaled panel stays anchored under it.
391
+ // The anchor is kept through the exit (clearing it at logical close would
392
+ // unmount the fading panel) and re-measured on the next open.
389
393
  useEffect(() => {
390
- if (!open) { setAnchor(null); return }
394
+ if (!open) return
391
395
  const update = () => {
392
396
  const el = containerRef.current
393
397
  if (!el) return
@@ -406,69 +410,26 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
406
410
  const totalMatched = searchData?.total_matched ?? 0
407
411
  const hasNsPill = pills.some((p) => p.key === 'ns')
408
412
  const dropdownOpen = open && !suggesting && (rows.length > 0 || searchActive)
413
+ // Presence: the scrim and the results panel are menu-kind overlays (140ms
414
+ // in / 100ms out) and stay mounted through their exit. What the panel was
415
+ // showing at logical close is what fades out — `dropdownOpen` drops with
416
+ // `open`, so the last open-frame value is retained for the exit.
417
+ const portalShown = open && (dropdownOpen || suggesting)
418
+ const portal = useAnimatedUnmount(portalShown, overlayExitMs('menu'))
419
+ const panelWasOpen = useRef(dropdownOpen)
420
+ if (open) panelWasOpen.current = dropdownOpen
421
+ const showPanel = open ? dropdownOpen : panelWasOpen.current
409
422
 
410
423
  const clearNsPills = () => { setPills((prev) => prev.filter((p) => p.key !== 'ns')); inputRef.current?.focus() }
411
424
 
412
425
  const hero = size === 'hero'
413
426
 
414
- return (
415
- <div
416
- ref={containerRef}
417
- className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-lg', open && hero && 'z-[16]')}
418
- // Open on click even when the field is already focused — onFocus alone
419
- // never fires again, so an autofocused hero (Home) wouldn't reveal the
420
- // launcher on a click.
421
- onMouseDown={() => setOpen(true)}
422
- >
423
- <SearchPillInput
424
- className={hero
425
- ? 'min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-colors focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]'
426
- : 'min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-colors'}
427
- inputClassName={hero ? 'text-lg py-4' : undefined}
428
- text={text}
429
- pills={pills}
430
- onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
431
- onKeyDown={handleKeyDown}
432
- onFocus={() => { if (skipFocusOpen.current) { skipFocusOpen.current = false; return } setOpen(true) }}
433
- onSuggestingChange={setSuggesting}
434
- modifierOptions={modifierOptions}
435
- placeholder={placeholder}
436
- aria-label="Search resources and commands"
437
- inputRef={inputRef}
438
- leftSlot={<Search className={hero ? 'w-5 h-5 shrink-0 text-theme-text-tertiary' : 'w-3.5 h-3.5 shrink-0 text-theme-text-tertiary'} />}
439
- rightSlot={
440
- <div className="flex items-center gap-1.5 shrink-0">
441
- <SearchSyntaxHelp />
442
- {!hero && !text && pills.length === 0 && (
443
- <kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
444
- {mac ? '⌘' : 'Ctrl+'}K
445
- </kbd>
446
- )}
447
- </div>
448
- }
449
- />
450
-
451
- {open && anchor && (dropdownOpen || suggesting) && createPortal(
452
- <>
453
- {/* Scrim — separates the dropdown from the page, consistently in both
454
- modes. At z-[15] it sits BELOW the rail/top bar (z-20/30), so the
455
- nav chrome stays lit while the content behind the panel dims+blurs:
456
- a "spotlight on search", not a full-screen modal dim (which fits a
457
- centered command palette, not an anchored omnibar). The hero covers
458
- from the top (its box is in the content, lifted to z-[16]); the
459
- top-bar launcher covers from below the field (its box is already in
460
- the z-20 chrome). Click closes. */}
461
- <div
462
- className="fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]"
463
- style={{ top: hero ? 0 : anchor.top }}
464
- onClick={() => { setOpen(false); inputRef.current?.blur() }}
465
- />
466
- {dropdownOpen && (
467
- <div
468
- ref={panelRef}
469
- style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)' }}
470
- className="z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden"
471
- >
427
+ // The results panel keeps showing what it showed at logical close while it
428
+ // fades: picking a result clears the query (and recents re-key off `open`)
429
+ // on the same render, and a panel that swaps to a different list mid-exit
430
+ // reads as a glitch. The last open-frame content is frozen for the exit.
431
+ const panelContent = (
432
+ <>
472
433
  <div ref={listRef} className="max-h-[60vh] overflow-y-auto py-1">
473
434
  {recentRows.length > 0 && (
474
435
  <div>
@@ -549,6 +510,81 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
549
510
  <span>⇞⇟ page</span>
550
511
  <span>esc close</span>
551
512
  </div>
513
+ </>
514
+ )
515
+ const frozenPanel = useRef<ReactNode>(null)
516
+ if (open) frozenPanel.current = panelContent
517
+ const shownPanel = open ? panelContent : frozenPanel.current
518
+
519
+ return (
520
+ <div
521
+ ref={containerRef}
522
+ className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-lg', portal.shouldRender && hero && 'z-[16]')}
523
+ // Open on click even when the field is already focused — onFocus alone
524
+ // never fires again, so an autofocused hero (Home) wouldn't reveal the
525
+ // launcher on a click.
526
+ onMouseDown={() => setOpen(true)}
527
+ >
528
+ <SearchPillInput
529
+ className={hero
530
+ ? `min-h-14 px-5 rounded-2xl bg-theme-surface border border-theme-border shadow-theme-sm transition-[color,background-color,border-color,box-shadow] duration-[140ms] ${TW_EASE_UI} focus-within:border-[var(--color-brand-500)] focus-within:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-brand-500)_15%,transparent)]`
531
+ : `min-h-8 px-2.5 rounded-md bg-theme-elevated border border-transparent focus-within:border-theme-border focus-within:bg-theme-surface transition-[color,background-color,border-color] duration-[140ms] ${TW_EASE_UI}`}
532
+ inputClassName={hero ? 'text-lg py-4' : undefined}
533
+ text={text}
534
+ pills={pills}
535
+ onChange={({ text: t, pills: p }) => { setText(t); setPills(p); setOpen(true) }}
536
+ onKeyDown={handleKeyDown}
537
+ onFocus={() => { if (skipFocusOpen.current) { skipFocusOpen.current = false; return } setOpen(true) }}
538
+ onSuggestingChange={setSuggesting}
539
+ modifierOptions={modifierOptions}
540
+ placeholder={placeholder}
541
+ aria-label="Search resources and commands"
542
+ inputRef={inputRef}
543
+ leftSlot={<Search className={hero ? 'w-5 h-5 shrink-0 text-theme-text-tertiary' : 'w-3.5 h-3.5 shrink-0 text-theme-text-tertiary'} />}
544
+ rightSlot={
545
+ <div className="flex items-center gap-1.5 shrink-0">
546
+ <SearchSyntaxHelp />
547
+ {!hero && !text && pills.length === 0 && (
548
+ <kbd className="text-[10px] text-theme-text-tertiary bg-theme-surface px-1 py-0.5 rounded border border-theme-border-light">
549
+ {mac ? '⌘' : 'Ctrl+'}K
550
+ </kbd>
551
+ )}
552
+ </div>
553
+ }
554
+ />
555
+
556
+ {portal.shouldRender && anchor && createPortal(
557
+ <>
558
+ {/* Scrim — separates the dropdown from the page, consistently in both
559
+ modes. At z-[15] it sits BELOW the rail/top bar (z-20/30), so the
560
+ nav chrome stays lit while the content behind the panel dims+blurs:
561
+ a "spotlight on search", not a full-screen modal dim (which fits a
562
+ centered command palette, not an anchored omnibar). The hero covers
563
+ from the top (its box is in the content, lifted to z-[16]); the
564
+ top-bar launcher covers from below the field (its box is already in
565
+ the z-20 chrome). Click closes. */}
566
+ <div
567
+ className={clsx(
568
+ 'fixed left-0 right-0 bottom-0 z-[15] bg-black/15 dark:bg-black/50 backdrop-blur-[3px]',
569
+ TRANSITION_BACKDROP,
570
+ portal.isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none',
571
+ )}
572
+ style={{ top: hero ? 0 : anchor.top, ...overlayTransitionStyle(portal.isOpen, 'menu') }}
573
+ onClick={open ? () => { setOpen(false); inputRef.current?.blur() } : undefined}
574
+ />
575
+ {showPanel && (
576
+ <div
577
+ ref={panelRef}
578
+ inert={!open || undefined}
579
+ style={{ position: 'fixed', top: anchor.top + 8, left: anchor.centerX, transform: 'translateX(-50%)', width: hero ? Math.round(anchor.width) : 640, maxWidth: 'calc(100vw - 2rem)', ...overlayTransitionStyle(portal.isOpen, 'menu') }}
580
+ className={clsx(
581
+ 'z-[121] dialog shadow-theme-lg ring-1 ring-black/5 dark:ring-white/10 overflow-hidden origin-top',
582
+ TRANSITION_MENU,
583
+ portal.isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-1 scale-[0.97]',
584
+ !open && 'pointer-events-none',
585
+ )}
586
+ >
587
+ {shownPanel}
552
588
  </div>
553
589
  )}
554
590
  </>,
@@ -182,8 +182,11 @@ export function ShortcutHelpOverlay({ onClose, currentView, isOpen = true }: Sho
182
182
  const shortcuts = useActiveShortcuts()
183
183
  const overlayRef = useRef<HTMLDivElement>(null)
184
184
 
185
- // Close on Escape
185
+ // Close on Escape — only while logically open: the overlay stays mounted
186
+ // through its exit and must not keep swallowing Escape (or blocking
187
+ // clicks) while it fades.
186
188
  useEffect(() => {
189
+ if (!isOpen) return
187
190
  const handler = (e: KeyboardEvent) => {
188
191
  if (e.key === 'Escape') {
189
192
  e.preventDefault()
@@ -194,7 +197,7 @@ export function ShortcutHelpOverlay({ onClose, currentView, isOpen = true }: Sho
194
197
  // Use capture to intercept before the shortcut system
195
198
  document.addEventListener('keydown', handler, true)
196
199
  return () => document.removeEventListener('keydown', handler, true)
197
- }, [onClose])
200
+ }, [isOpen, onClose])
198
201
 
199
202
  // Group shortcuts by category, merging duplicates with same description
200
203
  // (e.g., "Zoom in" registered for both + and = shows as one row)
@@ -224,16 +227,16 @@ export function ShortcutHelpOverlay({ onClose, currentView, isOpen = true }: Sho
224
227
  const isEmpty = globalCategories.length === 0 && !hasViewSection && !hasContextSection
225
228
 
226
229
  return (
227
- <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
230
+ <div className="fixed inset-0 z-[100] flex items-center justify-center p-4" inert={!isOpen || undefined}>
228
231
  {/* Backdrop */}
229
232
  <div
230
233
  className={clsx(
231
234
  'absolute inset-0 bg-theme-base/70 backdrop-blur-sm',
232
235
  TRANSITION_BACKDROP,
233
- isOpen ? 'opacity-100' : 'opacity-0'
236
+ isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
234
237
  )}
235
238
  style={overlayTransitionStyle(isOpen, 'dialog')}
236
- onClick={onClose}
239
+ onClick={isOpen ? onClose : undefined}
237
240
  />
238
241
 
239
242
  {/* Panel */}
@@ -742,9 +742,15 @@ export function WorkloadView({
742
742
  [helmOwner, helmSourceResource],
743
743
  )
744
744
 
745
- // Fetch topology for hierarchy building (only when expanded)
745
+ // Fetch topology for hierarchy building (only when expanded). Polled like
746
+ // useTrace's "drawer feeling live" pattern — without this, a resource
747
+ // whose status/labels/edges change without its node/edge identity changing
748
+ // (a canary weight step, a Pod's traffic role flipping to stable, an
749
+ // AnalysisRun finishing) never refreshes until something else forces a
750
+ // remount; a genuinely stuck-looking Topology tab, not just a stale one.
746
751
  const { data: topology } = useTopology([namespace], 'resources', {
747
752
  enabled: expanded,
753
+ refetchInterval: expanded ? 5000 : false,
748
754
  })
749
755
 
750
756
  // Always fetched so Recent Events populates on drawer open; allEvents below is