@skyhook-io/k8s-ui 1.7.14 → 1.8.0
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 +7 -7
- package/src/components/applications/ApplicationsList.tsx +131 -115
- package/src/components/applications/index.ts +3 -1
- package/src/components/audit/AuditCard.tsx +1 -1
- package/src/components/charts/AreaChart.tsx +52 -18
- package/src/components/charts/MetricsSummary.tsx +12 -3
- package/src/components/charts/saturation.test.ts +34 -0
- package/src/components/charts/saturation.ts +1 -0
- package/src/components/charts/types.ts +5 -4
- package/src/components/checks/ChecksView.tsx +12 -23
- package/src/components/checks/severity.ts +11 -11
- package/src/components/gitops/GitOpsTableView.tsx +298 -274
- package/src/components/issues/IssuesView.tsx +4 -6
- package/src/components/issues/severity.ts +19 -11
- package/src/components/issues/types.ts +22 -2
- package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
- package/src/components/resources/ResourcesSidebar.tsx +3 -1
- package/src/components/resources/ResourcesView.tsx +180 -21
- package/src/components/resources/get-pod-problems.test.ts +127 -0
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
- package/src/components/resources/renderers/HPARenderer.tsx +93 -86
- package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
- package/src/components/resources/renderers/PodRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
- package/src/components/resources/resource-utils-hpa.test.ts +30 -0
- package/src/components/resources/resource-utils-hpa.ts +106 -0
- package/src/components/resources/resource-utils.ts +41 -28
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
- package/src/components/ui/DistributionBar.tsx +88 -0
- package/src/components/ui/Facet.tsx +130 -0
- package/src/components/ui/PageHeader.tsx +43 -0
- package/src/components/ui/SearchPillInput.tsx +237 -0
- package/src/components/ui/SortableTh.tsx +58 -0
- package/src/components/ui/SummaryTile.tsx +60 -0
- package/src/components/ui/drawer-components.tsx +56 -17
- package/src/components/ui/index.ts +12 -0
- package/src/components/workload/WorkloadView.tsx +18 -1
- package/src/theme/tailwind-theme.css +1 -0
- package/src/theme/variables.css +7 -3
- package/src/types/core.ts +64 -2
- 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/src/utils/rbac-blast-radius.test.ts +48 -3
- package/src/utils/rbac-blast-radius.ts +14 -4
- package/tsconfig.json +1 -0
|
@@ -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>
|
|
@@ -1,23 +1,21 @@
|
|
|
1
1
|
import type { IssueSeverity } from './types';
|
|
2
|
+
import { BADGE_SEVERITY_COLORS as sev } from '../ui/Badge';
|
|
2
3
|
|
|
3
|
-
// Visual language for the 2-tier Issues severity,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// Class strings are literal so each consuming app's Tailwind @source scan emits
|
|
10
|
-
// them.
|
|
4
|
+
// Visual language for the 2-tier Issues severity. critical = red, warning =
|
|
5
|
+
// amber. Issues and Checks are different severity axes but must read as one
|
|
6
|
+
// product — both pull from the canonical Badge severity tones
|
|
7
|
+
// (BADGE_SEVERITY_COLORS), so their pills match each other and every status
|
|
8
|
+
// badge elsewhere.
|
|
11
9
|
|
|
12
10
|
export const ISSUE_SEVERITY_LABEL: Record<IssueSeverity, string> = {
|
|
13
11
|
critical: 'Critical',
|
|
14
12
|
warning: 'Warning',
|
|
15
13
|
};
|
|
16
14
|
|
|
17
|
-
// Pill badge — the loud, explicit severity signal on a row.
|
|
15
|
+
// Pill badge — the loud, explicit severity signal on a row (rendered with `badge-sm`).
|
|
18
16
|
export const ISSUE_SEVERITY_BADGE_CLASS: Record<IssueSeverity, string> = {
|
|
19
|
-
critical:
|
|
20
|
-
warning:
|
|
17
|
+
critical: sev.error,
|
|
18
|
+
warning: sev.warning,
|
|
21
19
|
};
|
|
22
20
|
|
|
23
21
|
// Solid fill — dots + the proportional distribution bar segments.
|
|
@@ -73,12 +71,18 @@ const CATEGORY_LABEL: Record<string, string> = {
|
|
|
73
71
|
high_restart: 'High restart count',
|
|
74
72
|
missing_config_ref: 'Missing reference',
|
|
75
73
|
pdb_blocks_evictions: 'PDB blocks evictions',
|
|
74
|
+
secret_sync_failed: 'Secret sync failed',
|
|
76
75
|
service_no_endpoints: 'No endpoints',
|
|
77
76
|
ingress_backend_missing: 'Ingress backend missing',
|
|
77
|
+
load_balancer_pending: 'Load balancer pending',
|
|
78
|
+
gateway_not_ready: 'Gateway not ready',
|
|
79
|
+
gateway_route_invalid: 'Gateway route invalid',
|
|
78
80
|
dns_failure: 'DNS failure',
|
|
79
81
|
network_policy_block: 'Network policy block',
|
|
80
82
|
pvc_pending: 'PVC pending',
|
|
81
83
|
pvc_lost: 'PVC lost',
|
|
84
|
+
pv_failed: 'PV failed',
|
|
85
|
+
pvc_resize_failed: 'PVC resize failed',
|
|
82
86
|
volume_mount_failed: 'Volume mount failed',
|
|
83
87
|
volume_access_mode_conflict: 'Volume access conflict',
|
|
84
88
|
job_failed: 'Job failed',
|
|
@@ -89,6 +93,10 @@ const CATEGORY_LABEL: Record<string, string> = {
|
|
|
89
93
|
certificate_not_ready: 'Certificate not ready',
|
|
90
94
|
pod_security_violation: 'Pod Security violation',
|
|
91
95
|
node_not_ready: 'Node not ready',
|
|
96
|
+
node_provisioning_failed: 'Node provisioning failed',
|
|
97
|
+
apiservice_unavailable: 'APIService unavailable',
|
|
98
|
+
crossplane_reconcile_failed: 'Crossplane reconcile failed',
|
|
99
|
+
termination_stuck: 'Stuck terminating',
|
|
92
100
|
operator_condition_failed: 'Controller condition',
|
|
93
101
|
gitops_sync_failed: 'GitOps sync failed',
|
|
94
102
|
gitops_render_failed: 'GitOps render failed',
|
|
@@ -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
|
|
@@ -464,7 +464,9 @@ export function ResourcesSidebar({
|
|
|
464
464
|
|
|
465
465
|
return (
|
|
466
466
|
<div className={clsx('w-56 2xl:w-72 bg-theme-surface dark:bg-theme-base border-r border-theme-border overflow-y-auto overflow-x-hidden shrink-0', className)}>
|
|
467
|
-
|
|
467
|
+
{/* py-3 matches the table toolbar's vertical padding so the sidebar
|
|
468
|
+
header and the toolbar align (same band height + border line). */}
|
|
469
|
+
<div className="px-2 py-3 border-b border-theme-border">
|
|
468
470
|
<div className="relative">
|
|
469
471
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
470
472
|
<input
|
|
@@ -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)
|
|
@@ -2701,6 +2722,19 @@ export function ResourcesView({
|
|
|
2701
2722
|
scope: 'resources',
|
|
2702
2723
|
handler: () => handleSort('status'),
|
|
2703
2724
|
},
|
|
2725
|
+
{
|
|
2726
|
+
id: 'resources-bulk-select',
|
|
2727
|
+
keys: 'b',
|
|
2728
|
+
description: 'Toggle bulk select',
|
|
2729
|
+
category: 'Table',
|
|
2730
|
+
scope: 'resources',
|
|
2731
|
+
handler: () => {
|
|
2732
|
+
if (bulkMode) { exitBulkMode(); return }
|
|
2733
|
+
if (!canBulkSelect) return
|
|
2734
|
+
exitCompareMode()
|
|
2735
|
+
setBulkMode(true)
|
|
2736
|
+
},
|
|
2737
|
+
},
|
|
2704
2738
|
{
|
|
2705
2739
|
id: 'resources-clear-highlight',
|
|
2706
2740
|
keys: 'Escape',
|
|
@@ -3652,13 +3686,44 @@ export function ResourcesView({
|
|
|
3652
3686
|
return filteredResources.filter(r => checkedResources.has(getResourceKey(r)))
|
|
3653
3687
|
}, [filteredResources, checkedResources, getResourceKey])
|
|
3654
3688
|
|
|
3689
|
+
const checkedBulkItems = useMemo(() => {
|
|
3690
|
+
return checkedItems.map(r => ({
|
|
3691
|
+
kind: selectedKind.name,
|
|
3692
|
+
group: selectedKind.group,
|
|
3693
|
+
namespace: r.metadata?.namespace || '',
|
|
3694
|
+
name: r.metadata?.name || '',
|
|
3695
|
+
}))
|
|
3696
|
+
}, [checkedItems, selectedKind.name, selectedKind.group])
|
|
3697
|
+
|
|
3698
|
+
const checkedItemDetails = useMemo(() => {
|
|
3699
|
+
return checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n')
|
|
3700
|
+
}, [checkedItems])
|
|
3701
|
+
|
|
3702
|
+
const selectedKindName = selectedKind.name.toLowerCase()
|
|
3703
|
+
const canBulkRestartSelectedKind = onBulkRestart != null && BULK_RESTART_WORKLOAD_KINDS.has(selectedKindName)
|
|
3704
|
+
const canBulkScaleSelectedKind = onBulkScale != null && BULK_SCALE_WORKLOAD_KINDS.has(selectedKindName)
|
|
3705
|
+
const canBulkSelect = onBulkDelete != null || canBulkRestartSelectedKind || canBulkScaleSelectedKind
|
|
3706
|
+
const isBulkMutating = isBulkDeleting || isBulkRestarting || isBulkScaling
|
|
3707
|
+
|
|
3708
|
+
const openBulkScaleDialog = useCallback(() => {
|
|
3709
|
+
const replicas = checkedItems[0]?.spec?.replicas
|
|
3710
|
+
setBulkScaleReplicas(typeof replicas === 'number' ? replicas : 0)
|
|
3711
|
+
setShowBulkScaleDialog(true)
|
|
3712
|
+
}, [checkedItems])
|
|
3713
|
+
|
|
3714
|
+
const commonBulkScaleReplicas = useMemo(() => {
|
|
3715
|
+
if (checkedItems.length === 0) return null
|
|
3716
|
+
const first = checkedItems[0]?.spec?.replicas ?? 0
|
|
3717
|
+
return checkedItems.every(r => (r.spec?.replicas ?? 0) === first) ? first : null
|
|
3718
|
+
}, [checkedItems])
|
|
3719
|
+
|
|
3655
3720
|
const allVisibleChecked = filteredResources.length > 0 && checkedItems.length === filteredResources.length
|
|
3656
3721
|
|
|
3657
3722
|
const toggleCheckAll = useCallback(() => {
|
|
3658
3723
|
setCheckedResources(allVisibleChecked ? new Set() : new Set(filteredResources.map(getResourceKey)))
|
|
3659
3724
|
}, [allVisibleChecked, filteredResources, getResourceKey])
|
|
3660
3725
|
|
|
3661
|
-
const isCheckboxMode =
|
|
3726
|
+
const isCheckboxMode = canBulkSelect && bulkMode
|
|
3662
3727
|
|
|
3663
3728
|
// Filter columns by visibility
|
|
3664
3729
|
const columns = useMemo(() => {
|
|
@@ -4330,7 +4395,7 @@ export function ResourcesView({
|
|
|
4330
4395
|
</button>
|
|
4331
4396
|
</Tooltip>
|
|
4332
4397
|
)}
|
|
4333
|
-
{
|
|
4398
|
+
{canBulkSelect && (
|
|
4334
4399
|
<Tooltip content={bulkMode ? 'Exit bulk select mode' : 'Select multiple resources'}>
|
|
4335
4400
|
<button
|
|
4336
4401
|
onClick={() => {
|
|
@@ -4358,15 +4423,41 @@ export function ResourcesView({
|
|
|
4358
4423
|
<span className="text-sm font-medium text-theme-text-primary">
|
|
4359
4424
|
{checkedItems.length} selected
|
|
4360
4425
|
</span>
|
|
4426
|
+
{canBulkRestartSelectedKind && (
|
|
4427
|
+
<button
|
|
4428
|
+
type="button"
|
|
4429
|
+
onClick={() => setShowBulkRestartConfirm(true)}
|
|
4430
|
+
disabled={checkedItems.length === 0 || isBulkMutating}
|
|
4431
|
+
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"
|
|
4432
|
+
>
|
|
4433
|
+
<RefreshCw className={clsx('w-3.5 h-3.5', isBulkRestarting && 'animate-spin')} />
|
|
4434
|
+
{isBulkRestarting ? 'Restarting...' : 'Restart'}
|
|
4435
|
+
</button>
|
|
4436
|
+
)}
|
|
4437
|
+
{canBulkScaleSelectedKind && (
|
|
4438
|
+
<button
|
|
4439
|
+
type="button"
|
|
4440
|
+
onClick={openBulkScaleDialog}
|
|
4441
|
+
disabled={checkedItems.length === 0 || isBulkMutating}
|
|
4442
|
+
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"
|
|
4443
|
+
>
|
|
4444
|
+
<Scale className="w-3.5 h-3.5" />
|
|
4445
|
+
{isBulkScaling ? 'Scaling...' : 'Scale'}
|
|
4446
|
+
</button>
|
|
4447
|
+
)}
|
|
4448
|
+
{onBulkDelete && (
|
|
4449
|
+
<button
|
|
4450
|
+
type="button"
|
|
4451
|
+
onClick={() => setShowBulkDeleteConfirm(true)}
|
|
4452
|
+
disabled={checkedItems.length === 0 || isBulkMutating}
|
|
4453
|
+
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"
|
|
4454
|
+
>
|
|
4455
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
4456
|
+
Delete
|
|
4457
|
+
</button>
|
|
4458
|
+
)}
|
|
4361
4459
|
<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
|
|
4460
|
+
type="button"
|
|
4370
4461
|
onClick={exitBulkMode}
|
|
4371
4462
|
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
4463
|
>
|
|
@@ -4724,13 +4815,7 @@ export function ResourcesView({
|
|
|
4724
4815
|
open={showBulkDeleteConfirm}
|
|
4725
4816
|
onClose={() => { setShowBulkDeleteConfirm(false); setBulkForceDelete(false) }}
|
|
4726
4817
|
onConfirm={() => {
|
|
4727
|
-
|
|
4728
|
-
kind: selectedKind.name,
|
|
4729
|
-
group: selectedKind.group,
|
|
4730
|
-
namespace: r.metadata?.namespace || '',
|
|
4731
|
-
name: r.metadata?.name || '',
|
|
4732
|
-
}))
|
|
4733
|
-
onBulkDelete?.(items, {
|
|
4818
|
+
onBulkDelete?.(checkedBulkItems, {
|
|
4734
4819
|
force: bulkForceDelete,
|
|
4735
4820
|
onSuccess: () => {
|
|
4736
4821
|
exitBulkMode()
|
|
@@ -4741,7 +4826,7 @@ export function ResourcesView({
|
|
|
4741
4826
|
}}
|
|
4742
4827
|
title={`Delete ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
|
|
4743
4828
|
message={`You are about to delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}. This action cannot be undone.`}
|
|
4744
|
-
details={
|
|
4829
|
+
details={checkedItemDetails}
|
|
4745
4830
|
confirmLabel={bulkForceDelete ? `Force Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}` : `Delete ${checkedItems.length} resource${checkedItems.length > 1 ? 's' : ''}`}
|
|
4746
4831
|
variant="danger"
|
|
4747
4832
|
isLoading={isBulkDeleting}
|
|
@@ -4757,6 +4842,80 @@ export function ResourcesView({
|
|
|
4757
4842
|
<span>Force delete (strips finalizers and bypasses grace period)</span>
|
|
4758
4843
|
</label>
|
|
4759
4844
|
</ConfirmDialog>
|
|
4845
|
+
<ConfirmDialog
|
|
4846
|
+
open={showBulkRestartConfirm}
|
|
4847
|
+
onClose={() => setShowBulkRestartConfirm(false)}
|
|
4848
|
+
onConfirm={() => {
|
|
4849
|
+
onBulkRestart?.(checkedBulkItems, {
|
|
4850
|
+
onSuccess: () => {
|
|
4851
|
+
exitBulkMode()
|
|
4852
|
+
setShowBulkRestartConfirm(false)
|
|
4853
|
+
},
|
|
4854
|
+
})
|
|
4855
|
+
}}
|
|
4856
|
+
title={`Restart ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
|
|
4857
|
+
message={`This will trigger a rolling restart for ${checkedItems.length} selected workload${checkedItems.length > 1 ? 's' : ''}.`}
|
|
4858
|
+
details={checkedItemDetails}
|
|
4859
|
+
confirmLabel={`Restart ${checkedItems.length} workload${checkedItems.length > 1 ? 's' : ''}`}
|
|
4860
|
+
variant="warning"
|
|
4861
|
+
isLoading={isBulkRestarting}
|
|
4862
|
+
isClosable
|
|
4863
|
+
/>
|
|
4864
|
+
<ConfirmDialog
|
|
4865
|
+
open={showBulkScaleDialog}
|
|
4866
|
+
onClose={() => setShowBulkScaleDialog(false)}
|
|
4867
|
+
onConfirm={() => {
|
|
4868
|
+
onBulkScale?.(checkedBulkItems, bulkScaleReplicas, {
|
|
4869
|
+
onSuccess: () => {
|
|
4870
|
+
exitBulkMode()
|
|
4871
|
+
setShowBulkScaleDialog(false)
|
|
4872
|
+
},
|
|
4873
|
+
})
|
|
4874
|
+
}}
|
|
4875
|
+
title={`Scale ${checkedItems.length} ${selectedKind.kind}${checkedItems.length > 1 ? 's' : ''}?`}
|
|
4876
|
+
message={`Set every selected workload to exactly ${bulkScaleReplicas} replica${bulkScaleReplicas === 1 ? '' : 's'}.`}
|
|
4877
|
+
details={checkedItemDetails}
|
|
4878
|
+
confirmLabel={`Scale to ${bulkScaleReplicas}`}
|
|
4879
|
+
variant={bulkScaleReplicas === 0 ? 'danger' : 'warning'}
|
|
4880
|
+
isLoading={isBulkScaling}
|
|
4881
|
+
isClosable
|
|
4882
|
+
>
|
|
4883
|
+
<div className="space-y-3">
|
|
4884
|
+
<div className="flex items-center justify-center gap-3">
|
|
4885
|
+
<button
|
|
4886
|
+
type="button"
|
|
4887
|
+
onClick={() => setBulkScaleReplicas(Math.max(0, bulkScaleReplicas - 1))}
|
|
4888
|
+
disabled={bulkScaleReplicas <= 0}
|
|
4889
|
+
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"
|
|
4890
|
+
>
|
|
4891
|
+
<Minus className="w-5 h-5" />
|
|
4892
|
+
</button>
|
|
4893
|
+
<input
|
|
4894
|
+
type="number"
|
|
4895
|
+
min="0"
|
|
4896
|
+
max="10000"
|
|
4897
|
+
value={bulkScaleReplicas}
|
|
4898
|
+
onChange={(e) => setBulkScaleReplicas(Math.min(10000, Math.max(0, Number.parseInt(e.target.value, 10) || 0)))}
|
|
4899
|
+
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"
|
|
4900
|
+
autoFocus
|
|
4901
|
+
/>
|
|
4902
|
+
<button
|
|
4903
|
+
type="button"
|
|
4904
|
+
onClick={() => setBulkScaleReplicas(Math.min(10000, bulkScaleReplicas + 1))}
|
|
4905
|
+
disabled={bulkScaleReplicas >= 10000}
|
|
4906
|
+
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"
|
|
4907
|
+
>
|
|
4908
|
+
<Plus className="w-5 h-5" />
|
|
4909
|
+
</button>
|
|
4910
|
+
</div>
|
|
4911
|
+
<div className="text-xs text-theme-text-tertiary text-center">
|
|
4912
|
+
{commonBulkScaleReplicas === null ? 'Current replicas vary across the selected workloads.' : `Current: ${commonBulkScaleReplicas} replicas`}
|
|
4913
|
+
</div>
|
|
4914
|
+
<p className="text-xs text-theme-text-secondary text-center">
|
|
4915
|
+
All selected workloads will be set to the same replica count. Autoscalers may override it.
|
|
4916
|
+
</p>
|
|
4917
|
+
</div>
|
|
4918
|
+
</ConfirmDialog>
|
|
4760
4919
|
</ResourcesViewDataContext.Provider>
|
|
4761
4920
|
)
|
|
4762
4921
|
}
|
|
@@ -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
|
+
})
|