@skyhook-io/radar-app 1.8.11 → 1.8.13
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 +4 -5
- package/src/api/client.ts +172 -4
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +1 -0
- package/src/components/diagnose/AISettings.tsx +47 -16
- package/src/components/diagnose/DiagnoseContext.tsx +3 -0
- package/src/components/diagnose/InvestigationView.tsx +8 -3
- package/src/components/diagnose/LocalDiagnoseAction.tsx +21 -9
- package/src/components/helm/InstallWizard.tsx +1 -1
- package/src/components/helm/ValuesViewer.tsx +1 -1
- package/src/components/settings/SettingsDialog.tsx +22 -17
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/workload/WorkloadView.tsx +15 -1
- package/src/main.tsx +0 -6
- package/src/monaco-deep.d.ts +0 -8
- package/src/monaco-setup.ts +0 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.13",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"@fontsource/dm-mono": "^5.2.7",
|
|
34
34
|
"@monaco-editor/react": "^4.7.0",
|
|
35
35
|
"diff": "^9.0.0",
|
|
36
|
-
"monaco-editor": "
|
|
36
|
+
"monaco-editor": "0.55.1",
|
|
37
37
|
"react-markdown": "^10.1.0",
|
|
38
38
|
"react-virtuoso": "^4.18.11",
|
|
39
39
|
"remark-gfm": "^4.0.1",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"yaml": "^2.9.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@skyhook-io/k8s-ui": ">=1.8.
|
|
44
|
+
"@skyhook-io/k8s-ui": ">=1.8.15",
|
|
45
45
|
"@tanstack/react-query": ">=5",
|
|
46
46
|
"@xyflow/react": ">=12.0.0",
|
|
47
47
|
"clsx": ">=2",
|
|
@@ -83,7 +83,6 @@
|
|
|
83
83
|
"vite": "^8.1.5"
|
|
84
84
|
},
|
|
85
85
|
"sideEffects": [
|
|
86
|
-
"*.css"
|
|
87
|
-
"./src/monaco-setup.ts"
|
|
86
|
+
"*.css"
|
|
88
87
|
]
|
|
89
88
|
}
|
package/src/api/client.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react'
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
AppHistory,
|
|
4
|
+
AppRow,
|
|
5
|
+
ArgoSyncOpts,
|
|
6
|
+
YamlDocumentIdentity,
|
|
7
|
+
YamlSchemaLoadResult,
|
|
8
|
+
} from '@skyhook-io/k8s-ui'
|
|
3
9
|
import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
|
|
4
10
|
import { showApiError, showApiSuccess } from '../components/ui/Toast'
|
|
5
11
|
import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
|
|
@@ -438,6 +444,8 @@ export interface IssuesResponse {
|
|
|
438
444
|
total_matched?: number
|
|
439
445
|
recent_changes?: IssueRecentChange[]
|
|
440
446
|
recent_changes_reason?: string
|
|
447
|
+
recent_changes_guidance?: string
|
|
448
|
+
recent_changes_truncated?: boolean
|
|
441
449
|
// Present only when RBAC visibility is incomplete (absent = full access).
|
|
442
450
|
// state 'degraded' means core workload reads are denied, so an empty list may
|
|
443
451
|
// mean "can't see" rather than "nothing broken" — the UI must say so.
|
|
@@ -2627,12 +2635,16 @@ export function useUpdateResource() {
|
|
|
2627
2635
|
name,
|
|
2628
2636
|
yaml,
|
|
2629
2637
|
force = true,
|
|
2638
|
+
reviewedResourceVersion,
|
|
2639
|
+
reviewedContext,
|
|
2630
2640
|
}: {
|
|
2631
2641
|
kind: string
|
|
2632
2642
|
namespace: string
|
|
2633
2643
|
name: string
|
|
2634
2644
|
yaml: string
|
|
2635
2645
|
force?: boolean
|
|
2646
|
+
reviewedResourceVersion?: string
|
|
2647
|
+
reviewedContext?: string
|
|
2636
2648
|
}) => {
|
|
2637
2649
|
const url = new URL(
|
|
2638
2650
|
`${getApiBase()}/resources/${kind}/${namespace}/${name}`,
|
|
@@ -2641,6 +2653,12 @@ export function useUpdateResource() {
|
|
|
2641
2653
|
if (!force) {
|
|
2642
2654
|
url.searchParams.set('force', 'false')
|
|
2643
2655
|
}
|
|
2656
|
+
if (reviewedResourceVersion) {
|
|
2657
|
+
url.searchParams.set('resourceVersion', reviewedResourceVersion)
|
|
2658
|
+
}
|
|
2659
|
+
if (reviewedContext) {
|
|
2660
|
+
url.searchParams.set('reviewedContext', reviewedContext)
|
|
2661
|
+
}
|
|
2644
2662
|
const response = await apiFetch(url.toString(), {
|
|
2645
2663
|
method: 'PUT',
|
|
2646
2664
|
headers: { 'Content-Type': 'text/plain' },
|
|
@@ -2979,6 +2997,144 @@ export interface ApplyResourceResult {
|
|
|
2979
2997
|
created: boolean
|
|
2980
2998
|
}
|
|
2981
2999
|
|
|
3000
|
+
interface ApplyResourceErrorResponse {
|
|
3001
|
+
error?: string
|
|
3002
|
+
results?: ApplyResourceResult[]
|
|
3003
|
+
failedIndex?: number
|
|
3004
|
+
total?: number
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
export class ApplyResourceError extends Error {
|
|
3008
|
+
readonly appliedResults: ApplyResourceResult[]
|
|
3009
|
+
readonly failedIndex?: number
|
|
3010
|
+
readonly total?: number
|
|
3011
|
+
|
|
3012
|
+
constructor(payload: ApplyResourceErrorResponse, status: number) {
|
|
3013
|
+
super(formatApplyResourceError(payload, status))
|
|
3014
|
+
this.name = 'ApplyResourceError'
|
|
3015
|
+
this.appliedResults = payload.results ?? []
|
|
3016
|
+
this.failedIndex = payload.failedIndex
|
|
3017
|
+
this.total = payload.total
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
export function formatApplyResourceError(
|
|
3022
|
+
payload: ApplyResourceErrorResponse,
|
|
3023
|
+
status: number,
|
|
3024
|
+
): string {
|
|
3025
|
+
const message = payload.error || `HTTP ${status}`
|
|
3026
|
+
const applied = payload.results ?? []
|
|
3027
|
+
if (applied.length === 0 || payload.failedIndex === undefined) return message
|
|
3028
|
+
|
|
3029
|
+
const total = payload.total ?? applied.length + 1
|
|
3030
|
+
const appliedLabel = applied.length === 1 ? 'resource was' : 'resources were'
|
|
3031
|
+
const names = applied
|
|
3032
|
+
.slice(0, 3)
|
|
3033
|
+
.map(({ kind, namespace, name }) => `${kind} ${namespace ? `${namespace}/` : ''}${name}`)
|
|
3034
|
+
.join(', ')
|
|
3035
|
+
const more = applied.length > 3 ? ` and ${applied.length - 3} more` : ''
|
|
3036
|
+
const cause = message.replace(/^document \d+:\s*/i, '')
|
|
3037
|
+
return `${applied.length} of ${total} ${appliedLabel} applied before document ${payload.failedIndex + 1} failed. Applied: ${names}${more}. ${cause}`
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
interface YamlSchemaResponse {
|
|
3041
|
+
documents: Array<{
|
|
3042
|
+
index: number
|
|
3043
|
+
status: 'available' | 'unavailable'
|
|
3044
|
+
bundleKey?: string
|
|
3045
|
+
schemaRef?: string
|
|
3046
|
+
error?: string
|
|
3047
|
+
}>
|
|
3048
|
+
bundles: Record<string, { definitions: Record<string, unknown> }>
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
export async function fetchYamlSchemas(
|
|
3052
|
+
documents: YamlDocumentIdentity[],
|
|
3053
|
+
): Promise<YamlSchemaLoadResult> {
|
|
3054
|
+
const response = await apiFetch(`${getApiBase()}/resources/schemas`, {
|
|
3055
|
+
method: 'POST',
|
|
3056
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3057
|
+
body: JSON.stringify({
|
|
3058
|
+
documents: documents.map(({ index, apiVersion, kind }) => ({
|
|
3059
|
+
index,
|
|
3060
|
+
apiVersion,
|
|
3061
|
+
kind,
|
|
3062
|
+
})),
|
|
3063
|
+
}),
|
|
3064
|
+
})
|
|
3065
|
+
if (!response.ok) {
|
|
3066
|
+
const error = await response.json().catch(() => ({ error: 'Cluster schemas are unavailable' }))
|
|
3067
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
3068
|
+
}
|
|
3069
|
+
const result = (await response.json()) as YamlSchemaResponse
|
|
3070
|
+
const schemas: Array<Record<string, unknown> | null> = documents.map(() => null)
|
|
3071
|
+
const unavailable: Array<{ index: number; reason: string }> = []
|
|
3072
|
+
for (const document of result.documents) {
|
|
3073
|
+
const position = documents.findIndex(({ index }) => index === document.index)
|
|
3074
|
+
if (position < 0) continue
|
|
3075
|
+
const bundle = document.bundleKey ? result.bundles[document.bundleKey] : undefined
|
|
3076
|
+
if (document.status === 'available' && document.schemaRef && bundle) {
|
|
3077
|
+
schemas[position] = {
|
|
3078
|
+
$ref: document.schemaRef,
|
|
3079
|
+
definitions: bundle.definitions,
|
|
3080
|
+
}
|
|
3081
|
+
} else {
|
|
3082
|
+
unavailable.push({
|
|
3083
|
+
index: document.index,
|
|
3084
|
+
reason: document.error || 'Schema unavailable',
|
|
3085
|
+
})
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
return { schemas, unavailable }
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
export interface YamlPreviewDocument {
|
|
3092
|
+
index: number
|
|
3093
|
+
status: 'accepted' | 'rejected' | 'unavailable'
|
|
3094
|
+
apiVersion?: string
|
|
3095
|
+
kind?: string
|
|
3096
|
+
namespace?: string
|
|
3097
|
+
name?: string
|
|
3098
|
+
action?: 'create' | 'update' | 'unknown'
|
|
3099
|
+
submittedYaml?: string
|
|
3100
|
+
baselineYaml?: string
|
|
3101
|
+
predictedYaml?: string
|
|
3102
|
+
warnings?: string[]
|
|
3103
|
+
error?: string
|
|
3104
|
+
reviewedResourceVersion?: string
|
|
3105
|
+
redacted?: boolean
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
export interface YamlPreviewResponse {
|
|
3109
|
+
documents: YamlPreviewDocument[]
|
|
3110
|
+
nonAtomic: boolean
|
|
3111
|
+
context: string
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
export interface YamlPreviewRequest {
|
|
3115
|
+
yaml: string
|
|
3116
|
+
mode: 'apply' | 'create' | 'update'
|
|
3117
|
+
force: boolean
|
|
3118
|
+
target?: { kind: string; namespace: string; name: string }
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
export function usePreviewResources() {
|
|
3122
|
+
return useMutation({
|
|
3123
|
+
mutationFn: async (request: YamlPreviewRequest) => {
|
|
3124
|
+
const response = await apiFetch(`${getApiBase()}/resources/preview`, {
|
|
3125
|
+
method: 'POST',
|
|
3126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3127
|
+
body: JSON.stringify(request),
|
|
3128
|
+
})
|
|
3129
|
+
if (!response.ok) {
|
|
3130
|
+
const error = await response.json().catch(() => ({ error: 'Preview failed' }))
|
|
3131
|
+
throw new Error(error.error || `HTTP ${response.status}`)
|
|
3132
|
+
}
|
|
3133
|
+
return response.json() as Promise<YamlPreviewResponse>
|
|
3134
|
+
},
|
|
3135
|
+
})
|
|
3136
|
+
}
|
|
3137
|
+
|
|
2982
3138
|
export function useApplyResource() {
|
|
2983
3139
|
const queryClient = useQueryClient()
|
|
2984
3140
|
|
|
@@ -2988,11 +3144,15 @@ export function useApplyResource() {
|
|
|
2988
3144
|
mode = 'apply',
|
|
2989
3145
|
dryRun = false,
|
|
2990
3146
|
force = false,
|
|
3147
|
+
reviewedResourceVersions,
|
|
3148
|
+
reviewedContext,
|
|
2991
3149
|
}: {
|
|
2992
3150
|
yaml: string
|
|
2993
3151
|
mode?: 'apply' | 'create'
|
|
2994
3152
|
dryRun?: boolean
|
|
2995
3153
|
force?: boolean
|
|
3154
|
+
reviewedResourceVersions?: Record<number, string>
|
|
3155
|
+
reviewedContext?: string
|
|
2996
3156
|
}) => {
|
|
2997
3157
|
const url = new URL(`${getApiBase()}/resources/apply`, window.location.origin)
|
|
2998
3158
|
url.searchParams.set('mode', mode)
|
|
@@ -3002,20 +3162,28 @@ export function useApplyResource() {
|
|
|
3002
3162
|
if (force) {
|
|
3003
3163
|
url.searchParams.set('force', 'true')
|
|
3004
3164
|
}
|
|
3165
|
+
if (reviewedResourceVersions && Object.keys(reviewedResourceVersions).length > 0) {
|
|
3166
|
+
url.searchParams.set('reviewedVersions', JSON.stringify(reviewedResourceVersions))
|
|
3167
|
+
}
|
|
3168
|
+
if (reviewedContext) {
|
|
3169
|
+
url.searchParams.set('reviewedContext', reviewedContext)
|
|
3170
|
+
}
|
|
3005
3171
|
const response = await apiFetch(url.toString(), {
|
|
3006
3172
|
method: 'POST',
|
|
3007
3173
|
headers: { 'Content-Type': 'text/plain' },
|
|
3008
3174
|
body: yaml,
|
|
3009
3175
|
})
|
|
3010
3176
|
if (!response.ok) {
|
|
3011
|
-
const error = await response
|
|
3012
|
-
|
|
3177
|
+
const error = (await response
|
|
3178
|
+
.json()
|
|
3179
|
+
.catch(() => ({ error: 'Unknown error' }))) as ApplyResourceErrorResponse
|
|
3180
|
+
throw new ApplyResourceError(error, response.status)
|
|
3013
3181
|
}
|
|
3014
3182
|
return response.json() as Promise<ApplyResourceResult[]>
|
|
3015
3183
|
},
|
|
3016
3184
|
// No meta errorMessage/successMessage — the CreateResourceDialog
|
|
3017
3185
|
// handles all feedback inline to avoid duplicate toasts.
|
|
3018
|
-
|
|
3186
|
+
onSettled: () => {
|
|
3019
3187
|
queryClient.invalidateQueries({ queryKey: ['resources'] })
|
|
3020
3188
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
3021
3189
|
},
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { ApplyResourceError, formatApplyResourceError } from './client'
|
|
3
|
+
|
|
4
|
+
describe('formatApplyResourceError', () => {
|
|
5
|
+
it('reports resources persisted before a multi-document failure', () => {
|
|
6
|
+
expect(
|
|
7
|
+
formatApplyResourceError(
|
|
8
|
+
{
|
|
9
|
+
error: 'document 3: admission denied the Service',
|
|
10
|
+
failedIndex: 2,
|
|
11
|
+
total: 4,
|
|
12
|
+
results: [
|
|
13
|
+
{ kind: 'Namespace', namespace: '', name: 'checkout', created: true },
|
|
14
|
+
{ kind: 'Deployment', namespace: 'checkout', name: 'api', created: true },
|
|
15
|
+
],
|
|
16
|
+
},
|
|
17
|
+
422,
|
|
18
|
+
),
|
|
19
|
+
).toBe(
|
|
20
|
+
'2 of 4 resources were applied before document 3 failed. Applied: Namespace checkout, Deployment checkout/api. admission denied the Service',
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('preserves ordinary apply errors', () => {
|
|
25
|
+
expect(formatApplyResourceError({ error: 'field is invalid' }, 422)).toBe('field is invalid')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('preserves structured partial-apply results for recovery', () => {
|
|
29
|
+
const result = { kind: 'Namespace', namespace: '', name: 'checkout', created: true }
|
|
30
|
+
const error = new ApplyResourceError(
|
|
31
|
+
{
|
|
32
|
+
error: 'document 2: admission denied',
|
|
33
|
+
failedIndex: 1,
|
|
34
|
+
total: 3,
|
|
35
|
+
results: [result],
|
|
36
|
+
},
|
|
37
|
+
422,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(error.message).toContain('1 of 3 resource was applied')
|
|
41
|
+
expect(error.appliedResults).toEqual([result])
|
|
42
|
+
expect(error.failedIndex).toBe(1)
|
|
43
|
+
expect(error.total).toBe(3)
|
|
44
|
+
})
|
|
45
|
+
})
|
package/src/api/diagnose.ts
CHANGED
|
@@ -18,7 +18,15 @@ export interface AIDraft {
|
|
|
18
18
|
// ClearHistoryRow is an immediate action (not part of the staged draft): a
|
|
19
19
|
// two-step confirm button that wipes finished investigations from the local
|
|
20
20
|
// history DB. Live investigations survive.
|
|
21
|
-
function ClearHistoryRow({
|
|
21
|
+
function ClearHistoryRow({
|
|
22
|
+
hosted,
|
|
23
|
+
agentLabel,
|
|
24
|
+
onCleared,
|
|
25
|
+
}: {
|
|
26
|
+
hosted: boolean;
|
|
27
|
+
agentLabel: string;
|
|
28
|
+
onCleared: () => void;
|
|
29
|
+
}) {
|
|
22
30
|
const [confirming, setConfirming] = useState(false);
|
|
23
31
|
const [state, setState] = useState<"idle" | "busy" | "done" | "error">(
|
|
24
32
|
"idle",
|
|
@@ -36,9 +44,15 @@ function ClearHistoryRow({ onCleared }: { onCleared: () => void }) {
|
|
|
36
44
|
return (
|
|
37
45
|
<div className="mt-3 flex items-center justify-between gap-2 border-t border-theme-border/60 pt-3">
|
|
38
46
|
<p className="text-[11px] leading-snug text-theme-text-tertiary">
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
{hosted ? (
|
|
48
|
+
`Investigation transcripts are stored by ${agentLabel} so history survives restarts.`
|
|
49
|
+
) : (
|
|
50
|
+
<>
|
|
51
|
+
Investigation transcripts are kept on this machine (
|
|
52
|
+
<code className="font-mono">~/.radar</code>) so history survives
|
|
53
|
+
restarts.
|
|
54
|
+
</>
|
|
55
|
+
)}
|
|
42
56
|
{state === "done" && (
|
|
43
57
|
<span className="ml-1 font-medium text-theme-text-secondary">
|
|
44
58
|
History cleared.
|
|
@@ -85,12 +99,16 @@ function ClearHistoryRow({ onCleared }: { onCleared: () => void }) {
|
|
|
85
99
|
export function AISettingsSection({
|
|
86
100
|
available,
|
|
87
101
|
agents,
|
|
102
|
+
hosted,
|
|
103
|
+
agentLabel,
|
|
88
104
|
draft,
|
|
89
105
|
onChange,
|
|
90
106
|
onHistoryCleared,
|
|
91
107
|
}: {
|
|
92
108
|
available: boolean;
|
|
93
109
|
agents: AgentInfo[];
|
|
110
|
+
hosted: boolean;
|
|
111
|
+
agentLabel: string;
|
|
94
112
|
draft: AIDraft;
|
|
95
113
|
onChange: (patch: Partial<AIDraft>) => void;
|
|
96
114
|
onHistoryCleared: () => void;
|
|
@@ -98,19 +116,32 @@ export function AISettingsSection({
|
|
|
98
116
|
if (!available || agents.length === 0) return null;
|
|
99
117
|
return (
|
|
100
118
|
<>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
{hosted ? (
|
|
120
|
+
// The agent, its model, and how it runs are all fixed by the host — none
|
|
121
|
+
// of the local BYO-agent knobs apply, so there's nothing to configure.
|
|
122
|
+
<p className="text-xs leading-snug text-theme-text-tertiary">
|
|
123
|
+
{agentLabel} manages the model and how it runs — there's nothing to
|
|
124
|
+
configure here.
|
|
125
|
+
</p>
|
|
126
|
+
) : (
|
|
127
|
+
<AgentControls
|
|
128
|
+
agents={agents}
|
|
129
|
+
selectedAgent={draft.agent}
|
|
130
|
+
// Model + effort are agent-specific; reset them when the agent changes.
|
|
131
|
+
onSelectAgent={(a) => onChange({ agent: a, model: "", effort: "" })}
|
|
132
|
+
isolated={draft.isolated}
|
|
133
|
+
onSetIsolated={(v) => onChange({ isolated: v })}
|
|
134
|
+
model={draft.model}
|
|
135
|
+
onSetModel={(v) => onChange({ model: v })}
|
|
136
|
+
effort={draft.effort}
|
|
137
|
+
onSetEffort={(v) => onChange({ effort: v })}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
<ClearHistoryRow
|
|
141
|
+
hosted={hosted}
|
|
142
|
+
agentLabel={agentLabel}
|
|
143
|
+
onCleared={onHistoryCleared}
|
|
112
144
|
/>
|
|
113
|
-
<ClearHistoryRow onCleared={onHistoryCleared} />
|
|
114
145
|
</>
|
|
115
146
|
);
|
|
116
147
|
}
|
|
@@ -34,6 +34,7 @@ export type DiagnoseView = "home" | "investigation";
|
|
|
34
34
|
interface DiagnoseCtx {
|
|
35
35
|
available: boolean; // an agent CLI is present (button/entry gate)
|
|
36
36
|
agentLabel: string; // label of the selected agent, e.g. "Claude Code"
|
|
37
|
+
hosted: boolean; // selected agent runs on the host's backend, not this machine
|
|
37
38
|
agents: AgentInfo[]; // supported agents detected on PATH (for the picker)
|
|
38
39
|
selectedAgent: string; // name of the chosen backend ("claude"/"codex")
|
|
39
40
|
setSelectedAgent: (name: string) => void;
|
|
@@ -262,6 +263,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
262
263
|
selectedAgent,
|
|
263
264
|
agents.find((a) => a.name === selectedAgent)?.label,
|
|
264
265
|
);
|
|
266
|
+
const hosted = !!agents.find((a) => a.name === selectedAgent)?.hosted;
|
|
265
267
|
|
|
266
268
|
useEffect(() => {
|
|
267
269
|
const onResize = () => setViewportW(window.innerWidth);
|
|
@@ -434,6 +436,7 @@ export function DiagnoseProvider({ children }: { children: ReactNode }) {
|
|
|
434
436
|
const value: DiagnoseCtx = {
|
|
435
437
|
available,
|
|
436
438
|
agentLabel,
|
|
439
|
+
hosted,
|
|
437
440
|
agents,
|
|
438
441
|
selectedAgent,
|
|
439
442
|
setSelectedAgent,
|
|
@@ -44,7 +44,9 @@ export function InvestigationView({
|
|
|
44
44
|
maximized: boolean;
|
|
45
45
|
}) {
|
|
46
46
|
const { kind, namespace, name } = run;
|
|
47
|
-
|
|
47
|
+
// Apply is off for hosted agents (read-only server-side). Keyed on the selected
|
|
48
|
+
// agent, which matches run.agent unless a deployment mixes hosted + local agents.
|
|
49
|
+
const { refreshRuns, openInvestigation, startError, hosted } = useDiagnose();
|
|
48
50
|
const retryDiagnosis = useCallback(
|
|
49
51
|
() => openInvestigation({ kind, namespace, name }),
|
|
50
52
|
[openInvestigation, kind, namespace, name],
|
|
@@ -498,7 +500,8 @@ export function InvestigationView({
|
|
|
498
500
|
<RunContextCard run={run} />
|
|
499
501
|
{turns.map((t, i) => {
|
|
500
502
|
const isLast = i === turns.length - 1;
|
|
501
|
-
|
|
503
|
+
// Hosted runners are read-only — the server refuses apply turns.
|
|
504
|
+
const canApply = i === lastRemediationIdx && !stale && !hosted;
|
|
502
505
|
const canCheck = isLast && t.status === "done" && !!t.apply;
|
|
503
506
|
return (
|
|
504
507
|
<TurnView
|
|
@@ -541,7 +544,9 @@ export function InvestigationView({
|
|
|
541
544
|
<ResultCard
|
|
542
545
|
diagnosis={turns[pinnedIdx].diagnosis!}
|
|
543
546
|
onApply={
|
|
544
|
-
pinnedIdx === lastRemediationIdx && !stale
|
|
547
|
+
pinnedIdx === lastRemediationIdx && !stale && !hosted
|
|
548
|
+
? requestApply
|
|
549
|
+
: undefined
|
|
545
550
|
}
|
|
546
551
|
onAsk={!busy && !stale ? askFollowup : undefined}
|
|
547
552
|
reveal="full"
|
|
@@ -5,14 +5,15 @@ import type { RenderDiagnoseAction } from "../../context/DiagnoseCustomization";
|
|
|
5
5
|
|
|
6
6
|
// The per-resource AI entry point. It no longer owns a panel — it just dispatches
|
|
7
7
|
// to the single app-level AI surface (DiagnoseContext), opening a new investigation
|
|
8
|
-
// for this resource. Self-hides when no agent CLI is present.
|
|
9
|
-
//
|
|
8
|
+
// for this resource. Self-hides when no agent CLI is present. Hosts can override
|
|
9
|
+
// this slot with their own action.
|
|
10
10
|
//
|
|
11
11
|
// Adaptive by health: on a resource with a live problem it reads as a prominent
|
|
12
12
|
// "Diagnose" (find the root cause); when the resource is fine or health is unknown
|
|
13
13
|
// it shrinks to a quiet colored-icon affordance ("ask my agent about this") — so it
|
|
14
14
|
// never implies "something is wrong here" on a healthy resource. The tooltip leads
|
|
15
|
-
// with the BYO framing
|
|
15
|
+
// with the BYO framing (the user's OWN agent, locally) — unless the agent is
|
|
16
|
+
// hosted, where those claims would be false.
|
|
16
17
|
function DiagnoseResourceButton({
|
|
17
18
|
kind,
|
|
18
19
|
namespace,
|
|
@@ -31,9 +32,13 @@ function DiagnoseResourceButton({
|
|
|
31
32
|
const running = runningKeys.has(runTargetKey(kind, namespace, name));
|
|
32
33
|
const tooltip = running
|
|
33
34
|
? `${d.agentLabel} is investigating this resource — click to watch it live.`
|
|
34
|
-
:
|
|
35
|
-
?
|
|
36
|
-
|
|
35
|
+
: d.hosted
|
|
36
|
+
? problem
|
|
37
|
+
? `Diagnose with ${d.agentLabel} — reads this resource's spec, events & logs to find the root cause.`
|
|
38
|
+
: `Ask ${d.agentLabel} about this resource — reads its spec, events & logs.`
|
|
39
|
+
: problem
|
|
40
|
+
? `Diagnose with your own ${d.agentLabel} — runs locally, reads this resource's spec, events & logs to find the root cause.`
|
|
41
|
+
: `Ask your own ${d.agentLabel} about this resource — runs locally, reads its spec, events & logs.`;
|
|
37
42
|
// While an investigation is live, the button advertises it (and clicking focuses
|
|
38
43
|
// the existing run rather than starting a new one — openInvestigation dedups).
|
|
39
44
|
const showLabel = problem || running;
|
|
@@ -95,7 +100,11 @@ export function IssueDiagnoseButton({
|
|
|
95
100
|
if (!d.available) return null;
|
|
96
101
|
return (
|
|
97
102
|
<Tooltip
|
|
98
|
-
content={
|
|
103
|
+
content={
|
|
104
|
+
d.hosted
|
|
105
|
+
? `Sends this resource's context to ${d.agentLabel} to find the root cause`
|
|
106
|
+
: `Runs ${d.agentLabel} on your machine and sends it this resource's context to find the root cause`
|
|
107
|
+
}
|
|
99
108
|
position="left"
|
|
100
109
|
>
|
|
101
110
|
<button
|
|
@@ -119,12 +128,15 @@ export function GlobalDiagnoseButton() {
|
|
|
119
128
|
const { runningKeys } = useDiagnoseLayout();
|
|
120
129
|
if (!d.available) return null;
|
|
121
130
|
const runningCount = runningKeys.size;
|
|
131
|
+
const agentSuffix = d.hosted
|
|
132
|
+
? `powered by ${d.agentLabel}`
|
|
133
|
+
: `runs your own ${d.agentLabel} locally`;
|
|
122
134
|
return (
|
|
123
135
|
<Tooltip
|
|
124
136
|
content={
|
|
125
137
|
runningCount > 0
|
|
126
|
-
? `${runningCount} investigation${runningCount > 1 ? "s" : ""} running —
|
|
127
|
-
: `AI investigations —
|
|
138
|
+
? `${runningCount} investigation${runningCount > 1 ? "s" : ""} running — ${agentSuffix}`
|
|
139
|
+
: `AI investigations — ${agentSuffix}`
|
|
128
140
|
}
|
|
129
141
|
position="bottom"
|
|
130
142
|
>
|
|
@@ -28,7 +28,6 @@ function deepMerge(base: Record<string, unknown>, overrides: Record<string, unkn
|
|
|
28
28
|
}
|
|
29
29
|
return result
|
|
30
30
|
}
|
|
31
|
-
|
|
32
31
|
interface InstallWizardProps {
|
|
33
32
|
repo: string
|
|
34
33
|
chartName: string
|
|
@@ -757,6 +756,7 @@ function ValuesStep({ valuesYaml, setValuesYaml, yamlError, setYamlError, chartD
|
|
|
757
756
|
<YamlEditor
|
|
758
757
|
value={valuesYaml}
|
|
759
758
|
onChange={setValuesYaml}
|
|
759
|
+
showProblems={false}
|
|
760
760
|
height="300px"
|
|
761
761
|
onValidate={(isValid, errors) => {
|
|
762
762
|
setYamlError(isValid ? null : errors[0] || 'Invalid YAML')
|
|
@@ -25,7 +25,6 @@ interface ValuesViewerProps {
|
|
|
25
25
|
currentRevision?: number
|
|
26
26
|
onApplySuccess?: () => void
|
|
27
27
|
}
|
|
28
|
-
|
|
29
28
|
export function ValuesViewer({
|
|
30
29
|
values,
|
|
31
30
|
isLoading,
|
|
@@ -297,6 +296,7 @@ export function ValuesViewer({
|
|
|
297
296
|
<YamlEditor
|
|
298
297
|
value={editedYaml}
|
|
299
298
|
onChange={setEditedYaml}
|
|
299
|
+
showProblems={false}
|
|
300
300
|
height="calc(100vh - 400px)"
|
|
301
301
|
onValidate={(isValid, errors) => {
|
|
302
302
|
setYamlError(isValid ? null : errors[0] || 'Invalid YAML')
|
|
@@ -517,8 +517,9 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
517
517
|
<div className="mb-4">
|
|
518
518
|
<h3 className="text-base font-semibold text-theme-text-primary">AI diagnose</h3>
|
|
519
519
|
<p className="mt-0.5 text-xs text-theme-text-tertiary">
|
|
520
|
-
|
|
521
|
-
|
|
520
|
+
{diag.hosted
|
|
521
|
+
? `Investigate incidents with ${diag.agentLabel} — reading logs, events, and topology to explain what's wrong.`
|
|
522
|
+
: "Investigate incidents with an AI agent that runs on your own machine — reading logs, events, and topology to explain what's wrong. No Radar cloud, no API key."}
|
|
522
523
|
</p>
|
|
523
524
|
</div>
|
|
524
525
|
{aiAvailable ? (
|
|
@@ -526,6 +527,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
526
527
|
<AISettingsSection
|
|
527
528
|
available={diag.available}
|
|
528
529
|
agents={diag.agents}
|
|
530
|
+
hosted={diag.hosted}
|
|
531
|
+
agentLabel={diag.agentLabel}
|
|
529
532
|
draft={aiDraft}
|
|
530
533
|
onChange={(patch) => {
|
|
531
534
|
setAiDraft((d) => ({ ...d, ...patch }))
|
|
@@ -533,21 +536,23 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
|
|
|
533
536
|
}}
|
|
534
537
|
onHistoryCleared={diag.refreshRuns}
|
|
535
538
|
/>
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
<
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
539
|
+
{!diag.hosted && (
|
|
540
|
+
<div className="flex items-center justify-end gap-3">
|
|
541
|
+
{aiSaved && !aiDirty && (
|
|
542
|
+
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
|
|
543
|
+
<Check className="w-3 h-3" />
|
|
544
|
+
Saved
|
|
545
|
+
</span>
|
|
546
|
+
)}
|
|
547
|
+
<button
|
|
548
|
+
onClick={saveAi}
|
|
549
|
+
disabled={!aiDirty}
|
|
550
|
+
className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
|
|
551
|
+
>
|
|
552
|
+
Save
|
|
553
|
+
</button>
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
551
556
|
</div>
|
|
552
557
|
) : (
|
|
553
558
|
<AIUnavailableNotice />
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { type ComponentProps } from 'react'
|
|
2
2
|
import { CreateResourceDialog as BaseCreateResourceDialog } from '@skyhook-io/k8s-ui'
|
|
3
|
-
import { useApplyResource } from '../../api/client'
|
|
3
|
+
import { fetchYamlSchemas, useApplyResource, usePreviewResources } from '../../api/client'
|
|
4
|
+
import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
|
|
4
5
|
|
|
5
6
|
type BaseProps = ComponentProps<typeof BaseCreateResourceDialog>
|
|
6
7
|
|
|
7
|
-
export function CreateResourceDialog(props: Omit<BaseProps, 'onApply' | 'isApplying'>) {
|
|
8
|
+
export function CreateResourceDialog(props: Omit<BaseProps, 'onApply' | 'isApplying' | 'onPreview' | 'isPreviewing' | 'previewError' | 'schemaLoader'>) {
|
|
8
9
|
const applyResource = useApplyResource()
|
|
10
|
+
const previewResources = usePreviewResources()
|
|
11
|
+
const { features } = useCapabilitiesContext()
|
|
9
12
|
|
|
10
13
|
return (
|
|
11
14
|
<BaseCreateResourceDialog
|
|
12
15
|
{...props}
|
|
13
16
|
onApply={(params) => applyResource.mutateAsync(params)}
|
|
14
17
|
isApplying={applyResource.isPending}
|
|
18
|
+
onPreview={features?.yamlReview ? (params) => previewResources.mutateAsync(params) : undefined}
|
|
19
|
+
isPreviewing={previewResources.isPending}
|
|
20
|
+
previewError={previewResources.error?.message ?? null}
|
|
21
|
+
schemaLoader={features?.yamlSchemas ? fetchYamlSchemas : undefined}
|
|
15
22
|
/>
|
|
16
23
|
)
|
|
17
24
|
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
usePodLogs,
|
|
36
36
|
useTopology,
|
|
37
37
|
useUpdateResource,
|
|
38
|
+
usePreviewResources,
|
|
38
39
|
useDeleteResource,
|
|
39
40
|
useTriggerCronJob,
|
|
40
41
|
useSuspendCronJob,
|
|
@@ -60,6 +61,7 @@ import {
|
|
|
60
61
|
useWorkloadRuns,
|
|
61
62
|
useApplications,
|
|
62
63
|
fetchJSON,
|
|
64
|
+
fetchYamlSchemas,
|
|
63
65
|
} from '../../api/client'
|
|
64
66
|
import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusCharts'
|
|
65
67
|
import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid'
|
|
@@ -79,6 +81,7 @@ import {
|
|
|
79
81
|
useCanNodeWrite,
|
|
80
82
|
useNamespacedCapabilities,
|
|
81
83
|
useIsLocalDeployment,
|
|
84
|
+
useCapabilitiesContext,
|
|
82
85
|
} from '../../contexts/CapabilitiesContext'
|
|
83
86
|
import { useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal } from '../dock'
|
|
84
87
|
import { PortForwardButton, PortForwardInlineButton } from '../portforward/PortForwardButton'
|
|
@@ -651,6 +654,7 @@ export function WorkloadView({
|
|
|
651
654
|
|
|
652
655
|
// RBAC
|
|
653
656
|
const canUpdateSecrets = useCanUpdateSecrets()
|
|
657
|
+
const { features } = useCapabilitiesContext()
|
|
654
658
|
const { canPortForward } = useNamespacedCapabilities(namespace)
|
|
655
659
|
const isLocalDeployment = useIsLocalDeployment()
|
|
656
660
|
const showServingPortForward = canPortForward || !isLocalDeployment
|
|
@@ -724,6 +728,7 @@ export function WorkloadView({
|
|
|
724
728
|
[closeServingCurl, servingCurl],
|
|
725
729
|
)
|
|
726
730
|
const updateResource = useUpdateResource()
|
|
731
|
+
const previewResources = usePreviewResources()
|
|
727
732
|
const baseActionsBarProps = useActionsBarProps(apiKind, namespace, name)
|
|
728
733
|
const desktopDownload = useDesktopDownload()
|
|
729
734
|
|
|
@@ -761,11 +766,16 @@ export function WorkloadView({
|
|
|
761
766
|
)
|
|
762
767
|
|
|
763
768
|
const handleUpdateResource = useCallback(
|
|
764
|
-
async (params:
|
|
769
|
+
async (params: Parameters<typeof updateResource.mutateAsync>[0]) => {
|
|
765
770
|
await updateResource.mutateAsync(params)
|
|
766
771
|
},
|
|
767
772
|
[updateResource],
|
|
768
773
|
)
|
|
774
|
+
const handlePreviewResource = useCallback(
|
|
775
|
+
async (params: Parameters<typeof previewResources.mutateAsync>[0]) =>
|
|
776
|
+
previewResources.mutateAsync(params),
|
|
777
|
+
[previewResources],
|
|
778
|
+
)
|
|
769
779
|
|
|
770
780
|
const navigateRouter = useNavigate()
|
|
771
781
|
const handleOpenGitOpsResource = useCallback(
|
|
@@ -901,6 +911,10 @@ export function WorkloadView({
|
|
|
901
911
|
onUpdateResource={handleUpdateResource}
|
|
902
912
|
isUpdatingResource={updateResource.isPending}
|
|
903
913
|
updateResourceError={updateResource.error?.message ?? null}
|
|
914
|
+
onPreviewResource={features?.yamlReview ? handlePreviewResource : undefined}
|
|
915
|
+
isPreviewingResource={previewResources.isPending}
|
|
916
|
+
previewResourceError={previewResources.error?.message ?? null}
|
|
917
|
+
yamlSchemaLoader={features?.yamlSchemas ? fetchYamlSchemas : undefined}
|
|
904
918
|
// Tab state (URL-synced)
|
|
905
919
|
activeTab={migratedTab}
|
|
906
920
|
onTabChange={handleTabChange}
|
package/src/main.tsx
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import ReactDOM from 'react-dom/client'
|
|
3
|
-
import { configureBundledMonaco } from './monaco-setup'
|
|
4
3
|
import { RadarApp } from './RadarApp'
|
|
5
4
|
import { openExternal } from './utils/navigation'
|
|
6
5
|
import './index.css'
|
|
7
6
|
|
|
8
|
-
// Keep this as an explicit call: side-effect-only imports of the Monaco setup can
|
|
9
|
-
// be dropped by production tree-shaking, which makes offline desktop builds fall
|
|
10
|
-
// back to Monaco's CDN loader.
|
|
11
|
-
configureBundledMonaco()
|
|
12
|
-
|
|
13
7
|
// Intercept external link clicks in the Wails desktop app.
|
|
14
8
|
// <a target="_blank"> is swallowed by WKWebView/WebView2 — route through openExternal()
|
|
15
9
|
// which calls the backend /api/desktop/open-url endpoint to open in the system browser.
|
package/src/monaco-deep.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// monaco-editor's package `exports` map ("./*": "./*") doesn't surface type
|
|
2
|
-
// declarations for deep ESM subpaths, so TS can't resolve these imports even
|
|
3
|
-
// though the .js/.d.ts files exist on disk. Re-export the root types for the
|
|
4
|
-
// editor API and declare the YAML grammar as a side-effect-only module.
|
|
5
|
-
declare module 'monaco-editor/esm/vs/editor/editor.api' {
|
|
6
|
-
export * from 'monaco-editor'
|
|
7
|
-
}
|
|
8
|
-
declare module 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
|
package/src/monaco-setup.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
// Load the Monaco editor from the bundled npm package instead of the default
|
|
2
|
-
// jsdelivr CDN. Without this, @monaco-editor/react fetches the editor at runtime
|
|
3
|
-
// over the network, so the YAML editor never loads in airgapped / offline
|
|
4
|
-
// deployments. Bundling makes the binary fully self-contained.
|
|
5
|
-
//
|
|
6
|
-
// Called from main.tsx (Radar's binary entry) only — library consumers
|
|
7
|
-
// (e.g. Radar Hub) keep the default CDN loader unless they opt in.
|
|
8
|
-
//
|
|
9
|
-
// Import the editor API + YAML grammar directly rather than the `monaco-editor`
|
|
10
|
-
// barrel: the barrel pulls in the JSON/CSS/HTML/TypeScript language services,
|
|
11
|
-
// each of which bundles a heavy web worker (the TS one alone is ~7MB) that Radar
|
|
12
|
-
// never uses — it only ever edits YAML.
|
|
13
|
-
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
|
|
14
|
-
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
|
|
15
|
-
import { loader } from '@monaco-editor/react'
|
|
16
|
-
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
|
17
|
-
|
|
18
|
-
let configured = false
|
|
19
|
-
|
|
20
|
-
export function configureBundledMonaco() {
|
|
21
|
-
if (configured) return
|
|
22
|
-
configured = true
|
|
23
|
-
|
|
24
|
-
// YAML has no dedicated Monaco language worker — the base editor worker covers
|
|
25
|
-
// everything we use, so route every label to it.
|
|
26
|
-
;(globalThis as typeof globalThis & { MonacoEnvironment?: { getWorker(): Worker } }).MonacoEnvironment = {
|
|
27
|
-
getWorker() {
|
|
28
|
-
return new EditorWorker()
|
|
29
|
-
},
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
loader.config({ monaco })
|
|
33
|
-
}
|