@skyhook-io/k8s-ui 1.8.6 → 1.8.7

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 +1 -1
  2. package/src/components/applications/ApplicationsView.tsx +2 -0
  3. package/src/components/audit/AuditAlerts.tsx +4 -0
  4. package/src/components/audit/AuditBadgeTooltip.test.tsx +30 -0
  5. package/src/components/audit/AuditBadgeTooltip.tsx +47 -0
  6. package/src/components/audit/AuditFindingsTable.tsx +4 -0
  7. package/src/components/audit/index.ts +1 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -3
  9. package/src/components/gitops/GitOpsStatusBadge.tsx +9 -3
  10. package/src/components/gitops/GitOpsTableView.tsx +3 -1
  11. package/src/components/issues/IssuesView.tsx +9 -36
  12. package/src/components/issues/ResourceIssuesSection.tsx +142 -0
  13. package/src/components/issues/diagnostic.ts +64 -0
  14. package/src/components/issues/index.ts +2 -1
  15. package/src/components/issues/severity.ts +10 -9
  16. package/src/components/issues/types.ts +5 -0
  17. package/src/components/resources/ResourcesView.tsx +38 -2
  18. package/src/components/resources/cron-to-human.test.ts +41 -0
  19. package/src/components/resources/get-pod-problems.test.ts +18 -0
  20. package/src/components/resources/health-golden.test.ts +66 -0
  21. package/src/components/resources/renderers/JobRenderer.tsx +6 -2
  22. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
  24. package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
  25. package/src/components/resources/renderers/PodRenderer.tsx +28 -9
  26. package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
  27. package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
  28. package/src/components/resources/resource-utils-argo.test.ts +23 -0
  29. package/src/components/resources/resource-utils-argo.ts +5 -1
  30. package/src/components/resources/resource-utils-keda.ts +12 -8
  31. package/src/components/resources/resource-utils.ts +34 -14
  32. package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
  33. package/src/components/timeline/shared.tsx +15 -4
  34. package/src/components/topology/K8sResourceNode.tsx +28 -1
  35. package/src/components/topology/layout.ts +11 -5
  36. package/src/components/ui/PaneLoader.tsx +24 -6
  37. package/src/components/ui/drawer-components.test.tsx +35 -0
  38. package/src/components/ui/drawer-components.tsx +13 -1
  39. package/src/components/workload/WorkloadView.tsx +35 -5
  40. package/src/types/core.ts +100 -3
  41. package/src/utils/applications.test.ts +55 -1
  42. package/src/utils/applications.ts +28 -7
  43. package/src/utils/badge-colors.ts +7 -0
@@ -129,11 +129,12 @@ import {
129
129
  podMatchesProblemCategory,
130
130
  SEVERITY_DOT_COLOR,
131
131
  } from './resource-utils'
132
- import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
132
+ import { SEVERITY_BADGE, EVENT_TYPE_COLORS, SEVERITY_TEXT } from '../../utils/badge-colors'
133
133
  import { pluralize } from '../../utils/pluralize'
134
134
  import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
135
135
  import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
136
136
  import { Tooltip } from '../ui/Tooltip'
137
+ import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
137
138
  // CRD-specific cell components (extracted)
138
139
  import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
139
140
  import { ArgoApplicationCell, ArgoApplicationSetCell, ArgoAppProjectCell } from './renderers/argo-cells'
@@ -1800,6 +1801,9 @@ interface ResourcesViewData {
1800
1801
  onNavigate?: (path: string, options?: { replace?: boolean }) => void
1801
1802
  certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
1802
1803
  certExpiryError?: boolean
1804
+ // Cluster Audit findings for the listed kind, keyed by "namespace/name" (the
1805
+ // list shows one kind at a time, so ns/name is unambiguous). Host-injected.
1806
+ auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
1803
1807
  onOpenLogs?: (params: { namespace: string; podName: string; containers: string[]; containerName?: string }) => void
1804
1808
  onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
1805
1809
  }
@@ -1852,6 +1856,8 @@ interface ResourcesViewProps {
1852
1856
  topNodeMetrics?: TopNodeMetrics[]
1853
1857
  certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
1854
1858
  certExpiryError?: boolean
1859
+ // Cluster Audit findings for the selected kind, keyed by "namespace/name".
1860
+ auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
1855
1861
  // Pinned kinds
1856
1862
  pinned?: Array<{ name: string; kind: string; group: string }>
1857
1863
  togglePin?: (kind: { name: string; kind: string; group: string }) => void
@@ -2070,6 +2076,7 @@ export function ResourcesView({
2070
2076
  topNodeMetrics,
2071
2077
  certExpiry,
2072
2078
  certExpiryError,
2079
+ auditBadges,
2073
2080
  pinned = [],
2074
2081
  togglePin = () => {},
2075
2082
  isPinned = () => false,
@@ -4014,9 +4021,10 @@ export function ResourcesView({
4014
4021
  onNavigate,
4015
4022
  certExpiry,
4016
4023
  certExpiryError,
4024
+ auditBadges,
4017
4025
  onOpenLogs,
4018
4026
  onOpenWorkloadLogs,
4019
- }), [onNavigate, certExpiry, certExpiryError, onOpenLogs, onOpenWorkloadLogs])
4027
+ }), [onNavigate, certExpiry, certExpiryError, auditBadges, onOpenLogs, onOpenWorkloadLogs])
4020
4028
 
4021
4029
  return (
4022
4030
  <ResourcesViewDataContext.Provider value={resourcesViewDataContextValue}>
@@ -5155,6 +5163,7 @@ interface CellContentProps {
5155
5163
  }
5156
5164
 
5157
5165
  function CellContent({ resource, kind, column, group, majorityNodeMinorVersion, extraColumn, nameHref }: CellContentProps) {
5166
+ const { auditBadges } = useContext(ResourcesViewDataContext)
5158
5167
  // Parent-injected extra columns short-circuit the built-in switch.
5159
5168
  // Used by hosts that inject leading columns (e.g. a multi-cluster Cluster column).
5160
5169
  if (extraColumn) {
@@ -5167,6 +5176,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5167
5176
  if (column === 'name') {
5168
5177
  const isTerminating = !!meta.deletionTimestamp
5169
5178
  const nameClass = clsx('text-sm font-medium truncate block', isTerminating ? 'text-theme-text-tertiary line-through' : 'text-theme-text-primary')
5179
+ const audit = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
5180
+ const auditTotal = audit ? audit.danger + audit.warning : 0
5170
5181
  return (
5171
5182
  <div className="flex items-center gap-1.5 min-w-0">
5172
5183
  <Tooltip content={meta.name}>
@@ -5184,6 +5195,16 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5184
5195
  )}
5185
5196
  </Tooltip>
5186
5197
  <CopyNameButton name={meta.name} />
5198
+ {auditTotal > 0 && audit && (
5199
+ <Tooltip content={audit.messages && audit.messages.length > 0
5200
+ ? <AuditBadgeTooltip messages={audit.messages} />
5201
+ : `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} danger` : ''}`}>
5202
+ <span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)}>
5203
+ <AlertTriangle className="w-3 h-3" />
5204
+ {auditTotal}
5205
+ </span>
5206
+ </Tooltip>
5207
+ )}
5187
5208
  {isTerminating && (
5188
5209
  <Tooltip content="Resource is being deleted (has deletionTimestamp set). May be stuck due to finalizers.">
5189
5210
  <span className="shrink-0 flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium bg-red-500/15 text-red-600 dark:text-red-400 rounded">
@@ -6022,6 +6043,7 @@ function ReplicaSetCell({ resource, column }: { resource: any; column: string })
6022
6043
  }
6023
6044
 
6024
6045
  function ServiceCell({ resource, column }: { resource: any; column: string }) {
6046
+ const { auditBadges } = useContext(ResourcesViewDataContext)
6025
6047
  switch (column) {
6026
6048
  case 'type': {
6027
6049
  const status = getServiceStatus(resource)
@@ -6042,6 +6064,20 @@ function ServiceCell({ resource, column }: { resource: any; column: string }) {
6042
6064
  )
6043
6065
  }
6044
6066
  case 'endpoints': {
6067
+ // getServiceEndpointsStatus can't see live pods, so it optimistically
6068
+ // reports "Active" for any service with a selector. The audit's
6069
+ // serviceNoMatchingPods check DOES resolve the selector against live pods —
6070
+ // the only badge-worthy finding a Service can carry — so when it fired,
6071
+ // trust it over the guess instead of showing a false-green "Active".
6072
+ const meta = resource.metadata || {}
6073
+ const flagged = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
6074
+ if (flagged && flagged.danger + flagged.warning > 0) {
6075
+ return (
6076
+ <span className={clsx('badge', flagged.danger > 0 ? 'status-unhealthy' : 'status-degraded')}>
6077
+ No endpoints
6078
+ </span>
6079
+ )
6080
+ }
6045
6081
  const { status, color } = getServiceEndpointsStatus(resource)
6046
6082
  return (
6047
6083
  <span className={clsx('badge', color)}>
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { cronToHuman } from './resource-utils'
3
+
4
+ describe('cronToHuman', () => {
5
+ it.each([
6
+ // The reported bug: step-minute with a wildcard hour was caught by the
7
+ // "Every hour at :MM" branch before the interval branch.
8
+ ['*/5 * * * *', 'Every 5 minutes'],
9
+ ['*/15 * * * *', 'Every 15 minutes'],
10
+ ['*/30 * * * *', 'Every 30 minutes'],
11
+ ['*/1 * * * *', 'Every minute'],
12
+ // Literal minute must still read as "Every hour at :MM" (not regressed).
13
+ ['30 * * * *', 'Every hour at :30'],
14
+ ['0 * * * *', 'Every hour at :00'],
15
+ ['5 * * * *', 'Every hour at :05'],
16
+ // Every minute.
17
+ ['* * * * *', 'Every minute'],
18
+ // Step-hour (the #952 fix) must still work.
19
+ ['0 */6 * * *', 'Every 6 hours'],
20
+ ['0 */1 * * *', 'Every hour'],
21
+ // Daily patterns.
22
+ ['0 0 * * *', 'Daily at midnight'],
23
+ ['0 9 * * *', 'Daily at 9:00'],
24
+ // Weekdays — only when hour:minute are literal.
25
+ ['0 9 * * 1-5', 'Weekdays at 9:00'],
26
+ ['0 9 * * MON-FRI', 'Weekdays at 9:00'],
27
+ ['30 14 * * 1-5', 'Weekdays at 14:30'],
28
+ // Constrained step-minute must NOT claim an unconstrained interval — these run
29
+ // only in a window, so we fall back to the raw cron rather than mislead.
30
+ ['*/5 9 * * *', '*/5 9 * * *'],
31
+ ['*/5 * * * 1-5', '*/5 * * * 1-5'],
32
+ ['*/5 9 * * 1-5', '*/5 9 * * 1-5'],
33
+ ['*/1 9 * * *', '*/1 9 * * *'],
34
+ // Falls back to the raw expression for shapes we don't humanize.
35
+ ['15 14 1 * *', '15 14 1 * *'],
36
+ ['*/5', '*/5'],
37
+ ['', '-'],
38
+ ])('humanizes %s -> %s', (cron, expected) => {
39
+ expect(cronToHuman(cron)).toBe(expected)
40
+ })
41
+ })
@@ -124,4 +124,22 @@ describe('getPodProblems', () => {
124
124
  }),
125
125
  ).not.toContainEqual(expect.objectContaining({ message: 'Sandbox Startup Stalled' }))
126
126
  })
127
+
128
+ it('does not flag a completing Job pod (Running, container exited 0, Ready=false) as Not Ready', () => {
129
+ expect(
130
+ getPodProblems({
131
+ status: {
132
+ phase: 'Running',
133
+ containerStatuses: [
134
+ {
135
+ name: 'job',
136
+ ready: false,
137
+ restartCount: 0,
138
+ state: { terminated: { reason: 'Completed', exitCode: 0 } },
139
+ },
140
+ ],
141
+ },
142
+ }),
143
+ ).not.toContainEqual(expect.objectContaining({ message: 'Not Ready' }))
144
+ })
127
145
  })
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { dirname, resolve } from 'node:path'
5
+ import {
6
+ getPodStatus,
7
+ getWorkloadStatus,
8
+ getJobStatus,
9
+ getCronJobStatus,
10
+ getPVCStatus,
11
+ type HealthLevel,
12
+ } from './resource-utils'
13
+
14
+ // Cross-language health contract. This loads the SAME fixture as the Go test
15
+ // (pkg/health/golden_crosslang_test.go) and asserts the TS table classifiers
16
+ // produce the level pkg/health recorded. pkg/health is the source of truth; this
17
+ // is the anti-drift gate that keeps the two implementations from diverging.
18
+ //
19
+ // If this fails after a backend health change, the TS classifier in
20
+ // resource-utils.ts must be updated to match — not the other way round.
21
+
22
+ interface GoldenVector {
23
+ name: string
24
+ kind: string
25
+ level: HealthLevel
26
+ object: any
27
+ }
28
+
29
+ const here = dirname(fileURLToPath(import.meta.url))
30
+ // src/components/resources -> repo root is five levels up, then pkg/health/testdata.
31
+ const fixturePath = resolve(here, '../../../../../pkg/health/testdata/golden_vectors.json')
32
+ const vectors: GoldenVector[] = JSON.parse(readFileSync(fixturePath, 'utf8')).vectors
33
+
34
+ // Map a fixture kind onto the TS classifier that backs its table badge.
35
+ function classify(kind: string, object: any): HealthLevel {
36
+ switch (kind) {
37
+ case 'Pod':
38
+ return getPodStatus(object).level
39
+ case 'Deployment':
40
+ return getWorkloadStatus(object, 'deployments').level
41
+ case 'StatefulSet':
42
+ return getWorkloadStatus(object, 'statefulsets').level
43
+ case 'DaemonSet':
44
+ return getWorkloadStatus(object, 'daemonsets').level
45
+ case 'Job':
46
+ return getJobStatus(object).level
47
+ case 'CronJob':
48
+ return getCronJobStatus(object).level
49
+ case 'PersistentVolumeClaim':
50
+ return getPVCStatus(object).level
51
+ default:
52
+ throw new Error(`golden vector kind "${kind}" has no TS classifier mapping`)
53
+ }
54
+ }
55
+
56
+ describe('health golden vectors (cross-language contract with pkg/health)', () => {
57
+ it('loaded a non-empty fixture shared with the Go test', () => {
58
+ expect(vectors.length).toBeGreaterThan(0)
59
+ })
60
+
61
+ for (const v of vectors) {
62
+ it(`${v.kind}: ${v.name}`, () => {
63
+ expect(classify(v.kind, v.object)).toBe(v.level)
64
+ })
65
+ }
66
+ })
@@ -25,8 +25,12 @@ function getJobProblems(data: any): string[] {
25
25
  }
26
26
  }
27
27
 
28
- // Check for pod failures without terminal condition yet
29
- if (!failedCondition && status.failed > 0) {
28
+ // Check for pod failures without terminal condition yet. A Job that already
29
+ // completed successfully (Complete condition) keeps its earlier failed pod
30
+ // attempts in status.failed — those are retries, not a problem — so don't flag
31
+ // them, or the drawer would read red while the table badge is calm neutral.
32
+ const completeCondition = conditions.find((c: any) => c.type === 'Complete' && c.status === 'True')
33
+ if (!failedCondition && !completeCondition && status.failed > 0) {
30
34
  const remaining = (spec.backoffLimit ?? 6) - status.failed
31
35
  if (remaining > 0) {
32
36
  problems.push(`${status.failed} pod(s) failed — ${remaining} retries remaining`)
@@ -62,9 +62,9 @@ export function KedaScaledObjectRenderer({ data, onNavigate }: KedaScaledObjectR
62
62
  )}
63
63
  {isPaused && (
64
64
  <AlertBanner
65
- variant="warning"
65
+ variant="info"
66
66
  title="Scaling Paused"
67
- message="Autoscaling is paused via annotation."
67
+ message="Autoscaling is paused via annotation. This is intentional — resume by removing the paused annotation."
68
68
  />
69
69
  )}
70
70
 
@@ -26,16 +26,12 @@ function formatStorage(value: string | undefined): string {
26
26
  return formatMemory(value)
27
27
  }
28
28
 
29
- // Extract problems from node status and spec
29
+ // Extract genuine problems from node status. Cordoned (unschedulable) is
30
+ // deliberately NOT included here — it's an intentional operator action
31
+ // (cordon/drain), surfaced separately as a calm advisory, not a red error.
30
32
  function getNodeProblems(data: any): string[] {
31
33
  const problems: string[] = []
32
34
  const conditions = data.status?.conditions || []
33
- const spec = data.spec || {}
34
-
35
- // Check if unschedulable
36
- if (spec.unschedulable) {
37
- problems.push('Node is cordoned (unschedulable)')
38
- }
39
35
 
40
36
  for (const cond of conditions) {
41
37
  // NotReady is a problem when status is not True
@@ -77,6 +73,7 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
77
73
  // Check for problems
78
74
  const problems = getNodeProblems(data)
79
75
  const hasProblems = problems.length > 0
76
+ const isCordoned = !!spec.unschedulable
80
77
 
81
78
  // Extract platform info from labels
82
79
  const instanceType = labels['node.kubernetes.io/instance-type']
@@ -88,11 +85,23 @@ export function NodeRenderer({ data, relationships, onViewPods, metrics, metrics
88
85
 
89
86
  return (
90
87
  <>
91
- {/* Problems alert - shown at top when there are issues */}
88
+ {/* Problems alert - shown at top when there are genuine issues */}
92
89
  {hasProblems && (
93
90
  <AlertBanner variant="error" title="Issues Detected" items={problems} />
94
91
  )}
95
92
 
93
+ {/* Cordoned is intentional but consequential — it removes scheduling
94
+ capacity and a forgotten cordon strands a node. So it's a warning (amber),
95
+ matching the node table badge + the Cordoned audit check — NOT the calm
96
+ sky of a no-op intentional state (suspended/idle), and not a red error. */}
97
+ {isCordoned && (
98
+ <AlertBanner
99
+ variant="warning"
100
+ title="Cordoned (unschedulable)"
101
+ message="New pods won't be scheduled here. Uncordon to resume scheduling."
102
+ />
103
+ )}
104
+
96
105
  {/* Node Info */}
97
106
  <Section title="Node Info" icon={Server}>
98
107
  <PropertyList>
@@ -28,10 +28,10 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
28
28
  const annotations = data.metadata?.annotations || {}
29
29
  const phase = status.phase
30
30
 
31
- // Problem detection
31
+ // Lost is a genuine failure (bound volume disappeared). Pending is a normal
32
+ // lifecycle state (provisioning / WaitForFirstConsumer), surfaced calmly below.
32
33
  const isLost = phase === 'Lost'
33
34
  const isPending = phase === 'Pending'
34
- const hasProblems = isLost || isPending
35
35
 
36
36
  // Provisioner info from annotations
37
37
  const provisioner = annotations['volume.kubernetes.io/storage-provisioner']
@@ -42,7 +42,7 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
42
42
  return (
43
43
  <>
44
44
  {/* Problem alerts */}
45
- {hasProblems && isLost && (
45
+ {isLost && (
46
46
  <AlertBanner
47
47
  variant="error"
48
48
  title="Issues Detected"
@@ -50,11 +50,11 @@ export function PVCRenderer({ data, onNavigate, extraSections }: PVCRendererProp
50
50
  />
51
51
  )}
52
52
 
53
- {hasProblems && isPending && (
53
+ {isPending && (
54
54
  <AlertBanner
55
- variant="warning"
56
- title="Issues Detected"
57
- message="PVC is waiting to be bound to a volume"
55
+ variant="info"
56
+ title="Pending — awaiting binding"
57
+ message="Not yet bound to a volume. This is normal while provisioning, and expected indefinitely for a WaitForFirstConsumer StorageClass until a Pod that mounts this claim is scheduled."
58
58
  />
59
59
  )}
60
60
 
@@ -1,7 +1,7 @@
1
1
  import { useState, type ReactNode, type JSX } from 'react'
2
2
  import { Server, HardDrive, Terminal as TerminalIcon, FileText, Activity, CirclePlay, FolderOpen, List, Eye, EyeOff, Shield } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
+ import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink, useOperationalIssuesShown } from '../../ui/drawer-components'
5
5
  import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR, getDefaultContainerName } from '../resource-utils'
6
6
  import { getResourceStatusColor, SEVERITY_BADGE_BORDERED } from '../../../utils/badge-colors'
7
7
  import {
@@ -274,9 +274,12 @@ export function PodRenderer({
274
274
  const podName = data.metadata?.name
275
275
  const isRunning = data.status?.phase === 'Running'
276
276
 
277
- // Check for problems
277
+ // Check for problems. Suppressed when the detail already shows the dedicated
278
+ // Operational Issues section (the Issues pipeline covers the same pod failures,
279
+ // richer) — avoids showing the same crashloop twice.
280
+ const operationalIssuesShown = useOperationalIssuesShown()
278
281
  const podProblems = getPodProblems(data)
279
- const hasProblems = podProblems.length > 0
282
+ const hasProblems = podProblems.length > 0 && !operationalIssuesShown
280
283
 
281
284
  // Image filesystem modal state
282
285
  const [selectedImage, setSelectedImage] = useState<string | null>(null)
@@ -548,6 +551,10 @@ export function PodRenderer({
548
551
  const lastTermination = status?.lastState?.terminated
549
552
  const currentWaiting = status?.state?.waiting
550
553
  const currentTerminated = status?.state?.terminated
554
+ // A container that exited 0 (a completed Job pod) is a success, not a
555
+ // failure — tone its badges/text sky, not red, so the drawer agrees
556
+ // with the calm "Completed" table badge instead of screaming red.
557
+ const terminatedOk = currentTerminated?.exitCode === 0
551
558
 
552
559
  return (
553
560
  <div key={container.name} className="card-inner-lg">
@@ -586,14 +593,17 @@ export function PodRenderer({
586
593
  )}
587
594
  <span className={clsx(
588
595
  'badge',
589
- isReady ? SEVERITY_BADGE_BORDERED.success : SEVERITY_BADGE_BORDERED.error
596
+ isReady ? SEVERITY_BADGE_BORDERED.success :
597
+ terminatedOk ? SEVERITY_BADGE_BORDERED.info :
598
+ SEVERITY_BADGE_BORDERED.error
590
599
  )}>
591
- {isReady ? 'Ready' : 'Not Ready'}
600
+ {isReady ? 'Ready' : terminatedOk ? 'Completed' : 'Not Ready'}
592
601
  </span>
593
602
  <span className={clsx(
594
603
  'badge',
595
604
  stateKey === 'running' ? SEVERITY_BADGE_BORDERED.success :
596
605
  stateKey === 'waiting' ? SEVERITY_BADGE_BORDERED.warning :
606
+ terminatedOk ? SEVERITY_BADGE_BORDERED.info :
597
607
  SEVERITY_BADGE_BORDERED.error
598
608
  )}>
599
609
  {stateKey}
@@ -621,9 +631,10 @@ export function PodRenderer({
621
631
  )}
622
632
  </div>
623
633
  )}
624
- {/* Show current terminated reason */}
634
+ {/* Show current terminated reason — sky for a clean exit-0
635
+ completion, red only for a genuine failure. */}
625
636
  {currentTerminated?.reason && (
626
- <div className="text-red-400 flex items-center gap-1">
637
+ <div className={clsx('flex items-center gap-1', terminatedOk ? 'text-sky-500 dark:text-sky-400' : 'text-red-400')}>
627
638
  <span className="font-medium">Terminated: {currentTerminated.reason}</span>
628
639
  {currentTerminated.exitCode !== undefined && currentTerminated.exitCode !== 0 && (
629
640
  <span className="text-theme-text-tertiary">(exit code {currentTerminated.exitCode})</span>
@@ -795,8 +806,16 @@ export function PodRenderer({
795
806
  </Section>
796
807
  )}
797
808
 
798
- {/* Conditions */}
799
- <ConditionsSection conditions={data.status?.conditions} />
809
+ {/* Conditions. A completed pod's Ready/ContainersReady flip to False with
810
+ reason "PodCompleted" — that's expected for a finished pod, not a failure,
811
+ so tone it neutral (gray) instead of red. Gated on the PodCompleted reason
812
+ so a genuinely not-ready pod (any other reason) still reads red. */}
813
+ <ConditionsSection
814
+ conditions={data.status?.conditions}
815
+ getConditionTone={(cond) =>
816
+ cond?.status === 'False' && cond?.reason === 'PodCompleted' ? 'unknown' : undefined
817
+ }
818
+ />
800
819
 
801
820
  {/* Permissions (via ServiceAccount) — placed below the diagnostic-
802
821
  * signal sections (status, containers, resource usage, conditions)
@@ -10,7 +10,11 @@ interface ServiceRendererProps {
10
10
  endpointSlices?: any[]
11
11
  endpointSlicesLoading?: boolean
12
12
  onNavigate?: (ref: ResourceRef) => void
13
- renderPortAction?: (props: { namespace: string; serviceName: string; port: number; protocol: string }) => ReactNode
13
+ renderPortAction?: (props: { namespace: string; serviceName: string; port: number; protocol: string; name?: string; appProtocol?: string }) => ReactNode
14
+ /** Optional full-width content rendered inside a port's card, below its header
15
+ * (e.g. an inline probe panel). Lets a host attach a port-scoped panel in the
16
+ * drawer flow rather than as a separate overlay. */
17
+ renderPortPanel?: (props: { namespace: string; serviceName: string; port: number; protocol: string; name?: string; appProtocol?: string }) => ReactNode
14
18
  }
15
19
 
16
20
  function endpointSliceAddressCount(slice: any): number {
@@ -28,7 +32,7 @@ function endpointSliceReadyClass(ready: number, total: number): string {
28
32
  return 'status-unhealthy'
29
33
  }
30
34
 
31
- export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, onNavigate, renderPortAction }: ServiceRendererProps) {
35
+ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpointSlicesLoading, onNavigate, renderPortAction, renderPortPanel }: ServiceRendererProps) {
32
36
  const spec = data.spec || {}
33
37
  const ports = spec.ports || []
34
38
  const lbIngress = data.status?.loadBalancer?.ingress || []
@@ -95,24 +99,34 @@ export function ServiceRenderer({ data, onCopy, copied, endpointSlices, endpoint
95
99
  <div className="space-y-2">
96
100
  {ports.map((port: any, i: number) => (
97
101
  <div key={`${port.port}-${port.protocol || 'TCP'}`} className="card-inner text-sm">
98
- <div className="flex items-center justify-between">
99
- <div className="flex items-center gap-2">
102
+ <div className="flex items-center justify-between gap-2">
103
+ <div className="flex items-baseline gap-x-2 gap-y-0.5 min-w-0 flex-wrap">
100
104
  <span className="text-theme-text-primary font-medium">{port.name || `port-${i + 1}`}</span>
101
105
  <span className="text-xs text-theme-text-tertiary">{port.protocol || 'TCP'}</span>
106
+ <span className="text-xs text-theme-text-secondary font-mono">
107
+ {port.port}{port.targetPort != null && port.targetPort !== port.port ? ` → ${port.targetPort}` : ''}
108
+ {port.nodePort ? ` (NodePort: ${port.nodePort})` : ''}
109
+ </span>
102
110
  </div>
103
- <div className="flex items-center gap-2">
111
+ <div className="flex items-center gap-2 shrink-0">
104
112
  {renderPortAction?.({
105
113
  namespace,
106
114
  serviceName,
107
115
  port: port.port,
108
116
  protocol: port.protocol || 'TCP',
117
+ name: port.name,
118
+ appProtocol: port.appProtocol,
109
119
  })}
110
120
  </div>
111
121
  </div>
112
- <div className="text-xs text-theme-text-secondary mt-1">
113
- {port.port}{port.targetPort != null && port.targetPort !== port.port ? ` → ${port.targetPort}` : ''}
114
- {port.nodePort ? ` (NodePort: ${port.nodePort})` : ''}
115
- </div>
122
+ {renderPortPanel?.({
123
+ namespace,
124
+ serviceName,
125
+ port: port.port,
126
+ protocol: port.protocol || 'TCP',
127
+ name: port.name,
128
+ appProtocol: port.appProtocol,
129
+ })}
116
130
  </div>
117
131
  ))}
118
132
  </div>
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import { Server, ExternalLink, Scale, Minus, Plus, Loader2, Shield } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink, ResourceRefBadge } from '../../ui/drawer-components'
4
+ import { Section, PropertyList, Property, ConditionsSection, PodTemplateSection, AlertBanner, ResourceLink, ResourceRefBadge, useOperationalIssuesShown } from '../../ui/drawer-components'
5
5
  import { DialogPortal } from '../../ui/DialogPortal'
6
6
  import { Tooltip } from '../../ui/Tooltip'
7
7
  import { Badge, type BadgeSeverity } from '../../ui/Badge'
@@ -165,8 +165,11 @@ export function WorkloadRenderer({ kind, data, onNavigate, onViewPods, onScale,
165
165
  }
166
166
  }, [spec.replicas, scaledTo])
167
167
 
168
- // Check for problems and progress
169
- const problems = getWorkloadProblems(status, spec, kind)
168
+ // Check for problems and progress. Suppressed when the dedicated Operational
169
+ // Issues section is shown — it carries the workload's own issues plus its pods'
170
+ // (richer, with cause/action), so the workload-status problems would duplicate.
171
+ const operationalIssuesShown = useOperationalIssuesShown()
172
+ const problems = operationalIssuesShown ? [] : getWorkloadProblems(status, spec, kind)
170
173
  const hasProblems = problems.length > 0
171
174
  const progressMessage = getWorkloadProgress(status, spec, kind)
172
175
 
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getArgoApplicationStatus } from './resource-utils-argo'
3
+
4
+ describe('getArgoApplicationStatus', () => {
5
+ // A Suspended Argo app is intentionally paused — neutral (sky), matching the
6
+ // backend rollup (mapArgoHealth) + the GitOps badge, so it doesn't read amber
7
+ // on the resource table while reading Idle in Applications.
8
+ it('maps health Suspended to neutral (sky), not degraded', () => {
9
+ const badge = getArgoApplicationStatus({ status: { health: { status: 'Suspended' }, sync: { status: 'Synced' } } })
10
+ expect(badge.level).toBe('neutral')
11
+ expect(badge.text).toBe('Suspended')
12
+ })
13
+
14
+ it('still maps a healthy synced app to healthy', () => {
15
+ const badge = getArgoApplicationStatus({ status: { health: { status: 'Healthy' }, sync: { status: 'Synced' } } })
16
+ expect(badge.level).toBe('healthy')
17
+ })
18
+
19
+ it('still maps a degraded app to unhealthy', () => {
20
+ const badge = getArgoApplicationStatus({ status: { health: { status: 'Degraded' }, sync: { status: 'Synced' } } })
21
+ expect(badge.level).toBe('unhealthy')
22
+ })
23
+ })
@@ -22,7 +22,11 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
22
22
  const annotations = app.metadata?.annotations
23
23
  const suspendedByRadar = annotations?.['radarhq.io/suspended-prune'] || annotations?.['skyhook.io/suspended-prune']
24
24
  if (health === 'Suspended' || (!hasAutomatedSync && suspendedByRadar)) {
25
- return { text: 'Suspended', color: healthColors.degraded, level: 'degraded' }
25
+ // Suspended = an operator deliberately paused this app — intentional, not a
26
+ // degradation. Neutral (sky), matching the backend rollup (mapArgoHealth) so a
27
+ // suspended app reads the same Idle tone in Applications, the resource table,
28
+ // and GitOps instead of amber in some surfaces and sky in others.
29
+ return { text: 'Suspended', color: healthColors.neutral, level: 'neutral' }
26
30
  }
27
31
 
28
32
  // Operation in progress
@@ -29,7 +29,9 @@ export function getScaledObjectStatus(resource: any): StatusBadge {
29
29
  conditions.some((c: any) => c.type === 'Paused' && c.status === 'True')
30
30
 
31
31
  if (isPaused) {
32
- return { text: 'Paused', color: healthColors.degraded, level: 'degraded' }
32
+ // Paused = operator deliberately froze autoscaling intentional, sky/neutral
33
+ // (like Idle), not amber.
34
+ return { text: 'Paused', color: healthColors.neutral, level: 'neutral' }
33
35
  }
34
36
 
35
37
  // Check Fallback condition
@@ -128,26 +130,28 @@ export function getScaledJobStatus(resource: any): StatusBadge {
128
130
  const conditions = resource.status?.conditions || []
129
131
 
130
132
  const readyCond = conditions.find((c: any) => c.type === 'Ready')
131
- if (readyCond?.status === 'True') {
132
- return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
133
- }
134
133
  // A non-operational scaler (Ready=False) is unhealthy and must take precedence
135
- // over the Idle (Active=False) branch below — otherwise a broken-and-idle
136
- // ScaledJob hides as benign "Idle". Mirrors getScaledObjectStatus.
134
+ // over the Idle branch — otherwise a broken-and-idle ScaledJob hides as benign.
137
135
  if (readyCond?.status === 'False') {
138
136
  return { text: readyCond.reason || 'NotReady', color: healthColors.unhealthy, level: 'unhealthy' }
139
137
  }
140
138
 
139
+ // Check Active BEFORE falling back to Ready=True: an operational scaler with no
140
+ // jobs running (Active=False) is intentionally idle → sky, not the green of a
141
+ // busy one. (Ready=True first would make Idle unreachable.) Mirrors
142
+ // getScaledObjectStatus.
141
143
  const activeCond = conditions.find((c: any) => c.type === 'Active')
142
144
  if (activeCond?.status === 'True') {
143
145
  return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
144
146
  }
145
147
  if (activeCond?.status === 'False') {
146
- // Idle is the normal resting state of a scaler with no triggers firing
147
- // (like a CronJob waiting for its next run), not a fault.
148
148
  return { text: 'Idle', color: healthColors.neutral, level: 'neutral' }
149
149
  }
150
150
 
151
+ if (readyCond?.status === 'True') {
152
+ return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
153
+ }
154
+
151
155
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
152
156
  }
153
157