@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.
- package/package.json +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsView.tsx +62 -10
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +2 -3
- package/src/components/checks/severity.ts +14 -10
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +3 -3
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +51 -38
- package/src/components/issues/ResourceIssuesSection.test.tsx +6 -5
- package/src/components/issues/ResourceIssuesSection.tsx +28 -146
- package/src/components/issues/index.ts +3 -0
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +102 -1
- package/src/components/issues/severity.ts +2 -0
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
- package/src/components/resources/ResourcesSidebar.tsx +46 -46
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +233 -51
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +4 -0
- package/src/components/ui/severity-tone.ts +11 -4
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +1 -1
- package/src/types/core.ts +57 -4
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
738
|
-
//
|
|
739
|
-
//
|
|
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
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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="
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
{
|
|
1171
|
-
<
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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 {
|
|
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
|
+
})
|