@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.11",
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": "^0.55.1",
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.11",
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 { AppHistory, AppRow, ArgoSyncOpts } from '@skyhook-io/k8s-ui'
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.json().catch(() => ({ error: 'Unknown error' }))
3012
- throw new Error(error.error || `HTTP ${response.status}`)
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
- onSuccess: () => {
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
+ })
@@ -10,6 +10,7 @@ export interface AgentInfo {
10
10
  version: string;
11
11
  present: boolean;
12
12
  supported: boolean;
13
+ hosted?: boolean;
13
14
  }
14
15
 
15
16
  export interface AgentsResponse {
@@ -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({ onCleared }: { onCleared: () => void }) {
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
- Investigation transcripts are kept on this machine (
40
- <code className="font-mono">~/.radar</code>) so history survives
41
- restarts.
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
- <AgentControls
102
- agents={agents}
103
- selectedAgent={draft.agent}
104
- // Model + effort are agent-specific; reset them when the agent changes.
105
- onSelectAgent={(a) => onChange({ agent: a, model: "", effort: "" })}
106
- isolated={draft.isolated}
107
- onSetIsolated={(v) => onChange({ isolated: v })}
108
- model={draft.model}
109
- onSetModel={(v) => onChange({ model: v })}
110
- effort={draft.effort}
111
- onSetEffort={(v) => onChange({ effort: v })}
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&apos;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
- const { refreshRuns, openInvestigation, startError } = useDiagnose();
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
- const canApply = i === lastRemediationIdx && !stale;
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 ? requestApply : undefined
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. A host like Radar Hub
9
- // overrides this slot with its own action.
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: this runs the user's OWN agent, locally.
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
- : problem
35
- ? `Diagnose with your own ${d.agentLabel} — runs locally, reads this resource's spec, events & logs to find the root cause.`
36
- : `Ask your own ${d.agentLabel} about this resource runs locally, reads its spec, events & logs.`;
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={`Runs ${d.agentLabel} on your machine and sends it this resource's context to find the root cause`}
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 — runs your own ${d.agentLabel} locally`
127
- : `AI investigations — runs your own ${d.agentLabel} locally`
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
- Investigate incidents with an AI agent that runs on your own machine — reading
521
- logs, events, and topology to explain what's wrong. No Radar cloud, no API key.
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
- <div className="flex items-center justify-end gap-3">
537
- {aiSaved && !aiDirty && (
538
- <span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
539
- <Check className="w-3 h-3" />
540
- Saved
541
- </span>
542
- )}
543
- <button
544
- onClick={saveAi}
545
- disabled={!aiDirty}
546
- className="px-4 py-1.5 text-sm font-medium btn-brand rounded-md disabled:opacity-50 disabled:pointer-events-none"
547
- >
548
- Save
549
- </button>
550
- </div>
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: { kind: string; namespace: string; name: string; yaml: string }) => {
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.
@@ -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'
@@ -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
- }