@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.
- package/package.json +5 -5
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/charts/AreaChart.test.tsx +173 -0
- package/src/components/charts/AreaChart.tsx +208 -53
- package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
- package/src/components/charts/PrometheusChartsView.tsx +205 -76
- package/src/components/charts/SeriesLegend.tsx +8 -3
- package/src/components/charts/annotations.ts +59 -0
- package/src/components/charts/axis.test.ts +48 -0
- package/src/components/charts/axis.ts +62 -0
- package/src/components/charts/colors.test.ts +27 -0
- package/src/components/charts/colors.ts +23 -0
- package/src/components/charts/format.test.ts +19 -0
- package/src/components/charts/format.ts +21 -1
- package/src/components/charts/index.ts +3 -1
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/charts/types.ts +12 -0
- package/src/components/checks/ChecksView.tsx +20 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
- package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/index.ts +7 -0
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/HPARenderer.tsx +4 -57
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -21
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +231 -11
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/topology/K8sResourceNode.test.ts +9 -0
- package/src/components/topology/K8sResourceNode.tsx +56 -5
- package/src/components/ui/Badge.tsx +5 -1
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/ui/index.ts +5 -0
- package/src/components/ui/monacoRuntime.ts +14 -0
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/monaco-deep.d.ts +10 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +38 -11
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- package/src/components/logs/JsonLogLine.tsx +0 -93
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { Check, X, AlertTriangle, Minus, Clock } from 'lucide-react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { canaryStepLabel, canaryStepTemplateRefs } from '../RolloutRenderer'
|
|
4
|
+
import { analysisPhaseLevel, healthColors, type HealthLevel } from '../../resource-utils'
|
|
5
|
+
|
|
6
|
+
export interface StepAnalysisStatus {
|
|
7
|
+
name?: string
|
|
8
|
+
status?: string
|
|
9
|
+
message?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CanaryStepTimelineProps {
|
|
13
|
+
steps: any[]
|
|
14
|
+
currentStepIndex?: number
|
|
15
|
+
// The live "Step analysis" slot from rolloutAnalysisRuns(status) — shown
|
|
16
|
+
// inline on the current step when it's an analysis step, instead of only
|
|
17
|
+
// in the separate Analysis section above.
|
|
18
|
+
stepAnalysisStatus?: StepAnalysisStatus
|
|
19
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
20
|
+
namespace?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type StepState = 'completed' | 'current' | 'pending'
|
|
24
|
+
|
|
25
|
+
// Only 'unhealthy' (Failed/Error) and 'alert' (Inconclusive) colour the dot.
|
|
26
|
+
// A Successful or still-running analysis deliberately gets no special colour:
|
|
27
|
+
// the step itself may still be paused or in progress regardless, so it keeps
|
|
28
|
+
// the same "current" tone as any other in-progress step rather than reading
|
|
29
|
+
// as done.
|
|
30
|
+
function stepDotTone(state: StepState, analysisLevel?: HealthLevel) {
|
|
31
|
+
if (state === 'current' && (analysisLevel === 'unhealthy' || analysisLevel === 'alert')) {
|
|
32
|
+
return healthColors[analysisLevel]
|
|
33
|
+
}
|
|
34
|
+
switch (state) {
|
|
35
|
+
case 'completed':
|
|
36
|
+
return healthColors.healthy
|
|
37
|
+
case 'current':
|
|
38
|
+
return healthColors.neutral
|
|
39
|
+
default:
|
|
40
|
+
return 'bg-theme-hover text-theme-text-tertiary'
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function StepDot({ state, analysisLevel }: { state: StepState; analysisLevel?: HealthLevel }) {
|
|
45
|
+
const tone = stepDotTone(state, analysisLevel)
|
|
46
|
+
let Icon = Minus
|
|
47
|
+
if (state === 'current' && analysisLevel === 'unhealthy') Icon = X
|
|
48
|
+
else if (state === 'current' && analysisLevel === 'alert') Icon = AlertTriangle
|
|
49
|
+
else if (state === 'completed') Icon = Check
|
|
50
|
+
else if (state === 'current') Icon = Clock
|
|
51
|
+
return (
|
|
52
|
+
<span className={clsx('z-10 mt-1 flex h-3 w-3 shrink-0 items-center justify-center rounded-full ring-2 ring-theme-surface', tone)}>
|
|
53
|
+
<Icon className="h-2 w-2" strokeWidth={4} />
|
|
54
|
+
</span>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Vertical connected stepper for a Rollout's canary steps — a linear
|
|
59
|
+
// sequence (no branching), so a simple top-to-bottom timeline reads more
|
|
60
|
+
// naturally than a graph. Same connecting-line visual as ConditionsSection.
|
|
61
|
+
export function CanaryStepTimeline({ steps, currentStepIndex, stepAnalysisStatus, onNavigate, namespace }: CanaryStepTimelineProps) {
|
|
62
|
+
if (steps.length === 0) return null
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="relative">
|
|
66
|
+
<div className="absolute bottom-2 left-[9px] top-2 w-px bg-theme-border" />
|
|
67
|
+
<div className="space-y-0.5">
|
|
68
|
+
{steps.map((step, index) => {
|
|
69
|
+
const state: StepState =
|
|
70
|
+
currentStepIndex === undefined ? 'pending' : index < currentStepIndex ? 'completed' : index === currentStepIndex ? 'current' : 'pending'
|
|
71
|
+
const isCurrent = state === 'current'
|
|
72
|
+
const label = canaryStepLabel(step)
|
|
73
|
+
const templateRefs = step.analysis ? canaryStepTemplateRefs(step) : []
|
|
74
|
+
const showAnalysisStatus = isCurrent && step.analysis && stepAnalysisStatus?.status
|
|
75
|
+
const analysisLevel: HealthLevel | undefined = showAnalysisStatus
|
|
76
|
+
? analysisPhaseLevel(stepAnalysisStatus!.status)
|
|
77
|
+
: undefined
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div key={index} className="relative flex items-start gap-2 py-1.5 pr-1 text-sm">
|
|
81
|
+
<StepDot state={state} analysisLevel={analysisLevel} />
|
|
82
|
+
<div className="min-w-0 flex-1 pl-1">
|
|
83
|
+
<div className="flex items-center gap-2">
|
|
84
|
+
<span className="text-[11px] text-theme-text-tertiary">#{index}</span>
|
|
85
|
+
<span className={clsx(isCurrent ? 'font-medium text-theme-text-primary' : state === 'completed' ? 'text-theme-text-secondary' : 'text-theme-text-tertiary')}>
|
|
86
|
+
{label}
|
|
87
|
+
</span>
|
|
88
|
+
{showAnalysisStatus && analysisLevel && (
|
|
89
|
+
onNavigate && stepAnalysisStatus!.name ? (
|
|
90
|
+
<button
|
|
91
|
+
onClick={() =>
|
|
92
|
+
onNavigate({ kind: 'AnalysisRun', namespace: namespace ?? '', name: stepAnalysisStatus!.name! })
|
|
93
|
+
}
|
|
94
|
+
className={clsx('badge-sm hover:underline', healthColors[analysisLevel])}
|
|
95
|
+
title={stepAnalysisStatus!.name}
|
|
96
|
+
>
|
|
97
|
+
{stepAnalysisStatus!.status}
|
|
98
|
+
</button>
|
|
99
|
+
) : (
|
|
100
|
+
<span className={clsx('badge-sm', healthColors[analysisLevel])}>
|
|
101
|
+
{stepAnalysisStatus!.status}
|
|
102
|
+
</span>
|
|
103
|
+
)
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
{templateRefs.length > 0 && (
|
|
107
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
|
108
|
+
{templateRefs.map((ref) => (
|
|
109
|
+
<button
|
|
110
|
+
key={ref.name}
|
|
111
|
+
onClick={() =>
|
|
112
|
+
onNavigate?.({
|
|
113
|
+
kind: ref.clusterScoped ? 'ClusterAnalysisTemplate' : 'AnalysisTemplate',
|
|
114
|
+
namespace: ref.clusterScoped ? '' : namespace ?? '',
|
|
115
|
+
name: ref.name,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
disabled={!onNavigate}
|
|
119
|
+
className={clsx(
|
|
120
|
+
'font-mono text-[11px]',
|
|
121
|
+
onNavigate ? 'text-brand hover:underline' : 'text-theme-text-tertiary'
|
|
122
|
+
)}
|
|
123
|
+
>
|
|
124
|
+
{ref.name}
|
|
125
|
+
</button>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
})}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface BlueGreenPhase {
|
|
139
|
+
label: string
|
|
140
|
+
state: 'completed' | 'current' | 'pending'
|
|
141
|
+
analysis?: { name?: string; status?: string }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Same connected-dot visual as CanaryStepTimeline, for blueGreenPhases()'s
|
|
145
|
+
// derived phase list — blueGreen has no steps[] array to iterate, so there's
|
|
146
|
+
// nothing to reuse structurally, but the "linear sequence, top to bottom"
|
|
147
|
+
// visual language should match.
|
|
148
|
+
export function BlueGreenTimeline({
|
|
149
|
+
phases,
|
|
150
|
+
onNavigate,
|
|
151
|
+
namespace,
|
|
152
|
+
}: {
|
|
153
|
+
phases: BlueGreenPhase[]
|
|
154
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
155
|
+
namespace?: string
|
|
156
|
+
}) {
|
|
157
|
+
if (phases.length === 0) return null
|
|
158
|
+
return (
|
|
159
|
+
<div className="relative">
|
|
160
|
+
<div className="absolute bottom-2 left-[9px] top-2 w-px bg-theme-border" />
|
|
161
|
+
<div className="space-y-0.5">
|
|
162
|
+
{phases.map((phase, index) => {
|
|
163
|
+
const level = phase.analysis?.status ? analysisPhaseLevel(phase.analysis.status) : undefined
|
|
164
|
+
return (
|
|
165
|
+
<div key={index} className="relative flex items-start gap-2 py-1.5 pr-1 text-sm">
|
|
166
|
+
<StepDot state={phase.state} analysisLevel={level} />
|
|
167
|
+
<span
|
|
168
|
+
className={clsx(
|
|
169
|
+
'pl-1',
|
|
170
|
+
phase.state === 'current'
|
|
171
|
+
? 'font-medium text-theme-text-primary'
|
|
172
|
+
: phase.state === 'completed'
|
|
173
|
+
? 'text-theme-text-secondary'
|
|
174
|
+
: 'text-theme-text-tertiary'
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{phase.label}
|
|
178
|
+
</span>
|
|
179
|
+
{level &&
|
|
180
|
+
(onNavigate && phase.analysis?.name ? (
|
|
181
|
+
<button
|
|
182
|
+
onClick={() =>
|
|
183
|
+
onNavigate({ kind: 'AnalysisRun', namespace: namespace ?? '', name: phase.analysis!.name! })
|
|
184
|
+
}
|
|
185
|
+
className={clsx('badge-sm hover:underline', healthColors[level])}
|
|
186
|
+
title={phase.analysis.name}
|
|
187
|
+
>
|
|
188
|
+
{phase.analysis.status}
|
|
189
|
+
</button>
|
|
190
|
+
) : (
|
|
191
|
+
<span className={clsx('badge-sm', healthColors[level])}>{phase.analysis!.status}</span>
|
|
192
|
+
))}
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
})}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ChevronDown, ChevronRight, Box } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { formatAge } from '../../resource-utils'
|
|
5
|
+
import { revisionRoleBadges } from '../../../shared/ResourceActionsBar'
|
|
6
|
+
import type { WorkloadRevision, WorkloadPodInfo } from '../../../../types'
|
|
7
|
+
|
|
8
|
+
interface ReplicaSetProgressionProps {
|
|
9
|
+
revisions: WorkloadRevision[]
|
|
10
|
+
pods?: WorkloadPodInfo[]
|
|
11
|
+
isRollout: true
|
|
12
|
+
namespace: string
|
|
13
|
+
onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function imageTag(image: string): string {
|
|
17
|
+
const lastColon = image.lastIndexOf(':')
|
|
18
|
+
const lastSlash = image.lastIndexOf('/')
|
|
19
|
+
return lastColon > lastSlash ? image.slice(lastColon + 1) : image
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function podToneClass(pod: WorkloadPodInfo): string {
|
|
23
|
+
switch (pod.healthLevel) {
|
|
24
|
+
case 'healthy':
|
|
25
|
+
return 'status-healthy'
|
|
26
|
+
case 'degraded':
|
|
27
|
+
return 'status-degraded'
|
|
28
|
+
case 'unhealthy':
|
|
29
|
+
return 'status-unhealthy'
|
|
30
|
+
default:
|
|
31
|
+
return 'status-unknown'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Read-only revision tree: Rollout -> ReplicaSet revision -> pods, mirroring
|
|
36
|
+
// `kubectl argo rollouts get rollout`'s tree output. Newest first, matching
|
|
37
|
+
// the existing rollback-history dialog's ordering. Rollback stays exclusive
|
|
38
|
+
// to that dialog — this is purely informational.
|
|
39
|
+
export function ReplicaSetProgression({ revisions, pods, namespace, onNavigate }: ReplicaSetProgressionProps) {
|
|
40
|
+
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
|
41
|
+
|
|
42
|
+
const podsByHash = new Map<string, WorkloadPodInfo[]>()
|
|
43
|
+
for (const pod of pods ?? []) {
|
|
44
|
+
if (!pod.revisionIdentity) continue
|
|
45
|
+
const list = podsByHash.get(pod.revisionIdentity) ?? []
|
|
46
|
+
list.push(pod)
|
|
47
|
+
podsByHash.set(pod.revisionIdentity, list)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const toggle = (number: number) =>
|
|
51
|
+
setExpanded((prev) => {
|
|
52
|
+
const next = new Set(prev)
|
|
53
|
+
if (next.has(number)) next.delete(number)
|
|
54
|
+
else next.add(number)
|
|
55
|
+
return next
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="space-y-1">
|
|
60
|
+
{revisions.map((rev) => {
|
|
61
|
+
const revPods = rev.podHash ? podsByHash.get(rev.podHash) ?? [] : []
|
|
62
|
+
const isOpen = expanded.has(rev.number)
|
|
63
|
+
return (
|
|
64
|
+
<div key={rev.number} className="rounded border border-theme-border/50">
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={() => toggle(rev.number)}
|
|
68
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-theme-hover"
|
|
69
|
+
disabled={revPods.length === 0}
|
|
70
|
+
>
|
|
71
|
+
{revPods.length > 0 ? (
|
|
72
|
+
isOpen ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" /> : <ChevronRight className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
|
|
73
|
+
) : (
|
|
74
|
+
<span className="w-3.5 shrink-0" />
|
|
75
|
+
)}
|
|
76
|
+
<span className="font-mono text-xs text-theme-text-primary">#{rev.number}</span>
|
|
77
|
+
<span className="min-w-0 flex-1 truncate font-mono text-xs text-theme-text-secondary">{imageTag(rev.image)}</span>
|
|
78
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
79
|
+
{revisionRoleBadges(rev, true).map(({ label, tone, tip }) => (
|
|
80
|
+
<span key={label} title={tip} className={clsx('badge', tone)}>
|
|
81
|
+
{label}
|
|
82
|
+
</span>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
<span className="shrink-0 text-xs tabular-nums text-theme-text-tertiary">{rev.replicas ?? 0} replicas</span>
|
|
86
|
+
<span className="shrink-0 text-xs text-theme-text-tertiary">{formatAge(rev.createdAt)}</span>
|
|
87
|
+
</button>
|
|
88
|
+
{isOpen && revPods.length > 0 && (
|
|
89
|
+
<div className="space-y-0.5 border-t border-theme-border/50 px-2 py-1.5 pl-8">
|
|
90
|
+
{revPods.map((pod) => (
|
|
91
|
+
<div key={pod.name} className="flex items-center gap-2 py-0.5 text-xs">
|
|
92
|
+
<Box className="h-3 w-3 shrink-0 text-theme-text-tertiary" />
|
|
93
|
+
{onNavigate ? (
|
|
94
|
+
<button
|
|
95
|
+
onClick={() => onNavigate({ kind: 'Pod', namespace, name: pod.name })}
|
|
96
|
+
className="min-w-0 flex-1 truncate text-left font-mono text-theme-text-secondary hover:text-brand hover:underline"
|
|
97
|
+
>
|
|
98
|
+
{pod.name}
|
|
99
|
+
</button>
|
|
100
|
+
) : (
|
|
101
|
+
<span className="min-w-0 flex-1 truncate font-mono text-theme-text-secondary">{pod.name}</span>
|
|
102
|
+
)}
|
|
103
|
+
<span className={clsx('badge-sm', podToneClass(pod))}>{pod.phase || (pod.ready ? 'Ready' : 'Not Ready')}</span>
|
|
104
|
+
{(pod.restartCount ?? 0) > 0 && (
|
|
105
|
+
<span className="shrink-0 text-theme-text-tertiary">{pod.restartCount} restarts</span>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
})}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Utility functions for resource display in tables
|
|
2
2
|
|
|
3
|
+
import { effectivePolicyTypeNames } from '../../utils/network-policy'
|
|
3
4
|
import { formatCPUString, formatMemoryString, formatBytes } from '../../utils/format'
|
|
4
5
|
import { pluralize } from '../../utils/pluralize'
|
|
5
6
|
import type { WorkloadPodInfo } from '../../types/core'
|
|
@@ -1544,24 +1545,33 @@ export function getRolloutStatus(rollout: any): StatusBadge {
|
|
|
1544
1545
|
return rolloutActivityBadge(getWorkloadRolloutActivity(rollout, 'rollout'))
|
|
1545
1546
|
}
|
|
1546
1547
|
|
|
1547
|
-
|
|
1548
|
-
|
|
1548
|
+
/** The AnalysisPhase vocabulary (AnalysisRun, Experiment, and a Rollout's
|
|
1549
|
+
* analysis slots all report it) mapped onto HealthLevel. Shared so a caller
|
|
1550
|
+
* holding only the phase string lands on the same tone as one holding the
|
|
1551
|
+
* whole run. */
|
|
1552
|
+
export function analysisPhaseLevel(phase?: string): HealthLevel {
|
|
1549
1553
|
switch (phase) {
|
|
1550
1554
|
case 'Successful':
|
|
1551
|
-
return
|
|
1555
|
+
return 'healthy'
|
|
1552
1556
|
case 'Running':
|
|
1553
1557
|
case 'Pending':
|
|
1554
|
-
return
|
|
1558
|
+
return 'degraded'
|
|
1555
1559
|
case 'Inconclusive':
|
|
1556
|
-
return
|
|
1560
|
+
return 'alert'
|
|
1557
1561
|
case 'Failed':
|
|
1558
1562
|
case 'Error':
|
|
1559
|
-
return
|
|
1563
|
+
return 'unhealthy'
|
|
1560
1564
|
default:
|
|
1561
|
-
return
|
|
1565
|
+
return 'unknown'
|
|
1562
1566
|
}
|
|
1563
1567
|
}
|
|
1564
1568
|
|
|
1569
|
+
export function getAnalysisRunStatus(run: any): StatusBadge {
|
|
1570
|
+
const phase = run.status?.phase || 'Unknown'
|
|
1571
|
+
const level = analysisPhaseLevel(phase)
|
|
1572
|
+
return { text: phase, color: healthColors[level], level }
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1565
1575
|
// Counts only verdict-bearing metrics: Argo excludes dryRun results from the run's
|
|
1566
1576
|
// phase, and Running/Pending have not passed yet.
|
|
1567
1577
|
export function summarizeAnalysisMetrics(run: any): {
|
|
@@ -2068,8 +2078,7 @@ export function getWorkflowTemplateEntrypoint(wt: any): string {
|
|
|
2068
2078
|
// ============================================================================
|
|
2069
2079
|
|
|
2070
2080
|
export function getNetworkPolicyTypes(np: any): string {
|
|
2071
|
-
|
|
2072
|
-
return types.join(', ') || '-'
|
|
2081
|
+
return effectivePolicyTypeNames(np.spec).join(', ')
|
|
2073
2082
|
}
|
|
2074
2083
|
|
|
2075
2084
|
export function getNetworkPolicyRuleCount(np: any): { ingress: number; egress: number } {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
-
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type DragEvent as ReactDragEvent } from 'react'
|
|
2
|
+
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2, Upload, X } from 'lucide-react'
|
|
3
|
+
import { ConfirmDialog } from '../ui/ConfirmDialog'
|
|
3
4
|
import { DialogPortal } from '../ui/DialogPortal'
|
|
4
5
|
import { YamlEditor, type YamlSchemaLoader } from '../ui/YamlEditor'
|
|
5
6
|
import {
|
|
@@ -9,6 +10,17 @@ import {
|
|
|
9
10
|
} from '../ui/YamlReview'
|
|
10
11
|
import { Tooltip } from '../ui/Tooltip'
|
|
11
12
|
import { formatApplyError } from '../../utils/k8s-errors'
|
|
13
|
+
import {
|
|
14
|
+
describeFileDrag,
|
|
15
|
+
needsReplaceConfirmation,
|
|
16
|
+
readYamlFile,
|
|
17
|
+
YAML_FILE_ACCEPT,
|
|
18
|
+
type FileDragState,
|
|
19
|
+
} from '../../utils/yaml-file-import'
|
|
20
|
+
|
|
21
|
+
// A frame this close to the last one means the main thread is keeping up; a
|
|
22
|
+
// blocked editor produces gaps far larger.
|
|
23
|
+
const responsiveFrameMs = 100
|
|
12
24
|
|
|
13
25
|
export interface ApplyResult {
|
|
14
26
|
name: string
|
|
@@ -71,6 +83,41 @@ export function CreateResourceDialog({
|
|
|
71
83
|
nonAtomic: boolean
|
|
72
84
|
context?: string
|
|
73
85
|
} | null>(null)
|
|
86
|
+
const [drag, setDrag] = useState<FileDragState>('none')
|
|
87
|
+
const [pendingImport, setPendingImport] = useState<{ fileName: string; yaml: string } | null>(null)
|
|
88
|
+
const [importing, setImporting] = useState<string | null>(null)
|
|
89
|
+
// An import is several awaits and several frames long. Anything that changes
|
|
90
|
+
// what the result should land on — a newer import, a close, a reopen — bumps
|
|
91
|
+
// the generation, and every deferred step checks it before touching state.
|
|
92
|
+
const importGeneration = useRef(0)
|
|
93
|
+
const scheduledFrames = useRef(new Set<number>())
|
|
94
|
+
// The buffer as it stands now, not as it stood when a read began.
|
|
95
|
+
const yamlRef = useRef(yaml)
|
|
96
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
97
|
+
// dragenter/dragleave also fire for the editor's own descendants, so the
|
|
98
|
+
// overlay tracks depth rather than the first dragleave it sees.
|
|
99
|
+
const dragDepth = useRef(0)
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
yamlRef.current = yaml
|
|
103
|
+
}, [yaml])
|
|
104
|
+
|
|
105
|
+
const retireDeferredImports = useCallback(() => {
|
|
106
|
+
importGeneration.current += 1
|
|
107
|
+
for (const id of scheduledFrames.current) cancelAnimationFrame(id)
|
|
108
|
+
scheduledFrames.current.clear()
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
useEffect(() => retireDeferredImports, [retireDeferredImports])
|
|
112
|
+
|
|
113
|
+
const scheduleFrame = useCallback((generation: number, run: () => void) => {
|
|
114
|
+
const id = requestAnimationFrame(() => {
|
|
115
|
+
scheduledFrames.current.delete(id)
|
|
116
|
+
if (generation !== importGeneration.current) return
|
|
117
|
+
run()
|
|
118
|
+
})
|
|
119
|
+
scheduledFrames.current.add(id)
|
|
120
|
+
}, [])
|
|
74
121
|
|
|
75
122
|
useEffect(() => {
|
|
76
123
|
if (!open) return
|
|
@@ -82,16 +129,24 @@ export function CreateResourceDialog({
|
|
|
82
129
|
setError(null)
|
|
83
130
|
setSuccess(null)
|
|
84
131
|
setPreview(null)
|
|
85
|
-
|
|
132
|
+
setDrag('none')
|
|
133
|
+
setPendingImport(null)
|
|
134
|
+
setImporting(null)
|
|
135
|
+
dragDepth.current = 0
|
|
136
|
+
retireDeferredImports()
|
|
137
|
+
}, [open, initialYaml, retireDeferredImports])
|
|
86
138
|
|
|
87
139
|
const pending = isApplying || isPreviewing
|
|
88
140
|
const closeNow = useCallback(() => {
|
|
141
|
+
retireDeferredImports()
|
|
89
142
|
setError(null)
|
|
90
143
|
setSuccess(null)
|
|
91
144
|
setPreview(null)
|
|
145
|
+
setPendingImport(null)
|
|
146
|
+
setImporting(null)
|
|
92
147
|
setYaml('')
|
|
93
148
|
onClose()
|
|
94
|
-
}, [onClose])
|
|
149
|
+
}, [onClose, retireDeferredImports])
|
|
95
150
|
const handleClose = useCallback(() => {
|
|
96
151
|
if (!pending) closeNow()
|
|
97
152
|
}, [closeNow, pending])
|
|
@@ -123,6 +178,100 @@ export function CreateResourceDialog({
|
|
|
123
178
|
[closeNow, onCreated],
|
|
124
179
|
)
|
|
125
180
|
|
|
181
|
+
// Handing a document to the editor parses the whole buffer and blocks the
|
|
182
|
+
// main thread, so the status has to be on screen before that starts. Two
|
|
183
|
+
// frames: the first commits it, the second lets the browser paint it. A
|
|
184
|
+
// spinner would only freeze mid-turn, so this says what is happening instead
|
|
185
|
+
// of pretending to animate.
|
|
186
|
+
const loadIntoEditor = useCallback(
|
|
187
|
+
(fileName: string, content: string, generation: number) => {
|
|
188
|
+
setImporting(fileName)
|
|
189
|
+
scheduleFrame(generation, () => {
|
|
190
|
+
scheduleFrame(generation, () => {
|
|
191
|
+
setYaml(content)
|
|
192
|
+
// Clearing it in this same callback would batch it into the very
|
|
193
|
+
// render that blocks, retiring the status while the editor is still
|
|
194
|
+
// unusable. Wait for a frame to arrive on time instead: that is the
|
|
195
|
+
// editor answering again, which is what the status was promising.
|
|
196
|
+
let previous = performance.now()
|
|
197
|
+
const clearWhenResponsive = () => {
|
|
198
|
+
const now = performance.now()
|
|
199
|
+
if (now - previous < responsiveFrameMs) {
|
|
200
|
+
setImporting(null)
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
previous = now
|
|
204
|
+
scheduleFrame(generation, clearWhenResponsive)
|
|
205
|
+
}
|
|
206
|
+
scheduleFrame(generation, clearWhenResponsive)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
},
|
|
210
|
+
[scheduleFrame],
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
const loadFiles = useCallback(
|
|
214
|
+
async (files: File[]) => {
|
|
215
|
+
const generation = ++importGeneration.current
|
|
216
|
+
const result = await readYamlFile(files)
|
|
217
|
+
// A newer import, a close or a reopen happened while this one was
|
|
218
|
+
// reading; its result describes an editor that has moved on.
|
|
219
|
+
if (generation !== importGeneration.current) return
|
|
220
|
+
// This import owns the status from here. Taking the generation retired
|
|
221
|
+
// the frames that would have cleared a previous import's overlay, so
|
|
222
|
+
// every path out of this function has to leave it in a sane state — the
|
|
223
|
+
// ones that return early below would otherwise strand it on screen.
|
|
224
|
+
setImporting(null)
|
|
225
|
+
if (!result.ok) {
|
|
226
|
+
setSuccess(null)
|
|
227
|
+
setError(result.message)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
setError(null)
|
|
231
|
+
setSuccess(null)
|
|
232
|
+
if (needsReplaceConfirmation(yamlRef.current, initialYaml)) {
|
|
233
|
+
setPendingImport({ fileName: result.fileName, yaml: result.yaml })
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
loadIntoEditor(result.fileName, result.yaml, generation)
|
|
237
|
+
},
|
|
238
|
+
[initialYaml, loadIntoEditor],
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
const handleDragEnter = useCallback((event: ReactDragEvent) => {
|
|
242
|
+
const state = describeFileDrag(event.dataTransfer)
|
|
243
|
+
if (state === 'none') return
|
|
244
|
+
event.preventDefault()
|
|
245
|
+
dragDepth.current += 1
|
|
246
|
+
setDrag(state)
|
|
247
|
+
}, [])
|
|
248
|
+
|
|
249
|
+
const handleDragOver = useCallback((event: ReactDragEvent) => {
|
|
250
|
+
if (describeFileDrag(event.dataTransfer) === 'none') return
|
|
251
|
+
// Without this the browser navigates away to the dropped file.
|
|
252
|
+
event.preventDefault()
|
|
253
|
+
event.dataTransfer.dropEffect = 'copy'
|
|
254
|
+
}, [])
|
|
255
|
+
|
|
256
|
+
const handleDragLeave = useCallback((event: ReactDragEvent) => {
|
|
257
|
+
if (describeFileDrag(event.dataTransfer) === 'none') return
|
|
258
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1)
|
|
259
|
+
if (dragDepth.current === 0) setDrag('none')
|
|
260
|
+
}, [])
|
|
261
|
+
|
|
262
|
+
const handleDrop = useCallback(
|
|
263
|
+
(event: ReactDragEvent) => {
|
|
264
|
+
if (describeFileDrag(event.dataTransfer) === 'none') return
|
|
265
|
+
event.preventDefault()
|
|
266
|
+
dragDepth.current = 0
|
|
267
|
+
setDrag('none')
|
|
268
|
+
// The DataTransfer is neutered once this handler returns, so take the
|
|
269
|
+
// files off it before anything awaits.
|
|
270
|
+
void loadFiles(Array.from(event.dataTransfer.files))
|
|
271
|
+
},
|
|
272
|
+
[loadFiles],
|
|
273
|
+
)
|
|
274
|
+
|
|
126
275
|
const handleSubmit = useCallback(async () => {
|
|
127
276
|
if (!yaml.trim()) {
|
|
128
277
|
setError('YAML content is required')
|
|
@@ -256,13 +405,64 @@ export function CreateResourceDialog({
|
|
|
256
405
|
</div>
|
|
257
406
|
|
|
258
407
|
<div className="min-h-0 flex-1 px-5 py-3">
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
408
|
+
<div className="mb-2 flex items-center gap-2">
|
|
409
|
+
<button
|
|
410
|
+
type="button"
|
|
411
|
+
onClick={() => fileInputRef.current?.click()}
|
|
412
|
+
className="flex items-center gap-1.5 rounded-md border border-theme-border px-2.5 py-1 text-xs font-medium text-theme-text-secondary transition-colors hover:bg-theme-hover hover:text-theme-text-primary"
|
|
413
|
+
>
|
|
414
|
+
<Upload className="h-3.5 w-3.5" />
|
|
415
|
+
Import YAML file
|
|
416
|
+
</button>
|
|
417
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
418
|
+
or drop a YAML file into the editor
|
|
419
|
+
</span>
|
|
420
|
+
<input
|
|
421
|
+
ref={fileInputRef}
|
|
422
|
+
type="file"
|
|
423
|
+
accept={YAML_FILE_ACCEPT}
|
|
424
|
+
className="hidden"
|
|
425
|
+
onChange={(event) => {
|
|
426
|
+
const picked = Array.from(event.target.files ?? [])
|
|
427
|
+
// Clearing the input empties its FileList, so read it first.
|
|
428
|
+
event.target.value = ''
|
|
429
|
+
void loadFiles(picked)
|
|
430
|
+
}}
|
|
431
|
+
/>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
<div
|
|
435
|
+
className="relative h-[400px]"
|
|
436
|
+
onDragEnter={handleDragEnter}
|
|
437
|
+
onDragOver={handleDragOver}
|
|
438
|
+
onDragLeave={handleDragLeave}
|
|
439
|
+
onDrop={handleDrop}
|
|
440
|
+
>
|
|
441
|
+
<YamlEditor
|
|
442
|
+
value={yaml}
|
|
443
|
+
onChange={setYaml}
|
|
444
|
+
height="400px"
|
|
445
|
+
onValidate={handleValidate}
|
|
446
|
+
schemaLoader={schemaLoader}
|
|
447
|
+
/>
|
|
448
|
+
{importing && (
|
|
449
|
+
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border border-theme-border bg-theme-base/85">
|
|
450
|
+
<span className="text-sm font-medium text-theme-text-primary">
|
|
451
|
+
Loading {importing}…
|
|
452
|
+
</span>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
{drag !== 'none' && (
|
|
456
|
+
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed border-accent bg-theme-base/85">
|
|
457
|
+
<span className="flex items-center gap-2 text-sm font-medium text-theme-text-primary">
|
|
458
|
+
<Upload className="h-4 w-4" />
|
|
459
|
+
{drag === 'multiple-files'
|
|
460
|
+
? 'Drop a single YAML file'
|
|
461
|
+
: 'Drop YAML file to load'}
|
|
462
|
+
</span>
|
|
463
|
+
</div>
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
266
466
|
</div>
|
|
267
467
|
|
|
268
468
|
{(error || previewError) && (
|
|
@@ -362,6 +562,26 @@ export function CreateResourceDialog({
|
|
|
362
562
|
</div>
|
|
363
563
|
</>
|
|
364
564
|
)}
|
|
565
|
+
|
|
566
|
+
<ConfirmDialog
|
|
567
|
+
open={pendingImport !== null}
|
|
568
|
+
onClose={() => setPendingImport(null)}
|
|
569
|
+
onConfirm={() => {
|
|
570
|
+
// Confirming is its own decision about the editor as it stands now,
|
|
571
|
+
// so it loads under a fresh generation rather than the one the read
|
|
572
|
+
// started under, which anything since may have retired.
|
|
573
|
+
if (pendingImport) {
|
|
574
|
+
const generation = ++importGeneration.current
|
|
575
|
+
loadIntoEditor(pendingImport.fileName, pendingImport.yaml, generation)
|
|
576
|
+
}
|
|
577
|
+
setPendingImport(null)
|
|
578
|
+
}}
|
|
579
|
+
variant="warning"
|
|
580
|
+
showWarning={false}
|
|
581
|
+
title="Replace editor contents?"
|
|
582
|
+
message={`Loading ${pendingImport?.fileName ?? 'this file'} discards the YAML currently in the editor.`}
|
|
583
|
+
confirmLabel="Replace"
|
|
584
|
+
/>
|
|
365
585
|
</DialogPortal>
|
|
366
586
|
)
|
|
367
587
|
}
|