@skyhook-io/k8s-ui 1.18.1 → 1.19.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 (101) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/charts/AreaChart.test.tsx +39 -1
  5. package/src/components/charts/AreaChart.tsx +32 -13
  6. package/src/components/charts/axis.test.ts +17 -1
  7. package/src/components/charts/axis.ts +13 -0
  8. package/src/components/charts/format.test.ts +159 -2
  9. package/src/components/charts/format.ts +118 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  11. package/src/components/compare/ResourceCompareView.tsx +6 -2
  12. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  13. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  14. package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
  15. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  16. package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
  17. package/src/components/gitops/insights/insights-helpers.ts +5 -3
  18. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  19. package/src/components/issues/IssueGuidance.tsx +48 -0
  20. package/src/components/issues/IssuesView.tsx +17 -5
  21. package/src/components/issues/diagnostic.ts +2 -0
  22. package/src/components/issues/index.ts +1 -0
  23. package/src/components/issues/severity.ts +2 -0
  24. package/src/components/issues/types.ts +10 -0
  25. package/src/components/logs/LogCore.tsx +13 -11
  26. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  27. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  28. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  29. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  30. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  31. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  32. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  33. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  34. package/src/components/resources/ResourcesView.tsx +238 -66
  35. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  36. package/src/components/resources/generic-status.ts +15 -0
  37. package/src/components/resources/printer-columns.test.ts +36 -0
  38. package/src/components/resources/printer-columns.ts +12 -1
  39. package/src/components/resources/readiness-gates.test.ts +95 -0
  40. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  41. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  42. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  43. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  45. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  46. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils.ts +80 -2
  56. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  57. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  58. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  59. package/src/components/shared/DetailShell.tsx +14 -3
  60. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  61. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  62. package/src/components/shared/EditableYamlView.tsx +18 -3
  63. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  64. package/src/components/shared/FilterRailPanel.tsx +62 -0
  65. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  66. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  67. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  68. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  69. package/src/components/shared/ManagedByChip.tsx +135 -2
  70. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  71. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  72. package/src/components/shared/SetImageDialog.tsx +3 -0
  73. package/src/components/topology/TopologyControls.tsx +1 -1
  74. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  75. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  77. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  78. package/src/components/ui/PageHeader.tsx +3 -2
  79. package/src/components/ui/SummaryTile.tsx +13 -3
  80. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  81. package/src/components/ui/Tooltip.tsx +11 -2
  82. package/src/components/ui/YamlReview.test.tsx +52 -0
  83. package/src/components/ui/YamlReview.tsx +7 -0
  84. package/src/components/ui/drawer-components.test.tsx +27 -0
  85. package/src/components/ui/drawer-components.tsx +22 -42
  86. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  87. package/src/components/workload/WorkloadView.tsx +57 -34
  88. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  89. package/src/hooks/useKeepInViewport.test.ts +27 -0
  90. package/src/hooks/useKeepInViewport.ts +87 -0
  91. package/src/types/core.ts +54 -1
  92. package/src/types/gitops-insights.ts +8 -0
  93. package/src/utils/api-resources.ts +1 -0
  94. package/src/utils/gitops-owner.ts +136 -8
  95. package/src/utils/gitops-write-guard.test.ts +332 -0
  96. package/src/utils/gitops-write-guard.ts +175 -11
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/read-actions.ts +34 -0
  99. package/src/utils/touch-intent.test.ts +39 -0
  100. package/src/utils/touch-intent.ts +35 -0
  101. package/vitest.config.ts +26 -0
@@ -1,9 +1,11 @@
1
1
  import { useState } from 'react'
2
- import { Info, CheckCircle, XCircle, AlertCircle, FileCode, AlertTriangle, Layers } from 'lucide-react'
2
+ import { Info, CheckCircle, XCircle, AlertCircle, FileCode, AlertTriangle, Layers, Minus } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property } from '../../ui/drawer-components'
5
5
  import { Collapse, CollapseChevron, useDisclosure } from '../../ui/Collapse'
6
6
  import { getGenericResourceStatus } from '../generic-status'
7
+ import { getCrossplanePauseState, getCrossplaneIndependentStatus, getCrossplaneConditionTone } from '../resource-utils-crossplane'
8
+ import { CrossplanePauseNotice } from './CrossplanePauseContext'
7
9
  import type { HealthLevel } from '../resource-utils'
8
10
 
9
11
  interface GenericRendererProps {
@@ -16,6 +18,7 @@ export function GenericRenderer({ data }: GenericRendererProps) {
16
18
  const conditions = status.conditions
17
19
 
18
20
  // Determine health status from conditions or status fields
21
+ const acknowledgedPause = getCrossplanePauseState(data) === 'acknowledged'
19
22
  const healthStatus = getHealthStatus(data)
20
23
 
21
24
  // Extract important fields from spec and status
@@ -32,6 +35,7 @@ export function GenericRenderer({ data }: GenericRendererProps) {
32
35
 
33
36
  return (
34
37
  <>
38
+ {acknowledgedPause && <CrossplanePauseNotice data={data} />}
35
39
  {/* Health Status Banner (if determinable) */}
36
40
  {healthStatus && (
37
41
  <div className={clsx(
@@ -90,7 +94,7 @@ export function GenericRenderer({ data }: GenericRendererProps) {
90
94
 
91
95
  {/* Conditions (common in CRDs) */}
92
96
  {conditions && Array.isArray(conditions) && conditions.length > 0 && (
93
- <GenericConditionsSection conditions={conditions} />
97
+ <GenericConditionsSection conditions={conditions} resource={data} />
94
98
  )}
95
99
 
96
100
  {/* Nested Status Objects */}
@@ -154,8 +158,9 @@ const BANNER_TYPE: Record<HealthLevel, HealthType> = {
154
158
  }
155
159
 
156
160
  function getHealthStatus(resource: any): HealthStatusResult | null {
157
- const derived = getGenericResourceStatus(resource)
158
- if (!derived) return null
161
+ const acknowledgedPause = getCrossplanePauseState(resource) === 'acknowledged'
162
+ const derived = acknowledgedPause ? getCrossplaneIndependentStatus(resource) : getGenericResourceStatus(resource)
163
+ if (!derived || (acknowledgedPause && derived.tone === 'healthy')) return null
159
164
  return { type: BANNER_TYPE[derived.tone], label: derived.text, message: derived.reason }
160
165
  }
161
166
 
@@ -323,11 +328,11 @@ function NestedObjectViewer({ name, value, depth = 0 }: NestedObjectViewerProps)
323
328
  // CONDITIONS SECTION
324
329
  // ============================================================================
325
330
 
326
- function GenericConditionsSection({ conditions }: { conditions: any[] }) {
331
+ function GenericConditionsSection({ conditions, resource }: { conditions: any[]; resource: any }) {
327
332
  // Sort conditions: problematic first, then alphabetically
328
333
  const sortedConditions = [...conditions].sort((a, b) => {
329
- const aProblematic = a.status === 'False' || (a.status === 'True' && ['Degraded', 'Warning'].includes(a.type))
330
- const bProblematic = b.status === 'False' || (b.status === 'True' && ['Degraded', 'Warning'].includes(b.type))
334
+ const aProblematic = getCrossplaneConditionTone(resource, a) !== 'neutral' && (a.status === 'False' || (a.status === 'True' && ['Degraded', 'Warning'].includes(a.type)))
335
+ const bProblematic = getCrossplaneConditionTone(resource, b) !== 'neutral' && (b.status === 'False' || (b.status === 'True' && ['Degraded', 'Warning'].includes(b.type)))
331
336
  if (aProblematic && !bProblematic) return -1
332
337
  if (!aProblematic && bProblematic) return 1
333
338
  return (a.type || '').localeCompare(b.type || '')
@@ -335,13 +340,14 @@ function GenericConditionsSection({ conditions }: { conditions: any[] }) {
335
340
 
336
341
  // Check for any problems
337
342
  const hasProblems = sortedConditions.some(c =>
338
- c.status === 'False' || (c.status === 'True' && ['Degraded', 'Warning'].includes(c.type))
343
+ getCrossplaneConditionTone(resource, c) !== 'neutral' && (c.status === 'False' || (c.status === 'True' && ['Degraded', 'Warning'].includes(c.type)))
339
344
  )
340
345
 
341
346
  return (
342
347
  <Section title={`Conditions (${conditions.length})`} defaultExpanded={hasProblems || conditions.length <= 5}>
343
348
  <div className="space-y-2">
344
349
  {sortedConditions.map((cond: any, index: number) => {
350
+ const isNeutral = getCrossplaneConditionTone(resource, cond) === 'neutral'
345
351
  const isTrue = cond.status === 'True'
346
352
  const isFalse = cond.status === 'False'
347
353
  const isWarningType = ['Degraded', 'Warning', 'ScalingLimited'].includes(cond.type)
@@ -349,7 +355,8 @@ function GenericConditionsSection({ conditions }: { conditions: any[] }) {
349
355
 
350
356
  // Determine icon color based on condition semantics
351
357
  let iconColor = 'text-yellow-400'
352
- if (isTrue && isPositiveType) iconColor = 'text-green-400'
358
+ if (isNeutral) iconColor = 'text-theme-text-tertiary'
359
+ else if (isTrue && isPositiveType) iconColor = 'text-green-400'
353
360
  else if (isTrue && isWarningType) iconColor = 'text-yellow-400'
354
361
  else if (isTrue) iconColor = 'text-green-400'
355
362
  else if (isFalse && isPositiveType) iconColor = 'text-red-400'
@@ -358,7 +365,8 @@ function GenericConditionsSection({ conditions }: { conditions: any[] }) {
358
365
  return (
359
366
  <div key={`${cond.type}-${index}`} className="flex items-start gap-2 text-sm">
360
367
  <span className={clsx('w-5 h-5 rounded-full flex items-center justify-center shrink-0 mt-0.5', iconColor)}>
361
- {isTrue && isPositiveType ? <CheckCircle className="w-4 h-4" /> :
368
+ {isNeutral ? <Minus className="w-4 h-4" /> :
369
+ isTrue && isPositiveType ? <CheckCircle className="w-4 h-4" /> :
362
370
  isTrue && isWarningType ? <AlertTriangle className="w-4 h-4" /> :
363
371
  isTrue ? <CheckCircle className="w-4 h-4" /> :
364
372
  isFalse && isPositiveType ? <XCircle className="w-4 h-4" /> :
@@ -370,7 +378,7 @@ function GenericConditionsSection({ conditions }: { conditions: any[] }) {
370
378
  <span className="text-theme-text-primary font-medium">{cond.type}</span>
371
379
  <span className={clsx(
372
380
  'badge-sm',
373
- isTrue ? 'bg-theme-elevated text-theme-text-secondary' : 'status-red'
381
+ isTrue || isNeutral ? 'bg-theme-elevated text-theme-text-secondary' : 'status-red'
374
382
  )}>
375
383
  {cond.status}
376
384
  </span>
@@ -1,4 +1,4 @@
1
- import { Box, Settings, Cloud, ScrollText, Pause, Layers } from 'lucide-react'
1
+ import { Box, Settings, Cloud, ScrollText, Layers } from 'lucide-react'
2
2
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink, useOperationalIssuesShown} from '../../ui/drawer-components'
3
3
  import { CodeViewer } from '../../ui/CodeViewer'
4
4
  import {
@@ -8,9 +8,10 @@ import {
8
8
  getExternalName,
9
9
  getManagementPolicies,
10
10
  getDeletionPolicy,
11
- isCrossplanePaused,
11
+ getCrossplaneConditionTone,
12
12
  getComposingXRRef,
13
13
  } from '../resource-utils-crossplane'
14
+ import { CrossplanePauseNotice } from './CrossplanePauseContext'
14
15
  import { kindToPluralWithGroup } from '../../../utils/navigation'
15
16
 
16
17
  interface ManagedResourceRendererProps {
@@ -34,7 +35,6 @@ export function ManagedResourceRenderer({ data, onNavigate }: ManagedResourceRen
34
35
  const deletionPolicy = getDeletionPolicy(data)
35
36
  const apiGroup = extractApiGroup(data?.apiVersion)
36
37
  const namespace = data?.metadata?.namespace || ''
37
- const paused = isCrossplanePaused(data)
38
38
  const composingXR = getComposingXRRef(data)
39
39
 
40
40
  const forProvider = data?.spec?.forProvider
@@ -45,14 +45,7 @@ export function ManagedResourceRenderer({ data, onNavigate }: ManagedResourceRen
45
45
 
46
46
  return (
47
47
  <>
48
- {paused && (
49
- <AlertBanner
50
- variant="warning"
51
- icon={Pause}
52
- title="Reconciliation paused"
53
- message="The crossplane.io/paused annotation is set. The provider will not reconcile this resource until the annotation is removed."
54
- />
55
- )}
48
+ <CrossplanePauseNotice data={data} />
56
49
 
57
50
  {alertVariant && statusReason && !operationalIssuesShown && (
58
51
  <AlertBanner
@@ -126,7 +119,7 @@ export function ManagedResourceRenderer({ data, onNavigate }: ManagedResourceRen
126
119
  </Section>
127
120
  )}
128
121
 
129
- <ConditionsSection conditions={data?.status?.conditions} />
122
+ <ConditionsSection conditions={data?.status?.conditions} getConditionTone={condition => getCrossplaneConditionTone(data, condition)} />
130
123
  </>
131
124
  )
132
125
  }
@@ -0,0 +1,73 @@
1
+ import { useMemo, useState } from 'react'
2
+ import { Badge } from '../../ui/Badge'
3
+ import { healthToSeverity } from '../../../utils/badge-colors'
4
+ import { Section, PropertyList, Property, ConditionsSection } from '../../ui/drawer-components'
5
+ import { getCollectorPipelines, getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from '../resource-utils-opentelemetry'
6
+
7
+ export function OpenTelemetryCollectorRenderer({ data }: { data: any }) {
8
+ const state = getOpenTelemetryCollectorStatus(data)
9
+ const preview = useMemo(() => getCollectorPipelines(data), [data])
10
+ const [showAll, setShowAll] = useState(false)
11
+ const ready = data.status?.conditions?.find((c: any) => c.type === 'Ready')
12
+ const components = (names: string[], separator = ', ') => names.slice(0, 10).map(name => `${name}${preview.connectors.includes(name) ? ' (connector)' : ''}`).join(separator) + (names.length > 10 ? `; ${names.length - 10} more in YAML` : '') || 'None'
13
+ return <>
14
+ <Section title="Collector" defaultExpanded>
15
+ <PropertyList>
16
+ <Property label="Reconciliation" value={<Badge severity={healthToSeverity(state.level)}>{state.text}</Badge>} />
17
+ <Property label="Mode" value={data.spec?.mode} />
18
+ <Property label="Management" value={data.spec?.managementState} />
19
+ <Property label={data.spec?.mode === 'daemonset' ? 'Reported ready / scheduled Pods' : 'Reported ready / observed Pods'} value={data.status?.scale?.statusReplicas} />
20
+ <Property label="Version" value={data.status?.version} />
21
+ {data.status?.image && <Property label="Image" value={<span className="break-all">{data.status.image}</span>} />}
22
+ </PropertyList>
23
+ <p className="mt-2 text-xs text-theme-text-tertiary">Reconciled means the operator applied its configuration. Pod counts are its last reported snapshot. Inspect owned workloads for current readiness; neither proves telemetry delivery.</p>
24
+ {state.text === 'Status stale' && <p className="mt-2 text-sm text-theme-text-secondary">The reported reconciliation describes an earlier configuration generation.</p>}
25
+ {ready?.message && <p className="mt-2 whitespace-pre-wrap break-words text-sm text-theme-text-secondary">{ready.message}</p>}
26
+ </Section>
27
+ <Section title={preview.error ? 'Configured Pipelines' : `Configured Pipelines (${preview.pipelines.length})`} defaultExpanded>
28
+ {preview.error ? <p className="text-sm text-theme-text-secondary">{preview.error}</p> : preview.pipelines.length === 0 ? <p className="text-sm text-theme-text-tertiary">No pipelines are declared in this configuration.</p> : <>
29
+ <div className="space-y-3">
30
+ {(showAll ? preview.pipelines : preview.pipelines.slice(0, 5)).map(pipeline => <div key={pipeline.name} className="card-inner min-w-0 break-words">
31
+ <div className="mb-2 text-sm font-medium text-theme-text-primary">{pipeline.name}</div>
32
+ <PropertyList>
33
+ <Property label="Receivers" value={components(pipeline.receivers)} />
34
+ <Property label="Processors" value={components(pipeline.processors, ' → ')} />
35
+ <Property label="Exporters" value={components(pipeline.exporters)} />
36
+ </PropertyList>
37
+ </div>)}
38
+ </div>
39
+ {preview.pipelines.length > 5 && <button type="button" className="mt-2 text-sm text-accent-text hover:underline" onClick={() => setShowAll(!showAll)}>{showAll ? 'Show fewer pipelines' : `Show all ${preview.pipelines.length} pipelines`}</button>}
40
+ <p className="mt-2 text-xs text-theme-text-tertiary">Declared component names, not observed data flow. Connectors pass data between pipelines within the collector.</p>
41
+ </>}
42
+ </Section>
43
+ <ConditionsSection conditions={data.status?.conditions} getConditionTone={condition => ['Status stale', 'Unmanaged'].includes(state.text) ? 'unknown' : condition.type === 'Ready' && condition.status === 'False' ? condition.reason === 'StatusUpdateFailed' ? 'unknown' : 'fail' : 'neutral'} />
44
+ </>
45
+ }
46
+
47
+ export function OpenTelemetryInstrumentationRenderer({ data }: { data: any }) {
48
+ const spec = data.spec ?? {}
49
+ const state = getOpenTelemetryInstrumentationStatus(data)
50
+ const blocked = Object.keys(data.status?.upgradeBlockedVersions ?? {})
51
+ const runtimeImages = ['java', 'nodejs', 'python', 'dotnet', 'go', 'apacheHttpd', 'nginx', 'php', 'ruby'].filter(language => spec[language]?.image)
52
+ return <>
53
+ <Section title="Auto-instrumentation Configuration" defaultExpanded>
54
+ <PropertyList>
55
+ <Property label="Configuration" value={<Badge severity={healthToSeverity(state.level)}>{state.text}</Badge>} />
56
+ {spec.exporter?.endpoint && <Property label="OTLP endpoint" value={<span className="break-all">{spec.exporter.endpoint}</span>} />}
57
+ <Property label="Propagators" value={spec.propagators?.join(', ')} />
58
+ <Property label="Sampler" value={spec.sampler?.type} />
59
+ <Property label="Sampler argument" value={spec.sampler?.argument} />
60
+ {blocked.length > 0 && <Property label="Blocked image upgrades" value={blocked.join(', ')} />}
61
+ </PropertyList>
62
+ <p className="mt-2 text-xs text-theme-text-tertiary">These settings configure injection into annotated workloads. This resource does not report which Pods were injected or whether telemetry reaches the endpoint.</p>
63
+ {blocked.length > 0 && <p className="mt-2 text-sm text-theme-text-secondary">The operator could not automatically upgrade the listed runtime images. Inspect status in YAML and the operator logs before choosing a supported image.</p>}
64
+ </Section>
65
+ {blocked.length > 0 && <Section title="Blocked Image Upgrades" defaultExpanded>
66
+ <PropertyList>{blocked.map(language => <Property key={language} label={language} value={<span className="whitespace-pre-wrap break-words">{String(data.status.upgradeBlockedVersions[language]).slice(0, 1000)}{String(data.status.upgradeBlockedVersions[language]).length > 1000 ? '… Inspect YAML for the full explanation.' : ''}</span>} />)}</PropertyList>
67
+ </Section>}
68
+ {/* Runtime image defaults are secondary to endpoint/sampler and upgrade failures. */}
69
+ {runtimeImages.length > 0 && <Section title="Runtime Images" defaultExpanded={false}><PropertyList>
70
+ {runtimeImages.map(language => <Property key={language} label={language} value={<span className="break-all">{spec[language].image}</span>} />)}
71
+ </PropertyList></Section>}
72
+ </>
73
+ }
@@ -21,7 +21,7 @@ import {
21
21
  getXRDKind,
22
22
  } from '../resource-utils-crossplane'
23
23
 
24
- function StatusBadge({ resource, getStatus }: { resource: any; getStatus: (r: any) => { text: string; color: string } }) {
24
+ export function CrossplaneStatusCell({ resource, getStatus = getCrossplaneStatus }: { resource: any; getStatus?: (r: any) => { text: string; color: string } }) {
25
25
  const status = getStatus(resource)
26
26
  const reason = getCrossplaneStatusReason(resource)
27
27
  const badge = (
@@ -35,7 +35,7 @@ function StatusBadge({ resource, getStatus }: { resource: any; getStatus: (r: an
35
35
  export function ManagedResourceCell({ resource, column }: { resource: any; column: string }) {
36
36
  switch (column) {
37
37
  case 'status':
38
- return <StatusBadge resource={resource} getStatus={getCrossplaneStatus} />
38
+ return <CrossplaneStatusCell resource={resource} getStatus={getCrossplaneStatus} />
39
39
  case 'external': {
40
40
  const name = getExternalName(resource)
41
41
  if (!name) return <span className="text-sm text-theme-text-tertiary">-</span>
@@ -60,7 +60,7 @@ export function ManagedResourceCell({ resource, column }: { resource: any; colum
60
60
  export function CompositeResourceCell({ resource, column }: { resource: any; column: string }) {
61
61
  switch (column) {
62
62
  case 'status':
63
- return <StatusBadge resource={resource} getStatus={getCrossplaneStatus} />
63
+ return <CrossplaneStatusCell resource={resource} getStatus={getCrossplaneStatus} />
64
64
  case 'composition': {
65
65
  const ref = getCompositionRef(resource)
66
66
  if (!ref) return <span className="text-sm text-theme-text-tertiary">-</span>
@@ -82,7 +82,7 @@ export function CompositeResourceCell({ resource, column }: { resource: any; col
82
82
  export function CrossplaneProviderCell({ resource, column }: { resource: any; column: string }) {
83
83
  switch (column) {
84
84
  case 'status':
85
- return <StatusBadge resource={resource} getStatus={getProviderStatus} />
85
+ return <CrossplaneStatusCell resource={resource} getStatus={getProviderStatus} />
86
86
  case 'package': {
87
87
  const pkg = getProviderPackage(resource)
88
88
  return (
@@ -108,7 +108,7 @@ export function CrossplaneProviderCell({ resource, column }: { resource: any; co
108
108
  export function CrossplaneProviderConfigCell({ resource, column }: { resource: any; column: string }) {
109
109
  switch (column) {
110
110
  case 'status':
111
- return <StatusBadge resource={resource} getStatus={getProviderConfigStatus} />
111
+ return <CrossplaneStatusCell resource={resource} getStatus={getProviderConfigStatus} />
112
112
  case 'credentials':
113
113
  return <span className="text-sm text-theme-text-secondary">{getProviderConfigCredentialsSource(resource)}</span>
114
114
  default:
@@ -192,3 +192,6 @@ export { RayServiceRenderer } from './RayServiceRenderer'
192
192
  export { RayClusterRenderer } from './RayClusterRenderer'
193
193
 
194
194
  export { RayJobRenderer } from './RayJobRenderer'
195
+
196
+ export { OpenTelemetryCollectorRenderer, OpenTelemetryInstrumentationRenderer } from './OpenTelemetryRenderers'
197
+ export { FlaggerCanaryRenderer, FlaggerMetricTemplateRenderer } from './FlaggerRenderers'
@@ -675,3 +675,48 @@ describe('isCrossplanePaused', () => {
675
675
 
676
676
  // Pluralization moved to the shared kindToPlural in utils/navigation.ts
677
677
  // (discovery-aware, handles provider-defined irregular plurals).
678
+
679
+ describe('Crossplane pause and independent status evidence', () => {
680
+ function paused(conditions: any[], annotation = 'true') {
681
+ return {
682
+ metadata: { generation: 9, annotations: { 'crossplane.io/paused': annotation } },
683
+ spec: { providerConfigRef: { name: 'default' } },
684
+ status: { conditions },
685
+ }
686
+ }
687
+ const pause = { type: 'Synced', status: 'False', reason: 'ReconcilePaused', message: 'pause observation', observedGeneration: 1 }
688
+
689
+ it('keeps acknowledged pause neutral with recorded availability despite generation changes', () => {
690
+ const resource = paused([{ type: 'Ready', status: 'True' }, pause])
691
+ expect(getCrossplaneStatus(resource)).toMatchObject({ text: 'Ready (reported)', level: 'neutral' })
692
+ expect(getCrossplaneStatusReason(resource)).toBeNull()
693
+ })
694
+
695
+ it.each([undefined, 'Unknown'])('does not invent provisioning or readiness when acknowledged pause has Ready=%s', status => {
696
+ const resource = paused([...(status ? [{ type: 'Ready', status }] : []), pause])
697
+ expect(getCrossplaneStatus(resource)).toMatchObject({ text: 'Readiness unknown', level: 'neutral' })
698
+ })
699
+
700
+ it('preserves the independent Ready failure and its message over the pause observation', () => {
701
+ const resource = paused([pause, { type: 'Ready', status: 'False', reason: 'Unavailable', message: 'external API access denied' }])
702
+ expect(getCrossplaneStatus(resource)).toMatchObject({ text: 'Unavailable', level: 'unhealthy' })
703
+ expect(getCrossplaneStatusReason(resource)).toBe('external API access denied')
704
+ })
705
+
706
+ it('does not mask a reconcile error with requested pause', () => {
707
+ const resource = paused([{ type: 'Ready', status: 'True' }, { ...pause, reason: 'ReconcileError', message: 'credentials rejected' }])
708
+ expect(getCrossplaneStatus(resource)).toMatchObject({ text: 'Out of sync', level: 'alert' })
709
+ expect(getCrossplaneStatusReason(resource)).toBe('credentials rejected')
710
+ })
711
+
712
+ it('keeps uncorroborated controller pause as an alert without inventing a failed resume', () => {
713
+ const resource = paused([pause], 'false')
714
+ expect(getCrossplaneStatus(resource)).toMatchObject({ text: 'ReconcilePaused', level: 'alert' })
715
+ expect(getCrossplaneStatusReason(resource)).toBe('pause observation')
716
+ })
717
+
718
+ it('preserves terminating over pause and recorded readiness', () => {
719
+ const resource = paused([{ type: 'Ready', status: 'True' }, pause])
720
+ expect(getCrossplaneStatus({ ...resource, metadata: { ...resource.metadata, deletionTimestamp: '2026-10-26T08:00:00Z' } })).toMatchObject({ text: 'Terminating', level: 'degraded' })
721
+ })
722
+ })
@@ -7,6 +7,8 @@
7
7
 
8
8
  import type { StatusBadge } from './resource-utils'
9
9
  import { healthColors } from './resource-utils'
10
+ import { getGenericResourceStatus, type GenericStatus } from './generic-status'
11
+ import type { ConditionTone } from '../ui/drawer-components'
10
12
 
11
13
  interface CrossplaneCondition {
12
14
  type: string
@@ -138,15 +140,37 @@ export function getDeletionPolicy(data: any): string | null {
138
140
  return data?.spec?.crossplane?.deletionPolicy ?? data?.spec?.deletionPolicy ?? null
139
141
  }
140
142
 
141
- /**
142
- * Crossplane convention: setting `crossplane.io/paused: "true"` on any MR/XR/Claim
143
- * stops the provider from reconciling it. Universal across every provider, so
144
- * surfacing it as a chip on every Crossplane resource is high-leverage.
145
- */
146
143
  export function isCrossplanePaused(data: any): boolean {
147
144
  return data?.metadata?.annotations?.['crossplane.io/paused'] === 'true'
148
145
  }
149
146
 
147
+ export function isExpectedCrossplanePauseCondition(data: any, condition: CrossplaneCondition): boolean {
148
+ return isCrossplanePaused(data) && condition?.type === 'Synced'
149
+ && condition.status === 'False' && condition.reason === 'ReconcilePaused'
150
+ }
151
+
152
+ export function getCrossplanePauseState(data: any): 'requested' | 'acknowledged' | null {
153
+ if (!isCrossplanePaused(data)) return null
154
+ const conditions = data?.status?.conditions
155
+ return Array.isArray(conditions) && conditions.some((condition: CrossplaneCondition) => isExpectedCrossplanePauseCondition(data, condition))
156
+ ? 'acknowledged' : 'requested'
157
+ }
158
+
159
+ export function getCrossplaneIndependentStatus(data: any): GenericStatus | null {
160
+ if (getCrossplanePauseState(data) !== 'acknowledged') return getGenericResourceStatus(data)
161
+ return getGenericResourceStatus({
162
+ ...data,
163
+ status: {
164
+ ...data.status,
165
+ conditions: data.status.conditions.filter((condition: CrossplaneCondition) => !isExpectedCrossplanePauseCondition(data, condition)),
166
+ },
167
+ })
168
+ }
169
+
170
+ export function getCrossplaneConditionTone(data: any, condition: CrossplaneCondition): ConditionTone | undefined {
171
+ return isExpectedCrossplanePauseCondition(data, condition) ? 'neutral' : undefined
172
+ }
173
+
150
174
  /**
151
175
  * Walk ownerReferences to find the parent Composite for a Managed Resource.
152
176
  * Crossplane sets exactly one ownerRef pointing at the composing XR. Returns
@@ -186,16 +210,24 @@ export function getCrossplaneStatus(data: any): StatusBadge {
186
210
  }
187
211
  const ready = findCondition(data, 'Ready')
188
212
  const synced = findCondition(data, 'Synced')
213
+ const independent = !isManagedResource(data) && !isComposite(data) && getCrossplanePauseState(data) === 'acknowledged'
214
+ ? getCrossplaneIndependentStatus(data) : null
215
+ if (independent && (independent.tone === 'unhealthy' || independent.tone === 'alert' || independent.tone === 'degraded')) {
216
+ return { text: independent.text, color: healthColors[independent.tone], level: independent.tone }
217
+ }
189
218
 
190
219
  if (!ready && !synced) {
191
220
  return { text: 'Pending', color: healthColors.neutral, level: 'neutral' }
192
221
  }
193
- if (synced?.status === 'False') {
194
- return { text: 'Out of sync', color: healthColors.alert, level: 'alert' }
222
+ if (synced?.status === 'False' && !isExpectedCrossplanePauseCondition(data, synced)) {
223
+ return { text: synced.reason === 'ReconcilePaused' ? 'ReconcilePaused' : 'Out of sync', color: healthColors.alert, level: 'alert' }
195
224
  }
196
225
  if (ready?.status === 'False') {
197
226
  return { text: ready.reason || 'Not ready', color: healthColors.unhealthy, level: 'unhealthy' }
198
227
  }
228
+ if (getCrossplanePauseState(data) === 'acknowledged') {
229
+ return { text: ready?.status === 'True' ? 'Ready (reported)' : 'Readiness unknown', color: healthColors.neutral, level: 'neutral' }
230
+ }
199
231
  if (ready?.status === 'True' && synced?.status === 'True') {
200
232
  return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
201
233
  }
@@ -211,8 +243,12 @@ export function getCrossplaneStatus(data: any): StatusBadge {
211
243
  * detail views.
212
244
  */
213
245
  export function getCrossplaneStatusReason(data: any): string | null {
246
+ const independent = !isManagedResource(data) && !isComposite(data) && getCrossplanePauseState(data) === 'acknowledged'
247
+ ? getCrossplaneIndependentStatus(data) : null
248
+ if (independent && (independent.tone === 'unhealthy' || independent.tone === 'alert' || independent.tone === 'degraded')) return independent.reason ?? null
214
249
  const ready = findCondition(data, 'Ready')
215
- const synced = findCondition(data, 'Synced')
250
+ const reportedSynced = findCondition(data, 'Synced')
251
+ const synced = reportedSynced && !isExpectedCrossplanePauseCondition(data, reportedSynced) ? reportedSynced : undefined
216
252
  if (synced && synced.status !== 'True' && synced.message) return synced.message
217
253
  if (ready && ready.status !== 'True' && ready.message) return ready.message
218
254
  if (synced && synced.status !== 'True' && synced.reason) return synced.reason
@@ -0,0 +1,82 @@
1
+ import { issueTitle } from '../issues/severity'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { readFileSync } from 'node:fs'
4
+ import { renderToString } from 'react-dom/server'
5
+ import { getFlaggerCanaryStatus, getFlaggerStrategy, getFlaggerTargetRef, getFlaggerAutoscalerRef, getFlaggerMetricTemplateRef } from './resource-utils-flagger'
6
+ import { getGenericResourceStatus } from './generic-status'
7
+ import { getCellFilterValue } from './resource-utils'
8
+ import { ResourceRendererDispatch, getResourceStatus } from '../shared/ResourceRendererDispatch'
9
+
10
+ const vectors = JSON.parse(readFileSync(new URL('../../../../../pkg/health/testdata/flagger.json', import.meta.url), 'utf8'))
11
+ describe('Flagger recorded rollout state across surfaces', () => {
12
+ it('names the failed operation in Issues', () => {
13
+ expect(issueTitle({ category: 'operator_condition_fail', reason: 'FlaggerCanaryFailed' })).toBe('Canary rollout failed')
14
+ })
15
+ for (const vector of vectors) it(vector.name, () => {
16
+ expect(getFlaggerCanaryStatus(vector.object)).toMatchObject({ text: vector.text, level: vector.level })
17
+ expect(getGenericResourceStatus(vector.object)).toMatchObject({ text: vector.text, tone: vector.level })
18
+ expect(getResourceStatus('canaries', vector.object)).toMatchObject({ text: vector.text, level: vector.level })
19
+ expect(getCellFilterValue(vector.object, 'status', 'canaries')).toBe(vector.text)
20
+ })
21
+ })
22
+
23
+ describe('Flagger controller reference semantics', () => {
24
+ const resource = (spec: any) => ({ metadata: { namespace: 'application' }, spec })
25
+ it('resolves native targets with the controller factory, including the Knative Service collision', () => {
26
+ expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'Deployment', apiVersion: 'foreign.example/v1', name: 'app' } }))).toMatchObject({ group: 'apps', name: 'app', namespace: 'application' })
27
+ expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'Service', apiVersion: 'serving.knative.dev/v1', name: 'app' } }))).toMatchObject({ group: 'serving.knative.dev' })
28
+ expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'Service', apiVersion: 'other.example/v1', name: 'app' } }))).toMatchObject({ group: '' })
29
+ expect(getFlaggerTargetRef(resource({ targetRef: { kind: 'FutureKind', name: 'app' } }))).toBeNull()
30
+ })
31
+ it('links exact supported scalers and always uses the typed MetricTemplate client', () => {
32
+ expect(getFlaggerAutoscalerRef(resource({ autoscalerRef: { kind: 'HorizontalPodAutoscaler', name: 'app' } }))).toMatchObject({ group: 'autoscaling', namespace: 'application' })
33
+ expect(getFlaggerAutoscalerRef(resource({ autoscalerRef: { kind: 'ScaledObject', name: 'app' } }))).toMatchObject({ group: 'keda.sh' })
34
+ expect(getFlaggerMetricTemplateRef(resource({}), { templateRef: { apiVersion: 'ignored.example/v1', kind: 'Ignored', name: 'latency', namespace: 'monitoring' } })).toMatchObject({ group: 'flagger.app', name: 'latency', namespace: 'monitoring' })
35
+ expect(getFlaggerMetricTemplateRef(resource({}), { templateRef: { name: 'latency' } })).toMatchObject({ namespace: 'application' })
36
+ expect(getFlaggerMetricTemplateRef(resource({}), { name: 'request-success-rate' })).toBeNull()
37
+ })
38
+ it('distinguishes strategies, including the actually served deprecated analysis field', () => {
39
+ expect(getFlaggerStrategy(resource({ analysis: { iterations: 3 } }))).toBe('Blue/green')
40
+ expect(getFlaggerStrategy(resource({ analysis: { iterations: 3, match: [{}] } }))).toBe('A/B testing')
41
+ expect(getFlaggerStrategy(resource({ canaryAnalysis: { iterations: 3, mirror: true } }))).toBe('Blue/green with mirroring')
42
+ expect(getFlaggerStrategy(resource({ analysis: { stepWeight: 10 } }))).toBe('Canary')
43
+ })
44
+ })
45
+
46
+ describe('Flagger drawer identity and useful states', () => {
47
+ function render(apiVersion: string, kind: string, spec = {}, status = {}) {
48
+ return renderToString(<ResourceRendererDispatch resource={{ kind: kind === 'Canary' ? 'canaries' : 'metrictemplates', namespace: 'test', name: 'test' }} data={{ apiVersion, kind, metadata: { name: 'test', namespace: 'test' }, spec, status }} onCopy={() => {}} copied={null} showCommonSections={false} />)
49
+ }
50
+ it('preserves zero reported weight, failure under suspension, and keeps built-in metrics distinct', () => {
51
+ const html = render('flagger.app/v1beta1', 'Canary', { suspend: true, analysis: { stepWeight: 10, metrics: [{ name: 'request-success-rate' }, { name: 'custom', query: 'sensitive-inline-query' }] } }, { phase: 'Failed', canaryWeight: 0 })
52
+ expect(html).toContain('0%')
53
+ expect(html).toContain('Failed')
54
+ expect(html).toContain('Built-in provider metric')
55
+ expect(html).toContain('Inline query')
56
+ expect(html).toContain('sensitive-inline-query')
57
+ })
58
+ it('does not show weight for blue/green or provider headers in native details', () => {
59
+ expect(render('flagger.app/v1beta1', 'Canary', { analysis: { iterations: 2 } }, { canaryWeight: 10 })).not.toContain('Reported canary weight')
60
+ expect(render('flagger.app/v1beta1', 'MetricTemplate', { provider: { type: 'prometheus', headers: { 'X-Key': 'private-value' } }, query: 'vector(1)' })).not.toContain('private-value')
61
+ })
62
+ it('shows the controller-default rollout hook when type is omitted', () => {
63
+ const html = render('flagger.app/v1beta1', 'Canary', { analysis: { webhooks: [{ name: 'load-test' }, { name: 'approve', type: 'confirm-rollout' }] } }, { phase: 'Waiting' })
64
+ expect(html).toContain('load-test')
65
+ expect(html).toContain('rollout')
66
+ expect(html).toContain('approve')
67
+ expect(html).toContain('confirm-rollout')
68
+ })
69
+ it('describes an active confirmation wait only while not suspended', () => {
70
+ const analysis = { webhooks: [{ name: 'approve', type: 'confirm-rollout' }] }
71
+ expect(render('flagger.app/v1beta1', 'Canary', { analysis }, { phase: 'Waiting' })).toContain('Flagger is waiting for a confirmation hook')
72
+ const suspended = render('flagger.app/v1beta1', 'Canary', { suspend: true, analysis }, { phase: 'Waiting' })
73
+ expect(suspended).not.toContain('Flagger is waiting for a confirmation hook')
74
+ expect(suspended).toContain('approve')
75
+ expect(suspended).toContain('Recorded phase')
76
+ })
77
+ for (const kind of ['Canary', 'MetricTemplate']) it(`keeps foreign ${kind} generic`, () => {
78
+ const html = render('other.example/v1', kind, { custom: 'generic-visible' })
79
+ expect(html).toContain('generic-visible')
80
+ expect(html).not.toContain(kind === 'Canary' ? 'Canary Rollout' : 'Metric Provider')
81
+ })
82
+ })
@@ -0,0 +1,76 @@
1
+ import { isApiGroup } from './resource-utils-cnpg'
2
+ import type { ResourceRef } from '../../types'
3
+ import { objectReferenceToResourceRef } from '../../utils/navigation'
4
+ import type { StatusBadge } from './resource-utils'
5
+ import { healthColors } from './resource-utils'
6
+
7
+ export function getFlaggerCanaryStatus(data: any): StatusBadge {
8
+ const phase = data.status?.phase
9
+ let text = phase || 'Not reported'
10
+ let level: StatusBadge['level'] = 'unknown'
11
+ if (phase === 'Failed') level = 'unhealthy'
12
+ else if (!phase && data.spec?.suspend === true) { text = 'Suspend requested'; level = 'neutral' }
13
+ else if (phase === 'Succeeded') level = data.spec?.suspend === true ? 'neutral' : 'healthy'
14
+ else if (['Initializing', 'Initialized', 'Waiting', 'Progressing', 'WaitingPromotion', 'Promoting', 'Finalising', 'Terminating', 'Terminated'].includes(phase)) level = 'neutral'
15
+ return { text, level, color: healthColors[level] }
16
+ }
17
+
18
+ // Both fields exist in the served v1beta1 schema. The controller's GetAnalysis
19
+ // explicitly prefers analysis and still accepts deprecated canaryAnalysis.
20
+ export function getFlaggerAnalysis(data: any): any {
21
+ return data.spec?.analysis ?? data.spec?.canaryAnalysis ?? {}
22
+ }
23
+
24
+ export function getFlaggerStrategy(data: any): string {
25
+ const analysis = getFlaggerAnalysis(data)
26
+ if (analysis.iterations > 0) {
27
+ if (analysis.match?.length > 0) return 'A/B testing'
28
+ return analysis.mirror ? 'Blue/green with mirroring' : 'Blue/green'
29
+ }
30
+ return analysis.mirror ? 'Canary with mirroring' : 'Canary'
31
+ }
32
+
33
+ // Match the pinned controller factory: it chooses its native clients by kind,
34
+ // not arbitrary targetRef.apiVersion (except the exact Knative Service case).
35
+ export function getFlaggerTargetRef(data: any): ResourceRef | null {
36
+ const target = data.spec?.targetRef
37
+ if (!target?.name) return null
38
+ let apiVersion: string
39
+ if (target.kind === 'Deployment' || target.kind === 'DaemonSet') apiVersion = 'apps/v1'
40
+ else if (target.kind === 'Service') apiVersion = target.apiVersion === 'serving.knative.dev/v1' ? target.apiVersion : 'v1'
41
+ else return null
42
+ return objectReferenceToResourceRef({ ...target, apiVersion, namespace: data.metadata?.namespace })
43
+ }
44
+
45
+ export function getFlaggerAutoscalerRef(data: any): ResourceRef | null {
46
+ const scaler = data.spec?.autoscalerRef
47
+ const apiVersion = scaler?.kind === 'HorizontalPodAutoscaler' ? 'autoscaling/v2' : scaler?.kind === 'ScaledObject' ? 'keda.sh/v1alpha1' : null
48
+ return apiVersion ? objectReferenceToResourceRef({ ...scaler, apiVersion, namespace: data.metadata?.namespace }) : null
49
+ }
50
+
51
+ // scheduler_metrics.go reads only templateRef.name/namespace through its typed
52
+ // MetricTemplate client. apiVersion/kind are ignored, so do not link elsewhere.
53
+ export function getFlaggerMetricTemplateRef(data: any, metric: any): ResourceRef | null {
54
+ return metric.templateRef?.name ? objectReferenceToResourceRef({ apiVersion: 'flagger.app/v1beta1', kind: 'MetricTemplate', name: metric.templateRef.name, namespace: metric.templateRef.namespace || data.metadata?.namespace }) : null
55
+ }
56
+
57
+ export function getFlaggerColumnValue(data: any, column: string): string | undefined {
58
+ if (!isApiGroup(data?.apiVersion, 'flagger.app')) return undefined
59
+ if (data.kind === 'Canary') {
60
+ switch (column) {
61
+ case 'status': return getFlaggerCanaryStatus(data).text
62
+ case 'strategy': return getFlaggerStrategy(data)
63
+ case 'target': return data.spec?.targetRef?.name ? `${data.spec.targetRef.kind}/${data.spec.targetRef.name}` : ''
64
+ case 'canaryWeight': return getFlaggerAnalysis(data).iterations > 0 ? 'Not weighted' : data.status?.canaryWeight != null ? `${data.status.canaryWeight}%` : 'Not reported'
65
+ case 'suspended': return data.spec?.suspend === true ? 'Yes' : 'No'
66
+ }
67
+ }
68
+ if (data.kind === 'MetricTemplate') {
69
+ switch (column) {
70
+ case 'status': return 'Configured'
71
+ case 'provider': return data.spec?.provider?.type ?? ''
72
+ case 'address': return data.spec?.provider?.address ?? ''
73
+ }
74
+ }
75
+ return undefined
76
+ }