@skyhook-io/k8s-ui 1.18.1 → 1.19.0

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 (101) 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/charts/AreaChart.test.tsx +39 -1
  5. package/src/components/charts/AreaChart.tsx +32 -13
  6. package/src/components/charts/axis.test.ts +17 -1
  7. package/src/components/charts/axis.ts +13 -0
  8. package/src/components/charts/format.test.ts +159 -2
  9. package/src/components/charts/format.ts +118 -0
  10. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  11. package/src/components/compare/ResourceCompareView.tsx +6 -2
  12. package/src/components/gitops/GitOpsTableView.tsx +13 -8
  13. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
  14. package/src/components/gitops/insights/GitOpsInsightViews.tsx +66 -36
  15. package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
  16. package/src/components/gitops/insights/insights-helpers.test.ts +28 -0
  17. package/src/components/gitops/insights/insights-helpers.ts +5 -3
  18. package/src/components/issues/IssueGuidance.test.tsx +190 -0
  19. package/src/components/issues/IssueGuidance.tsx +48 -0
  20. package/src/components/issues/IssuesView.tsx +17 -5
  21. package/src/components/issues/diagnostic.ts +2 -0
  22. package/src/components/issues/index.ts +1 -0
  23. package/src/components/issues/severity.ts +2 -0
  24. package/src/components/issues/types.ts +10 -0
  25. package/src/components/logs/LogCore.tsx +13 -11
  26. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  27. package/src/components/namespace-switcher/NamespacePicker.tsx +2 -0
  28. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  29. package/src/components/resources/ResourcesSidebar.tsx +26 -5
  30. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  31. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  32. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  33. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  34. package/src/components/resources/ResourcesView.tsx +238 -66
  35. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  36. package/src/components/resources/generic-status.ts +15 -0
  37. package/src/components/resources/printer-columns.test.ts +36 -0
  38. package/src/components/resources/printer-columns.ts +12 -1
  39. package/src/components/resources/readiness-gates.test.ts +95 -0
  40. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  41. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  42. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  43. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  44. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  45. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  46. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  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.ts +80 -2
  56. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  57. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  58. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  59. package/src/components/shared/DetailShell.tsx +14 -3
  60. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  61. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  62. package/src/components/shared/EditableYamlView.tsx +18 -3
  63. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  64. package/src/components/shared/FilterRailPanel.tsx +62 -0
  65. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  66. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  67. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  68. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  69. package/src/components/shared/ManagedByChip.tsx +135 -2
  70. package/src/components/shared/ResourceActionsBar.tsx +22 -6
  71. package/src/components/shared/ResourceRendererDispatch.tsx +23 -0
  72. package/src/components/shared/SetImageDialog.tsx +3 -0
  73. package/src/components/topology/TopologyControls.tsx +1 -1
  74. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  75. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  77. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  78. package/src/components/ui/PageHeader.tsx +3 -2
  79. package/src/components/ui/SummaryTile.tsx +13 -3
  80. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  81. package/src/components/ui/Tooltip.tsx +11 -2
  82. package/src/components/ui/YamlReview.test.tsx +52 -0
  83. package/src/components/ui/YamlReview.tsx +7 -0
  84. package/src/components/ui/drawer-components.test.tsx +27 -0
  85. package/src/components/ui/drawer-components.tsx +22 -42
  86. package/src/components/workload/WorkloadView.relationships.test.tsx +40 -0
  87. package/src/components/workload/WorkloadView.tsx +57 -34
  88. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  89. package/src/hooks/useKeepInViewport.test.ts +27 -0
  90. package/src/hooks/useKeepInViewport.ts +87 -0
  91. package/src/types/core.ts +54 -1
  92. package/src/types/gitops-insights.ts +8 -0
  93. package/src/utils/api-resources.ts +1 -0
  94. package/src/utils/gitops-owner.ts +136 -8
  95. package/src/utils/gitops-write-guard.test.ts +332 -0
  96. package/src/utils/gitops-write-guard.ts +175 -11
  97. package/src/utils/index.ts +1 -0
  98. package/src/utils/read-actions.ts +34 -0
  99. package/src/utils/touch-intent.test.ts +39 -0
  100. package/src/utils/touch-intent.ts +35 -0
  101. package/vitest.config.ts +26 -0
@@ -0,0 +1,48 @@
1
+ import { AlertTriangle } from 'lucide-react'
2
+ import { Disclosure } from '../ui/Disclosure'
3
+
4
+ export function issueGuidanceSummary(action?: string, primary?: string, hazard?: string) {
5
+ const summary = primary || action
6
+ if (!hazard || !summary) return summary
7
+ if (summary === hazard) return undefined
8
+ return summary.endsWith(` ${hazard}`) ? summary.slice(0, -hazard.length - 1) : summary
9
+ }
10
+
11
+ export function IssueGuidance({ action, primary, secondaryTitle, secondary, ordered, hazard, className }: {
12
+ action?: string
13
+ /** Replaces a supplied action; primary alone renders no action text. */
14
+ primary?: string
15
+ /** Paired with secondary; no disclosure is shown without both. */
16
+ secondaryTitle?: string
17
+ /** Paired with secondaryTitle; no disclosure is shown without both. */
18
+ secondary?: string[]
19
+ ordered?: boolean
20
+ hazard?: string
21
+ className?: string
22
+ }) {
23
+ if (!action && !hazard && !(secondaryTitle && secondary?.length)) return null
24
+ return (
25
+ <div className={className}>
26
+ {hazard && (
27
+ <div className="mb-2 flex items-start gap-2 text-warning-text">
28
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
29
+ <p>{hazard}</p>
30
+ </div>
31
+ )}
32
+ {action && <p>{primary || action}</p>}
33
+ {secondaryTitle && secondary && (
34
+ <Disclosure summary={secondaryTitle} className="mt-2" summaryClassName="font-medium">
35
+ {ordered ? (
36
+ <ol className="list-decimal space-y-2 pl-5 pt-2">
37
+ {secondary.map((step) => <li key={step}>{step}</li>)}
38
+ </ol>
39
+ ) : (
40
+ <div className="space-y-2 pt-2">
41
+ {secondary.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
42
+ </div>
43
+ )}
44
+ </Disclosure>
45
+ )}
46
+ </div>
47
+ )
48
+ }
@@ -1,7 +1,8 @@
1
+ import { IssueGuidance, issueGuidanceSummary } from './IssueGuidance'
1
2
  import { summarizeSchedulerMessage } from '../resources/resource-utils'
2
3
  import { useMemo, useState, type ComponentType, type ReactNode } from 'react';
3
4
  import { AlertOctagon, AlertTriangle, ArrowRight, CircleCheck, Clock, Eraser, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
4
- import { CardBody, CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
5
+ import { CardSection, ClusterName, Disclosure, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
5
6
  import { Collapse, CollapseChevron, useDisclosure } from '../ui/Collapse';
6
7
  import { Tooltip } from '../ui/Tooltip';
7
8
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
@@ -88,8 +89,8 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
88
89
  tone="healthy"
89
90
  variant="card"
90
91
  icon={CircleCheck}
91
- headline="Nothing broken right now"
92
- body="No active issues across the selected scope."
92
+ headline="No issues detected"
93
+ 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
94
  />
94
95
  ) : (
95
96
  <EmptyState headline="No issue data yet" body="Connect a cluster to populate the issue queue." action={emptyAction} />
@@ -368,6 +369,15 @@ export function IssueRow({
368
369
  </div>
369
370
  </div>
370
371
 
372
+ {(issue.action_primary || issue.hazard) && (
373
+ <Collapse open={!open} unmountOnExit>
374
+ <div className="px-4 pb-3 pl-6 text-xs">
375
+ {issue.cause && <p className="mb-1 text-theme-text-primary">{issue.cause}</p>}
376
+ <IssueGuidance hazard={issue.hazard} action={issue.action_primary ? issueGuidanceSummary(issue.action, issue.action_primary, issue.hazard) : undefined} secondaryTitle={issue.action_secondary_title} secondary={issue.action_secondary} ordered={issue.reason === 'SharedResourceWarning'} className="text-theme-text-tertiary" />
377
+ </div>
378
+ </Collapse>
379
+ )}
380
+
371
381
  {/* Details unmount after the close transition: a list can hold hundreds
372
382
  of rows and the body is the expensive part, so closed rows stay light. */}
373
383
  <Collapse open={open} unmountOnExit id={panelId}>
@@ -509,11 +519,13 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
509
519
  </>
510
520
  ) : undefined;
511
521
 
522
+ const guidanceSummary = issueGuidanceSummary(issue.action, issue.action_primary, issue.hazard);
512
523
  const hasNextStep =
513
- !!issue.action || !!sourceNextStep || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
524
+ !!guidanceSummary || !!issue.action_secondary?.length || !!sourceNextStep || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
514
525
 
515
526
  return (
516
527
  <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
528
+ <IssueGuidance hazard={issue.hazard} className="text-sm leading-relaxed" />
517
529
  {/* When a parsed cause replaces the detector text, the terse reason code
518
530
  (CrashLoopBackOff, FailedMount…) rides the eyebrow — it's the greppable
519
531
  identifier operators anchor on, and the collapsed header drops it while
@@ -547,7 +559,7 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
547
559
  tone="fix"
548
560
  labelExtra={nextStepAttribution ?? undefined}
549
561
  >
550
- {sourceNextStep ?? (issue.action ? <CardBody>{issue.action}</CardBody> : null)}
562
+ {sourceNextStep ?? <IssueGuidance action={guidanceSummary} secondaryTitle={issue.action_secondary_title} secondary={issue.action_secondary} ordered={issue.reason === 'SharedResourceWarning'} className="text-sm leading-relaxed text-theme-text-secondary" />}
551
563
  {/* Survives an override: this is a structured one-click fix with an
552
564
  apply path, not advice competing with the host's. */}
553
565
  {issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
@@ -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
  }
@@ -15,6 +15,7 @@ export {
15
15
  memberRef,
16
16
  issueSortAnchor,
17
17
  compareIssueSortAnchors,
18
+ compareIssues,
18
19
  } from './types';
19
20
  export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField, IssueRunningPreviousValue, IssueWithheld } from './types';
20
21
  export {
@@ -100,6 +100,7 @@ const CATEGORY_LABEL: Record<string, string> = {
100
100
  crossplane_reconcile_failed: 'Crossplane reconcile failed',
101
101
  termination_stuck: 'Stuck terminating',
102
102
  operator_condition_failed: 'Controller reports a problem',
103
+ gitops_resource_warning: 'GitOps resource warning',
103
104
  gitops_sync_failed: 'GitOps sync failed',
104
105
  gitops_render_failed: 'GitOps render failed',
105
106
  gitops_spec_invalid: 'GitOps spec invalid',
@@ -140,6 +141,7 @@ export function categoryLabel(category: string): string {
140
141
  // Reasons filed under a category whose label names a different operation:
141
142
  // CNPG archiving and an unanswered scheduled run sit under backup_failed.
142
143
  const REASON_TITLE: Record<string, string> = {
144
+ FlaggerCanaryFailed: "Canary rollout failed",
143
145
  InvalidSchedule: 'Scheduling error reported',
144
146
  UnparseableSchedule: 'Scheduling error reported',
145
147
  UnParseableCronJobSchedule: 'Scheduling error reported',
@@ -231,6 +231,16 @@ export interface Issue {
231
231
  * Server-emitted (omitempty); empty for issues without a parser. */
232
232
  cause?: string;
233
233
  action?: string;
234
+ /** What the suggested or most obvious action could destroy or take over,
235
+ * such as a GitOps sync that would prune every managed resource. Show it
236
+ * even when replacing cause or action with other guidance; action also
237
+ * contains it. Absence means no warning was supplied, not that the action
238
+ * is safe; older producers omit it. */
239
+ hazard?: string;
240
+ /** Optional presentation split; action always retains the complete guidance. */
241
+ action_primary?: string;
242
+ action_secondary_title?: string;
243
+ action_secondary?: string[];
234
244
  remediation_kind?: string;
235
245
  remediation_target?: string;
236
246
  /** A cleanup that is right only when its condition holds — never part of
@@ -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
+ })