@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.
Files changed (48) hide show
  1. package/package.json +7 -7
  2. package/src/components/applications/ApplicationsList.tsx +131 -115
  3. package/src/components/applications/index.ts +3 -1
  4. package/src/components/audit/AuditCard.tsx +1 -1
  5. package/src/components/charts/AreaChart.tsx +52 -18
  6. package/src/components/charts/MetricsSummary.tsx +12 -3
  7. package/src/components/charts/saturation.test.ts +34 -0
  8. package/src/components/charts/saturation.ts +1 -0
  9. package/src/components/charts/types.ts +5 -4
  10. package/src/components/checks/ChecksView.tsx +12 -23
  11. package/src/components/checks/severity.ts +11 -11
  12. package/src/components/gitops/GitOpsTableView.tsx +298 -274
  13. package/src/components/issues/IssuesView.tsx +4 -6
  14. package/src/components/issues/severity.ts +19 -11
  15. package/src/components/issues/types.ts +22 -2
  16. package/src/components/logs/WorkloadLogsViewer.tsx +16 -7
  17. package/src/components/resources/ResourcesSidebar.tsx +3 -1
  18. package/src/components/resources/ResourcesView.tsx +180 -21
  19. package/src/components/resources/get-pod-problems.test.ts +127 -0
  20. package/src/components/resources/index.ts +1 -0
  21. package/src/components/resources/renderers/HPARenderer.test.tsx +91 -0
  22. package/src/components/resources/renderers/HPARenderer.tsx +93 -86
  23. package/src/components/resources/renderers/PodRenderer.test.tsx +56 -0
  24. package/src/components/resources/renderers/PodRenderer.tsx +1 -1
  25. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  26. package/src/components/resources/renderers/WorkloadRenderer.tsx +73 -4
  27. package/src/components/resources/resource-utils-hpa.test.ts +30 -0
  28. package/src/components/resources/resource-utils-hpa.ts +106 -0
  29. package/src/components/resources/resource-utils.ts +41 -28
  30. package/src/components/shared/ResourceRendererDispatch.tsx +10 -2
  31. package/src/components/ui/DistributionBar.tsx +88 -0
  32. package/src/components/ui/Facet.tsx +130 -0
  33. package/src/components/ui/PageHeader.tsx +43 -0
  34. package/src/components/ui/SearchPillInput.tsx +237 -0
  35. package/src/components/ui/SortableTh.tsx +58 -0
  36. package/src/components/ui/SummaryTile.tsx +60 -0
  37. package/src/components/ui/drawer-components.tsx +56 -17
  38. package/src/components/ui/index.ts +12 -0
  39. package/src/components/workload/WorkloadView.tsx +18 -1
  40. package/src/theme/tailwind-theme.css +1 -0
  41. package/src/theme/variables.css +7 -3
  42. package/src/types/core.ts +64 -2
  43. package/src/utils/bulk-workload-actions.test.ts +78 -0
  44. package/src/utils/bulk-workload-actions.ts +50 -0
  45. package/src/utils/index.ts +1 -0
  46. package/src/utils/rbac-blast-radius.test.ts +48 -3
  47. package/src/utils/rbac-blast-radius.ts +14 -4
  48. 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 (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>
@@ -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, deliberately reusing the
4
- // SAME class strings as the Checks queue (components/checks/severity.ts):
5
- // critical = red (= Checks `critical`), warning = amber (= Checks `medium`).
6
- // Issues and Checks are different severity axes, but the queues must read as
7
- // one product — sharing the exact hues makes the rails/pills pixel-identical.
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: 'bg-red-50 text-red-700 ring-1 ring-red-200 dark:bg-red-950/50 dark:text-red-300 dark:ring-red-900',
20
- warning: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-950/50 dark:text-amber-300 dark:ring-amber-900',
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 (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
@@ -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
- <div className="px-2 py-2 border-b border-theme-border">
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: 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)
@@ -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 = onBulkDelete != null && bulkMode
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
- {onBulkDelete && (
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
- 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
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
- 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, {
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={checkedItems.map(r => `${r.metadata?.namespace ? r.metadata.namespace + '/' : ''}${r.metadata?.name}`).join('\n')}
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
+ })
@@ -1,4 +1,5 @@
1
1
  export * from './resource-utils'
2
+ export * from './resource-utils-hpa'
2
3
  export * from './resource-utils-argo'
3
4
  export * from './resource-utils-certmanager'
4
5
  export * from './resource-utils-cnpg'