@skyhook-io/radar-app 1.8.10 → 1.8.12

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.10",
3
+ "version": "1.8.12",
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
+ })
@@ -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')
@@ -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
- }