@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.
- package/package.json +3 -3
- package/src/components/applications/ApplicationsList.tsx +5 -2
- package/src/components/applications/ApplicationsView.tsx +37 -30
- package/src/components/checks/ChecksView.tsx +25 -14
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
- package/src/components/gitops/GitOpsTableView.tsx +163 -88
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
- package/src/components/issues/IssuesView.tsx +41 -5
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/diagnostic.ts +22 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +21 -0
- package/src/components/issues/types.ts +18 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
- package/src/components/namespace-switcher/index.ts +6 -0
- package/src/components/resources/ResourcesView.tsx +20 -81
- package/src/components/scope-pill/ScopePill.tsx +35 -0
- package/src/components/scope-pill/index.ts +2 -0
- package/src/components/timeline/TimelineList.tsx +27 -1
- package/src/components/topology/TopologyControls.tsx +90 -14
- package/src/components/ui/FreshnessControl.tsx +153 -0
- package/src/components/ui/SortableTh.tsx +16 -10
- package/src/components/ui/SummaryTile.tsx +9 -1
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
- package/src/components/workload/WorkloadView.tsx +26 -8
- package/src/filter-state/filter-state-core.test.ts +98 -0
- package/src/filter-state/filter-state-core.ts +138 -0
- package/src/filter-state/filter-state.tsx +127 -0
- package/src/filter-state/index.ts +16 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useKeyboardShortcuts.tsx +23 -2
- package/src/hooks/useRefreshAnimation.ts +15 -2
- package/src/index.ts +12 -0
- package/src/types/core.ts +42 -0
- package/src/types/gitops-insights.ts +4 -0
- package/src/utils/animation.ts +10 -0
- package/src/utils/format-freshness.test.ts +34 -0
- package/src/utils/format.ts +32 -0
- package/src/utils/resource-hierarchy.test.ts +51 -0
- 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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
547
|
-
clear: () =>
|
|
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: () =>
|
|
556
|
-
clear: () =>
|
|
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: () =>
|
|
565
|
-
clear: () =>
|
|
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: () =>
|
|
574
|
-
clear: () =>
|
|
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={
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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) =>
|
|
651
|
+
onToggleSync={(value) => filters.toggle('sync', value)}
|
|
627
652
|
healthCounts={healthCounts}
|
|
628
653
|
healthFilters={healthFilters}
|
|
629
|
-
onToggleHealth={(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={
|
|
659
|
+
onLifecycleFilterChange={(v) => filters.setString('lifecycle', v)}
|
|
635
660
|
terminatingCount={terminatingCount}
|
|
636
661
|
projects={projects}
|
|
637
662
|
projectFilters={projectFilters}
|
|
638
|
-
onToggleProject={(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) =>
|
|
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
|
|
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) =>
|
|
688
|
-
onClear={() =>
|
|
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
|
-
) :
|
|
757
|
-
<
|
|
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
|
|
1206
|
-
<SortableTh label="Project" sortKey="project" activeKey={sort
|
|
1207
|
-
<SortableTh label="Sync" sortKey="sync" activeKey={sort
|
|
1208
|
-
<SortableTh label="Health" sortKey="health" activeKey={sort
|
|
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
|
|
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={
|
|
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 ?
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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
|
|
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
|
|
313
|
-
|
|
314
|
-
{
|
|
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 (
|