@skyhook-io/k8s-ui 1.14.1 → 1.14.2

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/issues/IssuesView.tsx +1 -1
  3. package/src/components/issues/types.ts +2 -0
  4. package/src/components/resources/ResourcesView.tsx +129 -67
  5. package/src/components/resources/column-settings-merge.test.ts +104 -0
  6. package/src/components/resources/istio-gateway-filter.test.ts +5 -3
  7. package/src/components/resources/istio-policy-accessors.test.ts +193 -0
  8. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +11 -24
  9. package/src/components/resources/renderers/eso-cells.tsx +0 -5
  10. package/src/components/resources/renderers/istio-cells.tsx +36 -26
  11. package/src/components/resources/renderers/kueue-cells.tsx +0 -9
  12. package/src/components/resources/renderers/kyverno-cells.tsx +1 -1
  13. package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
  14. package/src/components/resources/renderers/ray-cells.tsx +12 -4
  15. package/src/components/resources/renderers/volcano-cells.tsx +0 -9
  16. package/src/components/resources/resource-utils-cnpg.ts +5 -0
  17. package/src/components/resources/resource-utils-eso.test.ts +56 -0
  18. package/src/components/resources/resource-utils-eso.ts +37 -5
  19. package/src/components/resources/resource-utils-istio.ts +152 -44
  20. package/src/components/resources/resource-utils-kyverno.ts +3 -6
  21. package/src/components/resources/resource-utils-prometheus.ts +51 -23
  22. package/src/components/resources/resource-utils-ray.test.ts +36 -0
  23. package/src/components/resources/resource-utils-ray.ts +6 -0
  24. package/src/components/resources/resource-utils.ts +166 -18
  25. package/src/components/resources/status-accessors.test.ts +363 -0
  26. package/src/components/resources/status-overclaim.test.ts +158 -0
  27. package/src/components/timeline/DiffViewer.test.tsx +26 -0
  28. package/src/components/timeline/DiffViewer.tsx +16 -12
  29. package/src/components/topology/TopologyGraph.tsx +7 -2
  30. package/src/components/ui/Collapse.tsx +9 -2
  31. package/src/components/ui/ConfirmDialog.test.tsx +37 -0
  32. package/src/components/ui/ConfirmDialog.tsx +3 -1
  33. package/src/components/ui/drawer-components.tsx +1 -1
  34. package/src/components/workload/WorkloadView.tsx +2 -1
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getSecretStoreProviderType, getSecretStoreProviderKey } from './resource-utils-eso'
3
+
4
+ // The provider label answers "which backend holds these secrets?", so it has to
5
+ // track the upstream `spec.provider` field names rather than plausible ones.
6
+
7
+ describe('getSecretStoreProviderType', () => {
8
+ it('names the AWS product rather than assuming Secrets Manager', () => {
9
+ // One `aws` key addresses three services; `service` is required and is the
10
+ // only thing that distinguishes them.
11
+ const forService = (service: string) =>
12
+ getSecretStoreProviderType({ spec: { provider: { aws: { service, region: 'us-east-1' } } } })
13
+
14
+ expect(forService('SecretsManager')).toBe('AWS Secrets Manager')
15
+ expect(forService('ParameterStore')).toBe('AWS Parameter Store')
16
+ expect(forService('CertificateManager')).toBe('AWS Certificate Manager')
17
+ })
18
+
19
+ it('falls back to the vendor when an AWS store declares no service', () => {
20
+ // Invalid per the CRD, but naming a product we cannot confirm is worse than
21
+ // naming only the vendor.
22
+ expect(getSecretStoreProviderType({ spec: { provider: { aws: {} } } })).toBe('AWS')
23
+ })
24
+
25
+ it('recognises Bitwarden under its real field name', () => {
26
+ expect(getSecretStoreProviderType({ spec: { provider: { bitwardensecretsmanager: {} } } }))
27
+ .toBe('Bitwarden Secrets Manager')
28
+ })
29
+
30
+ it('labels providers added after the map was first written', () => {
31
+ expect(getSecretStoreProviderType({ spec: { provider: { openBao: {} } } })).toBe('OpenBao')
32
+ expect(getSecretStoreProviderType({ spec: { provider: { conjur: {} } } })).toBe('CyberArk Conjur')
33
+ })
34
+
35
+ it('keeps v1beta1-only providers, which v1 clusters never send', () => {
36
+ expect(getSecretStoreProviderType({ spec: { provider: { device42: {} } } })).toBe('Device42')
37
+ })
38
+
39
+ it('shows the raw key rather than Unknown for an unrecognised provider', () => {
40
+ expect(getSecretStoreProviderType({ spec: { provider: { somefutureprovider: {} } } }))
41
+ .toBe('somefutureprovider')
42
+ })
43
+
44
+ it('reports nothing when there is no provider at all', () => {
45
+ expect(getSecretStoreProviderType({ spec: {} })).toBe('Unknown')
46
+ })
47
+ })
48
+
49
+ describe('getSecretStoreProviderKey', () => {
50
+ it('stays at the vendor level, which is what the badge colours by', () => {
51
+ const key = (service: string) =>
52
+ getSecretStoreProviderKey({ spec: { provider: { aws: { service } } } })
53
+ expect(key('SecretsManager')).toBe('aws')
54
+ expect(key('ParameterStore')).toBe('aws')
55
+ })
56
+ })
@@ -7,33 +7,63 @@ import { healthColors, formatAge } from './resource-utils'
7
7
  // PROVIDER DETECTION
8
8
  // ============================================================================
9
9
 
10
+ // Keys are the SecretStore `spec.provider` field names. v1 and v1beta1 do not
11
+ // carry the same set, so entries dropped upstream (device42, alibaba) stay for
12
+ // clusters still serving v1beta1.
10
13
  const PROVIDER_MAP: Record<string, string> = {
11
- aws: 'AWS Secrets Manager',
14
+ aws: 'AWS',
12
15
  azurekv: 'Azure Key Vault',
13
16
  gcpsm: 'GCP Secret Manager',
14
17
  vault: 'HashiCorp Vault',
18
+ openBao: 'OpenBao',
15
19
  kubernetes: 'Kubernetes',
20
+ crd: 'Kubernetes Resource',
16
21
  oracle: 'Oracle Vault',
17
22
  ibm: 'IBM Secrets Manager',
23
+ alibaba: 'Alibaba Cloud KMS',
24
+ ovh: 'OVHcloud Secret Manager',
25
+ volcengine: 'Volcengine',
26
+ cloudrusm: 'Cloud.ru Secret Manager',
27
+ nebiusmysterybox: 'Nebius Mysterybox',
28
+ barbican: 'OpenStack Barbican',
29
+ yandexlockbox: 'Yandex Lockbox',
30
+ yandexcertificatemanager: 'Yandex Certificate Manager',
18
31
  doppler: 'Doppler',
19
32
  onepassword: '1Password',
33
+ onepasswordSDK: '1Password',
34
+ bitwardensecretsmanager: 'Bitwarden Secrets Manager',
20
35
  senhasegura: 'senhasegura',
36
+ github: 'GitHub',
21
37
  gitlab: 'GitLab',
22
38
  webhook: 'Webhook',
23
39
  fake: 'Fake',
24
40
  keepersecurity: 'Keeper Security',
25
41
  scaleway: 'Scaleway',
26
- delinea: 'Delinea',
42
+ conjur: 'CyberArk Conjur',
43
+ delinea: 'Delinea DevOps Secrets Vault',
44
+ secretserver: 'Delinea Secret Server',
45
+ beyondtrust: 'BeyondTrust',
46
+ beyondtrustworkloadcredentials: 'BeyondTrust Workload Credentials',
27
47
  chef: 'Chef',
28
48
  pulumi: 'Pulumi ESC',
29
49
  fortanix: 'Fortanix',
30
50
  passworddepot: 'Password Depot',
51
+ passbolt: 'Passbolt',
52
+ previder: 'Previder',
53
+ onboardbase: 'Onboardbase',
54
+ dvls: 'Devolutions Server',
31
55
  device42: 'Device42',
32
56
  akeyless: 'Akeyless',
33
- beyondtrust: 'BeyondTrust',
34
57
  infisical: 'Infisical',
35
- passbolt: 'Passbolt',
36
- bitwarden: 'Bitwarden',
58
+ ngrok: 'ngrok',
59
+ }
60
+
61
+ // A single `aws` key addresses three unrelated AWS products; `service` is a
62
+ // required enum and is the only thing that says which one holds the secrets.
63
+ const AWS_SERVICE_LABELS: Record<string, string> = {
64
+ SecretsManager: 'AWS Secrets Manager',
65
+ ParameterStore: 'AWS Parameter Store',
66
+ CertificateManager: 'AWS Certificate Manager',
37
67
  }
38
68
 
39
69
  /**
@@ -44,6 +74,8 @@ export function getSecretStoreProviderType(resource: any): string {
44
74
  const provider = resource.spec?.provider
45
75
  if (!provider) return 'Unknown'
46
76
 
77
+ if (provider.aws) return AWS_SERVICE_LABELS[provider.aws.service] ?? 'AWS'
78
+
47
79
  for (const [key, label] of Object.entries(PROVIDER_MAP)) {
48
80
  if (provider[key]) return label
49
81
  }
@@ -28,15 +28,19 @@ export function getVirtualServiceStatus(resource: any): StatusBadge {
28
28
  const tlsRoutes = spec.tls || []
29
29
  const hosts = spec.hosts || []
30
30
 
31
- if (hosts.length === 0) {
32
- return { text: 'No Hosts', color: healthColors.unhealthy, level: 'unhealthy' }
33
- }
34
-
31
+ // Routes are what a VirtualService exists to provide, delegate or not.
35
32
  const totalRoutes = httpRoutes.length + tcpRoutes.length + tlsRoutes.length
36
33
  if (totalRoutes === 0) {
37
34
  return { text: 'No Routes', color: healthColors.unhealthy, level: 'unhealthy' }
38
35
  }
39
36
 
37
+ // A delegate VirtualService is required to declare no hosts, and this object
38
+ // cannot tell us whether a parent delegates to it, so an empty list is not
39
+ // evidence of a fault.
40
+ if (hosts.length === 0) {
41
+ return { text: 'No Hosts', color: healthColors.unknown, level: 'unknown' }
42
+ }
43
+
40
44
  // Check for fault injection on any route
41
45
  const hasFaultInjection = httpRoutes.some((r: any) => r.fault)
42
46
  if (hasFaultInjection) {
@@ -49,7 +53,7 @@ export function getVirtualServiceStatus(resource: any): StatusBadge {
49
53
  return { text: 'Mirroring', color: healthColors.degraded, level: 'degraded' }
50
54
  }
51
55
 
52
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
56
+ return { text: 'Defined', color: healthColors.neutral, level: 'neutral' }
53
57
  }
54
58
 
55
59
  export function getVirtualServiceHosts(resource: any): string {
@@ -123,26 +127,18 @@ export function getVirtualServiceDestinations(resource: any): Array<{ host: stri
123
127
  // DESTINATIONRULE UTILITIES
124
128
  // ============================================================================
125
129
 
130
+ /**
131
+ * The only failure this object can show on its own.
132
+ *
133
+ * A rule without a host applies to nothing. Everything else it declares —
134
+ * subsets, a traffic policy — is configuration, and says nothing about whether
135
+ * the host is reachable or the policy is in effect.
136
+ */
126
137
  export function getDestinationRuleStatus(resource: any): StatusBadge {
127
- const spec = resource.spec || {}
128
- const host = spec.host
129
-
130
- if (!host) {
138
+ if (!resource.spec?.host) {
131
139
  return { text: 'No Host', color: healthColors.unhealthy, level: 'unhealthy' }
132
140
  }
133
-
134
- const subsets = spec.subsets || []
135
- const trafficPolicy = spec.trafficPolicy
136
-
137
- if (subsets.length > 0) {
138
- return { text: pluralize(subsets.length, 'Subset'), color: healthColors.healthy, level: 'healthy' }
139
- }
140
-
141
- if (trafficPolicy) {
142
- return { text: 'Configured', color: healthColors.healthy, level: 'healthy' }
143
- }
144
-
145
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
141
+ return { text: 'Not assessed', color: healthColors.unknown, level: 'unknown' }
146
142
  }
147
143
 
148
144
  export function getDestinationRuleHost(resource: any): string {
@@ -161,6 +157,22 @@ export function getDestinationRuleSubsets(resource: any): Array<{ name: string;
161
157
  }))
162
158
  }
163
159
 
160
+ /**
161
+ * The client-side TLS mode this rule declares for its host.
162
+ *
163
+ * What it means depends on a different object. DISABLE is defined as "do not
164
+ * setup a TLS connection to the upstream endpoint", so the client sends
165
+ * plaintext; a PeerAuthentication STRICT is "an mTLS tunnel (TLS with client
166
+ * cert must be presented)" and PERMISSIVE is "either plaintext or mTLS tunnel".
167
+ * The pairing therefore lands as unencrypted traffic or as rejected requests —
168
+ * deduced from those two definitions, since neither API documents the mismatch
169
+ * directly. Nothing in this row shows which, and subset and port policies can
170
+ * override the mode besides, so it reports the declaration and not the posture.
171
+ */
172
+ export function getDestinationRuleTlsMode(resource: any): string {
173
+ return getDestinationRuleTrafficPolicy(resource)?.tls?.mode || '-'
174
+ }
175
+
164
176
  export function getDestinationRuleTrafficPolicy(resource: any): {
165
177
  connectionPool?: { tcp?: any; http?: any }
166
178
  loadBalancer?: { simple?: string; consistentHash?: any }
@@ -182,6 +194,15 @@ export function getDestinationRuleLoadBalancer(resource: any): string {
182
194
  // ISTIO GATEWAY UTILITIES
183
195
  // ============================================================================
184
196
 
197
+ /**
198
+ * What spec.servers alone can honestly claim.
199
+ *
200
+ * Not liveness: an Istio Gateway whose spec.selector matches no running
201
+ * ingressgateway pod is how these actually fail, and it has servers like any
202
+ * other, so a green badge there would say the opposite of the truth. TLS is a
203
+ * property of a server rather than a verdict, and this slot is the one every
204
+ * other kind here uses for a verdict.
205
+ */
185
206
  export function getIstioGatewayStatus(resource: any): StatusBadge {
186
207
  const servers = resource.spec?.servers || []
187
208
 
@@ -189,12 +210,7 @@ export function getIstioGatewayStatus(resource: any): StatusBadge {
189
210
  return { text: 'No Servers', color: healthColors.unhealthy, level: 'unhealthy' }
190
211
  }
191
212
 
192
- const hasTLS = servers.some((s: any) => s.tls)
193
- if (hasTLS) {
194
- return { text: 'TLS', color: healthColors.healthy, level: 'healthy' }
195
- }
196
-
197
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
213
+ return { text: 'Defined', color: healthColors.neutral, level: 'neutral' }
198
214
  }
199
215
 
200
216
  export function getIstioGatewayServers(resource: any): Array<{
@@ -236,12 +252,14 @@ export function getServiceEntryStatus(resource: any): StatusBadge {
236
252
  return { text: 'No Hosts', color: healthColors.unhealthy, level: 'unhealthy' }
237
253
  }
238
254
 
255
+ // Location is a mesh-membership classification, not reachability: nothing here
256
+ // establishes the hosts resolve or answer.
239
257
  const location = spec.location || 'MESH_EXTERNAL'
240
258
  if (location === 'MESH_EXTERNAL') {
241
- return { text: 'External', color: healthColors.healthy, level: 'healthy' }
259
+ return { text: 'External', color: healthColors.neutral, level: 'neutral' }
242
260
  }
243
261
 
244
- return { text: 'Internal', color: healthColors.healthy, level: 'healthy' }
262
+ return { text: 'Internal', color: healthColors.neutral, level: 'neutral' }
245
263
  }
246
264
 
247
265
  export function getServiceEntryHosts(resource: any): string {
@@ -327,21 +345,33 @@ export function getPeerAuthenticationPortLevelMtls(resource: any): Record<string
327
345
  // AUTHORIZATIONPOLICY UTILITIES
328
346
  // ============================================================================
329
347
 
348
+ /**
349
+ * What one AuthorizationPolicy declares — never a health verdict.
350
+ *
351
+ * An action is not a state of wellness: a DENY doing its job is not unhealthy,
352
+ * and an ALLOW is not proof anything is permitted. The request decision is made
353
+ * across every policy selecting the workload, so no single object can establish
354
+ * it, and a green or red tone would assert an answer this object does not have.
355
+ *
356
+ * The one shape worth marking is an ALLOW — the default action — carrying no
357
+ * rules. Rules are alternatives, so zero of them match nothing, and Istio
358
+ * documents `spec: {}` as the deny-all idiom. Amber says look closer; it stops
359
+ * short of claiming the outage, because other policies may permit the traffic.
360
+ */
330
361
  export function getAuthorizationPolicyStatus(resource: any): StatusBadge {
331
362
  const action = resource.spec?.action || 'ALLOW'
332
363
  const rules = resource.spec?.rules || []
333
364
 
334
- switch (action) {
335
- case 'ALLOW':
336
- return { text: `Allow (${pluralize(rules.length, 'rule')})`, color: healthColors.healthy, level: 'healthy' }
337
- case 'DENY':
338
- return { text: `Deny (${pluralize(rules.length, 'rule')})`, color: healthColors.unhealthy, level: 'unhealthy' }
339
- case 'CUSTOM':
340
- return { text: 'Custom', color: healthColors.degraded, level: 'degraded' }
341
- case 'AUDIT':
342
- return { text: 'Audit', color: healthColors.degraded, level: 'degraded' }
343
- default:
344
- return { text: action, color: healthColors.unknown, level: 'unknown' }
365
+ if (action === 'ALLOW' && rules.length === 0) {
366
+ return { text: 'No allow rules', color: healthColors.degraded, level: 'degraded' }
367
+ }
368
+ const known: Record<string, string> = { ALLOW: 'Allow', DENY: 'Deny', CUSTOM: 'Custom', AUDIT: 'Audit' }
369
+ const label = known[action]
370
+ if (!label) return { text: action, color: healthColors.unknown, level: 'unknown' }
371
+ return {
372
+ text: `${label} (${pluralize(rules.length, 'rule')})`,
373
+ color: healthColors.neutral,
374
+ level: 'neutral',
345
375
  }
346
376
  }
347
377
 
@@ -365,9 +395,87 @@ export function getAuthorizationPolicySelector(resource: any): Record<string, st
365
395
  return resource.spec?.selector?.matchLabels || {}
366
396
  }
367
397
 
398
+ /**
399
+ * What the policy declares it applies to.
400
+ *
401
+ * A policy can attach by targetRef instead of by labels — waypoints and
402
+ * Gateways in ambient mode do — so the selector alone does not describe scope.
403
+ * With neither, scope is inherited: the namespace, or the whole mesh when the policy
404
+ * sits in the mesh root namespace. Which of those applies depends on
405
+ * MeshConfig.rootNamespace, which is not readable from this object, so the cell
406
+ * names both rather than guessing from the conventional namespace name.
407
+ */
368
408
  export function getAuthorizationPolicySelectorString(resource: any): string {
369
- const labels = resource.spec?.selector?.matchLabels || {}
370
- const entries = Object.entries(labels)
371
- if (entries.length === 0) return 'Namespace-wide'
409
+ const spec = resource.spec || {}
410
+ const targets = [
411
+ ...(Array.isArray(spec.targetRefs) ? spec.targetRefs : []),
412
+ ...(spec.targetRef ? [spec.targetRef] : []),
413
+ ].filter(Boolean)
414
+ if (targets.length > 0) {
415
+ return targets
416
+ .map((t: any) => {
417
+ const name = [t?.kind, t?.name].filter(Boolean).join('/') || 'target'
418
+ return t?.namespace && t.namespace !== resource.metadata?.namespace
419
+ ? `${t.namespace}/${name}`
420
+ : name
421
+ })
422
+ .join(', ')
423
+ }
424
+ const entries = Object.entries(spec.selector?.matchLabels || {})
425
+ if (entries.length === 0) return 'Namespace / mesh scope'
372
426
  return entries.map(([k, v]) => `${k}=${v}`).join(', ')
373
427
  }
428
+
429
+ /**
430
+ * What an AuthorizationPolicy's rule list means for the traffic it governs.
431
+ *
432
+ * Rules are alternatives and an unset list never matches, so BOTH actions with
433
+ * no rules are inert in the same way — a DENY with none denies nothing, an
434
+ * ALLOW with none permits nothing. Only the ALLOW case has a consequence worth
435
+ * raising, because a workload with any ALLOW policy admits only what one of
436
+ * them matches. Neither can be stated as an outcome for the workload: every
437
+ * policy selecting it takes part in the decision.
438
+ */
439
+ export function getAuthorizationPolicyRuleNotice(
440
+ resource: any,
441
+ ): { level: 'warning' | 'info'; title: string; message: string } | null {
442
+ const action = resource?.spec?.action || 'ALLOW'
443
+ const rules = resource?.spec?.rules
444
+ if (Array.isArray(rules) && rules.length > 0) {
445
+ // A rule matches every request when it carries no conditions — which
446
+ // includes serialized empty lists, not just an empty object. On a DENY that
447
+ // is not a baseline other policies carve exceptions out of: DENY is
448
+ // evaluated first, so no ALLOW can re-permit what it matches. Unknown keys
449
+ // are treated as conditions, so a future field cannot be read as blanket.
450
+ const CONDITION_KEYS = ['from', 'to', 'when']
451
+ const matchesEverything = rules.some((r: any) => {
452
+ if (!r || typeof r !== 'object') return false
453
+ const keys = Object.keys(r)
454
+ if (keys.some(k => !CONDITION_KEYS.includes(k))) return false
455
+ return CONDITION_KEYS.every(k => !Array.isArray(r[k]) || r[k].length === 0)
456
+ })
457
+ if (action === 'DENY' && matchesEverything) {
458
+ return {
459
+ level: 'info',
460
+ title: 'Matches all requests',
461
+ message: 'A rule with no conditions matches every request, so this policy denies all traffic to the workloads it selects. DENY is evaluated before ALLOW, so no ALLOW policy can permit an exception.',
462
+ }
463
+ }
464
+ return null
465
+ }
466
+ if (action === 'ALLOW') {
467
+ return {
468
+ level: 'warning',
469
+ title: 'No allow rules',
470
+ message: 'Rules are alternatives, so an ALLOW policy with none of them matches nothing and contributes no permitted traffic. Other ALLOW policies selecting the same workload may still permit requests — a default-deny-plus-exceptions setup looks exactly like this.',
471
+ }
472
+ }
473
+ if (action === 'DENY') {
474
+ return {
475
+ level: 'info',
476
+ title: 'No deny rules',
477
+ message: 'This DENY policy has no rules, so it matches no requests and denies no traffic. Other policies may still deny requests.',
478
+ }
479
+ }
480
+ return null
481
+ }
@@ -107,12 +107,9 @@ export function getKyvernoPolicyStatus(resource: any): StatusBadge {
107
107
  return { text: readyCond.reason || 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
108
108
  }
109
109
 
110
- // Fallback: if spec exists, likely active
111
- if (resource.spec?.rules?.length > 0) {
112
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
113
- }
114
-
115
- return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
110
+ // Reached when the controller has not written Ready, or wrote it as Unknown.
111
+ // Declaring rules is not the controller accepting them.
112
+ return { text: 'Not assessed', color: healthColors.unknown, level: 'unknown' }
116
113
  }
117
114
 
118
115
  /**
@@ -7,31 +7,59 @@ import { healthColors } from './resource-utils'
7
7
  // SHARED HELPERS
8
8
  // ============================================================================
9
9
 
10
- function getConditionStatus(resource: any): StatusBadge {
11
- const conditions = resource.status?.conditions || []
12
-
13
- const reconciledCond = conditions.find((c: any) => c.type === 'Reconciled')
14
- if (reconciledCond?.status === 'True') {
15
- return { text: 'Reconciled', color: healthColors.healthy, level: 'healthy' }
16
- }
17
- if (reconciledCond?.status === 'False') {
18
- return { text: reconciledCond.reason || 'Not Reconciled', color: healthColors.unhealthy, level: 'unhealthy' }
10
+ // ServiceMonitor, PodMonitor and PrometheusRule are configuration resources.
11
+ // They have no `status.conditions`: the operator reports per selecting workload
12
+ // under `status.bindings[].conditions`, the only condition type is `Accepted`,
13
+ // and it writes any of it only when the StatusForConfigurationResources feature
14
+ // gate is on. Absence is therefore the ordinary case and is not evidence the
15
+ // configuration works — and `Accepted` means the config was accepted, not that
16
+ // any target is being scraped.
17
+ function getConfigResourceStatus(resource: any): StatusBadge {
18
+ const generation = resource?.metadata?.generation
19
+ let reporting = 0
20
+ let accepted = 0
21
+ let rejected = 0
22
+ let rejection = ''
23
+
24
+ for (const binding of resource?.status?.bindings || []) {
25
+ for (const cond of binding?.conditions || []) {
26
+ if (cond?.type !== 'Accepted') continue
27
+ // A condition observed against an older spec does not describe the object
28
+ // as it stands now.
29
+ if (generation !== undefined && cond.observedGeneration !== undefined
30
+ && cond.observedGeneration !== generation) continue
31
+ reporting++
32
+ if (cond.status === 'True') accepted++
33
+ else if (cond.status === 'False') {
34
+ rejected++
35
+ if (!rejection) rejection = cond.reason || ''
36
+ }
37
+ }
19
38
  }
20
39
 
21
- const availableCond = conditions.find((c: any) => c.type === 'Available')
22
- if (availableCond?.status === 'True') {
23
- return { text: 'Available', color: healthColors.healthy, level: 'healthy' }
40
+ if (reporting === 0) {
41
+ return { text: 'Not assessed', color: healthColors.unknown, level: 'unknown' }
24
42
  }
25
- if (availableCond?.status === 'False') {
26
- return { text: availableCond.reason || 'Unavailable', color: healthColors.unhealthy, level: 'unhealthy' }
43
+ if (rejected === reporting) {
44
+ return { text: rejection || 'Not Accepted', color: healthColors.unhealthy, level: 'unhealthy' }
27
45
  }
28
-
29
- // Fallback: if resource exists but has no conditions, it's likely active
30
- if (resource.spec) {
31
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
46
+ if (rejected > 0) {
47
+ return {
48
+ text: `Accepted by ${accepted}/${reporting}`,
49
+ color: healthColors.degraded,
50
+ level: 'degraded',
51
+ }
32
52
  }
33
-
34
- return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
53
+ // No rejection, but a workload reported Accepted=Unknown: it has not decided,
54
+ // so the resource is not accepted everywhere that selects it.
55
+ if (accepted < reporting) {
56
+ return {
57
+ text: `Accepted by ${accepted}/${reporting}`,
58
+ color: healthColors.unknown,
59
+ level: 'unknown',
60
+ }
61
+ }
62
+ return { text: 'Accepted', color: healthColors.healthy, level: 'healthy' }
35
63
  }
36
64
 
37
65
  function formatMatchLabels(selector: any): string {
@@ -45,7 +73,7 @@ function formatMatchLabels(selector: any): string {
45
73
  // ============================================================================
46
74
 
47
75
  export function getServiceMonitorStatus(resource: any): StatusBadge {
48
- return getConditionStatus(resource)
76
+ return getConfigResourceStatus(resource)
49
77
  }
50
78
 
51
79
  export function getServiceMonitorEndpointCount(resource: any): number {
@@ -87,7 +115,7 @@ export function getServiceMonitorNamespaceSelector(resource: any): string {
87
115
  // ============================================================================
88
116
 
89
117
  export function getPrometheusRuleStatus(resource: any): StatusBadge {
90
- return getConditionStatus(resource)
118
+ return getConfigResourceStatus(resource)
91
119
  }
92
120
 
93
121
  export function getPrometheusRuleGroupCount(resource: any): number {
@@ -169,7 +197,7 @@ export function getPrometheusRuleGroups(resource: any): PrometheusRuleGroup[] {
169
197
  // ============================================================================
170
198
 
171
199
  export function getPodMonitorStatus(resource: any): StatusBadge {
172
- return getConditionStatus(resource)
200
+ return getConfigResourceStatus(resource)
173
201
  }
174
202
 
175
203
  export function getPodMonitorEndpointCount(resource: any): number {
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getRayCronJobTimeZone, getRayCronJobStatus } from './resource-utils-ray'
3
+ import { getCellFilterValue } from './resource-utils'
4
+
5
+ describe('getRayCronJobTimeZone', () => {
6
+ it('shows the declared zone', () => {
7
+ expect(getRayCronJobTimeZone({ spec: { schedule: '0 2 * * *', timeZone: 'America/New_York' } }))
8
+ .toBe('America/New_York')
9
+ })
10
+
11
+ it('does not present an undeclared zone as UTC', () => {
12
+ // KubeRay reads the schedule in the operator pod's local zone when timeZone
13
+ // is unset, so the object cannot say what the cron expression means.
14
+ expect(getRayCronJobTimeZone({ spec: { schedule: '0 2 * * *' } })).toBe('Operator local')
15
+ })
16
+ })
17
+
18
+ describe('getRayCronJobStatus', () => {
19
+ it('reports only what spec.suspend says, which is all the CRD carries', () => {
20
+ // RayCronJobStatus holds lastScheduleTime and nothing else — there is no
21
+ // controller-reported phase for this badge to reflect.
22
+ expect(getRayCronJobStatus({ spec: { suspend: true } }).text).toBe('Suspended')
23
+ expect(getRayCronJobStatus({ spec: {} }).text).toBe('Active')
24
+ })
25
+ })
26
+
27
+ describe('getCellFilterValue for raycronjobs timeZone', () => {
28
+ it('offers the same value the cell renders, so undeclared rows stay filterable', () => {
29
+ // The generic fallback reads spec.timeZone and yields '' when unset, which
30
+ // would hide those rows from a filter whose column shows them a value.
31
+ expect(getCellFilterValue({ spec: { schedule: '0 2 * * *' } }, 'timeZone', 'raycronjobs'))
32
+ .toBe('Operator local')
33
+ expect(getCellFilterValue({ spec: { timeZone: 'Europe/Berlin' } }, 'timeZone', 'raycronjobs'))
34
+ .toBe('Europe/Berlin')
35
+ })
36
+ })
@@ -203,6 +203,12 @@ export function getRayCronJobSchedule(resource: any): string {
203
203
  return resource.spec?.schedule || '-'
204
204
  }
205
205
 
206
+ // An unset timeZone does not mean UTC: KubeRay reads the schedule in the
207
+ // operator pod's own local zone, which this object cannot report.
208
+ export function getRayCronJobTimeZone(resource: any): string {
209
+ return resource.spec?.timeZone || 'Operator local'
210
+ }
211
+
206
212
  export function getRayCronJobSuspend(resource: any): boolean {
207
213
  return resource.spec?.suspend === true
208
214
  }