@skyhook-io/k8s-ui 1.18.0 → 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.
Files changed (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. package/vitest.config.ts +26 -0
@@ -1,3 +1,5 @@
1
+ import { getOpenTelemetryColumnValue } from './resource-utils-opentelemetry'
2
+ import { getFlaggerColumnValue } from './resource-utils-flagger'
1
3
  // Utility functions for resource display in tables
2
4
 
3
5
  import { getNodeLifecycle } from '../../utils/node-lifecycle'
@@ -33,6 +35,7 @@ import { getExternalSecretStatus as _getExternalSecretStatus, getClusterExternal
33
35
  import { getHPATableState, hpaStatusFromState } from './resource-utils-hpa'
34
36
  import { getCNPGClusterStatus as _getCNPGClusterStatus, getCNPGBackupStatus as _getCNPGBackupStatus, getCNPGScheduledBackupStatus as _getCNPGScheduledBackupStatus, getCNPGPoolerStatus as _getCNPGPoolerStatus, isApiGroup as _isApiGroup, CNPG_GROUP as _CNPG_GROUP } from './resource-utils-cnpg'
35
37
  import { getGenericResourceStatus } from './generic-status'
38
+ import { getCrossplaneStatus, getProviderStatus, getProviderConfigStatus, isManagedResource, isComposite } from './resource-utils-crossplane'
36
39
  import { getIstioGatewayStatus as _getIstioGatewayStatus, getIstioGatewayServerCount as _getIstioGatewayServerCount, getIstioGatewaySelectorString as _getIstioGatewaySelectorString, getAuthorizationPolicySelectorString as _getAuthorizationPolicySelectorString, getDestinationRuleTlsMode as _getDestinationRuleTlsMode } from './resource-utils-istio'
37
40
  import { formatKubernetesLabelSelector, getCalicoIPPoolAllowedUses, getCalicoIPPoolBlockSize, getCalicoIPPoolEncapsulation, getCalicoPolicyNamespaceSelector, getCalicoPolicyServiceAccountSelector, getCalicoPolicyTypes, isCalicoApiVersion, isCalicoPolicyResource } from './resource-utils-calico'
38
41
 
@@ -159,7 +162,7 @@ export function podMatchesProblemCategory(problems: PodProblem[], restarts: numb
159
162
  case 'Unschedulable':
160
163
  return msgs.includes('Unschedulable')
161
164
  case 'Not Ready':
162
- return msgs.includes('Not Ready') || msgs.some(m => m.includes('Probe'))
165
+ return msgs.includes('Not Ready') || msgs.some(m => m.includes('Probe') || m.includes('Readiness Gate'))
163
166
  case 'High Restarts':
164
167
  return restarts > 5
165
168
  case 'Init Failed':
@@ -169,7 +172,7 @@ export function podMatchesProblemCategory(problems: PodProblem[], restarts: numb
169
172
  case 'Failed':
170
173
  return msgs.includes('Failed') || msgs.includes('Unknown')
171
174
  case 'Other': {
172
- const knownPatterns = ['CrashLoopBackOff', 'ImagePull', 'ErrImagePull', 'OOMKilled', 'Unschedulable', 'Not Ready', 'Probe', 'Init:', 'Exit Code', 'Failed', 'Unknown']
175
+ const knownPatterns = ['CrashLoopBackOff', 'ImagePull', 'ErrImagePull', 'OOMKilled', 'Unschedulable', 'Not Ready', 'Probe', 'Readiness Gate', 'Init:', 'Exit Code', 'Failed', 'Unknown']
173
176
  return problems.some(p => !knownPatterns.some(pat => p.message.includes(pat)) && p.message !== `${restarts} restarts`)
174
177
  }
175
178
  default:
@@ -293,6 +296,52 @@ function podCrashRestartTotal(pod: any): number {
293
296
  .reduce((sum, cs) => sum + (cs?.restartCount || 0), 0)
294
297
  }
295
298
 
299
+ function podReadinessGateBlocked(pod: any): boolean {
300
+ const gates: { conditionType: string }[] = pod.spec?.readinessGates || []
301
+ if (pod.metadata?.deletionTimestamp || pod.status?.phase !== 'Running' || gates.length === 0) return false
302
+ const conditions: any[] = pod.status?.conditions || []
303
+ const ready = conditions.find(c => c.type === 'Ready')
304
+ const containersReady = conditions.find(c => c.type === 'ContainersReady')
305
+ if (ready?.status !== 'False' || ready.reason !== 'ReadinessGatesNotReady' || containersReady?.status !== 'True') return false
306
+ const now = Date.now()
307
+ let recent = false
308
+ for (const c of [ready, containersReady]) {
309
+ const since = Date.parse(c.lastTransitionTime || pod.metadata?.creationTimestamp)
310
+ if (!Number.isFinite(since)) continue
311
+ recent ||= since <= now + PENDING_STUCK_MINUTES * 60000 && now - since < PENDING_STUCK_MINUTES * 60000
312
+ }
313
+ if (recent) return false
314
+ return gates.some(g => {
315
+ const condition = conditions.find(c => c.type === g.conditionType)
316
+ return !condition || condition.status !== 'True'
317
+ })
318
+ }
319
+
320
+ // Match the health classifier's OOM recovery precedence before showing a gate failure.
321
+ function readinessGateOOMRecovery(pod: any): string | null {
322
+ const now = Date.now()
323
+ for (const cs of pod.status?.containerStatuses || []) {
324
+ if (cs.lastState?.terminated?.reason !== 'OOMKilled') continue
325
+ const probe = pod.spec?.containers?.find((c: any) => c.name === cs.name)?.readinessProbe
326
+ if (cs.ready && probe) continue
327
+ const startedAt = Date.parse(cs.state?.running?.startedAt)
328
+ if (Number.isFinite(startedAt)) {
329
+ if (now - startedAt <= CRASH_SETTLE_MS) return cs.name
330
+ continue
331
+ }
332
+ const finishedAt = Date.parse(cs.lastState.terminated.finishedAt)
333
+ if (cs.state?.waiting || (Number.isFinite(finishedAt) && now - finishedAt <= CRASH_SETTLE_MS)) return cs.name
334
+ }
335
+ for (const cs of pod.status?.initContainerStatuses || []) {
336
+ if (cs.lastState?.terminated?.reason !== 'OOMKilled') continue
337
+ if (cs.state?.terminated?.exitCode === 0) continue
338
+ const startedAt = Date.parse(cs.state?.running?.startedAt)
339
+ if (Number.isFinite(startedAt) && now - startedAt > CRASH_SETTLE_MS) continue
340
+ return cs.name
341
+ }
342
+ return null
343
+ }
344
+
296
345
  export function getPodStatus(pod: any): StatusBadge {
297
346
  const phase = pod.status?.phase || 'Unknown'
298
347
  const containerStatuses = pod.status?.containerStatuses || []
@@ -316,6 +365,12 @@ export function getPodStatus(pod: any): StatusBadge {
316
365
  return { text: 'Failed', color: healthColors.unhealthy, level: 'unhealthy' }
317
366
  }
318
367
 
368
+ const gateBlocked = podReadinessGateBlocked(pod)
369
+ const gateOOM = gateBlocked ? readinessGateOOMRecovery(pod) : null
370
+ if (gateOOM) {
371
+ return { text: 'OOMKilled', color: healthColors.unhealthy, level: 'unhealthy' }
372
+ }
373
+
319
374
  const crashHistoryLevel = phase !== 'Succeeded' && phase !== 'Unknown'
320
375
  ? podCrashHistoryLevel(pod)
321
376
  : null
@@ -348,6 +403,9 @@ export function getPodStatus(pod: any): StatusBadge {
348
403
 
349
404
  switch (phase) {
350
405
  case 'Running': {
406
+ if (gateBlocked) {
407
+ return { text: 'Readiness gate blocked', color: healthColors.degraded, level: 'degraded' }
408
+ }
351
409
  // Degrade only on containers that are neither ready nor successfully done —
352
410
  // a completed container (exit 0) on a Running pod is finishing, not a fault.
353
411
  const ready = containerStatuses.filter((c: any) => c.ready).length
@@ -439,6 +497,12 @@ export function getPodPhaseDisplay(pod: any): PodPhaseDisplay {
439
497
  return { phase, text: 'Failed', level: 'unhealthy' }
440
498
  }
441
499
 
500
+ const gateBlocked = podReadinessGateBlocked(pod)
501
+ const gateOOM = gateBlocked ? readinessGateOOMRecovery(pod) : null
502
+ if (gateOOM) {
503
+ return { phase, text: `${phase} — OOMKilled`, level: 'unhealthy', hint: `Container "${gateOOM}" is recovering from an OOM kill.` }
504
+ }
505
+
442
506
  const crashHistoryLevel = phase !== 'Succeeded' && phase !== 'Unknown'
443
507
  ? podCrashHistoryLevel(pod)
444
508
  : null
@@ -477,6 +541,9 @@ export function getPodPhaseDisplay(pod: any): PodPhaseDisplay {
477
541
 
478
542
  switch (phase) {
479
543
  case 'Running': {
544
+ if (gateBlocked) {
545
+ return { phase, text: 'Running — Readiness gate blocked', level: 'degraded', hint: pod.status?.conditions?.find((c: any) => c.type === 'Ready')?.message }
546
+ }
480
547
  // A successfully-completed container (exit 0) is settled, not "not ready" —
481
548
  // don't degrade a completing pod over it (matches getPodStatus / timeline).
482
549
  const unsettled = containerStatuses.filter((c) => !containerSettledOk(c)).length
@@ -613,6 +680,10 @@ export function getPodProblems(pod: any): PodProblem[] {
613
680
  }
614
681
  }
615
682
 
683
+ if (podReadinessGateBlocked(pod)) {
684
+ problems.push({ severity: 'medium', message: 'Readiness Gate Blocked', detail: conditions.find((c: any) => c.type === 'Ready')?.message })
685
+ }
686
+
616
687
  // Check conditions
617
688
  for (const cond of conditions) {
618
689
  if (cond.type === 'PodScheduled' && cond.status === 'False') {
@@ -2393,6 +2464,10 @@ function parseColumnFilterPair(
2393
2464
 
2394
2465
  export function getCellFilterValue(resource: any, column: string, kind: string): string {
2395
2466
  try {
2467
+ const otelValue = getOpenTelemetryColumnValue(resource, column)
2468
+ if (otelValue !== undefined) return otelValue
2469
+ const flaggerValue = getFlaggerColumnValue(resource, column)
2470
+ if (flaggerValue !== undefined) return flaggerValue
2396
2471
  const kindLower = kind.toLowerCase()
2397
2472
 
2398
2473
  switch (column) {
@@ -2530,6 +2605,9 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
2530
2605
  if (kindLower === 'scheduledbackups' && _isApiGroup(resource.apiVersion, _CNPG_GROUP)) return _getCNPGScheduledBackupStatus(resource).text
2531
2606
  if (kindLower === 'poolers' && _isApiGroup(resource.apiVersion, _CNPG_GROUP)) return _getCNPGPoolerStatus(resource).text
2532
2607
  if (kindLower === 'istiogateways') return _getIstioGatewayStatus(resource).text
2608
+ if (kindLower === 'providers' && _isApiGroup(resource.apiVersion, 'pkg.crossplane.io')) return getProviderStatus(resource).text
2609
+ if (kindLower === 'providerconfigs') return getProviderConfigStatus(resource).text
2610
+ if (isManagedResource(resource) || isComposite(resource)) return getCrossplaneStatus(resource).text
2533
2611
  // Generic CRDs. Must read the same text the cell renders or the dropdown
2534
2612
  // offers strings that appear on no row — hence the shared derivation.
2535
2613
  return getGenericResourceStatus(resource)?.text ?? ''
@@ -328,3 +328,17 @@ it('keeps created-object facts and the edited draft when canceling a file replac
328
328
  expect(onClose).not.toHaveBeenCalled()
329
329
  } finally { await act(async () => root.unmount()); host.remove() }
330
330
  })
331
+
332
+ it.each(['apply', 'create'] as const)('explains unknown live pause only in direct %s mode without changing submission', async (mode) => {
333
+ const onApply = vi.fn(async () => [])
334
+ const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host)
335
+ const yaml = 'apiVersion: example.test/v1\nkind: Bucket\nmetadata:\n name: archive\nspec:\n providerConfigRef:\n name: default\n'
336
+ try {
337
+ await act(async () => root.render(<CreateResourceDialog open onClose={() => {}} initialYaml={yaml} initialMode={mode} lockMode={mode === 'create'} onApply={onApply} isApplying={false} />))
338
+ expect(document.body.textContent?.includes('Existing pause state is unknown')).toBe(mode === 'apply')
339
+ const submit = button(mode === 'apply' ? 'Apply' : 'Create')!
340
+ expect(submit.disabled).toBe(false)
341
+ await act(async () => submit.click())
342
+ expect(onApply).toHaveBeenCalledWith(expect.objectContaining({ yaml, mode }))
343
+ } finally { await act(async () => root.unmount()); host.remove() }
344
+ })
@@ -9,6 +9,8 @@ import {
9
9
  YamlReview,
10
10
  type YamlPreviewResult,
11
11
  } from '../ui/YamlReview'
12
+ import { useDebouncedValue } from '../../hooks/useDebouncedValue'
13
+ import { CrossplaneYamlApplyNotice } from '../ui/CrossplaneOperationNotice'
12
14
  import { Tooltip } from '../ui/Tooltip'
13
15
  import { formatApplyError } from '../../utils/k8s-errors'
14
16
  import {
@@ -84,6 +86,7 @@ export function CreateResourceDialog({
84
86
  }: CreateResourceDialogProps) {
85
87
  const titleId = useId()
86
88
  const [yaml, setYaml] = useState(initialYaml)
89
+ const pauseContextYaml = useDebouncedValue(onPreview ? '' : yaml, 200)
87
90
  const [mode, setMode] = useState<'apply' | 'create'>(initialMode)
88
91
  const [dryRun, setDryRun] = useState(false)
89
92
  const [force, setForce] = useState(false)
@@ -401,6 +404,7 @@ export function CreateResourceDialog({
401
404
  <YamlReview
402
405
  titleId={titleId}
403
406
  submittedYaml={preview.yaml}
407
+ mode={preview.mode}
404
408
  documents={preview.documents}
405
409
  nonAtomic={preview.nonAtomic}
406
410
  force={preview.force}
@@ -493,6 +497,7 @@ export function CreateResourceDialog({
493
497
  </div>
494
498
  </div>
495
499
 
500
+ {!onPreview && <div className="px-4"><CrossplaneYamlApplyNotice yaml={pauseContextYaml} baselineUnavailable={mode === 'apply'} /></div>}
496
501
  {(error || previewError || partialCreates.length > 0) && (
497
502
  <ApplyErrorBanner error={error || previewError || 'Resources were created before the previous attempt stopped.'} partialCreates={partialCreates} strictCreate={lockMode && mode === 'create'} hasPreview={Boolean(onPreview)} />
498
503
  )}
@@ -0,0 +1,54 @@
1
+ // @vitest-environment jsdom
2
+ //
3
+ // On a phone, a tab that opts into a compact header shows the host's compact
4
+ // identity in place of the full one (whose links aren't rendered at all);
5
+ // the breadcrumb stays, since some hosts navigate through it.
6
+ import { act } from 'react'
7
+ import { createRoot } from 'react-dom/client'
8
+ import { afterEach, expect, it, vi } from 'vitest'
9
+ import { DetailShell } from './DetailShell'
10
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
11
+ afterEach(() => { vi.unstubAllGlobals() })
12
+
13
+ function render({ phone, activeTab }: { phone: boolean; activeTab: 'overview' | 'logs' }) {
14
+ vi.stubGlobal('matchMedia', (query: string) => ({ matches: phone, media: query, addEventListener: () => {}, removeEventListener: () => {} }))
15
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
16
+ const tabs = [{ id: 'overview' as const, label: 'Overview' }, { id: 'logs' as const, label: 'Logs', phoneCompactHeader: true }]
17
+ const host = document.createElement('div'); const root = createRoot(host)
18
+ act(() => root.render(
19
+ <DetailShell
20
+ breadcrumb={<nav>Workspace crumb</nav>}
21
+ identity={<div>web <a href="#owner">Owner link</a></div>}
22
+ compactIdentity={<span>web (compact)</span>}
23
+ headerActions={<button type="button">Investigate</button>}
24
+ subheader={<p>Status summary</p>}
25
+ tabs={tabs}
26
+ activeTab={activeTab}
27
+ onTabChange={() => {}}
28
+ >
29
+ content
30
+ </DetailShell>,
31
+ ))
32
+ const text = host.textContent ?? ''
33
+ act(() => root.unmount())
34
+ return text
35
+ }
36
+
37
+ it('shows only the compact identity on a phone while the tab opts in', () => {
38
+ const text = render({ phone: true, activeTab: 'logs' })
39
+ expect(text).toContain('web (compact)')
40
+ expect(text).toContain('Workspace crumb')
41
+ expect(text).not.toContain('Owner link')
42
+ expect(text).not.toContain('Investigate')
43
+ expect(text).not.toContain('Status summary')
44
+ })
45
+
46
+ it('keeps the full header on other tabs and off a phone', () => {
47
+ for (const args of [{ phone: true, activeTab: 'overview' as const }, { phone: false, activeTab: 'logs' as const }]) {
48
+ const text = render(args)
49
+ expect(text).toContain('Owner link')
50
+ expect(text).toContain('Investigate')
51
+ expect(text).toContain('Status summary')
52
+ expect(text).not.toContain('web (compact)')
53
+ }
54
+ })
@@ -1,5 +1,6 @@
1
1
  import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { clsx } from 'clsx'
3
+ import { usePhoneLayout } from '../../hooks/usePhoneLayout'
3
4
 
4
5
  // Shared tabbed detail chrome. Hosts provide all data-aware pieces; this owns
5
6
  // only the header, tab strip, optional controls, overlay, and content frame.
@@ -11,6 +12,8 @@ export interface DetailShellTab<TId extends string = string> {
11
12
  badge?: ReactNode
12
13
  /** Omit the tab from the strip without disturbing the others' identity. */
13
14
  hidden?: boolean
15
+ /** On a phone, show `compactIdentity` alone in the header while this tab is active. */
16
+ phoneCompactHeader?: boolean
14
17
  }
15
18
 
16
19
  export interface DetailShellProps<TId extends string = string> {
@@ -23,6 +26,8 @@ export interface DetailShellProps<TId extends string = string> {
23
26
  /** Inline leading control on the identity row — a back button in standalone Radar. */
24
27
  nav?: ReactNode
25
28
  identity: ReactNode
29
+ /** Replaces the identity on a phone while a `phoneCompactHeader` tab is active (e.g. the name). The breadcrumb and nav stay. */
30
+ compactIdentity?: ReactNode
26
31
  headerActions?: ReactNode
27
32
  /** A line under the identity row, above the tabs — e.g. a status summary that holds on every tab. */
28
33
  subheader?: ReactNode
@@ -46,6 +51,7 @@ export function DetailShell<TId extends string = string>({
46
51
  breadcrumb,
47
52
  nav,
48
53
  identity,
54
+ compactIdentity,
49
55
  headerActions,
50
56
  subheader,
51
57
  scopeControls,
@@ -71,28 +77,35 @@ export function DetailShell<TId extends string = string>({
71
77
  }
72
78
  }, [activeTab, compact])
73
79
 
80
+ // A tab that wants the screen (Logs) swaps the header for the host's compact
81
+ // identity on a phone, so its content gets the rest of the height.
82
+ const isPhone = usePhoneLayout()
83
+ const phoneCompact = isPhone && compactIdentity != null && !!visibleTabs.find((t) => t.id === activeTab)?.phoneCompactHeader
84
+
74
85
  return (
75
86
  <div className="flex flex-col h-full w-full bg-theme-base">
76
87
  {/* Header */}
77
88
  <div className="shrink-0 border-b border-theme-border bg-theme-base">
78
89
  {!compactHeader && (
79
90
  <>
80
- {breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
81
- <div className={clsx('px-6 flex items-start gap-4', wrapHeader && 'flex-wrap gap-y-2', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
91
+ {breadcrumb && <div className="px-6 pt-2.5 phone:px-4">{breadcrumb}</div>}
92
+ {/* Phones always wrap: the identity takes the row beside the back
93
+ button and the actions drop below it. */}
94
+ <div className={clsx('px-6 flex items-start gap-4 phone:px-4 phone:flex-wrap phone:gap-x-3 phone:gap-y-2', wrapHeader && 'flex-wrap gap-y-2', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
82
95
  {nav}
83
96
  {/* Wrapping on, the identity's basis is its one-line width, so the
84
97
  actions move below whenever the whole title line would not fit
85
98
  beside them — never squeezing the name to keep them up. */}
86
- <div className={clsx('min-w-0', wrapHeader ? 'flex-auto' : 'flex-1')}>{identity}</div>
87
- {wrapHeader ? <div className="ml-auto flex shrink-0 items-start gap-4">{headerActions}</div> : headerActions}
99
+ <div className={clsx('min-w-0 phone:basis-[calc(100%-3rem)]', wrapHeader ? 'flex-auto' : 'flex-1')}>{phoneCompact ? compactIdentity : identity}</div>
100
+ {phoneCompact ? null : wrapHeader ? <div className="ml-auto flex shrink-0 items-start gap-4">{headerActions}</div> : headerActions}
88
101
  </div>
89
- {subheader && <div className="-mt-1 px-6 pb-3">{subheader}</div>}
102
+ {subheader && !phoneCompact && <div className="-mt-1 px-6 pb-3 phone:px-4">{subheader}</div>}
90
103
  </>
91
104
  )}
92
105
 
93
106
  {/* Tabs (left) + scope controls / actions (right) */}
94
- <div className={clsx('flex items-center', compactHeader ? 'px-0' : 'border-t border-theme-border px-6')}>
95
- <div ref={stripRef} className={clsx('flex min-w-0 flex-auto overflow-x-auto', compact ? 'gap-0' : 'gap-1')} role="tablist">
107
+ <div className={clsx('flex items-center phone:flex-wrap', compactHeader ? 'px-0' : 'border-t border-theme-border px-6 phone:px-4')}>
108
+ <div ref={stripRef} className={clsx('flex min-w-0 flex-auto overflow-x-auto phone:basis-full', compact ? 'gap-0' : 'gap-1')} role="tablist">
96
109
  {visibleTabs.map((t) => (
97
110
  <DetailShellTabButton key={t.id} active={activeTab === t.id} compact={compact} onClick={() => onTabChange(t.id)}>
98
111
  {!compact && t.icon}
@@ -102,7 +115,7 @@ export function DetailShell<TId extends string = string>({
102
115
  ))}
103
116
  </div>
104
117
  {(scopeControls || tabStripEnd) && (
105
- <div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2 pl-2">
118
+ <div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2 pl-2 phone:py-2">
106
119
  {scopeControls}
107
120
  {tabStripEnd}
108
121
  </div>
@@ -0,0 +1,44 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+
6
+ vi.mock("../ui/YamlEditor", () => ({ YamlEditor: () => <div />, YamlDiffEditor: () => <div /> }));
7
+
8
+ const { EditableYamlView } = await import("./EditableYamlView");
9
+
10
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
11
+ afterEach(() => vi.unstubAllGlobals());
12
+
13
+ function render(phone: boolean) {
14
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
15
+ vi.stubGlobal("matchMedia", (query: string) => ({ matches: phone, media: query, addEventListener: () => {}, removeEventListener: () => {} }));
16
+ const host = document.createElement("div");
17
+ const root = createRoot(host);
18
+ act(() =>
19
+ root.render(
20
+ <EditableYamlView
21
+ resource={{ kind: "deployments", namespace: "default", name: "web" }}
22
+ data={{ apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "web", namespace: "default" } }}
23
+ onCopy={() => {}}
24
+ copied={false}
25
+ onSave={async () => {}}
26
+ onDuplicate={() => {}}
27
+ />,
28
+ ),
29
+ );
30
+ const buttons = [...host.querySelectorAll("button")].map((b) => b.textContent?.trim());
31
+ act(() => root.unmount());
32
+ return buttons;
33
+ }
34
+
35
+ it("offers Edit and Duplicate off a phone", () => {
36
+ expect(render(false)).toEqual(expect.arrayContaining(["Edit", "Duplicate"]));
37
+ });
38
+
39
+ it("keeps Copy and Download but drops Edit and Duplicate on a phone", () => {
40
+ const buttons = render(true);
41
+ expect(buttons).not.toContain("Edit");
42
+ expect(buttons).not.toContain("Duplicate");
43
+ expect(buttons.some((b) => b?.includes("Download"))).toBe(true);
44
+ });
@@ -7,6 +7,7 @@
7
7
  import { act } from "react";
8
8
  import { createRoot, type Root } from "react-dom/client";
9
9
  import { afterEach, beforeEach, expect, it, vi } from "vitest";
10
+ import { resourceToYaml } from "../../utils/yaml";
10
11
 
11
12
  vi.mock("../ui/YamlEditor", async () => {
12
13
  const { useEffect } = await import("react");
@@ -273,3 +274,60 @@ it("uses the accent token for a generic ConfigMap Edit control", () => {
273
274
  expect(edit.classList.contains("text-accent-text")).toBe(true);
274
275
  expect(edit.className).not.toContain("text-blue-");
275
276
  });
277
+
278
+
279
+ it("keeps the SSA omission caveat in a direct editor save with a known paused baseline", () => {
280
+ const target = { kind: "TaskBucket", namespace: "shop", name: "archive" };
281
+ const baseline = { apiVersion: "example.test/v1", kind: "TaskBucket", metadata: { name: "archive", namespace: "shop", annotations: { "crossplane.io/paused": "true" } }, spec: { providerConfigRef: { name: "default" } } };
282
+ sessionStorage.setItem("radar_yaml_draft:TaskBucket/shop/archive", "apiVersion: example.test/v1\nkind: TaskBucket\nmetadata:\n name: archive\n namespace: shop\nspec:\n providerConfigRef:\n name: default\n");
283
+ act(() => root.render(<EditableYamlView resource={target as never} data={baseline} onCopy={() => {}} copied={false} onSave={async () => {}} />));
284
+ expect(text()).toContain("Pause affects reconciliation");
285
+ expect(text()).toContain("Omitting the annotation from server-side apply YAML does not guarantee its removal");
286
+ });
287
+
288
+ it("does not carry a paused baseline to a different API group in a direct editor save", async () => {
289
+ const target = { kind: "TaskBucket", namespace: "shop", name: "archive" };
290
+ const baseline = { apiVersion: "example.test/v1", kind: "TaskBucket", metadata: { name: "archive", namespace: "shop", annotations: { "crossplane.io/paused": "true" } }, spec: { providerConfigRef: { name: "default" } } };
291
+ const yaml = "apiVersion: another.example.test/v1\nkind: TaskBucket\nmetadata:\n name: archive\n namespace: shop\nspec: {}\n";
292
+ const onSave = vi.fn(async () => {});
293
+ sessionStorage.setItem("radar_yaml_draft:TaskBucket/shop/archive", yaml);
294
+ act(() => root.render(<EditableYamlView resource={target as never} data={baseline} onCopy={() => {}} copied={false} onSave={onSave} />));
295
+ expect(text()).not.toContain("Pause affects reconciliation");
296
+ expect(text()).not.toContain("Existing pause state is unknown");
297
+ await click(/^Save$/);
298
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ kind: "TaskBucket", namespace: "shop", name: "archive", yaml }));
299
+ });
300
+
301
+ it("does not infer annotation omission before the first direct-edit debounce settles", () => {
302
+ vi.useFakeTimers();
303
+ try {
304
+ const target = { kind: "TaskBucket", namespace: "shop", name: "archive" };
305
+ const baseline = { apiVersion: "example.test/v1", kind: "TaskBucket", metadata: { name: "archive", namespace: "shop", annotations: { "crossplane.io/paused": "true" } }, spec: { providerConfigRef: { name: "default" } } };
306
+ act(() => root.render(<EditableYamlView resource={target as never} data={baseline} onCopy={() => {}} copied={false} onSave={async () => {}} />));
307
+ const edit = [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === "Edit")!;
308
+ act(() => edit.click());
309
+ expect(text()).not.toContain("Omitting the annotation");
310
+ act(() => vi.advanceTimersByTime(200));
311
+ expect(text()).toContain("Pause affects reconciliation");
312
+ expect(text()).not.toContain("Omitting the annotation");
313
+ } finally {
314
+ vi.useRealTimers();
315
+ }
316
+ });
317
+
318
+
319
+ it("retains raw acknowledged ref-less pause evidence through editor and status-free review", async () => {
320
+ const target = { kind: "TaskClaim", namespace: "shop", name: "ref-less" };
321
+ const raw = { apiVersion: "example.test/v1", kind: "TaskClaim", metadata: { name: "ref-less", namespace: "shop", annotations: { "crossplane.io/paused": "true" } }, spec: {}, status: { conditions: [{ type: "Synced", status: "False", reason: "ReconcilePaused" }] } };
322
+ const clean = resourceToYaml(raw);
323
+ expect(clean).not.toContain("status:");
324
+ expect(clean).not.toContain("ReconcilePaused");
325
+ const onPreview = vi.fn(async () => ({ documents: [{ index: 0, status: "accepted" as const, action: "update" as const, baselineYaml: clean, predictedYaml: clean, reviewedResourceVersion: "1" }], nonAtomic: false }));
326
+ act(() => root.render(<EditableYamlView resource={target as never} data={raw} onCopy={() => {}} copied={false} onSave={async () => {}} onPreview={onPreview} />));
327
+ await click(/^Edit$/);
328
+ await click(/Review changes/);
329
+ expect(text()).toContain("Pause affects reconciliation");
330
+ expect(text()).toContain("Server preview keeps the pause request on TaskClaim shop/ref-less");
331
+ expect(onPreview).toHaveBeenCalledWith(expect.objectContaining({ yaml: clean }));
332
+ expect(raw.status.conditions[0].reason).toBe("ReconcilePaused");
333
+ });
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useEffect, useRef } from 'react'
1
+ import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
2
  import {
3
3
  Copy,
4
4
  CopyPlus,
@@ -12,6 +12,8 @@ import {
12
12
  } from 'lucide-react'
13
13
  import { Download } from 'lucide-react'
14
14
  import { stringify as yamlStringify } from 'yaml'
15
+ import { useDebouncedValue } from '../../hooks/useDebouncedValue'
16
+ import { CrossplaneYamlApplyNotice } from '../ui/CrossplaneOperationNotice'
15
17
  import { CodeViewer } from '../ui/CodeViewer'
16
18
  import { YamlEditor } from '../ui/YamlEditor'
17
19
  import type { YamlSchemaLoader } from '../ui/YamlEditor'
@@ -21,6 +23,7 @@ import type { SelectedResource } from '../../types'
21
23
  import { resourceToYaml } from '../../utils/yaml'
22
24
  import { triggerDownload } from '../../utils/download'
23
25
  import { isCoreBatchJob } from '../../utils/api-resources'
26
+ import { usePhoneLayout } from '../../hooks/usePhoneLayout'
24
27
 
25
28
  // ============================================================================
26
29
  // SUCCESS ANIMATION
@@ -197,6 +200,10 @@ export function EditableYamlView({
197
200
  onDownload,
198
201
  }: EditableYamlViewProps) {
199
202
  const draftKey = `radar_yaml_draft:${resource.kind}/${resource.namespace}/${resource.name}`
203
+ // Phones read YAML but don't start an edit or a duplicate: authoring YAML by
204
+ // touch is error-prone. Not folded into readOnly, which would discard an edit
205
+ // already open when a phone rotates from landscape to portrait.
206
+ const isPhone = usePhoneLayout()
200
207
 
201
208
  // Restore draft from sessionStorage (e.g., after session-expiry redirect).
202
209
  // All sessionStorage calls are wrapped in try-catch — storage can throw
@@ -207,6 +214,8 @@ export function EditableYamlView({
207
214
  const reviewGeneration = useRef(0)
208
215
  const [isEditing, setIsEditing] = useState(savedDraft.current !== null)
209
216
  const [editedYaml, setEditedYaml] = useState(savedDraft.current ?? '')
217
+ const pauseContextYaml = useDebouncedValue(!onPreview && isEditing ? editedYaml : '', 200)
218
+ const pauseBaselineYaml = useMemo(() => !onPreview && isEditing ? resourceToYaml(data) : '', [data, onPreview, isEditing])
210
219
  const [yamlErrors, setYamlErrors] = useState<string[]>([])
211
220
  const [showErrorDetails, setShowErrorDetails] = useState(false)
212
221
  // Default on: the editor resubmits the full live manifest, so an unforced
@@ -216,6 +225,7 @@ export function EditableYamlView({
216
225
  yaml: string
217
226
  force: boolean
218
227
  originalYaml: string
228
+ resourceData: unknown
219
229
  documents: YamlPreviewResult[]
220
230
  nonAtomic: boolean
221
231
  context?: string
@@ -293,6 +303,7 @@ export function EditableYamlView({
293
303
  yaml: editedYaml,
294
304
  force,
295
305
  originalYaml: resourceToYaml(data),
306
+ resourceData: data,
296
307
  documents: result.documents,
297
308
  nonAtomic: result.nonAtomic,
298
309
  context: result.context,
@@ -382,7 +393,9 @@ export function EditableYamlView({
382
393
  return (
383
394
  <YamlReview
384
395
  submittedYaml={preview.yaml}
396
+ mode="update"
385
397
  originalYaml={preview.originalYaml}
398
+ resourceData={preview.resourceData}
386
399
  documents={preview.documents}
387
400
  nonAtomic={preview.nonAtomic}
388
401
  force={preview.force}
@@ -465,6 +478,8 @@ export function EditableYamlView({
465
478
  </div>
466
479
  </div>
467
480
 
481
+ {!onPreview && <div className="px-4 pt-2"><CrossplaneYamlApplyNotice yaml={pauseContextYaml} baselineYaml={pauseBaselineYaml} resourceData={data} /></div>}
482
+
468
483
  {editWarning && (
469
484
  <div className="px-4 py-2.5 bg-amber-500/10 dark:bg-yellow-500/10 border-b border-amber-300 dark:border-yellow-500/30">
470
485
  <div className="flex items-start gap-2">
@@ -549,7 +564,7 @@ export function EditableYamlView({
549
564
  <div className="flex items-center justify-between mb-2">
550
565
  <span className="text-sm font-medium text-theme-text-secondary">YAML</span>
551
566
  <div className="flex items-center gap-2">
552
- {!readOnly && (
567
+ {!readOnly && !isPhone && (
553
568
  <button
554
569
  onClick={handleStartEdit}
555
570
  className="flex items-center gap-1 px-2 py-1 text-xs text-accent-text hover:text-accent-light hover:bg-theme-elevated rounded"
@@ -578,7 +593,7 @@ export function EditableYamlView({
578
593
  Download
579
594
  </button>
580
595
  </Tooltip>
581
- {onDuplicate && (
596
+ {onDuplicate && !isPhone && (
582
597
  <Tooltip content="Duplicate as new resource">
583
598
  <button
584
599
  onClick={() =>
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { afterEach, expect, it, vi } from 'vitest'
5
+ import { FilterRailPanel } from './FilterRailPanel'
6
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
7
+ afterEach(() => { vi.unstubAllGlobals() })
8
+
9
+ function stubPhone(matches: boolean) {
10
+ vi.stubGlobal('matchMedia', (query: string) => ({ matches, media: query, addEventListener: () => {}, removeEventListener: () => {} }))
11
+ }
12
+
13
+ function render(activeCount: number) {
14
+ const host = document.createElement('div'); const root = createRoot(host)
15
+ act(() => root.render(<FilterRailPanel activeCount={activeCount} clear={<button type="button">Clear</button>}><div>Status facet</div></FilterRailPanel>))
16
+ return host
17
+ }
18
+
19
+ it('keeps the plain header and an open body off a phone', () => {
20
+ stubPhone(false)
21
+ const host = render(2)
22
+ expect(host.querySelector('[aria-expanded]')).toBeNull()
23
+ expect(host.textContent).toContain('Status facet')
24
+ expect(host.textContent).not.toContain('2')
25
+ })
26
+
27
+ it('collapses the facets behind a Filters button with the active count on a phone', () => {
28
+ stubPhone(true)
29
+ const host = render(2)
30
+ const button = host.querySelector<HTMLButtonElement>('button[aria-expanded]')!
31
+ expect(button.textContent).toContain('Filters')
32
+ expect(button.textContent).toContain('2')
33
+ expect(button.getAttribute('aria-expanded')).toBe('false')
34
+ const panel = host.querySelector(`#${button.getAttribute('aria-controls')}`)!
35
+ expect(panel.querySelector('[inert]')).not.toBeNull()
36
+ act(() => button.click())
37
+ expect(button.getAttribute('aria-expanded')).toBe('true')
38
+ expect(panel.querySelector('[inert]')).toBeNull()
39
+ })