@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
|
@@ -38,3 +38,55 @@ describe('YamlReview after a failed apply', () => {
|
|
|
38
38
|
expect(review()).not.toContain('changed after your review')
|
|
39
39
|
})
|
|
40
40
|
})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
it('retains pause context when the prediction removes the annotation without claiming confirmed resumption', () => {
|
|
44
|
+
const baselineYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\n annotations:\n crossplane.io/paused: "true"\nspec:\n providerConfigRef:\n name: default\n'
|
|
45
|
+
const predictedYaml = baselineYaml.replace(' crossplane.io/paused: "true"', ' crossplane.io/paused: "false"')
|
|
46
|
+
const result = renderToString(<YamlReview submittedYaml={predictedYaml} documents={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml }]} onBack={() => {}} onApply={() => {}} />)
|
|
47
|
+
expect(result).toContain('Pause affects reconciliation')
|
|
48
|
+
expect(result).toContain('requests resumption')
|
|
49
|
+
expect(result).not.toContain('will resume')
|
|
50
|
+
expect(result.replaceAll('<!-- -->', '')).toContain('Server preview clears the pause request on Bucket archive')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
it('shows pause context for an unselected later document because Apply submits the whole batch', () => {
|
|
55
|
+
const first = 'apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ordinary\n'
|
|
56
|
+
const second = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: later-paused\n annotations:\n crossplane.io/paused: "true"\nspec:\n providerConfigRef:\n name: default\n'
|
|
57
|
+
const result = renderToString(<YamlReview submittedYaml={first + '\n---\n' + second} documents={[{ index: 0, status: 'accepted', predictedYaml: first }, { index: 1, status: 'accepted', predictedYaml: second }]} onBack={() => {}} onApply={() => {}} />)
|
|
58
|
+
expect(result).toContain('Pause affects reconciliation')
|
|
59
|
+
expect(result).toContain('Bucket later-paused')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
it('states the actual retained pause in the prediction without inferring who preserved it', () => {
|
|
64
|
+
const baselineYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\n annotations:\n crossplane.io/paused: "true"\nspec:\n providerConfigRef:\n name: default\n'
|
|
65
|
+
const submittedYaml = baselineYaml.replace(' crossplane.io/paused: "true"', '')
|
|
66
|
+
const result = renderToString(<YamlReview submittedYaml={submittedYaml} documents={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml: baselineYaml }]} onBack={() => {}} onApply={() => {}} />)
|
|
67
|
+
expect(result.replaceAll('<!-- -->', '')).toContain('Server preview keeps the pause request on Bucket archive')
|
|
68
|
+
expect(result).not.toContain('another field manager owns')
|
|
69
|
+
expect(result).not.toContain('Server preview clears')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it.each(['apply', 'create'] as const)('handles a not-previewable Crossplane document in %s mode without pretending submitted YAML is live', mode => {
|
|
73
|
+
const submittedYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\nspec:\n providerConfigRef:\n name: default\n'
|
|
74
|
+
const result = renderToString(<YamlReview submittedYaml={submittedYaml} mode={mode} documents={[{ index: 0, status: 'unavailable', submittedYaml }]} onBack={() => {}} onApply={() => {}} />)
|
|
75
|
+
expect(result.includes('Existing pause state is unknown')).toBe(mode === 'apply')
|
|
76
|
+
expect(result.includes('Omitting the annotation from server-side apply YAML does not guarantee its removal')).toBe(mode === 'apply')
|
|
77
|
+
expect(result).not.toContain('Server preview clears')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
it.each(['create', 'unavailable'] as const)('keeps per-document pause evidence paired when the first document has no baseline (%s)', firstState => {
|
|
82
|
+
const firstYaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: fresh\nspec:\n providerConfigRef:\n name: default\n'
|
|
83
|
+
const pausedYaml = firstYaml.replace('name: fresh', 'name: archive\n annotations:\n crossplane.io/paused: "true"')
|
|
84
|
+
const first = firstState === 'create'
|
|
85
|
+
? { index: 0, status: 'accepted' as const, action: 'create' as const, name: 'fresh', predictedYaml: firstYaml }
|
|
86
|
+
: { index: 0, status: 'unavailable' as const, action: 'unknown' as const, name: 'fresh', submittedYaml: firstYaml }
|
|
87
|
+
const result = renderToString(<YamlReview submittedYaml={firstYaml + '\n---\n' + pausedYaml} documents={[first, { index: 1, status: 'accepted', action: 'update', name: 'archive', baselineYaml: pausedYaml, predictedYaml: pausedYaml }]} onBack={() => {}} onApply={() => {}} />).replaceAll('<!-- -->', '')
|
|
88
|
+
expect(result).toContain('Server preview keeps the pause request on Bucket archive')
|
|
89
|
+
expect(result).not.toContain('Server preview clears the pause request')
|
|
90
|
+
expect(result).not.toContain('Server preview requests a pause on Bucket archive')
|
|
91
|
+
expect(result.includes('Live pause state is unverified for Bucket fresh')).toBe(firstState === 'unavailable')
|
|
92
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import { AlertTriangle, ArrowLeft, Check, Loader2, ShieldAlert, ShieldCheck, X } from 'lucide-react'
|
|
3
3
|
import { normalizeYamlForReview, splitYamlDocuments } from '../../utils/yaml'
|
|
4
|
+
import { CrossplaneYamlApplyNotice } from './CrossplaneOperationNotice'
|
|
4
5
|
import { Badge } from './Badge'
|
|
5
6
|
import { StatusDot } from './status-tone'
|
|
6
7
|
import { YamlDiffEditor } from './YamlEditor'
|
|
@@ -25,6 +26,9 @@ export interface YamlPreviewResult {
|
|
|
25
26
|
|
|
26
27
|
export interface YamlReviewProps {
|
|
27
28
|
submittedYaml: string
|
|
29
|
+
mode?: 'apply' | 'create' | 'update'
|
|
30
|
+
/** Review YAML omits status; raw evidence is bound to one editor target. */
|
|
31
|
+
resourceData?: unknown
|
|
28
32
|
originalYaml?: string
|
|
29
33
|
documents: YamlPreviewResult[]
|
|
30
34
|
nonAtomic?: boolean
|
|
@@ -85,6 +89,8 @@ export function reviewedResourceVersionsForPreview(documents: YamlPreviewResult[
|
|
|
85
89
|
|
|
86
90
|
export function YamlReview({
|
|
87
91
|
submittedYaml,
|
|
92
|
+
mode = 'apply',
|
|
93
|
+
resourceData,
|
|
88
94
|
originalYaml,
|
|
89
95
|
documents,
|
|
90
96
|
nonAtomic = false,
|
|
@@ -363,6 +369,7 @@ export function YamlReview({
|
|
|
363
369
|
</div>
|
|
364
370
|
|
|
365
371
|
<div className="shrink-0 border-t border-theme-border bg-theme-elevated/80 px-4 py-3">
|
|
372
|
+
<CrossplaneYamlApplyNotice yaml={submittedYaml} baselineYaml={originalYaml} previewDocuments={documents} mode={mode} resourceData={resourceData} />
|
|
366
373
|
{changedSinceReview && (
|
|
367
374
|
<AlertBanner
|
|
368
375
|
variant="warning"
|
|
@@ -218,6 +218,33 @@ describe('RelatedResourcesSection ownership', () => {
|
|
|
218
218
|
expect(html).toContain("Its destination isn't this cluster as far as Radar can tell, so its members aren't listed.")
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
+
describe('titles managers the way the chip does', () => {
|
|
222
|
+
const other = { ...app, name: 'other' }
|
|
223
|
+
const title = (relationships: Relationships) => {
|
|
224
|
+
const html = renderToString(<RelatedResourcesSection relationships={relationships} onNavigate={() => {}} />)
|
|
225
|
+
return html.includes('Listed By') ? 'Listed By' : html.includes('Managed By') ? 'Managed By' : 'none'
|
|
226
|
+
}
|
|
227
|
+
const answer = (state: 'listed' | 'shared' | 'disagree' | 'agree', recorders = [app]) => ({ state, direct: { object: rs, recorders } })
|
|
228
|
+
it.each(['listed', 'shared', 'disagree'] as const)('a %s answer lists its recorders', state => {
|
|
229
|
+
expect(title({ managers: [app], ownership: { children: { complete: true }, manager: answer(state) } })).toBe('Listed By')
|
|
230
|
+
})
|
|
231
|
+
it('an agree answer manages', () => {
|
|
232
|
+
expect(title({ managers: [app], ownership: { children: { complete: true }, manager: answer('agree') } })).toBe('Managed By')
|
|
233
|
+
})
|
|
234
|
+
it('a manager that is not a direct recorder keeps Managed By', () => {
|
|
235
|
+
expect(title({ managers: [app, other], ownership: { children: { complete: true }, manager: answer('listed') } })).toBe('Managed By')
|
|
236
|
+
})
|
|
237
|
+
it('an inherited answer keeps Managed By', () => {
|
|
238
|
+
expect(title({ managers: [app], ownership: { children: { complete: true }, manager: { ...answer('listed'), via: { object: cm, recorders: [app] } } } })).toBe('Managed By')
|
|
239
|
+
})
|
|
240
|
+
it('a note without managers keeps Managed By', () => {
|
|
241
|
+
expect(title({ ownership: { children: { complete: true }, manager: answer('listed', []), managers: { remote: 1 } } })).toBe('Managed By')
|
|
242
|
+
})
|
|
243
|
+
it('an older Radar without an answer keeps Managed By', () => {
|
|
244
|
+
expect(title({ managers: [app] })).toBe('Managed By')
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
221
248
|
it('says which direction Managed By comes from', () => {
|
|
222
249
|
const html = renderToString(<RelatedResourcesSection relationships={{ managers: [app] }} onNavigate={() => {}} />)
|
|
223
250
|
expect(html).toContain('Argo CD Applications or Flux objects whose status lists this resource, and other controllers that manage it without an owner reference.')
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { dedupeResourceRefs, omitResourceRefs } from '../../utils/resource-refs'
|
|
1
|
+
import { dedupeResourceRefs, omitResourceRefs, resourceRefKey } from '../../utils/resource-refs'
|
|
2
|
+
import { gapReasonText } from '../../utils/read-actions'
|
|
2
3
|
import { customResourceDefinitionRef, getCRDDefinitionsSnapshot, subscribeCRDDefinitions } from '../../utils/navigation'
|
|
3
4
|
import { Disclosure } from './Disclosure'
|
|
4
5
|
import { createContext, useContext, useState, useSyncExternalStore } from 'react'
|
|
@@ -295,9 +296,9 @@ function isConditionHealthy(cond: { type?: string; status?: string }): boolean {
|
|
|
295
296
|
return inverted ? cond.status === 'False' : cond.status === 'True'
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown'
|
|
299
|
+
export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown' | 'neutral'
|
|
299
300
|
|
|
300
|
-
export function defaultConditionTone(cond: { type?: string; status?: string }): ConditionTone {
|
|
301
|
+
export function defaultConditionTone(cond: { type?: string; status?: string }): Exclude<ConditionTone, 'neutral'> {
|
|
301
302
|
if (cond.status !== 'True' && cond.status !== 'False') return 'unknown'
|
|
302
303
|
return isConditionHealthy(cond) ? 'ok' : 'fail'
|
|
303
304
|
}
|
|
@@ -342,6 +343,7 @@ export function ConditionsSection({
|
|
|
342
343
|
const isOk = tone === 'ok'
|
|
343
344
|
const isWarning = tone === 'warning'
|
|
344
345
|
const isFail = tone === 'fail'
|
|
346
|
+
const isNeutral = tone === 'neutral'
|
|
345
347
|
return (
|
|
346
348
|
<div key={cond.type} className={clsx(
|
|
347
349
|
'flex items-start py-1.5 pr-1 text-sm relative',
|
|
@@ -355,12 +357,14 @@ export function ConditionsSection({
|
|
|
355
357
|
{/* Timeline dot */}
|
|
356
358
|
<span className={clsx(
|
|
357
359
|
'w-3 h-3 rounded-full flex items-center justify-center shrink-0 mt-1 z-10 ring-2 ring-theme-surface',
|
|
358
|
-
|
|
360
|
+
isNeutral ? 'bg-theme-elevated text-theme-text-tertiary'
|
|
361
|
+
: isOk ? 'bg-emerald-500/20 text-emerald-500 dark:bg-emerald-500/30'
|
|
359
362
|
: tone === 'unknown' ? 'bg-gray-400/20 text-gray-400 dark:bg-gray-400/30'
|
|
360
363
|
: isWarning ? 'bg-amber-500/25 text-amber-600 dark:text-amber-400 dark:bg-amber-500/35'
|
|
361
364
|
: 'bg-red-500/25 text-red-500 dark:bg-red-500/35'
|
|
362
365
|
)}>
|
|
363
|
-
{
|
|
366
|
+
{isNeutral ? <Minus className="w-2 h-2" strokeWidth={4} />
|
|
367
|
+
: isOk ? <Check className="w-2 h-2" strokeWidth={4} />
|
|
364
368
|
: tone === 'unknown' ? <Minus className="w-2 h-2" strokeWidth={4} />
|
|
365
369
|
: isWarning ? <AlertTriangle className="w-2 h-2" strokeWidth={4} />
|
|
366
370
|
: <X className="w-2 h-2" strokeWidth={4} />}
|
|
@@ -369,7 +373,8 @@ export function ConditionsSection({
|
|
|
369
373
|
<div className="min-w-0 flex-1 pl-2">
|
|
370
374
|
<span className={clsx(
|
|
371
375
|
'font-medium text-[13px]',
|
|
372
|
-
|
|
376
|
+
isNeutral ? 'text-theme-text-secondary'
|
|
377
|
+
: isOk ? 'text-theme-text-primary'
|
|
373
378
|
: tone === 'unknown' ? 'text-theme-text-secondary'
|
|
374
379
|
: isWarning ? 'text-amber-600 dark:text-amber-400'
|
|
375
380
|
: 'text-red-600 dark:text-red-400'
|
|
@@ -415,7 +420,7 @@ interface AlertBannerProps {
|
|
|
415
420
|
icon?: React.ComponentType<{ className?: string }>
|
|
416
421
|
title: string
|
|
417
422
|
message?: React.ReactNode
|
|
418
|
-
items?:
|
|
423
|
+
items?: React.ReactNode[]
|
|
419
424
|
children?: React.ReactNode
|
|
420
425
|
/** A control at the end of the title line, e.g. a link to where the problem is handled. */
|
|
421
426
|
action?: React.ReactNode
|
|
@@ -815,7 +820,7 @@ export type CopyHandler = (text: string, key: string) => void
|
|
|
815
820
|
// RELATED RESOURCES SECTION
|
|
816
821
|
// ============================================================================
|
|
817
822
|
|
|
818
|
-
import type { TimelineEvent, Relationships, ResourceRef, OwnershipCoverage } from '../../types'
|
|
823
|
+
import type { TimelineEvent, Relationships, ResourceRef, OwnershipCoverage, ManagerAnswer } from '../../types'
|
|
819
824
|
import { isChangeEvent, isK8sEvent } from '../../types'
|
|
820
825
|
import { Info, Link } from 'lucide-react'
|
|
821
826
|
import { formatRelativeAgeTime } from '../../utils/format'
|
|
@@ -910,7 +915,7 @@ export function RelatedResourcesSection({ relationships, onNavigate, kind, track
|
|
|
910
915
|
/>
|
|
911
916
|
)}
|
|
912
917
|
{(managers.length > 0 || managedByNote.text) && (
|
|
913
|
-
<RelationshipGroup label=
|
|
918
|
+
<RelationshipGroup label={managersListOnly(relationships.ownership?.manager, managers) ? 'Listed By' : 'Managed By'} refs={managers} onNavigate={onNavigate} note={managedByNote} />
|
|
914
919
|
)}
|
|
915
920
|
{relationships.deployment && (
|
|
916
921
|
<RelationshipGroup label="Deployment" refs={[relationships.deployment]} onNavigate={onNavigate} />
|
|
@@ -1073,39 +1078,6 @@ function joinOr(items: string[]): string {
|
|
|
1073
1078
|
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`
|
|
1074
1079
|
}
|
|
1075
1080
|
|
|
1076
|
-
function gapReasonText(reason: string): string {
|
|
1077
|
-
switch (reason) {
|
|
1078
|
-
case 'radar_kind_not_collected':
|
|
1079
|
-
case 'radar_namespace_not_collected':
|
|
1080
|
-
return "Radar doesn't collect it"
|
|
1081
|
-
case 'kind_sync_pending':
|
|
1082
|
-
return 'still loading'
|
|
1083
|
-
case 'kind_sync_timed_out':
|
|
1084
|
-
return 'loading is overdue'
|
|
1085
|
-
case 'radar_forbidden':
|
|
1086
|
-
case 'radar_credentials_rejected':
|
|
1087
|
-
return 'Radar may not list it'
|
|
1088
|
-
case 'kind_not_watchable':
|
|
1089
|
-
return "its API can't be watched"
|
|
1090
|
-
case 'user_permission_check_failed':
|
|
1091
|
-
return "your access couldn't be checked"
|
|
1092
|
-
case 'user_forbidden':
|
|
1093
|
-
return 'you may not list it'
|
|
1094
|
-
case 'kind_unknown':
|
|
1095
|
-
return "Radar's API discovery doesn't list its kind or version"
|
|
1096
|
-
case 'object_not_found':
|
|
1097
|
-
return "Radar doesn't have it yet"
|
|
1098
|
-
case 'cluster_connecting':
|
|
1099
|
-
return 'Radar is still connecting'
|
|
1100
|
-
case 'cluster_disconnected':
|
|
1101
|
-
return 'Radar lost its connection'
|
|
1102
|
-
case 'connection_changed':
|
|
1103
|
-
return "Radar's connection changed meanwhile"
|
|
1104
|
-
default:
|
|
1105
|
-
return 'the read failed'
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
1081
|
// membersNote says what a GitOps manager's status records beyond the members
|
|
1110
1082
|
// Manages lists; listed says whether it lists any.
|
|
1111
1083
|
function membersNote(manages: OwnershipCoverage['manages'] | undefined, listed: boolean): RelationshipNote | undefined {
|
|
@@ -1180,6 +1152,14 @@ const MANAGED_BY_HINT = [
|
|
|
1180
1152
|
"Kubernetes' garbage collector follows owner references, not these links.",
|
|
1181
1153
|
]
|
|
1182
1154
|
|
|
1155
|
+
// managersListOnly: every manager shown is one whose list names the resource
|
|
1156
|
+
// while its metadata doesn't confirm it, which the chip calls Listed by.
|
|
1157
|
+
function managersListOnly(answer: ManagerAnswer | undefined, managers: ResourceRef[]): boolean {
|
|
1158
|
+
if (!answer || answer.via || !['listed', 'shared', 'disagree'].includes(answer.state) || !managers.length) return false
|
|
1159
|
+
const recorders = new Set((answer.direct.recorders ?? []).map(resourceRefKey))
|
|
1160
|
+
return managers.every(m => recorders.has(resourceRefKey(m)))
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1183
1163
|
// managersNote explains Managed By: where it comes from, more than one
|
|
1184
1164
|
// manager, and the managers the search couldn't settle. With none listed, a
|
|
1185
1165
|
// remote list naming the resource or a manager kind Radar couldn't search is
|
|
@@ -68,3 +68,43 @@ describe('workload GitOps manager', () => {
|
|
|
68
68
|
expect(html).toContain("Its tracking label names Application shop, whose status doesn't list it.")
|
|
69
69
|
})
|
|
70
70
|
})
|
|
71
|
+
|
|
72
|
+
describe('manager answer group projection', () => {
|
|
73
|
+
const app = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'shop' }
|
|
74
|
+
const object = { kind: 'Deployment', group: 'apps', namespace: 'demo', name: 'web' }
|
|
75
|
+
const answer: import('../../types/core').ManagerAnswer = { state: 'listed', lead: app, direct: { object, recorders: [app] } }
|
|
76
|
+
const render = (manager: typeof answer, extra: Partial<import('../../types/core').Relationships> = {}) =>
|
|
77
|
+
renderToStaticMarkup(<WorkloadView kind="deployments" namespace="demo" name="web" onBack={() => {}} resource={workload} expanded={false}
|
|
78
|
+
relationships={{ managers: [app], ownership: { children: { complete: true }, manager }, ...extra }} />)
|
|
79
|
+
// The group is titled the way the chip words the answer.
|
|
80
|
+
const group = (html: string) => html.includes('Listed By') ? 'Listed By' : html.includes('Managed By') ? 'Managed By' : null
|
|
81
|
+
|
|
82
|
+
it.each(['agree', 'listed'] as const)('hides only the exact single %s recorder', state => {
|
|
83
|
+
const html = render({ ...answer, state })
|
|
84
|
+
expect(html).toContain(state === 'agree' ? 'Managed by' : 'Listed by')
|
|
85
|
+
expect(group(html)).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
it.each(['claimed', 'unknown', 'none'] as const)('keeps Managed By for %s', state => {
|
|
88
|
+
expect(group(render({ ...answer, state }))).toBe('Managed By')
|
|
89
|
+
})
|
|
90
|
+
it.each(['disagree', 'shared'] as const)('keeps the group for %s, titled Listed By', state => {
|
|
91
|
+
expect(group(render({ ...answer, state }))).toBe('Listed By')
|
|
92
|
+
})
|
|
93
|
+
it('keeps Managed By with via', () => {
|
|
94
|
+
expect(render({ ...answer, via: { object, recorders: [app] } })).toContain('Managed By')
|
|
95
|
+
})
|
|
96
|
+
it('keeps extra non-recorded managers', () => {
|
|
97
|
+
expect(render(answer, { managers: [app, { kind: 'Certificate', group: 'cert-manager.io', namespace: 'demo', name: 'tls' }] })).toContain('Managed By')
|
|
98
|
+
})
|
|
99
|
+
it('keeps a different single manager', () => {
|
|
100
|
+
expect(render(answer, { managers: [{ ...app, name: 'other' }] })).toContain('Managed By')
|
|
101
|
+
})
|
|
102
|
+
it('keeps the coverage note', () => {
|
|
103
|
+
expect(group(render(answer, { ownership: { children: { complete: true }, manager: answer, managers: { unreadable: 1 } } }))).toBe('Listed By')
|
|
104
|
+
})
|
|
105
|
+
it('uses native Helm’s existing chip', () => {
|
|
106
|
+
const helm = { kind: 'HelmRelease', group: '', namespace: 'demo', name: 'chart' }
|
|
107
|
+
const html = render({ state: 'claimed', lead: helm, direct: { object, claims: [{ manager: helm, source: 'helm-release', check: 'not-recording' }] } })
|
|
108
|
+
expect(html).toContain('Managed by Helm')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -43,7 +43,7 @@ import type { NavigateToResource } from '../../utils/navigation'
|
|
|
43
43
|
import { refToSelectedResource, pluralToKind, knownKindForPluralWithGroup, kindToPlural, kindToPluralWithGroup, apiVersionToGroup } from '../../utils/navigation'
|
|
44
44
|
import { neighborhoodFor, seedNodeIds, topologyNodeResourceKind } from '../../utils/topology-neighborhood'
|
|
45
45
|
import { TopologyGraph } from '../topology/TopologyGraph'
|
|
46
|
-
import { gitOpsOwnerFromRelationships, managerListing, type GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
46
|
+
import { gitOpsOwnerFromRelationships, managerChipCoversGroup, managerChipPresentation, managerListing, type GitOpsOwnerRef } from '../../utils/gitops-owner'
|
|
47
47
|
import { gitOpsRouteForResource } from '../../utils/gitops-route'
|
|
48
48
|
import { buildResourceHierarchy, getAllEventsFromHierarchy, isProblematicEvent, type ResourceLane } from '../../utils/resource-hierarchy'
|
|
49
49
|
import { TimelineSwimlanes, type TimeWindow } from '../timeline/TimelineSwimlanes'
|
|
@@ -62,6 +62,8 @@ import { AlertBanner, getKindColorOutline, displayKindName, OperationalIssuesSho
|
|
|
62
62
|
import { Badge, type BadgeSeverity } from '../ui/Badge'
|
|
63
63
|
import { Tooltip } from '../ui/Tooltip'
|
|
64
64
|
import { midTruncate } from '../../utils/format'
|
|
65
|
+
import { isManagedResource, isComposite, getCrossplanePauseState } from '../resources/resource-utils-crossplane'
|
|
66
|
+
import { CrossplanePauseChip } from '../resources/renderers/CrossplanePauseContext'
|
|
65
67
|
import { hpaStateLabel } from '../resources/resource-utils-hpa'
|
|
66
68
|
import {
|
|
67
69
|
cronToHuman,
|
|
@@ -540,7 +542,7 @@ export function WorkloadView({
|
|
|
540
542
|
gitOpsOwnerPending = false,
|
|
541
543
|
gitOpsOwnerSource,
|
|
542
544
|
gitOpsOwnerStatus,
|
|
543
|
-
helmOwner,
|
|
545
|
+
helmOwner: resolvedHelmOwner,
|
|
544
546
|
helmOwnerSource,
|
|
545
547
|
onOpenHelmRelease,
|
|
546
548
|
onNavigateGitOpsPath,
|
|
@@ -725,7 +727,12 @@ export function WorkloadView({
|
|
|
725
727
|
// Metadata
|
|
726
728
|
const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
|
|
727
729
|
const relationshipGitOpsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
|
|
728
|
-
const
|
|
730
|
+
const manager = relationships?.ownership?.manager
|
|
731
|
+
const gitopsOwner = manager ? relationshipGitOpsOwner : resolvedGitOpsOwner ?? relationshipGitOpsOwner
|
|
732
|
+
const helmOwner = manager
|
|
733
|
+
? manager.lead?.kind === 'HelmRelease' && !manager.lead.group ? { namespace: manager.lead.namespace, name: manager.lead.name } : null
|
|
734
|
+
: resolvedHelmOwner
|
|
735
|
+
const showGitOpsChip = manager ? !helmOwner && Boolean(managerChipPresentation(manager)) : Boolean(gitopsOwner)
|
|
729
736
|
const listing = useMemo(
|
|
730
737
|
() => managerListing(gitopsOwner, relationships?.managers, gitOpsOwnerVerified, !relationships?.ownership?.managers),
|
|
731
738
|
[gitopsOwner, relationships?.managers, relationships?.ownership?.managers, gitOpsOwnerVerified],
|
|
@@ -733,11 +740,11 @@ export function WorkloadView({
|
|
|
733
740
|
// When the chip's manager is the only one whose status lists the resource,
|
|
734
741
|
// the chip says it all: Related Resources would repeat it.
|
|
735
742
|
const rendererRelationships = useMemo(
|
|
736
|
-
() => (relationships && listing?.state === 'confirmed' ? { ...relationships, managers: undefined } : relationships),
|
|
737
|
-
[relationships, listing],
|
|
743
|
+
() => (relationships && (manager ? managerChipCoversGroup(relationships) : listing?.state === 'confirmed') ? { ...relationships, managers: undefined } : relationships),
|
|
744
|
+
[relationships, manager, listing],
|
|
738
745
|
)
|
|
739
746
|
// When they disagree, Related Resources says which manager the label names.
|
|
740
|
-
const trackedBy = listing?.state === 'conflict' && gitopsOwner ? `${gitOpsOwnerKindLabel(gitopsOwner)} ${gitopsOwner.namespace ? `${gitopsOwner.namespace}/` : ''}${gitopsOwner.name}` : undefined
|
|
747
|
+
const trackedBy = !manager && listing?.state === 'conflict' && gitopsOwner ? `${gitOpsOwnerKindLabel(gitopsOwner)} ${gitopsOwner.namespace ? `${gitopsOwner.namespace}/` : ''}${gitopsOwner.name}` : undefined
|
|
741
748
|
// When the resource itself is a portal GitOps CR (Application, Kustomization,
|
|
742
749
|
// HelmRelease, etc.), surface a link to its dedicated GitOps detail page —
|
|
743
750
|
// the drawer's renderer is thorough but the tab has the tree + insights +
|
|
@@ -835,6 +842,7 @@ export function WorkloadView({
|
|
|
835
842
|
? getWorkloadDisplayStatus(resource, apiKind, workloadPods)
|
|
836
843
|
: null
|
|
837
844
|
const rolloutActivity = rolloutDisplay?.activity ?? null
|
|
845
|
+
const crossplaneResource = isManagedResource(resource) || isComposite(resource) || getCrossplanePauseState(resource) === 'acknowledged'
|
|
838
846
|
// A 404 with a cached copy: the object was deleted while this view was
|
|
839
847
|
// open. Everything below that copy describes the past.
|
|
840
848
|
const resourceGone = Boolean(resource) && isFetchError(resourceError) && resourceError.status === 404
|
|
@@ -922,7 +930,7 @@ export function WorkloadView({
|
|
|
922
930
|
icon: <Activity className="w-4 h-4" />,
|
|
923
931
|
badge: resourceEvents.length > 0 ? <span className="ml-1 badge-sm bg-theme-elevated">{resourceEvents.length}</span> : undefined,
|
|
924
932
|
},
|
|
925
|
-
{ id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible },
|
|
933
|
+
{ id: 'logs', label: 'Logs', icon: <Terminal className="w-4 h-4" />, hidden: !logsTabVisible, phoneCompactHeader: true },
|
|
926
934
|
{ id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !metricsTabVisible },
|
|
927
935
|
{
|
|
928
936
|
id: 'reachability',
|
|
@@ -975,6 +983,7 @@ export function WorkloadView({
|
|
|
975
983
|
{status.text}
|
|
976
984
|
</span>
|
|
977
985
|
)}
|
|
986
|
+
{!resourceGone && crossplaneResource && <CrossplanePauseChip state={getCrossplanePauseState(resource)} />}
|
|
978
987
|
</div>
|
|
979
988
|
<div className="flex shrink-0 flex-nowrap items-center justify-end gap-1.5">
|
|
980
989
|
{diagnoseAction}
|
|
@@ -1035,10 +1044,10 @@ export function WorkloadView({
|
|
|
1035
1044
|
</Tooltip>
|
|
1036
1045
|
</div>
|
|
1037
1046
|
<p className="text-sm text-theme-text-tertiary">{namespace}</p>
|
|
1038
|
-
{(
|
|
1047
|
+
{(showGitOpsChip || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath) || onOpenLiveTraffic) && (
|
|
1039
1048
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
|
1040
|
-
{
|
|
1041
|
-
{helmOwner && <HelmManagedByChip owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} />}
|
|
1049
|
+
{showGitOpsChip && <ManagedByChip manager={manager} owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} />}
|
|
1050
|
+
{helmOwner && <HelmManagedByChip manager={manager} owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} />}
|
|
1042
1051
|
{gitOpsResourcePath && onNavigateGitOpsPath && (
|
|
1043
1052
|
<OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
|
|
1044
1053
|
)}
|
|
@@ -1155,10 +1164,35 @@ export function WorkloadView({
|
|
|
1155
1164
|
}
|
|
1156
1165
|
|
|
1157
1166
|
// ── Expanded (full) mode ─────────────────────────────────────────────────
|
|
1167
|
+
const nameAndCopy = (
|
|
1168
|
+
<>
|
|
1169
|
+
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
|
|
1170
|
+
<Tooltip content="Copy name" delay={150}>
|
|
1171
|
+
<button
|
|
1172
|
+
onClick={() => copyToClipboard(name, 'name')}
|
|
1173
|
+
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
|
|
1174
|
+
aria-label="Copy name"
|
|
1175
|
+
>
|
|
1176
|
+
{copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
1177
|
+
</button>
|
|
1178
|
+
</Tooltip>
|
|
1179
|
+
</>
|
|
1180
|
+
)
|
|
1158
1181
|
return (
|
|
1159
1182
|
<OperationalIssuesShownContext.Provider value={!!hasOperationalIssues || !!operationalIssuesPending}>
|
|
1160
1183
|
<DetailShell
|
|
1161
1184
|
breadcrumb={breadcrumb}
|
|
1185
|
+
compactIdentity={
|
|
1186
|
+
// The namespace stays: Restart and the other actions sit right below.
|
|
1187
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
|
|
1188
|
+
{titlePrefix}
|
|
1189
|
+
<div className="flex min-w-0 max-w-full items-center gap-2">{nameAndCopy}</div>
|
|
1190
|
+
{namespace && namespace !== '_' && (
|
|
1191
|
+
<span className="whitespace-nowrap text-sm text-theme-text-secondary">Namespace: <span className="text-theme-text-primary">{namespace}</span></span>
|
|
1192
|
+
)}
|
|
1193
|
+
{namespace && namespace !== '_' && namespaceNote}
|
|
1194
|
+
</div>
|
|
1195
|
+
}
|
|
1162
1196
|
wrapHeader={inlineBadges}
|
|
1163
1197
|
subheader={resourceGone ? undefined : subheader}
|
|
1164
1198
|
nav={
|
|
@@ -1175,20 +1209,6 @@ export function WorkloadView({
|
|
|
1175
1209
|
)
|
|
1176
1210
|
}
|
|
1177
1211
|
identity={(() => {
|
|
1178
|
-
const nameAndCopy = (
|
|
1179
|
-
<>
|
|
1180
|
-
<h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
|
|
1181
|
-
<Tooltip content="Copy name" delay={150}>
|
|
1182
|
-
<button
|
|
1183
|
-
onClick={() => copyToClipboard(name, 'name')}
|
|
1184
|
-
className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
|
|
1185
|
-
aria-label="Copy name"
|
|
1186
|
-
>
|
|
1187
|
-
{copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
|
1188
|
-
</button>
|
|
1189
|
-
</Tooltip>
|
|
1190
|
-
</>
|
|
1191
|
-
)
|
|
1192
1212
|
const kindAndStatus = (
|
|
1193
1213
|
<>
|
|
1194
1214
|
{!hideKindBadge && (
|
|
@@ -1201,6 +1221,7 @@ export function WorkloadView({
|
|
|
1201
1221
|
{status.text}
|
|
1202
1222
|
</span>
|
|
1203
1223
|
)}
|
|
1224
|
+
{!resourceGone && crossplaneResource && <CrossplanePauseChip state={getCrossplanePauseState(resource)} />}
|
|
1204
1225
|
{!resourceGone && statusNote}
|
|
1205
1226
|
</>
|
|
1206
1227
|
)
|
|
@@ -1222,11 +1243,11 @@ export function WorkloadView({
|
|
|
1222
1243
|
</span>
|
|
1223
1244
|
</Tooltip>
|
|
1224
1245
|
),
|
|
1225
|
-
|
|
1226
|
-
<ManagedByChip key="gitops" owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} variant="block" />
|
|
1246
|
+
showGitOpsChip && (
|
|
1247
|
+
<ManagedByChip key="gitops" manager={manager} owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} listing={listing} onOpen={onOpenGitOpsResource} variant="block" />
|
|
1227
1248
|
),
|
|
1228
1249
|
helmOwner && (
|
|
1229
|
-
<HelmManagedByChip key="helm" owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} variant="block" />
|
|
1250
|
+
<HelmManagedByChip key="helm" manager={manager} owner={helmOwner} source={helmOwnerSource} onOpen={onOpenHelmRelease} variant="block" />
|
|
1230
1251
|
),
|
|
1231
1252
|
gitOpsResourcePath && onNavigateGitOpsPath && (
|
|
1232
1253
|
<OpenInGitOpsChip key="open-gitops" onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
|
|
@@ -1447,10 +1468,12 @@ export function WorkloadView({
|
|
|
1447
1468
|
</div>
|
|
1448
1469
|
)}
|
|
1449
1470
|
{effectiveTab === 'yaml' && (
|
|
1450
|
-
|
|
1471
|
+
// Phones put the objects in a scrolling row above the YAML, which
|
|
1472
|
+
// gets the full width.
|
|
1473
|
+
<div className="flex h-full min-h-0 phone:flex-col">
|
|
1451
1474
|
{renderRelatedYaml && yamlObjects.length > 1 && (
|
|
1452
|
-
<div className="flex w-56 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-theme-border bg-theme-base px-2 py-2">
|
|
1453
|
-
<div className="px-1.5 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Objects</div>
|
|
1475
|
+
<div className="flex w-56 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-theme-border bg-theme-base px-2 py-2 phone:w-full phone:flex-row phone:gap-1 phone:overflow-x-auto phone:overflow-y-hidden phone:border-b phone:border-r-0">
|
|
1476
|
+
<div className="px-1.5 pb-1 pt-0.5 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary phone:hidden">Objects</div>
|
|
1454
1477
|
{yamlObjects.map((o) => {
|
|
1455
1478
|
const active = o.primary ? yamlObjectId === null : yamlObjectId === o.id
|
|
1456
1479
|
return (
|
|
@@ -1459,7 +1482,7 @@ export function WorkloadView({
|
|
|
1459
1482
|
type="button"
|
|
1460
1483
|
onClick={() => setYamlObjectId(o.primary ? null : o.id)}
|
|
1461
1484
|
className={clsx(
|
|
1462
|
-
'flex w-full flex-col rounded-md px-1.5 py-1.5 text-left transition-colors',
|
|
1485
|
+
'flex w-full flex-col rounded-md px-1.5 py-1.5 text-left transition-colors phone:w-auto phone:max-w-[11rem] phone:shrink-0',
|
|
1463
1486
|
active ? 'selection selection-ring' : 'hover:bg-theme-hover',
|
|
1464
1487
|
)}
|
|
1465
1488
|
>
|
|
@@ -2300,7 +2323,7 @@ function WorkloadOverviewTab({
|
|
|
2300
2323
|
|
|
2301
2324
|
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
|
|
2302
2325
|
<div className="space-y-4">
|
|
2303
|
-
<div className="grid items-start gap-4 2xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
|
|
2326
|
+
<div className="grid items-start gap-4 phone:grid-cols-[minmax(0,1fr)] 2xl:grid-cols-[minmax(0,1fr)_minmax(320px,420px)]">
|
|
2304
2327
|
<div className="space-y-4">
|
|
2305
2328
|
{showServingPath && (
|
|
2306
2329
|
<ServingConfigurationCard
|
|
@@ -2393,7 +2416,7 @@ function WorkloadOverviewTab({
|
|
|
2393
2416
|
<div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
|
|
2394
2417
|
<div className="space-y-4">
|
|
2395
2418
|
<CurrentStateCard resource={resource} apiKind={apiKind} readiness={readiness} />
|
|
2396
|
-
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
|
2419
|
+
<div className="grid items-start gap-4 phone:grid-cols-[minmax(0,1fr)] 2xl:grid-cols-2">
|
|
2397
2420
|
{overviewShape === 'cronjob' ? (
|
|
2398
2421
|
<CronJobRunsCard
|
|
2399
2422
|
resource={resource}
|
|
@@ -2433,7 +2456,7 @@ function WorkloadOverviewTab({
|
|
|
2433
2456
|
/>
|
|
2434
2457
|
) : null}
|
|
2435
2458
|
{extraContent && (
|
|
2436
|
-
<div className="grid items-start gap-4 2xl:grid-cols-2">
|
|
2459
|
+
<div className="grid items-start gap-4 phone:grid-cols-[minmax(0,1fr)] 2xl:grid-cols-2">
|
|
2437
2460
|
{extraContent}
|
|
2438
2461
|
</div>
|
|
2439
2462
|
)}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// The hook must leave a caller's own width constraints alone unless the menu
|
|
4
|
+
// can't fit, and put them back exactly once it fits again.
|
|
5
|
+
import { act, useRef } from 'react'
|
|
6
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
7
|
+
import { afterEach, expect, it } from 'vitest'
|
|
8
|
+
import { useKeepInViewport } from './useKeepInViewport'
|
|
9
|
+
|
|
10
|
+
let root: Root | undefined
|
|
11
|
+
let host: HTMLDivElement | undefined
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
act(() => root?.unmount())
|
|
14
|
+
host?.remove()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
function Menu({ tick }: { tick: number }) {
|
|
18
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
19
|
+
useKeepInViewport(ref, true)
|
|
20
|
+
return <div ref={ref} data-tick={tick} style={{ minWidth: 240 }} />
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function setMenuWidth(el: HTMLElement, width: number) {
|
|
24
|
+
Object.defineProperty(el, 'offsetWidth', { configurable: true, value: width })
|
|
25
|
+
el.getBoundingClientRect = () => ({ left: 0, right: width, top: 0, bottom: 0, width, height: 0, x: 0, y: 0, toJSON: () => ({}) })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function render(tick: number) {
|
|
29
|
+
act(() => root!.render(<Menu tick={tick} />))
|
|
30
|
+
return host!.querySelector('div')!
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
it('keeps the caller min-width when the menu fits, and restores it after a squeeze', () => {
|
|
34
|
+
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1024 })
|
|
35
|
+
host = document.createElement('div')
|
|
36
|
+
document.body.appendChild(host)
|
|
37
|
+
root = createRoot(host)
|
|
38
|
+
let el = render(1)
|
|
39
|
+
setMenuWidth(el, 260)
|
|
40
|
+
el = render(2)
|
|
41
|
+
expect(el.style.minWidth).toBe('240px')
|
|
42
|
+
expect(el.style.maxWidth).toBe('')
|
|
43
|
+
|
|
44
|
+
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 200 })
|
|
45
|
+
el = render(3)
|
|
46
|
+
expect(el.style.minWidth).toBe('0px')
|
|
47
|
+
expect(el.style.maxWidth).toBe('184px')
|
|
48
|
+
|
|
49
|
+
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1024 })
|
|
50
|
+
el = render(4)
|
|
51
|
+
expect(el.style.minWidth).toBe('240px')
|
|
52
|
+
expect(el.style.maxWidth).toBe('')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('measures with transitions off and restores the caller transition', () => {
|
|
56
|
+
Object.defineProperty(document.documentElement, 'clientWidth', { configurable: true, value: 1024 })
|
|
57
|
+
host = document.createElement('div')
|
|
58
|
+
document.body.appendChild(host)
|
|
59
|
+
root = createRoot(host)
|
|
60
|
+
let el = render(1)
|
|
61
|
+
el.style.transition = 'opacity 100ms'
|
|
62
|
+
const seen: string[] = []
|
|
63
|
+
el.getBoundingClientRect = () => {
|
|
64
|
+
seen.push(el.style.transition)
|
|
65
|
+
return { left: 410, right: 650, top: 0, bottom: 0, width: 240, height: 0, x: 410, y: 0, toJSON: () => ({}) }
|
|
66
|
+
}
|
|
67
|
+
el = render(2)
|
|
68
|
+
expect(seen.length).toBeGreaterThan(0)
|
|
69
|
+
expect(seen.every((t) => t === 'none')).toBe(true)
|
|
70
|
+
expect(el.style.transition).toBe('opacity 100ms')
|
|
71
|
+
expect(el.style.translate).toBe('')
|
|
72
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { keepInViewportShift } from './useKeepInViewport'
|
|
3
|
+
|
|
4
|
+
describe('keepInViewportShift', () => {
|
|
5
|
+
it('leaves a menu that fits alone', () => {
|
|
6
|
+
expect(keepInViewportShift(56, 336, 8, 1272)).toBe(0)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('pulls a menu past the right edge back inside', () => {
|
|
10
|
+
// A 280px menu opened at x=56 on a 320px phone.
|
|
11
|
+
expect(keepInViewportShift(56, 336, 8, 312)).toBe(-24)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('stops at a clipping ancestor, not the viewport edge', () => {
|
|
15
|
+
// Content column starts after a 56px rail: the left bound is 64, not 8,
|
|
16
|
+
// so a menu sitting under the rail moves out from behind it.
|
|
17
|
+
expect(keepInViewportShift(40, 240, 64, 312)).toBe(24)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps the left edge when the menu is wider than the bounds', () => {
|
|
21
|
+
expect(keepInViewportShift(20, 420, 8, 312)).toBe(-12)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('pushes a menu past the left edge back in', () => {
|
|
25
|
+
expect(keepInViewportShift(-30, 200, 8, 382)).toBe(38)
|
|
26
|
+
})
|
|
27
|
+
})
|