@skyhook-io/k8s-ui 1.18.1 → 1.18.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.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsTableView.tsx +13 -8
- package/src/components/issues/IssuesView.tsx +2 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.tsx +26 -5
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.tsx +238 -66
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +14 -3
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.tsx +135 -2
- package/src/components/shared/ResourceActionsBar.tsx +22 -6
- package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/PageHeader.tsx +3 -2
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +27 -0
- package/src/components/ui/drawer-components.tsx +22 -42
- package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
- package/src/components/workload/WorkloadView.tsx +57 -34
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/types/core.ts +53 -1
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/gitops-owner.ts +136 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +1 -0
- package/src/utils/read-actions.ts +34 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- 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
|
|
158
|
-
|
|
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 (
|
|
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
|
-
{
|
|
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,
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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
|
|
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
|
+
}
|