@skyhook-io/k8s-ui 1.14.4 → 1.14.6

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 (85) hide show
  1. package/package.json +5 -5
  2. package/src/components/audit/AuditCard.tsx +18 -7
  3. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  4. package/src/components/charts/AreaChart.test.tsx +173 -0
  5. package/src/components/charts/AreaChart.tsx +208 -53
  6. package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
  7. package/src/components/charts/PrometheusChartsView.tsx +205 -76
  8. package/src/components/charts/SeriesLegend.tsx +8 -3
  9. package/src/components/charts/annotations.ts +59 -0
  10. package/src/components/charts/axis.test.ts +48 -0
  11. package/src/components/charts/axis.ts +62 -0
  12. package/src/components/charts/colors.test.ts +27 -0
  13. package/src/components/charts/colors.ts +23 -0
  14. package/src/components/charts/format.test.ts +19 -0
  15. package/src/components/charts/format.ts +21 -1
  16. package/src/components/charts/index.ts +3 -1
  17. package/src/components/charts/nearestSample.test.ts +31 -0
  18. package/src/components/charts/nearestSample.ts +15 -0
  19. package/src/components/charts/types.ts +12 -0
  20. package/src/components/checks/ChecksView.tsx +20 -2
  21. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
  22. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  23. package/src/components/gitops/health-provenance.test.ts +54 -0
  24. package/src/components/gitops/health-provenance.ts +24 -0
  25. package/src/components/gitops/index.ts +2 -0
  26. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  28. package/src/components/gitops/tree/merge.test.ts +34 -0
  29. package/src/components/gitops/tree/merge.ts +7 -0
  30. package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
  31. package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
  32. package/src/components/resources/ResourcesView.tsx +67 -2
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/index.ts +7 -0
  35. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  36. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  37. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  38. package/src/components/resources/renderers/HPARenderer.tsx +4 -57
  39. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  40. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  41. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  42. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  43. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  44. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  45. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  46. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  47. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  48. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  49. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  50. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -21
  52. package/src/components/resources/renderers/index.ts +3 -0
  53. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  54. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
  55. package/src/components/resources/resource-utils.ts +18 -9
  56. package/src/components/shared/CreateResourceDialog.tsx +231 -11
  57. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  58. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  59. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  60. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  61. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  62. package/src/components/shared/index.ts +4 -0
  63. package/src/components/topology/K8sResourceNode.test.ts +9 -0
  64. package/src/components/topology/K8sResourceNode.tsx +56 -5
  65. package/src/components/ui/Badge.tsx +5 -1
  66. package/src/components/ui/FetchResult.test.tsx +20 -2
  67. package/src/components/ui/FetchResult.tsx +33 -3
  68. package/src/components/ui/FreshnessControl.tsx +8 -2
  69. package/src/components/ui/index.ts +5 -0
  70. package/src/components/ui/monacoRuntime.ts +14 -0
  71. package/src/components/workload/WorkloadView.tsx +2 -2
  72. package/src/monaco-deep.d.ts +10 -0
  73. package/src/theme/components.css +16 -0
  74. package/src/types/core.ts +38 -11
  75. package/src/types/gitops-insights.ts +23 -0
  76. package/src/types/gitops-tree.ts +29 -0
  77. package/src/utils/index.ts +1 -0
  78. package/src/utils/navigation.test.ts +20 -1
  79. package/src/utils/navigation.ts +25 -0
  80. package/src/utils/network-policy.test.ts +42 -0
  81. package/src/utils/network-policy.ts +59 -0
  82. package/src/utils/reflector.ts +139 -0
  83. package/src/utils/yaml-file-import.test.ts +189 -0
  84. package/src/utils/yaml-file-import.ts +95 -0
  85. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -3,6 +3,7 @@ import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
3
3
  import { useDebouncedValue } from '../../hooks/useDebouncedValue'
4
4
  import { PaneLoader } from '../ui/PaneLoader'
5
5
  import { RestrictedState } from '../ui/RestrictedState'
6
+ import { FetchResult } from '../ui/FetchResult'
6
7
  import { Input } from '../ui/Input'
7
8
  import type { TopPodMetrics, TopNodeMetrics, ContainerResourceMetrics } from '../../types'
8
9
  import {
@@ -711,6 +712,23 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
711
712
  { key: 'metrics', label: 'Metrics', width: 'w-40', tooltip: 'Per-metric verdicts' },
712
713
  { key: 'age', label: 'Age', width: 'w-24' },
713
714
  ],
715
+ analysistemplates: [
716
+ { key: 'name', label: 'Name' },
717
+ { key: 'namespace', label: 'Namespace', width: 'w-48' },
718
+ { key: 'metricCount', label: 'Metrics', width: 'w-24' },
719
+ { key: 'age', label: 'Age', width: 'w-24' },
720
+ ],
721
+ clusteranalysistemplates: [
722
+ { key: 'name', label: 'Name' },
723
+ { key: 'metricCount', label: 'Metrics', width: 'w-24' },
724
+ { key: 'age', label: 'Age', width: 'w-24' },
725
+ ],
726
+ experiments: [
727
+ { key: 'name', label: 'Name' },
728
+ { key: 'namespace', label: 'Namespace', width: 'w-48' },
729
+ { key: 'status', label: 'Phase', width: 'w-28' },
730
+ { key: 'age', label: 'Age', width: 'w-24' },
731
+ ],
714
732
  workflows: [
715
733
  { key: 'name', label: 'Name' },
716
734
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
@@ -2826,6 +2844,9 @@ const CURATED_COLUMN_GROUPS: Record<string, readonly string[]> = {
2826
2844
  sbomreports: ['aquasecurity.github.io'],
2827
2845
  vulnerabilityreports: ['aquasecurity.github.io'],
2828
2846
  analysisruns: ['argoproj.io'],
2847
+ analysistemplates: ['argoproj.io'],
2848
+ clusteranalysistemplates: ['argoproj.io'],
2849
+ experiments: ['argoproj.io'],
2829
2850
  applications: ['argoproj.io'],
2830
2851
  applicationsets: ['argoproj.io'],
2831
2852
  appprojects: ['argoproj.io'],
@@ -4987,11 +5008,13 @@ export function ResourcesView({
4987
5008
  // forbidden/unavailable in counts (e.g. an informer not yet synced at counts
4988
5009
  // time) while the list query has since returned data — show the table, not
4989
5010
  // RestrictedState. A 403 on the list itself never carries rows, so it still
4990
- // forces the restricted state.
5011
+ // forces the restricted state. A non-403 list error also beats the counts
5012
+ // entry: a kind whose sync deadline fired is reported unavailable by counts,
5013
+ // but the list knows it failed to load — that is not an RBAC answer.
4991
5014
  const selectedHasRows = Array.isArray(resources) && resources.length > 0
4992
5015
  const isSelectedForbidden =
4993
5016
  isForbiddenError(selectedQueryError) ||
4994
- (!selectedHasRows && forbiddenKinds.has(selectedKindCountKey))
5017
+ (!selectedHasRows && !selectedQueryError && forbiddenKinds.has(selectedKindCountKey))
4995
5018
 
4996
5019
  // Reset sort and filters when kind changes (but not when syncing from URL navigation)
4997
5020
  // Track previous kind to skip on mount (where the effect fires but kind hasn't actually changed)
@@ -6277,6 +6300,15 @@ export function ResourcesView({
6277
6300
  />
6278
6301
  </div>
6279
6302
  </div>
6303
+ ) : selectedQueryError && !Array.isArray(resources) ? (
6304
+ // A failed fetch with no rows to fall back on must not reach the
6305
+ // empty state below — it would tell the user the cluster has none.
6306
+ <FetchResult
6307
+ loading={false}
6308
+ error={selectedQueryError}
6309
+ onRetry={selectedQuery?.refetch}
6310
+ className="absolute inset-0"
6311
+ />
6280
6312
  ) : largeListGuard ? (
6281
6313
  <div className="absolute inset-0 flex flex-col items-center justify-center text-theme-text-tertiary px-6 text-center">
6282
6314
  <AlertTriangle className="w-8 h-8 text-amber-400 mb-2" />
@@ -7081,6 +7113,11 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
7081
7113
  return <RolloutCell resource={resource} column={column} />
7082
7114
  case 'analysisruns':
7083
7115
  return <AnalysisRunCell resource={resource} column={column} />
7116
+ case 'analysistemplates':
7117
+ case 'clusteranalysistemplates':
7118
+ return <AnalysisTemplateCell resource={resource} column={column} />
7119
+ case 'experiments':
7120
+ return <ExperimentCell resource={resource} column={column} />
7084
7121
  case 'workflows':
7085
7122
  return <WorkflowCell resource={resource} column={column} />
7086
7123
  case 'cronworkflows':
@@ -8746,6 +8783,23 @@ function RolloutCell({ resource, column }: { resource: any; column: string }) {
8746
8783
  }
8747
8784
  }
8748
8785
 
8786
+ // An Experiment reports status.phase from the same AnalysisPhase vocabulary
8787
+ // as AnalysisRun (Successful/Running/Pending/Inconclusive/Failed/Error), so
8788
+ // getAnalysisRunStatus's mapping applies directly — routing here instead of
8789
+ // GenericCell avoids the generic phase heuristic's HEALTHY_PHASES set, which
8790
+ // doesn't recognize 'Successful' and treats 'Running' as healthy (neither
8791
+ // true for this vocabulary).
8792
+ function ExperimentCell({ resource, column }: { resource: any; column: string }) {
8793
+ switch (column) {
8794
+ case 'status': {
8795
+ const status = getAnalysisRunStatus(resource)
8796
+ return <span className={clsx('badge', status.color)}>{status.text}</span>
8797
+ }
8798
+ default:
8799
+ return <span className="text-sm text-theme-text-tertiary">-</span>
8800
+ }
8801
+ }
8802
+
8749
8803
  function AnalysisRunCell({ resource, column }: { resource: any; column: string }) {
8750
8804
  switch (column) {
8751
8805
  case 'status': {
@@ -8784,6 +8838,17 @@ function AnalysisRunCell({ resource, column }: { resource: any; column: string }
8784
8838
  }
8785
8839
  }
8786
8840
 
8841
+ function AnalysisTemplateCell({ resource, column }: { resource: any; column: string }) {
8842
+ switch (column) {
8843
+ case 'metricCount': {
8844
+ const count = (resource.spec?.metrics || []).length
8845
+ return <span className="text-sm text-theme-text-secondary">{count}</span>
8846
+ }
8847
+ default:
8848
+ return <span className="text-sm text-theme-text-tertiary">-</span>
8849
+ }
8850
+ }
8851
+
8787
8852
  function WorkflowCell({ resource, column }: { resource: any; column: string }) {
8788
8853
  switch (column) {
8789
8854
  case 'status': {
@@ -63,8 +63,8 @@ describe('curated column ownership', () => {
63
63
  // Brace-counting, not parsing: assert the extraction actually saw the whole
64
64
  // table, or a silently-missed key would let the guard above pass vacuously.
65
65
  it('extracted every curated key', () => {
66
- expect(CURATED.length).toBe(236)
67
- expect(OWNED.size).toBe(198)
66
+ expect(CURATED.length).toBe(239)
67
+ expect(OWNED.size).toBe(201)
68
68
  expect(CURATED).toContain('pods')
69
69
  expect(CURATED).toContain('crossplanemanagedresources')
70
70
  expect([...OWNED]).toContain('awsmanagedcontrolplanes')
@@ -121,7 +121,7 @@ describe('every declaration resolves', () => {
121
121
  .map(m => [m[1], m[2].split(',').map(g => g.trim().replace(/'/g, ''))] as const)
122
122
 
123
123
  it('covers the whole ownership table', () => {
124
- expect(DECLARED.length).toBe(198)
124
+ expect(DECLARED.length).toBe(201)
125
125
  })
126
126
 
127
127
  it.each(DECLARED)('%s is curated under each group it claims', (key, groups) => {
@@ -1,5 +1,12 @@
1
1
  export * from './resource-utils'
2
2
  export * from './resource-utils-hpa'
3
+ export {
4
+ HPADiagnosisSummary,
5
+ hpaBadgeSeverity,
6
+ hpaReasonGroups,
7
+ isHPAReasonRedundant,
8
+ } from './HPADiagnosisSummary'
9
+ export type { HPADiagnosisSummaryProps, HPAReasonGroups } from './HPADiagnosisSummary'
3
10
  export * from './resource-utils-argo'
4
11
  export * from './resource-utils-certmanager'
5
12
  export * from './resource-utils-cnpg'
@@ -0,0 +1,150 @@
1
+ import { Activity, Server } from 'lucide-react'
2
+ import { Section, PropertyList, Property } from '../../ui/drawer-components'
3
+ import { Badge } from '../../ui/Badge'
4
+
5
+ interface AnalysisTemplateRendererProps {
6
+ data: any
7
+ }
8
+
9
+ // Every metric.provider Argo Rollouts defines. Order matches the CRD's own
10
+ // AnalysisTemplate reference doc.
11
+ const PROVIDER_KEYS = [
12
+ 'prometheus', 'web', 'kubernetes', 'job', 'wavefront', 'newRelic',
13
+ 'datadog', 'cloudWatch', 'graphite', 'influxdb', 'skywalking', 'plugin',
14
+ ] as const
15
+
16
+ function detectProvider(metric: any): { name: string; details: any } | null {
17
+ const provider = metric?.provider
18
+ if (!provider) return null
19
+ for (const key of PROVIDER_KEYS) {
20
+ if (provider[key]) return { name: key, details: provider[key] }
21
+ }
22
+ const key = Object.keys(provider)[0]
23
+ return key ? { name: key, details: provider[key] } : null
24
+ }
25
+
26
+ function providerLabel(name: string): string {
27
+ switch (name) {
28
+ case 'web': return 'Web'
29
+ case 'newRelic': return 'New Relic'
30
+ case 'cloudWatch': return 'CloudWatch'
31
+ default: return name.charAt(0).toUpperCase() + name.slice(1)
32
+ }
33
+ }
34
+
35
+ // A metric's provider config is unbounded (a full Job PodSpec, a PromQL
36
+ // query, a plugin's arbitrary args) — show the handful of fields an operator
37
+ // actually scans for per provider, not a full dump.
38
+ function ProviderDetails({ name, details }: { name: string; details: any }) {
39
+ switch (name) {
40
+ case 'prometheus':
41
+ return (
42
+ <>
43
+ {details.address && <Property label="Address" value={details.address} />}
44
+ {details.query && <Property label="Query" value={<code className="text-xs">{details.query}</code>} />}
45
+ </>
46
+ )
47
+ case 'web':
48
+ return (
49
+ <>
50
+ {details.url && <Property label="URL" value={details.url} />}
51
+ {details.jsonPath && <Property label="JSON Path" value={<code className="text-xs">{details.jsonPath}</code>} />}
52
+ {details.method && <Property label="Method" value={details.method} />}
53
+ </>
54
+ )
55
+ case 'job':
56
+ return (
57
+ <Property
58
+ label="Job Image"
59
+ value={details.spec?.template?.spec?.containers?.[0]?.image}
60
+ />
61
+ )
62
+ case 'kubernetes':
63
+ return <Property label="Function" value={details.function} />
64
+ case 'datadog':
65
+ return details.query ? <Property label="Query" value={<code className="text-xs">{details.query}</code>} /> : null
66
+ case 'newRelic':
67
+ return details.query ? <Property label="Query" value={<code className="text-xs">{details.query}</code>} /> : null
68
+ case 'graphite':
69
+ return (
70
+ <>
71
+ {details.address && <Property label="Address" value={details.address} />}
72
+ {details.query && <Property label="Query" value={<code className="text-xs">{details.query}</code>} />}
73
+ </>
74
+ )
75
+ case 'influxdb':
76
+ return details.query ? <Property label="Query" value={<code className="text-xs">{details.query}</code>} /> : null
77
+ case 'wavefront':
78
+ return details.query ? <Property label="Query" value={<code className="text-xs">{details.query}</code>} /> : null
79
+ case 'plugin':
80
+ return null
81
+ default:
82
+ return null
83
+ }
84
+ }
85
+
86
+ // Pure spec renderer for AnalysisTemplate / ClusterAnalysisTemplate — no live
87
+ // status to show (that's the AnalysisRun this template gets instantiated
88
+ // into), just the metric definitions and args an operator configured.
89
+ export function AnalysisTemplateRenderer({ data }: AnalysisTemplateRendererProps) {
90
+ const spec = data?.spec || {}
91
+ const metrics: any[] = spec.metrics || []
92
+ const args: any[] = spec.args || []
93
+
94
+ return (
95
+ <>
96
+ {metrics.length > 0 && (
97
+ <Section title={`Metrics (${metrics.length})`} icon={Activity} defaultExpanded>
98
+ <div className="space-y-2">
99
+ {metrics.map((metric: any) => {
100
+ const provider = detectProvider(metric)
101
+ return (
102
+ <div key={metric.name} className="card-inner space-y-1.5">
103
+ <div className="flex items-center justify-between gap-2">
104
+ <span className="font-mono text-sm text-theme-text-primary">{metric.name}</span>
105
+ {provider && <Badge severity="neutral" size="sm">{providerLabel(provider.name)}</Badge>}
106
+ </div>
107
+ <PropertyList>
108
+ {metric.interval && <Property label="Interval" value={metric.interval} />}
109
+ {metric.count != null && <Property label="Count" value={metric.count} />}
110
+ {metric.initialDelay && <Property label="Initial Delay" value={metric.initialDelay} />}
111
+ {metric.successCondition && (
112
+ <Property label="Success if" value={<code className="text-xs">{metric.successCondition}</code>} />
113
+ )}
114
+ {metric.failureCondition && (
115
+ <Property label="Fail if" value={<code className="text-xs">{metric.failureCondition}</code>} />
116
+ )}
117
+ {metric.failureLimit != null && <Property label="Failure Limit" value={metric.failureLimit} />}
118
+ {metric.inconclusiveLimit != null && <Property label="Inconclusive Limit" value={metric.inconclusiveLimit} />}
119
+ {metric.consecutiveErrorLimit != null && (
120
+ <Property label="Consecutive Error Limit" value={metric.consecutiveErrorLimit} />
121
+ )}
122
+ {provider && <ProviderDetails name={provider.name} details={provider.details} />}
123
+ </PropertyList>
124
+ </div>
125
+ )
126
+ })}
127
+ </div>
128
+ </Section>
129
+ )}
130
+
131
+ {args.length > 0 && (
132
+ <Section title={`Arguments (${args.length})`} icon={Server}>
133
+ <PropertyList>
134
+ {args.map((arg: any) => (
135
+ <Property
136
+ key={arg.name}
137
+ label={arg.name}
138
+ value={arg.value ?? (arg.valueFrom ? 'from reference' : undefined)}
139
+ />
140
+ ))}
141
+ </PropertyList>
142
+ </Section>
143
+ )}
144
+
145
+ {metrics.length === 0 && args.length === 0 && (
146
+ <div className="p-4 text-sm text-theme-text-tertiary">This template declares no metrics.</div>
147
+ )}
148
+ </>
149
+ )
150
+ }
@@ -2,10 +2,14 @@ import { useState, useRef, useEffect } from 'react'
2
2
  import { AlertTriangle, Copy, Check } from 'lucide-react'
3
3
  import { Section, ExpandableSection } from '../../ui/drawer-components'
4
4
  import { Tooltip } from '../../ui/Tooltip'
5
+ import type { Relationships, ResourceRef } from '../../../types'
6
+ import { ReflectorSummary, ReflectorSection } from './ReflectorSection'
5
7
  import { formatBytes } from '../../../utils/format'
6
8
 
7
9
  interface ConfigMapRendererProps {
8
10
  data: any
11
+ relationships?: Relationships
12
+ onNavigate?: (ref: ResourceRef) => void
9
13
  }
10
14
 
11
15
  // Short = single-line and under 120 chars — render inline
@@ -46,7 +50,7 @@ function CopyButton({ text }: { text: string }) {
46
50
  )
47
51
  }
48
52
 
49
- export function ConfigMapRenderer({ data }: ConfigMapRendererProps) {
53
+ export function ConfigMapRenderer({ data, relationships, onNavigate }: ConfigMapRendererProps) {
50
54
  const dataEntries = Object.entries((data.data || {}) as Record<string, string>)
51
55
  const binaryDataKeys = Object.keys(data.binaryData || {})
52
56
 
@@ -62,6 +66,7 @@ export function ConfigMapRenderer({ data }: ConfigMapRendererProps) {
62
66
 
63
67
  return (
64
68
  <>
69
+ <ReflectorSummary data={data} reflection={relationships?.reflection} onNavigate={onNavigate} />
65
70
  <Section title={`Data (${dataEntries.length + binaryDataKeys.length} keys)`} defaultExpanded>
66
71
  <div className="space-y-3">
67
72
  {/* Short values: compact table */}
@@ -113,6 +118,8 @@ export function ConfigMapRenderer({ data }: ConfigMapRendererProps) {
113
118
  </div>
114
119
  </Section>
115
120
 
121
+ <ReflectorSection data={data} reflection={relationships?.reflection} onNavigate={onNavigate} />
122
+
116
123
  {data.immutable && (
117
124
  <div className="flex items-center gap-2 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded text-yellow-400 text-sm">
118
125
  <AlertTriangle className="w-4 h-4" />
@@ -0,0 +1,179 @@
1
+ import { Activity, Boxes, Server } from 'lucide-react'
2
+ import { clsx } from 'clsx'
3
+ import { Section, PropertyList, Property, AlertBanner, ConditionsSection } from '../../ui/drawer-components'
4
+ import { formatAge } from '../resource-utils'
5
+ import { canaryStepTemplateRefs } from './RolloutRenderer'
6
+
7
+ interface ExperimentRendererProps {
8
+ data: any
9
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
10
+ }
11
+
12
+ function phaseClass(phase?: string): string {
13
+ switch (phase) {
14
+ case 'Successful':
15
+ return 'status-healthy'
16
+ case 'Running':
17
+ case 'Progressing':
18
+ case 'Pending':
19
+ return 'status-degraded'
20
+ case 'Inconclusive':
21
+ return 'status-alert'
22
+ case 'Failed':
23
+ case 'Error':
24
+ return 'status-unhealthy'
25
+ default:
26
+ return 'status-unknown'
27
+ }
28
+ }
29
+
30
+ // Experiments run one or more Deployment-shaped templates side by side
31
+ // (often unweighted "just try it" baselines, not always traffic-split) for
32
+ // spec.duration, optionally gated by the same AnalysisTemplate references a
33
+ // canary analysis step uses. No live demo fixture exists for this CRD (see
34
+ // scripts/rollouts-demo/README.md) — built against the public schema.
35
+ export function ExperimentRenderer({ data, onNavigate }: ExperimentRendererProps) {
36
+ const spec = data?.spec || {}
37
+ const status = data?.status || {}
38
+ const phase = status.phase || 'Unknown'
39
+ const templates: any[] = spec.templates || []
40
+ const templateStatuses: any[] = status.templateStatuses || []
41
+ const analyses: any[] = spec.analyses || []
42
+ // The controller's real field is analysisRuns (verified live) — each entry
43
+ // is {name, analysisRun, phase}, not the analysisRunStatuses/status shape
44
+ // the public docs' examples suggest.
45
+ const analysisRunStatuses: any[] = status.analysisRuns || []
46
+
47
+ const templateStatusByName = new Map(templateStatuses.map((t) => [t.name, t]))
48
+ const analysisRunStatusByName = new Map(analysisRunStatuses.map((a) => [a.name, a]))
49
+
50
+ const ownerRollout = (data?.metadata?.ownerReferences || []).find((ref: any) => ref.kind === 'Rollout')
51
+
52
+ return (
53
+ <>
54
+ {(phase === 'Failed' || phase === 'Error') && (
55
+ <AlertBanner
56
+ variant="error"
57
+ title={phase === 'Error' ? 'Experiment could not run' : 'Experiment failed'}
58
+ message={status.message || 'One or more templates or analyses did not succeed.'}
59
+ />
60
+ )}
61
+
62
+ {phase === 'Inconclusive' && (
63
+ <AlertBanner
64
+ variant="warning"
65
+ title="Experiment inconclusive"
66
+ message={status.message || 'An analysis matched neither its success nor failure condition.'}
67
+ />
68
+ )}
69
+
70
+ <Section title="Status" icon={Server}>
71
+ <PropertyList>
72
+ <Property label="Phase" value={<span className={clsx('badge', phaseClass(phase))}>{phase}</span>} />
73
+ <Property label="Duration" value={spec.duration} />
74
+ <Property label="Message" value={status.message} />
75
+ <Property label="Available At" value={status.availableAt ? formatAge(status.availableAt) : undefined} />
76
+ {ownerRollout && (
77
+ <Property
78
+ label="Rollout"
79
+ value={
80
+ onNavigate ? (
81
+ <button
82
+ onClick={() => onNavigate({ kind: 'Rollout', namespace: data?.metadata?.namespace ?? '', name: ownerRollout.name })}
83
+ className="text-brand hover:underline"
84
+ >
85
+ {ownerRollout.name}
86
+ </button>
87
+ ) : (
88
+ ownerRollout.name
89
+ )
90
+ }
91
+ />
92
+ )}
93
+ </PropertyList>
94
+ </Section>
95
+
96
+ {templates.length > 0 && (
97
+ <Section title={`Templates (${templates.length})`} icon={Boxes} defaultExpanded>
98
+ <div className="space-y-2">
99
+ {templates.map((tmpl: any) => {
100
+ const tmplStatus = templateStatusByName.get(tmpl.name)
101
+ return (
102
+ <div key={tmpl.name} className="card-inner space-y-1.5">
103
+ <div className="flex items-center justify-between gap-2">
104
+ <span className="font-mono text-sm text-theme-text-primary">{tmpl.name}</span>
105
+ {tmplStatus?.status && <span className={clsx('badge-sm', phaseClass(tmplStatus.status))}>{tmplStatus.status}</span>}
106
+ </div>
107
+ <PropertyList>
108
+ <Property label="Replicas" value={tmpl.replicas} />
109
+ {tmpl.weight !== undefined && <Property label="Weight" value={`${tmpl.weight}%`} />}
110
+ {tmplStatus && (
111
+ <Property
112
+ label="Ready"
113
+ value={`${tmplStatus.readyReplicas ?? 0}/${tmplStatus.replicas ?? tmpl.replicas ?? 0}`}
114
+ />
115
+ )}
116
+ {tmplStatus?.podTemplateHash && <Property label="Pod Hash" value={tmplStatus.podTemplateHash} />}
117
+ </PropertyList>
118
+ </div>
119
+ )
120
+ })}
121
+ </div>
122
+ </Section>
123
+ )}
124
+
125
+ {analyses.length > 0 && (
126
+ <Section title={`Analyses (${analyses.length})`} icon={Activity} defaultExpanded>
127
+ <PropertyList>
128
+ {analyses.map((analysis: any) => {
129
+ const runStatus = analysisRunStatusByName.get(analysis.name)
130
+ const refs = canaryStepTemplateRefs(analysis)
131
+ return (
132
+ <Property
133
+ key={analysis.name}
134
+ label={analysis.name}
135
+ value={
136
+ <span className="flex flex-wrap items-center gap-2">
137
+ {runStatus?.phase && (
138
+ <span className={clsx('badge-sm', phaseClass(runStatus.phase))}>{runStatus.phase}</span>
139
+ )}
140
+ {refs.map((ref) =>
141
+ onNavigate ? (
142
+ <button
143
+ key={ref.name}
144
+ onClick={() =>
145
+ onNavigate({
146
+ kind: ref.clusterScoped ? 'ClusterAnalysisTemplate' : 'AnalysisTemplate',
147
+ namespace: ref.clusterScoped ? '' : data?.metadata?.namespace ?? '',
148
+ name: ref.name,
149
+ })
150
+ }
151
+ className="font-mono text-xs text-brand hover:underline"
152
+ >
153
+ {ref.name}
154
+ </button>
155
+ ) : (
156
+ <span key={ref.name} className="font-mono text-xs text-theme-text-secondary">{ref.name}</span>
157
+ )
158
+ )}
159
+ {runStatus?.analysisRun && onNavigate && (
160
+ <button
161
+ onClick={() => onNavigate({ kind: 'AnalysisRun', namespace: data?.metadata?.namespace ?? '', name: runStatus.analysisRun })}
162
+ className="font-mono text-xs text-theme-text-tertiary hover:underline"
163
+ >
164
+ run: {runStatus.analysisRun}
165
+ </button>
166
+ )}
167
+ </span>
168
+ }
169
+ />
170
+ )
171
+ })}
172
+ </PropertyList>
173
+ </Section>
174
+ )}
175
+
176
+ <ConditionsSection conditions={status.conditions} />
177
+ </>
178
+ )
179
+ }
@@ -2,11 +2,11 @@ import type { ReactNode } from 'react'
2
2
  import { Activity, Cpu } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
4
  import { Section, PropertyList, Property, ConditionsSection, ResourceLink, type ConditionTone } from '../../ui/drawer-components'
5
- import { Badge, type BadgeSeverity } from '../../ui/Badge'
5
+ import { Badge } from '../../ui/Badge'
6
6
  import { kindToPlural } from '../../../utils/navigation'
7
7
  import { formatAge } from '../resource-utils'
8
- import { hpaStateLabel, hpaStateLevel } from '../resource-utils-hpa'
9
- import type { HPADiagnosis, HPADiagnosisState } from '../../../types'
8
+ import { HPADiagnosisSummary } from '../HPADiagnosisSummary'
9
+ import type { HPADiagnosis } from '../../../types'
10
10
 
11
11
  interface HPARendererProps {
12
12
  data: any
@@ -16,23 +16,6 @@ interface HPARendererProps {
16
16
  extraSections?: ReactNode
17
17
  }
18
18
 
19
- function hpaBadgeSeverity(state: HPADiagnosisState): BadgeSeverity {
20
- switch (hpaStateLevel(state)) {
21
- case 'healthy':
22
- return 'success'
23
- case 'unhealthy':
24
- return 'error'
25
- case 'degraded':
26
- return 'warning'
27
- case 'alert':
28
- return 'alert'
29
- case 'neutral':
30
- return 'info'
31
- default:
32
- return 'neutral'
33
- }
34
- }
35
-
36
19
  function hpaConditionTone(condition: any): ConditionTone | undefined {
37
20
  if (condition?.type === 'ScaledToZero') {
38
21
  if (condition.status === 'False') return 'ok'
@@ -50,14 +33,6 @@ function hpaConditionTone(condition: any): ConditionTone | undefined {
50
33
  return 'warning'
51
34
  }
52
35
 
53
- function formatReasonID(id: string): string {
54
- return id.replace(/_/g, ' ')
55
- }
56
-
57
- function isReasonMessageRedundant(state: HPADiagnosisState, reason: NonNullable<HPADiagnosis['reasons']>[number]): boolean {
58
- return state === 'limited_max' && reason.id === 'limited_max'
59
- }
60
-
61
36
  export function HPARenderer({ data, onNavigate, hpaDiagnosis, extraSections }: HPARendererProps) {
62
37
  const status = data.status || {}
63
38
  const spec = data.spec || {}
@@ -67,35 +42,7 @@ export function HPARenderer({ data, onNavigate, hpaDiagnosis, extraSections }: H
67
42
  <>
68
43
  {hpaDiagnosis && (
69
44
  <Section title="Diagnosis" icon={Activity}>
70
- <div className="card-inner space-y-3">
71
- <div className="flex flex-wrap items-start justify-between gap-2">
72
- <div className="min-w-0">
73
- <div className="text-sm font-medium text-theme-text-primary">{hpaDiagnosis.summary}</div>
74
- <div className="mt-1 text-xs text-theme-text-secondary">
75
- {hpaDiagnosis.bounds.current}/{hpaDiagnosis.bounds.desired} replicas, bounds {hpaDiagnosis.bounds.min}-{hpaDiagnosis.bounds.max}
76
- </div>
77
- </div>
78
- <Badge severity={hpaBadgeSeverity(hpaDiagnosis.state)}>{hpaStateLabel(hpaDiagnosis.state)}</Badge>
79
- </div>
80
- {hpaDiagnosis.reasons && hpaDiagnosis.reasons.length > 0 && (
81
- <div className="space-y-2">
82
- {hpaDiagnosis.reasons.map((reason) => (
83
- <div key={`${reason.id}-${reason.message}`} className="rounded border border-theme-border bg-theme-surface p-2">
84
- <div className="flex flex-wrap items-center gap-2 text-xs">
85
- <span className="font-medium text-theme-text-primary">Evidence</span>
86
- <span className="text-theme-text-tertiary">{formatReasonID(reason.id)}</span>
87
- {reason.conditionType && <span className="text-theme-text-tertiary">{reason.conditionType}</span>}
88
- {reason.conditionReason && <span className="text-theme-text-tertiary">{reason.conditionReason}</span>}
89
- {reason.detail && <span className="text-theme-text-tertiary">{reason.detail}</span>}
90
- </div>
91
- {!isReasonMessageRedundant(hpaDiagnosis.state, reason) && (
92
- <div className="mt-1 text-xs text-theme-text-secondary">{reason.message}</div>
93
- )}
94
- </div>
95
- ))}
96
- </div>
97
- )}
98
- </div>
45
+ <HPADiagnosisSummary diagnosis={hpaDiagnosis} variant="detail" />
99
46
  </Section>
100
47
  )}
101
48