@skyhook-io/k8s-ui 1.8.7 → 1.8.9

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 (42) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +37 -30
  4. package/src/components/checks/ChecksView.tsx +25 -14
  5. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  6. package/src/components/gitops/GitOpsTableView.tsx +163 -88
  7. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  8. package/src/components/issues/IssuesView.tsx +41 -5
  9. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  10. package/src/components/issues/diagnostic.ts +22 -0
  11. package/src/components/issues/index.ts +1 -1
  12. package/src/components/issues/issues.test.ts +21 -0
  13. package/src/components/issues/types.ts +18 -0
  14. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  15. package/src/components/namespace-switcher/index.ts +6 -0
  16. package/src/components/resources/ResourcesView.tsx +20 -81
  17. package/src/components/scope-pill/ScopePill.tsx +35 -0
  18. package/src/components/scope-pill/index.ts +2 -0
  19. package/src/components/timeline/TimelineList.tsx +27 -1
  20. package/src/components/topology/TopologyControls.tsx +90 -14
  21. package/src/components/ui/FreshnessControl.tsx +153 -0
  22. package/src/components/ui/SortableTh.tsx +16 -10
  23. package/src/components/ui/SummaryTile.tsx +9 -1
  24. package/src/components/ui/Toast.tsx +1 -1
  25. package/src/components/ui/index.ts +2 -0
  26. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  27. package/src/components/workload/WorkloadView.tsx +26 -8
  28. package/src/filter-state/filter-state-core.test.ts +98 -0
  29. package/src/filter-state/filter-state-core.ts +138 -0
  30. package/src/filter-state/filter-state.tsx +127 -0
  31. package/src/filter-state/index.ts +16 -0
  32. package/src/hooks/index.ts +1 -0
  33. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  34. package/src/hooks/useRefreshAnimation.ts +15 -2
  35. package/src/index.ts +12 -0
  36. package/src/types/core.ts +42 -0
  37. package/src/types/gitops-insights.ts +4 -0
  38. package/src/utils/animation.ts +10 -0
  39. package/src/utils/format-freshness.test.ts +34 -0
  40. package/src/utils/format.ts +32 -0
  41. package/src/utils/resource-hierarchy.test.ts +51 -0
  42. package/src/utils/resource-hierarchy.ts +7 -4
@@ -32,11 +32,10 @@ import { FacetSection, FacetButton } from '../ui/Facet'
32
32
  import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
33
33
  import { DistributionBar } from '../ui/DistributionBar'
34
34
  import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
35
- import { PaneLoader } from '../ui/PaneLoader'
36
- import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
37
35
  import { getGitOpsResourceStatus } from './detail-helpers'
38
36
  import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
39
37
  import { toggleSet } from './GitOpsGraphFilterRail'
38
+ import { useFilterState, defineFilterSchema } from '../../filter-state'
40
39
  import { parseContextName } from '../../utils/context-name'
41
40
 
42
41
  // =============================================================================
@@ -192,8 +191,13 @@ export interface GitOpsTableViewProps {
192
191
  // the Scope-section mode tabs and the empty-state check.
193
192
  counts: Record<string, number>
194
193
  countsUnavailable?: string[]
195
- // Caller refresh typically invalidates its useQuery + refetches.
194
+ // @deprecated Superseded by `freshnessSlot` kept for source compatibility
195
+ // with existing consumers; no longer drives any affordance here.
196
196
  onRefresh?: () => void
197
+ // Host-injected freshness/liveness control (e.g. a <FreshnessControl>),
198
+ // rendered leading the header actions. The host owns the mode + data, so this
199
+ // shared table makes no assumption about whether the view auto-updates.
200
+ freshnessSlot?: ReactNode
197
201
  // Row click — caller routes to its own detail page. When the host also
198
202
  // passes `rowHrefFor`, the callback receives the MouseEvent so it can
199
203
  // `preventDefault()` for same-tree nav (e.g. react-router) or skip the
@@ -269,13 +273,23 @@ export interface GitOpsTableViewProps {
269
273
 
270
274
  // ----- Main component --------------------------------------------------------
271
275
 
276
+ const GITOPS_FILTER_SCHEMA = defineFilterSchema({
277
+ q: { param: 'q', type: 'text' },
278
+ sync: { param: 'sync', type: 'set' },
279
+ health: { param: 'health', type: 'set' },
280
+ project: { param: 'project', type: 'set' },
281
+ automation: { param: 'automation', type: 'set' },
282
+ labels: { param: 'labels', type: 'set' },
283
+ lifecycle: { param: 'lifecycle', type: 'single', default: 'all' },
284
+ })
285
+
272
286
  export function GitOpsTableView({
273
287
  rows: allRowsInput,
274
288
  loading,
275
289
  error,
276
290
  counts,
277
291
  countsUnavailable,
278
- onRefresh,
292
+ freshnessSlot,
279
293
  onRowClick,
280
294
  rowHrefFor,
281
295
  onDestinationClick,
@@ -296,36 +310,37 @@ export function GitOpsTableView({
296
310
  const searchInputRef = useRef<HTMLInputElement>(null)
297
311
  const [mode, setMode] = useState<GitOpsMode>('applications')
298
312
  const [viewMode, setViewMode] = useState<GitOpsViewMode>('table')
299
- const [search, setSearch] = useState('')
300
- const [syncFilters, setSyncFilters] = useState<Set<string>>(new Set())
301
- const [healthFilters, setHealthFilters] = useState<Set<string>>(new Set())
302
- const [projectFilters, setProjectFilters] = useState<Set<string>>(new Set())
313
+ // Sync / health / project / automation / labels / lifecycle + search live in
314
+ // the URL (shareable, bookmarkable) via the shared filter-state contract.
315
+ // Deliberate divergences kept on their own state: `namespace` (entangled with
316
+ // the host globalNamespaces seam + the header scope pill — a separate product
317
+ // decision), `sort` (a compound view-preference, not a filter), and the
318
+ // destination filter (host-controlled, below).
319
+ const filters = useFilterState(GITOPS_FILTER_SCHEMA)
320
+ const search = filters.values.q
321
+ const syncFilters = filters.values.sync
322
+ const healthFilters = filters.values.health
323
+ const projectFilters = filters.values.project
324
+ const labelFilters = filters.values.labels
325
+ const automationFilters = filters.values.automation as Set<'auto' | 'manual' | 'suspended'>
326
+ const lifecycleFilter = filters.values.lifecycle as 'all' | 'terminating' | 'active'
327
+ const toggleAutomation = useCallback((value: 'auto' | 'manual' | 'suspended') => filters.toggle('automation', value), [filters.toggle])
303
328
  const [namespaceFilters, setNamespaceFilters] = useState<Set<string>>(new Set())
304
- const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set())
305
329
  const [showLabelsDropdown, setShowLabelsDropdown] = useState(false)
306
330
  const [labelSearch, setLabelSearch] = useState('')
307
- // Auto-sync / Manual / Suspended are INDEPENDENT row attributes (a Flux app can
308
- // be auto-reconciling AND suspended), so this is a multi-select facet like Sync
309
- // and Health not the old single-select that conflated the mode (auto vs
310
- // manual) with the orthogonal suspended state.
311
- const [automationFilters, setAutomationFilters] = useState<Set<'auto' | 'manual' | 'suspended'>>(new Set())
312
- const toggleAutomation = useCallback((value: 'auto' | 'manual' | 'suspended') => {
313
- setAutomationFilters((prev) => {
314
- const next = new Set(prev)
315
- next.has(value) ? next.delete(value) : next.add(value)
316
- return next
317
- })
318
- }, [])
319
- const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
320
- const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'urgency', dir: 'asc' })
321
- // Shared refresh feedback (spin ≥400ms → checkmark) so clicking Refresh gives
322
- // the same visual confirmation as every other view, even when the refetch is
323
- // instant (the cache is already warm).
324
- const [triggerRefresh, , refreshPhase] = useRefreshAnimation(onRefresh ?? (() => {}))
325
- // Clicking a column sorts by it (starting at the column's natural direction —
326
- // e.g. last-sync newest-first); clicking the active column reverses.
331
+ const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>({ key: 'urgency', dir: 'asc' })
332
+ // 3-state cycle: natural direction reversed off. The first click uses each
333
+ // column's natural direction (SORT_DEFAULT_DIR e.g. Last Sync is newest-first)
334
+ // so the header cycle agrees with the tile-mode sort menu, which seeds the same
335
+ // default. "Off" (null) falls back to the urgency/health-worst-first ordering.
327
336
  const onSort = useCallback(
328
- (key: SortKey) => setSort((prev) => (prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: SORT_DEFAULT_DIR[key] })),
337
+ (key: SortKey) =>
338
+ setSort((prev) => {
339
+ const natural = SORT_DEFAULT_DIR[key]
340
+ if (!prev || prev.key !== key) return { key, dir: natural }
341
+ if (prev.dir === natural) return { key, dir: natural === 'asc' ? 'desc' : 'asc' }
342
+ return null
343
+ }),
329
344
  [],
330
345
  )
331
346
 
@@ -453,22 +468,19 @@ export function GitOpsTableView({
453
468
  }
454
469
  return true
455
470
  })
456
- return [...rows].sort((a, b) => compareRows(a, b, sort.key) * (sort.dir === 'asc' ? 1 : -1))
471
+ const eff = sort ?? { key: 'urgency' as SortKey, dir: 'asc' as SortDir }
472
+ return [...rows].sort((a, b) => compareRows(a, b, eff.key) * (eff.dir === 'asc' ? 1 : -1))
457
473
  }, [allRows, automationFilters, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sort, syncFilters, destinationFilter])
458
474
 
459
475
  const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
460
476
 
461
477
  const clearAllFilters = useCallback(() => {
462
- setSearch('')
463
- setSyncFilters(new Set())
464
- setHealthFilters(new Set())
465
- setProjectFilters(new Set())
478
+ filters.clearAll()
466
479
  setNamespaceFilters(new Set())
467
- setLabelFilters(new Set())
468
- setAutomationFilters(new Set())
469
- setLifecycleFilter('all')
470
480
  onClearNamespaces?.()
471
481
  onDestinationFilterChange?.('all')
482
+ // filters.clearAll is a stable ref from the hook.
483
+ // eslint-disable-next-line react-hooks/exhaustive-deps
472
484
  }, [onClearNamespaces, onDestinationFilterChange])
473
485
 
474
486
  // True when nothing is filtered at all — backs the Total tile's active state.
@@ -522,6 +534,12 @@ export function GitOpsTableView({
522
534
 
523
535
  const showCrossClusterTile = typeof crossClusterCount === 'number' && mode === 'applications'
524
536
 
537
+ // First fetch, nothing to show yet. Drives the shape-stable loading
538
+ // treatment: tiles pulse instead of reading "0" (a false zero), the table
539
+ // area renders skeleton rows, and the filter rail holds its loaded height
540
+ // with section stubs — so the settled layout doesn't reflow into place.
541
+ const initialLoading = Boolean(loading) && allRowsInput.length === 0
542
+
525
543
  // Header tiles unify with the facet rail: each STATUS tile toggles its own
526
544
  // dimension and composes with the other facets + search (clicking "Out of
527
545
  // sync" adds sync=OutOfSync without wiping an active health filter or your
@@ -543,8 +561,8 @@ export function GitOpsTableView({
543
561
  value: statusSummary.outOfSync,
544
562
  tone: 'warning',
545
563
  active: syncFilters.size === 1 && syncFilters.has('OutOfSync'),
546
- apply: () => setSyncFilters(new Set(['OutOfSync'])),
547
- clear: () => setSyncFilters(new Set()),
564
+ apply: () => filters.setSet('sync', ['OutOfSync']),
565
+ clear: () => filters.setSet('sync', []),
548
566
  },
549
567
  {
550
568
  key: 'degraded',
@@ -552,8 +570,8 @@ export function GitOpsTableView({
552
570
  value: statusSummary.degraded,
553
571
  tone: 'error',
554
572
  active: healthFilters.size === 1 && healthFilters.has('Degraded'),
555
- apply: () => setHealthFilters(new Set(['Degraded'])),
556
- clear: () => setHealthFilters(new Set()),
573
+ apply: () => filters.setSet('health', ['Degraded']),
574
+ clear: () => filters.setSet('health', []),
557
575
  },
558
576
  {
559
577
  key: 'suspended',
@@ -561,8 +579,8 @@ export function GitOpsTableView({
561
579
  value: statusSummary.suspended,
562
580
  tone: 'warning',
563
581
  active: automationFilters.size === 1 && automationFilters.has('suspended'),
564
- apply: () => setAutomationFilters(new Set(['suspended'])),
565
- clear: () => setAutomationFilters(new Set()),
582
+ apply: () => filters.setSet('automation', ['suspended']),
583
+ clear: () => filters.setSet('automation', []),
566
584
  },
567
585
  {
568
586
  key: 'reconciling',
@@ -570,8 +588,8 @@ export function GitOpsTableView({
570
588
  value: syncCounts.get('Reconciling') ?? 0,
571
589
  tone: 'info',
572
590
  active: syncFilters.size === 1 && syncFilters.has('Reconciling'),
573
- apply: () => setSyncFilters(new Set(['Reconciling'])),
574
- clear: () => setSyncFilters(new Set()),
591
+ apply: () => filters.setSet('sync', ['Reconciling']),
592
+ clear: () => filters.setSet('sync', []),
575
593
  },
576
594
  ...(showCrossClusterTile
577
595
  ? [
@@ -596,19 +614,25 @@ export function GitOpsTableView({
596
614
  icon={GitBranch}
597
615
  title="GitOps"
598
616
  description="Applications and reconciliations with source, destination, sync, and health state."
599
- actions={summaryTiles.map((tile) => (
600
- <SummaryTile
601
- key={tile.key}
602
- label={tile.label}
603
- value={tile.value}
604
- tone={tile.tone}
605
- active={tile.active}
606
- onClick={() => {
607
- if (tile.active) tile.clear?.()
608
- else tile.apply?.()
609
- }}
610
- />
611
- ))}
617
+ actions={
618
+ <>
619
+ {freshnessSlot}
620
+ {summaryTiles.map((tile) => (
621
+ <SummaryTile
622
+ key={tile.key}
623
+ label={tile.label}
624
+ value={tile.value}
625
+ tone={tile.tone}
626
+ active={tile.active}
627
+ loading={initialLoading}
628
+ onClick={() => {
629
+ if (tile.active) tile.clear?.()
630
+ else tile.apply?.()
631
+ }}
632
+ />
633
+ ))}
634
+ </>
635
+ }
612
636
  />
613
637
  </div>
614
638
  <div
@@ -617,25 +641,26 @@ export function GitOpsTableView({
617
641
  }`}
618
642
  >
619
643
  <GitOpsFilterSidebar
644
+ loading={initialLoading}
620
645
  side={filtersSide}
621
646
  mode={mode}
622
647
  onModeChange={setMode}
623
648
  modeCounts={modeCounts}
624
649
  syncCounts={syncCounts}
625
650
  syncFilters={syncFilters}
626
- onToggleSync={(value) => toggleSet(syncFilters, setSyncFilters, value)}
651
+ onToggleSync={(value) => filters.toggle('sync', value)}
627
652
  healthCounts={healthCounts}
628
653
  healthFilters={healthFilters}
629
- onToggleHealth={(value) => toggleSet(healthFilters, setHealthFilters, value)}
654
+ onToggleHealth={(value) => filters.toggle('health', value)}
630
655
  automationFilters={automationFilters}
631
656
  automationCounts={automationCounts}
632
657
  onToggleAutomation={toggleAutomation}
633
658
  lifecycleFilter={lifecycleFilter}
634
- onLifecycleFilterChange={setLifecycleFilter}
659
+ onLifecycleFilterChange={(v) => filters.setString('lifecycle', v)}
635
660
  terminatingCount={terminatingCount}
636
661
  projects={projects}
637
662
  projectFilters={projectFilters}
638
- onToggleProject={(value) => toggleSet(projectFilters, setProjectFilters, value)}
663
+ onToggleProject={(value) => filters.toggle('project', value)}
639
664
  namespaces={rowNamespaces}
640
665
  namespaceFilters={namespaceFilters}
641
666
  onToggleNamespace={(value) => toggleSet(namespaceFilters, setNamespaceFilters, value)}
@@ -664,7 +689,7 @@ export function GitOpsTableView({
664
689
  <input
665
690
  ref={searchInputRef}
666
691
  value={search}
667
- onChange={(e) => setSearch(e.target.value)}
692
+ onChange={(e) => filters.setString('q', e.target.value)}
668
693
  placeholder="Search applications, repos, paths..."
669
694
  className="h-8 w-full rounded-md border border-theme-border bg-theme-base pl-8 pr-3 text-sm text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
670
695
  />
@@ -678,14 +703,14 @@ export function GitOpsTableView({
678
703
  pattern); tile mode has no headers, so it keeps a compact sort
679
704
  control wired to the same sort state. */}
680
705
  {viewMode === 'tiles' && (
681
- <GitOpsSortMenu sortKey={sort.key} onChange={(k) => setSort({ key: k, dir: SORT_DEFAULT_DIR[k] })} />
706
+ <GitOpsSortMenu sortKey={sort?.key ?? 'urgency'} onChange={(k) => setSort({ key: k, dir: SORT_DEFAULT_DIR[k] })} />
682
707
  )}
683
708
  {labels.length > 0 && (
684
709
  <LabelsDropdown
685
710
  labels={labels}
686
711
  activeLabels={labelFilters}
687
- onToggle={(value) => toggleSet(labelFilters, setLabelFilters, value)}
688
- onClear={() => setLabelFilters(new Set())}
712
+ onToggle={(value) => filters.toggle('labels', value)}
713
+ onClear={() => filters.setSet('labels', [])}
689
714
  open={showLabelsDropdown}
690
715
  onOpenChange={setShowLabelsDropdown}
691
716
  search={labelSearch}
@@ -727,19 +752,6 @@ export function GitOpsTableView({
727
752
  <GitOpsIconToggle active={viewMode === 'table'} label="Table view" icon={List} onClick={() => setViewMode('table')} />
728
753
  <GitOpsIconToggle active={viewMode === 'tiles'} label="Tiles view" icon={LayoutGrid} onClick={() => setViewMode('tiles')} />
729
754
  </div>
730
- {onRefresh && (
731
- <Tooltip content="Refresh GitOps resources">
732
- <button
733
- type="button"
734
- onClick={triggerRefresh}
735
- className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-theme-border bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
736
- >
737
- {refreshPhase === 'success'
738
- ? <Check className="h-3.5 w-3.5 text-emerald-500" />
739
- : <RefreshCw className={clsx('h-3.5 w-3.5', (refreshPhase === 'spinning' || loading) && 'animate-spin')} />}
740
- </button>
741
- </Tooltip>
742
- )}
743
755
  </div>
744
756
  </div>
745
757
 
@@ -753,8 +765,8 @@ export function GitOpsTableView({
753
765
  <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
754
766
  {modeLabel(mode)} view is queued behind the application list.
755
767
  </div>
756
- ) : loading && filteredRows.length === 0 ? (
757
- <PaneLoader label="Loading GitOps applications…" className="h-full" />
768
+ ) : initialLoading ? (
769
+ <GitOpsTableSkeleton />
758
770
  ) : error ? (
759
771
  <div className="p-4 text-sm text-red-500">Failed to load GitOps applications: {error.message}</div>
760
772
  ) : filteredRows.length === 0 ? (
@@ -804,7 +816,62 @@ export function GitOpsTableView({
804
816
  // GitOpsTableView's visual language and not generally useful elsewhere.
805
817
  // =============================================================================
806
818
 
819
+ // Shape-stable loading stand-ins. Both mirror the loaded anatomy so data
820
+ // resolves in place: rows appear inside the table frame, facets inside the
821
+ // rail — no half-height rail dangling next to a centered spinner, no layout
822
+ // jump when the response lands.
823
+ function GitOpsTableSkeleton() {
824
+ return (
825
+ <div aria-live="polite" aria-label="Loading GitOps applications…" className="divide-y divide-theme-border-light">
826
+ {Array.from({ length: 10 }, (_, i) => (
827
+ <div key={i} className="flex items-center gap-6 px-4 py-3" style={{ opacity: 1 - i * 0.09 }}>
828
+ <div className="min-w-0 flex-[2] space-y-2">
829
+ <div className="h-4 w-2/3 animate-pulse rounded bg-theme-hover" />
830
+ <div className="h-3 w-1/2 animate-pulse rounded bg-theme-hover" />
831
+ </div>
832
+ <div className="hidden h-4 flex-1 animate-pulse rounded bg-theme-hover md:block" />
833
+ <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
834
+ <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
835
+ <div className="hidden h-4 flex-[1.5] animate-pulse rounded bg-theme-hover lg:block" />
836
+ </div>
837
+ ))}
838
+ </div>
839
+ )
840
+ }
841
+
842
+ function GitOpsSidebarSkeleton() {
843
+ // Section stubs sized like the loaded rail (Sync ~4 rows, Health ~5,
844
+ // Automation ~3, Projects/Namespaces a few each) so the rail keeps its
845
+ // height while counts are unknown — showing real facet buttons with "0"
846
+ // counts would be false zeros.
847
+ const sections: Array<[string, number]> = [
848
+ ['Sync', 4],
849
+ ['Health', 5],
850
+ ['Automation', 3],
851
+ ['Projects', 4],
852
+ ['Namespaces', 4],
853
+ ]
854
+ return (
855
+ <div aria-hidden>
856
+ {sections.map(([title, rows]) => (
857
+ <div key={title} className="border-b border-theme-border-light px-3 py-3">
858
+ <div className="mb-2 h-3 w-24 animate-pulse rounded bg-theme-hover" />
859
+ <div className="space-y-1.5">
860
+ {Array.from({ length: rows }, (_, i) => (
861
+ <div key={i} className="flex items-center justify-between px-1.5 py-1">
862
+ <div className="h-3.5 animate-pulse rounded bg-theme-hover" style={{ width: `${52 - i * 6}%` }} />
863
+ <div className="h-3.5 w-6 animate-pulse rounded bg-theme-hover" />
864
+ </div>
865
+ ))}
866
+ </div>
867
+ </div>
868
+ ))}
869
+ </div>
870
+ )
871
+ }
872
+
807
873
  function GitOpsFilterSidebar({
874
+ loading,
808
875
  side,
809
876
  mode,
810
877
  onModeChange,
@@ -852,6 +919,8 @@ function GitOpsFilterSidebar({
852
919
  namespaceFilters: Set<string>
853
920
  onToggleNamespace: (value: string) => void
854
921
  onClear: () => void
922
+ /** Initial fetch in flight — hold the rail's shape with section stubs. */
923
+ loading?: boolean
855
924
  }) {
856
925
  return (
857
926
  <aside
@@ -866,6 +935,10 @@ function GitOpsFilterSidebar({
866
935
  </button>
867
936
  </div>
868
937
  <div className="flex-1 overflow-y-auto">
938
+ {loading ? (
939
+ <GitOpsSidebarSkeleton />
940
+ ) : (
941
+ <>
869
942
  {AVAILABLE_MODES.length > 1 && (
870
943
  <GitOpsFilterSection icon={GitBranch} title="Scope">
871
944
  <div className="grid grid-cols-2 gap-1">
@@ -959,6 +1032,8 @@ function GitOpsFilterSidebar({
959
1032
  />
960
1033
  ))}
961
1034
  </GitOpsFilterSection>
1035
+ </>
1036
+ )}
962
1037
  </div>
963
1038
  </aside>
964
1039
  )
@@ -1187,7 +1262,7 @@ function GitOpsTable({
1187
1262
  pendingRowActions,
1188
1263
  }: {
1189
1264
  rows: GitOpsRow[]
1190
- sort: { key: SortKey; dir: SortDir }
1265
+ sort: { key: SortKey; dir: SortDir } | null
1191
1266
  onSort: (key: SortKey) => void
1192
1267
  onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
1193
1268
  hrefFor?: (row: GitOpsRow) => string
@@ -1202,13 +1277,13 @@ function GitOpsTable({
1202
1277
  <table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
1203
1278
  <thead className="sticky top-0 z-10 bg-theme-base">
1204
1279
  <tr>
1205
- <SortableTh label="Application" sortKey="name" activeKey={sort.key} direction={sort.dir} onSort={onSort} className={showActions ? 'w-[16%]' : 'w-[22%]'} />
1206
- <SortableTh label="Project" sortKey="project" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1207
- <SortableTh label="Sync" sortKey="sync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1208
- <SortableTh label="Health" sortKey="health" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1280
+ <SortableTh label="Application" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className={showActions ? 'w-[16%]' : 'w-[22%]'} />
1281
+ <SortableTh label="Project" sortKey="project" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
1282
+ <SortableTh label="Sync" sortKey="sync" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
1283
+ <SortableTh label="Health" sortKey="health" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
1209
1284
  <th className={clsx(TH_CLASS, showDestination ? 'w-[20%]' : 'w-[28%]')}>Source</th>
1210
1285
  {showDestination && <th className={clsx(TH_CLASS, 'w-[14%]')}>Destination</th>}
1211
- <SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[10%]" />
1286
+ <SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[10%]" />
1212
1287
  {showActions && (
1213
1288
  <th className={clsx(TH_CLASS, 'w-[6%] text-right')}>
1214
1289
  <span className="sr-only">Actions</span>
@@ -56,6 +56,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
56
56
  const operationFailure = (insight.issues ?? []).find(
57
57
  (i) => i.severity === 'critical' && i.scope === 'operation' && i.stuck,
58
58
  )
59
+ const operationTooltipMessage = summary.rawOperationMessage || summary.operationMessage
59
60
  return (
60
61
  <div className="border-b border-theme-border bg-theme-base px-4 py-2">
61
62
  <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
@@ -87,7 +88,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
87
88
  (parsed cause, retry count, raw message) so the strip stays a
88
89
  calm orientation row instead of duplicating the error three times. */}
89
90
  {operation && summary.operationMessage && isInFlightPhase(operation) && (
90
- <Tooltip content={summary.operationMessage} delay={400} wrapperClassName="min-w-0 max-w-[60ch]">
91
+ <Tooltip content={operationTooltipMessage} delay={400} wrapperClassName="min-w-0 max-w-[60ch]">
91
92
  <span className="block truncate text-[11px] text-theme-text-secondary">
92
93
  {summary.operationMessage}
93
94
  </span>
@@ -424,6 +425,8 @@ function GitOpsFailureCard({
424
425
  const [showRaw, setShowRaw] = useState(false)
425
426
  const stuck = !!issue.stuck
426
427
  const ref = issue.refs?.[0]
428
+ const rawControllerMessage = issue.rawMessage || issue.message
429
+ const rawControllerLabel = issue.rawMessage ? 'raw controller error' : 'controller message'
427
430
  // Title prioritizes the parsed cause's first sentence. Without parsing we
428
431
  // get the bare phase ("Failed") which alone tells the user nothing — fall
429
432
  // back to the first sentence of the raw message in that case so something
@@ -486,12 +489,12 @@ function GitOpsFailureCard({
486
489
  className="inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
487
490
  >
488
491
  {showRaw ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
489
- {showRaw ? 'Hide raw controller error' : 'Show raw controller error'}
492
+ {showRaw ? `Hide ${rawControllerLabel}` : `Show ${rawControllerLabel}`}
490
493
  </button>
491
494
  </div>
492
495
  {showRaw && (
493
496
  <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">
494
- {issue.message}
497
+ {rawControllerMessage}
495
498
  </pre>
496
499
  )}
497
500
  </div>
@@ -609,6 +612,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
609
612
  const t = severityTone(issue.severity)
610
613
  const ref = issue.refs?.[0]
611
614
  const actionable = !!(onSelectIssue && ref)
615
+ const rawMessage = issue.rawMessage && issue.rawMessage !== issue.message ? issue.rawMessage : ''
612
616
  return (
613
617
  <button
614
618
  key={`${issue.reason}-${index}`}
@@ -628,6 +632,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
628
632
  </div>
629
633
  <p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
630
634
  {issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
635
+ {rawMessage && <p className="mt-0.5 break-words font-mono text-[11px] text-theme-text-tertiary">{rawMessage}</p>}
631
636
  {issue.action && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
632
637
  </div>
633
638
  {actionable && ref && (
@@ -1112,7 +1117,7 @@ function ChangeRow({
1112
1117
  live health message — operators chasing a broken sync want
1113
1118
  the failure reason on the same row, not in a drawer. */}
1114
1119
  {change.syncError && (
1115
- <Tooltip content={change.syncError} delay={400} wrapperClassName="ml-[18px] mt-1 block max-w-full">
1120
+ <Tooltip content={change.rawSyncError || change.syncError} delay={400} wrapperClassName="ml-[18px] mt-1 block max-w-full">
1116
1121
  <span className="line-clamp-3 text-xs text-red-600 dark:text-red-400">{change.syncError}</span>
1117
1122
  </Tooltip>
1118
1123
  )}
@@ -1386,7 +1391,9 @@ function HistoryRows({
1386
1391
  </Tooltip>
1387
1392
  )}
1388
1393
  {item.message && (
1389
- <div className={clsx('mt-0.5 line-clamp-2 text-[11px]', sourceDisplay ? 'text-theme-text-tertiary' : 'text-theme-text-secondary')}>{item.message}</div>
1394
+ <Tooltip content={item.rawMessage || item.message} delay={400} wrapperClassName="mt-0.5 block max-w-full">
1395
+ <div className={clsx('line-clamp-2 text-[11px]', sourceDisplay ? 'text-theme-text-tertiary' : 'text-theme-text-secondary')}>{item.message}</div>
1396
+ </Tooltip>
1390
1397
  )}
1391
1398
  </div>
1392
1399
  </li>
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from
2
2
  import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
3
3
  import { ClusterName, EmptyState } from '../ui';
4
4
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
5
- import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle } from './diagnostic';
5
+ import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
6
6
  import {
7
7
  ISSUE_SEVERITY_BADGE_CLASS,
8
8
  ISSUE_SEVERITY_LABEL,
@@ -209,6 +209,17 @@ export function IssueRow({
209
209
  <span className="shrink-0 tabular-nums">{affected}</span>
210
210
  </>
211
211
  ) : null}
212
+ {issue.incident_parent ? (
213
+ <>
214
+ <span aria-hidden>·</span>
215
+ {/* Non-interactive signal (the header is the toggle — a nested
216
+ button would be invalid); the clickable link lives in the body. */}
217
+ <span className="min-w-0 truncate text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence ?? '')}>
218
+ ↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
219
+ <span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
220
+ </span>
221
+ </>
222
+ ) : null}
212
223
  {renderMeta?.(slotCtx)}
213
224
  </div>
214
225
  </div>
@@ -249,6 +260,21 @@ export function IssueRow({
249
260
  <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
250
261
  <div className="flex flex-col gap-4">
251
262
  <Diagnosis issue={issue} />
263
+ {issue.incident_parent ? (
264
+ <section className="flex flex-col gap-1">
265
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
266
+ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
267
+ {issue.incident_parent.confidence ? (
268
+ <span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence)}>
269
+ {issue.incident_parent.confidence} confidence
270
+ </span>
271
+ ) : null}
272
+ </h4>
273
+ <ul className="flex flex-col gap-px">
274
+ <ResourceLine refForLink={memberRef(issue, issue.incident_parent.ref)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
275
+ </ul>
276
+ </section>
277
+ ) : null}
252
278
  <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
253
279
  <div className="border-t border-theme-border/70 pt-3">
254
280
  <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
@@ -274,7 +300,11 @@ function Diagnosis({ issue }: { issue: Issue }) {
274
300
  const { headline, detail } = issueMessageParts(issue);
275
301
  // When the issue carries a parsed plain-English cause, lead with it. The raw
276
302
  // detector message is kept below as de-emphasized detail.
277
- const rawMessage = issue.cause ? issue.message ?? '' : [headline, detail].filter(Boolean).join(' ');
303
+ const visibleMessage = [headline, detail].filter(Boolean).join(' ');
304
+ const rawMessage = issue.raw_message ?? (issue.cause ? issue.message ?? '' : '');
305
+ const shouldShowRawMessage = issue.cause
306
+ ? Boolean(rawMessage)
307
+ : Boolean(issue.raw_message && issue.raw_message !== visibleMessage);
278
308
  return (
279
309
  <section className="flex flex-col gap-1">
280
310
  <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
@@ -309,9 +339,9 @@ function Diagnosis({ issue }: { issue: Issue }) {
309
339
  {issue.operation_retry_count ? ` · retried ${issue.operation_retry_count}×` : ''}
310
340
  </p>
311
341
  ) : null}
312
- {/* Raw detector message, de-emphasized shown below the parsed cause so
313
- the precise error (URLs, resource names) is available without leading. */}
314
- {issue.cause && rawMessage ? (
342
+ {/* Raw detector message, de-emphasized so precise controller/kubelet text
343
+ remains available without leading the diagnosis. */}
344
+ {shouldShowRawMessage ? (
315
345
  <p className="break-words font-mono text-[11px] leading-relaxed text-theme-text-tertiary">{rawMessage}</p>
316
346
  ) : null}
317
347
  {crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
@@ -384,6 +414,7 @@ function DiagnosticContext({
384
414
  key={`${related.ref.group ?? ''}/${related.ref.kind}/${related.ref.namespace ?? ''}/${related.ref.name}#${relIdx}`}
385
415
  label="Related"
386
416
  refForLink={memberRef(issue, related.ref)}
417
+ count={related.count}
387
418
  resourceHref={resourceHref}
388
419
  onResourceClick={onResourceClick}
389
420
  ResourceLinkIcon={ResourceLinkIcon}
@@ -478,12 +509,14 @@ function AffectedResources({
478
509
  function ResourceLine({
479
510
  label,
480
511
  refForLink,
512
+ count,
481
513
  resourceHref,
482
514
  onResourceClick,
483
515
  ResourceLinkIcon,
484
516
  }: {
485
517
  label?: string;
486
518
  refForLink: IssueResourceRef;
519
+ count?: number;
487
520
  resourceHref?: (ref: IssueResourceRef) => string;
488
521
  onResourceClick?: (ref: IssueResourceRef) => void;
489
522
  ResourceLinkIcon: ComponentType<{ className?: string }>;
@@ -498,6 +531,9 @@ function ResourceLine({
498
531
  {r.namespace ? `${r.namespace} / ` : ''}
499
532
  {r.name}
500
533
  </span>
534
+ {count && count > 1 ? (
535
+ <span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums" title={`${count} affected resources grouped under this issue`}>{count} affected</span>
536
+ ) : null}
501
537
  {linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
502
538
  </>
503
539
  );
@@ -113,6 +113,9 @@ function CausalContext({ issue, onResourceClick }: { issue: Issue; onResourceCli
113
113
  {rel.ref.namespace ? `${rel.ref.namespace} / ` : ''}
114
114
  {rel.ref.name}
115
115
  </span>
116
+ {rel.count && rel.count > 1 ? (
117
+ <span className="ml-1 tabular-nums" title={`${rel.count} affected resources grouped under this issue`}>· {rel.count} affected</span>
118
+ ) : null}
116
119
  </>
117
120
  )
118
121
  return (