@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
@@ -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, 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'
@@ -57,11 +57,13 @@ import {
57
57
  import { ResourceRendererDispatch, getResourceStatus, diagnoseHealthHint, type DiagnoseHealthHint, type RendererOverrides } from '../shared/ResourceRendererDispatch'
58
58
  import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
59
59
  import { DetailShell, type DetailShellTab } from '../shared/DetailShell'
60
- import { HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
60
+ import { gitOpsOwnerKindLabel, HelmManagedByChip, ManagedByChip, type HelmOwnerRef } from '../shared/ManagedByChip'
61
61
  import { AlertBanner, getKindColorOutline, displayKindName, OperationalIssuesShownContext, ResourceRefBadge } from '../ui/drawer-components'
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,
@@ -216,6 +218,8 @@ interface WorkloadViewProps {
216
218
  /** Timeline events the history lanes are built from: this workload's own
217
219
  * history, or events for its namespace from a host that has no scoped query. */
218
220
  allEvents?: TimelineEvent[]
221
+ /** Reveals one event, loading older pages if needed. Use a new object for each navigation request. */
222
+ timelineFocusEvent?: { id: string }
219
223
  /** Older history exists beyond allEvents. */
220
224
  historyTruncated?: boolean
221
225
  /** Loads the next older page of history; omit when the host can't page. */
@@ -469,6 +473,7 @@ export function WorkloadView({
469
473
  refetch: refetchProp,
470
474
  // Timeline
471
475
  allEvents,
476
+ timelineFocusEvent,
472
477
  historyTruncated = false,
473
478
  onLoadOlderHistory,
474
479
  loadingOlderHistory = false,
@@ -537,7 +542,7 @@ export function WorkloadView({
537
542
  gitOpsOwnerPending = false,
538
543
  gitOpsOwnerSource,
539
544
  gitOpsOwnerStatus,
540
- helmOwner,
545
+ helmOwner: resolvedHelmOwner,
541
546
  helmOwnerSource,
542
547
  onOpenHelmRelease,
543
548
  onNavigateGitOpsPath,
@@ -722,7 +727,24 @@ export function WorkloadView({
722
727
  // Metadata
723
728
  const metadata = useMemo(() => extractMetadata(kind, resource), [kind, resource])
724
729
  const relationshipGitOpsOwner = useMemo(() => gitOpsOwnerFromRelationships(relationships), [relationships])
725
- const gitopsOwner = resolvedGitOpsOwner ?? relationshipGitOpsOwner
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)
736
+ const listing = useMemo(
737
+ () => managerListing(gitopsOwner, relationships?.managers, gitOpsOwnerVerified, !relationships?.ownership?.managers),
738
+ [gitopsOwner, relationships?.managers, relationships?.ownership?.managers, gitOpsOwnerVerified],
739
+ )
740
+ // When the chip's manager is the only one whose status lists the resource,
741
+ // the chip says it all: Related Resources would repeat it.
742
+ const rendererRelationships = useMemo(
743
+ () => (relationships && (manager ? managerChipCoversGroup(relationships) : listing?.state === 'confirmed') ? { ...relationships, managers: undefined } : relationships),
744
+ [relationships, manager, listing],
745
+ )
746
+ // When they disagree, Related Resources says which manager the label names.
747
+ const trackedBy = !manager && listing?.state === 'conflict' && gitopsOwner ? `${gitOpsOwnerKindLabel(gitopsOwner)} ${gitopsOwner.namespace ? `${gitopsOwner.namespace}/` : ''}${gitopsOwner.name}` : undefined
726
748
  // When the resource itself is a portal GitOps CR (Application, Kustomization,
727
749
  // HelmRelease, etc.), surface a link to its dedicated GitOps detail page —
728
750
  // the drawer's renderer is thorough but the tab has the tree + insights +
@@ -820,6 +842,7 @@ export function WorkloadView({
820
842
  ? getWorkloadDisplayStatus(resource, apiKind, workloadPods)
821
843
  : null
822
844
  const rolloutActivity = rolloutDisplay?.activity ?? null
845
+ const crossplaneResource = isManagedResource(resource) || isComposite(resource) || getCrossplanePauseState(resource) === 'acknowledged'
823
846
  // A 404 with a cached copy: the object was deleted while this view was
824
847
  // open. Everything below that copy describes the past.
825
848
  const resourceGone = Boolean(resource) && isFetchError(resourceError) && resourceError.status === 404
@@ -907,7 +930,7 @@ export function WorkloadView({
907
930
  icon: <Activity className="w-4 h-4" />,
908
931
  badge: resourceEvents.length > 0 ? <span className="ml-1 badge-sm bg-theme-elevated">{resourceEvents.length}</span> : undefined,
909
932
  },
910
- { 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 },
911
934
  { id: 'metrics', label: 'Metrics', icon: <BarChart3 className="w-4 h-4" />, hidden: !metricsTabVisible },
912
935
  {
913
936
  id: 'reachability',
@@ -960,6 +983,7 @@ export function WorkloadView({
960
983
  {status.text}
961
984
  </span>
962
985
  )}
986
+ {!resourceGone && crossplaneResource && <CrossplanePauseChip state={getCrossplanePauseState(resource)} />}
963
987
  </div>
964
988
  <div className="flex shrink-0 flex-nowrap items-center justify-end gap-1.5">
965
989
  {diagnoseAction}
@@ -1020,10 +1044,10 @@ export function WorkloadView({
1020
1044
  </Tooltip>
1021
1045
  </div>
1022
1046
  <p className="text-sm text-theme-text-tertiary">{namespace}</p>
1023
- {(gitopsOwner || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath) || onOpenLiveTraffic) && (
1047
+ {(showGitOpsChip || helmOwner || (gitOpsResourcePath && onNavigateGitOpsPath) || onOpenLiveTraffic) && (
1024
1048
  <div className="mt-1 flex flex-wrap items-center gap-1.5">
1025
- {gitopsOwner && <ManagedByChip owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} onOpen={onOpenGitOpsResource} />}
1026
- {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} />}
1027
1051
  {gitOpsResourcePath && onNavigateGitOpsPath && (
1028
1052
  <OpenInGitOpsChip onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
1029
1053
  )}
@@ -1109,7 +1133,8 @@ export function WorkloadView({
1109
1133
  <ResourceRendererDispatch
1110
1134
  resource={selectedResource}
1111
1135
  data={resource}
1112
- relationships={relationships}
1136
+ relationships={rendererRelationships}
1137
+ trackedBy={trackedBy}
1113
1138
  certificateInfo={certificateInfo}
1114
1139
  hpaDiagnosis={hpaDiagnosis}
1115
1140
  scalerDiagnostics={scalerDiagnostics}
@@ -1139,10 +1164,35 @@ export function WorkloadView({
1139
1164
  }
1140
1165
 
1141
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
+ )
1142
1181
  return (
1143
1182
  <OperationalIssuesShownContext.Provider value={!!hasOperationalIssues || !!operationalIssuesPending}>
1144
1183
  <DetailShell
1145
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
+ }
1146
1196
  wrapHeader={inlineBadges}
1147
1197
  subheader={resourceGone ? undefined : subheader}
1148
1198
  nav={
@@ -1159,20 +1209,6 @@ export function WorkloadView({
1159
1209
  )
1160
1210
  }
1161
1211
  identity={(() => {
1162
- const nameAndCopy = (
1163
- <>
1164
- <h1 className="text-lg font-semibold text-theme-text-primary truncate">{name}</h1>
1165
- <Tooltip content="Copy name" delay={150}>
1166
- <button
1167
- onClick={() => copyToClipboard(name, 'name')}
1168
- className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded shrink-0"
1169
- aria-label="Copy name"
1170
- >
1171
- {copied === 'name' ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
1172
- </button>
1173
- </Tooltip>
1174
- </>
1175
- )
1176
1212
  const kindAndStatus = (
1177
1213
  <>
1178
1214
  {!hideKindBadge && (
@@ -1185,6 +1221,7 @@ export function WorkloadView({
1185
1221
  {status.text}
1186
1222
  </span>
1187
1223
  )}
1224
+ {!resourceGone && crossplaneResource && <CrossplanePauseChip state={getCrossplanePauseState(resource)} />}
1188
1225
  {!resourceGone && statusNote}
1189
1226
  </>
1190
1227
  )
@@ -1206,11 +1243,11 @@ export function WorkloadView({
1206
1243
  </span>
1207
1244
  </Tooltip>
1208
1245
  ),
1209
- gitopsOwner && (
1210
- <ManagedByChip key="gitops" owner={gitopsOwner} status={gitOpsOwnerStatus} verified={gitOpsOwnerVerified} pending={gitOpsOwnerPending} source={gitOpsOwnerSource} 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" />
1211
1248
  ),
1212
1249
  helmOwner && (
1213
- <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" />
1214
1251
  ),
1215
1252
  gitOpsResourcePath && onNavigateGitOpsPath && (
1216
1253
  <OpenInGitOpsChip key="open-gitops" onClick={() => onNavigateGitOpsPath(gitOpsResourcePath)} />
@@ -1322,7 +1359,8 @@ export function WorkloadView({
1322
1359
  <InfoTab
1323
1360
  resource={resource}
1324
1361
  selectedResource={selectedResource}
1325
- relationships={relationships}
1362
+ relationships={rendererRelationships}
1363
+ trackedBy={trackedBy}
1326
1364
  certificateInfo={certificateInfo}
1327
1365
  hpaDiagnosis={hpaDiagnosis}
1328
1366
  scalerDiagnostics={scalerDiagnostics}
@@ -1383,6 +1421,7 @@ export function WorkloadView({
1383
1421
  )}
1384
1422
  {effectiveTab === 'timeline' && (
1385
1423
  <EventsTab
1424
+ focusEvent={timelineFocusEvent}
1386
1425
  events={resourceEvents}
1387
1426
  isLoading={eventsLoading}
1388
1427
  truncated={historyTruncated}
@@ -1429,10 +1468,12 @@ export function WorkloadView({
1429
1468
  </div>
1430
1469
  )}
1431
1470
  {effectiveTab === 'yaml' && (
1432
- <div className="flex h-full min-h-0">
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">
1433
1474
  {renderRelatedYaml && yamlObjects.length > 1 && (
1434
- <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">
1435
- <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>
1436
1477
  {yamlObjects.map((o) => {
1437
1478
  const active = o.primary ? yamlObjectId === null : yamlObjectId === o.id
1438
1479
  return (
@@ -1441,7 +1482,7 @@ export function WorkloadView({
1441
1482
  type="button"
1442
1483
  onClick={() => setYamlObjectId(o.primary ? null : o.id)}
1443
1484
  className={clsx(
1444
- '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',
1445
1486
  active ? 'selection selection-ring' : 'hover:bg-theme-hover',
1446
1487
  )}
1447
1488
  >
@@ -1688,6 +1729,7 @@ function OwnershipHeading({
1688
1729
  // ============================================================================
1689
1730
 
1690
1731
  function EventsTab({
1732
+ focusEvent,
1691
1733
  events,
1692
1734
  isLoading,
1693
1735
  truncated,
@@ -1703,6 +1745,7 @@ function EventsTab({
1703
1745
  topology,
1704
1746
  onResourceClick,
1705
1747
  }: {
1748
+ focusEvent?: { id: string }
1706
1749
  events: TimelineEvent[]
1707
1750
  isLoading: boolean
1708
1751
  truncated?: boolean
@@ -1798,6 +1841,22 @@ function EventsTab({
1798
1841
  onSelectEvent(id)
1799
1842
  }, [onSelectEvent])
1800
1843
 
1844
+ const focusedRequest = useRef<typeof focusEvent>(undefined)
1845
+ const requestedPage = useRef<{ focus: typeof focusEvent; events: TimelineEvent[] } | undefined>(undefined)
1846
+ const focusFound = Boolean(focusEvent && cleanEvents.some((event) => event.id === focusEvent.id))
1847
+ useEffect(() => {
1848
+ if (!focusEvent || focusedRequest.current === focusEvent || isLoading) return
1849
+ if (focusFound) {
1850
+ handleSelect(focusEvent.id)
1851
+ focusedRequest.current = focusEvent
1852
+ } else if (truncated && onLoadOlder && !loadingOlder && !olderError && !error) {
1853
+ // Strict Mode can replay this effect before the loading prop updates.
1854
+ if (requestedPage.current?.focus === focusEvent && requestedPage.current.events === events) return
1855
+ requestedPage.current = { focus: focusEvent, events }
1856
+ onLoadOlder()
1857
+ }
1858
+ }, [focusEvent, focusFound, events, isLoading, truncated, onLoadOlder, loadingOlder, olderError, error, handleSelect])
1859
+
1801
1860
  if (isLoading) {
1802
1861
  return (
1803
1862
  <div className="flex items-center justify-center h-full text-theme-text-tertiary">
@@ -1827,6 +1886,15 @@ function EventsTab({
1827
1886
 
1828
1887
  return (
1829
1888
  <div className="h-full flex flex-col overflow-hidden">
1889
+ {focusEvent && !focusFound && (
1890
+ <div className="shrink-0 border-b border-theme-border px-4 py-2 text-xs text-theme-text-secondary" role="status">
1891
+ {olderError || error
1892
+ ? "Couldn't find the reported error because some history couldn't be loaded."
1893
+ : truncated && onLoadOlder
1894
+ ? 'Finding the reported error in older history…'
1895
+ : 'The reported error is not available in the retained timeline history.'}
1896
+ </div>
1897
+ )}
1830
1898
  {error && (
1831
1899
  <div className="shrink-0 flex flex-wrap items-center gap-x-2 border-b border-theme-border bg-theme-base px-4 py-1.5 text-xs" role="alert">
1832
1900
  <span className="text-[var(--color-error)]" title={error.message}>
@@ -1932,6 +2000,7 @@ function InfoTab({
1932
2000
  resource,
1933
2001
  selectedResource,
1934
2002
  relationships,
2003
+ trackedBy,
1935
2004
  certificateInfo,
1936
2005
  hpaDiagnosis,
1937
2006
  scalerDiagnostics,
@@ -1968,6 +2037,7 @@ function InfoTab({
1968
2037
  resource: any
1969
2038
  selectedResource: SelectedResource
1970
2039
  relationships?: Relationships
2040
+ trackedBy?: string
1971
2041
  certificateInfo?: any
1972
2042
  hpaDiagnosis?: HPADiagnosis
1973
2043
  scalerDiagnostics?: ScalerDiagnosis[]
@@ -2022,6 +2092,7 @@ function InfoTab({
2022
2092
  resource={selectedResource}
2023
2093
  data={resource}
2024
2094
  relationships={relationships}
2095
+ trackedBy={trackedBy}
2025
2096
  certificateInfo={certificateInfo}
2026
2097
  hpaDiagnosis={hpaDiagnosis}
2027
2098
  scalerDiagnostics={scalerDiagnostics}
@@ -2252,7 +2323,7 @@ function WorkloadOverviewTab({
2252
2323
 
2253
2324
  <div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
2254
2325
  <div className="space-y-4">
2255
- <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)]">
2256
2327
  <div className="space-y-4">
2257
2328
  {showServingPath && (
2258
2329
  <ServingConfigurationCard
@@ -2345,7 +2416,7 @@ function WorkloadOverviewTab({
2345
2416
  <div className="grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_360px] 2xl:grid-cols-[minmax(0,1fr)_400px]">
2346
2417
  <div className="space-y-4">
2347
2418
  <CurrentStateCard resource={resource} apiKind={apiKind} readiness={readiness} />
2348
- <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">
2349
2420
  {overviewShape === 'cronjob' ? (
2350
2421
  <CronJobRunsCard
2351
2422
  resource={resource}
@@ -2385,7 +2456,7 @@ function WorkloadOverviewTab({
2385
2456
  />
2386
2457
  ) : null}
2387
2458
  {extraContent && (
2388
- <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">
2389
2460
  {extraContent}
2390
2461
  </div>
2391
2462
  )}
@@ -1,6 +1,8 @@
1
1
  export { useAnimatedUnmount } from './useAnimatedUnmount'
2
2
  export { useDebouncedValue } from './useDebouncedValue'
3
3
  export { useRefreshAnimation } from './useRefreshAnimation'
4
+ export { useMediaQuery } from './useMediaQuery'
5
+ export { usePhoneLayout, PHONE_MEDIA_QUERY } from './usePhoneLayout'
4
6
  export {
5
7
  KeyboardShortcutProvider,
6
8
  useRegisterShortcut,
@@ -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
+ })
@@ -0,0 +1,87 @@
1
+ import { useLayoutEffect, useEffect, type RefObject } from 'react'
2
+
3
+ /** Keeps an open menu or popover horizontally inside the visible area: the
4
+ * viewport, narrowed by any ancestor that clips horizontally (a content
5
+ * column beside a nav rail clips what slides under the rail). Anchored menus
6
+ * open at their trigger's edge with a fixed minimum width, so on a narrow
7
+ * screen a trigger near the edge pushes the menu out of view. After each
8
+ * layout, and on resize while open, this shifts the element back with the
9
+ * `translate` property (which moves it whichever side it's anchored to), and
10
+ * narrows it only if it can't fit. Don't use it on an element whose own
11
+ * motion animates `translate`: the inline value would override it.
12
+ *
13
+ * Runs after every render while `active`, so a menu whose content changes
14
+ * width (a filter narrowing a list) is re-measured. */
15
+ export function useKeepInViewport(ref: RefObject<HTMLElement | null>, active: boolean, margin = 8): void {
16
+ useLayoutEffect(() => {
17
+ if (active) keepInView(ref.current, margin)
18
+ })
19
+ useEffect(() => {
20
+ if (!active) return
21
+ const onResize = () => keepInView(ref.current, margin)
22
+ window.addEventListener('resize', onResize)
23
+ return () => window.removeEventListener('resize', onResize)
24
+ }, [ref, active, margin])
25
+ }
26
+
27
+ // Width overrides this hook applied, keyed by element, with the inline values
28
+ // they replaced, so a later measurement can put back exactly what the caller set
29
+ // (React won't re-apply an inline style whose value it thinks is unchanged).
30
+ const sizedBy = new WeakMap<HTMLElement, { minWidth: string; maxWidth: string }>()
31
+
32
+ function keepInView(el: HTMLElement | null, margin: number): void {
33
+ if (!el) return
34
+ // Measure and move with transitions off. Under reduced motion every
35
+ // element gets a near-zero transition on all properties, and a transitioned
36
+ // element reports its pre-change box in the same frame: a menu just moved
37
+ // from left 0 to its trigger still measured at 0, and was pushed off its
38
+ // trigger by the margin.
39
+ const transition = el.style.transition
40
+ el.style.transition = 'none'
41
+ place(el, margin)
42
+ el.getBoundingClientRect() // apply the move before transitions return
43
+ el.style.transition = transition
44
+ }
45
+
46
+ function place(el: HTMLElement, margin: number): void {
47
+ el.style.translate = ''
48
+ const saved = sizedBy.get(el)
49
+ if (saved) {
50
+ el.style.minWidth = saved.minWidth
51
+ el.style.maxWidth = saved.maxWidth
52
+ sizedBy.delete(el)
53
+ }
54
+ const [boundLeft, boundRight] = visibleBounds(el, margin)
55
+ const room = boundRight - boundLeft
56
+ // Only a menu that can't fit gets resized; otherwise the caller's own
57
+ // minimum and maximum widths stand.
58
+ if (el.offsetWidth > room) {
59
+ sizedBy.set(el, { minWidth: el.style.minWidth, maxWidth: el.style.maxWidth })
60
+ el.style.minWidth = '0'
61
+ el.style.maxWidth = `${room}px`
62
+ }
63
+ const { left, right } = el.getBoundingClientRect()
64
+ const shift = keepInViewportShift(left, right, boundLeft, boundRight)
65
+ if (shift !== 0) el.style.translate = `${shift}px 0`
66
+ }
67
+
68
+ function visibleBounds(el: HTMLElement, margin: number): [number, number] {
69
+ let left = margin
70
+ let right = document.documentElement.clientWidth - margin
71
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
72
+ if (getComputedStyle(p).overflowX === 'visible') continue
73
+ const r = p.getBoundingClientRect()
74
+ left = Math.max(left, r.left + margin)
75
+ right = Math.min(right, r.right - margin)
76
+ }
77
+ return [left, Math.max(left, right)]
78
+ }
79
+
80
+ /** Horizontal shift (px) that brings [left, right] inside [boundLeft,
81
+ * boundRight], preferring the left edge when the element is wider. */
82
+ export function keepInViewportShift(left: number, right: number, boundLeft: number, boundRight: number): number {
83
+ let shift = 0
84
+ if (right > boundRight) shift = boundRight - right
85
+ if (left + shift < boundLeft) shift = boundLeft - left
86
+ return shift
87
+ }
@@ -0,0 +1,21 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ // Subscribe to a CSS media query. SSR-safe (returns false before mount) and
4
+ // updates on viewport changes.
5
+ export function useMediaQuery(query: string): boolean {
6
+ const [matches, setMatches] = useState(() => {
7
+ if (typeof window === 'undefined' || !window.matchMedia) return false
8
+ return window.matchMedia(query).matches
9
+ })
10
+
11
+ useEffect(() => {
12
+ if (typeof window === 'undefined' || !window.matchMedia) return
13
+ const mql = window.matchMedia(query)
14
+ const onChange = () => setMatches(mql.matches)
15
+ onChange()
16
+ mql.addEventListener('change', onChange)
17
+ return () => mql.removeEventListener('change', onChange)
18
+ }, [query])
19
+
20
+ return matches
21
+ }
@@ -0,0 +1,12 @@
1
+ import { useMediaQuery } from './useMediaQuery'
2
+
3
+ // A phone: a narrow screen whose primary pointer is touch. A desktop browser
4
+ // narrowed below this width keeps the desktop layout (and its content floor),
5
+ // since a wider window can show the full UI; browser dev-tools device mode
6
+ // emulates touch and gets the phone layout. Keep in sync with the `phone:`
7
+ // variant in theme/tailwind-theme.css.
8
+ export const PHONE_MEDIA_QUERY = '(max-width: 639px) and (pointer: coarse)'
9
+
10
+ export function usePhoneLayout(): boolean {
11
+ return useMediaQuery(PHONE_MEDIA_QUERY)
12
+ }
@@ -6,6 +6,11 @@
6
6
  * Import after variables.css.
7
7
  */
8
8
 
9
+ /* `phone:` styles apply on a narrow touch-first screen only (see
10
+ * PHONE_MEDIA_QUERY in hooks/usePhoneLayout.ts). A narrowed desktop window
11
+ * keeps the desktop layout. */
12
+ @custom-variant phone (@media (max-width: 639px) and (pointer: coarse));
13
+
9
14
  @theme inline {
10
15
  /* Backgrounds */
11
16
  --color-theme-sidebar: var(--bg-sidebar);