@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 +6 -7
- package/src/components/issues/IssuesView.tsx +4 -6
- package/src/components/issues/types.ts +22 -2
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
- package/src/components/resources/ResourcesView.tsx +167 -21
- package/src/components/resources/get-pod-problems.test.ts +127 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
- package/src/components/resources/renderers/PodRenderer.tsx +1 -1
- package/src/components/resources/resource-utils.ts +39 -18
- package/src/types/core.ts +10 -0
- package/src/utils/bulk-workload-actions.test.ts +78 -0
- package/src/utils/bulk-workload-actions.ts +50 -0
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.7.
|
|
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.
|
|
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/
|
|
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.
|
|
100
|
-
"react-dom": "^19.2.
|
|
98
|
+
"react": "^19.2.7",
|
|
99
|
+
"react-dom": "^19.2.7",
|
|
101
100
|
"typescript": "^6.0.2",
|
|
102
|
-
"vitest": "^4.1.
|
|
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
|
|
275
|
-
//
|
|
276
|
-
|
|
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
|
|
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
|
|
147
|
-
*
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
|
2116
|
-
//
|
|
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 =
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
|
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')
|
|
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
|
+
}
|
package/src/utils/index.ts
CHANGED