@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.
- package/README.md +21 -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/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- 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/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- 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.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- 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/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- 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-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -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 +21 -8
- 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.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- 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/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- 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 +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- 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/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -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 +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- package/vitest.config.ts +26 -0
|
@@ -1,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
|
-
|
|
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
|
+
})
|