@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.
- package/package.json +2 -2
- package/src/api/client.ts +32 -0
- package/src/api/diagnose.ts +7 -1
- package/src/components/CloudConnectFlow.tsx +21 -7
- package/src/components/CloudFunnelButton.tsx +85 -8
- package/src/components/cloudConnectHandoff.test.ts +10 -1
- package/src/components/cloudConnectHandoff.ts +15 -1
- package/src/components/diagnose/AIMarkdown.tsx +87 -0
- package/src/components/diagnose/ActivityTurn.tsx +1244 -0
- package/src/components/diagnose/AgentControls.tsx +603 -0
- package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
- package/src/components/diagnose/AnalysisStory.tsx +13 -18
- package/src/components/diagnose/ApplyDialog.tsx +289 -0
- package/src/components/diagnose/AssessmentCard.tsx +1579 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
- package/src/components/diagnose/EvidenceCard.tsx +611 -0
- package/src/components/diagnose/Home.test.tsx +1 -3
- package/src/components/diagnose/Home.tsx +2 -1
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
- package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
- package/src/components/diagnose/InvestigationView.tsx +39 -51
- package/src/components/diagnose/StoryExcerpt.tsx +118 -0
- package/src/components/diagnose/assessmentCopy.ts +169 -0
- package/src/components/diagnose/investigationCase.test.tsx +658 -2
- package/src/components/diagnose/investigationCase.ts +135 -26
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +682 -0
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +8 -2
- package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +1 -1
- package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +136 -0
- package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
- package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
- package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
- package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
- package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
- package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
- package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
- package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
- package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
- package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
- package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
- package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
- package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
- package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
- package/src/components/diagnose/investigationEvidence/index.ts +1 -1
- package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +13 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
- package/src/components/diagnose/investigationEvidence/types.ts +97 -2
- package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
- package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
- package/src/components/diagnose/investigationState.test.ts +2 -2
- package/src/components/diagnose/investigationState.ts +2 -2
- package/src/components/diagnose/investigationStory.ts +1 -1
- package/src/components/diagnose/parts.test.tsx +15 -1
- package/src/components/diagnose/parts.tsx +26 -3904
- package/src/components/diagnose/target.ts +9 -3
- package/src/components/diagnose/toolCallLabel.test.ts +47 -1
- package/src/components/diagnose/toolCallLabel.ts +41 -2
- package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +27 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
- package/src/components/resources/PodFilePreview.tsx +1 -1
- package/src/components/rightsizing/RightsizingScanView.tsx +20 -1
- package/src/components/ui/CommandPalette.tsx +12 -8
- package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
- package/src/components/ui/Omnibar.tsx +96 -60
- package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
- package/src/components/workload/WorkloadView.tsx +7 -1
- 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 {
|
|
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 =
|
|
35
|
-
|
|
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 '
|
|
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 '
|
|
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 "
|
|
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 '
|
|
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 '
|
|
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 '
|
|
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.
|
|
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)
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|