@skyhook-io/k8s-ui 1.12.0 → 1.13.0

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 (103) hide show
  1. package/package.json +4 -4
  2. package/src/components/issues/severity.ts +1 -0
  3. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  4. package/src/components/resources/ResourcesView.tsx +410 -5
  5. package/src/components/resources/index.ts +27 -0
  6. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  7. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  8. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  9. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  10. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  11. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  12. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  13. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  14. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  15. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  16. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  17. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  18. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  19. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  20. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  23. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  24. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  25. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  26. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  27. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  28. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  29. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  30. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  31. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  32. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  33. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  34. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  35. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  36. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  37. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  38. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  39. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  40. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  41. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  42. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  43. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  44. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  45. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  46. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  47. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  48. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  49. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  50. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  51. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  52. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  53. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  54. package/src/components/resources/renderers/index.ts +16 -1
  55. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  56. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  57. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  58. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  59. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  60. package/src/components/resources/resource-utils-calico.ts +261 -0
  61. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  62. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  63. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  64. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  65. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  66. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  67. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  68. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  69. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  70. package/src/components/resources/resource-utils-velero.ts +68 -0
  71. package/src/components/resources/resource-utils.ts +98 -0
  72. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  73. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  74. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  75. package/src/components/shared/index.ts +1 -1
  76. package/src/components/shared/revision-role-badges.test.ts +120 -0
  77. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  78. package/src/components/topology/K8sResourceNode.tsx +22 -0
  79. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  80. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  81. package/src/components/topology/TopologyGraph.tsx +6 -6
  82. package/src/components/topology/TopologySearch.tsx +7 -0
  83. package/src/components/topology/layout.ts +3 -0
  84. package/src/components/trace/ReachabilityView.tsx +1 -0
  85. package/src/components/trace/TracePanel.tsx +12 -2
  86. package/src/components/trace/reachFixtures.ts +26 -1
  87. package/src/components/trace/reachGraphModel.test.ts +20 -0
  88. package/src/components/trace/reachGraphModel.ts +8 -1
  89. package/src/components/trace/reachOrigins.test.ts +31 -0
  90. package/src/components/trace/reachOrigins.ts +11 -1
  91. package/src/components/ui/Badge.tsx +10 -0
  92. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  93. package/src/components/ui/ScheduleValue.tsx +85 -0
  94. package/src/components/ui/drawer-components.test.tsx +17 -1
  95. package/src/components/ui/drawer-components.tsx +9 -2
  96. package/src/components/ui/index.ts +2 -0
  97. package/src/components/workload/WorkloadView.tsx +3 -0
  98. package/src/types/core.ts +29 -0
  99. package/src/types/policy.ts +153 -2
  100. package/src/utils/navigation.test.ts +5 -0
  101. package/src/utils/navigation.ts +5 -0
  102. package/src/utils/resource-icons.test.ts +49 -2
  103. package/src/utils/resource-icons.ts +29 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -71,7 +71,7 @@
71
71
  "html-to-image": "^1.11.0",
72
72
  "monaco-yaml": "5.5.1",
73
73
  "react-virtuoso": "^4.18.11",
74
- "shiki": "^4.2.0"
74
+ "shiki": "^4.4.2"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "@xterm/addon-fit": ">=0.10.0",
@@ -88,7 +88,7 @@
88
88
  "yaml": ">=2.0.0"
89
89
  },
90
90
  "devDependencies": {
91
- "@types/node": "^26.1.1",
91
+ "@types/node": "^26.2.0",
92
92
  "@types/react": "^19.2.18",
93
93
  "@types/react-dom": "^19.2.3",
94
94
  "@xterm/addon-fit": "^0.11.0",
@@ -98,7 +98,7 @@
98
98
  "clsx": "^2.1.1",
99
99
  "diff": "^9.0.0",
100
100
  "elkjs": "^0.11.1",
101
- "lucide-react": "^1.28.0",
101
+ "lucide-react": "^1.30.0",
102
102
  "monaco-editor": "0.55.1",
103
103
  "react": "^19.2.8",
104
104
  "react-dom": "^19.2.8",
@@ -85,6 +85,7 @@ const CATEGORY_LABEL: Record<string, string> = {
85
85
  volume_access_mode_conflict: 'Volume access conflict',
86
86
  backup_failed: 'Backup failed',
87
87
  backup_target_unavailable: 'Backup target unavailable',
88
+ backup_stalled: 'Backup not progressing',
88
89
  job_failed: 'Job failed',
89
90
  cronjob_failed: 'CronJob failed',
90
91
  rollout_stalled: 'Rollout stalled',
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { emptyKindNote } from './ResourcesView'
3
+
4
+ describe('emptyKindNote', () => {
5
+ it('explains that no UpdateRequest is the resting state, and that it looks the same as a rule that never ran', () => {
6
+ const note = emptyKindNote('UpdateRequest', 'kyverno.io')
7
+ expect(note).toContain('deleted seconds after')
8
+ expect(note).toContain('never ran')
9
+ })
10
+
11
+ it('says an absent EphemeralReport means the scan finished, not that nothing was checked', () => {
12
+ expect(emptyKindNote('EphemeralReport', 'reports.kyverno.io')).toContain('scan has finished')
13
+ expect(emptyKindNote('ClusterEphemeralReport', 'reports.kyverno.io')).toContain('scan has finished')
14
+ })
15
+
16
+ it('is case-insensitive on the kind, which arrives from a URL as often as from discovery', () => {
17
+ expect(emptyKindNote('updaterequest', 'kyverno.io')).toBeDefined()
18
+ })
19
+
20
+ // Same plural, different operator: Kyverno's group is what makes the note true.
21
+ it('says nothing about a kind of the same name from another group', () => {
22
+ expect(emptyKindNote('UpdateRequest', 'example.com')).toBeUndefined()
23
+ expect(emptyKindNote('EphemeralReport', '')).toBeUndefined()
24
+ })
25
+
26
+ it('says nothing about kinds with no such story', () => {
27
+ expect(emptyKindNote('Pod', '')).toBeUndefined()
28
+ })
29
+
30
+ // The table can be empty because the reader emptied it. Telling them none is
31
+ // normal then answers a question they did not ask, with their term still in
32
+ // the search box.
33
+ it('stays silent when the kind has rows and a filter is what emptied the table', () => {
34
+ expect(emptyKindNote('UpdateRequest', 'kyverno.io', 3)).toBeUndefined()
35
+ expect(emptyKindNote('EphemeralReport', 'reports.kyverno.io', 192)).toBeUndefined()
36
+ })
37
+
38
+ it('still speaks when the kind is genuinely empty', () => {
39
+ expect(emptyKindNote('UpdateRequest', 'kyverno.io', 0)).toBeDefined()
40
+ })
41
+ })
@@ -79,6 +79,8 @@ import {
79
79
  getPVCCapacity,
80
80
  getPVCAccessModes,
81
81
  getRolloutStatus,
82
+ getAnalysisRunStatus,
83
+ summarizeAnalysisMetrics,
82
84
  getRolloutStrategy,
83
85
  getRolloutReady,
84
86
  getRolloutStep,
@@ -152,14 +154,17 @@ import { ScaledObjectCell, ScaledJobCell, TriggerAuthenticationCell, ClusterTrig
152
154
  import { ResourceClaimCell, ResourceClaimTemplateCell, DeviceClassCell, ResourceSliceCell } from './renderers/dra-cells'
153
155
  import { NvidiaClusterPolicyCell, NvidiaDriverCell } from './renderers/nvidia-cells'
154
156
  import { ServiceMonitorCell, PrometheusRuleCell, PodMonitorCell } from './renderers/prometheus-cells'
155
- import { PolicyReportCell, ClusterPolicyReportCell, KyvernoPolicyCell, ClusterPolicyCell } from './renderers/kyverno-cells'
157
+ import { PolicyReportCell, ClusterPolicyReportCell, KyvernoPolicyCell, ClusterPolicyCell,
158
+ KyvernoUpdateRequestCell,
159
+ KyvernoEphemeralReportCell,
160
+ } from './renderers/kyverno-cells'
156
161
  import { KyvernoModernPolicyCell, KyvernoPolicyExceptionCell, KyvernoCleanupPolicyCell } from './renderers/kyverno-modern-cells'
157
162
  import { KYVERNO_MODERN_PLURALS, isModernKyvernoPolicy } from './resource-utils-kyverno-modern'
158
163
  import { isAnyKyvernoPolicyException } from './resource-utils-kyverno-exceptions'
159
164
  import { ExternalSecretCell, ClusterExternalSecretCell, SecretStoreCell, ClusterSecretStoreCell } from './renderers/eso-cells'
160
165
  import { BackupCell, RestoreCell, ScheduleCell, BackupStorageLocationCell, VolumeSnapshotLocationCell, BackupRepositoryCell } from './renderers/velero-cells'
161
166
  import { isVeleroResource } from './resource-utils-velero'
162
- import { CNPGClusterCell, CNPGBackupCell, CNPGScheduledBackupCell, CNPGPoolerCell } from './renderers/cnpg-cells'
167
+ import { CNPGClusterCell, CNPGBackupCell, CNPGScheduledBackupCell, CNPGPoolerCell, CNPGObjectStoreCell, CNPGDeclarativeCell, CNPGImageCatalogCell } from './renderers/cnpg-cells'
163
168
  import { isApiGroup, CNPG_GROUP } from './resource-utils-cnpg'
164
169
  import { ManagedResourceCell, CompositeResourceCell, CrossplaneProviderCell, CrossplaneProviderConfigCell, CompositionCell, XRDCell } from './renderers/crossplane-cells'
165
170
  import { isManagedResource, isComposite } from './resource-utils-crossplane'
@@ -171,6 +176,8 @@ import { CAPIClusterCell, CAPIMachineCell, CAPIMachineDeploymentCell, CAPIMachin
171
176
  import { AWSManagedControlPlaneCell, AWSManagedMachinePoolCell, AWSMachineCell, AWSMachineTemplateCell, AWSManagedClusterCell } from './renderers/aws-capi-cells'
172
177
  import { GCPManagedControlPlaneCell, GCPManagedMachinePoolCell, GCPMachineCell, GCPMachineTemplateCell, GCPManagedClusterCell } from './renderers/gcp-capi-cells'
173
178
  import { AzureManagedControlPlaneCell, AzureManagedMachinePoolCell, AzureMachineCell, AzureMachineTemplateCell, AzureManagedClusterCell } from './renderers/azure-capi-cells'
179
+ import { CalicoInfraCell, CalicoPolicyCell } from './renderers/calico-cells'
180
+ import { isCalicoPolicyResource, isCoreNetworkPolicyKind } from './resource-utils-calico'
174
181
  import { useRegisterShortcut, useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
175
182
  import { ResourcesSidebar } from './ResourcesSidebar'
176
183
  import type { SelectedKindInfo } from './ResourcesSidebar'
@@ -274,10 +281,14 @@ function buildCustomColumn(d: CustomColumnDef): ExtraColumn {
274
281
  }
275
282
 
276
283
  // Tailwind width class → pixel minimum mapping for CSS Grid column sizing
284
+ // A width class missing from this map falls back to 80px, and nothing says so —
285
+ // the column just renders narrow enough to truncate its header. Keep the whole
286
+ // Tailwind step range present rather than only the sizes in use today.
277
287
  const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
278
288
  'w-12': 48, 'w-14': 56, 'w-16': 64, 'w-20': 80, 'w-24': 96,
279
289
  'w-28': 112, 'w-32': 128, 'w-36': 144, 'w-40': 160, 'w-44': 176,
280
- 'w-48': 192, 'w-56': 224, 'w-64': 256,
290
+ 'w-48': 192, 'w-52': 208, 'w-56': 224, 'w-60': 240, 'w-64': 256,
291
+ 'w-72': 288, 'w-80': 320, 'w-96': 384,
281
292
  }
282
293
 
283
294
  const COMPARE_COLUMN_WIDTH = 36
@@ -296,6 +307,30 @@ function getColumnMinWidth(col: Column): number {
296
307
  }
297
308
 
298
309
  // Default columns for unknown resource types (CRDs)
310
+ /**
311
+ * Why a list of this kind being empty is not the answer it looks like.
312
+ *
313
+ * Kyverno's two working records are garbage-collected within seconds of
314
+ * finishing, so "none" is the resting state of a healthy cluster — and for
315
+ * UpdateRequest it is ALSO what you see when a generate rule never fired at
316
+ * all. Those are opposite conclusions and a bare "No X found" renders them
317
+ * identically.
318
+ */
319
+ export function emptyKindNote(kind: string, group?: string, unfilteredTotal = 0): string | undefined {
320
+ // A search or column filter emptying the table is the reader's own doing.
321
+ // Explaining that none is the resting state then answers a question they did
322
+ // not ask, while their own term is still in the box.
323
+ if (unfilteredTotal > 0) return undefined
324
+ const g = group ?? ''
325
+ if (kind.toLowerCase() === 'updaterequest' && g === 'kyverno.io') {
326
+ return 'These are deleted seconds after the work completes, so none is the normal resting state. It also looks like this if a generate or mutate-existing rule never ran — check the policy itself to tell the two apart.'
327
+ }
328
+ if ((kind.toLowerCase() === 'ephemeralreport' || kind.toLowerCase() === 'clusterephemeralreport') && g === 'reports.kyverno.io') {
329
+ return 'These exist only between a background scan and the PolicyReport it becomes, so none means the scan has finished rather than that nothing was checked.'
330
+ }
331
+ return undefined
332
+ }
333
+
299
334
  const DEFAULT_COLUMNS: Column[] = [
300
335
  { key: 'name', label: 'Name' },
301
336
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
@@ -464,6 +499,14 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
464
499
  { key: 'images', label: 'Images', width: 'w-48', hideOnMobile: true },
465
500
  { key: 'age', label: 'Age', width: 'w-24' },
466
501
  ],
502
+ analysisruns: [
503
+ { key: 'name', label: 'Name' },
504
+ { key: 'namespace', label: 'Namespace', width: 'w-48' },
505
+ { key: 'status', label: 'Phase', width: 'w-28' },
506
+ { key: 'trigger', label: 'Trigger', width: 'w-32', tooltip: 'Which part of the Rollout started this run' },
507
+ { key: 'metrics', label: 'Metrics', width: 'w-40', hideOnMobile: true, tooltip: 'Per-metric verdicts' },
508
+ { key: 'age', label: 'Age', width: 'w-24' },
509
+ ],
467
510
  workflows: [
468
511
  { key: 'name', label: 'Name' },
469
512
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
@@ -913,6 +956,88 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
913
956
  { key: 'rules', label: 'Rules', width: 'w-24', tooltip: 'Ingress / Egress rule count' },
914
957
  { key: 'age', label: 'Age', width: 'w-24' },
915
958
  ],
959
+ caliconetworkpolicies: [
960
+ { key: 'name', label: 'Name' },
961
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
962
+ { key: 'selector', label: 'Selector', width: 'w-48' },
963
+ { key: 'namespaceSelector', label: 'Namespace Selector', width: 'w-48', defaultVisible: false },
964
+ { key: 'serviceAccountSelector', label: 'Service Account Selector', width: 'w-48', defaultVisible: false },
965
+ { key: 'tier', label: 'Tier', width: 'w-28' },
966
+ { key: 'order', label: 'Order', width: 'w-24' },
967
+ { key: 'types', label: 'Types', width: 'w-28' },
968
+ { key: 'rules', label: 'Rules', width: 'w-24', tooltip: 'Ingress / Egress rule count' },
969
+ { key: 'age', label: 'Age', width: 'w-24' },
970
+ ],
971
+ calicoglobalnetworkpolicies: [
972
+ { key: 'name', label: 'Name' },
973
+ { key: 'selector', label: 'Selector', width: 'w-48' },
974
+ { key: 'namespaceSelector', label: 'Namespace Selector', width: 'w-48', defaultVisible: false },
975
+ { key: 'serviceAccountSelector', label: 'Service Account Selector', width: 'w-48', defaultVisible: false },
976
+ { key: 'tier', label: 'Tier', width: 'w-28' },
977
+ { key: 'order', label: 'Order', width: 'w-24' },
978
+ { key: 'types', label: 'Types', width: 'w-28' },
979
+ { key: 'rules', label: 'Rules', width: 'w-24', tooltip: 'Ingress / Egress rule count' },
980
+ { key: 'age', label: 'Age', width: 'w-24' },
981
+ ],
982
+ calicostagednetworkpolicies: [
983
+ { key: 'name', label: 'Name' },
984
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
985
+ { key: 'selector', label: 'Selector', width: 'w-48' },
986
+ { key: 'namespaceSelector', label: 'Namespace Selector', width: 'w-48', defaultVisible: false },
987
+ { key: 'serviceAccountSelector', label: 'Service Account Selector', width: 'w-48', defaultVisible: false },
988
+ { key: 'tier', label: 'Tier', width: 'w-28' },
989
+ { key: 'order', label: 'Order', width: 'w-24' },
990
+ { key: 'stagedAction', label: 'Staged Action', width: 'w-32' },
991
+ { key: 'types', label: 'Types', width: 'w-28' },
992
+ { key: 'rules', label: 'Rules', width: 'w-24', tooltip: 'Ingress / Egress rule count' },
993
+ { key: 'age', label: 'Age', width: 'w-24' },
994
+ ],
995
+ calicostagedglobalnetworkpolicies: [
996
+ { key: 'name', label: 'Name' },
997
+ { key: 'selector', label: 'Selector', width: 'w-48' },
998
+ { key: 'namespaceSelector', label: 'Namespace Selector', width: 'w-48', defaultVisible: false },
999
+ { key: 'serviceAccountSelector', label: 'Service Account Selector', width: 'w-48', defaultVisible: false },
1000
+ { key: 'tier', label: 'Tier', width: 'w-28' },
1001
+ { key: 'order', label: 'Order', width: 'w-24' },
1002
+ { key: 'stagedAction', label: 'Staged Action', width: 'w-32' },
1003
+ { key: 'types', label: 'Types', width: 'w-28' },
1004
+ { key: 'rules', label: 'Rules', width: 'w-24', tooltip: 'Ingress / Egress rule count' },
1005
+ { key: 'age', label: 'Age', width: 'w-24' },
1006
+ ],
1007
+ calicoippools: [
1008
+ { key: 'name', label: 'Name' },
1009
+ { key: 'cidr', label: 'CIDR', width: 'w-44' },
1010
+ { key: 'blockSize', label: 'Block Size', width: 'w-28' },
1011
+ { key: 'encapsulation', label: 'Encapsulation', width: 'w-36' },
1012
+ { key: 'natOutgoing', label: 'NAT Outgoing', width: 'w-32' },
1013
+ { key: 'disabled', label: 'Disabled', width: 'w-28' },
1014
+ { key: 'allowedUses', label: 'Allowed Uses', width: 'w-40', defaultVisible: false },
1015
+ { key: 'nodeSelector', label: 'Node Selector', width: 'w-48', defaultVisible: false },
1016
+ { key: 'age', label: 'Age', width: 'w-24' },
1017
+ ],
1018
+ calicohostendpoints: [
1019
+ { key: 'name', label: 'Name' },
1020
+ { key: 'node', label: 'Node', width: 'w-48' },
1021
+ { key: 'interfaceName', label: 'Interface', width: 'w-32' },
1022
+ { key: 'expectedIPs', label: 'Expected IPs', width: 'w-48' },
1023
+ { key: 'profiles', label: 'Profiles', width: 'w-48', defaultVisible: false },
1024
+ { key: 'age', label: 'Age', width: 'w-24' },
1025
+ ],
1026
+ calicotiers: [
1027
+ { key: 'name', label: 'Name' },
1028
+ { key: 'order', label: 'Order', width: 'w-24' },
1029
+ { key: 'defaultAction', label: 'Default Action', width: 'w-36' },
1030
+ { key: 'age', label: 'Age', width: 'w-24' },
1031
+ ],
1032
+ calicostagedkubernetesnetworkpolicies: [
1033
+ { key: 'name', label: 'Name' },
1034
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1035
+ { key: 'selector', label: 'Pod Selector', width: 'w-48' },
1036
+ { key: 'stagedAction', label: 'Staged Action', width: 'w-32' },
1037
+ { key: 'types', label: 'Types', width: 'w-28' },
1038
+ { key: 'rules', label: 'Rules', width: 'w-24', tooltip: 'Ingress / Egress rule count' },
1039
+ { key: 'age', label: 'Age', width: 'w-24' },
1040
+ ],
916
1041
  poddisruptionbudgets: [
917
1042
  { key: 'name', label: 'Name' },
918
1043
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
@@ -1305,6 +1430,92 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1305
1430
  // than an absent one — the reader can't tell which meaning they're getting.
1306
1431
  { key: 'age', label: 'Age', width: 'w-24' },
1307
1432
  ],
1433
+ // `databases`, `publications` and `subscriptions` are crowded plurals — other
1434
+ // database operators ship all three, and Knative owns the unqualified
1435
+ // `subscriptions` set. Keyed under cnpg* and reached only through
1436
+ // GROUP_QUALIFIED_COLUMN_KEYS, so a foreign CRD gets the generic columns
1437
+ // instead of a Cluster column it can never fill.
1438
+ // Kyverno's working records. Both are keyed by things the reader has to see
1439
+ // to know whether anything is wrong: which policy queued the work and what it
1440
+ // was queued against, and which resource a scan is about.
1441
+ updaterequests: [
1442
+ { key: 'name', label: 'Name' },
1443
+ { key: 'namespace', label: 'Namespace', width: 'w-32' },
1444
+ { key: 'status', label: 'State', width: 'w-28' },
1445
+ { key: 'type', label: 'Type', width: 'w-24' },
1446
+ { key: 'policy', label: 'Policy', width: 'w-52' },
1447
+ { key: 'triggers', label: 'Triggered By', width: 'w-56' },
1448
+ { key: 'age', label: 'Age', width: 'w-24' },
1449
+ ],
1450
+ ephemeralreports: [
1451
+ { key: 'name', label: 'Name' },
1452
+ { key: 'namespace', label: 'Namespace', width: 'w-32' },
1453
+ { key: 'status', label: 'Outcome', width: 'w-28' },
1454
+ { key: 'subject', label: 'About', width: 'w-56' },
1455
+ { key: 'source', label: 'Produced By', width: 'w-36' },
1456
+ { key: 'results', label: 'Results', width: 'w-24' },
1457
+ { key: 'age', label: 'Age', width: 'w-24' },
1458
+ ],
1459
+ clusterephemeralreports: [
1460
+ { key: 'name', label: 'Name' },
1461
+ { key: 'status', label: 'Outcome', width: 'w-28' },
1462
+ { key: 'subject', label: 'About', width: 'w-56' },
1463
+ { key: 'source', label: 'Produced By', width: 'w-36' },
1464
+ { key: 'results', label: 'Results', width: 'w-24' },
1465
+ { key: 'age', label: 'Age', width: 'w-24' },
1466
+ ],
1467
+ cnpgdatabases: [
1468
+ { key: 'name', label: 'Name' },
1469
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1470
+ { key: 'status', label: 'Applied', width: 'w-32' },
1471
+ { key: 'cluster', label: 'Cluster', width: 'w-36' },
1472
+ { key: 'target', label: 'Database', width: 'w-40' },
1473
+ { key: 'message', label: 'Message', width: 'w-64' },
1474
+ { key: 'age', label: 'Age', width: 'w-24' },
1475
+ ],
1476
+ cnpgpublications: [
1477
+ { key: 'name', label: 'Name' },
1478
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1479
+ { key: 'status', label: 'Applied', width: 'w-32' },
1480
+ { key: 'cluster', label: 'Cluster', width: 'w-36' },
1481
+ { key: 'dbname', label: 'In Database', width: 'w-40' },
1482
+ { key: 'message', label: 'Message', width: 'w-64' },
1483
+ { key: 'age', label: 'Age', width: 'w-24' },
1484
+ ],
1485
+ cnpgsubscriptions: [
1486
+ { key: 'name', label: 'Name' },
1487
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1488
+ { key: 'status', label: 'Applied', width: 'w-32' },
1489
+ { key: 'cluster', label: 'Cluster', width: 'w-36' },
1490
+ { key: 'dbname', label: 'In Database', width: 'w-40' },
1491
+ { key: 'message', label: 'Message', width: 'w-64' },
1492
+ { key: 'age', label: 'Age', width: 'w-24' },
1493
+ ],
1494
+ cnpgimagecatalogs: [
1495
+ { key: 'name', label: 'Name' },
1496
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1497
+ { key: 'majors', label: 'Major Versions', width: 'w-40' },
1498
+ { key: 'images', label: 'Images', width: 'w-24' },
1499
+ { key: 'age', label: 'Age', width: 'w-24' },
1500
+ ],
1501
+ cnpgclusterimagecatalogs: [
1502
+ { key: 'name', label: 'Name' },
1503
+ { key: 'majors', label: 'Major Versions', width: 'w-40' },
1504
+ { key: 'images', label: 'Images', width: 'w-24' },
1505
+ { key: 'age', label: 'Age', width: 'w-24' },
1506
+ ],
1507
+ barmanobjectstores: [
1508
+ { key: 'name', label: 'Name' },
1509
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1510
+ { key: 'status', label: 'Status', width: 'w-40' },
1511
+ // Sized rather than flexible: every sibling table gives its long value a
1512
+ // fixed width, and leaving two columns unsized let Name absorb the row at
1513
+ // 1920px — truncating the destination and pushing Retention off-screen.
1514
+ { key: 'destination', label: 'Destination', width: 'w-64' },
1515
+ { key: 'servers', label: 'Servers', width: 'w-32' },
1516
+ { key: 'retention', label: 'Retention', width: 'w-28' },
1517
+ { key: 'age', label: 'Age', width: 'w-24' },
1518
+ ],
1308
1519
  cnpgbackups: [
1309
1520
  { key: 'name', label: 'Name' },
1310
1521
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
@@ -1499,7 +1710,10 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1499
1710
  { key: 'address', label: 'Address', width: 'w-56' },
1500
1711
  { key: 'age', label: 'Age', width: 'w-24' },
1501
1712
  ],
1502
- subscriptions: [
1713
+ // Keyed under knative*, reached only through GROUP_QUALIFIED_COLUMN_KEYS, so a
1714
+ // third operator's `subscriptions` gets the generic columns instead of Channel
1715
+ // and Subscriber it can never fill.
1716
+ knativesubscriptions: [
1503
1717
  { key: 'name', label: 'Name' },
1504
1718
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1505
1719
  { key: 'status', label: 'Status', width: 'w-28' },
@@ -1869,6 +2083,16 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1869
2083
 
1870
2084
  // Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
1871
2085
  const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
2086
+ networkpolicy: {
2087
+ 'networking.k8s.io': 'networkpolicies',
2088
+ 'projectcalico.org': 'caliconetworkpolicies',
2089
+ 'crd.projectcalico.org': 'caliconetworkpolicies',
2090
+ },
2091
+ networkpolicies: {
2092
+ 'networking.k8s.io': 'networkpolicies',
2093
+ 'projectcalico.org': 'caliconetworkpolicies',
2094
+ 'crd.projectcalico.org': 'caliconetworkpolicies',
2095
+ },
1872
2096
  clusters: { 'postgresql.cnpg.io': 'cnpgclusters', 'cluster.x-k8s.io': 'capiclusters' },
1873
2097
  // Velero owns the unqualified `backups` column set; CNPG Backups carry a
1874
2098
  // completely different shape (cluster + method, no storage location/expiry).
@@ -1882,8 +2106,81 @@ const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
1882
2106
  certificates: { 'networking.internal.knative.dev': 'knativecertificates' },
1883
2107
  restores: { 'velero.io': 'velerorestores' },
1884
2108
  schedules: { 'velero.io': 'veleroschedules' },
2109
+ // CNPG's declarative objects. Knative owns the unqualified `subscriptions`
2110
+ // set, and `databases` / `publications` are shipped by several database
2111
+ // operators — none of which have a CNPG Cluster to point at.
2112
+ databases: { 'postgresql.cnpg.io': 'cnpgdatabases' },
2113
+ publications: { 'postgresql.cnpg.io': 'cnpgpublications' },
2114
+ subscriptions: {
2115
+ 'postgresql.cnpg.io': 'cnpgsubscriptions',
2116
+ 'messaging.knative.dev': 'knativesubscriptions',
2117
+ },
2118
+ imagecatalogs: { 'postgresql.cnpg.io': 'cnpgimagecatalogs' },
2119
+ clusterimagecatalogs: { 'postgresql.cnpg.io': 'cnpgclusterimagecatalogs' },
2120
+ objectstores: { 'barmancloud.cnpg.io': 'barmanobjectstores' },
2121
+ ippools: {
2122
+ 'projectcalico.org': 'calicoippools',
2123
+ 'crd.projectcalico.org': 'calicoippools',
2124
+ },
2125
+ hostendpoints: {
2126
+ 'projectcalico.org': 'calicohostendpoints',
2127
+ 'crd.projectcalico.org': 'calicohostendpoints',
2128
+ },
2129
+ tiers: {
2130
+ 'projectcalico.org': 'calicotiers',
2131
+ 'crd.projectcalico.org': 'calicotiers',
2132
+ },
2133
+ globalnetworkpolicies: {
2134
+ 'projectcalico.org': 'calicoglobalnetworkpolicies',
2135
+ 'crd.projectcalico.org': 'calicoglobalnetworkpolicies',
2136
+ },
2137
+ globalnetworkpolicy: {
2138
+ 'projectcalico.org': 'calicoglobalnetworkpolicies',
2139
+ 'crd.projectcalico.org': 'calicoglobalnetworkpolicies',
2140
+ },
2141
+ stagednetworkpolicies: {
2142
+ 'projectcalico.org': 'calicostagednetworkpolicies',
2143
+ 'crd.projectcalico.org': 'calicostagednetworkpolicies',
2144
+ },
2145
+ stagednetworkpolicy: {
2146
+ 'projectcalico.org': 'calicostagednetworkpolicies',
2147
+ 'crd.projectcalico.org': 'calicostagednetworkpolicies',
2148
+ },
2149
+ stagedglobalnetworkpolicies: {
2150
+ 'projectcalico.org': 'calicostagedglobalnetworkpolicies',
2151
+ 'crd.projectcalico.org': 'calicostagedglobalnetworkpolicies',
2152
+ },
2153
+ stagedglobalnetworkpolicy: {
2154
+ 'projectcalico.org': 'calicostagedglobalnetworkpolicies',
2155
+ 'crd.projectcalico.org': 'calicostagedglobalnetworkpolicies',
2156
+ },
2157
+ stagedkubernetesnetworkpolicies: {
2158
+ 'projectcalico.org': 'calicostagedkubernetesnetworkpolicies',
2159
+ 'crd.projectcalico.org': 'calicostagedkubernetesnetworkpolicies',
2160
+ },
2161
+ stagedkubernetesnetworkpolicy: {
2162
+ 'projectcalico.org': 'calicostagedkubernetesnetworkpolicies',
2163
+ 'crd.projectcalico.org': 'calicostagedkubernetesnetworkpolicies',
2164
+ },
1885
2165
  }
1886
2166
 
2167
+ const GROUP_QUALIFIED_ONLY_COLUMN_KEYS = new Set([
2168
+ 'networkpolicy', 'networkpolicies',
2169
+ // Other projects serve their own IPPool CRD, so only a Calico group earns the
2170
+ // Calico columns.
2171
+ 'ippool', 'ippools',
2172
+ 'globalnetworkpolicy', 'globalnetworkpolicies',
2173
+ 'stagednetworkpolicy', 'stagednetworkpolicies',
2174
+ 'stagedglobalnetworkpolicy', 'stagedglobalnetworkpolicies',
2175
+ 'stagedkubernetesnetworkpolicy', 'stagedkubernetesnetworkpolicies',
2176
+ ])
2177
+ const CALICO_ONLY_COLUMN_KEYS = new Set([
2178
+ 'globalnetworkpolicy', 'globalnetworkpolicies',
2179
+ 'stagednetworkpolicy', 'stagednetworkpolicies',
2180
+ 'stagedglobalnetworkpolicy', 'stagedglobalnetworkpolicies',
2181
+ 'stagedkubernetesnetworkpolicy', 'stagedkubernetesnetworkpolicies',
2182
+ ])
2183
+
1887
2184
  // Normalize a kind name to its plural API form used in KNOWN_COLUMNS keys.
1888
2185
  // Handles CRD singular names from URLs: 'ScaledObject' → 'scaledobjects', 'NodePool' → 'nodepools'
1889
2186
  // When group is provided, resolves collisions (e.g., 'services' + 'serving.knative.dev' → 'knativeservices')
@@ -1893,6 +2190,8 @@ function normalizeKindToPlural(kind: string, group?: string): string {
1893
2190
  if (group && GROUP_QUALIFIED_COLUMN_KEYS[lower]?.[group]) {
1894
2191
  return GROUP_QUALIFIED_COLUMN_KEYS[lower][group]
1895
2192
  }
2193
+ if (group && GROUP_QUALIFIED_ONLY_COLUMN_KEYS.has(lower)) return `__generic_${lower}`
2194
+ if (CALICO_ONLY_COLUMN_KEYS.has(lower)) return `__generic_${lower}`
1896
2195
  if (KNOWN_COLUMNS[lower]) return lower
1897
2196
  // Try adding 's' but avoid double-s (e.g., "ingress" → "ingresss")
1898
2197
  if (!lower.endsWith('s') && KNOWN_COLUMNS[lower + 's']) return lower + 's'
@@ -4928,6 +5227,11 @@ export function ResourcesView({
4928
5227
  ) : filteredResources.length === 0 ? (
4929
5228
  <div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary">
4930
5229
  <p>No {selectedKind.kind} found</p>
5230
+ {emptyKindNote(selectedKind.kind, selectedKind.group, resources?.length ?? 0) && (
5231
+ <p className="text-xs mt-2 max-w-md text-center text-theme-text-disabled">
5232
+ {emptyKindNote(selectedKind.kind, selectedKind.group, resources?.length ?? 0)}
5233
+ </p>
5234
+ )}
4931
5235
  {searchTerm && (
4932
5236
  <button
4933
5237
  onClick={() => {
@@ -5692,6 +5996,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5692
5996
  return <PVCCell resource={resource} column={column} />
5693
5997
  case 'rollouts':
5694
5998
  return <RolloutCell resource={resource} column={column} />
5999
+ case 'analysisruns':
6000
+ return <AnalysisRunCell resource={resource} column={column} />
5695
6001
  case 'workflows':
5696
6002
  return <WorkflowCell resource={resource} column={column} />
5697
6003
  case 'cronworkflows':
@@ -5731,7 +6037,21 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5731
6037
  case 'clusterworkflowtemplates':
5732
6038
  return <WorkflowTemplateCell resource={resource} column={column} />
5733
6039
  case 'networkpolicies':
6040
+ if (!isCoreNetworkPolicyKind(resource.kind ?? kind, resource.apiVersion, group)) {
6041
+ return <GenericCell resource={resource} column={column} />
6042
+ }
5734
6043
  return <NetworkPolicyCell resource={resource} column={column} />
6044
+ case 'calicoippools':
6045
+ case 'calicohostendpoints':
6046
+ case 'calicotiers':
6047
+ return <CalicoInfraCell resource={resource} column={column} />
6048
+ case 'caliconetworkpolicies':
6049
+ case 'calicoglobalnetworkpolicies':
6050
+ case 'calicostagednetworkpolicies':
6051
+ case 'calicostagedglobalnetworkpolicies':
6052
+ case 'calicostagedkubernetesnetworkpolicies':
6053
+ if (!isCalicoPolicyResource(resource)) return <GenericCell resource={resource} column={column} />
6054
+ return <CalicoPolicyCell resource={resource} column={column} />
5735
6055
  case 'poddisruptionbudgets':
5736
6056
  return <PDBCell resource={resource} column={column} />
5737
6057
  case 'serviceaccounts':
@@ -5848,6 +6168,42 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5848
6168
  case 'clustersecretstores':
5849
6169
  return <ClusterSecretStoreCell resource={resource} column={column} />
5850
6170
  // Velero
6171
+ // The cnpg* keys come from GROUP_QUALIFIED_COLUMN_KEYS; the unqualified
6172
+ // plurals below are reached when the caller had no group to resolve with,
6173
+ // and stay group-guarded so a foreign CRD is not read as CNPG.
6174
+ case 'updaterequests':
6175
+ return <KyvernoUpdateRequestCell resource={resource} column={column} />
6176
+ case 'ephemeralreports':
6177
+ case 'clusterephemeralreports':
6178
+ return <KyvernoEphemeralReportCell resource={resource} column={column} />
6179
+ case 'cnpgdatabases':
6180
+ case 'cnpgpublications':
6181
+ case 'cnpgsubscriptions':
6182
+ return <CNPGDeclarativeCell resource={resource} column={column} />
6183
+ case 'databases':
6184
+ case 'publications':
6185
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
6186
+ return <CNPGDeclarativeCell resource={resource} column={column} />
6187
+ }
6188
+ return <GenericCell resource={resource} column={column} />
6189
+ case 'cnpgimagecatalogs':
6190
+ case 'cnpgclusterimagecatalogs':
6191
+ return <CNPGImageCatalogCell resource={resource} column={column} />
6192
+ case 'imagecatalogs':
6193
+ case 'clusterimagecatalogs':
6194
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
6195
+ return <CNPGImageCatalogCell resource={resource} column={column} />
6196
+ }
6197
+ return <GenericCell resource={resource} column={column} />
6198
+ case 'barmanobjectstores':
6199
+ return <CNPGObjectStoreCell resource={resource} column={column} />
6200
+ case 'objectstores':
6201
+ // Group-guarded: another operator could serve an `objectstores` plural,
6202
+ // and it would have no recovery window to report.
6203
+ if (isApiGroup(resource.apiVersion, 'barmancloud.cnpg.io')) {
6204
+ return <CNPGObjectStoreCell resource={resource} column={column} />
6205
+ }
6206
+ return <GenericCell resource={resource} column={column} />
5851
6207
  case 'cnpgbackups':
5852
6208
  return <CNPGBackupCell resource={resource} column={column} />
5853
6209
  case 'backups':
@@ -5936,8 +6292,19 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5936
6292
  return <ChannelCell resource={resource} column={column} />
5937
6293
  case 'inmemorychannels':
5938
6294
  return <InMemoryChannelCell resource={resource} column={column} />
5939
- case 'subscriptions':
6295
+ case 'knativesubscriptions':
5940
6296
  return <SubscriptionCell resource={resource} column={column} />
6297
+ case 'subscriptions':
6298
+ // Shared plural, and not only by two: CNPG declares one, Knative declares
6299
+ // one, and a third operator's would be read as Knative's — Channel and
6300
+ // Subscriber blank on every row — unless both are matched positively.
6301
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
6302
+ return <CNPGDeclarativeCell resource={resource} column={column} />
6303
+ }
6304
+ if (isApiGroup(resource.apiVersion, 'messaging.knative.dev')) {
6305
+ return <SubscriptionCell resource={resource} column={column} />
6306
+ }
6307
+ return <GenericCell resource={resource} column={column} />
5941
6308
  // Knative Flows
5942
6309
  case 'sequences':
5943
6310
  return <SequenceCell resource={resource} column={column} />
@@ -7240,6 +7607,44 @@ function RolloutCell({ resource, column }: { resource: any; column: string }) {
7240
7607
  }
7241
7608
  }
7242
7609
 
7610
+ function AnalysisRunCell({ resource, column }: { resource: any; column: string }) {
7611
+ switch (column) {
7612
+ case 'status': {
7613
+ const status = getAnalysisRunStatus(resource)
7614
+ return <span className={clsx('badge', status.color)}>{status.text}</span>
7615
+ }
7616
+ case 'trigger': {
7617
+ const trigger = resource.metadata?.labels?.['rollout-type']
7618
+ const step = resource.metadata?.labels?.['step-index']
7619
+ if (!trigger) return <span className="text-sm text-theme-text-tertiary">-</span>
7620
+ return (
7621
+ <span className="text-sm text-theme-text-secondary">
7622
+ {trigger}
7623
+ {step ? ` (step ${step})` : ''}
7624
+ </span>
7625
+ )
7626
+ }
7627
+ case 'metrics': {
7628
+ const results: any[] = resource.status?.metricResults || []
7629
+ if (results.length === 0) return <span className="text-sm text-theme-text-tertiary">-</span>
7630
+ const { total, passing, notPassing } = summarizeAnalysisMetrics(resource)
7631
+ return (
7632
+ <Tooltip
7633
+ content={results
7634
+ .map((m) => `${m.name}: ${m.phase || 'Unknown'}${m.dryRun ? ' (dry-run)' : ''}`)
7635
+ .join('\n')}
7636
+ >
7637
+ <span className={clsx('text-sm', notPassing > 0 ? 'text-amber-400' : 'text-theme-text-secondary')}>
7638
+ {notPassing > 0 ? `${notPassing}/${total} not passing` : `${passing}/${total} passing`}
7639
+ </span>
7640
+ </Tooltip>
7641
+ )
7642
+ }
7643
+ default:
7644
+ return <span className="text-sm text-theme-text-tertiary">-</span>
7645
+ }
7646
+ }
7647
+
7243
7648
  function WorkflowCell({ resource, column }: { resource: any; column: string }) {
7244
7649
  switch (column) {
7245
7650
  case 'status': {