@skyhook-io/radar-app 1.3.1 → 1.3.2

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.3.1",
3
+ "version": "1.3.2",
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",
@@ -18,6 +18,7 @@ import {
18
18
  formatGitOpsSourceUrl,
19
19
  getGitOpsResourceStatus,
20
20
  getGitOpsTool,
21
+ isArgoSuspendedByRadar,
21
22
  gitOpsInsightChangeKey,
22
23
  initNavigationMap,
23
24
  kindToPlural,
@@ -34,6 +35,7 @@ import {
34
35
  type GitOpsResourceTree,
35
36
  type GitOpsInsightRef,
36
37
  type GitOpsRow,
38
+ type GitOpsRowAction,
37
39
  type GitOpsTreeFilters,
38
40
  type GitOpsTreeRef,
39
41
  type GitOpsTreePreset,
@@ -102,6 +104,34 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
102
104
  const namespacesParam = namespaces.join(',')
103
105
  const { data: apiResources, isLoading: apiResourcesLoading } = useAPIResources()
104
106
 
107
+ const argoSync = useArgoSync()
108
+ const argoRefresh = useArgoRefresh()
109
+ const argoTerminate = useArgoTerminate()
110
+ const argoSuspend = useArgoSuspend()
111
+ const argoResume = useArgoResume()
112
+ const fluxReconcile = useFluxReconcile()
113
+ const fluxSyncWithSource = useFluxSyncWithSource()
114
+ const fluxSuspend = useFluxSuspend()
115
+ const fluxResume = useFluxResume()
116
+
117
+ const [syncDialogRow, setSyncDialogRow] = useState<GitOpsRow | null>(null)
118
+ const [pendingActions, setPendingActions] = useState<Map<string, Set<GitOpsRowAction>>>(new Map())
119
+
120
+ // Mark an action as in-flight (or done) for a given row. Cloning the
121
+ // outer Map + inner Set keeps the state immutable so React rerenders
122
+ // and the per-item spinner flips at the right moment.
123
+ function markAction(rowId: string, action: GitOpsRowAction, on: boolean) {
124
+ setPendingActions((prev) => {
125
+ const next = new Map(prev)
126
+ const current = new Set(next.get(rowId) ?? [])
127
+ if (on) current.add(action)
128
+ else current.delete(action)
129
+ if (current.size === 0) next.delete(rowId)
130
+ else next.set(rowId, current)
131
+ return next
132
+ })
133
+ }
134
+
105
135
  useEffect(() => {
106
136
  initNavigationMap([...(apiResources ?? []), ...GITOPS_KINDS])
107
137
  }, [apiResources])
@@ -157,23 +187,99 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
157
187
  refetchInterval: 120_000,
158
188
  })
159
189
 
190
+ // Row mutations invalidate granular keys (['resource', …], ['gitops-tree', …])
191
+ // that don't match the table's aggregate gitops-rows-main / counts queries,
192
+ // so refetch those explicitly — otherwise a row keeps showing the pre-action
193
+ // state (e.g. "Suspend" after a successful suspend) until the 120s poll,
194
+ // inviting a duplicate request. Radar serves reads from an informer cache that
195
+ // lags the write by the watch-propagation delay, so refetch once now (covers
196
+ // an already-current cache) and once shortly after to catch the propagated
197
+ // update; refetch() forces a fetch regardless of staleTime.
198
+ const refetchTable = () => {
199
+ rowsQuery.refetch()
200
+ countsQuery.refetch()
201
+ }
202
+ const refetchTableAfterMutation = () => {
203
+ refetchTable()
204
+ window.setTimeout(refetchTable, 1200)
205
+ }
206
+
207
+ const handleRowAction = (row: GitOpsRow, action: GitOpsRowAction) => {
208
+ const { kindName: kind, namespace, name, id } = row
209
+ const settle = { onSuccess: refetchTableAfterMutation, onSettled: () => markAction(id, action, false) }
210
+ markAction(id, action, true)
211
+ switch (action) {
212
+ case 'sync':
213
+ // Argo Sync is the one action that confirms — same dialog the
214
+ // detail page uses. The mutation fires from onConfirm; clear the
215
+ // in-flight flag here since the dialog now owns the lifecycle.
216
+ markAction(id, action, false)
217
+ setSyncDialogRow(row)
218
+ return
219
+ case 'refresh':
220
+ argoRefresh.mutate({ namespace, name, hard: false }, settle)
221
+ return
222
+ case 'hard-refresh':
223
+ argoRefresh.mutate({ namespace, name, hard: true }, settle)
224
+ return
225
+ case 'terminate':
226
+ argoTerminate.mutate({ namespace, name }, settle)
227
+ return
228
+ case 'suspend':
229
+ if (row.tool === 'argo') argoSuspend.mutate({ namespace, name }, settle)
230
+ else fluxSuspend.mutate({ kind, namespace, name }, settle)
231
+ return
232
+ case 'resume':
233
+ if (row.tool === 'argo') argoResume.mutate({ namespace, name }, settle)
234
+ else fluxResume.mutate({ kind, namespace, name }, settle)
235
+ return
236
+ case 'reconcile':
237
+ fluxReconcile.mutate({ kind, namespace, name }, settle)
238
+ return
239
+ case 'sync-with-source':
240
+ fluxSyncWithSource.mutate({ kind, namespace, name }, settle)
241
+ return
242
+ }
243
+ }
244
+
160
245
  return (
161
- <SharedGitOpsTableView
162
- rows={rowsQuery.data ?? []}
163
- loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading}
164
- error={(rowsQuery.error as Error | null) ?? null}
165
- counts={countsQuery.data?.counts ?? {}}
166
- onRefresh={() => rowsQuery.refetch()}
167
- onRowClick={(row) => {
168
- const ns = row.namespace || '_'
169
- const params = new URLSearchParams()
170
- params.set('apiGroup', row.group)
171
- navigate({ pathname: gitOpsDetailPath(row.kindName, ns, row.name), search: params.toString() })
172
- }}
173
- searchHotkey
174
- globalNamespaces={namespaces}
175
- onClearNamespaces={onClearNamespaces}
176
- />
246
+ <>
247
+ <SharedGitOpsTableView
248
+ rows={rowsQuery.data ?? []}
249
+ loading={apiResourcesLoading || countsQuery.isLoading || rowsQuery.isLoading}
250
+ error={(rowsQuery.error as Error | null) ?? null}
251
+ counts={countsQuery.data?.counts ?? {}}
252
+ onRefresh={() => rowsQuery.refetch()}
253
+ onRowClick={(row) => {
254
+ const ns = row.namespace || '_'
255
+ const params = new URLSearchParams()
256
+ params.set('apiGroup', row.group)
257
+ navigate({ pathname: gitOpsDetailPath(row.kindName, ns, row.name), search: params.toString() })
258
+ }}
259
+ onRowAction={handleRowAction}
260
+ pendingRowActions={pendingActions}
261
+ searchHotkey
262
+ globalNamespaces={namespaces}
263
+ onClearNamespaces={onClearNamespaces}
264
+ />
265
+ <SyncOptionsDialog
266
+ open={!!syncDialogRow}
267
+ appLabel={syncDialogRow ? `${syncDialogRow.namespace}/${syncDialogRow.name}` : ''}
268
+ pending={argoSync.isPending}
269
+ onCancel={() => setSyncDialogRow(null)}
270
+ onConfirm={(opts) => {
271
+ if (!syncDialogRow) return
272
+ const { namespace, name } = syncDialogRow
273
+ argoSync.mutate(
274
+ { namespace, name, ...opts },
275
+ // onSettled so the dialog closes on both success and error —
276
+ // otherwise the error toast surfaces behind the still-open
277
+ // modal and the user can't read it.
278
+ { onSuccess: refetchTableAfterMutation, onSettled: () => setSyncDialogRow(null) },
279
+ )
280
+ }}
281
+ />
282
+ </>
177
283
  )
178
284
  }
179
285
 
@@ -219,15 +325,9 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
219
325
  // pre-suspend prune/selfHeal state for restoration on resume. When present,
220
326
  // the app is in a deliberately-paused state (vs. Manual mode, which is a
221
327
  // normal operational choice) and should surface a Suspended chip alongside
222
- // the other status indicators.
223
- const argoSuspendedByRadar =
224
- kind === 'applications' &&
225
- Boolean(
226
- resourceQ.data?.metadata?.annotations?.['radarhq.io/suspended-prune'] ||
227
- resourceQ.data?.metadata?.annotations?.['radarhq.io/suspended-selfheal'] ||
228
- resourceQ.data?.metadata?.annotations?.['skyhook.io/suspended-prune'] ||
229
- resourceQ.data?.metadata?.annotations?.['skyhook.io/suspended-selfheal'],
230
- )
328
+ // the other status indicators. Shared with the fleet table's row normalizer
329
+ // (isArgoSuspendedByRadar) so both surfaces agree on what "suspended" means.
330
+ const argoSuspendedByRadar = kind === 'applications' && isArgoSuspendedByRadar(resourceQ.data)
231
331
  const effectiveSuspended = (status?.suspended ?? false) || argoSuspendedByRadar
232
332
  // Lifecycle gate: when the resource is pending deletion, mutating
233
333
  // actions are futile (the controller is processing finalizers and
@@ -608,7 +708,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
608
708
  onCancel={() => setSyncDialogOpen(false)}
609
709
  onConfirm={(opts) => {
610
710
  argoSync.mutate({ namespace, name, ...opts }, {
611
- onSuccess: () => setSyncDialogOpen(false),
711
+ onSettled: () => setSyncDialogOpen(false),
612
712
  })
613
713
  }}
614
714
  />
@@ -627,7 +727,7 @@ function GitOpsDetailView({ namespaces, onOpenResource }: GitOpsViewProps) {
627
727
  return
628
728
  }
629
729
  argoRollback.mutate({ namespace, name, id, ...opts }, {
630
- onSuccess: () => setRollbackTarget(null),
730
+ onSettled: () => setRollbackTarget(null),
631
731
  })
632
732
  }}
633
733
  />
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useEffect, useRef } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
- import { PaneLoader, useDockReservedHeight } from '@skyhook-io/k8s-ui'
3
+ import { FetchResult, useDockReservedHeight } from '@skyhook-io/k8s-ui'
4
4
  import { startViewTransitionSafe } from '@skyhook-io/k8s-ui/utils/view-transition'
5
5
  import { TRANSITION_DRAWER } from '../../utils/animation'
6
6
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
@@ -60,7 +60,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
60
60
  const canViewSensitive = canAtLeast('member')
61
61
  const helmNamespace = release.storageNamespace || release.namespace
62
62
 
63
- const { data: releaseDetail, isLoading, refetch: refetchRelease } = useHelmRelease(
63
+ const { data: releaseDetail, isLoading, error: releaseError, refetch: refetchRelease } = useHelmRelease(
64
64
  helmNamespace,
65
65
  release.name
66
66
  )
@@ -446,10 +446,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
446
446
 
447
447
  {/* Content */}
448
448
  <div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'helm-drawer-content' }}>
449
- {isLoading ? (
450
- <PaneLoader className="h-32" />
451
- ) : !releaseDetail ? (
452
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Release not found</div>
449
+ {!releaseDetail ? (
450
+ <FetchResult loading={isLoading} error={releaseError} notFoundMessage="Release not found" className="h-32" />
453
451
  ) : (
454
452
  <>
455
453
  {activeTab === 'overview' && (
@@ -41,21 +41,23 @@ export function GitOpsControllersCard({ data, onNavigate }: GitOpsControllersCar
41
41
  <button
42
42
  type="button"
43
43
  onClick={onNavigate}
44
- className="flex flex-col gap-3 rounded-xl bg-theme-surface p-4 text-left shadow-theme-sm transition-colors hover:bg-theme-hover"
44
+ className="group h-[260px] rounded-xl bg-theme-surface shadow-theme-sm hover:-translate-y-1 hover:shadow-theme-md transition-all duration-200 text-left animate-fade-in-up"
45
45
  >
46
- <div className="flex items-center justify-between">
47
- <div className="flex items-center gap-2">
48
- <GitBranch className="h-4 w-4 text-theme-text-tertiary" />
49
- <span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">
50
- GitOps Controllers
51
- </span>
46
+ <div className="flex flex-col h-full w-full">
47
+ <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
48
+ <div className="flex items-center gap-2">
49
+ <GitBranch className={clsx('h-4 w-4', headerTone)} />
50
+ <span className={clsx('text-xs font-semibold uppercase tracking-wider', headerTone)}>
51
+ GitOps Controllers
52
+ </span>
53
+ </div>
54
+ <span className={clsx('text-[11px] font-medium', headerTone)}>{headerLabel}</span>
52
55
  </div>
53
- <span className={clsx('text-[11px] font-medium', headerTone)}>{headerLabel}</span>
54
- </div>
55
56
 
56
- <div className="flex flex-col gap-2">
57
- {argo.length > 0 && <ControllerSection label="Argo CD" controllers={argo} />}
58
- {flux.length > 0 && <ControllerSection label="Flux CD" controllers={flux} />}
57
+ <div className="flex-1 min-h-0 overflow-y-auto px-5 py-3 flex flex-col gap-3">
58
+ {argo.length > 0 && <ControllerSection label="Argo CD" controllers={argo} />}
59
+ {flux.length > 0 && <ControllerSection label="Flux CD" controllers={flux} />}
60
+ </div>
59
61
  </div>
60
62
  </button>
61
63
  )
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, type ReactNode } from 'react'
2
2
  import { useDashboard, useDashboardCRDs, useDashboardHelm } from '../../api/client'
3
3
  import type { DashboardResponse } from '../../api/client'
4
4
  import type { ExtendedMainView, Topology, SelectedResource } from '../../types'
@@ -113,71 +113,81 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
113
113
  )}>
114
114
  {/* Left column: teaser cards */}
115
115
  <div className="flex flex-col gap-6 auto-rows-min">
116
- {/* Primary cards — 2-col grid */}
116
+ {/* Live bandTopology + Timeline always render, so a fixed 2-up never strands.
117
+ These are the richest visuals and the most-used live views, so they get the width. */}
117
118
  <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
118
119
  <TopologyPreview
119
120
  topology={scopedTopology}
120
121
  summary={data.topologySummary}
121
122
  onNavigate={() => onNavigateToView('topology')}
122
123
  />
123
- <HelmSummary
124
- data={helmData}
125
- onNavigate={() => onNavigateToView('helm')}
126
- />
127
124
  <ActivitySummary
128
125
  namespaces={namespaces}
129
126
  topology={scopedTopology}
130
127
  onNavigate={() => onNavigateToView('timeline')}
131
128
  />
132
- <TrafficSummary
133
- data={data.trafficSummary}
134
- onNavigate={() => onNavigateToView('traffic')}
135
- />
136
- <CostCard onNavigate={() => onNavigateToView('cost')} />
137
129
  </div>
138
130
 
139
- {/* Health & compliance cards 3-col when enough cards, 2-col fallback */}
140
- {(data.certificateHealth || data.networkPolicyCoverage || data.audit || data.gitopsControllers) && (() => {
141
- const healthCards = [
142
- data.certificateHealth && (
143
- <CertificateHealthCard
144
- key="certs"
145
- data={data.certificateHealth}
146
- onNavigate={() => onNavigateToResourceKind('secrets', undefined, { type: ['TLS'] })}
147
- />
148
- ),
149
- data.networkPolicyCoverage && (
150
- <NetworkPolicyCoverageCard
151
- key="netpol"
152
- data={data.networkPolicyCoverage}
153
- onNavigate={() => onNavigateToResourceKind('networkpolicies', 'networking.k8s.io')}
154
- />
155
- ),
156
- data.gitopsControllers && (
157
- <GitOpsControllersCard
158
- key="gitops-controllers"
159
- data={data.gitopsControllers}
160
- onNavigate={() => onNavigateToView('gitops')}
161
- />
162
- ),
163
- data.audit && (
164
- <AuditCard
165
- key="audit"
166
- data={data.audit}
167
- onNavigate={() => onNavigateToView('audit')}
168
- />
169
- ),
170
- ].filter(Boolean)
171
-
172
- return (
173
- <div className={clsx(
174
- 'grid gap-6',
175
- healthCards.length >= 3 ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1 sm:grid-cols-2'
176
- )}>
177
- {healthCards}
178
- </div>
179
- )
180
- })()}
131
+ {/* Explore bandflex-grow wrap so the row always fills. The conditional
132
+ Cost card self-hides via BandItem's empty:hidden when OpenCost is absent,
133
+ leaving Traffic + Helm to stretch rather than stranding an empty cell. */}
134
+ <div className="flex flex-wrap gap-6">
135
+ <BandItem>
136
+ <TrafficSummary
137
+ data={data.trafficSummary}
138
+ onNavigate={() => onNavigateToView('traffic')}
139
+ />
140
+ </BandItem>
141
+ <BandItem>
142
+ <HelmSummary
143
+ data={helmData}
144
+ onNavigate={() => onNavigateToView('helm')}
145
+ />
146
+ </BandItem>
147
+ <BandItem>
148
+ <CostCard onNavigate={() => onNavigateToView('cost')} />
149
+ </BandItem>
150
+ </div>
151
+
152
+ {/* Posture band — same flex-grow wrap so any subset of compliance cards
153
+ fills its row instead of stranding the last one (the old 3-col grid
154
+ left Cluster Audit alone with two empty cells beside it). */}
155
+ {(data.certificateHealth || data.networkPolicyCoverage || data.audit || data.gitopsControllers) && (
156
+ <div className="flex flex-wrap gap-6">
157
+ {data.certificateHealth && (
158
+ <BandItem>
159
+ <CertificateHealthCard
160
+ data={data.certificateHealth}
161
+ onNavigate={() => onNavigateToResourceKind('secrets', undefined, { type: ['TLS'] })}
162
+ />
163
+ </BandItem>
164
+ )}
165
+ {data.networkPolicyCoverage && (
166
+ <BandItem>
167
+ <NetworkPolicyCoverageCard
168
+ data={data.networkPolicyCoverage}
169
+ onNavigate={() => onNavigateToResourceKind('networkpolicies', 'networking.k8s.io')}
170
+ />
171
+ </BandItem>
172
+ )}
173
+ {data.gitopsControllers && (
174
+ <BandItem>
175
+ <GitOpsControllersCard
176
+ data={data.gitopsControllers}
177
+ onNavigate={() => onNavigateToView('gitops')}
178
+ />
179
+ </BandItem>
180
+ )}
181
+ {data.audit && (
182
+ <BandItem>
183
+ <AuditCard
184
+ data={data.audit}
185
+ onNavigate={() => onNavigateToView('audit')}
186
+ />
187
+ </BandItem>
188
+ )}
189
+ </div>
190
+ )}
181
191
  </div>
182
192
 
183
193
  {/* Right column: problems panel */}
@@ -193,6 +203,13 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
193
203
  )
194
204
  }
195
205
 
206
+ // A self-tiling flex item: grows to share the row, clamps to a sensible min
207
+ // width, and removes itself (empty:hidden) when its card renders null — so a
208
+ // data-gated card (e.g. Cost without OpenCost) can't leave a phantom column.
209
+ function BandItem({ children }: { children: ReactNode }) {
210
+ return <div className="flex-1 min-w-[260px] empty:hidden [&>*]:w-full">{children}</div>
211
+ }
212
+
196
213
  // ============================================================================
197
214
  // Problems Panel (right sidebar, scrollable)
198
215
  // ============================================================================
@@ -1,6 +1,7 @@
1
1
  import { useRef, useEffect, useState, useCallback } from 'react'
2
2
  import { X, Copy, Check, Radio, Terminal, MessageSquare, Code2, ChevronRight, Pin } from 'lucide-react'
3
3
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
4
+ import { MCP_TOOL_CATALOG } from './mcpToolCatalog'
4
5
 
5
6
  interface MCPSetupDialogProps {
6
7
  open: boolean
@@ -208,8 +209,9 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
208
209
  verbose YAML output.
209
210
  </p>
210
211
  <p className="text-sm text-theme-text-secondary leading-relaxed">
211
- Read tools are strictly read-only. Write tools (restart, scale, sync) are
212
- non-destructive and annotated so your AI client can distinguish them.
212
+ Read tools are strictly read-only. Write tools (restart, scale, sync, apply,
213
+ node drain) are annotated as destructive so your AI client can flag them and
214
+ prompt before running.
213
215
  </p>
214
216
  </div>
215
217
 
@@ -296,95 +298,27 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
296
298
 
297
299
  {/* Available tools */}
298
300
  <div className="space-y-2">
299
- <h4 className="text-sm font-semibold text-theme-text-primary">Tools</h4>
301
+ <div className="flex items-baseline justify-between">
302
+ <h4 className="text-sm font-semibold text-theme-text-primary">Tools</h4>
303
+ <span className="text-[11px] text-theme-text-tertiary">{MCP_TOOL_CATALOG.length} tools</span>
304
+ </div>
300
305
  <div className="grid grid-cols-1 gap-1.5">
301
- {[
302
- { name: 'get_dashboard', desc: 'Get cluster health overview including resource counts, problems (failing pods, unhealthy deployments), recent warning events, and Helm release status. Start here to understand cluster state before drilling into specific resources.', params: [
303
- { name: 'namespace', required: false, desc: 'filter to a specific namespace' },
304
- ]},
305
- { name: 'list_resources', desc: 'List Kubernetes resources of a given kind with minified summaries. Supports all built-in kinds (pods, deployments, services, etc.) and CRDs. Use to discover what\'s running before inspecting individual resources.', params: [
306
- { name: 'kind', required: true, desc: 'resource kind, e.g. pods, deployments, services' },
307
- { name: 'namespace', required: false, desc: 'filter to a specific namespace' },
308
- ]},
309
- { name: 'get_resource', desc: 'Get a single Kubernetes resource: minified spec/status/metadata plus default-on resourceContext (managedBy, exposes, selectedBy, uses, runsOn, issue/audit rollups). Optionally include heavier sidecars (events, metrics, logs).', params: [
310
- { name: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service' },
311
- { name: 'namespace', required: false, desc: 'omit for cluster-scoped kinds (Node, ClusterRole, IngressClass, etc.)' },
312
- { name: 'name', required: true, desc: 'resource name' },
313
- { name: 'group', required: false, desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev for Knative Service vs core Service)' },
314
- { name: 'include', required: false, desc: 'events, metrics, logs' },
315
- { name: 'context', required: false, desc: 'resourceContext tier: basic (default) or none (bare minified)' },
316
- ]},
317
- { name: 'get_topology', desc: 'Get the topology graph showing relationships between Kubernetes resources. Returns nodes and edges representing Deployments, Services, Ingresses, Pods, etc. Use \'traffic\' view for network flow or \'resources\' view for ownership hierarchy. Use \'summary\' format for LLM-friendly text descriptions.', params: [
318
- { name: 'namespace', required: false, desc: 'filter to a specific namespace' },
319
- { name: 'view', required: false, desc: 'traffic or resources' },
320
- { name: 'format', required: false, desc: 'graph (default) or summary (text)' },
321
- ]},
322
- { name: 'get_events', desc: 'Get recent Kubernetes warning events, deduplicated and sorted by recency. Useful for diagnosing issues — shows event reason, message, and occurrence count. Filter by resource kind/name to scope to a specific resource.', params: [
323
- { name: 'namespace', required: false, desc: 'filter to a specific namespace' },
324
- { name: 'limit', required: false, desc: 'max events to return (default 20)' },
325
- { name: 'kind', required: false, desc: 'filter to events for this resource kind' },
326
- { name: 'name', required: false, desc: 'filter to events for this resource name' },
327
- ]},
328
- { name: 'get_pod_logs', desc: 'Get filtered log lines from a pod, prioritizing errors and warnings. Returns diagnostically relevant lines (errors, panics, stack traces) or falls back to the last 20 lines if no error patterns match.', params: [
329
- { name: 'namespace', required: true, desc: 'pod namespace' },
330
- { name: 'name', required: true, desc: 'pod name' },
331
- { name: 'container', required: false, desc: 'container name (defaults to first)' },
332
- { name: 'tail_lines', required: false, desc: 'lines from end (default 200)' },
333
- ]},
334
- { name: 'list_namespaces', desc: 'List all Kubernetes namespaces with their status. Use to discover available namespaces before filtering other queries.', params: [] },
335
- { name: 'get_changes', desc: 'Get recent resource changes (creates, updates, deletes) from the cluster timeline. Use to investigate what changed before an incident. Filter by namespace, resource kind, or specific resource name.', params: [
336
- { name: 'namespace', required: false, desc: 'filter to a specific namespace' },
337
- { name: 'kind', required: false, desc: 'filter to a resource kind (e.g. Deployment)' },
338
- { name: 'name', required: false, desc: 'filter to a specific resource name' },
339
- { name: 'since', required: false, desc: 'lookback duration, e.g. 1h, 30m (default 1h)' },
340
- { name: 'limit', required: false, desc: 'max changes to return (default 20, max 50)' },
341
- ]},
342
- { name: 'list_helm_releases', desc: 'List all Helm releases in the cluster with their status and health. Returns release name, namespace, chart, version, status, and resource health.', params: [
343
- { name: 'namespace', required: false, desc: 'filter to a specific namespace' },
344
- ]},
345
- { name: 'get_helm_release', desc: 'Get detailed information about a specific Helm release including owned resources and their status. Optionally include values, revision history, or manifest diff between revisions.', params: [
346
- { name: 'namespace', required: true, desc: 'release namespace' },
347
- { name: 'name', required: true, desc: 'release name' },
348
- { name: 'include', required: false, desc: 'values, history, diff' },
349
- { name: 'diff_revision_1', required: false, desc: 'first revision for diff' },
350
- { name: 'diff_revision_2', required: false, desc: 'second revision for diff (defaults to current)' },
351
- ]},
352
- { name: 'get_workload_logs', desc: 'Get aggregated, AI-filtered logs from all pods of a workload (Deployment, StatefulSet, or DaemonSet). Logs are collected from all matching pods, filtered for errors/warnings, and deduplicated.', params: [
353
- { name: 'kind', required: true, desc: 'deployment, statefulset, or daemonset' },
354
- { name: 'namespace', required: true, desc: 'workload namespace' },
355
- { name: 'name', required: true, desc: 'workload name' },
356
- { name: 'container', required: false, desc: 'specific container name' },
357
- { name: 'tail_lines', required: false, desc: 'lines per pod (default 100)' },
358
- ]},
359
- { name: 'manage_workload', desc: 'Perform operations on a workload. \'restart\' triggers a rolling restart, \'scale\' changes the replica count, \'rollback\' reverts to a previous revision.', params: [
360
- { name: 'action', required: true, desc: 'restart, scale, or rollback' },
361
- { name: 'kind', required: true, desc: 'deployment, statefulset, or daemonset' },
362
- { name: 'namespace', required: true, desc: 'workload namespace' },
363
- { name: 'name', required: true, desc: 'workload name' },
364
- { name: 'replicas', required: false, desc: 'target replica count (for scale)' },
365
- { name: 'revision', required: false, desc: 'target revision (for rollback)' },
366
- ]},
367
- { name: 'manage_cronjob', desc: 'Perform operations on a CronJob. \'trigger\' creates a manual Job run, \'suspend\' pauses the schedule, \'resume\' re-enables it.', params: [
368
- { name: 'action', required: true, desc: 'trigger, suspend, or resume' },
369
- { name: 'namespace', required: true, desc: 'cronjob namespace' },
370
- { name: 'name', required: true, desc: 'cronjob name' },
371
- ]},
372
- { name: 'manage_gitops', desc: 'Perform operations on GitOps resources. ArgoCD: sync, suspend, resume. FluxCD: reconcile, suspend, resume.', params: [
373
- { name: 'action', required: true, desc: 'sync/reconcile, suspend, or resume' },
374
- { name: 'tool', required: true, desc: 'argocd or fluxcd' },
375
- { name: 'namespace', required: true, desc: 'resource namespace' },
376
- { name: 'name', required: true, desc: 'resource name' },
377
- { name: 'kind', required: false, desc: 'FluxCD resource kind (e.g. kustomization, helmrelease)' },
378
- ]},
379
- ].map((tool) => (
306
+ {MCP_TOOL_CATALOG.map((tool) => (
380
307
  <div key={tool.name} className="card-inner space-y-1.5">
381
- <code className="text-[11px] font-mono text-purple-400">{tool.name}</code>
308
+ <div className="flex items-center gap-2">
309
+ <code className="text-[11px] font-mono text-purple-400">{tool.name}</code>
310
+ {tool.write && (
311
+ <span className="badge-sm bg-amber-500/10 text-amber-600 dark:text-amber-400" title="Write tool — annotated as destructive">
312
+ write
313
+ </span>
314
+ )}
315
+ </div>
382
316
  <p className="text-[11px] text-theme-text-tertiary leading-relaxed">{tool.desc}</p>
383
317
  {tool.params.length > 0 && (
384
318
  <div className="flex flex-wrap gap-1.5 pt-0.5">
385
319
  {tool.params.map((p) => (
386
- <span key={p.name} className="badge-sm font-mono bg-theme-elevated text-theme-text-secondary" title={p.desc}>
387
- <span className="text-theme-text-secondary">{p.name}</span>
320
+ <span key={p.arg} className="badge-sm font-mono bg-theme-elevated text-theme-text-secondary" title={p.desc}>
321
+ <span className="text-theme-text-secondary">{p.arg}</span>
388
322
  {p.required && <span className="text-red-400">*</span>}
389
323
  </span>
390
324
  ))}
@@ -0,0 +1,276 @@
1
+ // Human-facing catalog for the MCP setup dialog (MCPSetupDialog.tsx).
2
+ //
3
+ // Source of truth for the *set* of tools is the backend registration in
4
+ // internal/mcp/tools.go. This list must stay in sync with it — the Go test
5
+ // TestSetupDialogCoversAllTools (internal/mcp/tools_catalog_test.go) parses
6
+ // this file and fails CI if the tool names here don't exactly match the
7
+ // registered tools. When you add or remove an MCP tool there, update this
8
+ // catalog too.
9
+ //
10
+ // Descriptions here are intentionally shorter and more human-facing than the
11
+ // LLM-oriented routing descriptions in tools.go — different audience, so they
12
+ // are not shared verbatim.
13
+
14
+ export interface MCPToolParam {
15
+ arg: string
16
+ required?: boolean
17
+ desc: string
18
+ }
19
+
20
+ export interface MCPToolInfo {
21
+ name: string
22
+ write?: boolean
23
+ desc: string
24
+ params: MCPToolParam[]
25
+ }
26
+
27
+ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
28
+ {
29
+ name: 'get_dashboard',
30
+ desc: 'Cluster or namespace health overview: resource counts, failing pods, unhealthy workloads, recent warning events, and Helm status. Start here before drilling into specific resources.',
31
+ params: [{ arg: 'namespace', desc: 'filter to a specific namespace' }],
32
+ },
33
+ {
34
+ name: 'top_resources',
35
+ desc: 'Live CPU/memory ranking (like `kubectl top`) joined with Kubernetes context — pod status, restarts, owner workload, requests, and limits. Ranks pods, workloads, or nodes.',
36
+ params: [
37
+ { arg: 'kind', desc: 'pods (default), workloads, or nodes' },
38
+ { arg: 'namespace', desc: 'filter pods/workloads to a namespace' },
39
+ { arg: 'sort', desc: 'cpu (default) or memory' },
40
+ { arg: 'limit', desc: 'max rows (default 20, max 100)' },
41
+ ],
42
+ },
43
+ {
44
+ name: 'list_resources',
45
+ desc: 'List Kubernetes resources of a given kind with compact summaries plus per-row health, managedBy, and issue counts. Supports built-in kinds and CRDs.',
46
+ params: [
47
+ { arg: 'kind', required: true, desc: 'resource kind, e.g. pods, deployments, services' },
48
+ { arg: 'group', desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev)' },
49
+ { arg: 'namespace', desc: 'filter to a specific namespace' },
50
+ { arg: 'context', desc: 'per-row context: default (summaryContext) or none' },
51
+ ],
52
+ },
53
+ {
54
+ name: 'get_resource',
55
+ desc: 'A single resource: minified spec/status/metadata plus resourceContext (relationships, refs, issue/audit/policy rollups). Optionally include heavier event/metrics sidecars.',
56
+ params: [
57
+ { arg: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service' },
58
+ { arg: 'name', required: true, desc: 'resource name' },
59
+ { arg: 'namespace', desc: 'omit for cluster-scoped kinds (Node, ClusterRole, IngressClass, etc.)' },
60
+ { arg: 'group', desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev for Knative Service vs core Service)' },
61
+ { arg: 'include', desc: 'events, metrics' },
62
+ { arg: 'context', desc: 'resourceContext tier: basic (default) or none' },
63
+ ],
64
+ },
65
+ {
66
+ name: 'get_topology',
67
+ desc: 'Topology graph of relationships between resources — Services, workloads, Pods, Ingresses, owners. Use traffic view for network flow or resources view for ownership hierarchy.',
68
+ params: [
69
+ { arg: 'namespace', desc: 'filter to a specific namespace' },
70
+ { arg: 'view', desc: 'traffic or resources' },
71
+ { arg: 'format', desc: 'graph (default) or summary (text)' },
72
+ ],
73
+ },
74
+ {
75
+ name: 'get_neighborhood',
76
+ desc: 'BFS-expanded topology around one resource — its upstream/downstream Services, workloads, Pods, refs, and owners. Cheaper and more focused than full topology once you have a suspect.',
77
+ params: [
78
+ { arg: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service, application' },
79
+ { arg: 'name', required: true, desc: 'resource name' },
80
+ { arg: 'namespace', desc: 'resource namespace; omit for cluster-scoped kinds' },
81
+ { arg: 'group', desc: 'API group to disambiguate colliding kinds' },
82
+ { arg: 'profile', desc: 'auto (default) or all (every edge type, heavier)' },
83
+ { arg: 'hops', desc: 'BFS depth (default 1, max 2)' },
84
+ { arg: 'max_nodes', desc: 'node budget (default 25)' },
85
+ ],
86
+ },
87
+ {
88
+ name: 'get_events',
89
+ desc: 'Recent Kubernetes warning events, deduplicated and sorted by recency. Shows reason, message, and occurrence count. Filter to a specific resource by kind/name.',
90
+ params: [
91
+ { arg: 'namespace', desc: 'filter to a specific namespace' },
92
+ { arg: 'limit', desc: 'max events (default 20, max 100)' },
93
+ { arg: 'kind', desc: 'filter to events for this resource kind' },
94
+ { arg: 'name', desc: 'filter to events for this resource name' },
95
+ ],
96
+ },
97
+ {
98
+ name: 'get_pod_logs',
99
+ desc: 'Filtered log lines from a pod, prioritizing errors and warnings, falling back to recent tail lines. Optional grep, since, and previous-container logs.',
100
+ params: [
101
+ { arg: 'namespace', required: true, desc: 'pod namespace' },
102
+ { arg: 'name', required: true, desc: 'pod name' },
103
+ { arg: 'container', desc: 'container name (defaults to first)' },
104
+ { arg: 'tail_lines', desc: 'lines from end (default 200)' },
105
+ { arg: 'grep', desc: 'regex to filter lines, like kubectl logs | grep' },
106
+ { arg: 'since', desc: 'only logs newer than this duration (e.g. 30s, 10m, 1h)' },
107
+ { arg: 'previous', desc: 'logs from the previous terminated container (CrashLoopBackOff)' },
108
+ ],
109
+ },
110
+ {
111
+ name: 'diagnose',
112
+ desc: 'One-call workload root-cause bundle: spec + resourceContext + current AND previous logs across all pods + warning events + startup-blocker analysis. For Pod/Deployment/StatefulSet/DaemonSet.',
113
+ params: [
114
+ { arg: 'kind', required: true, desc: 'pod, deployment, statefulset, or daemonset' },
115
+ { arg: 'namespace', required: true, desc: 'workload namespace' },
116
+ { arg: 'name', required: true, desc: 'resource name' },
117
+ { arg: 'container', desc: 'specific container (defaults to all)' },
118
+ { arg: 'tail_lines', desc: 'lines per pod/stream (default 100)' },
119
+ { arg: 'since', desc: 'only logs newer than this duration' },
120
+ ],
121
+ },
122
+ {
123
+ name: 'list_namespaces',
124
+ desc: 'List all Kubernetes namespaces with their status. Use to discover available namespaces before filtering other queries.',
125
+ params: [],
126
+ },
127
+ {
128
+ name: 'get_changes',
129
+ desc: 'Recent resource creates, updates, and deletes from the cluster timeline. Use to investigate what changed before an incident.',
130
+ params: [
131
+ { arg: 'namespace', desc: 'filter to a specific namespace' },
132
+ { arg: 'kind', desc: 'filter to a resource kind (e.g. Deployment)' },
133
+ { arg: 'name', desc: 'filter to a specific resource name' },
134
+ { arg: 'since', desc: 'lookback duration, e.g. 1h, 30m (default 1h)' },
135
+ { arg: 'limit', desc: 'max changes (default 20, max 50)' },
136
+ ],
137
+ },
138
+ {
139
+ name: 'get_cluster_audit',
140
+ desc: 'Best-practice and security posture findings — Security, Reliability, Efficiency — each with remediation guidance. Static config posture, independent of live operational health.',
141
+ params: [
142
+ { arg: 'namespace', desc: 'filter to a specific namespace' },
143
+ { arg: 'category', desc: 'Security, Reliability, or Efficiency' },
144
+ { arg: 'severity', desc: 'danger or warning' },
145
+ { arg: 'limit', desc: 'max findings (default 30, max 100)' },
146
+ ],
147
+ },
148
+ {
149
+ name: 'list_helm_releases',
150
+ desc: 'All Helm releases in the cluster with status and resource health — name, namespace, chart, version.',
151
+ params: [{ arg: 'namespace', desc: 'filter to a specific namespace' }],
152
+ },
153
+ {
154
+ name: 'get_helm_release',
155
+ desc: 'Detailed Helm release info with owned resources and their status. Optionally include values, revision history, or a manifest diff between revisions.',
156
+ params: [
157
+ { arg: 'namespace', required: true, desc: 'release namespace' },
158
+ { arg: 'name', required: true, desc: 'release name' },
159
+ { arg: 'include', desc: 'values, history, diff' },
160
+ { arg: 'diff_revision_1', desc: 'first revision for diff' },
161
+ { arg: 'diff_revision_2', desc: 'second revision for diff (defaults to current)' },
162
+ ],
163
+ },
164
+ {
165
+ name: 'list_packages',
166
+ desc: 'Unified "what\'s installed" view across Helm releases, workload labels, CRD registrations, and GitOps declarations (Argo + Flux), with sources, versions, and health.',
167
+ params: [
168
+ { arg: 'namespace', desc: 'filter to a specific namespace' },
169
+ { arg: 'source', desc: 'H (Helm), L (labels), C (CRDs), A (Argo), F (Flux)' },
170
+ { arg: 'chart', desc: 'case-insensitive chart-name substring' },
171
+ ],
172
+ },
173
+ {
174
+ name: 'issues',
175
+ desc: 'Ranked list of what is broken right now — failing workloads, dangling references, scheduling blockers, and false CRD conditions. Live operational state (distinct from get_cluster_audit posture).',
176
+ params: [
177
+ { arg: 'namespace', desc: 'filter to one namespace' },
178
+ { arg: 'severity', desc: 'comma-separated: critical, warning' },
179
+ { arg: 'kind', desc: 'comma-separated kind filter' },
180
+ { arg: 'limit', desc: 'max issues (default 200, max 1000)' },
181
+ { arg: 'filter', desc: 'optional CEL boolean expression' },
182
+ ],
183
+ },
184
+ {
185
+ name: 'search',
186
+ desc: 'Find resources by content/term match — config keys, env refs, images, label values, ConfigMap data, CRD fields, status messages. Secret values are never indexed.',
187
+ params: [
188
+ { arg: 'query', required: true, desc: 'tokens AND\'d; modifiers kind:, ns:, label:, image:' },
189
+ { arg: 'limit', desc: 'max hits (default 50, max 500)' },
190
+ { arg: 'include', desc: 'summary (default), raw, or none' },
191
+ { arg: 'filter', desc: 'optional CEL boolean expression' },
192
+ { arg: 'context', desc: 'per-hit context: default (summaryContext) or none' },
193
+ ],
194
+ },
195
+ {
196
+ name: 'get_subject_permissions',
197
+ desc: 'Effective RBAC for a ServiceAccount, User, or Group: the bindings that grant access, a flattened rule list, and (for SAs) the Pods running as it. Answers "what\'s the blast radius if compromised?".',
198
+ params: [
199
+ { arg: 'kind', required: true, desc: 'ServiceAccount, User, or Group' },
200
+ { arg: 'name', required: true, desc: 'subject name' },
201
+ { arg: 'namespace', desc: 'required for ServiceAccount; omit for User/Group' },
202
+ ],
203
+ },
204
+ {
205
+ name: 'get_workload_logs',
206
+ desc: 'Aggregated, filtered logs across all pods of a workload (Deployment, StatefulSet, or DaemonSet) — collected concurrently, filtered for errors/warnings, and deduplicated.',
207
+ params: [
208
+ { arg: 'kind', desc: 'deployment (default), statefulset, or daemonset' },
209
+ { arg: 'namespace', required: true, desc: 'workload namespace' },
210
+ { arg: 'name', required: true, desc: 'workload name' },
211
+ { arg: 'container', desc: 'specific container (defaults to all)' },
212
+ { arg: 'tail_lines', desc: 'lines per pod (default 100)' },
213
+ { arg: 'grep', desc: 'regex to filter lines, like kubectl logs | grep' },
214
+ { arg: 'since', desc: 'only logs newer than this duration' },
215
+ { arg: 'previous', desc: 'logs from the previous terminated container' },
216
+ ],
217
+ },
218
+ {
219
+ name: 'manage_workload',
220
+ write: true,
221
+ desc: 'Operate on a workload: restart triggers a rolling restart, scale changes the replica count, rollback reverts to a previous revision.',
222
+ params: [
223
+ { arg: 'action', required: true, desc: 'restart, scale, or rollback' },
224
+ { arg: 'kind', required: true, desc: 'deployment, statefulset, or daemonset' },
225
+ { arg: 'namespace', required: true, desc: 'workload namespace' },
226
+ { arg: 'name', required: true, desc: 'workload name' },
227
+ { arg: 'replicas', desc: 'target replica count (for scale)' },
228
+ { arg: 'revision', desc: 'target revision (for rollback)' },
229
+ ],
230
+ },
231
+ {
232
+ name: 'manage_cronjob',
233
+ write: true,
234
+ desc: 'Operate on a CronJob: trigger creates a manual Job run, suspend pauses the schedule, resume re-enables it.',
235
+ params: [
236
+ { arg: 'action', required: true, desc: 'trigger, suspend, or resume' },
237
+ { arg: 'namespace', required: true, desc: 'cronjob namespace' },
238
+ { arg: 'name', required: true, desc: 'cronjob name' },
239
+ ],
240
+ },
241
+ {
242
+ name: 'manage_gitops',
243
+ write: true,
244
+ desc: 'Operate on GitOps resources. ArgoCD: sync, refresh, terminate, rollback, suspend, resume. FluxCD: reconcile, sync-with-source, suspend, resume.',
245
+ params: [
246
+ { arg: 'action', required: true, desc: 'sync/reconcile, refresh, terminate, rollback, suspend, or resume' },
247
+ { arg: 'tool', required: true, desc: 'argocd or fluxcd' },
248
+ { arg: 'namespace', required: true, desc: 'resource namespace' },
249
+ { arg: 'name', required: true, desc: 'resource name' },
250
+ { arg: 'kind', desc: 'FluxCD resource kind (e.g. kustomization, helmrelease)' },
251
+ ],
252
+ },
253
+ {
254
+ name: 'apply_resource',
255
+ write: true,
256
+ desc: 'Create or update a resource from YAML. apply mode is a server-side apply with Force=true (can take field ownership from Helm/Flux); create mode fails if it exists. Multi-document YAML supported.',
257
+ params: [
258
+ { arg: 'yaml', required: true, desc: 'YAML manifest (multi-document with --- supported)' },
259
+ { arg: 'mode', desc: 'apply (default) or create' },
260
+ { arg: 'dry_run', desc: 'validate without persisting' },
261
+ { arg: 'namespace', desc: 'override namespace for the resource' },
262
+ ],
263
+ },
264
+ {
265
+ name: 'manage_node',
266
+ write: true,
267
+ desc: 'Operate on a node: cordon marks it unschedulable, uncordon reverses that, drain cordons then evicts all non-DaemonSet pods.',
268
+ params: [
269
+ { arg: 'action', required: true, desc: 'cordon, uncordon, or drain' },
270
+ { arg: 'name', required: true, desc: 'node name' },
271
+ { arg: 'delete_empty_dir_data', desc: 'evict pods with emptyDir volumes (default true)' },
272
+ { arg: 'force', desc: 'evict pods not managed by a controller (default false)' },
273
+ { arg: 'timeout', desc: 'drain timeout in seconds (default 60)' },
274
+ ],
275
+ },
276
+ ]
@@ -21,10 +21,11 @@ export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererPro
21
21
  const namespace = isClusterRole ? '' : (data?.metadata?.namespace ?? '')
22
22
  const name = roleRef.name ?? ''
23
23
 
24
- const { data: role, isLoading } = useResource<any>(kind, namespace, name)
24
+ const { data: role, isLoading, error } = useResource<any>(kind, namespace, name)
25
25
  // `role` is undefined while loading, then the resource, then potentially
26
- // null on 404. Pass [] for "loaded but no rules" so the base renderer's
27
- // `rules === null` branch fires only on outright fetch failure (orphan).
26
+ // null on 404 / 403. Pass [] for "loaded but no rules" so the base
27
+ // renderer's `rules === null` branch fires only on outright fetch failure
28
+ // (orphan or forbidden); `roleRulesError` then disambiguates the two.
28
29
  const rules =
29
30
  isLoading
30
31
  ? undefined
@@ -38,6 +39,7 @@ export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererPro
38
39
  onNavigate={onNavigate}
39
40
  roleRules={rules ?? null}
40
41
  roleRulesLoading={isLoading}
42
+ roleRulesError={error}
41
43
  />
42
44
  )
43
45
  }
@@ -255,7 +255,7 @@ export function WorkloadView({
255
255
  }, [searchParams, setSearchParams])
256
256
 
257
257
  // Fetch resource with relationships
258
- const { data: resourceResponse, isLoading: resourceLoading, refetch: refetchResource } = useResourceWithRelationships<any>(kindProp, namespace, name, rest.group)
258
+ const { data: resourceResponse, isLoading: resourceLoading, error: resourceError, refetch: refetchResource } = useResourceWithRelationships<any>(kindProp, namespace, name, rest.group)
259
259
  const resource = resourceResponse?.resource
260
260
  const relationships = resourceResponse?.relationships
261
261
  const certificateInfo = resourceResponse?.certificateInfo
@@ -398,6 +398,7 @@ export function WorkloadView({
398
398
  relationships={relationships}
399
399
  certificateInfo={certificateInfo}
400
400
  isLoading={resourceLoading}
401
+ resourceError={resourceError}
401
402
  refetch={refetchResource}
402
403
  // Timeline
403
404
  allEvents={allEvents}