@skyhook-io/k8s-ui 1.5.9 → 1.5.11

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 (43) hide show
  1. package/package.json +3 -3
  2. package/src/components/dock/BottomDock.tsx +21 -10
  3. package/src/components/resources/get-pod-phase-display.test.ts +254 -0
  4. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +2 -4
  5. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +3 -2
  6. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -8
  7. package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -1
  8. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
  9. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -1
  10. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
  11. package/src/components/resources/renderers/GatewayClassRenderer.tsx +2 -1
  12. package/src/components/resources/renderers/GatewayRenderer.tsx +5 -4
  13. package/src/components/resources/renderers/IngressClassRenderer.tsx +2 -1
  14. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +2 -1
  15. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +3 -2
  16. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -1
  17. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
  18. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +13 -11
  19. package/src/components/resources/renderers/OrderRenderer.tsx +2 -1
  20. package/src/components/resources/renderers/PodRenderer.tsx +23 -2
  21. package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -2
  22. package/src/components/resources/renderers/RolloutRenderer.tsx +2 -1
  23. package/src/components/resources/renderers/SecretRenderer.tsx +15 -6
  24. package/src/components/resources/renderers/aws-capi-cells.tsx +2 -4
  25. package/src/components/resources/renderers/azure-capi-cells.tsx +3 -8
  26. package/src/components/resources/renderers/istio-cells.tsx +3 -2
  27. package/src/components/resources/renderers/karpenter-cells.tsx +1 -1
  28. package/src/components/resources/resource-utils-karpenter.ts +0 -6
  29. package/src/components/resources/resource-utils.ts +108 -0
  30. package/src/components/timeline/shared.tsx +5 -6
  31. package/src/components/topology/K8sResourceNode.tsx +46 -59
  32. package/src/components/topology/TopologyGraph.tsx +3 -3
  33. package/src/components/ui/Tooltip.tsx +73 -40
  34. package/src/components/ui/tooltip-position.test.ts +132 -0
  35. package/src/components/ui/tooltip-position.ts +86 -0
  36. package/src/components/workload/WorkloadView.tsx +6 -6
  37. package/src/types/core.ts +16 -0
  38. package/src/utils/badge-colors.ts +19 -0
  39. package/src/utils/index.ts +2 -0
  40. package/src/utils/validators.test.ts +207 -0
  41. package/src/utils/validators.ts +178 -0
  42. package/src/utils/view-transition.test.ts +81 -0
  43. package/src/utils/view-transition.ts +62 -0
@@ -190,7 +190,7 @@ function FlowRow({
190
190
  const isIngress = direction === 'ingress'
191
191
 
192
192
  const sourceNodes = (
193
- <div className="flex-1 min-w-0 space-y-1">
193
+ <div className="min-w-0 max-w-full shrink space-y-1">
194
194
  {sources.map((peer, i) => (
195
195
  <div key={i}>
196
196
  {i > 0 && (
@@ -199,13 +199,13 @@ function FlowRow({
199
199
  <Tooltip content={peer.sublabel ? `${peer.label}\n${peer.sublabel}` : peer.label} position="top">
200
200
  <div
201
201
  className={clsx(
202
- 'rounded-md border px-2 py-1.5',
202
+ 'rounded-md border px-2 py-1.5 min-w-0 overflow-hidden',
203
203
  PEER_STYLES[peer.type],
204
204
  )}
205
205
  >
206
- <div className="flex items-center gap-1.5">
206
+ <div className="flex items-center gap-1.5 min-w-0">
207
207
  <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', PEER_DOT[peer.type])} />
208
- <span className={clsx('text-[11px] font-medium truncate', denied && 'line-through text-red-400')}>
208
+ <span className={clsx('text-[11px] font-medium truncate min-w-0', denied && 'line-through text-red-400')}>
209
209
  {peer.label}
210
210
  </span>
211
211
  </div>
@@ -220,14 +220,16 @@ function FlowRow({
220
220
  )
221
221
 
222
222
  const targetNode = (
223
- <Tooltip content={target} position="top">
224
- <div className="rounded-md border border-indigo-500/30 bg-indigo-500/8 px-2 py-1.5 flex-1 min-w-0">
225
- <div className="flex items-center gap-1.5">
226
- <span className="w-1.5 h-1.5 rounded-full shrink-0 bg-indigo-500" />
227
- <span className="text-[11px] font-medium truncate">{target}</span>
223
+ <div className="min-w-0 max-w-full shrink-[1000]">
224
+ <Tooltip content={target} position="top">
225
+ <div className="rounded-md border border-indigo-500/30 bg-indigo-500/8 px-2 py-1.5 min-w-0 max-w-full overflow-hidden">
226
+ <div className="flex items-center gap-1.5 min-w-0">
227
+ <span className="w-1.5 h-1.5 rounded-full shrink-0 bg-indigo-500" />
228
+ <span className="text-[11px] font-medium truncate min-w-0">{target}</span>
229
+ </div>
228
230
  </div>
229
- </div>
230
- </Tooltip>
231
+ </Tooltip>
232
+ </div>
231
233
  )
232
234
 
233
235
  const arrow = (
@@ -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: 'bg-gray-500/20 text-gray-400' }
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
- <Property label="Phase" value={data.status?.phase} />
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 'bg-gray-500/20 text-gray-400'
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 'bg-gray-500/20 text-gray-400'
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 && 'bg-gray-500/20 text-gray-400'
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) ? next.delete(key) : next.add(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 === 'System'
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] || 'bg-gray-500/20 text-gray-400'
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] || 'bg-gray-500/20 text-gray-400'
198
+ actionColors[action] || BADGE_INACTIVE
198
199
  )}>
199
200
  {action}
200
201
  </span>
@@ -2,8 +2,8 @@
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
- CAPACITY_TYPE_BADGE,
7
7
  getNodePoolStatus,
8
8
  getNodePoolNodeClassRef,
9
9
  getNodePoolLimits,
@@ -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 || []
@@ -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 `bg-amber-500${opacity}`
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 `bg-green-500${opacity}`
253
+ return dimmed ? 'bg-green-500/50' : 'bg-green-500'
255
254
  case 'delete':
256
- return `bg-red-500${opacity}`
255
+ return dimmed ? 'bg-red-500/50' : 'bg-red-500'
257
256
  case 'update':
258
- return `bg-blue-500${opacity}`
257
+ return dimmed ? 'bg-blue-500/50' : 'bg-blue-500'
259
258
  }
260
259
  }
261
- return `bg-theme-text-tertiary${opacity}`
260
+ return dimmed ? 'bg-theme-text-tertiary/50' : 'bg-theme-text-tertiary'
262
261
  }
263
262
 
264
263
  const getOperationLabel = () => {
@@ -374,72 +374,59 @@ export const K8sResourceNode = memo(function K8sResourceNode({
374
374
  'pl-3 pr-3',
375
375
  isSmallNode ? 'py-2' : 'py-2.5'
376
376
  )}>
377
- {/* Header row: icon + kind label + expand/collapse + status dot */}
377
+ {/* Header row: icon + kind label + (right-aligned) policy badge + expand/collapse + status dot */}
378
378
  <div className="flex items-center gap-1.5 mb-0.5">
379
379
  <span className={iconClass} />
380
380
  <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary font-medium">
381
381
  {isPodGroup ? 'Pod Group' : displayKind(kind)}
382
382
  </span>
383
- {/* Expand/Collapse button for PodGroup */}
384
- {canExpand && (
385
- <button
386
- onClick={(e) => {
387
- e.stopPropagation()
388
- onExpand(id)
389
- }}
390
- className="ml-auto p-0.5 hover:bg-theme-elevated rounded transition-colors"
391
- title="Expand to show individual pods"
392
- >
393
- <ChevronDown className="w-3.5 h-3.5 text-theme-text-secondary" />
394
- </button>
395
- )}
396
- {canCollapse && (
397
- <button
398
- onClick={(e) => {
399
- e.stopPropagation()
400
- onCollapse(id)
401
- }}
402
- className="ml-auto p-0.5 hover:bg-theme-elevated rounded transition-colors"
403
- title="Collapse back to group"
404
- >
405
- <ChevronUp className="w-3.5 h-3.5 text-theme-text-secondary" />
406
- </button>
407
- )}
408
- {issueTooltip ? (
409
- <Tooltip content={issueTooltip} position="right">
410
- <span
411
- className={clsx(
412
- canExpand || canCollapse ? '' : 'ml-auto',
413
- 'w-1.5 h-1.5 rounded-full cursor-help',
414
- getStatusDotColor(status)
415
- )}
416
- />
417
- </Tooltip>
418
- ) : (
419
- <span
420
- className={clsx(
421
- canExpand || canCollapse ? '' : 'ml-auto',
422
- 'w-1.5 h-1.5 rounded-full',
423
- getStatusDotColor(status)
424
- )}
425
- />
426
- )}
383
+ <div className="ml-auto flex items-center gap-1.5">
384
+ {policyStatus && (
385
+ <Tooltip content={policyStatus === 'protected' ? 'Protected by NetworkPolicy' : 'No NetworkPolicy coverage'} position="right">
386
+ <span className={clsx(
387
+ 'inline-flex items-center justify-center w-3 h-3 rounded-sm text-[8px] font-bold cursor-help leading-none',
388
+ policyStatus === 'protected'
389
+ ? 'bg-green-500/20 text-green-500 border border-green-500/30'
390
+ : 'bg-yellow-500/20 text-yellow-500 border border-yellow-500/30',
391
+ )}>
392
+ {policyStatus === 'protected' ? '✓' : '!'}
393
+ </span>
394
+ </Tooltip>
395
+ )}
396
+ {canExpand && (
397
+ <button
398
+ onClick={(e) => {
399
+ e.stopPropagation()
400
+ onExpand(id)
401
+ }}
402
+ className="p-0.5 hover:bg-theme-elevated rounded transition-colors"
403
+ title="Expand to show individual pods"
404
+ >
405
+ <ChevronDown className="w-3.5 h-3.5 text-theme-text-secondary" />
406
+ </button>
407
+ )}
408
+ {canCollapse && (
409
+ <button
410
+ onClick={(e) => {
411
+ e.stopPropagation()
412
+ onCollapse(id)
413
+ }}
414
+ className="p-0.5 hover:bg-theme-elevated rounded transition-colors"
415
+ title="Collapse back to group"
416
+ >
417
+ <ChevronUp className="w-3.5 h-3.5 text-theme-text-secondary" />
418
+ </button>
419
+ )}
420
+ {issueTooltip ? (
421
+ <Tooltip content={issueTooltip} position="right">
422
+ <span className={clsx('w-1.5 h-1.5 rounded-full cursor-help', getStatusDotColor(status))} />
423
+ </Tooltip>
424
+ ) : (
425
+ <span className={clsx('w-1.5 h-1.5 rounded-full', getStatusDotColor(status))} />
426
+ )}
427
+ </div>
427
428
  </div>
428
429
 
429
- {/* Network Policy coverage indicator */}
430
- {policyStatus && (
431
- <Tooltip content={policyStatus === 'protected' ? 'Protected by NetworkPolicy' : 'No NetworkPolicy coverage'} position="right">
432
- <span className={clsx(
433
- 'inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold cursor-help',
434
- policyStatus === 'protected'
435
- ? 'bg-green-500/20 text-green-500 border border-green-500/30'
436
- : 'bg-yellow-500/20 text-yellow-500 border border-yellow-500/30',
437
- )}>
438
- {policyStatus === 'protected' ? '✓' : '!'}
439
- </span>
440
- </Tooltip>
441
- )}
442
-
443
430
  {/* Name */}
444
431
  <div className="text-sm font-medium text-theme-text-primary truncate pr-1">
445
432
  {name}
@@ -654,12 +654,12 @@ export function TopologyGraph({
654
654
  }, [selectedNodeId, setNodes])
655
655
 
656
656
  if (!topology) {
657
- return <PaneLoader label="Loading topology…" className="flex-1" />
657
+ return <PaneLoader label="Loading topology…" className="absolute inset-0" />
658
658
  }
659
659
 
660
660
  if (topology.nodes.length === 0) {
661
661
  return (
662
- <div className="flex-1 flex items-center justify-center text-theme-text-secondary">
662
+ <div className="absolute inset-0 flex items-center justify-center text-theme-text-secondary">
663
663
  <div className="text-center">
664
664
  <p className="text-lg">No resources found</p>
665
665
  <p className="text-sm mt-2">
@@ -673,7 +673,7 @@ export function TopologyGraph({
673
673
  // Show layout error if we have topology data but layout failed
674
674
  if (layoutError && nodes.length === 0) {
675
675
  return (
676
- <div className="flex-1 flex items-center justify-center text-theme-text-secondary">
676
+ <div className="absolute inset-0 flex items-center justify-center text-theme-text-secondary">
677
677
  <div className="text-center max-w-md">
678
678
  <p className="text-lg text-amber-400">Layout Error</p>
679
679
  <p className="text-sm mt-2">