@skyhook-io/k8s-ui 1.8.10 → 1.8.11

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 (116) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  16. package/src/components/gitops/GitOpsTableView.tsx +3 -3
  17. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  18. package/src/components/gitops/SyncCountdown.tsx +26 -17
  19. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  20. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  21. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  22. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  23. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  24. package/src/components/issues/IssuesView.tsx +51 -38
  25. package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
  26. package/src/components/issues/ResourceIssuesSection.tsx +28 -146
  27. package/src/components/issues/index.ts +3 -0
  28. package/src/components/issues/issue-timing.ts +53 -0
  29. package/src/components/issues/issues.test.ts +102 -1
  30. package/src/components/issues/severity.ts +2 -0
  31. package/src/components/logs/LogCore.tsx +14 -4
  32. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  33. package/src/components/logs/useLogStream.ts +8 -1
  34. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  35. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  36. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  37. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  38. package/src/components/resources/ResourcesView.tsx +233 -51
  39. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  40. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  41. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  42. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  43. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  44. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  45. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  46. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  47. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  48. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  49. package/src/components/resources/renderers/index.ts +1 -0
  50. package/src/components/resources/resource-utils-capi.ts +1 -1
  51. package/src/components/resources/resource-utils.ts +31 -0
  52. package/src/components/shared/DetailShell.tsx +2 -2
  53. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  54. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  55. package/src/components/timeline/TimelineList.tsx +371 -354
  56. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  57. package/src/components/timeline/TimelineStrip.tsx +836 -0
  58. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  59. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  60. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  61. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  62. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  63. package/src/components/timeline/index.ts +23 -0
  64. package/src/components/timeline/scrubber-math.test.ts +235 -0
  65. package/src/components/timeline/scrubber-math.ts +249 -0
  66. package/src/components/timeline/shared.test.tsx +219 -0
  67. package/src/components/timeline/shared.tsx +232 -447
  68. package/src/components/timeline/timeline-filters.test.ts +196 -0
  69. package/src/components/timeline/timeline-filters.ts +180 -0
  70. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  71. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  72. package/src/components/timeline/timeline-live.test.ts +131 -0
  73. package/src/components/timeline/timeline-live.ts +77 -0
  74. package/src/components/topology/GroupNode.tsx +9 -2
  75. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  76. package/src/components/topology/K8sResourceNode.tsx +387 -243
  77. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  78. package/src/components/topology/TopologyGraph.tsx +89 -16
  79. package/src/components/topology/TopologySearch.tsx +2 -2
  80. package/src/components/topology/layout.ts +10 -2
  81. package/src/components/topology/topology.css +0 -92
  82. package/src/components/ui/Badge.tsx +5 -0
  83. package/src/components/ui/CodeViewer.tsx +2 -2
  84. package/src/components/ui/Collapse.tsx +59 -0
  85. package/src/components/ui/ConfirmDialog.tsx +7 -3
  86. package/src/components/ui/DialogPortal.tsx +31 -9
  87. package/src/components/ui/Input.tsx +24 -0
  88. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  89. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  90. package/src/components/ui/PaneLoader.tsx +10 -2
  91. package/src/components/ui/ResourceBar.tsx +68 -25
  92. package/src/components/ui/SearchBox.tsx +18 -4
  93. package/src/components/ui/SearchPillInput.tsx +2 -2
  94. package/src/components/ui/drawer-components.tsx +39 -26
  95. package/src/components/ui/index.ts +4 -0
  96. package/src/components/ui/severity-tone.ts +11 -4
  97. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  98. package/src/components/workload/WorkloadView.tsx +2682 -472
  99. package/src/components/workload/index.ts +1 -1
  100. package/src/theme/variables.css +1 -1
  101. package/src/types/core.ts +57 -4
  102. package/src/utils/application-topology.test.ts +198 -0
  103. package/src/utils/application-topology.ts +261 -0
  104. package/src/utils/applications.test.ts +167 -1
  105. package/src/utils/applications.ts +1157 -393
  106. package/src/utils/index.ts +2 -0
  107. package/src/utils/navigation.test.ts +32 -1
  108. package/src/utils/navigation.ts +70 -0
  109. package/src/utils/resource-hierarchy.test.ts +990 -1
  110. package/src/utils/resource-hierarchy.ts +905 -267
  111. package/src/utils/resource-icons.ts +2 -0
  112. package/src/utils/topology-neighborhood.test.ts +202 -1
  113. package/src/utils/topology-neighborhood.ts +194 -13
  114. package/src/utils/workflow-execution.test.ts +556 -0
  115. package/src/utils/workflow-execution.ts +814 -0
  116. package/src/utils/workload-colors.ts +2 -3
@@ -1,4 +1,4 @@
1
- import { AlertTriangle, ChevronDown, ChevronRight, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, Trash2 } from 'lucide-react'
1
+ import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronUp, CircleAlert, Clock3, GitBranch, GitCommit, Info, Loader2, Plus, Trash2 } from 'lucide-react'
2
2
  import { PaneLoader } from '../../ui/PaneLoader'
3
3
  import { clsx } from 'clsx'
4
4
  import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
@@ -7,7 +7,27 @@ import { HealthStatusBadge, SyncStatusBadge } from '../GitOpsStatusBadge'
7
7
  import { SEVERITY_BADGE, SEVERITY_TEXT } from '../../../utils/badge-colors'
8
8
  import { formatRelativeAgeTime } from '../../../utils/format'
9
9
  import { Tooltip } from '../../ui/Tooltip'
10
- import { compactSource, entryTone, gitopsToSeverity, messageToPhase, normalizeHealthStatus, normalizeSyncStatus } from './insights-helpers'
10
+ import { SearchBox } from '../../ui/SearchBox'
11
+ import { FilterPill, type FilterPillTone } from '../../ui/FilterPill'
12
+ import { Collapse, CollapseChevron } from '../../ui/Collapse'
13
+ import type { SortDir } from '../../ui/SortableTh'
14
+ import {
15
+ changeHealth,
16
+ changeMatchesFacets,
17
+ changeMatchesSearch,
18
+ changeSync,
19
+ compactSource,
20
+ entryTone,
21
+ gitopsToSeverity,
22
+ healthStatusRank,
23
+ messageToPhase,
24
+ normalizeHealthStatus,
25
+ normalizeSyncStatus,
26
+ resourceStatusCounts,
27
+ syncStatusRank,
28
+ type ResourceSortKey,
29
+ type ResourceStatusFacet,
30
+ } from './insights-helpers'
11
31
 
12
32
  interface GitOpsStatusStripProps {
13
33
  insight?: GitOpsInsight | null
@@ -247,7 +267,7 @@ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsigh
247
267
  {showHistorical ? '− Hide pre-deletion metadata' : '+ Show pre-deletion metadata'}
248
268
  </button>
249
269
  </div>
250
- {showHistorical && (
270
+ <Collapse open={showHistorical}>
251
271
  <div className="mt-2 flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1 border-t border-theme-border/40 pt-2 text-[11px] text-theme-text-tertiary">
252
272
  {summary.source && <MetaFact label="Source" value={summary.source} />}
253
273
  {(summary.lastRevision || summary.targetRevision) && (
@@ -256,7 +276,7 @@ function TerminatingStatusStrip({ summary }: { summary: NonNullable<GitOpsInsigh
256
276
  {summary.lastReconcile && <MetaFact label="Last reconcile" value={formatRelative(summary.lastReconcile)} />}
257
277
  {summary.autoSyncMode && <MetaFact label="Sync mode" value={summary.autoSyncMode} />}
258
278
  </div>
259
- )}
279
+ </Collapse>
260
280
  </div>
261
281
  )
262
282
  }
@@ -386,7 +406,7 @@ function GitOpsHistoricalIssuesDisclosure({
386
406
  aria-expanded={expanded}
387
407
  >
388
408
  <div className="flex items-center gap-2">
389
- {expanded ? <ChevronDown className="h-3.5 w-3.5 text-theme-text-tertiary" /> : <ChevronRight className="h-3.5 w-3.5 text-theme-text-tertiary" />}
409
+ <CollapseChevron open={expanded} className="h-3.5 w-3.5" />
390
410
  <span className="text-[12px] font-medium text-theme-text-secondary">
391
411
  Pre-deletion issues ({total})
392
412
  </span>
@@ -395,12 +415,12 @@ function GitOpsHistoricalIssuesDisclosure({
395
415
  </span>
396
416
  </div>
397
417
  </button>
398
- {expanded && (
418
+ <Collapse open={expanded}>
399
419
  <div className="border-t border-theme-border">
400
420
  {operationFailure && <GitOpsFailureCard issue={operationFailure} onSelect={onSelectIssue} onRemediate={onRemediate} remediationPending={remediationPending} />}
401
421
  {others.length > 0 && <GitOpsCompactIssueStack issues={others} onSelectIssue={onSelectIssue} />}
402
422
  </div>
403
- )}
423
+ </Collapse>
404
424
  </div>
405
425
  )
406
426
  }
@@ -488,15 +508,15 @@ function GitOpsFailureCard({
488
508
  onClick={() => setShowRaw((v) => !v)}
489
509
  className="inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
490
510
  >
491
- {showRaw ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
511
+ <CollapseChevron open={showRaw} className="h-3 w-3" />
492
512
  {showRaw ? `Hide ${rawControllerLabel}` : `Show ${rawControllerLabel}`}
493
513
  </button>
494
514
  </div>
495
- {showRaw && (
515
+ <Collapse open={showRaw}>
496
516
  <pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border border-theme-border bg-theme-base px-3 py-2 font-mono text-[11px] text-theme-text-secondary">
497
517
  {rawControllerMessage}
498
518
  </pre>
499
- )}
519
+ </Collapse>
500
520
  </div>
501
521
  </div>
502
522
  </div>
@@ -600,13 +620,10 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
600
620
  Open {refText(headlineRef)} →
601
621
  </span>
602
622
  )}
603
- {canExpand && (
604
- expanded
605
- ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
606
- : <ChevronRight className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
607
- )}
623
+ {canExpand && <CollapseChevron open={expanded} className="h-3.5 w-3.5" />}
608
624
  </button>
609
- {expanded && canExpand && (
625
+ {canExpand && (
626
+ <Collapse open={expanded}>
610
627
  <div className="divide-y divide-theme-border border-t border-theme-border bg-theme-base/40">
611
628
  {issues.slice(1).map((issue: GitOpsIssue, index: number) => {
612
629
  const t = severityTone(issue.severity)
@@ -644,6 +661,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
644
661
  )
645
662
  })}
646
663
  </div>
664
+ </Collapse>
647
665
  )}
648
666
  </div>
649
667
  )
@@ -698,6 +716,14 @@ interface GitOpsChangesViewProps {
698
716
  tree?: GitOpsResourceTree | null
699
717
  }
700
718
 
719
+ // Status facets for the Resources list. OutOfSync is a sync-status concern
720
+ // (amber/warn); Degraded + Missing are health failures (red/danger).
721
+ const STATUS_FACETS: { key: ResourceStatusFacet; label: string; tone: FilterPillTone }[] = [
722
+ { key: 'outOfSync', label: 'Out of sync', tone: 'warn' },
723
+ { key: 'degraded', label: 'Degraded', tone: 'danger' },
724
+ { key: 'missing', label: 'Missing', tone: 'danger' },
725
+ ]
726
+
701
727
  export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tree }: GitOpsChangesViewProps) {
702
728
  // "All resources" toggle: when on, render generated descendants alongside
703
729
  // the controller's declared inventory. Argo's UI defaults to "all" — we
@@ -705,6 +731,15 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
705
731
  // on declared resources only and the triage flow stays cleaner without
706
732
  // 30+ Pod rows in the way. Operators who want the full picture flip it.
707
733
  const [showAll, setShowAll] = useState(false)
734
+ // Resources list controls: free-text search (name/kind/namespace), status
735
+ // facets (the three states worth isolating on a drifted app — they replaced
736
+ // the per-resource OutOfSync "issues" that used to restate the whole table),
737
+ // and column sort. Default sort is 'order' — the controller's apply sequence
738
+ // — because it's the only sort under which the wave grouping headers read
739
+ // correctly.
740
+ const [search, setSearch] = useState('')
741
+ const [statusFilters, setStatusFilters] = useState<Set<ResourceStatusFacet>>(new Set())
742
+ const [sort, setSort] = useState<{ key: ResourceSortKey; dir: SortDir }>({ key: 'order', dir: 'asc' })
708
743
  const changes = insight?.changes ?? []
709
744
  const plan = insight?.plan ?? []
710
745
  // Synthesize Change rows for generated tree nodes that aren't already in
@@ -718,13 +753,32 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
718
753
  // map persists across renders so the effect can find the node even when
719
754
  // changes re-render (e.g. polling).
720
755
  const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map())
756
+ // A deep-link (an issue's "View →") is an explicit "take me to this resource"
757
+ // intent. Clear any active search/facet filters so the target can't be
758
+ // filtered out of the list before we scroll to it. Functional updates keep
759
+ // this a no-op when no filter is active.
721
760
  useEffect(() => {
722
761
  if (!focusKey) return
762
+ setSearch((s) => (s ? '' : s))
763
+ setStatusFilters((f) => (f.size ? new Set<ResourceStatusFacet>() : f))
764
+ }, [focusKey])
765
+ // Scroll to the focused row once it has mounted. Re-runs when the filter
766
+ // state changes so that after the reset above remounts a previously
767
+ // filtered-out row, the scroll fires; the ref guard keeps it to one scroll
768
+ // per focusKey instead of re-scrolling on every poll re-render.
769
+ const scrolledFor = useRef<string | null>(null)
770
+ useEffect(() => {
771
+ if (!focusKey) {
772
+ scrolledFor.current = null
773
+ return
774
+ }
775
+ if (scrolledFor.current === focusKey) return
723
776
  const node = rowRefs.current.get(focusKey)
724
777
  if (node) {
725
778
  node.scrollIntoView({ behavior: 'smooth', block: 'center' })
779
+ scrolledFor.current = focusKey
726
780
  }
727
- }, [focusKey])
781
+ }, [focusKey, search, statusFilters])
728
782
  // Distinguish "still loading" from "fetch failed" so a backend 5xx
729
783
  // doesn't render as a stuck "Loading…".
730
784
  if (error && !insight) {
@@ -734,22 +788,20 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
734
788
  return <PaneLoader label="Loading GitOps resources…" className="h-full" />
735
789
  }
736
790
  // Build plan metadata maps keyed by ref so each Change row can advertise
737
- // its sync step, hook phase, and wave assignment. The plan and changes
738
- // lists are the same resources from different angles — we render one
739
- // unified list ordered by plan step, with plan metadata folded onto each
740
- // change row.
791
+ // its hook phase and wave assignment. The plan and changes lists are the
792
+ // same resources from different angles — we fold the plan metadata onto
793
+ // each change row and order the list by the plan's apply sequence.
741
794
  const planByRef = new Map<string, GitOpsPlanItem>()
742
795
  for (const item of plan) {
743
796
  const key = refKey(item.ref)
744
797
  if (!planByRef.has(key)) planByRef.set(key, item)
745
798
  }
746
- // Sort changes by plan order (step) so the list reads top-to-bottom in
747
- // the order the controller will reconcile them. Changes without a plan
748
- // entry land at the end in name order — they're managed resources the
749
- // controller saw but didn't sequence (rare but possible for hook resources
750
- // already completed, or status-only entries). Extras-from-tree land
751
- // after all declared rows.
752
- const sortedChanges = [...changes, ...extraFromTree].sort((a, b) => {
799
+ // Apply-order baseline: the list in the sequence the controller reconciles.
800
+ // Changes without a plan entry land at the end in name order — managed
801
+ // resources the controller saw but didn't sequence (completed hooks,
802
+ // status-only entries). Extras-from-tree land after all declared rows.
803
+ // The column-sort/search/facet controls derive off this ordered baseline.
804
+ const applyOrdered = [...changes, ...extraFromTree].sort((a, b) => {
753
805
  const ap = planByRef.get(refKey(a.ref))?.order
754
806
  const bp = planByRef.get(refKey(b.ref))?.order
755
807
  if (ap == null && bp == null) return refKey(a.ref).localeCompare(refKey(b.ref))
@@ -757,15 +809,31 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
757
809
  if (bp == null) return -1
758
810
  return ap - bp
759
811
  })
760
- // Wave grouping: when at least one plan entry declares a wave, we render
761
- // wave headers between rows so multi-wave apps read as the operator
762
- // wrote them. Skip the headers entirely for single-wave / no-wave apps —
763
- // an "always wave 0" label is noise.
764
- const hasAnyWave = plan.some((i) => i.waveSet)
812
+ const totalCount = applyOrdered.length
813
+ const counts = resourceStatusCounts(applyOrdered)
814
+ const filtered = applyOrdered.filter((c) => changeMatchesSearch(c, search) && changeMatchesFacets(c, statusFilters))
815
+ const displayChanges = sortChanges(filtered, sort)
816
+ const filtersActive = search.trim() !== '' || statusFilters.size > 0
817
+ // Wave grouping only reads correctly in apply order (ascending). Any column
818
+ // sort or a descending flip breaks the sequence, so drop the headers and
819
+ // render a flat list. Also skip them for single-wave / no-wave apps — an
820
+ // "always wave 0" label is noise.
821
+ const groupByWave = sort.key === 'order' && sort.dir === 'asc' && plan.some((i) => i.waveSet)
765
822
  // Source URL for Missing rows. We can't show their live state (resource
766
823
  // doesn't exist), but we CAN point at where they're declared in Git —
767
824
  // which is the most useful thing to do when there's no drawer to open.
768
825
  const sourceTreeURL = gitTreeURL(insight.summary.source, insight.summary.lastRevision || insight.summary.targetRevision || '')
826
+ const toggleFacet = (facet: ResourceStatusFacet) => {
827
+ setStatusFilters((prev) => {
828
+ const next = new Set(prev)
829
+ if (next.has(facet)) next.delete(facet)
830
+ else next.add(facet)
831
+ return next
832
+ })
833
+ }
834
+ const onSort = (key: ResourceSortKey) => {
835
+ setSort((prev) => (prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' }))
836
+ }
769
837
  return (
770
838
  <div className="h-full overflow-auto bg-theme-base p-4">
771
839
  <section className="rounded-md border border-theme-border bg-theme-surface">
@@ -781,7 +849,9 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
781
849
  </Tooltip>
782
850
  )}
783
851
  <span className="text-[11px] tabular-nums text-theme-text-tertiary">
784
- {sortedChanges.length} {sortedChanges.length === 1 ? 'resource' : 'resources'}
852
+ {filtersActive && displayChanges.length !== totalCount
853
+ ? `${displayChanges.length} of ${totalCount}`
854
+ : `${totalCount} ${totalCount === 1 ? 'resource' : 'resources'}`}
785
855
  </span>
786
856
  </div>
787
857
  {tree && (
@@ -817,11 +887,52 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
817
887
  </div>
818
888
  )}
819
889
  </div>
890
+ {/* Filter/sort toolbar. The status facets are the primary way to
891
+ answer "which resources are the problem?" now that the issues band
892
+ no longer restates every OutOfSync resource above. */}
893
+ {totalCount > 0 && (
894
+ <div className="flex flex-wrap items-center gap-2 border-b border-theme-border px-4 py-2">
895
+ <SearchBox
896
+ value={search}
897
+ onChange={setSearch}
898
+ scope="gitops"
899
+ shortcutId="gitops-resources-search"
900
+ placeholder="Filter resources… (press /)"
901
+ className="w-full min-w-[180px] sm:w-64"
902
+ />
903
+ {/* Status facets appear only when the app actually has that state
904
+ (or the operator has it toggled on) — a healthy app shouldn't
905
+ render three "(0)" pills. */}
906
+ <div className="flex flex-wrap items-center gap-1.5">
907
+ {STATUS_FACETS.map(({ key, label, tone }) =>
908
+ counts[key] > 0 || statusFilters.has(key) ? (
909
+ <FilterPill
910
+ key={key}
911
+ label={label}
912
+ tone={tone}
913
+ active={statusFilters.has(key)}
914
+ count={counts[key]}
915
+ onClick={() => toggleFacet(key)}
916
+ />
917
+ ) : null,
918
+ )}
919
+ </div>
920
+ {sort.key !== 'order' && (
921
+ <button
922
+ type="button"
923
+ onClick={() => setSort({ key: 'order', dir: 'asc' })}
924
+ className="ml-auto inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
925
+ >
926
+ <ArrowUpDown className="h-3 w-3" /> Apply order
927
+ </button>
928
+ )}
929
+ </div>
930
+ )}
820
931
  {/* Honest disclaimer about diff scope. Neither Argo nor Flux exposes
821
932
  per-resource desired-vs-live diffs on the CRD — they're computed
822
933
  on demand by their respective servers/CLIs, which Radar doesn't
823
934
  call. */}
824
- {sortedChanges.length > 0 && (
935
+ {totalCount > 0 && (
825
936
  <div className="border-b border-theme-border bg-theme-base/40 px-4 py-2 text-[11px] text-theme-text-tertiary">
826
937
  Radar reads each resource's drift status from the controller. For a line-by-line diff, {insight.summary.tool === 'fluxcd' ? (
827
938
  insight.summary.kind === 'HelmRelease' ? (
@@ -834,66 +945,137 @@ export function GitOpsChangesView({ insight, error, onOpenResource, focusKey, tr
834
945
  )}
835
946
  </div>
836
947
  )}
837
- {sortedChanges.length === 0 ? (
948
+ {totalCount === 0 ? (
838
949
  <div className="p-4 text-sm text-theme-text-secondary">No managed resources reported by the GitOps controller.</div>
839
- ) : (
840
- <div className="divide-y divide-theme-border">
841
- {sortedChanges.map((change, idx) => {
842
- const planItem = planByRef.get(refKey(change.ref))
843
- const step = planItem?.order
844
- const hook = planItem?.hook
845
- const wave = planItem?.wave
846
- const waveSet = !!planItem?.waveSet
847
- const rowKey = refKey(change.ref)
848
- const focused = focusKey === rowKey
849
- const explanation = !change.syncError && !change.message
850
- ? explainChangeStatus(change.sync, change.health, insight.summary)
851
- : ''
852
- const hasInlineDetail = !!(
853
- (change.drift && change.drift.entries.length > 0) ||
854
- (change.recentEvents && change.recentEvents.length > 0)
855
- )
856
- // Render a wave separator above this row when the wave value
857
- // changed from the previous one. waveSet=false rows under a
858
- // hasAnyWave plan get a "Default wave" header — matches
859
- // how Argo's UI separates explicitly-waved from default.
860
- const prevPlan = idx > 0 ? planByRef.get(refKey(sortedChanges[idx - 1]!.ref)) : undefined
861
- const showWaveHeader = hasAnyWave && (idx === 0 || prevPlan?.wave !== wave || prevPlan?.waveSet !== waveSet)
862
- return (
863
- <Fragment key={`${change.ref.group}/${change.ref.kind}/${change.ref.namespace}/${change.ref.name}`}>
864
- {showWaveHeader && (
865
- <div className="bg-theme-base/50 px-4 py-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
866
- {waveSet ? `Wave ${wave}` : 'Default wave'}
867
- </div>
868
- )}
869
- <ChangeRow
870
- change={change}
871
- step={step}
872
- hook={hook}
873
- explanation={explanation}
874
- focused={focused}
875
- autoExpand={focused}
876
- hasInlineDetail={hasInlineDetail}
877
- onOpenResource={onOpenResource}
878
- sourceTreeURL={sourceTreeURL}
879
- registerRef={(el) => {
880
- if (el) {
881
- rowRefs.current.set(rowKey, el)
882
- } else {
883
- rowRefs.current.delete(rowKey)
884
- }
885
- }}
886
- />
887
- </Fragment>
888
- )
889
- })}
950
+ ) : displayChanges.length === 0 ? (
951
+ <div className="flex flex-col items-start gap-2 p-4 text-sm text-theme-text-secondary">
952
+ <span>No resources match the current filters.</span>
953
+ <button
954
+ type="button"
955
+ onClick={() => {
956
+ setSearch('')
957
+ setStatusFilters(new Set())
958
+ }}
959
+ className="text-[12px] font-medium text-skyhook-500 hover:underline"
960
+ >
961
+ Clear filters
962
+ </button>
890
963
  </div>
964
+ ) : (
965
+ <>
966
+ <div className="grid grid-cols-[minmax(0,1fr)_120px_120px_220px] gap-3 border-b border-theme-border bg-theme-base/40 px-4 py-2">
967
+ <ResSortHeader label="Resource" sortKey="name" sort={sort} onSort={onSort} />
968
+ <ResSortHeader label="Sync" sortKey="sync" sort={sort} onSort={onSort} />
969
+ <ResSortHeader label="Health" sortKey="health" sort={sort} onSort={onSort} />
970
+ <div />
971
+ </div>
972
+ <div className="divide-y divide-theme-border">
973
+ {displayChanges.map((change, idx) => {
974
+ const planItem = planByRef.get(refKey(change.ref))
975
+ const hook = planItem?.hook
976
+ const wave = planItem?.wave
977
+ const waveSet = !!planItem?.waveSet
978
+ const rowKey = refKey(change.ref)
979
+ const focused = focusKey === rowKey
980
+ const explanation = !change.syncError && !change.message
981
+ ? explainChangeStatus(change.sync, change.health, insight.summary)
982
+ : ''
983
+ const hasInlineDetail = !!(
984
+ (change.drift && change.drift.entries.length > 0) ||
985
+ (change.recentEvents && change.recentEvents.length > 0)
986
+ )
987
+ // Render a wave separator above this row when the wave value
988
+ // changed from the previous one. waveSet=false rows under a
989
+ // waved plan get a "Default wave" header — matches how Argo's
990
+ // UI separates explicitly-waved from default. Only in apply
991
+ // order (groupByWave); a column sort renders a flat list.
992
+ const prevPlan = idx > 0 ? planByRef.get(refKey(displayChanges[idx - 1]!.ref)) : undefined
993
+ const showWaveHeader = groupByWave && (idx === 0 || prevPlan?.wave !== wave || prevPlan?.waveSet !== waveSet)
994
+ return (
995
+ <Fragment key={`${change.ref.group}/${change.ref.kind}/${change.ref.namespace}/${change.ref.name}`}>
996
+ {showWaveHeader && (
997
+ <div className="bg-theme-base/50 px-4 py-1 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
998
+ {waveSet ? `Wave ${wave}` : 'Default wave'}
999
+ </div>
1000
+ )}
1001
+ <ChangeRow
1002
+ change={change}
1003
+ hook={hook}
1004
+ explanation={explanation}
1005
+ focused={focused}
1006
+ autoExpand={focused}
1007
+ hasInlineDetail={hasInlineDetail}
1008
+ onOpenResource={onOpenResource}
1009
+ sourceTreeURL={sourceTreeURL}
1010
+ registerRef={(el) => {
1011
+ if (el) {
1012
+ rowRefs.current.set(rowKey, el)
1013
+ } else {
1014
+ rowRefs.current.delete(rowKey)
1015
+ }
1016
+ }}
1017
+ />
1018
+ </Fragment>
1019
+ )
1020
+ })}
1021
+ </div>
1022
+ </>
891
1023
  )}
892
1024
  </section>
893
1025
  </div>
894
1026
  )
895
1027
  }
896
1028
 
1029
+ // sortChanges applies the active column sort to the already apply-ordered
1030
+ // list. 'order' is the identity ordering (the list arrives in apply sequence),
1031
+ // so ascending returns it untouched and descending reverses. Column sorts
1032
+ // tie-break on kind/name so equal-status rows stay stable and readable.
1033
+ function sortChanges(list: GitOpsChange[], sort: { key: ResourceSortKey; dir: SortDir }): GitOpsChange[] {
1034
+ if (sort.key === 'order') return sort.dir === 'asc' ? list : [...list].reverse()
1035
+ const dir = sort.dir === 'asc' ? 1 : -1
1036
+ const nameKey = (c: GitOpsChange) => `${c.ref.kind}/${c.ref.name}`
1037
+ return [...list].sort((a, b) => {
1038
+ let cmp = 0
1039
+ if (sort.key === 'name') cmp = nameKey(a).localeCompare(nameKey(b))
1040
+ else if (sort.key === 'sync') cmp = syncStatusRank(changeSync(a)) - syncStatusRank(changeSync(b))
1041
+ else if (sort.key === 'health') cmp = healthStatusRank(changeHealth(a)) - healthStatusRank(changeHealth(b))
1042
+ if (cmp === 0) cmp = nameKey(a).localeCompare(nameKey(b))
1043
+ return cmp * dir
1044
+ })
1045
+ }
1046
+
1047
+ // ResSortHeader is the div-grid equivalent of ui/SortableTh (which renders a
1048
+ // <th>): the Resources list is a grid of expandable rows, not a <table>, so it
1049
+ // can't use the table header cell. Same chevron affordance for consistency.
1050
+ function ResSortHeader({
1051
+ label,
1052
+ sortKey,
1053
+ sort,
1054
+ onSort,
1055
+ }: {
1056
+ label: string
1057
+ sortKey: ResourceSortKey
1058
+ sort: { key: ResourceSortKey; dir: SortDir }
1059
+ onSort: (key: ResourceSortKey) => void
1060
+ }) {
1061
+ const active = sort.key === sortKey
1062
+ return (
1063
+ <button
1064
+ type="button"
1065
+ onClick={() => onSort(sortKey)}
1066
+ aria-label={`Sort by ${label}`}
1067
+ className="inline-flex items-center gap-1 select-none text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary transition-colors hover:text-theme-text-secondary focus-visible:text-theme-text-secondary focus-visible:outline-none"
1068
+ >
1069
+ {label}
1070
+ {active ? (
1071
+ sort.dir === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
1072
+ ) : (
1073
+ <ArrowUpDown className="h-3 w-3 opacity-40" />
1074
+ )}
1075
+ </button>
1076
+ )
1077
+ }
1078
+
897
1079
  // Keys must match between Plan items and Change items for the cross-ref to
898
1080
  // work. Group can be omitted in either source, so we don't require it for
899
1081
  // equality — kind+namespace+name is the practical identifier here.
@@ -990,7 +1172,6 @@ function explainChangeStatus(
990
1172
  // "what's the underlying cluster reason?"
991
1173
  function ChangeRow({
992
1174
  change,
993
- step,
994
1175
  hook,
995
1176
  explanation,
996
1177
  focused,
@@ -1001,7 +1182,6 @@ function ChangeRow({
1001
1182
  registerRef,
1002
1183
  }: {
1003
1184
  change: GitOpsChange
1004
- step: number | undefined
1005
1185
  // Hook phase from the plan item (the controller-declared annotation).
1006
1186
  // Falls back to change.hookPhase (the executed phase) for visibility on
1007
1187
  // resources that already ran their hook.
@@ -1068,19 +1248,10 @@ function ChangeRow({
1068
1248
  >
1069
1249
  <div className="flex items-baseline gap-2">
1070
1250
  {hasInlineDetail ? (
1071
- expanded
1072
- ? <ChevronDown className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
1073
- : <ChevronRight className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
1251
+ <CollapseChevron open={expanded} className="h-3.5 w-3.5 self-center" />
1074
1252
  ) : (
1075
1253
  <span aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
1076
1254
  )}
1077
- {step !== undefined && (
1078
- <Tooltip content={`Sync plan step ${step}`} delay={200} wrapperClassName="shrink-0">
1079
- <span className="rounded border border-theme-border bg-theme-elevated px-1.5 py-0.5 font-mono text-[10px] text-theme-text-tertiary">
1080
- step {step}
1081
- </span>
1082
- </Tooltip>
1083
- )}
1084
1255
  <div className="min-w-0 truncate font-medium text-theme-text-primary">{change.ref.kind} / {change.ref.name}</div>
1085
1256
  {hookLabel && (
1086
1257
  <Tooltip content={`Sync hook: ${hookLabel}`} delay={200} wrapperClassName="shrink-0">
@@ -1167,11 +1338,13 @@ function ChangeRow({
1167
1338
  )}
1168
1339
  </div>
1169
1340
  </div>
1170
- {expanded && hasInlineDetail && (
1171
- <div className="border-t border-theme-border bg-theme-base/40 px-4 py-3">
1172
- {driftEntries.length > 0 && <DriftPanel drift={change.drift!} />}
1173
- {events.length > 0 && <RecentEventsPanel events={events} />}
1174
- </div>
1341
+ {hasInlineDetail && (
1342
+ <Collapse open={expanded} mountLazily>
1343
+ <div className="border-t border-theme-border bg-theme-base/40 px-4 py-3">
1344
+ {driftEntries.length > 0 && <DriftPanel drift={change.drift!} />}
1345
+ {events.length > 0 && <RecentEventsPanel events={events} />}
1346
+ </div>
1347
+ </Collapse>
1175
1348
  )}
1176
1349
  </div>
1177
1350
  )
@@ -1,6 +1,22 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { SEVERITY_DOT } from '../../../utils/badge-colors'
3
- import { compactSource, entryTone, gitopsToSeverity, messageToPhase, phaseToTone } from './insights-helpers'
3
+ import type { GitOpsChange } from '../../../types'
4
+ import {
5
+ changeMatchesFacets,
6
+ changeMatchesSearch,
7
+ compactSource,
8
+ entryTone,
9
+ gitopsToSeverity,
10
+ healthStatusRank,
11
+ messageToPhase,
12
+ phaseToTone,
13
+ resourceStatusCounts,
14
+ syncStatusRank,
15
+ } from './insights-helpers'
16
+
17
+ function change(partial: Partial<GitOpsChange> & { ref: GitOpsChange['ref'] }): GitOpsChange {
18
+ return { category: 'Unknown', hasDesired: true, hasLive: true, partial: false, ...partial }
19
+ }
4
20
 
5
21
  describe('gitopsToSeverity', () => {
6
22
  it.each([
@@ -96,3 +112,72 @@ describe('compactSource', () => {
96
112
  expect(compactSource('')).toBe('')
97
113
  })
98
114
  })
115
+
116
+ describe('changeMatchesSearch', () => {
117
+ const c = change({ ref: { kind: 'Deployment', name: 'guestbook-ui', namespace: 'demo', group: 'apps' } })
118
+ it('matches on kind, name, namespace, and group (case-insensitive)', () => {
119
+ expect(changeMatchesSearch(c, 'deploy')).toBe(true)
120
+ expect(changeMatchesSearch(c, 'GUESTBOOK')).toBe(true)
121
+ expect(changeMatchesSearch(c, 'demo')).toBe(true)
122
+ expect(changeMatchesSearch(c, 'apps')).toBe(true)
123
+ })
124
+ it('empty/whitespace query matches everything', () => {
125
+ expect(changeMatchesSearch(c, '')).toBe(true)
126
+ expect(changeMatchesSearch(c, ' ')).toBe(true)
127
+ })
128
+ it('non-matching query is filtered out', () => {
129
+ expect(changeMatchesSearch(c, 'nomatch')).toBe(false)
130
+ })
131
+ })
132
+
133
+ describe('changeMatchesFacets', () => {
134
+ const outOfSync = change({ ref: { kind: 'Service', name: 'a' }, sync: 'OutOfSync', health: 'Healthy' })
135
+ const degraded = change({ ref: { kind: 'Deployment', name: 'b' }, sync: 'Synced', health: 'Degraded' })
136
+ const missing = change({ ref: { kind: 'Secret', name: 'c' }, sync: 'OutOfSync', health: 'Missing' })
137
+ const healthy = change({ ref: { kind: 'ConfigMap', name: 'd' }, sync: 'Synced', health: 'Healthy' })
138
+
139
+ it('empty facet set matches everything', () => {
140
+ const none = new Set<never>()
141
+ expect(changeMatchesFacets(outOfSync, none)).toBe(true)
142
+ expect(changeMatchesFacets(healthy, none)).toBe(true)
143
+ })
144
+ it('outOfSync keys off sync status', () => {
145
+ const f = new Set(['outOfSync'] as const)
146
+ expect(changeMatchesFacets(outOfSync, f)).toBe(true)
147
+ expect(changeMatchesFacets(degraded, f)).toBe(false)
148
+ })
149
+ it('degraded/missing key off health status', () => {
150
+ expect(changeMatchesFacets(degraded, new Set(['degraded'] as const))).toBe(true)
151
+ expect(changeMatchesFacets(missing, new Set(['missing'] as const))).toBe(true)
152
+ expect(changeMatchesFacets(healthy, new Set(['degraded'] as const))).toBe(false)
153
+ })
154
+ it('multiple facets union (OR)', () => {
155
+ const f = new Set(['degraded', 'missing'] as const)
156
+ expect(changeMatchesFacets(degraded, f)).toBe(true)
157
+ expect(changeMatchesFacets(missing, f)).toBe(true)
158
+ expect(changeMatchesFacets(outOfSync, f)).toBe(false)
159
+ })
160
+ })
161
+
162
+ describe('resourceStatusCounts', () => {
163
+ it('counts each facet independently (a resource can be both OutOfSync and Missing)', () => {
164
+ const counts = resourceStatusCounts([
165
+ change({ ref: { kind: 'Service', name: 'a' }, sync: 'OutOfSync', health: 'Healthy' }),
166
+ change({ ref: { kind: 'Secret', name: 'c' }, sync: 'OutOfSync', health: 'Missing' }),
167
+ change({ ref: { kind: 'Deployment', name: 'b' }, sync: 'Synced', health: 'Degraded' }),
168
+ change({ ref: { kind: 'ConfigMap', name: 'd' }, sync: 'Synced', health: 'Healthy' }),
169
+ ])
170
+ expect(counts).toEqual({ outOfSync: 2, degraded: 1, missing: 1 })
171
+ })
172
+ })
173
+
174
+ describe('syncStatusRank / healthStatusRank', () => {
175
+ it('ascending sync rank surfaces OutOfSync before Synced', () => {
176
+ expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Synced'))
177
+ expect(syncStatusRank('OutOfSync')).toBeLessThan(syncStatusRank('Unknown'))
178
+ })
179
+ it('ascending health rank surfaces Missing/Degraded before Healthy', () => {
180
+ expect(healthStatusRank('Missing')).toBeLessThan(healthStatusRank('Degraded'))
181
+ expect(healthStatusRank('Degraded')).toBeLessThan(healthStatusRank('Healthy'))
182
+ })
183
+ })