@skyhook-io/k8s-ui 1.7.14 → 1.7.15

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/k8s-ui",
3
- "version": "1.7.14",
3
+ "version": "1.7.15",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -69,7 +69,7 @@
69
69
  "@monaco-editor/react": "^4.7.0",
70
70
  "html-to-image": "^1.11.0",
71
71
  "react-virtuoso": "^4.18.7",
72
- "shiki": "^4.0.0"
72
+ "shiki": "^4.2.0"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@xterm/addon-fit": ">=0.10.0",
@@ -85,8 +85,7 @@
85
85
  "yaml": ">=2.0.0"
86
86
  },
87
87
  "devDependencies": {
88
- "@types/diff": "^7.0.2",
89
- "@types/react": "^19.2.14",
88
+ "@types/react": "^19.2.17",
90
89
  "@types/react-dom": "^19.2.3",
91
90
  "@xterm/addon-fit": "^0.11.0",
92
91
  "@xterm/addon-web-links": "^0.12.0",
@@ -96,10 +95,10 @@
96
95
  "diff": "^9.0.0",
97
96
  "elkjs": "^0.11.1",
98
97
  "lucide-react": "^1.16.0",
99
- "react": "^19.2.6",
100
- "react-dom": "^19.2.6",
98
+ "react": "^19.2.7",
99
+ "react-dom": "^19.2.7",
101
100
  "typescript": "^6.0.2",
102
- "vitest": "^4.1.5",
101
+ "vitest": "^4.1.8",
103
102
  "yaml": "^2.9.0"
104
103
  }
105
104
  }
@@ -271,11 +271,9 @@ function Diagnosis({ issue }: { issue: Issue }) {
271
271
  .join(' · ')
272
272
  : null;
273
273
  const { headline, detail } = issueMessageParts(issue);
274
- // When the issue carries a parsed plain-English cause (GitOps controller
275
- // errors), lead with it — the raw controller message is long and buries the
276
- // useful part, and the detector reason ("OperationFailed") just repeats the
277
- // category chip. The raw message is kept below as de-emphasized detail.
278
- const rawMessage = [headline, detail].filter(Boolean).join(' ');
274
+ // When the issue carries a parsed plain-English cause, lead with it. The raw
275
+ // detector message is kept below as de-emphasized detail.
276
+ const rawMessage = issue.cause ? issue.message ?? '' : [headline, detail].filter(Boolean).join(' ');
279
277
  return (
280
278
  <section className="flex flex-col gap-1">
281
279
  <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
@@ -310,7 +308,7 @@ function Diagnosis({ issue }: { issue: Issue }) {
310
308
  {issue.operation_retry_count ? ` · retried ${issue.operation_retry_count}×` : ''}
311
309
  </p>
312
310
  ) : null}
313
- {/* Raw controller message, de-emphasized — shown below the parsed cause so
311
+ {/* Raw detector message, de-emphasized — shown below the parsed cause so
314
312
  the precise error (URLs, resource names) is available without leading. */}
315
313
  {issue.cause && rawMessage ? (
316
314
  <p className="break-words font-mono text-[11px] leading-relaxed text-theme-text-tertiary">{rawMessage}</p>
@@ -108,6 +108,16 @@ export interface IssueRecentChange {
108
108
  change_category?: 'spec_config' | 'lifecycle' | 'runtime_status' | string;
109
109
  rank_reason?: string;
110
110
  fields?: IssueRecentChangeField[];
111
+ /** Workloads that mount/reference this ConfigMap directly ("Deployment/flagd").
112
+ * Direct spec references only — runtime consumers via an intermediary
113
+ * service are not captured. */
114
+ consumed_by?: string[];
115
+ }
116
+
117
+ /** "No tracked non-status changes in the window" — the claim is scoped by
118
+ * window_seconds so a change just outside it can't be misread as absent. */
119
+ export interface IssueNoRecentChanges {
120
+ window_seconds: number;
111
121
  }
112
122
 
113
123
  /**
@@ -143,8 +153,8 @@ export interface Issue {
143
153
 
144
154
  reason: string;
145
155
  message?: string;
146
- /** Parsed domain diagnosis (today GitOps controller errors): plain-English
147
- * cause, suggested next step, and an optional structured one-click fix.
156
+ /** Parsed domain diagnosis: plain-English cause, suggested next step, and
157
+ * an optional structured one-click fix.
148
158
  * Server-emitted (omitempty); empty for issues without a parser. */
149
159
  cause?: string;
150
160
  action?: string;
@@ -185,6 +195,16 @@ export interface Issue {
185
195
  issue_timing?: 'started_at_resource_creation' | 'started_after_resource_was_healthy';
186
196
  /** The evidence that determined issue_timing (for auditability). */
187
197
  issue_timing_basis?: 'condition' | 'owner_condition' | 'pod_creation' | 'deletion' | 'phase' | 'spec';
198
+
199
+ /** Recent non-status changes (spec/config and lifecycle) on this issue's
200
+ * subject (and, for workload subjects, its referenced ConfigMaps) —
201
+ * deterministic evidence, not a causal claim. Populated only on
202
+ * single-namespace MCP issue responses; never set on /api/issues today. */
203
+ correlated_changes?: IssueRecentChange[];
204
+ /** Explicit "no tracked changes in the window" evidence. An issue with
205
+ * NEITHER correlation field was not checked — absence must not be read as
206
+ * "no changes". MCP-only, like correlated_changes. */
207
+ no_recent_changes?: IssueNoRecentChanges;
188
208
  }
189
209
 
190
210
  /** subjectRef builds a deep-linkable ref for an issue's subject — the row's
@@ -82,6 +82,11 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
82
82
  pods.forEach((pod, i) => m.set(pod.name, i))
83
83
  return m
84
84
  }, [pods])
85
+ const podColorIndexRef = useRef<Map<string, number>>(new Map())
86
+
87
+ useEffect(() => {
88
+ podColorIndexRef.current = podColorIndex
89
+ }, [podColorIndex])
85
90
 
86
91
  const podsInitialized = useRef(false)
87
92
 
@@ -94,6 +99,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
94
99
  const resultPods = result.pods ?? []
95
100
  const resultLogs = result.logs ?? []
96
101
 
102
+ podColorIndexRef.current = new Map(resultPods.map((pod, i) => [pod.name, i]))
97
103
  setPods(resultPods)
98
104
 
99
105
  if (!podsInitialized.current && resultPods.length > 0) {
@@ -148,10 +154,12 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
148
154
  {
149
155
  onConnected: (data: any) => {
150
156
  if (data?.pods) {
151
- setPods(data.pods)
152
- if (selectedPods.size === 0) {
153
- setSelectedPods(new Set((data.pods as WorkloadPodInfo[]).map((p: WorkloadPodInfo) => p.name)))
154
- }
157
+ const nextPods = data.pods as WorkloadPodInfo[]
158
+ podColorIndexRef.current = new Map(nextPods.map((pod, i) => [pod.name, i]))
159
+ setPods(nextPods)
160
+ setSelectedPods(prev => (
161
+ prev.size === 0 ? new Set(nextPods.map((p: WorkloadPodInfo) => p.name)) : prev
162
+ ))
155
163
  }
156
164
  },
157
165
  onLog: (data: any) => {
@@ -161,7 +169,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
161
169
  content: data.content || '',
162
170
  container: data.container || '',
163
171
  pod: data.pod || '',
164
- podColorIndex: podColorIndex.get(data.pod || ''),
172
+ podColorIndex: podColorIndexRef.current.get(data.pod || ''),
165
173
  })
166
174
  }
167
175
  },
@@ -171,7 +179,8 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
171
179
  setPods(prev => {
172
180
  const existing = new Set(prev.map(p => p.name))
173
181
  const toAdd = newPods.filter(p => !existing.has(p.name))
174
- return toAdd.length > 0 ? [...prev, ...toAdd] : prev
182
+ if (toAdd.length === 0) return prev
183
+ return [...prev, ...toAdd]
175
184
  })
176
185
  setSelectedPods(prev => {
177
186
  const next = new Set(prev)
@@ -191,7 +200,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
191
200
  },
192
201
  'Workload log stream connection failed',
193
202
  )
194
- }, [createStream, startStreaming, selectedContainer, sinceSeconds, append, podColorIndex, selectedPods.size, clear])
203
+ }, [createStream, startStreaming, selectedContainer, sinceSeconds, append, clear])
195
204
 
196
205
  const handleStopStreaming = useCallback(() => {
197
206
  userStoppedRef.current = true
@@ -25,6 +25,8 @@ import {
25
25
  GitCompare,
26
26
  Regex,
27
27
  ListChecks,
28
+ Minus,
29
+ Scale,
28
30
  } from 'lucide-react'
29
31
  import { clsx } from 'clsx'
30
32
  import { ResourceBar } from '../ui/ResourceBar'
@@ -165,6 +167,8 @@ import { ConfirmDialog } from '../ui/ConfirmDialog'
165
167
  const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const
166
168
  const WORKLOAD_PROBLEMS = ['Unavailable', 'Rollout Stuck', 'Rollout In Progress'] as const
167
169
  const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets'])
170
+ const BULK_RESTART_WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets', 'rollouts'])
171
+ const BULK_SCALE_WORKLOAD_KINDS = new Set(['deployments', 'statefulsets'])
168
172
 
169
173
  // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
170
174
  export const SKIP_FILTER_COLUMNS = new Set([
@@ -203,6 +207,8 @@ interface Column {
203
207
  minWidth?: number // minimum width in px
204
208
  }
205
209
 
210
+ type BulkResourceItem = { kind: string; group?: string; namespace: string; name: string }
211
+
206
212
  /**
207
213
  * Extra column injected by the parent — for example, a leading "Cluster"
208
214
  * column when the table is rendered inside a multi-cluster host.
@@ -1906,8 +1912,12 @@ interface ResourcesViewProps {
1906
1912
  */
1907
1913
  onClearNamespaces?: () => void
1908
1914
  // Bulk operations
1909
- onBulkDelete?: (items: Array<{ kind: string; group?: string; namespace: string; name: string }>, options?: { force?: boolean; onSuccess?: () => void }) => void
1915
+ onBulkDelete?: (items: BulkResourceItem[], options?: { force?: boolean; onSuccess?: () => void }) => void
1910
1916
  isBulkDeleting?: boolean
1917
+ onBulkRestart?: (items: BulkResourceItem[], options?: { onSuccess?: () => void }) => void
1918
+ isBulkRestarting?: boolean
1919
+ onBulkScale?: (items: BulkResourceItem[], replicas: number, options?: { onSuccess?: () => void }) => void
1920
+ isBulkScaling?: boolean
1911
1921
  }
1912
1922
 
1913
1923
  // Default selected kind
@@ -2058,6 +2068,10 @@ export function ResourcesView({
2058
2068
  onClearNamespaces,
2059
2069
  onBulkDelete,
2060
2070
  isBulkDeleting = false,
2071
+ onBulkRestart,
2072
+ isBulkRestarting = false,
2073
+ onBulkScale,
2074
+ isBulkScaling = false,
2061
2075
  }: ResourcesViewProps) {
2062
2076
  const initialFilters = getInitialFiltersFromURL()
2063
2077
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(() => getInitialKindFromURL(basePath, defaultKind, locationPathname, locationSearch))
@@ -2077,6 +2091,10 @@ export function ResourcesView({
2077
2091
  onSelectedKindChange?.(selectedKind)
2078
2092
  setBulkMode(false)
2079
2093
  setCheckedResources(new Set())
2094
+ setShowBulkDeleteConfirm(false)
2095
+ setShowBulkRestartConfirm(false)
2096
+ setShowBulkScaleDialog(false)
2097
+ setBulkForceDelete(false)
2080
2098
  }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
2081
2099
  const [searchTerm, setSearchTerm] = useState(initialFilters.search)
2082
2100
  const [regexMode, setRegexMode] = useState(false)
@@ -2112,12 +2130,15 @@ export function ResourcesView({
2112
2130
  const [ownerName, setOwnerName] = useState<string>(initialFilters.ownerName)
2113
2131
 
2114
2132
  // Multi-select state for bulk operations. Checkboxes only render while
2115
- // bulk mode is active — entered via the toolbar toggle — so the risky
2116
- // bulk-delete surface stays out of the way during normal browsing.
2133
+ // bulk mode is active — entered via the toolbar toggle — so mutating
2134
+ // actions stay out of the way during normal browsing.
2117
2135
  const [bulkMode, setBulkMode] = useState(false)
2118
2136
  const [checkedResources, setCheckedResources] = useState<Set<string>>(new Set())
2119
2137
  const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
2138
+ const [showBulkRestartConfirm, setShowBulkRestartConfirm] = useState(false)
2139
+ const [showBulkScaleDialog, setShowBulkScaleDialog] = useState(false)
2120
2140
  const [bulkForceDelete, setBulkForceDelete] = useState(false)
2141
+ const [bulkScaleReplicas, setBulkScaleReplicas] = useState(0)
2121
2142
 
2122
2143
  const exitBulkMode = useCallback(() => {
2123
2144
  setBulkMode(false)
@@ -3652,13 +3673,44 @@ export function ResourcesView({
3652
3673
  return filteredResources.filter(r => checkedResources.has(getResourceKey(r)))
3653
3674
  }, [filteredResources, checkedResources, getResourceKey])
3654
3675
 
3676
+ const checkedBulkItems = useMemo(() => {
3677
+ return checkedItems.map(r => ({
3678
+ kind: selectedKind.name,
3679
+ group: selectedKind.group,
3680
+ namespace: r.metadata?.namespace || '',
3681
+ name: r.metadata?.name || '',
3682
+ }))
3683
+ }, [checkedItems, selectedKind.name, selectedKind.group])
3684
+
3685
+ const checkedItemDetails = useMemo(() => {
3686
+ return checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n')
3687
+ }, [checkedItems])
3688
+
3689
+ const selectedKindName = selectedKind.name.toLowerCase()
3690
+ const canBulkRestartSelectedKind = onBulkRestart != null && BULK_RESTART_WORKLOAD_KINDS.has(selectedKindName)
3691
+ const canBulkScaleSelectedKind = onBulkScale != null && BULK_SCALE_WORKLOAD_KINDS.has(selectedKindName)
3692
+ const canBulkSelect = onBulkDelete != null || canBulkRestartSelectedKind || canBulkScaleSelectedKind
3693
+ const isBulkMutating = isBulkDeleting || isBulkRestarting || isBulkScaling
3694
+
3695
+ const openBulkScaleDialog = useCallback(() => {
3696
+ const replicas = checkedItems[0]?.spec?.replicas
3697
+ setBulkScaleReplicas(typeof replicas === 'number' ? replicas : 0)
3698
+ setShowBulkScaleDialog(true)
3699
+ }, [checkedItems])
3700
+
3701
+ const commonBulkScaleReplicas = useMemo(() => {
3702
+ if (checkedItems.length === 0) return null
3703
+ const first = checkedItems[0]?.spec?.replicas ?? 0
3704
+ return checkedItems.every(r => (r.spec?.replicas ?? 0) === first) ? first : null
3705
+ }, [checkedItems])
3706
+
3655
3707
  const allVisibleChecked = filteredResources.length > 0 && checkedItems.length === filteredResources.length
3656
3708
 
3657
3709
  const toggleCheckAll = useCallback(() => {
3658
3710
  setCheckedResources(allVisibleChecked ? new Set() : new Set(filteredResources.map(getResourceKey)))
3659
3711
  }, [allVisibleChecked, filteredResources, getResourceKey])
3660
3712
 
3661
- const isCheckboxMode = onBulkDelete != null && bulkMode
3713
+ const isCheckboxMode = canBulkSelect && bulkMode
3662
3714
 
3663
3715
  // Filter columns by visibility
3664
3716
  const columns = useMemo(() => {
@@ -4330,7 +4382,7 @@ export function ResourcesView({
4330
4382
  </button>
4331
4383
  </Tooltip>
4332
4384
  )}
4333
- {onBulkDelete && (
4385
+ {canBulkSelect && (
4334
4386
  <Tooltip content={bulkMode ? 'Exit bulk select mode' : 'Select multiple resources'}>
4335
4387
  <button
4336
4388
  onClick={() => {
@@ -4358,15 +4410,41 @@ export function ResourcesView({
4358
4410
  <span className="text-sm font-medium text-theme-text-primary">
4359
4411
  {checkedItems.length} selected
4360
4412
  </span>
4413
+ {canBulkRestartSelectedKind && (
4414
+ <button
4415
+ type="button"
4416
+ onClick={() => setShowBulkRestartConfirm(true)}
4417
+ disabled={checkedItems.length === 0 || isBulkMutating}
4418
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium btn-brand-muted disabled:opacity-50 disabled:pointer-events-none rounded-lg transition-colors"
4419
+ >
4420
+ <RefreshCw className={clsx('w-3.5 h-3.5', isBulkRestarting && 'animate-spin')} />
4421
+ {isBulkRestarting ? 'Restarting...' : 'Restart'}
4422
+ </button>
4423
+ )}
4424
+ {canBulkScaleSelectedKind && (
4425
+ <button
4426
+ type="button"
4427
+ onClick={openBulkScaleDialog}
4428
+ disabled={checkedItems.length === 0 || isBulkMutating}
4429
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-theme-elevated hover:bg-theme-hover disabled:opacity-50 disabled:pointer-events-none text-theme-text-primary border border-theme-border rounded-lg transition-colors"
4430
+ >
4431
+ <Scale className="w-3.5 h-3.5" />
4432
+ {isBulkScaling ? 'Scaling...' : 'Scale'}
4433
+ </button>
4434
+ )}
4435
+ {onBulkDelete && (
4436
+ <button
4437
+ type="button"
4438
+ onClick={() => setShowBulkDeleteConfirm(true)}
4439
+ disabled={checkedItems.length === 0 || isBulkMutating}
4440
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:pointer-events-none text-white rounded-lg transition-colors"
4441
+ >
4442
+ <Trash2 className="w-3.5 h-3.5" />
4443
+ Delete
4444
+ </button>
4445
+ )}
4361
4446
  <button
4362
- onClick={() => setShowBulkDeleteConfirm(true)}
4363
- disabled={checkedItems.length === 0}
4364
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:pointer-events-none text-white rounded-lg transition-colors"
4365
- >
4366
- <Trash2 className="w-3.5 h-3.5" />
4367
- Delete
4368
- </button>
4369
- <button
4447
+ type="button"
4370
4448
  onClick={exitBulkMode}
4371
4449
  className="px-3 py-1.5 text-xs text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors"
4372
4450
  >
@@ -4724,13 +4802,7 @@ export function ResourcesView({
4724
4802
  open={showBulkDeleteConfirm}
4725
4803
  onClose={() => { setShowBulkDeleteConfirm(false); setBulkForceDelete(false) }}
4726
4804
  onConfirm={() => {
4727
- const items = checkedItems.map(r => ({
4728
- kind: selectedKind.name,
4729
- group: selectedKind.group,
4730
- namespace: r.metadata?.namespace || '',
4731
- name: r.metadata?.name || '',
4732
- }))
4733
- onBulkDelete?.(items, {
4805
+ onBulkDelete?.(checkedBulkItems, {
4734
4806
  force: bulkForceDelete,
4735
4807
  onSuccess: () => {
4736
4808
  exitBulkMode()
@@ -4741,7 +4813,7 @@ export function ResourcesView({
4741
4813
  }}
4742
4814
  title={`Delete ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
4743
4815
  message={`You are about to delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}. This action cannot be undone.`}
4744
- details={checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n')}
4816
+ details={checkedItemDetails}
4745
4817
  confirmLabel={bulkForceDelete ? `Force Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}` : `Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}`}
4746
4818
  variant="danger"
4747
4819
  isLoading={isBulkDeleting}
@@ -4757,6 +4829,80 @@ export function ResourcesView({
4757
4829
  <span>Force delete (strips finalizers and bypasses grace period)</span>
4758
4830
  </label>
4759
4831
  </ConfirmDialog>
4832
+ <ConfirmDialog
4833
+ open={showBulkRestartConfirm}
4834
+ onClose={() => setShowBulkRestartConfirm(false)}
4835
+ onConfirm={() => {
4836
+ onBulkRestart?.(checkedBulkItems, {
4837
+ onSuccess: () => {
4838
+ exitBulkMode()
4839
+ setShowBulkRestartConfirm(false)
4840
+ },
4841
+ })
4842
+ }}
4843
+ title={`Restart ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
4844
+ message={`This will trigger a rolling restart for ${checkedItems.length} selected workload${checkedItems.length > 1 ? 's' : ''}.`}
4845
+ details={checkedItemDetails}
4846
+ confirmLabel={`Restart ${checkedItems.length} workload${checkedItems.length > 1 ? 's' : ''}`}
4847
+ variant="warning"
4848
+ isLoading={isBulkRestarting}
4849
+ isClosable
4850
+ />
4851
+ <ConfirmDialog
4852
+ open={showBulkScaleDialog}
4853
+ onClose={() => setShowBulkScaleDialog(false)}
4854
+ onConfirm={() => {
4855
+ onBulkScale?.(checkedBulkItems, bulkScaleReplicas, {
4856
+ onSuccess: () => {
4857
+ exitBulkMode()
4858
+ setShowBulkScaleDialog(false)
4859
+ },
4860
+ })
4861
+ }}
4862
+ title={`Scale ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
4863
+ message={`Set every selected workload to exactly ${bulkScaleReplicas} replica${bulkScaleReplicas === 1 ? '' : 's'}.`}
4864
+ details={checkedItemDetails}
4865
+ confirmLabel={`Scale to ${bulkScaleReplicas}`}
4866
+ variant={bulkScaleReplicas === 0 ? 'danger' : 'warning'}
4867
+ isLoading={isBulkScaling}
4868
+ isClosable
4869
+ >
4870
+ <div className="space-y-3">
4871
+ <div className="flex items-center justify-center gap-3">
4872
+ <button
4873
+ type="button"
4874
+ onClick={() => setBulkScaleReplicas(Math.max(0, bulkScaleReplicas - 1))}
4875
+ disabled={bulkScaleReplicas <= 0}
4876
+ className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
4877
+ >
4878
+ <Minus className="w-5 h-5" />
4879
+ </button>
4880
+ <input
4881
+ type="number"
4882
+ min="0"
4883
+ max="10000"
4884
+ value={bulkScaleReplicas}
4885
+ onChange={(e) => setBulkScaleReplicas(Math.min(10000, Math.max(0, Number.parseInt(e.target.value, 10) || 0)))}
4886
+ className="w-24 text-center text-2xl font-semibold bg-theme-elevated border border-theme-border rounded-lg py-2 text-theme-text-primary focus:outline-none focus:border-skyhook-500"
4887
+ autoFocus
4888
+ />
4889
+ <button
4890
+ type="button"
4891
+ onClick={() => setBulkScaleReplicas(Math.min(10000, bulkScaleReplicas + 1))}
4892
+ disabled={bulkScaleReplicas >= 10000}
4893
+ className="p-2 rounded-lg bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
4894
+ >
4895
+ <Plus className="w-5 h-5" />
4896
+ </button>
4897
+ </div>
4898
+ <div className="text-xs text-theme-text-tertiary text-center">
4899
+ {commonBulkScaleReplicas === null ? 'Current replicas vary across the selected workloads.' : `Current: ${commonBulkScaleReplicas} replicas`}
4900
+ </div>
4901
+ <p className="text-xs text-theme-text-secondary text-center">
4902
+ All selected workloads will be set to the same replica count. Autoscalers may override it.
4903
+ </p>
4904
+ </div>
4905
+ </ConfirmDialog>
4760
4906
  </ResourcesViewDataContext.Provider>
4761
4907
  )
4762
4908
  }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getPodProblems } from './resource-utils'
3
+
4
+ describe('getPodProblems', () => {
5
+ it('includes the pod status message for evicted pods', () => {
6
+ const detail = 'Usage of EmptyDir volume "logs-nginx" exceeds the limit "2Gi".'
7
+
8
+ expect(
9
+ getPodProblems({
10
+ status: {
11
+ phase: 'Failed',
12
+ reason: 'Evicted',
13
+ message: detail,
14
+ },
15
+ }),
16
+ ).toContainEqual({ severity: 'high', message: 'Evicted', detail })
17
+ })
18
+
19
+ it('keeps exit-code labels stable while surfacing terminated messages', () => {
20
+ const detail = 'Container process exited after receiving SIGKILL.'
21
+
22
+ expect(
23
+ getPodProblems({
24
+ status: {
25
+ phase: 'Running',
26
+ containerStatuses: [
27
+ {
28
+ name: 'api',
29
+ restartCount: 0,
30
+ state: {
31
+ terminated: {
32
+ exitCode: 137,
33
+ reason: 'Error',
34
+ message: detail,
35
+ },
36
+ },
37
+ },
38
+ ],
39
+ },
40
+ }),
41
+ ).toContainEqual({ severity: 'high', message: 'Exit Code 137', detail })
42
+ })
43
+
44
+ it('keeps waiting-state labels stable while surfacing kubelet messages', () => {
45
+ const detail = 'Back-off pulling image "registry.example.com/api:missing".'
46
+
47
+ expect(
48
+ getPodProblems({
49
+ status: {
50
+ phase: 'Pending',
51
+ containerStatuses: [
52
+ {
53
+ name: 'api',
54
+ restartCount: 0,
55
+ state: {
56
+ waiting: {
57
+ reason: 'ImagePullBackOff',
58
+ message: detail,
59
+ },
60
+ },
61
+ },
62
+ ],
63
+ },
64
+ }),
65
+ ).toContainEqual({ severity: 'critical', message: 'ImagePullBackOff', detail })
66
+ })
67
+
68
+ it('infers sandbox startup stalls only for scheduled pods and follows backend severity gates', () => {
69
+ expect(
70
+ getPodProblems({
71
+ metadata: { creationTimestamp: new Date(Date.now() - 20 * 60 * 1000).toISOString() },
72
+ spec: { nodeName: 'worker-1' },
73
+ status: {
74
+ phase: 'Pending',
75
+ containerStatuses: [
76
+ {
77
+ name: 'api',
78
+ restartCount: 0,
79
+ state: { waiting: { reason: 'ContainerCreating' } },
80
+ },
81
+ ],
82
+ },
83
+ }),
84
+ ).toContainEqual({ severity: 'high', message: 'Sandbox Startup Stalled' })
85
+
86
+ expect(
87
+ getPodProblems({
88
+ metadata: { creationTimestamp: new Date(Date.now() - 45 * 60 * 1000).toISOString() },
89
+ spec: { nodeName: 'worker-1' },
90
+ status: {
91
+ phase: 'Pending',
92
+ containerStatuses: [
93
+ {
94
+ name: 'api',
95
+ restartCount: 0,
96
+ state: { waiting: { reason: 'ContainerCreating' } },
97
+ },
98
+ ],
99
+ },
100
+ }),
101
+ ).toContainEqual({ severity: 'critical', message: 'Sandbox Startup Stalled' })
102
+
103
+ expect(
104
+ getPodProblems({
105
+ metadata: { creationTimestamp: new Date(Date.now() - 45 * 60 * 1000).toISOString() },
106
+ spec: { nodeName: 'worker-1' },
107
+ status: {
108
+ phase: 'Pending',
109
+ conditions: [
110
+ {
111
+ type: 'PodScheduled',
112
+ status: 'False',
113
+ reason: 'Unschedulable',
114
+ },
115
+ ],
116
+ containerStatuses: [
117
+ {
118
+ name: 'api',
119
+ restartCount: 0,
120
+ state: { waiting: { reason: 'ContainerCreating' } },
121
+ },
122
+ ],
123
+ },
124
+ }),
125
+ ).not.toContainEqual(expect.objectContaining({ message: 'Sandbox Startup Stalled' }))
126
+ })
127
+ })
@@ -51,3 +51,59 @@ describe('PodRenderer envFrom expansion', () => {
51
51
  expect(html).not.toContain('PUBLIC_URL<!-- -->=')
52
52
  })
53
53
  })
54
+
55
+ describe('PodRenderer issues banner', () => {
56
+ it('renders pod status messages for evicted pods', () => {
57
+ const html = renderToString(
58
+ <PodRenderer
59
+ data={{
60
+ metadata: { name: 'nginx', namespace: 'default' },
61
+ spec: { containers: [{ name: 'nginx', image: 'nginx:latest' }] },
62
+ status: {
63
+ phase: 'Failed',
64
+ reason: 'Evicted',
65
+ message: 'Usage of EmptyDir volume "logs-nginx" exceeds the limit "2Gi".',
66
+ },
67
+ }}
68
+ onCopy={() => undefined}
69
+ copied={null}
70
+ />,
71
+ )
72
+
73
+ expect(html).toContain('Issues Detected')
74
+ expect(html).toContain('Evicted')
75
+ expect(html).toContain('Usage of EmptyDir volume')
76
+ expect(html).toContain('exceeds the limit')
77
+ })
78
+
79
+ it('wraps long issue detail text inside the banner', () => {
80
+ const html = renderToString(
81
+ <PodRenderer
82
+ data={{
83
+ metadata: { name: 'api', namespace: 'default' },
84
+ spec: { containers: [{ name: 'api', image: 'registry.example.com/api:missing' }] },
85
+ status: {
86
+ phase: 'Pending',
87
+ containerStatuses: [
88
+ {
89
+ name: 'api',
90
+ restartCount: 0,
91
+ state: {
92
+ waiting: {
93
+ reason: 'ImagePullBackOff',
94
+ message: `Back-off pulling image "${'a'.repeat(240)}"`,
95
+ },
96
+ },
97
+ },
98
+ ],
99
+ },
100
+ }}
101
+ onCopy={() => undefined}
102
+ copied={null}
103
+ />,
104
+ )
105
+
106
+ expect(html).toContain('ImagePullBackOff')
107
+ expect(html).toContain('min-w-0 break-words')
108
+ })
109
+ })
@@ -347,7 +347,7 @@ export function PodRenderer({
347
347
  {podProblems.map((p, i) => (
348
348
  <li key={i} className="flex items-start gap-1.5">
349
349
  <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0 mt-1', SEVERITY_DOT_COLOR[p.severity])} />
350
- <span className="text-red-600 dark:text-red-400">
350
+ <span className="min-w-0 break-words text-red-600 dark:text-red-400">
351
351
  {p.message}
352
352
  {p.detail && <span className="text-theme-text-secondary">: {p.detail}</span>}
353
353
  </span>
@@ -267,26 +267,39 @@ export function getPodProblems(pod: any): PodProblem[] {
267
267
  const initContainerStatuses = pod.status?.initContainerStatuses || []
268
268
  const conditions = pod.status?.conditions || []
269
269
  const phase = pod.status?.phase
270
+ const podStatusMessage = pod.status?.message || undefined
271
+ const hasPodIP = Boolean(pod.status?.podIP || pod.status?.podIPs?.some((ip: any) => ip?.ip))
272
+ const hasScheduledNode = Boolean(pod.spec?.nodeName)
273
+ const hasUnschedulableCondition = conditions.some((cond: any) => cond.type === 'PodScheduled' && cond.status === 'False')
274
+ const hasContainerCreating = containerStatuses.some((cs: any) => cs.state?.waiting?.reason === 'ContainerCreating')
275
+ const createdAtMs = pod.metadata?.creationTimestamp ? new Date(pod.metadata.creationTimestamp).getTime() : NaN
276
+ const podAgeMs = Number.isFinite(createdAtMs) ? Date.now() - createdAtMs : 0
277
+ const sandboxStartupStallAgeMs = 10 * 60 * 1000
278
+ const sandboxStartupStallCriticalAgeMs = 30 * 60 * 1000
279
+ const inferredSandboxStartupStall = phase === 'Pending' && hasScheduledNode && !hasUnschedulableCondition && hasContainerCreating && !hasPodIP && podAgeMs > sandboxStartupStallAgeMs
280
+ const inferredSandboxStartupStallSeverity: PodProblem['severity'] = podAgeMs >= sandboxStartupStallCriticalAgeMs ? 'critical' : 'high'
281
+ let hasSandboxStartupStallProblem = false
270
282
 
271
283
  // Failed or Unknown phase
272
284
  if (phase === 'Failed' && pod.status?.reason !== 'Evicted') {
273
- problems.push({ severity: 'critical', message: 'Failed' })
285
+ problems.push({ severity: 'critical', message: 'Failed', detail: podStatusMessage })
274
286
  } else if (phase === 'Unknown') {
275
- problems.push({ severity: 'high', message: 'Unknown' })
287
+ problems.push({ severity: 'high', message: 'Unknown', detail: podStatusMessage })
276
288
  }
277
289
 
278
290
  // Init container failures
279
291
  for (const cs of initContainerStatuses) {
280
292
  if (cs.state?.waiting?.reason && cs.state.waiting.reason !== 'PodInitializing') {
281
293
  const reason = cs.state.waiting.reason
294
+ const detail = cs.state.waiting.message || undefined
282
295
  if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
283
- problems.push({ severity: 'critical', message: `Init: ${reason}` })
296
+ problems.push({ severity: 'critical', message: `Init: ${reason}`, detail })
284
297
  } else {
285
- problems.push({ severity: 'high', message: `Init: ${reason}` })
298
+ problems.push({ severity: 'high', message: `Init: ${reason}`, detail })
286
299
  }
287
300
  }
288
301
  if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
289
- problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}` })
302
+ problems.push({ severity: 'high', message: `Init: Exit Code ${cs.state.terminated.exitCode}`, detail: cs.state.terminated.message || undefined })
290
303
  }
291
304
  }
292
305
 
@@ -294,30 +307,32 @@ export function getPodProblems(pod: any): PodProblem[] {
294
307
  // Check waiting state
295
308
  if (cs.state?.waiting?.reason) {
296
309
  const reason = cs.state.waiting.reason
310
+ const detail = cs.state.waiting.message || undefined
297
311
  if (['CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull'].includes(reason)) {
298
- problems.push({ severity: 'critical', message: reason })
312
+ problems.push({ severity: 'critical', message: reason, detail })
299
313
  } else if (reason === 'CreateContainerConfigError') {
300
- problems.push({ severity: 'critical', message: 'Config Error' })
314
+ problems.push({ severity: 'critical', message: 'Config Error', detail })
301
315
  } else if (reason === 'ContainerCannotRun') {
302
- problems.push({ severity: 'critical', message: 'Cannot Run' })
316
+ problems.push({ severity: 'critical', message: 'Cannot Run', detail })
303
317
  } else if (reason !== 'ContainerCreating' && reason !== 'PodInitializing') {
304
- problems.push({ severity: 'high', message: reason })
318
+ problems.push({ severity: 'high', message: reason, detail })
305
319
  }
306
320
  }
307
321
  // Check terminated state
308
322
  if (cs.state?.terminated?.reason === 'OOMKilled') {
309
- problems.push({ severity: 'critical', message: 'OOMKilled' })
323
+ problems.push({ severity: 'critical', message: 'OOMKilled', detail: cs.state.terminated.message || undefined })
310
324
  } else if (cs.state?.terminated?.exitCode && cs.state.terminated.exitCode !== 0) {
311
- problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}` })
325
+ problems.push({ severity: 'high', message: `Exit Code ${cs.state.terminated.exitCode}`, detail: cs.state.terminated.message || undefined })
312
326
  }
313
327
  // High restart count
314
328
  if (cs.restartCount > 5) {
315
329
  problems.push({ severity: 'medium', message: `${cs.restartCount} restarts` })
316
330
  }
317
331
  // Volume mount issues from last state
318
- const lastMsg = cs.lastState?.terminated?.message?.toLowerCase() || ''
332
+ const lastMsgRaw = cs.lastState?.terminated?.message || ''
333
+ const lastMsg = lastMsgRaw.toLowerCase()
319
334
  if (lastMsg.includes('failed to mount') || lastMsg.includes('failedattachvolume')) {
320
- problems.push({ severity: 'high', message: 'Volume Mount Failed' })
335
+ problems.push({ severity: 'high', message: 'Volume Mount Failed', detail: lastMsgRaw || undefined })
321
336
  }
322
337
  }
323
338
 
@@ -332,23 +347,29 @@ export function getPodProblems(pod: any): PodProblem[] {
332
347
  if (cond.type === 'ContainersReady' && cond.status === 'False') {
333
348
  const msg = (cond.message || '').toLowerCase()
334
349
  if (msg.includes('readiness')) {
335
- problems.push({ severity: 'medium', message: 'Readiness Probe Failing' })
350
+ problems.push({ severity: 'medium', message: 'Readiness Probe Failing', detail: cond.message || undefined })
336
351
  } else if (msg.includes('liveness')) {
337
- problems.push({ severity: 'high', message: 'Liveness Probe Failing' })
352
+ problems.push({ severity: 'high', message: 'Liveness Probe Failing', detail: cond.message || undefined })
338
353
  }
339
354
  }
340
355
  // IP allocation failures (subnet exhaustion)
341
356
  if (cond.type === 'PodReadyToStartContainers' && cond.status === 'False') {
342
357
  const msg = (cond.message || '').toLowerCase()
343
- if (msg.includes('failed to assign an ip') || msg.includes('pod sandbox')) {
344
- problems.push({ severity: 'critical', message: 'IP Allocation Failed' })
358
+ if (msg.includes('failed to assign an ip')) {
359
+ problems.push({ severity: 'critical', message: 'IP Allocation Failed', detail: cond.message || undefined })
360
+ } else if (msg.includes('pod sandbox')) {
361
+ problems.push({ severity: 'critical', message: 'Sandbox Startup Stalled', detail: cond.message || undefined })
362
+ hasSandboxStartupStallProblem = true
345
363
  }
346
364
  }
347
365
  }
366
+ if (inferredSandboxStartupStall && !hasSandboxStartupStallProblem) {
367
+ problems.push({ severity: inferredSandboxStartupStallSeverity, message: 'Sandbox Startup Stalled' })
368
+ }
348
369
 
349
370
  // Evicted pods
350
371
  if (phase === 'Failed' && pod.status?.reason === 'Evicted') {
351
- problems.push({ severity: 'high', message: 'Evicted' })
372
+ problems.push({ severity: 'high', message: 'Evicted', detail: podStatusMessage })
352
373
  }
353
374
 
354
375
  // Stuck terminating (zombie pod)
package/src/types/core.ts CHANGED
@@ -47,6 +47,15 @@ export const OPTIONAL_RESOURCE_KINDS: ReadonlyArray<keyof ResourcePermissions> =
47
47
  'verticalPodAutoscalers',
48
48
  ]
49
49
 
50
+ // Per-workload write permissions. Field names must match
51
+ // WorkloadWritePermissions in internal/k8s/capabilities.go.
52
+ export interface WorkloadWritePermissions {
53
+ deployments: boolean
54
+ daemonSets: boolean
55
+ statefulSets: boolean
56
+ rollouts: boolean
57
+ }
58
+
50
59
  // Feature capabilities based on RBAC permissions
51
60
  export interface Capabilities {
52
61
  exec: boolean // Terminal feature (pods/exec)
@@ -57,6 +66,7 @@ export interface Capabilities {
57
66
  secretsUpdate: boolean // Update secrets (inline editing)
58
67
  helmWrite: boolean // Helm write operations (install, upgrade, rollback, uninstall, apply values)
59
68
  nodeWrite: boolean // Node write operations (cordon, uncordon, drain)
69
+ workloadWrites?: WorkloadWritePermissions // Workload patch permissions (restart/scale controls)
60
70
  mcpEnabled: boolean // MCP server is running
61
71
  // How / where this Radar binary is running. Optional on the wire so a
62
72
  // newer frontend (e.g. radar-hub-web bundling a fresher @skyhook-io/radar-app)
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ canBulkRestartKind,
4
+ canBulkScaleKind,
5
+ intersectWorkloadWrites,
6
+ } from './bulk-workload-actions'
7
+ import type { Capabilities, WorkloadWritePermissions } from '../types/core'
8
+
9
+ const writes = (overrides: Partial<WorkloadWritePermissions> = {}): WorkloadWritePermissions => ({
10
+ deployments: false,
11
+ daemonSets: false,
12
+ statefulSets: false,
13
+ rollouts: false,
14
+ ...overrides,
15
+ })
16
+
17
+ const caps = (workloadWrites: WorkloadWritePermissions): Pick<Capabilities, 'workloadWrites'> => ({
18
+ workloadWrites,
19
+ })
20
+
21
+ describe('bulk workload action gating', () => {
22
+ it('allows restart for patchable workload kinds', () => {
23
+ const all = writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })
24
+
25
+ expect(canBulkRestartKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
26
+ expect(canBulkRestartKind({ name: 'daemonsets', group: 'apps' }, all)).toBe(true)
27
+ expect(canBulkRestartKind({ name: 'statefulsets', group: 'apps' }, all)).toBe(true)
28
+ expect(canBulkRestartKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(true)
29
+ })
30
+
31
+ it('requires the apps group for built-in workload restart', () => {
32
+ const all = writes({ deployments: true, daemonSets: true, statefulSets: true })
33
+
34
+ expect(canBulkRestartKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
35
+ expect(canBulkRestartKind({ name: 'deployments', group: 'example.com' }, all)).toBe(false)
36
+ expect(canBulkRestartKind({ name: 'deployments' }, all)).toBe(false)
37
+ expect(canBulkRestartKind({ name: 'daemonsets', group: 'example.com' }, all)).toBe(false)
38
+ expect(canBulkRestartKind({ name: 'statefulsets', group: 'example.com' }, all)).toBe(false)
39
+ })
40
+
41
+ it('requires the Argo group for rollout restart', () => {
42
+ const all = writes({ rollouts: true })
43
+
44
+ expect(canBulkRestartKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(true)
45
+ expect(canBulkRestartKind({ name: 'rollouts', group: 'example.com' }, all)).toBe(false)
46
+ expect(canBulkRestartKind({ name: 'rollouts' }, all)).toBe(false)
47
+ })
48
+
49
+ it('allows scale only for deployments and statefulsets', () => {
50
+ const all = writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })
51
+
52
+ expect(canBulkScaleKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
53
+ expect(canBulkScaleKind({ name: 'statefulsets', group: 'apps' }, all)).toBe(true)
54
+ expect(canBulkScaleKind({ name: 'daemonsets', group: 'apps' }, all)).toBe(false)
55
+ expect(canBulkScaleKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(false)
56
+ expect(canBulkScaleKind({ name: 'deployments', group: 'example.com' }, all)).toBe(false)
57
+ expect(canBulkScaleKind({ name: 'deployments' }, all)).toBe(false)
58
+ })
59
+
60
+ it('withholds actions when permissions are absent', () => {
61
+ expect(canBulkRestartKind({ name: 'deployments', group: 'apps' }, undefined)).toBe(false)
62
+ expect(canBulkScaleKind({ name: 'deployments', group: 'apps' }, undefined)).toBe(false)
63
+ expect(intersectWorkloadWrites(undefined)).toBeUndefined()
64
+ expect(intersectWorkloadWrites([])).toBeUndefined()
65
+ })
66
+
67
+ it('intersects multi-namespace permissions with AND semantics', () => {
68
+ expect(intersectWorkloadWrites([
69
+ caps(writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })),
70
+ caps(writes({ deployments: true, statefulSets: true })),
71
+ ])).toEqual({
72
+ deployments: true,
73
+ daemonSets: false,
74
+ statefulSets: true,
75
+ rollouts: false,
76
+ })
77
+ })
78
+ })
@@ -0,0 +1,50 @@
1
+ import type { Capabilities, WorkloadWritePermissions } from '../types/core'
2
+
3
+ export type BulkWorkloadKindInfo = {
4
+ name: string
5
+ group?: string
6
+ }
7
+
8
+ export function canBulkRestartKind(
9
+ kind: BulkWorkloadKindInfo | null | undefined,
10
+ writes: WorkloadWritePermissions | undefined,
11
+ ): boolean {
12
+ switch (kind?.name.toLowerCase()) {
13
+ case 'deployments':
14
+ return kind.group === 'apps' && writes?.deployments === true
15
+ case 'daemonsets':
16
+ return kind.group === 'apps' && writes?.daemonSets === true
17
+ case 'statefulsets':
18
+ return kind.group === 'apps' && writes?.statefulSets === true
19
+ case 'rollouts':
20
+ return kind.group === 'argoproj.io' && writes?.rollouts === true
21
+ default:
22
+ return false
23
+ }
24
+ }
25
+
26
+ export function canBulkScaleKind(
27
+ kind: BulkWorkloadKindInfo | null | undefined,
28
+ writes: WorkloadWritePermissions | undefined,
29
+ ): boolean {
30
+ switch (kind?.name.toLowerCase()) {
31
+ case 'deployments':
32
+ return kind.group === 'apps' && writes?.deployments === true
33
+ case 'statefulsets':
34
+ return kind.group === 'apps' && writes?.statefulSets === true
35
+ default:
36
+ return false
37
+ }
38
+ }
39
+
40
+ export function intersectWorkloadWrites(
41
+ capabilities: Array<Pick<Capabilities, 'workloadWrites'>> | undefined,
42
+ ): WorkloadWritePermissions | undefined {
43
+ if (!capabilities || capabilities.length === 0) return undefined
44
+ return {
45
+ deployments: capabilities.every(c => c.workloadWrites?.deployments === true),
46
+ daemonSets: capabilities.every(c => c.workloadWrites?.daemonSets === true),
47
+ statefulSets: capabilities.every(c => c.workloadWrites?.statefulSets === true),
48
+ rollouts: capabilities.every(c => c.workloadWrites?.rollouts === true),
49
+ }
50
+ }
@@ -22,3 +22,4 @@ export * from './rbac-badges'
22
22
  export * from './git-provider-urls'
23
23
  export * from './applications'
24
24
  export * from './topology-neighborhood'
25
+ export * from './bulk-workload-actions'