@skyhook-io/k8s-ui 1.8.5 → 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 (72) 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-phase-display.test.ts +115 -3
  20. package/src/components/resources/get-pod-problems.test.ts +18 -0
  21. package/src/components/resources/health-golden.test.ts +66 -0
  22. package/src/components/resources/renderers/CronJobRenderer.tsx +14 -7
  23. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +4 -3
  24. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +9 -15
  25. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -11
  26. package/src/components/resources/renderers/GatewayRenderer.tsx +11 -35
  27. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +10 -16
  28. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -6
  29. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +24 -28
  30. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +19 -26
  31. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +20 -19
  32. package/src/components/resources/renderers/JobRenderer.tsx +12 -7
  33. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +2 -2
  34. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
  35. package/src/components/resources/renderers/NodeRenderer.tsx +17 -8
  36. package/src/components/resources/renderers/PVCRenderer.tsx +7 -7
  37. package/src/components/resources/renderers/PodRenderer.tsx +28 -9
  38. package/src/components/resources/renderers/ServiceRenderer.tsx +23 -9
  39. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +6 -12
  40. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +11 -10
  41. package/src/components/resources/renderers/TraefikMiddlewareRenderer.tsx +185 -0
  42. package/src/components/resources/renderers/TraefikServersTransportRenderer.tsx +84 -0
  43. package/src/components/resources/renderers/TraefikServiceRenderer.tsx +118 -0
  44. package/src/components/resources/renderers/TraefikTLSOptionRenderer.tsx +62 -0
  45. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -1
  46. package/src/components/resources/renderers/WorkloadRenderer.tsx +6 -3
  47. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +66 -0
  48. package/src/components/resources/renderers/contour-cells.tsx +6 -5
  49. package/src/components/resources/renderers/index.ts +4 -0
  50. package/src/components/resources/renderers/istio-cells.tsx +16 -25
  51. package/src/components/resources/resource-utils-argo.test.ts +23 -0
  52. package/src/components/resources/resource-utils-argo.ts +5 -1
  53. package/src/components/resources/resource-utils-cnpg.ts +1 -1
  54. package/src/components/resources/resource-utils-keda.ts +17 -7
  55. package/src/components/resources/resource-utils.ts +206 -71
  56. package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
  57. package/src/components/timeline/TimelineSwimlanes.tsx +1 -0
  58. package/src/components/timeline/shared.tsx +15 -4
  59. package/src/components/topology/K8sResourceNode.tsx +28 -1
  60. package/src/components/topology/TopologyControls.tsx +41 -9
  61. package/src/components/topology/layout.ts +11 -5
  62. package/src/components/ui/Badge.tsx +66 -3
  63. package/src/components/ui/ClusterName.tsx +12 -4
  64. package/src/components/ui/PaneLoader.tsx +30 -7
  65. package/src/components/ui/drawer-components.test.tsx +35 -0
  66. package/src/components/ui/drawer-components.tsx +13 -1
  67. package/src/components/workload/WorkloadView.tsx +35 -5
  68. package/src/types/core.ts +136 -2
  69. package/src/utils/applications.test.ts +55 -1
  70. package/src/utils/applications.ts +28 -7
  71. package/src/utils/asset-url.ts +13 -0
  72. 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
+ })
@@ -1,5 +1,32 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { getPodPhaseDisplay } from './resource-utils'
2
+ import { getPodPhaseDisplay, getPodStatus } from './resource-utils'
3
+
4
+ describe('getPodStatus', () => {
5
+ it('keeps a Succeeded pod neutral despite a sidecar that OOMed before completion', () => {
6
+ const succeededWithOomedSidecar = {
7
+ status: {
8
+ phase: 'Succeeded',
9
+ containerStatuses: [
10
+ { name: 'main', ready: false, state: { terminated: { reason: 'Completed', exitCode: 0 } } },
11
+ { name: 'sidecar', ready: false, state: { terminated: { reason: 'OOMKilled', exitCode: 137 } } },
12
+ ],
13
+ },
14
+ }
15
+ const r = getPodStatus(succeededWithOomedSidecar)
16
+ expect(r.text).toBe('Completed')
17
+ expect(r.level).toBe('neutral')
18
+ })
19
+
20
+ it('still flags a crash-looping (non-terminal) pod as unhealthy', () => {
21
+ const crashing = {
22
+ status: {
23
+ phase: 'Running',
24
+ containerStatuses: [{ name: 'app', ready: false, state: { waiting: { reason: 'CrashLoopBackOff' } } }],
25
+ },
26
+ }
27
+ expect(getPodStatus(crashing).level).toBe('unhealthy')
28
+ })
29
+ })
3
30
 
4
31
  const podRunningHealthy = {
5
32
  status: {
@@ -192,12 +219,59 @@ describe('getPodPhaseDisplay', () => {
192
219
  expect(r.level).toBe('unhealthy')
193
220
  })
194
221
 
195
- it('marks pods with deletionTimestamp as Terminating regardless of phase', () => {
222
+ it('marks a long-terminating pod as Terminating + degraded (stuck shutdown)', () => {
196
223
  const r = getPodPhaseDisplay(podTerminating)
197
224
  expect(r.text).toBe('Running — Terminating')
198
225
  expect(r.level).toBe('degraded')
199
226
  })
200
227
 
228
+ it('keeps a recently-terminating pod neutral (graceful shutdown in progress)', () => {
229
+ const recent = {
230
+ metadata: { deletionTimestamp: new Date(Date.now() - 30 * 1000).toISOString() },
231
+ status: { phase: 'Running', containerStatuses: [{ name: 'app', ready: true, restartCount: 0 }] },
232
+ }
233
+ const r = getPodPhaseDisplay(recent)
234
+ expect(r.text).toBe('Running — Terminating')
235
+ expect(r.level).toBe('neutral')
236
+ })
237
+
238
+ it('treats a completing pod (container exited 0, not ready) as healthy', () => {
239
+ const completing = {
240
+ status: {
241
+ phase: 'Running',
242
+ containerStatuses: [
243
+ { name: 'app', ready: false, restartCount: 0, state: { terminated: { reason: 'Completed', exitCode: 0 } } },
244
+ ],
245
+ },
246
+ }
247
+ const r = getPodPhaseDisplay(completing)
248
+ expect(r.text).toBe('Running')
249
+ expect(r.level).toBe('healthy')
250
+ })
251
+
252
+ it('keeps a Failed pod unhealthy even while it is terminating', () => {
253
+ const failedTerminating = {
254
+ metadata: { deletionTimestamp: new Date().toISOString() },
255
+ status: { phase: 'Failed' },
256
+ }
257
+ const r = getPodPhaseDisplay(failedTerminating)
258
+ expect(r.text).toBe('Failed')
259
+ expect(r.level).toBe('unhealthy')
260
+ })
261
+
262
+ it('surfaces a fatal container failure even while the pod is terminating', () => {
263
+ const terminatingCrash = {
264
+ metadata: { deletionTimestamp: new Date().toISOString() },
265
+ status: {
266
+ phase: 'Running',
267
+ containerStatuses: [{ name: 'app', ready: false, state: { waiting: { reason: 'CrashLoopBackOff' } } }],
268
+ },
269
+ }
270
+ const r = getPodPhaseDisplay(terminatingCrash)
271
+ expect(r.text).toBe('Running — CrashLoopBackOff')
272
+ expect(r.level).toBe('unhealthy')
273
+ })
274
+
201
275
  it('falls back to Unknown for missing/unknown phase', () => {
202
276
  expect(getPodPhaseDisplay({}).level).toBe('unknown')
203
277
  expect(getPodPhaseDisplay({}).phase).toBe('Unknown')
@@ -206,10 +280,48 @@ describe('getPodPhaseDisplay', () => {
206
280
 
207
281
  it('handles Succeeded/Pending/Failed phases', () => {
208
282
  expect(getPodPhaseDisplay({ status: { phase: 'Succeeded' } }).level).toBe('neutral')
209
- expect(getPodPhaseDisplay({ status: { phase: 'Pending' } }).level).toBe('degraded')
283
+ // A freshly-created Pending pod is benign (neutral), not degraded.
284
+ expect(getPodPhaseDisplay({ status: { phase: 'Pending' } }).level).toBe('neutral')
210
285
  expect(getPodPhaseDisplay({ status: { phase: 'Failed' } }).level).toBe('unhealthy')
211
286
  })
212
287
 
288
+ it('flags Unschedulable immediately, but gives plain young Pending a grace window', () => {
289
+ // The scheduler tried and failed — surface it now, no grace (matches backend).
290
+ const unsched = {
291
+ status: { phase: 'Pending', conditions: [{ type: 'PodScheduled', status: 'False', reason: 'Unschedulable' }] },
292
+ }
293
+ expect(getPodPhaseDisplay(unsched).text).toBe('Pending — Unschedulable')
294
+ expect(getPodPhaseDisplay(unsched).level).toBe('degraded')
295
+
296
+ // Plain Pending (not yet placed) is benign while young, degraded once stuck.
297
+ expect(getPodPhaseDisplay({ status: { phase: 'Pending' } }).level).toBe('neutral')
298
+ const stuckPending = {
299
+ metadata: { creationTimestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString() },
300
+ status: { phase: 'Pending' },
301
+ }
302
+ expect(getPodPhaseDisplay(stuckPending).level).toBe('degraded')
303
+ })
304
+
305
+ it('flags a failing init container immediately, even on a young Pending pod', () => {
306
+ const initFail = {
307
+ metadata: { creationTimestamp: new Date().toISOString() },
308
+ status: {
309
+ phase: 'Pending',
310
+ initContainerStatuses: [{ name: 'init', ready: false, state: { waiting: { reason: 'ImagePullBackOff' } } }],
311
+ },
312
+ }
313
+ const r = getPodPhaseDisplay(initFail)
314
+ expect(r.text).toBe('Pending — ImagePullBackOff')
315
+ expect(r.level).toBe('unhealthy')
316
+ })
317
+
318
+ it('flags broadened fatal reasons like InvalidImageName', () => {
319
+ const bad = {
320
+ status: { phase: 'Pending', containerStatuses: [{ name: 'app', state: { waiting: { reason: 'InvalidImageName' } } }] },
321
+ }
322
+ expect(getPodPhaseDisplay(bad).level).toBe('unhealthy')
323
+ })
324
+
213
325
  it('flags ErrImagePull as unhealthy', () => {
214
326
  const r = getPodPhaseDisplay(podErrImagePull)
215
327
  expect(r.text).toBe('Pending — ErrImagePull')
@@ -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
+ })
@@ -15,17 +15,24 @@ export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
15
15
  const isSuspended = spec.suspend === true
16
16
  const hasNeverRun = !status.lastScheduleTime
17
17
 
18
- // Calculate time since last success vs last schedule
19
- const lastScheduleAge = status.lastScheduleTime ? new Date().getTime() - new Date(status.lastScheduleTime).getTime() : 0
20
- const lastSuccessAge = status.lastSuccessfulTime ? new Date().getTime() - new Date(status.lastSuccessfulTime).getTime() : 0
21
- const recentFailures = lastScheduleAge > 0 && lastSuccessAge > lastScheduleAge
18
+ // "Recent failures" means the latest scheduled run didn't reach success.
19
+ // Only meaningful once nothing is running while a job is active the most
20
+ // recent run is simply still in flight, not failing. With concurrencyPolicy
21
+ // Allow an older failed run can overlap a new active one; CronJob status only
22
+ // carries aggregate timestamps, so we accept missing that rare case here (the
23
+ // failed Job still surfaces in the job list / topology) rather than reviving
24
+ // the false positive that fired on every normal in-flight run.
25
+ const activeJobs = status.active?.length || 0
26
+ const lastSchedule = status.lastScheduleTime ? new Date(status.lastScheduleTime).getTime() : 0
27
+ const lastSuccess = status.lastSuccessfulTime ? new Date(status.lastSuccessfulTime).getTime() : 0
28
+ const recentFailures = activeJobs === 0 && lastSchedule > 0 && lastSchedule > lastSuccess
22
29
 
23
30
  return (
24
31
  <>
25
- {/* Suspended warning */}
32
+ {/* Suspended is an intentional operator state, not a fault — keep it informational. */}
26
33
  {isSuspended && (
27
34
  <AlertBanner
28
- variant="warning"
35
+ variant="info"
29
36
  icon={Pause}
30
37
  title="CronJob Suspended"
31
38
  message="No new jobs will be scheduled until this CronJob is resumed."
@@ -42,7 +49,7 @@ export function CronJobRenderer({ data, onNavigate }: CronJobRendererProps) {
42
49
  )}
43
50
 
44
51
  {/* Recent failures warning */}
45
- {recentFailures && (
52
+ {recentFailures && !isSuspended && (
46
53
  <AlertBanner
47
54
  variant="error"
48
55
  title="Recent Jobs Failing"
@@ -1,6 +1,7 @@
1
1
  import { Radio, Server, Waypoints } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
4
+ import { Badge } from '../../ui/Badge'
4
5
  import type { ResourceRef } from '../../../types'
5
6
 
6
7
  interface EndpointSliceRendererProps {
@@ -69,7 +70,7 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere
69
70
  )}
70
71
  </div>
71
72
  <div className="flex items-center gap-2 shrink-0">
72
- <span className="badge-sm bg-theme-elevated text-theme-text-secondary border border-theme-border">{port.protocol || 'TCP'}</span>
73
+ <Badge size="sm" protocol={port.protocol || 'TCP'}>{port.protocol || 'TCP'}</Badge>
73
74
  <span className="font-mono text-theme-text-secondary">{port.port ?? '-'}</span>
74
75
  </div>
75
76
  </div>
@@ -92,9 +93,9 @@ export function EndpointSliceRenderer({ data, onNavigate }: EndpointSliceRendere
92
93
  <div className="min-w-0 space-y-1">
93
94
  <div className="flex flex-wrap items-center gap-1.5">
94
95
  {(endpoint.addresses || []).map((address: string) => (
95
- <span key={address} className="badge-sm bg-theme-elevated text-theme-text-primary border border-theme-border font-mono">
96
+ <Badge key={address} size="sm" tone="structural" className="font-mono">
96
97
  {address}
97
- </span>
98
+ </Badge>
98
99
  ))}
99
100
  </div>
100
101
  {targetLabel && (
@@ -1,6 +1,6 @@
1
1
  import { Globe, ArrowRight, Network, Filter } from 'lucide-react'
2
- import { clsx } from 'clsx'
3
2
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceRefBadge } from '../../ui/drawer-components'
3
+ import { Badge } from '../../ui/Badge'
4
4
  import type { ResourceRef } from '../../../types'
5
5
 
6
6
  interface GRPCRouteRendererProps {
@@ -77,7 +77,7 @@ export function GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
77
77
  hostnames.length > 0 ? (
78
78
  <div className="flex flex-wrap gap-1">
79
79
  {hostnames.map((h: string) => (
80
- <span key={h} className="badge bg-theme-elevated text-theme-text-secondary">{h}</span>
80
+ <Badge key={h} tone="structural">{h}</Badge>
81
81
  ))}
82
82
  </div>
83
83
  ) : (
@@ -150,7 +150,7 @@ export function GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
150
150
  {match.method && (
151
151
  <>
152
152
  {match.method.type && match.method.type !== 'Exact' && (
153
- <span className="px-1.5 py-0.5 bg-amber-500/20 text-amber-400 rounded text-[10px]">{match.method.type}</span>
153
+ <Badge tone="accent1" size="sm">{match.method.type}</Badge>
154
154
  )}
155
155
  {match.method.service && (
156
156
  <span>
@@ -200,9 +200,9 @@ export function GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
200
200
  <div className="text-xs text-theme-text-secondary flex flex-wrap items-center gap-1.5">
201
201
  <Filter className="w-3 h-3 text-theme-text-tertiary" />
202
202
  {filters.map((filter: any, filterIdx: number) => (
203
- <span key={filterIdx} className="px-1.5 py-0.5 bg-amber-500/20 text-amber-400 rounded">
203
+ <Badge key={filterIdx} tone="accent1" size="sm">
204
204
  {filter.type}
205
- </span>
205
+ </Badge>
206
206
  ))}
207
207
  </div>
208
208
  </div>
@@ -231,20 +231,14 @@ export function GRPCRouteRenderer({ data, onNavigate }: GRPCRouteRendererProps)
231
231
  </div>
232
232
  <div className="flex flex-wrap gap-2">
233
233
  {accepted && (
234
- <span className={clsx(
235
- 'badge',
236
- accepted.status === 'True' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
237
- )}>
234
+ <Badge severity={accepted.status === 'True' ? 'success' : 'error'}>
238
235
  {accepted.status === 'True' ? 'Accepted' : 'Not Accepted'}
239
- </span>
236
+ </Badge>
240
237
  )}
241
238
  {resolved && (
242
- <span className={clsx(
243
- 'badge',
244
- resolved.status === 'True' ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'
245
- )}>
239
+ <Badge severity={resolved.status === 'True' ? 'success' : 'warning'}>
246
240
  {resolved.status === 'True' ? 'Refs Resolved' : 'Unresolved Refs'}
247
- </span>
241
+ </Badge>
248
242
  )}
249
243
  </div>
250
244
  {accepted?.message && accepted.status === 'False' && (
@@ -1,7 +1,6 @@
1
1
  import { Cpu } from 'lucide-react'
2
- import { clsx } from 'clsx'
3
2
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner } from '../../ui/drawer-components'
4
- import { BADGE_INACTIVE } from '../../../utils/badge-colors'
3
+ import { Badge } from '../../ui/Badge'
5
4
 
6
5
  interface GatewayClassRendererProps {
7
6
  data: any
@@ -35,16 +34,9 @@ export function GatewayClassRenderer({ data }: GatewayClassRendererProps) {
35
34
  <Property
36
35
  label="Accepted"
37
36
  value={
38
- <span className={clsx(
39
- 'badge',
40
- isAccepted
41
- ? 'bg-green-500/20 text-green-400'
42
- : isNotAccepted
43
- ? 'bg-red-500/20 text-red-400'
44
- : BADGE_INACTIVE
45
- )}>
37
+ <Badge severity={isAccepted ? 'success' : isNotAccepted ? 'error' : 'neutral'}>
46
38
  {isAccepted ? 'True' : isNotAccepted ? 'False' : 'Unknown'}
47
- </span>
39
+ </Badge>
48
40
  }
49
41
  />
50
42
  </PropertyList>
@@ -2,14 +2,7 @@ import { Globe, Radio, Lock } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
4
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
5
-
6
- const protocolColors: Record<string, string> = {
7
- HTTP: 'bg-blue-500/20 text-blue-400',
8
- HTTPS: 'bg-green-500/20 text-green-400',
9
- TLS: 'bg-green-500/20 text-green-400',
10
- TCP: 'bg-orange-500/20 text-orange-400',
11
- UDP: 'bg-purple-500/20 text-purple-400',
12
- }
5
+ import { Badge } from '../../ui/Badge'
13
6
 
14
7
  interface GatewayRendererProps {
15
8
  data: any
@@ -68,31 +61,17 @@ export function GatewayRenderer({ data, onNavigate }: GatewayRendererProps) {
68
61
  <Property
69
62
  label="Accepted"
70
63
  value={
71
- <span className={clsx(
72
- 'badge',
73
- isAccepted
74
- ? 'bg-green-500/20 text-green-400'
75
- : isNotAccepted
76
- ? 'bg-red-500/20 text-red-400'
77
- : BADGE_INACTIVE
78
- )}>
64
+ <Badge severity={isAccepted ? 'success' : isNotAccepted ? 'error' : 'neutral'}>
79
65
  {isAccepted ? 'True' : isNotAccepted ? 'False' : 'Unknown'}
80
- </span>
66
+ </Badge>
81
67
  }
82
68
  />
83
69
  <Property
84
70
  label="Programmed"
85
71
  value={
86
- <span className={clsx(
87
- 'badge',
88
- isProgrammed
89
- ? 'bg-green-500/20 text-green-400'
90
- : isNotProgrammed
91
- ? 'bg-red-500/20 text-red-400'
92
- : BADGE_INACTIVE
93
- )}>
72
+ <Badge severity={isProgrammed ? 'success' : isNotProgrammed ? 'error' : 'neutral'}>
94
73
  {isProgrammed ? 'True' : isNotProgrammed ? 'False' : 'Unknown'}
95
- </span>
74
+ </Badge>
96
75
  }
97
76
  />
98
77
  </PropertyList>
@@ -133,23 +112,20 @@ export function GatewayRenderer({ data, onNavigate }: GatewayRendererProps) {
133
112
  <div className="flex items-center gap-2">
134
113
  {isHTTPS && <Lock className="w-3.5 h-3.5 text-green-400" />}
135
114
  <span className="text-sm font-medium text-theme-text-primary">{listener.name}</span>
136
- <span className={clsx(
137
- 'px-1.5 py-0.5 rounded text-[10px] font-medium',
138
- protocolColors[listener.protocol] || BADGE_INACTIVE
139
- )}>
115
+ <Badge protocol={listener.protocol} size="sm">
140
116
  {listener.protocol}:{listener.port}
141
- </span>
117
+ </Badge>
142
118
  </div>
143
119
  <div className="flex items-center gap-1">
144
120
  {isConflicted && (
145
- <span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/20 text-red-400">
121
+ <Badge severity="error" size="sm">
146
122
  Conflicted
147
- </span>
123
+ </Badge>
148
124
  )}
149
125
  {hasUnresolvedRefs && (
150
- <span className="px-1.5 py-0.5 rounded text-[10px] font-medium bg-yellow-500/20 text-yellow-400">
126
+ <Badge severity="warning" size="sm">
151
127
  Unresolved Refs
152
- </span>
128
+ </Badge>
153
129
  )}
154
130
  {listenerAccepted && (
155
131
  <span className={clsx(