@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,104 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mergeSavedVisibleColumns, type Column, type ExtraColumn } from './ResourcesView'
3
+
4
+ // A saved blob records only what is VISIBLE, so a curated key's absence is
5
+ // ambiguous: the user hid it, or it did not exist when they saved. `known` — the
6
+ // key set they were offered at save time — is what separates the two. Without
7
+ // it, every column added to a curated kind is invisible to anyone who has ever
8
+ // touched that kind's column picker.
9
+
10
+ const col = (key: string, defaultVisible?: boolean): Column =>
11
+ defaultVisible === undefined ? { key, label: key } : { key, label: key, defaultVisible }
12
+
13
+ const printer = (key: string, defaultVisible?: boolean): ExtraColumn =>
14
+ ({ ...col(key, defaultVisible), render: () => null })
15
+
16
+ describe('mergeSavedVisibleColumns', () => {
17
+ it('shows a curated column added since the blob was written', () => {
18
+ const effective = [col('name'), col('status'), col('lastSuccess')]
19
+ const got = mergeSavedVisibleColumns(['name', 'status'], [], [], effective, ['name', 'status'])
20
+ expect(got.has('lastSuccess')).toBe(true)
21
+ })
22
+
23
+ it('keeps a column the user actually hid hidden', () => {
24
+ // 'status' was offered and is absent from visible — a deliberate hide.
25
+ const effective = [col('name'), col('status')]
26
+ const got = mergeSavedVisibleColumns(['name'], [], [], effective, ['name', 'status'])
27
+ expect(got.has('status')).toBe(false)
28
+ })
29
+
30
+ it('respects defaultVisible:false on a newly added column', () => {
31
+ const effective = [col('name'), col('podIP', false)]
32
+ const got = mergeSavedVisibleColumns(['name'], [], [], effective, ['name'])
33
+ expect(got.has('podIP')).toBe(false)
34
+ })
35
+
36
+ it('leaves blobs written before `known` on the old behaviour', () => {
37
+ // No record of what was offered, so absence stays ambiguous and we don't
38
+ // guess — the next save adopts the current set as the baseline.
39
+ const effective = [col('name'), col('status'), col('lastSuccess')]
40
+ const got = mergeSavedVisibleColumns(['name', 'status'], [], [], effective, undefined)
41
+ expect(got.has('lastSuccess')).toBe(false)
42
+ expect(Array.from(got).sort()).toEqual(['name', 'status'])
43
+ })
44
+
45
+ it('does nothing without the effective column set', () => {
46
+ const got = mergeSavedVisibleColumns(['name'], [], [], undefined, ['name'])
47
+ expect(Array.from(got)).toEqual(['name'])
48
+ })
49
+
50
+ it('still seeds host extras unconditionally', () => {
51
+ const got = mergeSavedVisibleColumns(['name'], ['cluster'], [], [col('name')], ['name'])
52
+ expect(got.has('cluster')).toBe(true)
53
+ })
54
+
55
+ it('still seeds printer columns only when the blob names none', () => {
56
+ const cols = [printer('printer:Phase'), printer('printer:Ready')]
57
+ expect(mergeSavedVisibleColumns(['name'], [], cols).has('printer:Phase')).toBe(true)
58
+ // Once the blob names one, the user has seen the set — absence is a hide.
59
+ expect(mergeSavedVisibleColumns(['name', 'printer:Ready'], [], cols).has('printer:Phase')).toBe(false)
60
+ })
61
+
62
+ it('does not re-show a hidden printer column when known predates the printer table', () => {
63
+ // Printer columns are fetched, so `known` can be written during the window
64
+ // before the table arrives — listing curated keys only. Treating their
65
+ // absence as "not offered yet" would un-hide one the user had hidden.
66
+ const cols = [printer('printer:Phase'), printer('printer:Ready')]
67
+ const effective = [col('name'), ...cols]
68
+ const got = mergeSavedVisibleColumns(
69
+ ['name', 'printer:Phase'], [], cols, effective,
70
+ ['name'], // written before the printer table loaded
71
+ )
72
+ expect(got.has('printer:Ready')).toBe(false)
73
+ expect(got.has('printer:Phase')).toBe(true)
74
+ })
75
+
76
+ it('treats a persisted known that is not an array as no record at all', () => {
77
+ // The blob is untyped JSON and can be corrupted or hand-edited; a throw
78
+ // here would abort the load effect and strand the table's column state.
79
+ const effective = [col('name'), col('status')]
80
+ const call = () => mergeSavedVisibleColumns(['name'], [], [], effective, {} as unknown as string[])
81
+ expect(call).not.toThrow()
82
+ expect(call().has('status')).toBe(false)
83
+ })
84
+
85
+ it('keeps a column hidden after the offered set shrinks and grows again', () => {
86
+ // Uncurated kinds offer a generic Status until their printer columns arrive,
87
+ // and stop offering it afterwards. If the record were replaced on each save
88
+ // rather than accumulated, that second save would forget Status was ever
89
+ // offered — and a later load without printer data would read the user's
90
+ // hide as "never shown" and undo it.
91
+ const known = ['name', 'namespace', 'status', 'age', 'printer:Phase']
92
+ const effective = [col('name'), col('namespace'), col('status'), col('age')]
93
+ const got = mergeSavedVisibleColumns(['name', 'namespace', 'age'], [], [], effective, known)
94
+ expect(got.has('status')).toBe(false)
95
+ })
96
+
97
+ it('seeds a new curated column alongside printer columns without disturbing them', () => {
98
+ const cols = [printer('printer:Phase')]
99
+ const effective = [col('name'), col('lastSuccess'), printer('printer:Phase')]
100
+ const got = mergeSavedVisibleColumns(['name', 'printer:Phase'], [], cols, effective, ['name', 'printer:Phase'])
101
+ expect(got.has('lastSuccess')).toBe(true)
102
+ expect(got.has('printer:Phase')).toBe(true)
103
+ })
104
+ })
@@ -13,13 +13,15 @@ describe('Istio Gateway filter values', () => {
13
13
  const tls = gateway({ servers: [{ port: { number: 443 }, tls: { mode: 'SIMPLE' } }] })
14
14
  const plain = gateway({ servers: [{ port: { number: 80 } }] })
15
15
  const none = gateway({ servers: [] })
16
- expect(getCellFilterValue(tls, 'status', 'istiogateways')).toBe('TLS')
17
- expect(getCellFilterValue(plain, 'status', 'istiogateways')).toBe('Active')
16
+ // TLS and plain share a verdict: neither says whether the gateway's
17
+ // selector resolves to a running pod, which is what a status column claims.
18
+ expect(getCellFilterValue(tls, 'status', 'istiogateways')).toBe('Defined')
19
+ expect(getCellFilterValue(plain, 'status', 'istiogateways')).toBe('Defined')
18
20
  expect(getCellFilterValue(none, 'status', 'istiogateways')).toBe('No Servers')
19
21
  })
20
22
 
21
23
  // Read as a Gateway API Gateway instead, the same object filters as
22
- // "Unknown" — a value that appears on no row, since the cell shows Active.
24
+ // "Unknown" — a value that appears on no row, since the cell shows Defined.
23
25
  // That divergence is what routing the normalized key fixes.
24
26
  it('diverges from the row when read under the Gateway API key', () => {
25
27
  const g = gateway({ servers: [{ port: { number: 80 } }] })
@@ -0,0 +1,193 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ getAuthorizationPolicySelectorString,
4
+ getAuthorizationPolicyStatus,
5
+ getDestinationRuleStatus,
6
+ getAuthorizationPolicyRuleNotice,
7
+ getDestinationRuleTlsMode,
8
+ } from './resource-utils-istio'
9
+
10
+ // A request decision is made across every policy selecting a workload, so no
11
+ // single object can establish it. These pin the line between what one policy
12
+ // declares and what the mesh actually does.
13
+
14
+ describe('getAuthorizationPolicyStatus', () => {
15
+ it('marks the deny-all idiom rather than presenting it as permitted', () => {
16
+ // `spec: {}` is Istio's documented deny-all: action defaults to ALLOW, and
17
+ // rules are alternatives, so zero of them match nothing.
18
+ expect(getAuthorizationPolicyStatus({ spec: {} })).toMatchObject({
19
+ text: 'No allow rules', level: 'degraded',
20
+ })
21
+ })
22
+
23
+ it('treats an explicit rule-less ALLOW the same as the defaulted one', () => {
24
+ expect(getAuthorizationPolicyStatus({ spec: { action: 'ALLOW', rules: [] } })).toMatchObject({
25
+ text: 'No allow rules', level: 'degraded',
26
+ })
27
+ })
28
+
29
+ it('does not call a DENY doing its job unhealthy', () => {
30
+ expect(getAuthorizationPolicyStatus({ spec: { action: 'DENY', rules: [{}] } })).toMatchObject({
31
+ text: 'Deny (1 rule)', level: 'neutral',
32
+ })
33
+ })
34
+
35
+ it('does not present an ALLOW with rules as proof anything is permitted', () => {
36
+ expect(getAuthorizationPolicyStatus({ spec: { action: 'ALLOW', rules: [{}, {}] } })).toMatchObject({
37
+ text: 'Allow (2 rules)', level: 'neutral',
38
+ })
39
+ })
40
+
41
+ it('keeps AUDIT and CUSTOM neutral — neither blocks nor permits on its own', () => {
42
+ expect(getAuthorizationPolicyStatus({ spec: { action: 'AUDIT', rules: [{}] } }).level).toBe('neutral')
43
+ expect(getAuthorizationPolicyStatus({ spec: { action: 'CUSTOM', rules: [{}] } }).level).toBe('neutral')
44
+ })
45
+
46
+ it('does not claim to recognise an unknown action', () => {
47
+ expect(getAuthorizationPolicyStatus({ spec: { action: 'FUTURE' } })).toMatchObject({
48
+ text: 'FUTURE', level: 'unknown',
49
+ })
50
+ })
51
+ })
52
+
53
+ describe('getAuthorizationPolicySelectorString', () => {
54
+ it('reports a targetRef attachment instead of claiming namespace scope', () => {
55
+ // Waypoint and Gateway attachment in ambient mode uses targetRefs, not
56
+ // labels; reading only the selector called these namespace-wide.
57
+ const p = {
58
+ metadata: { namespace: 'prod' },
59
+ spec: { targetRefs: [{ kind: 'Gateway', name: 'waypoint' }] },
60
+ }
61
+ expect(getAuthorizationPolicySelectorString(p)).toBe('Gateway/waypoint')
62
+ })
63
+
64
+ it('qualifies a target naming another namespace', () => {
65
+ // Rendering only — supported attachments are same-namespace, so this
66
+ // records what the cell shows, not that the attachment would be enforced.
67
+ const p = {
68
+ metadata: { namespace: 'prod' },
69
+ spec: { targetRef: { kind: 'Gateway', name: 'shared', namespace: 'infra' } },
70
+ }
71
+ expect(getAuthorizationPolicySelectorString(p)).toBe('infra/Gateway/shared')
72
+ })
73
+
74
+ it('lists every target', () => {
75
+ const p = {
76
+ metadata: { namespace: 'prod' },
77
+ spec: { targetRefs: [{ kind: 'Gateway', name: 'a' }, { kind: 'Service', name: 'b' }] },
78
+ }
79
+ expect(getAuthorizationPolicySelectorString(p)).toBe('Gateway/a, Service/b')
80
+ })
81
+
82
+ it('falls back to selector labels', () => {
83
+ const p = { metadata: { namespace: 'prod' }, spec: { selector: { matchLabels: { app: 'api' } } } }
84
+ expect(getAuthorizationPolicySelectorString(p)).toBe('app=api')
85
+ })
86
+
87
+ it('names both possible scopes when neither is declared', () => {
88
+ // Which one applies depends on MeshConfig.rootNamespace, which this object
89
+ // cannot see — and the root namespace is configurable, so the conventional
90
+ // name is not evidence.
91
+ expect(getAuthorizationPolicySelectorString({ metadata: { namespace: 'istio-system' }, spec: {} }))
92
+ .toBe('Namespace / mesh scope')
93
+ })
94
+ })
95
+
96
+ describe('getDestinationRuleStatus', () => {
97
+ it('flags a rule that applies to nothing', () => {
98
+ expect(getDestinationRuleStatus({ spec: {} })).toMatchObject({ text: 'No Host', level: 'unhealthy' })
99
+ })
100
+
101
+ it('does not restate the subset count as a health verdict', () => {
102
+ expect(getDestinationRuleStatus({ spec: { host: 'api', subsets: [{ name: 'v1' }, { name: 'v2' }] } }))
103
+ .toMatchObject({ text: 'Not assessed', level: 'unknown' })
104
+ })
105
+
106
+ it('does not read a declared traffic policy as working', () => {
107
+ expect(getDestinationRuleStatus({ spec: { host: 'api', trafficPolicy: { tls: { mode: 'ISTIO_MUTUAL' } } } }))
108
+ .toMatchObject({ text: 'Not assessed', level: 'unknown' })
109
+ })
110
+ })
111
+
112
+ describe('getDestinationRuleTlsMode', () => {
113
+ it('surfaces a declared DISABLE, whose outcome depends on the server policy', () => {
114
+ // Against a PERMISSIVE PeerAuthentication this sends unencrypted traffic;
115
+ // against a STRICT one the requests fail. The row shows neither.
116
+ expect(getDestinationRuleTlsMode({ spec: { host: 'api', trafficPolicy: { tls: { mode: 'DISABLE' } } } }))
117
+ .toBe('DISABLE')
118
+ })
119
+
120
+ it('shows the declared mode when one is set', () => {
121
+ expect(getDestinationRuleTlsMode({ spec: { trafficPolicy: { tls: { mode: 'ISTIO_MUTUAL' } } } }))
122
+ .toBe('ISTIO_MUTUAL')
123
+ })
124
+
125
+ it('reports nothing declared rather than inventing a default', () => {
126
+ expect(getDestinationRuleTlsMode({ spec: { host: 'api' } })).toBe('-')
127
+ expect(getDestinationRuleTlsMode({ spec: { host: 'api', trafficPolicy: {} } })).toBe('-')
128
+ })
129
+ })
130
+
131
+ describe('getAuthorizationPolicyRuleNotice', () => {
132
+ it('does not claim an empty DENY blocks anything', () => {
133
+ // "If not set, the match will never occur" — a DENY with no rules matches
134
+ // nothing and denies nothing. Calling it Deny All is the dangerous
135
+ // direction: it tells an operator an ineffective policy protects them.
136
+ for (const spec of [{ action: 'DENY' }, { action: 'DENY', rules: [] }]) {
137
+ const notice = getAuthorizationPolicyRuleNotice({ spec })
138
+ expect(notice).toMatchObject({ level: 'info', title: 'No deny rules' })
139
+ expect(notice!.message).not.toMatch(/denies all/i)
140
+ }
141
+ })
142
+
143
+ it('explains an unconditional DENY, which no ALLOW can carve an exception from', () => {
144
+ // rules: [{}] matches every request. DENY is evaluated before ALLOW, so
145
+ // this is not a baseline with exceptions — it blocks everything it selects.
146
+ const notice = getAuthorizationPolicyRuleNotice({ spec: { action: 'DENY', rules: [{}] } })
147
+ expect(notice).toMatchObject({ level: 'info', title: 'Matches all requests' })
148
+ })
149
+
150
+ it('recognises a blanket DENY written with empty condition lists', () => {
151
+ // from: [] / to: [] / when: [] serialize as present but impose nothing, so
152
+ // the rule still matches every request the policy selects.
153
+ for (const rule of [{ from: [] }, { to: [] }, { when: [] }, { from: [], to: [], when: [] }]) {
154
+ expect(getAuthorizationPolicyRuleNotice({ spec: { action: 'DENY', rules: [rule] } }))
155
+ .toMatchObject({ title: 'Matches all requests' })
156
+ }
157
+ })
158
+
159
+ it('does not read an unrecognised rule field as unconditional', () => {
160
+ // A future condition type must not be mistaken for the absence of one.
161
+ expect(getAuthorizationPolicyRuleNotice({ spec: { action: 'DENY', rules: [{ someFutureMatcher: [{}] }] } }))
162
+ .toBeNull()
163
+ })
164
+
165
+ it('stays quiet on a DENY whose rules have conditions', () => {
166
+ expect(getAuthorizationPolicyRuleNotice({
167
+ spec: { action: 'DENY', rules: [{ from: [{ source: { namespaces: ['dev'] } }] }] },
168
+ })).toBeNull()
169
+ })
170
+
171
+ it('does not flag an unconditional ALLOW the same way', () => {
172
+ // An ALLOW matching everything is permissive, not a blanket block.
173
+ expect(getAuthorizationPolicyRuleNotice({ spec: { action: 'ALLOW', rules: [{}] } })).toBeNull()
174
+ })
175
+
176
+ it('warns on a rule-less ALLOW without asserting an outage', () => {
177
+ for (const spec of [{}, { action: 'ALLOW', rules: [] }]) {
178
+ const notice = getAuthorizationPolicyRuleNotice({ spec })
179
+ expect(notice).toMatchObject({ level: 'warning', title: 'No allow rules' })
180
+ expect(notice!.message).toMatch(/may still permit/i)
181
+ expect(notice!.message).not.toMatch(/no traffic is allowed/i)
182
+ }
183
+ })
184
+
185
+ it('stays quiet when the policy has rules', () => {
186
+ expect(getAuthorizationPolicyRuleNotice({ spec: { action: 'ALLOW', rules: [{}] } })).toBeNull()
187
+ })
188
+
189
+ it('does not editorialise about AUDIT or CUSTOM', () => {
190
+ expect(getAuthorizationPolicyRuleNotice({ spec: { action: 'AUDIT' } })).toBeNull()
191
+ expect(getAuthorizationPolicyRuleNotice({ spec: { action: 'CUSTOM' } })).toBeNull()
192
+ })
193
+ })
@@ -1,19 +1,13 @@
1
1
  import { Shield } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, KeyValueBadgeList } from '../../ui/drawer-components'
3
- import { Badge, type BadgeSeverity } from '../../ui/Badge'
3
+ import { Badge } from '../../ui/Badge'
4
4
  import {
5
5
  getAuthorizationPolicyAction,
6
6
  getAuthorizationPolicyRules,
7
+ getAuthorizationPolicyRuleNotice,
7
8
  getAuthorizationPolicySelector,
8
9
  } from '../resource-utils-istio'
9
10
 
10
- const actionSeverity: Record<string, BadgeSeverity> = {
11
- ALLOW: 'success',
12
- DENY: 'error',
13
- CUSTOM: 'info',
14
- AUDIT: 'warning',
15
- }
16
-
17
11
  interface IstioAuthorizationPolicyRendererProps {
18
12
  data: any
19
13
  }
@@ -24,25 +18,18 @@ export function IstioAuthorizationPolicyRenderer({ data }: IstioAuthorizationPol
24
18
  const selector = getAuthorizationPolicySelector(data)
25
19
  const hasSelector = Object.keys(selector).length > 0
26
20
 
27
- // Special case: DENY with no rules = deny all
28
- const isDenyAll = action === 'DENY' && rules.length === 0
29
- // Special case: ALLOW with no rules = allow nothing (deny all)
30
- const isAllowNothing = action === 'ALLOW' && rules.length === 0 && !data.spec?.rules
21
+ const ruleNotice = getAuthorizationPolicyRuleNotice(data)
22
+ // The action badge is uniformly neutral: an action is a declaration, not a
23
+ // verdict. A green ALLOW would make a deny-all policy look healthiest, and a
24
+ // red DENY would make a control doing its job look failed.
31
25
 
32
26
  return (
33
27
  <>
34
- {isDenyAll && (
35
- <AlertBanner
36
- variant="error"
37
- title="Deny All"
38
- message="This policy denies all traffic to the target workload (DENY action with no rules)."
39
- />
40
- )}
41
- {isAllowNothing && (
28
+ {ruleNotice && (
42
29
  <AlertBanner
43
- variant="warning"
44
- title="Allow Nothing"
45
- message="This policy has ALLOW action but no rules, which means no traffic is allowed."
30
+ variant={ruleNotice.level}
31
+ title={ruleNotice.title}
32
+ message={ruleNotice.message}
46
33
  />
47
34
  )}
48
35
 
@@ -50,7 +37,7 @@ export function IstioAuthorizationPolicyRenderer({ data }: IstioAuthorizationPol
50
37
  <Section title="Authorization Policy" icon={Shield} defaultExpanded>
51
38
  <PropertyList>
52
39
  <Property label="Action" value={
53
- <Badge severity={actionSeverity[action] ?? 'neutral'}>
40
+ <Badge severity="neutral">
54
41
  {action}
55
42
  </Badge>
56
43
  } />
@@ -6,7 +6,6 @@ import {
6
6
  getExternalSecretStore,
7
7
  getExternalSecretRefreshInterval,
8
8
  getExternalSecretLastSync,
9
- getExternalSecretProvider,
10
9
  getClusterExternalSecretStatus,
11
10
  getClusterExternalSecretNamespaceCount,
12
11
  getClusterExternalSecretFailedCount,
@@ -33,10 +32,6 @@ export function ExternalSecretCell({ resource, column }: { resource: any; column
33
32
  </span>
34
33
  )
35
34
  }
36
- case 'provider': {
37
- const provider = getExternalSecretProvider(resource)
38
- return <span className="text-sm text-theme-text-secondary truncate block">{provider}</span>
39
- }
40
35
  case 'refreshInterval': {
41
36
  const interval = getExternalSecretRefreshInterval(resource)
42
37
  return <span className="text-sm text-theme-text-secondary">{interval}</span>
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { clsx } from 'clsx'
4
4
  import { Badge, type BadgeSeverity } from '../../ui/Badge'
5
+ import { Tooltip } from '../../ui/Tooltip'
5
6
  import {
6
7
  getVirtualServiceStatus,
7
8
  getVirtualServiceHosts,
@@ -11,19 +12,19 @@ import {
11
12
  getDestinationRuleHost,
12
13
  getDestinationRuleSubsetCount,
13
14
  getDestinationRuleLoadBalancer,
15
+ getDestinationRuleTlsMode,
14
16
  getIstioGatewayStatus,
15
17
  getIstioGatewayServerCount,
16
18
  getIstioGatewaySelectorString,
17
- getServiceEntryStatus,
18
19
  getServiceEntryHosts,
19
20
  getServiceEntryLocation,
20
21
  getServiceEntryPortsString,
21
- getPeerAuthenticationStatus,
22
22
  getPeerAuthenticationMode,
23
23
  getPeerAuthenticationSelectorString,
24
24
  getAuthorizationPolicyStatus,
25
25
  getAuthorizationPolicyAction,
26
26
  getAuthorizationPolicyRuleCount,
27
+ getAuthorizationPolicySelectorString,
27
28
  } from '../resource-utils-istio'
28
29
 
29
30
  const modeSeverity: Record<string, BadgeSeverity> = {
@@ -32,13 +33,6 @@ const modeSeverity: Record<string, BadgeSeverity> = {
32
33
  DISABLE: 'error',
33
34
  }
34
35
 
35
- const actionSeverity: Record<string, BadgeSeverity> = {
36
- ALLOW: 'success',
37
- DENY: 'error',
38
- CUSTOM: 'info',
39
- AUDIT: 'warning',
40
- }
41
-
42
36
  export function VirtualServiceCell({ resource, column }: { resource: any; column: string }) {
43
37
  switch (column) {
44
38
  case 'status': {
@@ -88,6 +82,13 @@ export function DestinationRuleCell({ resource, column }: { resource: any; colum
88
82
  const lb = getDestinationRuleLoadBalancer(resource)
89
83
  return <span className="text-sm text-theme-text-secondary">{lb}</span>
90
84
  }
85
+ case 'tlsMode': {
86
+ // Every mode reads neutral: this is the rule's declaration, not the
87
+ // posture in force. What a DISABLE produces depends on the server's
88
+ // PeerAuthentication — unencrypted traffic, or failed requests.
89
+ const mode = getDestinationRuleTlsMode(resource)
90
+ return <span className="text-sm text-theme-text-secondary">{mode}</span>
91
+ }
91
92
  default:
92
93
  return <span className="text-sm text-theme-text-tertiary">-</span>
93
94
  }
@@ -118,14 +119,6 @@ export function IstioGatewayCell({ resource, column }: { resource: any; column:
118
119
 
119
120
  export function ServiceEntryCell({ resource, column }: { resource: any; column: string }) {
120
121
  switch (column) {
121
- case 'status': {
122
- const status = getServiceEntryStatus(resource)
123
- return (
124
- <span className={clsx('badge', status.color)}>
125
- {status.text}
126
- </span>
127
- )
128
- }
129
122
  case 'hosts': {
130
123
  const hosts = getServiceEntryHosts(resource)
131
124
  return <span className="text-sm text-theme-text-secondary truncate block">{hosts}</span>
@@ -149,14 +142,6 @@ export function ServiceEntryCell({ resource, column }: { resource: any; column:
149
142
 
150
143
  export function PeerAuthenticationCell({ resource, column }: { resource: any; column: string }) {
151
144
  switch (column) {
152
- case 'status': {
153
- const status = getPeerAuthenticationStatus(resource)
154
- return (
155
- <span className={clsx('badge', status.color)}>
156
- {status.text}
157
- </span>
158
- )
159
- }
160
145
  case 'mode': {
161
146
  const mode = getPeerAuthenticationMode(resource)
162
147
  return (
@@ -186,16 +171,41 @@ export function AuthorizationPolicyCell({ resource, column }: { resource: any; c
186
171
  }
187
172
  case 'action': {
188
173
  const action = getAuthorizationPolicyAction(resource)
174
+ // Uniformly neutral: an action is a declaration, not a verdict. A green
175
+ // ALLOW would make a deny-all policy look healthiest, and a red DENY
176
+ // would make a control doing its job look failed.
189
177
  return (
190
- <Badge severity={actionSeverity[action] ?? 'neutral'}>
178
+ <Badge severity="neutral">
191
179
  {action}
192
180
  </Badge>
193
181
  )
194
182
  }
195
183
  case 'rules': {
196
184
  const count = getAuthorizationPolicyRuleCount(resource)
185
+ // Rules are alternatives, so an ALLOW with none of them matches nothing —
186
+ // Istio's deny-all idiom. This is the default-visible cell that carries
187
+ // the warning; the Status column is off by default.
188
+ const allowsNothing = count === 0 && (resource.spec?.action || 'ALLOW') === 'ALLOW'
189
+ // Amber on the count itself: it fits the column and reads at a glance
190
+ // against grey neighbours. The explanation rides on the badge so it does
191
+ // not depend on finding the column header's tooltip.
192
+ if (allowsNothing) {
193
+ return (
194
+ <Tooltip content="Rules are alternatives, so this ALLOW policy matches nothing and permits no traffic on its own. Other ALLOW policies selecting the same workload may still permit requests.">
195
+ <Badge severity="warning">0</Badge>
196
+ </Tooltip>
197
+ )
198
+ }
197
199
  return <span className="text-sm text-theme-text-secondary">{count}</span>
198
200
  }
201
+ case 'selector': {
202
+ const scope = getAuthorizationPolicySelectorString(resource)
203
+ return (
204
+ <Tooltip content={scope}>
205
+ <span className="text-sm text-theme-text-secondary truncate block">{scope}</span>
206
+ </Tooltip>
207
+ )
208
+ }
199
209
  default:
200
210
  return <span className="text-sm text-theme-text-tertiary">-</span>
201
211
  }
@@ -15,7 +15,6 @@ import {
15
15
  getKueueWorkloadQueueName,
16
16
  getKueueWorkloadAdmittedBy,
17
17
  getKueueWorkloadPriority,
18
- getResourceFlavorStatus,
19
18
  getResourceFlavorNodeLabelCount,
20
19
  getResourceFlavorTaintCount,
21
20
  getAdmissionCheckStatus,
@@ -112,14 +111,6 @@ export function KueueWorkloadCell({ resource, column }: { resource: any; column:
112
111
 
113
112
  export function ResourceFlavorCell({ resource, column }: { resource: any; column: string }) {
114
113
  switch (column) {
115
- case 'status': {
116
- const status = getResourceFlavorStatus(resource)
117
- return (
118
- <span className={clsx('badge', status.color)}>
119
- {status.text}
120
- </span>
121
- )
122
- }
123
114
  case 'nodeLabels': {
124
115
  const count = getResourceFlavorNodeLabelCount(resource)
125
116
  return <span className="text-sm text-theme-text-secondary">{count > 0 ? count : '-'}</span>
@@ -73,7 +73,7 @@ export function KyvernoPolicyCell({ resource, column }: { resource: any; column:
73
73
  case 'status': {
74
74
  const status = getKyvernoPolicyStatus(resource)
75
75
  return (
76
- <span className={clsx('badge', status.color)}>
76
+ <span className={clsx('badge truncate max-w-full', status.color)} title={status.text}>
77
77
  {status.text}
78
78
  </span>
79
79
  )
@@ -19,7 +19,7 @@ export function ServiceMonitorCell({ resource, column }: { resource: any; column
19
19
  case 'status': {
20
20
  const status = getServiceMonitorStatus(resource)
21
21
  return (
22
- <span className={clsx('badge', status.color)}>
22
+ <span className={clsx('badge truncate max-w-full', status.color)} title={status.text}>
23
23
  {status.text}
24
24
  </span>
25
25
  )
@@ -40,7 +40,7 @@ export function PrometheusRuleCell({ resource, column }: { resource: any; column
40
40
  case 'status': {
41
41
  const status = getPrometheusRuleStatus(resource)
42
42
  return (
43
- <span className={clsx('badge', status.color)}>
43
+ <span className={clsx('badge truncate max-w-full', status.color)} title={status.text}>
44
44
  {status.text}
45
45
  </span>
46
46
  )
@@ -59,7 +59,7 @@ export function PodMonitorCell({ resource, column }: { resource: any; column: st
59
59
  case 'status': {
60
60
  const status = getPodMonitorStatus(resource)
61
61
  return (
62
- <span className={clsx('badge', status.color)}>
62
+ <span className={clsx('badge truncate max-w-full', status.color)} title={status.text}>
63
63
  {status.text}
64
64
  </span>
65
65
  )
@@ -15,7 +15,7 @@ import {
15
15
  getRayServiceClusters,
16
16
  getRayCronJobStatus,
17
17
  getRayCronJobSchedule,
18
- getRayCronJobSuspend,
18
+ getRayCronJobTimeZone,
19
19
  getRayCronJobLastSchedule,
20
20
  } from '../resource-utils-ray'
21
21
 
@@ -110,9 +110,17 @@ export function RayCronJobCell({ resource, column }: { resource: any; column: st
110
110
  const schedule = getRayCronJobSchedule(resource)
111
111
  return <span className="text-sm font-mono text-theme-text-secondary">{schedule}</span>
112
112
  }
113
- case 'suspend': {
114
- const suspended = getRayCronJobSuspend(resource)
115
- return <span className="text-sm text-theme-text-secondary">{suspended ? 'Yes' : 'No'}</span>
113
+ case 'timeZone': {
114
+ const zone = getRayCronJobTimeZone(resource)
115
+ const declared = Boolean(resource.spec?.timeZone)
116
+ return (
117
+ <span
118
+ className={clsx('text-sm truncate block', declared ? 'text-theme-text-secondary' : 'text-theme-text-tertiary')}
119
+ title={zone}
120
+ >
121
+ {zone}
122
+ </span>
123
+ )
116
124
  }
117
125
  case 'lastSchedule': {
118
126
  const lastSchedule = getRayCronJobLastSchedule(resource)
@@ -16,7 +16,6 @@ import {
16
16
  getVolcanoPodGroupMinMember,
17
17
  getJobFlowStatus,
18
18
  getJobFlowFlowCount,
19
- getJobTemplateStatus,
20
19
  getJobTemplateTaskCount,
21
20
  } from '../resource-utils-volcano'
22
21
 
@@ -122,14 +121,6 @@ export function JobFlowCell({ resource, column }: { resource: any; column: strin
122
121
 
123
122
  export function JobTemplateCell({ resource, column }: { resource: any; column: string }) {
124
123
  switch (column) {
125
- case 'status': {
126
- const status = getJobTemplateStatus(resource)
127
- return (
128
- <span className={clsx('badge', status.color)}>
129
- {status.text}
130
- </span>
131
- )
132
- }
133
124
  case 'tasks': {
134
125
  const count = getJobTemplateTaskCount(resource)
135
126
  return <span className="text-sm text-theme-text-secondary">{count > 0 ? count : '-'}</span>
@@ -646,6 +646,11 @@ export function getCNPGObjectStoreStatus(resource: any): StatusBadge {
646
646
  if (windows.some((w) => w.failingSinceLastSuccess)) {
647
647
  return { text: 'Backups Failing', color: healthColors.unhealthy, level: 'unhealthy' }
648
648
  }
649
+ // Every timestamp on a recovery window is optional, so a server can be listed
650
+ // with no recovery point at all. The entry existing is not a backup existing.
651
+ if (!windows.some((w) => w.firstRecoverabilityPoint || w.lastSuccessfulBackupTime)) {
652
+ return { text: 'No recovery point', color: healthColors.unknown, level: 'unknown' }
653
+ }
649
654
  return { text: 'Recoverable', color: healthColors.healthy, level: 'healthy' }
650
655
  }
651
656