@skyhook-io/k8s-ui 1.5.10 → 1.5.12
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 +1 -1
- package/src/components/audit/AuditFindingsTable.tsx +66 -30
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +65 -12
- package/src/components/dock/BottomDock.tsx +23 -12
- package/src/components/gitops/ManagedResourcesList.tsx +4 -2
- package/src/components/logs/LogCore.tsx +109 -14
- package/src/components/resources/ResourcesView.tsx +19 -2
- package/src/components/resources/get-pod-phase-display.test.ts +254 -0
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +2 -4
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -8
- package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -1
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +2 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +5 -4
- package/src/components/resources/renderers/IngressClassRenderer.tsx +2 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +2 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +3 -2
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +5 -5
- package/src/components/resources/renderers/OrderRenderer.tsx +2 -1
- package/src/components/resources/renderers/PodRenderer.tsx +23 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -2
- package/src/components/resources/renderers/RolloutRenderer.tsx +2 -1
- package/src/components/resources/renderers/SecretRenderer.tsx +15 -6
- package/src/components/resources/renderers/aws-capi-cells.tsx +2 -4
- package/src/components/resources/renderers/azure-capi-cells.tsx +3 -8
- package/src/components/resources/renderers/istio-cells.tsx +3 -2
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -1
- package/src/components/resources/resource-utils-karpenter.ts +0 -6
- package/src/components/resources/resource-utils.ts +110 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +85 -0
- package/src/components/timeline/shared.tsx +5 -6
- package/src/components/topology/TopologySearch.tsx +49 -16
- package/src/components/topology/topology-search-match.test.ts +112 -0
- package/src/components/ui/Tooltip.tsx +73 -40
- package/src/components/ui/tooltip-position.test.ts +132 -0
- package/src/components/ui/tooltip-position.ts +86 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +3 -3
- package/src/components/workload/WorkloadView.tsx +6 -6
- package/src/types/core.ts +13 -1
- package/src/utils/badge-colors.ts +19 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/validators.test.ts +207 -0
- package/src/utils/validators.ts +178 -0
- package/src/utils/view-transition.test.ts +81 -0
- package/src/utils/view-transition.ts +62 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ShoppingCart, AlertTriangle, Globe, Shield } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
|
|
4
|
+
import { BADGE_INACTIVE } from '../../../utils/badge-colors'
|
|
4
5
|
|
|
5
6
|
function getOrderStateBadge(state: string): { color: string; text: string } {
|
|
6
7
|
switch (state?.toLowerCase()) {
|
|
@@ -17,7 +18,7 @@ function getOrderStateBadge(state: string): { color: string; text: string } {
|
|
|
17
18
|
case 'errored':
|
|
18
19
|
return { text: 'Errored', color: 'bg-red-500/20 text-red-400' }
|
|
19
20
|
default:
|
|
20
|
-
return { text: state || 'Unknown', color:
|
|
21
|
+
return { text: state || 'Unknown', color: BADGE_INACTIVE }
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -2,7 +2,7 @@ import { useState, type ReactNode, type JSX } from 'react'
|
|
|
2
2
|
import { Server, HardDrive, Terminal as TerminalIcon, FileText, Activity, CirclePlay, FolderOpen, List, Eye, EyeOff } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink } from '../../ui/drawer-components'
|
|
5
|
-
import { formatResources, formatDuration, getPodProblems, SEVERITY_DOT_COLOR } from '../resource-utils'
|
|
5
|
+
import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR } from '../resource-utils'
|
|
6
6
|
import { getResourceStatusColor, SEVERITY_BADGE_BORDERED } from '../../../utils/badge-colors'
|
|
7
7
|
import type { ResolvedEnvFrom } from '../../../types'
|
|
8
8
|
import { Tooltip } from '../../ui/Tooltip'
|
|
@@ -333,7 +333,28 @@ export function PodRenderer({
|
|
|
333
333
|
{/* Status section */}
|
|
334
334
|
<Section title="Status" icon={Server}>
|
|
335
335
|
<PropertyList>
|
|
336
|
-
|
|
336
|
+
{(() => {
|
|
337
|
+
const phaseDisplay = getPodPhaseDisplay(data)
|
|
338
|
+
const node = (
|
|
339
|
+
<span className={clsx(healthColors[phaseDisplay.level])}>
|
|
340
|
+
{phaseDisplay.text}
|
|
341
|
+
</span>
|
|
342
|
+
)
|
|
343
|
+
return (
|
|
344
|
+
<Property
|
|
345
|
+
label="Phase"
|
|
346
|
+
value={
|
|
347
|
+
phaseDisplay.hint ? (
|
|
348
|
+
<Tooltip content={phaseDisplay.hint} position="right">
|
|
349
|
+
{node}
|
|
350
|
+
</Tooltip>
|
|
351
|
+
) : (
|
|
352
|
+
node
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
/>
|
|
356
|
+
)
|
|
357
|
+
})()}
|
|
337
358
|
<Property label="Node" value={
|
|
338
359
|
data.spec?.nodeName ? <ResourceLink name={data.spec.nodeName} kind="nodes" onNavigate={onNavigate} /> : undefined
|
|
339
360
|
} copyable onCopy={onCopy} copied={copied} />
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Shield, Users } from 'lucide-react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ResourceLink, AlertBanner } from '../../ui/drawer-components'
|
|
4
|
+
import { BADGE_INACTIVE } from '../../../utils/badge-colors'
|
|
4
5
|
|
|
5
6
|
interface RoleBindingRendererProps {
|
|
6
7
|
data: any
|
|
@@ -16,7 +17,7 @@ function getSubjectKindBadgeClass(kind: string): string {
|
|
|
16
17
|
case 'Group':
|
|
17
18
|
return 'bg-purple-500/20 text-purple-400'
|
|
18
19
|
default:
|
|
19
|
-
return
|
|
20
|
+
return BADGE_INACTIVE
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -27,7 +28,7 @@ function getRoleRefKindBadgeClass(kind: string): string {
|
|
|
27
28
|
case 'ClusterRole':
|
|
28
29
|
return 'bg-purple-500/20 text-purple-400'
|
|
29
30
|
default:
|
|
30
|
-
return
|
|
31
|
+
return BADGE_INACTIVE
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -2,6 +2,7 @@ import { Server, GitBranch, AlertTriangle, Network } from 'lucide-react'
|
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
3
|
import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection } from '../../ui/drawer-components'
|
|
4
4
|
import { formatAge } from '../resource-utils'
|
|
5
|
+
import { BADGE_INACTIVE } from '../../../utils/badge-colors'
|
|
5
6
|
|
|
6
7
|
interface RolloutRendererProps {
|
|
7
8
|
data: any
|
|
@@ -292,7 +293,7 @@ export function RolloutRenderer({ data }: RolloutRendererProps) {
|
|
|
292
293
|
'w-5 h-5 rounded-full flex items-center justify-center text-xs shrink-0',
|
|
293
294
|
isCompleted && 'bg-green-500/20 text-green-400',
|
|
294
295
|
isCurrent && 'bg-blue-500/20 text-blue-400',
|
|
295
|
-
isPending &&
|
|
296
|
+
isPending && BADGE_INACTIVE
|
|
296
297
|
)}
|
|
297
298
|
>
|
|
298
299
|
{isCompleted ? '\u2713' : isCurrent ? '\u25CF' : '\u25CB'}
|
|
@@ -34,7 +34,11 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
|
|
|
34
34
|
function toggleReveal(key: string) {
|
|
35
35
|
setRevealed(prev => {
|
|
36
36
|
const next = new Set(prev)
|
|
37
|
-
next.has(key)
|
|
37
|
+
if (next.has(key)) {
|
|
38
|
+
next.delete(key)
|
|
39
|
+
} else {
|
|
40
|
+
next.add(key)
|
|
41
|
+
}
|
|
38
42
|
return next
|
|
39
43
|
})
|
|
40
44
|
}
|
|
@@ -153,6 +157,16 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
|
|
|
153
157
|
</>
|
|
154
158
|
)}
|
|
155
159
|
|
|
160
|
+
{dataKeys.length > 0 && (
|
|
161
|
+
<div className="flex items-center gap-2 p-3 bg-amber-500/10 border border-amber-500/30 rounded text-amber-400 text-sm">
|
|
162
|
+
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
|
163
|
+
<span>
|
|
164
|
+
Secret values are sensitive. Reveal carefully — anyone who can see your
|
|
165
|
+
screen will see the plaintext.
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
|
|
156
170
|
<Section title="Data" defaultExpanded>
|
|
157
171
|
<div className="space-y-2">
|
|
158
172
|
{dataKeys.map((key) => {
|
|
@@ -250,11 +264,6 @@ export function SecretRenderer({ data, certificateInfo, resourceData, onSaveSecr
|
|
|
250
264
|
</div>
|
|
251
265
|
</Section>
|
|
252
266
|
|
|
253
|
-
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
|
|
254
|
-
<AlertTriangle className="w-4 h-4" />
|
|
255
|
-
Secret values are sensitive. Be careful when revealing.
|
|
256
|
-
</div>
|
|
257
|
-
|
|
258
267
|
{editingKey && (
|
|
259
268
|
<ConfirmDialog
|
|
260
269
|
open={showSaveConfirm}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { Tooltip } from '../../ui/Tooltip'
|
|
5
|
+
import { CAPACITY_TYPE_BADGE } from '../../../utils/badge-colors'
|
|
5
6
|
import {
|
|
6
7
|
getAWSMCPStatus, getAWSMCPEKSClusterName, getAWSMCPRegion, getAWSMCPVersion,
|
|
7
8
|
getAWSMMPStatus, getAWSMMPInstanceType, getAWSMMPReplicas, getAWSMMPCapacityType,
|
|
@@ -57,10 +58,7 @@ export function AWSManagedMachinePoolCell({ resource, column }: { resource: any;
|
|
|
57
58
|
case 'capacityType': {
|
|
58
59
|
const ct = getAWSMMPCapacityType(resource)
|
|
59
60
|
return (
|
|
60
|
-
<span className={clsx('badge badge-sm', ct === 'spot'
|
|
61
|
-
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40'
|
|
62
|
-
: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40'
|
|
63
|
-
)}>{ct === 'onDemand' ? 'On-Demand' : ct}</span>
|
|
61
|
+
<span className={clsx('badge badge-sm', ct === 'spot' ? CAPACITY_TYPE_BADGE.spot : CAPACITY_TYPE_BADGE.onDemand)}>{ct === 'onDemand' ? 'On-Demand' : ct}</span>
|
|
64
62
|
)
|
|
65
63
|
}
|
|
66
64
|
default:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Tooltip } from '../../ui/Tooltip'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
5
|
+
import { CAPACITY_TYPE_BADGE, NODEPOOL_MODE_BADGE } from '../../../utils/badge-colors'
|
|
5
6
|
import {
|
|
6
7
|
getAzureMCPStatus, getAzureMCPLocation, getAzureMCPVersion, getAzureMCPResourceGroup,
|
|
7
8
|
getAzureMMPStatus, getAzureMMPSKU, getAzureMMPMode, getAzureMMPReplicas, getAzureMMPScaleSetPriority,
|
|
@@ -40,20 +41,14 @@ export function AzureManagedMachinePoolCell({ resource, column }: { resource: an
|
|
|
40
41
|
case 'mode': {
|
|
41
42
|
const mode = getAzureMMPMode(resource)
|
|
42
43
|
return (
|
|
43
|
-
<span className={clsx('badge badge-sm', mode
|
|
44
|
-
? 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-950/50 dark:text-purple-400 dark:border-purple-700/40'
|
|
45
|
-
: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40'
|
|
46
|
-
)}>{mode}</span>
|
|
44
|
+
<span className={clsx('badge badge-sm', NODEPOOL_MODE_BADGE[mode] || NODEPOOL_MODE_BADGE.User)}>{mode}</span>
|
|
47
45
|
)
|
|
48
46
|
}
|
|
49
47
|
case 'replicas': return <TextCell value={getAzureMMPReplicas(resource)} />
|
|
50
48
|
case 'priority': {
|
|
51
49
|
const p = getAzureMMPScaleSetPriority(resource)
|
|
52
50
|
return (
|
|
53
|
-
<span className={clsx('badge badge-sm', p === 'Spot'
|
|
54
|
-
? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40'
|
|
55
|
-
: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40'
|
|
56
|
-
)}>{p}</span>
|
|
51
|
+
<span className={clsx('badge badge-sm', p === 'Spot' ? CAPACITY_TYPE_BADGE.spot : CAPACITY_TYPE_BADGE.regular)}>{p}</span>
|
|
57
52
|
)
|
|
58
53
|
}
|
|
59
54
|
default: return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Istio cell components for ResourcesView table
|
|
2
2
|
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
+
import { BADGE_INACTIVE } from '../../../utils/badge-colors'
|
|
4
5
|
import {
|
|
5
6
|
getVirtualServiceStatus,
|
|
6
7
|
getVirtualServiceHosts,
|
|
@@ -164,7 +165,7 @@ export function PeerAuthenticationCell({ resource, column }: { resource: any; co
|
|
|
164
165
|
return (
|
|
165
166
|
<span className={clsx(
|
|
166
167
|
'badge',
|
|
167
|
-
modeColors[mode] ||
|
|
168
|
+
modeColors[mode] || BADGE_INACTIVE
|
|
168
169
|
)}>
|
|
169
170
|
{mode}
|
|
170
171
|
</span>
|
|
@@ -194,7 +195,7 @@ export function AuthorizationPolicyCell({ resource, column }: { resource: any; c
|
|
|
194
195
|
return (
|
|
195
196
|
<span className={clsx(
|
|
196
197
|
'badge',
|
|
197
|
-
actionColors[action] ||
|
|
198
|
+
actionColors[action] || BADGE_INACTIVE
|
|
198
199
|
)}>
|
|
199
200
|
{action}
|
|
200
201
|
</span>
|
|
@@ -3,12 +3,6 @@
|
|
|
3
3
|
import type { StatusBadge } from './resource-utils'
|
|
4
4
|
import { healthColors } from './resource-utils'
|
|
5
5
|
|
|
6
|
-
// Shared capacity type badge colors for NodeClaim renderer and table cells
|
|
7
|
-
export const CAPACITY_TYPE_BADGE: Record<string, string> = {
|
|
8
|
-
spot: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-400 dark:border-amber-700/40',
|
|
9
|
-
'on-demand': 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
|
|
10
|
-
}
|
|
11
|
-
|
|
12
6
|
// ============================================================================
|
|
13
7
|
// KARPENTER NODEPOOL UTILITIES
|
|
14
8
|
// ============================================================================
|
|
@@ -130,6 +130,114 @@ export function getPodStatus(pod: any): StatusBadge {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Pod phase enriched with readiness/restart/crash signals so the Phase
|
|
135
|
+
* row doesn't show a bare "Running" on a 0/1 or crash-looping pod.
|
|
136
|
+
*/
|
|
137
|
+
export interface PodPhaseDisplay {
|
|
138
|
+
/** Verbatim `pod.status.phase` (or "Unknown") — never lost. */
|
|
139
|
+
phase: string
|
|
140
|
+
/** Phase + derived qualifier, e.g. "Running — Not Ready (0/1)". */
|
|
141
|
+
text: string
|
|
142
|
+
/** Severity tier for tinting / icon choice, mirrors getPodStatus. */
|
|
143
|
+
level: HealthLevel
|
|
144
|
+
/** Optional one-line explanation surfaced as a tooltip / muted suffix. */
|
|
145
|
+
hint?: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const RESTART_CYCLING_THRESHOLD = 5
|
|
149
|
+
|
|
150
|
+
export function getPodPhaseDisplay(pod: any): PodPhaseDisplay {
|
|
151
|
+
const phase: string = pod?.status?.phase || 'Unknown'
|
|
152
|
+
const containerStatuses: any[] = pod?.status?.containerStatuses || []
|
|
153
|
+
const totalContainers = containerStatuses.length
|
|
154
|
+
const readyContainers = containerStatuses.filter((c) => c?.ready).length
|
|
155
|
+
const restartTotal = containerStatuses.reduce(
|
|
156
|
+
(sum, c) => sum + (c?.restartCount || 0),
|
|
157
|
+
0
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if (pod?.metadata?.deletionTimestamp) {
|
|
161
|
+
return {
|
|
162
|
+
phase,
|
|
163
|
+
text: `${phase} — Terminating`,
|
|
164
|
+
level: 'degraded',
|
|
165
|
+
hint: 'Pod has a deletionTimestamp set; awaiting graceful termination.',
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Container-state failures take precedence over phase: a CrashLoopBackOff
|
|
170
|
+
// pod can still report phase: Running. Skip for Succeeded — a Job pod whose
|
|
171
|
+
// sidecar was OOMKilled before the main container completed should not be
|
|
172
|
+
// shown as unhealthy after the pod has reached terminal success.
|
|
173
|
+
if (phase !== 'Succeeded') {
|
|
174
|
+
for (const cs of containerStatuses) {
|
|
175
|
+
const waitingReason = cs?.state?.waiting?.reason
|
|
176
|
+
if (
|
|
177
|
+
waitingReason === 'CrashLoopBackOff' ||
|
|
178
|
+
waitingReason === 'ImagePullBackOff' ||
|
|
179
|
+
waitingReason === 'ErrImagePull' ||
|
|
180
|
+
waitingReason === 'CreateContainerConfigError'
|
|
181
|
+
) {
|
|
182
|
+
return {
|
|
183
|
+
phase,
|
|
184
|
+
text: `${phase} — ${waitingReason}`,
|
|
185
|
+
level: 'unhealthy',
|
|
186
|
+
hint: `Container "${cs.name}" is stuck in ${waitingReason}.`,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (cs?.state?.terminated?.reason === 'OOMKilled') {
|
|
190
|
+
return {
|
|
191
|
+
phase,
|
|
192
|
+
text: `${phase} — OOMKilled`,
|
|
193
|
+
level: 'unhealthy',
|
|
194
|
+
hint: `Container "${cs.name}" was OOMKilled.`,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
switch (phase) {
|
|
201
|
+
case 'Running': {
|
|
202
|
+
const notReady = totalContainers > 0 && readyContainers < totalContainers
|
|
203
|
+
const cycling = restartTotal > RESTART_CYCLING_THRESHOLD
|
|
204
|
+
if (notReady && cycling) {
|
|
205
|
+
return {
|
|
206
|
+
phase,
|
|
207
|
+
text: `Running — Not Ready (${readyContainers}/${totalContainers}), ${restartTotal} restarts`,
|
|
208
|
+
level: 'unhealthy',
|
|
209
|
+
hint: 'Containers report not-ready and have restarted many times — likely crash-looping.',
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (notReady) {
|
|
213
|
+
return {
|
|
214
|
+
phase,
|
|
215
|
+
text: `Running — Not Ready (${readyContainers}/${totalContainers})`,
|
|
216
|
+
level: 'degraded',
|
|
217
|
+
hint: 'Pod is in the Running phase but at least one container is not ready (probes failing or still starting).',
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (cycling) {
|
|
221
|
+
return {
|
|
222
|
+
phase,
|
|
223
|
+
text: `Running — Restarting (${restartTotal} restarts)`,
|
|
224
|
+
level: 'degraded',
|
|
225
|
+
hint: 'Containers are ready right now but have restarted many times — investigate stability.',
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { phase, text: 'Running', level: 'healthy' }
|
|
229
|
+
}
|
|
230
|
+
case 'Succeeded':
|
|
231
|
+
return { phase, text: 'Completed', level: 'neutral' }
|
|
232
|
+
case 'Pending':
|
|
233
|
+
return { phase, text: 'Pending', level: 'degraded' }
|
|
234
|
+
case 'Failed':
|
|
235
|
+
return { phase, text: 'Failed', level: 'unhealthy' }
|
|
236
|
+
default:
|
|
237
|
+
return { phase, text: phase, level: 'unknown' }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
133
241
|
export function getPodProblems(pod: any): PodProblem[] {
|
|
134
242
|
const problems: PodProblem[] = []
|
|
135
243
|
const containerStatuses = pod.status?.containerStatuses || []
|
|
@@ -1528,6 +1636,8 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
|
|
|
1528
1636
|
const kindLower = kind.toLowerCase()
|
|
1529
1637
|
|
|
1530
1638
|
switch (column) {
|
|
1639
|
+
case 'namespace':
|
|
1640
|
+
return resource.metadata?.namespace || ''
|
|
1531
1641
|
case 'type':
|
|
1532
1642
|
if (kindLower === 'secrets' || kindLower === 'sealedsecrets') return getSecretType(resource).type
|
|
1533
1643
|
if (kindLower === 'services') return resource.spec?.type || ''
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { pluralize } from '../../utils/pluralize'
|
|
3
|
+
import type { SelectedKindInfo } from './ResourcesSidebar'
|
|
4
|
+
|
|
5
|
+
// Mirrors the empty-state hint rule inside ResourcesView (kept in
|
|
6
|
+
// sync by reuse) — pure so we can pin the lookup + render contract
|
|
7
|
+
// without rendering the React component. See
|
|
8
|
+
// `packages/k8s-ui/src/components/resources/ResourcesView.tsx`
|
|
9
|
+
// search for "the count is unfiltered".
|
|
10
|
+
function sidebarHint(
|
|
11
|
+
counts: Record<string, number>,
|
|
12
|
+
selectedKind: SelectedKindInfo,
|
|
13
|
+
searchTerm: string,
|
|
14
|
+
): string | null {
|
|
15
|
+
if (!searchTerm) return null
|
|
16
|
+
const key = selectedKind.group ? `${selectedKind.group}/${selectedKind.kind}` : selectedKind.kind
|
|
17
|
+
const totalForKind = counts[key] ?? 0
|
|
18
|
+
if (totalForKind === 0) return null
|
|
19
|
+
return `The sidebar shows ${pluralize(totalForKind, selectedKind.kind)} in the cluster — the count is unfiltered.`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('ResourcesView empty-search sidebar hint (SKY-828 bug 46)', () => {
|
|
23
|
+
// The bug: sidebar count badge stays unfiltered and shows the
|
|
24
|
+
// cluster-wide total per kind. When a search returns zero rows
|
|
25
|
+
// (e.g. "5 Pods" in sidebar, "No Pods found" in pane) the user
|
|
26
|
+
// reads the badge as a lie. The empty-state appends a sentence
|
|
27
|
+
// making the unfiltered nature explicit. The rule must:
|
|
28
|
+
// 1. Suppress entirely when there's no active search.
|
|
29
|
+
// 2. Suppress when the cluster total for the kind is 0
|
|
30
|
+
// (no badge to explain → no hint).
|
|
31
|
+
// 3. Look up the count under `${group}/${kind}` for grouped
|
|
32
|
+
// kinds, plain `kind` for core kinds (matches sidebar key).
|
|
33
|
+
// 4. Pluralize via pluralize() so "Ingress" → "Ingresses",
|
|
34
|
+
// "NetworkPolicy" → "NetworkPolicies" (regression of the
|
|
35
|
+
// naive `${kind}s` Cursor Bugbot caught in 80eb64b).
|
|
36
|
+
// 5. Singular for n===1: "1 Pod", not "1 Pods".
|
|
37
|
+
|
|
38
|
+
const pod: SelectedKindInfo = { name: 'pods', kind: 'Pod', group: '' }
|
|
39
|
+
|
|
40
|
+
it('returns null when there is no active search', () => {
|
|
41
|
+
expect(sidebarHint({ Pod: 232 }, pod, '')).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns null when the cluster total for the kind is 0', () => {
|
|
45
|
+
expect(sidebarHint({ Pod: 0 }, pod, 'xyz')).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns null when the kind is missing from counts', () => {
|
|
49
|
+
expect(sidebarHint({}, pod, 'xyz')).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('formats the sentence with the cluster total and pluralized kind', () => {
|
|
53
|
+
expect(sidebarHint({ Pod: 232 }, pod, 'xyz')).toBe(
|
|
54
|
+
'The sidebar shows 232 Pods in the cluster — the count is unfiltered.',
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('uses singular noun when total is 1', () => {
|
|
59
|
+
expect(sidebarHint({ Pod: 1 }, pod, 'xyz')).toBe(
|
|
60
|
+
'The sidebar shows 1 Pod in the cluster — the count is unfiltered.',
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('looks up grouped kinds under `${group}/${kind}`', () => {
|
|
65
|
+
const ar: SelectedKindInfo = { name: 'rollouts', kind: 'Rollout', group: 'argoproj.io' }
|
|
66
|
+
const counts = { Rollout: 999, 'argoproj.io/Rollout': 4 }
|
|
67
|
+
expect(sidebarHint(counts, ar, 'xyz')).toBe(
|
|
68
|
+
'The sidebar shows 4 Rollouts in the cluster — the count is unfiltered.',
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('pluralizes Ingress correctly (regression: not "Ingresss")', () => {
|
|
73
|
+
const ing: SelectedKindInfo = { name: 'ingresses', kind: 'Ingress', group: '' }
|
|
74
|
+
expect(sidebarHint({ Ingress: 3 }, ing, 'xyz')).toBe(
|
|
75
|
+
'The sidebar shows 3 Ingresses in the cluster — the count is unfiltered.',
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('pluralizes NetworkPolicy correctly (regression: not "NetworkPolicys")', () => {
|
|
80
|
+
const np: SelectedKindInfo = { name: 'networkpolicies', kind: 'NetworkPolicy', group: 'networking.k8s.io' }
|
|
81
|
+
expect(sidebarHint({ 'networking.k8s.io/NetworkPolicy': 2 }, np, 'xyz')).toBe(
|
|
82
|
+
'The sidebar shows 2 NetworkPolicies in the cluster — the count is unfiltered.',
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -244,21 +244,20 @@ export function EventMarker({ event, x, selected, onClick, dimmed, small }: Even
|
|
|
244
244
|
return 'bg-theme-hover/30 border-2 border-dashed border-theme-border-light'
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
const opacity = dimmed ? '/50' : ''
|
|
248
247
|
if (isProblematic) {
|
|
249
|
-
return
|
|
248
|
+
return dimmed ? 'bg-amber-500/50' : 'bg-amber-500'
|
|
250
249
|
}
|
|
251
250
|
if (isChange) {
|
|
252
251
|
switch (event.eventType) {
|
|
253
252
|
case 'add':
|
|
254
|
-
return
|
|
253
|
+
return dimmed ? 'bg-green-500/50' : 'bg-green-500'
|
|
255
254
|
case 'delete':
|
|
256
|
-
return
|
|
255
|
+
return dimmed ? 'bg-red-500/50' : 'bg-red-500'
|
|
257
256
|
case 'update':
|
|
258
|
-
return
|
|
257
|
+
return dimmed ? 'bg-blue-500/50' : 'bg-blue-500'
|
|
259
258
|
}
|
|
260
259
|
}
|
|
261
|
-
return
|
|
260
|
+
return dimmed ? 'bg-theme-text-tertiary/50' : 'bg-theme-text-tertiary'
|
|
262
261
|
}
|
|
263
262
|
|
|
264
263
|
const getOperationLabel = () => {
|
|
@@ -9,6 +9,20 @@ interface TopologySearchProps {
|
|
|
9
9
|
nodes: TopologyNode[]
|
|
10
10
|
onNodeSelect?: (node: TopologyNode) => void
|
|
11
11
|
onZoomToNode?: (nodeId: string) => void
|
|
12
|
+
/**
|
|
13
|
+
* Optional unfiltered node set. When the parent has applied a
|
|
14
|
+
* view-mode filter (e.g. Fleet mode hides Pods/Deployments and
|
|
15
|
+
* only shows CAPI kinds), it should still pass the full topology
|
|
16
|
+
* here so the empty-state can tell users "your query matches X
|
|
17
|
+
* resources hidden by the current view" rather than the
|
|
18
|
+
* misleading "No resources found." (SKY-828 bug 45)
|
|
19
|
+
*/
|
|
20
|
+
allNodes?: TopologyNode[]
|
|
21
|
+
/**
|
|
22
|
+
* Human-readable label for the current view mode. Used in the
|
|
23
|
+
* empty-state hint above. Defaults to "current view".
|
|
24
|
+
*/
|
|
25
|
+
viewModeLabel?: string
|
|
12
26
|
}
|
|
13
27
|
|
|
14
28
|
// Icon mapping for different resource kinds
|
|
@@ -50,33 +64,47 @@ function getKindColor(kind: string): string {
|
|
|
50
64
|
}
|
|
51
65
|
}
|
|
52
66
|
|
|
53
|
-
export function TopologySearch({ nodes, onNodeSelect, onZoomToNode }: TopologySearchProps) {
|
|
67
|
+
export function TopologySearch({ nodes, onNodeSelect, onZoomToNode, allNodes, viewModeLabel }: TopologySearchProps) {
|
|
54
68
|
const [isOpen, setIsOpen] = useState(false)
|
|
55
69
|
const [query, setQuery] = useState('')
|
|
56
70
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
57
71
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
58
72
|
const resultsRef = useRef<HTMLDivElement>(null)
|
|
59
73
|
|
|
74
|
+
const matchesQuery = useCallback((node: TopologyNode, lowerQuery: string) => {
|
|
75
|
+
const name = node.name.toLowerCase()
|
|
76
|
+
const kind = node.kind.toLowerCase()
|
|
77
|
+
const namespace = (node.data.namespace as string || '').toLowerCase()
|
|
78
|
+
return (
|
|
79
|
+
name.includes(lowerQuery) ||
|
|
80
|
+
kind.includes(lowerQuery) ||
|
|
81
|
+
namespace.includes(lowerQuery) ||
|
|
82
|
+
`${kind}/${name}`.includes(lowerQuery) ||
|
|
83
|
+
`${namespace}/${name}`.includes(lowerQuery)
|
|
84
|
+
)
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
60
87
|
// Filter nodes based on query
|
|
61
88
|
const filteredNodes = useMemo(() => {
|
|
62
89
|
if (!query.trim()) return []
|
|
63
|
-
|
|
64
90
|
const lowerQuery = query.toLowerCase()
|
|
65
91
|
return nodes
|
|
66
|
-
.filter(node =>
|
|
67
|
-
const name = node.name.toLowerCase()
|
|
68
|
-
const kind = node.kind.toLowerCase()
|
|
69
|
-
const namespace = (node.data.namespace as string || '').toLowerCase()
|
|
70
|
-
return (
|
|
71
|
-
name.includes(lowerQuery) ||
|
|
72
|
-
kind.includes(lowerQuery) ||
|
|
73
|
-
namespace.includes(lowerQuery) ||
|
|
74
|
-
`${kind}/${name}`.includes(lowerQuery) ||
|
|
75
|
-
`${namespace}/${name}`.includes(lowerQuery)
|
|
76
|
-
)
|
|
77
|
-
})
|
|
92
|
+
.filter(node => matchesQuery(node, lowerQuery))
|
|
78
93
|
.slice(0, 10) // Limit results
|
|
79
|
-
}, [nodes, query])
|
|
94
|
+
}, [nodes, query, matchesQuery])
|
|
95
|
+
|
|
96
|
+
// Count of matches in the unfiltered topology that the current
|
|
97
|
+
// view-mode filter is hiding. We only compute this when there's
|
|
98
|
+
// a query AND the visible set returned zero results, so the cost
|
|
99
|
+
// is bounded to actual no-result interactions.
|
|
100
|
+
// (SKY-828 bug 45: Fleet view hides Pods, so searching pod names
|
|
101
|
+
// returned "No resources found" — misleading.)
|
|
102
|
+
const hiddenMatchCount = useMemo(() => {
|
|
103
|
+
if (!query.trim() || filteredNodes.length > 0 || !allNodes) return 0
|
|
104
|
+
const lowerQuery = query.toLowerCase()
|
|
105
|
+
const visibleIds = new Set(nodes.map(n => n.id))
|
|
106
|
+
return allNodes.filter(n => !visibleIds.has(n.id) && matchesQuery(n, lowerQuery)).length
|
|
107
|
+
}, [query, filteredNodes.length, allNodes, nodes, matchesQuery])
|
|
80
108
|
|
|
81
109
|
// Reset selection when results change
|
|
82
110
|
useEffect(() => {
|
|
@@ -216,7 +244,12 @@ export function TopologySearch({ nodes, onNodeSelect, onZoomToNode }: TopologySe
|
|
|
216
244
|
{query && filteredNodes.length === 0 && (
|
|
217
245
|
<div className="px-4 py-8 text-center text-theme-text-tertiary">
|
|
218
246
|
<Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
|
219
|
-
<p>No resources found for "{query}"</p>
|
|
247
|
+
<p>No resources found for "{query}"{viewModeLabel ? ` in ${viewModeLabel}` : ''}</p>
|
|
248
|
+
{hiddenMatchCount > 0 && (
|
|
249
|
+
<p className="mt-2 text-xs text-amber-400">
|
|
250
|
+
{hiddenMatchCount} {hiddenMatchCount === 1 ? 'match is' : 'matches are'} hidden by the current view{viewModeLabel ? ` (${viewModeLabel})` : ''}. Switch view to see them.
|
|
251
|
+
</p>
|
|
252
|
+
)}
|
|
220
253
|
</div>
|
|
221
254
|
)}
|
|
222
255
|
|