@skyhook-io/k8s-ui 1.18.1 → 1.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +8 -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/ResourceCompareView.tsx +6 -2
  6. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  7. package/src/components/issues/IssuesView.tsx +2 -2
  8. package/src/components/issues/diagnostic.ts +2 -0
  9. package/src/components/issues/severity.ts +1 -0
  10. package/src/components/logs/LogCore.tsx +13 -11
  11. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  12. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  13. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  14. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  15. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  16. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  17. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  18. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  19. package/src/components/resources/ResourcesView.tsx +238 -66
  20. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  21. package/src/components/resources/generic-status.ts +15 -0
  22. package/src/components/resources/printer-columns.test.ts +36 -0
  23. package/src/components/resources/printer-columns.ts +12 -1
  24. package/src/components/resources/readiness-gates.test.ts +95 -0
  25. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  26. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  27. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  28. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  29. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  30. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  31. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  32. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  33. package/src/components/resources/renderers/index.ts +3 -0
  34. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  35. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  36. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  37. package/src/components/resources/resource-utils-flagger.ts +76 -0
  38. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  39. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  40. package/src/components/resources/resource-utils.ts +80 -2
  41. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  42. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  43. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  44. package/src/components/shared/DetailShell.tsx +14 -3
  45. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  46. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  47. package/src/components/shared/EditableYamlView.tsx +18 -3
  48. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  49. package/src/components/shared/FilterRailPanel.tsx +62 -0
  50. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  51. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  52. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  53. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  54. package/src/components/shared/ManagedByChip.tsx +135 -2
  55. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  56. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  57. package/src/components/shared/SetImageDialog.tsx +3 -0
  58. package/src/components/topology/TopologyControls.tsx +1 -1
  59. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  60. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  61. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  62. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  63. package/src/components/ui/PageHeader.tsx +3 -2
  64. package/src/components/ui/SummaryTile.tsx +13 -3
  65. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  66. package/src/components/ui/Tooltip.tsx +11 -2
  67. package/src/components/ui/YamlReview.test.tsx +52 -0
  68. package/src/components/ui/YamlReview.tsx +7 -0
  69. package/src/components/ui/drawer-components.test.tsx +27 -0
  70. package/src/components/ui/drawer-components.tsx +22 -42
  71. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  72. package/src/components/workload/WorkloadView.tsx +57 -34
  73. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  74. package/src/hooks/useKeepInViewport.test.ts +27 -0
  75. package/src/hooks/useKeepInViewport.ts +87 -0
  76. package/src/types/core.ts +53 -1
  77. package/src/utils/api-resources.ts +1 -0
  78. package/src/utils/gitops-owner.ts +136 -8
  79. package/src/utils/gitops-write-guard.test.ts +332 -0
  80. package/src/utils/gitops-write-guard.ts +175 -11
  81. package/src/utils/index.ts +1 -0
  82. package/src/utils/read-actions.ts +34 -0
  83. package/src/utils/touch-intent.test.ts +39 -0
  84. package/src/utils/touch-intent.ts +35 -0
  85. package/vitest.config.ts +26 -0
package/README.md CHANGED
@@ -83,3 +83,11 @@ tooltip. `certaintyGlyph` and `certaintyValueLabel` expose the same vocabulary.
83
83
  `ActionConfirmDialog` adds context, reviewed writes and optional typed acknowledgement to the simpler `ConfirmDialog`. Hosts own loading, permission checks, stale-fact validation and result classification. Its `result` binds the state (`error`, `unknown` or `partial`), message and optional heading; unknown and partial results lock another submit. Retain that result outside the dialog across dismissal and remount, and clear it only after explicitly reviewing current state. The component does not infer outcomes from HTTP status. Remount it when changing the reviewed target or action, so acknowledgement and disclosure state start fresh. The default error heading makes no claim about whether a write occurred; use a stronger result title only when the producer proves it.
84
84
 
85
85
  Existing CNPG primitive exports remain available and delegate to these shared implementations.
86
+
87
+ `PrinterColumnDef.jsonPath` optionally carries source provenance from newer Radar servers. The UI does not evaluate it: values still come from the server's table converter. Without it, printer-cell tones stay generic. Crossplane pause qualification only recognizes exact Ready/Synced condition paths and corroborated annotation/condition evidence; same-named unrelated columns retain their normal tones. `ConditionTone` also supports opt-in `neutral`; `defaultConditionTone` retains its original return vocabulary. `ComposedRefStatus.pauseState` optionally supplies recorded Crossplane pause context for a composed-resource row.
88
+
89
+ `ForceDeleteConfirmDialog.resourceData` optionally supplies the current object for Crossplane pause context. Paused MR/XR/Claim shapes holding a known Crossplane finalizer receive a cleanup warning; the prop does not change confirmation or force-delete behavior. YAML operation context uses the existing baseline and predicted manifests; removing a pause annotation requests resumption rather than proving it.
90
+
91
+ `YamlReview.mode` distinguishes Apply, strict Create and Update (default: `apply`). Hosts using strict creation should pass `create` so an unpreviewable new document does not imply unknown live pause state. Crossplane operation context reports what the server preview actually keeps or clears; unsupported dry-run documents retain conditional uncertainty rather than treating submitted YAML as a live baseline.
92
+
93
+ For a single editor target, `YamlReview.resourceData` may retain its raw condition evidence when the review YAML intentionally omits status. `EditableYamlView` binds that snapshot to its preview. It is not used to classify unrelated documents in a batch. Generic Apply with neither a known Crossplane shape nor raw acknowledged pause does not infer a resource’s controller from the annotation alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.18.1",
3
+ "version": "1.18.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -46,6 +46,7 @@ import {
46
46
  import { ReadyBar } from './ReadyBar'
47
47
  import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
48
48
  import { AppIdentityTooltip, EnvHint, RolloutSummaryTooltip } from './AppTooltips'
49
+ import { FilterRailPanel } from '../shared/FilterRailPanel'
49
50
 
50
51
  function ApplicationRuntimeCell({ apps, workloadClass, ready, desired }: { apps: AppRow[]; workloadClass: AppWorkloadClass; ready: number; desired: number }) {
51
52
  if (workloadClass !== 'job') {
@@ -337,6 +338,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
337
338
  // showSystem lives in the Filters rail, so Clear resets it too (and its
338
339
  // non-default ON state counts as an active filter that surfaces the button).
339
340
  const anyFilterActive = filters.isActive
341
+ const activeFilterCount = fHealth.size + fClass.size + fType.size + fEnv.size + fSource.size + (systemCount > 0 && showSystem ? 1 : 0)
340
342
  const clearAllFilters = () => {
341
343
  filters.clearAll()
342
344
  }
@@ -355,7 +357,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
355
357
  actions={
356
358
  <>
357
359
  {headerActions}
358
- <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} loading={initialLoading} />
360
+ <SummaryTile label={total === 1 ? 'application' : 'applications'} phoneLabel={total === 1 ? 'app' : 'apps'} value={total} loading={initialLoading} />
359
361
  {healthTile('unhealthy', 'error')}
360
362
  {healthTile('degraded', 'warning')}
361
363
  {healthTile('healthy', 'success')}
@@ -381,13 +383,12 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
381
383
  Arbitrary 900px breakpoint (not `sm`) so the collapse is independent of
382
384
  the consuming app's Tailwind breakpoint config (Hub uses defaults). */}
383
385
  <aside className="flex w-52 shrink-0 flex-col overflow-hidden border-r border-theme-border bg-theme-surface/90 max-[899px]:max-h-72 max-[899px]:w-full max-[899px]:border-b max-[899px]:border-r-0">
384
- <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
385
- <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
386
- {anyFilterActive && (
386
+ <FilterRailPanel
387
+ activeCount={activeFilterCount}
388
+ clear={anyFilterActive && (
387
389
  <button type="button" onClick={clearAllFilters} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">Clear</button>
388
390
  )}
389
- </div>
390
- <div className="flex-1 overflow-y-auto">
391
+ >
391
392
  {initialLoading ? (
392
393
  <BoardRailSkeleton
393
394
  sections={[
@@ -414,7 +415,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
414
415
  )}
415
416
  </>
416
417
  )}
417
- </div>
418
+ </FilterRailPanel>
418
419
  </aside>
419
420
 
420
421
  {/* Content: toolbar (search + sort) over the scrollable table. */}
@@ -12,6 +12,7 @@ import { ClusterName } from '../ui/ClusterName'
12
12
  import { Input } from '../ui/Input'
13
13
  import { MiddleEllipsis } from '../ui/MiddleEllipsis'
14
14
  import { StatusDot, type StatusTone } from '../ui/status-tone'
15
+ import { useKeepInViewport } from '../../hooks/useKeepInViewport'
15
16
 
16
17
  export interface ClusterSwitcherItem {
17
18
  id: string
@@ -107,6 +108,8 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
107
108
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
108
109
  const rootRef = useRef<HTMLDivElement>(null)
109
110
  const searchInputRef = useRef<HTMLInputElement>(null)
111
+ const menuRef = useRef<HTMLDivElement>(null)
112
+ useKeepInViewport(menuRef, isOpen)
110
113
 
111
114
  useImperativeHandle(ref, () => ({
112
115
  open: () => {
@@ -284,6 +287,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
284
287
 
285
288
  {isOpen && (
286
289
  <div
290
+ ref={menuRef}
287
291
  className={`absolute top-full ${positionClass} mt-1 z-50 min-w-[280px] max-w-[420px] bg-theme-surface border border-theme-border-light rounded-lg shadow-xl overflow-hidden`}
288
292
  >
289
293
  {searchable && (
@@ -5,6 +5,7 @@ import { YamlDiffEditor } from '../ui/YamlEditor'
5
5
  import { PaneLoader } from '../ui/PaneLoader'
6
6
  import { Tooltip } from '../ui/Tooltip'
7
7
  import { pluralToKind } from '../../utils/navigation'
8
+ import { usePhoneLayout } from '../../hooks/usePhoneLayout'
8
9
  import { toComparableYaml } from './normalize'
9
10
  import { SIDE_TONES, type CompareSide } from './types'
10
11
 
@@ -120,7 +121,10 @@ export function ResourceCompareView({
120
121
  bleed = false,
121
122
  }: ResourceCompareViewProps) {
122
123
  const [specOnly, setSpecOnly] = useState(false)
123
- const [unified, setUnified] = useState(false)
124
+ const [unifiedPicked, setUnified] = useState(false)
125
+ // Two side-by-side panes don't fit a phone, so phones always get one column.
126
+ const isPhone = usePhoneLayout()
127
+ const unified = unifiedPicked || isPhone
124
128
  const [hideUnchanged, setHideUnchanged] = useState(true)
125
129
  const [rawMetadata, setRawMetadata] = useState(false)
126
130
 
@@ -180,7 +184,7 @@ export function ResourceCompareView({
180
184
  <ToggleButton active={rawMetadata} onClick={() => setRawMetadata(v => !v)} icon={<Sparkles className="w-3.5 h-3.5" />} label="Raw metadata" tooltip="Keep server-assigned noise (uid, resourceVersion, managedFields, last-applied)" />
181
185
  <ToggleButton active={specOnly} onClick={() => setSpecOnly(v => !v)} icon={<FileCode2 className="w-3.5 h-3.5" />} label="Spec only" tooltip="Drop status fields from both sides" />
182
186
  <ToggleButton active={hideUnchanged} onClick={() => setHideUnchanged(v => !v)} icon={<FileText className="w-3.5 h-3.5" />} label="Diff only" tooltip="Collapse unchanged regions" />
183
- <ToggleButton active={unified} onClick={() => setUnified(v => !v)} icon={<Rows className="w-3.5 h-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />
187
+ {!isPhone && <ToggleButton active={unified} onClick={() => setUnified(v => !v)} icon={<Rows className="w-3.5 h-3.5" />} label="Unified" tooltip="Switch between side-by-side and single-column" />}
184
188
  </div>
185
189
 
186
190
  <div className="w-px h-5 bg-theme-border shrink-0" aria-hidden />
@@ -41,6 +41,7 @@ import { toggleSet } from './GitOpsGraphFilterRail'
41
41
  import { useFilterState, defineFilterSchema } from '../../filter-state'
42
42
  import { parseContextName } from '../../utils/context-name'
43
43
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format'
44
+ import { FilterRailPanel } from '../shared/FilterRailPanel'
44
45
 
45
46
  // =============================================================================
46
47
  // GitOpsTableView — the canonical GitOps fleet list (Argo + Flux).
@@ -937,13 +938,17 @@ function GitOpsFilterSidebar({
937
938
  side === 'right' ? 'border-l max-[899px]:border-l-0' : 'border-r max-[899px]:border-r-0'
938
939
  }`}
939
940
  >
940
- <div className="flex items-center justify-between border-b border-theme-border px-3 py-2">
941
- <span className="text-sm font-medium text-theme-text-secondary">Filters</span>
942
- <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
943
- Clear
944
- </button>
945
- </div>
946
- <div className="flex-1 overflow-y-auto">
941
+ <FilterRailPanel
942
+ activeCount={
943
+ syncFilters.size + healthFilters.size + automationFilters.size + kindFilters.size +
944
+ projectFilters.size + namespaceFilters.size + (lifecycleFilter === 'all' ? 0 : 1)
945
+ }
946
+ clear={
947
+ <button type="button" onClick={onClear} className="text-[10px] font-medium text-blue-500 hover:text-blue-400">
948
+ Clear
949
+ </button>
950
+ }
951
+ >
947
952
  {loading ? (
948
953
  <BoardRailSkeleton
949
954
  sections={[
@@ -1062,7 +1067,7 @@ function GitOpsFilterSidebar({
1062
1067
  </GitOpsFilterSection>
1063
1068
  </>
1064
1069
  )}
1065
- </div>
1070
+ </FilterRailPanel>
1066
1071
  </aside>
1067
1072
  )
1068
1073
  }
@@ -88,8 +88,8 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
88
88
  tone="healthy"
89
89
  variant="card"
90
90
  icon={CircleCheck}
91
- headline="Nothing broken right now"
92
- body="No active issues across the selected scope."
91
+ headline="No issues detected"
92
+ body="Radar's checks found nothing broken in the selected scope. They don't cover every failure, and Audit lists configuration that can still break things."
93
93
  />
94
94
  ) : (
95
95
  <EmptyState headline="No issue data yet" body="Connect a cluster to populate the issue queue." action={emptyAction} />
@@ -54,6 +54,8 @@ export function diagnosticFactLabel(type: string): string {
54
54
  return 'Admission dependency';
55
55
  case 'network_policy_isolates':
56
56
  return 'Affected resources';
57
+ case 'tls_certificate_consumers':
58
+ return 'Used for TLS by';
57
59
  default:
58
60
  return type.replace(/_/g, ' ');
59
61
  }
@@ -140,6 +140,7 @@ export function categoryLabel(category: string): string {
140
140
  // Reasons filed under a category whose label names a different operation:
141
141
  // CNPG archiving and an unanswered scheduled run sit under backup_failed.
142
142
  const REASON_TITLE: Record<string, string> = {
143
+ FlaggerCanaryFailed: "Canary rollout failed",
143
144
  InvalidSchedule: 'Scheduling error reported',
144
145
  UnparseableSchedule: 'Scheduling error reported',
145
146
  UnParseableCronJobSchedule: 'Scheduling error reported',
@@ -707,7 +707,7 @@ export function LogCore({
707
707
  })}
708
708
  </div>
709
709
 
710
- <div role="group" aria-label="Log display and utilities" className="ml-auto flex shrink-0 flex-nowrap items-center gap-2">
710
+ <div role="group" aria-label="Log display and utilities" className="ml-auto flex shrink-0 flex-nowrap items-center gap-2 phone:gap-1">
711
711
 
712
712
  {/* Structured-log display mode: icon cycles compact→expanded→raw, chevron picks explicitly. */}
713
713
  {hasStructuredEntries && (
@@ -715,7 +715,7 @@ export function LogCore({
715
715
  <Tooltip content={`Structured: ${STRUCTURED_MODE_LABELS[structuredMode]} — click to cycle`} delay={TIP_DELAY} position="bottom">
716
716
  <button
717
717
  onClick={cycleStructuredMode}
718
- className={`p-1.5 rounded-l transition-colors ${
718
+ className={`p-1.5 rounded-l phone:rounded transition-colors ${
719
719
  structuredMode === 'compact' ? iconBtnInactive : palette.toolbarActive
720
720
  }`}
721
721
  aria-label={`Structured log display mode: ${STRUCTURED_MODE_LABELS[structuredMode]}`}
@@ -723,7 +723,8 @@ export function LogCore({
723
723
  <Braces className="w-4 h-4" />
724
724
  </button>
725
725
  </Tooltip>
726
- <div className="relative" ref={structuredMenuRef}>
726
+ {/* Phones keep the icon (it cycles the modes) and drop the picker to fit one row. */}
727
+ <div className="relative phone:hidden" ref={structuredMenuRef}>
727
728
  <Tooltip content="Pick structured display mode" delay={TIP_DELAY} position="bottom">
728
729
  <button
729
730
  onClick={() => setShowStructuredMenu(prev => !prev)}
@@ -874,7 +875,7 @@ export function LogCore({
874
875
  </Tooltip>
875
876
 
876
877
  {!themeLocked && (
877
- <Tooltip content={isDark ? 'Switch to light mode' : 'Switch to dark mode'} delay={TIP_DELAY} position="bottom">
878
+ <Tooltip content={isDark ? 'Switch to light mode' : 'Switch to dark mode'} delay={TIP_DELAY} position="bottom" wrapperClassName="phone:hidden">
878
879
  <button
879
880
  onClick={toggleDark}
880
881
  className={iconBtnInactive}
@@ -987,8 +988,9 @@ export function LogCore({
987
988
  </div>
988
989
 
989
990
  {/* Search bar */}
991
+ {/* Phones: the query takes the first line and the search options wrap under it. */}
990
992
  {search.isOpen && (
991
- <div className={`@container/logsearch flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
993
+ <div className={`@container/logsearch flex items-center gap-2 phone:flex-wrap phone:gap-y-1.5 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
992
994
  <Search className={`w-4 h-4 ${palette.textSecondary} shrink-0`} />
993
995
  <Input
994
996
  value={search.query}
@@ -1005,7 +1007,7 @@ export function LogCore({
1005
1007
  }
1006
1008
  }}
1007
1009
  placeholder="Search logs..."
1008
- className={`flex-1 bg-transparent ${palette.textPrimary} text-sm ${palette.placeholder} focus:outline-none min-w-0`}
1010
+ className={`flex-1 phone:basis-[calc(100%-1.5rem)] bg-transparent ${palette.textPrimary} text-sm ${palette.placeholder} focus:outline-none min-w-0`}
1009
1011
  autoFocus
1010
1012
  />
1011
1013
 
@@ -1187,8 +1189,8 @@ export function LogCore({
1187
1189
  </div>
1188
1190
  )}
1189
1191
 
1190
- {/* Keyboard shortcut hints */}
1191
- <div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
1192
+ {/* Keyboard shortcut hints (a phone has no keyboard to use them) */}
1193
+ <div className={`flex items-center gap-4 px-3 py-1 border-t phone:hidden ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
1192
1194
  {onStartStream && (!sourceUnavailable || isStreaming) && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
1193
1195
  <Shortcut keys="Ctrl+F" label="Search" palette={palette} />
1194
1196
  {search.mode !== 'hide' && (
@@ -1353,7 +1355,7 @@ function LogLine({
1353
1355
  }
1354
1356
 
1355
1357
  return (
1356
- <div className={`flex ${palette.hoverSurface} group leading-5 px-2 ${isCurrentMatch ? palette.currentMatchBg : ''}`}>
1358
+ <div className={`flex phone:flex-wrap ${palette.hoverSurface} group leading-5 px-2 ${isCurrentMatch ? palette.currentMatchBg : ''}`}>
1357
1359
  {leadSlot}
1358
1360
  {showTimestamp && entry.timestamp && (
1359
1361
  <span
@@ -1371,10 +1373,10 @@ function LogLine({
1371
1373
  [{entry.sourceLabel || entry.pod.split('-').slice(-2).join('-')}]
1372
1374
  </span>
1373
1375
  )}
1374
- <span className="flex-1 min-w-0">{contentElement}</span>
1376
+ <span className="flex-1 min-w-0 phone:order-last phone:basis-full">{contentElement}</span>
1375
1377
  <button
1376
1378
  onClick={handleCopy}
1377
- className={`opacity-0 group-hover:opacity-100 focus-visible:opacity-100 ml-1 p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} shrink-0 transition-opacity`}
1379
+ className={`opacity-0 group-hover:opacity-100 focus-visible:opacity-100 ml-1 p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} shrink-0 transition-opacity phone:ml-auto phone:opacity-100`}
1378
1380
  title="Copy line"
1379
1381
  >
1380
1382
  <Copy className="w-3 h-3" />
@@ -327,7 +327,7 @@ export function WorkloadLogsViewer({ name, fetchAll, createStream, overrideDownl
327
327
  const renderToolbarExtra = ({ isDark, palette }: { isDark: boolean; palette: LogPalette }) => (
328
328
  <>
329
329
  {/* Pod filter */}
330
- <fieldset disabled={sourceUnavailable} className="flex items-center gap-2 disabled:opacity-50">
330
+ <fieldset disabled={sourceUnavailable} className="flex items-center gap-2 disabled:opacity-50 phone:flex-wrap">
331
331
  <div className="relative">
332
332
  <button
333
333
  onClick={() => setShowPodFilter(v => !v)}
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'
3
3
  import { ChevronDown, Globe, Search } from 'lucide-react'
4
4
  import { Badge } from '../ui/Badge'
5
5
  import { Tooltip } from '../ui/Tooltip'
6
+ import { useKeepInViewport } from '../../hooks/useKeepInViewport'
6
7
  import { MultiSelectPicker } from '../ui/MultiSelectPicker'
7
8
 
8
9
  /**
@@ -96,6 +97,7 @@ export const NamespacePicker = forwardRef<NamespacePickerHandle, NamespacePicker
96
97
 
97
98
  const triggerRef = useRef<HTMLButtonElement>(null)
98
99
  const dropdownRef = useRef<HTMLDivElement>(null)
100
+ useKeepInViewport(dropdownRef, isOpen)
99
101
  const [restoreFocus, setRestoreFocus] = useState(false)
100
102
 
101
103
  const scopeActives = useMemo(() => scope?.actives ?? [], [scope?.actives])
@@ -40,3 +40,31 @@ it('opens a matching View with Enter when search finds no kind', () => {
40
40
  act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })))
41
41
  expect(view).toHaveBeenCalledTimes(1)
42
42
  })
43
+ it('in a drawer, Escape in a non-empty kind filter clears it without reaching the dialog', () => {
44
+ const outside = vi.fn()
45
+ document.addEventListener('keydown', outside)
46
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
47
+ act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} containEscape />))
48
+ const input = host.querySelector('input')!
49
+ const type = (v: string) => act(() => {
50
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, v)
51
+ input.dispatchEvent(new Event('input', { bubbles: true }))
52
+ })
53
+ const escape = () => act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
54
+ type('pod'); escape()
55
+ expect(input.value).toBe('')
56
+ expect(outside).not.toHaveBeenCalled()
57
+ // An empty filter lets Escape through, so the drawer still closes on it.
58
+ escape()
59
+ expect(outside).toHaveBeenCalledTimes(1)
60
+ document.removeEventListener('keydown', outside)
61
+ })
62
+ it('reads and reports display settings through the parent when it holds them', () => {
63
+ const onChange = vi.fn()
64
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
65
+ act(() => root.render(<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} pinned={[{ name: 'pods', kind: 'Pod', group: '' }]} displayState={{ showEmptyKinds: false, favoritesExpanded: false }} onDisplayStateChange={onChange} />))
66
+ const favorites = [...host.querySelectorAll('button')].find((b) => b.textContent?.toLowerCase().includes('favorites'))!
67
+ expect(favorites.getAttribute('aria-expanded')).toBe('false')
68
+ act(() => favorites.click())
69
+ expect(onChange).toHaveBeenCalledWith({ showEmptyKinds: false, favoritesExpanded: true })
70
+ })
@@ -79,6 +79,18 @@ export interface ResourcesSidebarProps {
79
79
  /** Task destinations keyed by category name (e.g. "CloudNativePG"). A category
80
80
  * with a workspace stays visible even when it has no resources. */
81
81
  categoryWorkspaces?: Record<string, SidebarCategoryWorkspace>
82
+ /** Display settings held by the parent, for a sidebar that unmounts between
83
+ * uses (the phone drawer) and should not reset them each time it opens. */
84
+ displayState?: SidebarDisplayState
85
+ onDisplayStateChange?: (state: SidebarDisplayState) => void
86
+ /** Escape in a non-empty kind filter clears it without letting the key
87
+ * reach an enclosing dialog, which would close. */
88
+ containEscape?: boolean
89
+ }
90
+
91
+ export interface SidebarDisplayState {
92
+ showEmptyKinds: boolean
93
+ favoritesExpanded: boolean
82
94
  }
83
95
 
84
96
  // Persisted across remounts so collapsed categories survive tab switches
@@ -250,6 +262,9 @@ export function ResourcesSidebar({
250
262
  basePath,
251
263
  onKindNavigated,
252
264
  categoryWorkspaces,
265
+ displayState,
266
+ onDisplayStateChange,
267
+ containEscape,
253
268
  }: ResourcesSidebarProps) {
254
269
  // Wraps kind selection to also navigate when basePath/onNavigate are provided
255
270
  const selectKind = (kind: SelectedKindInfo) => {
@@ -267,8 +282,13 @@ export function ResourcesSidebar({
267
282
  () => persistedExpandedCategories ?? new Set(['Workloads', 'Networking', 'Configuration'])
268
283
  )
269
284
  useEffect(() => { persistedExpandedCategories = expandedCategories }, [expandedCategories])
270
- const [showEmptyKinds, setShowEmptyKinds] = useState(false)
271
- const [favoritesExpanded, setFavoritesExpanded] = useState(() => pinned.length > 0)
285
+ const [ownDisplayState, setOwnDisplayState] = useState<SidebarDisplayState>(() => ({ showEmptyKinds: false, favoritesExpanded: pinned.length > 0 }))
286
+ const { showEmptyKinds, favoritesExpanded } = displayState ?? ownDisplayState
287
+ const setDisplayState = (patch: Partial<SidebarDisplayState>) => {
288
+ const next = { showEmptyKinds, favoritesExpanded, ...patch }
289
+ if (onDisplayStateChange) onDisplayStateChange(next)
290
+ else setOwnDisplayState(next)
291
+ }
272
292
  const favoritesDisclosure = useDisclosure(favoritesExpanded)
273
293
  // Category sections are mapped inline, so they can't each call useDisclosure;
274
294
  // one generated prefix plus the category name keeps aria-controls unique.
@@ -529,6 +549,7 @@ export function ResourcesSidebar({
529
549
 
530
550
  const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
531
551
  if (e.key === 'Escape') {
552
+ if (containEscape && kindFilter) e.stopPropagation()
532
553
  setKindFilter('')
533
554
  setHighlightedIndex(-1)
534
555
  ;(e.target as HTMLInputElement).blur()
@@ -546,7 +567,7 @@ export function ResourcesSidebar({
546
567
  setKindFilter('')
547
568
  onKindNavigated?.()
548
569
  }
549
- }, [visibleResults.length, highlightedResult, selectKind, onKindNavigated]) // eslint-disable-line react-hooks/exhaustive-deps
570
+ }, [visibleResults.length, highlightedResult, selectKind, onKindNavigated, containEscape, kindFilter]) // eslint-disable-line react-hooks/exhaustive-deps
550
571
 
551
572
  const isKindHighlighted = useCallback((name: string, group: string) => {
552
573
  return highlightedKind?.name === name && highlightedKind?.group === group
@@ -603,7 +624,7 @@ export function ResourcesSidebar({
603
624
  <div className="mb-2">
604
625
  <button
605
626
  {...favoritesDisclosure.buttonProps}
606
- onClick={() => setFavoritesExpanded((v) => !v)}
627
+ onClick={() => setDisplayState({ favoritesExpanded: !favoritesExpanded })}
607
628
  className="w-full flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary uppercase tracking-wide"
608
629
  >
609
630
  <CollapseChevron open={favoritesExpanded} className="w-3 h-3" />
@@ -778,7 +799,7 @@ export function ResourcesSidebar({
778
799
  {/* Toggle for showing/hiding empty kinds and groups */}
779
800
  {hiddenKindsCount > 0 || hiddenGroupsCount > 0 || showEmptyKinds ? (
780
801
  <button
781
- onClick={() => setShowEmptyKinds(!showEmptyKinds)}
802
+ onClick={() => setDisplayState({ showEmptyKinds: !showEmptyKinds })}
782
803
  className="w-full flex items-center gap-2 px-3 py-2 mt-2 text-xs text-theme-text-tertiary hover:text-theme-text-secondary border-t border-theme-border"
783
804
  >
784
805
  {showEmptyKinds ? (
@@ -0,0 +1,133 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { VirtuosoMockContext } from 'react-virtuoso'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { ResourcesView } from './ResourcesView'
7
+ import { getCellFilterValue, serializeColumnFilters } from './resource-utils'
8
+ import { getResourceStatus } from '../shared/ResourceRendererDispatch'
9
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
10
+ import type { APIResource } from '../../types'
11
+ import type { PrinterTable } from './printer-columns'
12
+
13
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
14
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
15
+ Element.prototype.scrollIntoView = vi.fn()
16
+ let root: ReturnType<typeof createRoot>
17
+ let host: HTMLDivElement
18
+
19
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear(); window.history.replaceState(null, '', '/') })
20
+
21
+ const pause = { type: 'Synced', status: 'False', reason: 'ReconcilePaused' }
22
+ const mr = { providerConfigRef: { name: 'default' } }
23
+ const xr = { resourceRefs: [] }
24
+ const claim = { resourceRef: { kind: 'Archive', name: 'xr' }, compositionRef: { name: 'archive' } }
25
+
26
+ it.each([
27
+ ['managed', 'test.crossplane.io', mr, false],
28
+ ['foreign-group managed', 'example.com', mr, false],
29
+ ['composite default', 'example.com', xr, false],
30
+ ['composite printer', 'example.com', xr, true],
31
+ ['claim printer', 'example.com', claim, true],
32
+ ['ref-less early composite printer', 'example.com', {}, true],
33
+ ['ref-less early claim default', 'example.com', {}, false],
34
+ ] as const)('keeps pause context and primary evidence on the real %s table path', async (_name, group, spec, printer) => {
35
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
36
+ const kind: APIResource = { group, version: 'v1', kind: 'Archive', name: 'archives', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
37
+ const resource = {
38
+ apiVersion: `${group}/v1`, kind: 'Archive',
39
+ metadata: { name: 'acknowledged', namespace: 'test', uid: 'fixture', annotations: { 'crossplane.io/paused': 'true' } },
40
+ spec, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable' }] },
41
+ }
42
+ const printerTable: PrinterTable | undefined = printer ? {
43
+ kind: kind.name, group,
44
+ columns: [
45
+ { name: 'Synced', jsonPath: ".status.conditions[?(@.type=='Synced')].status", type: 'string' },
46
+ { name: 'Ready', jsonPath: ".status.conditions[?(@.type=='Ready')].status", type: 'string' },
47
+ ],
48
+ cells: { fixture: ['False', 'False'] },
49
+ } : undefined
50
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
51
+ namespaces={['test']}
52
+ apiResources={[kind]}
53
+ resourceCounts={{ Archive: 1 }}
54
+ selectedKindQuery={{ isLoading: false, data: [resource], resourceName: kind.name, group }}
55
+ locationPathname="/resources/archives"
56
+ locationSearch={`?apiGroup=${group}`}
57
+ onNavigate={() => {}}
58
+ printerTable={printerTable}
59
+ /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
60
+ const row = host.querySelector('tbody tr')!
61
+ expect(row.textContent).toContain('acknowledged')
62
+ expect(row.textContent?.match(/Paused/g)).toHaveLength(1)
63
+ if (printer) {
64
+ const printed = [...row.querySelectorAll('td')].filter(cell => cell.textContent?.trim() === 'False')
65
+ expect(printed).toHaveLength(2)
66
+ expect(printed[0].querySelector('.status-neutral')).not.toBeNull()
67
+ expect(printed[1].querySelector('.status-unhealthy')).not.toBeNull()
68
+ expect(row.textContent).not.toContain('Unavailable')
69
+ } else {
70
+ expect(row.textContent).toContain('Unavailable')
71
+ expect(row.textContent).not.toContain('Out of sync')
72
+ }
73
+ })
74
+
75
+
76
+ it('preserves curated package install failure ahead of a recorded pause in the table and filter', async () => {
77
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
78
+ const group = 'pkg.crossplane.io'
79
+ const kind: APIResource = { group, version: 'v1', kind: 'Provider', name: 'providers', namespaced: false, isCrd: true, verbs: ['get', 'list'] }
80
+ const resource = {
81
+ apiVersion: `${group}/v1`, kind: 'Provider',
82
+ metadata: { name: 'install-failure', uid: 'package-fixture', annotations: { 'crossplane.io/paused': 'true' } },
83
+ spec: { package: 'example.invalid/provider:v1' },
84
+ status: { conditions: [pause, { type: 'Installed', status: 'False', reason: 'UnpackFailed' }, { type: 'Healthy', status: 'True' }] },
85
+ }
86
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
87
+ namespaces={[]}
88
+ apiResources={[kind]}
89
+ resourceCounts={{ Provider: 1 }}
90
+ selectedKindQuery={{ isLoading: false, data: [resource], resourceName: kind.name, group }}
91
+ locationPathname="/resources/providers"
92
+ locationSearch={`?apiGroup=${group}`}
93
+ onNavigate={() => {}}
94
+ /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
95
+ const row = host.querySelector('tbody tr')!
96
+ expect(row.textContent).toContain('UnpackFailed')
97
+ expect(row.querySelector('.status-unhealthy')).not.toBeNull()
98
+ expect(row.textContent).not.toContain('Readiness unknown')
99
+ expect(getCellFilterValue(resource, 'status', 'providers')).toBe('UnpackFailed')
100
+ expect(getCellFilterValue(resource, 'status', 'providers')).toBe(getResourceStatus('providers', resource)?.text)
101
+ })
102
+
103
+
104
+ it.each([
105
+ ['ref-less generic', 'example.com', 'Archive', 'archives', {}, 'Ready (reported)', 'Ready (reported)'],
106
+ ['managed-name collision', 'acm.aws.upbound.io', 'Certificate', 'certificates', mr, 'Ready (reported)', 'Ready (reported)'],
107
+ ['curated Knative', 'serving.knative.dev', 'Revision', 'revisions', {}, 'Ready', 'Scaled to Zero'],
108
+ ] as const)('routes the actual %s status filter through the existing column policy', async (_name, group, kindName, plural, spec, filterText, cellText) => {
109
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
110
+ const kind: APIResource = { group, version: 'v1', kind: kindName, name: plural, namespaced: true, isCrd: true, verbs: ['get', 'list'] }
111
+ const resource = {
112
+ apiVersion: `${group}/v1`, kind: kindName,
113
+ metadata: { name: 'kept', namespace: 'test', uid: 'kept-fixture', annotations: { 'crossplane.io/paused': 'true' } },
114
+ spec, status: { conditions: [pause, { type: 'Ready', status: 'True' }, { type: 'Active', status: 'False', reason: 'NoTraffic' }] },
115
+ }
116
+ const excluded = { ...resource, metadata: { ...resource.metadata, name: 'excluded', uid: 'excluded-fixture' }, status: { conditions: [pause, { type: 'Ready', status: 'False', reason: 'Unavailable' }] } }
117
+ const search = `?${new URLSearchParams({ apiGroup: group, filters: serializeColumnFilters({ status: [filterText] }) })}`
118
+ window.history.replaceState(null, '', `/resources/${plural}${search}`)
119
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView
120
+ namespaces={['test']}
121
+ apiResources={[kind]}
122
+ resourceCounts={{ [kindName]: 2 }}
123
+ selectedKindQuery={{ isLoading: false, data: [resource, excluded], resourceName: plural, group }}
124
+ locationPathname={`/resources/${plural}`}
125
+ locationSearch={search}
126
+ onNavigate={() => {}}
127
+ /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
128
+ const rows = host.querySelectorAll('tbody tr')
129
+ expect(rows).toHaveLength(1)
130
+ expect(rows[0].textContent).toContain('kept')
131
+ expect(rows[0].textContent).toContain(cellText)
132
+ expect(rows[0].textContent).not.toContain('excluded')
133
+ })
@@ -0,0 +1,31 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot } from 'react-dom/client'
4
+ import { VirtuosoMockContext } from 'react-virtuoso'
5
+ import { afterEach, expect, it, vi } from 'vitest'
6
+ import { ResourcesView } from './ResourcesView'
7
+ import { KeyboardShortcutProvider } from '../../hooks/useKeyboardShortcuts'
8
+ import type { APIResource } from '../../types'
9
+
10
+ ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
11
+ window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })
12
+ Element.prototype.scrollIntoView = vi.fn()
13
+ let root: ReturnType<typeof createRoot>
14
+ let host: HTMLDivElement
15
+ afterEach(() => { act(() => root?.unmount()); host?.remove(); localStorage.clear() })
16
+
17
+ it('shows failed rollout and suspension together in the actual Canary table', async () => {
18
+ host = document.createElement('div'); document.body.append(host); root = createRoot(host)
19
+ const kind: APIResource = { group: 'flagger.app', version: 'v1beta1', kind: 'Canary', name: 'canaries', namespaced: true, isCrd: true, verbs: ['get', 'list'] }
20
+ const data = { apiVersion: 'flagger.app/v1beta1', kind: 'Canary', metadata: { name: 'storefront', namespace: 'test' }, spec: { suspend: true, targetRef: { kind: 'Deployment', name: 'storefront' }, analysis: { iterations: 2 } }, status: { phase: 'Failed' } }
21
+ await act(async () => root.render(<VirtuosoMockContext.Provider value={{ viewportHeight: 600, itemHeight: 48 }}><KeyboardShortcutProvider><ResourcesView namespaces={['test']} apiResources={[kind]} resourceCounts={{ Canary: 1 }} selectedKindQuery={{ isLoading: false, data: [data], resourceName: kind.name, group: kind.group }} locationPathname="/resources/canaries" locationSearch="?apiGroup=flagger.app" onNavigate={() => {}} /></KeyboardShortcutProvider></VirtuosoMockContext.Provider>))
22
+ const table = host.querySelector('table')!
23
+ expect(table).not.toBeNull()
24
+ expect(table.textContent).toContain('Rollout State')
25
+ expect(table.textContent).toContain('Failed')
26
+ expect(table.querySelector('.status-unhealthy')).not.toBeNull()
27
+ expect(table.textContent).toContain('Blue/green')
28
+ expect(table.textContent).toContain('Not weighted')
29
+ expect(table.textContent).toContain('Suspend Requested')
30
+ expect(table.textContent).toContain('Yes')
31
+ })