@skyhook-io/k8s-ui 1.14.0 → 1.14.1
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 +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +168 -11
- package/src/components/gitops/applicationset-row.test.ts +214 -0
- package/src/components/gitops/detail-helpers.test.ts +54 -0
- package/src/components/gitops/detail-helpers.ts +13 -3
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +7 -0
- package/src/components/resources/ResourcesView.tsx +257 -78
- package/src/components/resources/column-header-width.test.ts +75 -0
- package/src/components/resources/rules-column-width.test.ts +42 -0
- package/src/components/shared/ResourceRendererDispatch.test.tsx +75 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +24 -6
- package/src/components/topology/GroupNode.tsx +6 -2
- package/src/components/topology/topology.css +4 -1
- package/src/components/ui/Collapse.tsx +9 -1
- package/src/components/ui/RowActionMenu.tsx +4 -0
- package/src/components/ui/drawer-components.tsx +4 -2
- package/src/components/workload/WorkloadView.tsx +9 -2
- package/src/types/gitops.ts +51 -0
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
CircleDot,
|
|
11
11
|
GitBranch,
|
|
12
12
|
HeartPulse,
|
|
13
|
+
Layers,
|
|
13
14
|
LayoutGrid,
|
|
14
15
|
List,
|
|
15
16
|
Pause,
|
|
@@ -291,6 +292,10 @@ const GITOPS_FILTER_SCHEMA = defineFilterSchema({
|
|
|
291
292
|
automation: { param: 'automation', type: 'set' },
|
|
292
293
|
labels: { param: 'labels', type: 'set' },
|
|
293
294
|
lifecycle: { param: 'lifecycle', type: 'single', default: 'all' },
|
|
295
|
+
// Argo kind names ('Application' | 'ApplicationSet'). An empty set places no
|
|
296
|
+
// restriction: both are shown, as is every Flux kind, which this filter never
|
|
297
|
+
// narrows. A non-empty set selects among the Argo kinds only.
|
|
298
|
+
kind: { param: 'kind', type: 'set' },
|
|
294
299
|
})
|
|
295
300
|
|
|
296
301
|
export function GitOpsTableView({
|
|
@@ -334,7 +339,9 @@ export function GitOpsTableView({
|
|
|
334
339
|
const labelFilters = filters.values.labels
|
|
335
340
|
const automationFilters = filters.values.automation as Set<'auto' | 'manual' | 'suspended'>
|
|
336
341
|
const lifecycleFilter = filters.values.lifecycle as 'all' | 'terminating' | 'active'
|
|
342
|
+
const kindFilters = filters.values.kind as Set<'Application' | 'ApplicationSet'>
|
|
337
343
|
const toggleAutomation = useCallback((value: 'auto' | 'manual' | 'suspended') => filters.toggle('automation', value), [filters.toggle])
|
|
344
|
+
const toggleKind = useCallback((value: 'Application' | 'ApplicationSet') => filters.toggle('kind', value), [filters.toggle])
|
|
338
345
|
const [namespaceFilters, setNamespaceFilters] = useState<Set<string>>(new Set())
|
|
339
346
|
const [showLabelsDropdown, setShowLabelsDropdown] = useState(false)
|
|
340
347
|
const [labelSearch, setLabelSearch] = useState('')
|
|
@@ -362,7 +369,8 @@ export function GitOpsTableView({
|
|
|
362
369
|
namespaceFilters.size > 0 ||
|
|
363
370
|
labelFilters.size > 0 ||
|
|
364
371
|
automationFilters.size > 0 ||
|
|
365
|
-
lifecycleFilter !== 'all'
|
|
372
|
+
lifecycleFilter !== 'all' ||
|
|
373
|
+
kindFilters.size > 0
|
|
366
374
|
const hasGlobalNamespaceFilter = !!onClearNamespaces && (globalNamespaces?.length ?? 0) > 0
|
|
367
375
|
const hasAnyFilter = hasLocalFilters || hasGlobalNamespaceFilter
|
|
368
376
|
|
|
@@ -413,10 +421,17 @@ export function GitOpsTableView({
|
|
|
413
421
|
)
|
|
414
422
|
const syncCounts = useMemo(() => countMap(allRows.map((row) => row.sync)), [allRows])
|
|
415
423
|
const healthCounts = useMemo(() => countMap(allRows.map((row) => row.health)), [allRows])
|
|
416
|
-
const automationCounts = useMemo(() =>
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
424
|
+
const automationCounts = useMemo(() => {
|
|
425
|
+
const policyRows = allRows.filter(hasSyncPolicy)
|
|
426
|
+
return {
|
|
427
|
+
auto: policyRows.filter((row) => row.autoSync).length,
|
|
428
|
+
manual: policyRows.filter((row) => !row.autoSync).length,
|
|
429
|
+
suspended: policyRows.filter((row) => row.suspended).length,
|
|
430
|
+
}
|
|
431
|
+
}, [allRows])
|
|
432
|
+
const kindCounts = useMemo(() => ({
|
|
433
|
+
Application: allRows.filter((row) => row.kind === 'Application').length,
|
|
434
|
+
ApplicationSet: allRows.filter((row) => row.kind === 'ApplicationSet').length,
|
|
420
435
|
}), [allRows])
|
|
421
436
|
// The Destination column earns its width only when destinations actually vary
|
|
422
437
|
// — in single-cluster OSS every row is the same in-cluster API server, so the
|
|
@@ -459,13 +474,17 @@ export function GitOpsTableView({
|
|
|
459
474
|
if (projectFilters.size > 0 && !projectFilters.has(row.project || '(none)')) return false
|
|
460
475
|
if (namespaceFilters.size > 0 && !namespaceFilters.has(row.namespace || '(cluster)')) return false
|
|
461
476
|
if (activeLabels.length > 0 && !activeLabels.every(({ key, value }) => row.labels[key] === value)) return false
|
|
462
|
-
if (automationFilters.size > 0 && !(
|
|
477
|
+
if (automationFilters.size > 0 && (!hasSyncPolicy(row) || !(
|
|
463
478
|
(automationFilters.has('auto') && row.autoSync) ||
|
|
464
479
|
(automationFilters.has('manual') && !row.autoSync) ||
|
|
465
480
|
(automationFilters.has('suspended') && row.suspended)
|
|
466
|
-
)) return false
|
|
481
|
+
))) return false
|
|
467
482
|
if (lifecycleFilter === 'terminating' && !row.terminating) return false
|
|
468
483
|
if (lifecycleFilter === 'active' && row.terminating) return false
|
|
484
|
+
// Only constrains Argo Application/ApplicationSet rows — Flux kinds
|
|
485
|
+
// (Kustomization, HelmRelease) are never affected by this filter, by
|
|
486
|
+
// design (see GITOPS_FILTER_SCHEMA comment on `kind`).
|
|
487
|
+
if (kindFilters.size > 0 && (row.kind === 'Application' || row.kind === 'ApplicationSet') && !kindFilters.has(row.kind)) return false
|
|
469
488
|
if (destinationFilter && destinationFilter !== 'all') {
|
|
470
489
|
const match = row._destination?.match
|
|
471
490
|
if (destinationFilter === 'this-cluster' && match !== 'in_cluster') return false
|
|
@@ -480,7 +499,7 @@ export function GitOpsTableView({
|
|
|
480
499
|
})
|
|
481
500
|
const eff = sort ?? { key: 'urgency' as SortKey, dir: 'asc' as SortDir }
|
|
482
501
|
return [...rows].sort((a, b) => compareRows(a, b, eff.key) * (eff.dir === 'asc' ? 1 : -1))
|
|
483
|
-
}, [allRows, automationFilters, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sort, syncFilters, destinationFilter])
|
|
502
|
+
}, [allRows, automationFilters, healthFilters, kindFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sort, syncFilters, destinationFilter])
|
|
484
503
|
|
|
485
504
|
const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
|
|
486
505
|
|
|
@@ -503,6 +522,7 @@ export function GitOpsTableView({
|
|
|
503
522
|
namespaceFilters.size === 0 &&
|
|
504
523
|
labelFilters.size === 0 &&
|
|
505
524
|
automationFilters.size === 0 &&
|
|
525
|
+
kindFilters.size === 0 &&
|
|
506
526
|
lifecycleFilter === 'all' &&
|
|
507
527
|
(!destinationFilter || destinationFilter === 'all'),
|
|
508
528
|
[
|
|
@@ -513,6 +533,7 @@ export function GitOpsTableView({
|
|
|
513
533
|
namespaceFilters,
|
|
514
534
|
labelFilters,
|
|
515
535
|
automationFilters,
|
|
536
|
+
kindFilters,
|
|
516
537
|
lifecycleFilter,
|
|
517
538
|
destinationFilter,
|
|
518
539
|
],
|
|
@@ -665,6 +686,9 @@ export function GitOpsTableView({
|
|
|
665
686
|
automationFilters={automationFilters}
|
|
666
687
|
automationCounts={automationCounts}
|
|
667
688
|
onToggleAutomation={toggleAutomation}
|
|
689
|
+
kindFilters={kindFilters}
|
|
690
|
+
kindCounts={kindCounts}
|
|
691
|
+
onToggleKind={toggleKind}
|
|
668
692
|
lifecycleFilter={lifecycleFilter}
|
|
669
693
|
onLifecycleFilterChange={(v) => filters.setString('lifecycle', v)}
|
|
670
694
|
terminatingCount={terminatingCount}
|
|
@@ -841,6 +865,9 @@ function GitOpsFilterSidebar({
|
|
|
841
865
|
automationFilters,
|
|
842
866
|
automationCounts,
|
|
843
867
|
onToggleAutomation,
|
|
868
|
+
kindFilters,
|
|
869
|
+
kindCounts,
|
|
870
|
+
onToggleKind,
|
|
844
871
|
lifecycleFilter,
|
|
845
872
|
onLifecycleFilterChange,
|
|
846
873
|
terminatingCount,
|
|
@@ -865,6 +892,9 @@ function GitOpsFilterSidebar({
|
|
|
865
892
|
automationFilters: Set<'auto' | 'manual' | 'suspended'>
|
|
866
893
|
automationCounts: { auto: number; manual: number; suspended: number }
|
|
867
894
|
onToggleAutomation: (value: 'auto' | 'manual' | 'suspended') => void
|
|
895
|
+
kindFilters: Set<'Application' | 'ApplicationSet'>
|
|
896
|
+
kindCounts: { Application: number; ApplicationSet: number }
|
|
897
|
+
onToggleKind: (value: 'Application' | 'ApplicationSet') => void
|
|
868
898
|
lifecycleFilter: 'all' | 'terminating' | 'active'
|
|
869
899
|
onLifecycleFilterChange: (value: 'all' | 'terminating' | 'active') => void
|
|
870
900
|
terminatingCount: number
|
|
@@ -940,6 +970,17 @@ function GitOpsFilterSidebar({
|
|
|
940
970
|
<GitOpsFacetButton label="Unknown" count={healthCounts.get('Unknown') ?? 0} active={healthFilters.has('Unknown')} onClick={() => onToggleHealth('Unknown')} />
|
|
941
971
|
</GitOpsFilterSection>
|
|
942
972
|
|
|
973
|
+
{/* Stays rendered while a kind filter is set even if this scope has no
|
|
974
|
+
ApplicationSets - otherwise the facet vanishes while still hiding
|
|
975
|
+
every Application, leaving an empty table with nothing on screen to
|
|
976
|
+
explain it or switch it off. */}
|
|
977
|
+
{(kindCounts.ApplicationSet > 0 || kindFilters.size > 0) && (
|
|
978
|
+
<GitOpsFilterSection icon={Layers} title="Kind">
|
|
979
|
+
<GitOpsFacetButton label="Applications" count={kindCounts.Application} active={kindFilters.has('Application')} onClick={() => onToggleKind('Application')} />
|
|
980
|
+
<GitOpsFacetButton label="ApplicationSets" count={kindCounts.ApplicationSet} active={kindFilters.has('ApplicationSet')} onClick={() => onToggleKind('ApplicationSet')} />
|
|
981
|
+
</GitOpsFilterSection>
|
|
982
|
+
)}
|
|
983
|
+
|
|
943
984
|
<GitOpsFilterSection icon={CircleDot} title="Automation (Sync policy)">
|
|
944
985
|
<GitOpsFacetButton label="Auto-sync" count={automationCounts.auto} active={automationFilters.has('auto')} onClick={() => onToggleAutomation('auto')} />
|
|
945
986
|
<GitOpsFacetButton label="Manual" count={automationCounts.manual} active={automationFilters.has('manual')} onClick={() => onToggleAutomation('manual')} />
|
|
@@ -1327,7 +1368,7 @@ function GitOpsTable({
|
|
|
1327
1368
|
{showDestination && (
|
|
1328
1369
|
<TableCell>
|
|
1329
1370
|
<DestinationCell row={row} onDestinationClick={onDestinationClick} destinationHrefFor={destinationHrefFor} />
|
|
1330
|
-
<div className="truncate text-xs text-theme-text-tertiary">{row
|
|
1371
|
+
<div className="truncate text-xs text-theme-text-tertiary">{destinationNamespaceLabel(row) || '-'}</div>
|
|
1331
1372
|
</TableCell>
|
|
1332
1373
|
)}
|
|
1333
1374
|
<TableCell>
|
|
@@ -1370,7 +1411,13 @@ function buildRowActionItems(
|
|
|
1370
1411
|
const suspendedReason = 'Cannot sync while suspended. Resume first.'
|
|
1371
1412
|
const items: RowActionItem[] = []
|
|
1372
1413
|
|
|
1373
|
-
|
|
1414
|
+
// Gate on the kind, not the tool. Every action below posts to
|
|
1415
|
+
// /argo/applications/{namespace}/{name}, which resolves the name against the
|
|
1416
|
+
// Application GVR - so offering them on any other argoproj.io kind either
|
|
1417
|
+
// 404s or, when an Application happens to share the name, mutates that
|
|
1418
|
+
// unrelated Application instead. The detail page gates the same actions the
|
|
1419
|
+
// same way.
|
|
1420
|
+
if (row.kindName === 'applications') {
|
|
1374
1421
|
const operationInProgress = isArgoOperationInProgress(row.raw)
|
|
1375
1422
|
items.push({
|
|
1376
1423
|
key: 'sync',
|
|
@@ -1445,6 +1492,7 @@ function buildRowActionItems(
|
|
|
1445
1492
|
}
|
|
1446
1493
|
|
|
1447
1494
|
// Flux (Kustomization / HelmRelease)
|
|
1495
|
+
if (row.tool !== 'flux') return items
|
|
1448
1496
|
items.push({
|
|
1449
1497
|
key: 'reconcile',
|
|
1450
1498
|
label: 'Reconcile',
|
|
@@ -1521,7 +1569,7 @@ function GitOpsTile({
|
|
|
1521
1569
|
const lastSyncRaw = row.lastSync || row.createdAt
|
|
1522
1570
|
const recencyClass = recencyTone(lastSyncRaw)
|
|
1523
1571
|
const dest = row.destination ? compactClusterURL(row.destination) : ''
|
|
1524
|
-
const ns = row
|
|
1572
|
+
const ns = destinationNamespaceLabel(row)
|
|
1525
1573
|
const tileClass = clsx(
|
|
1526
1574
|
'group relative flex min-w-0 flex-col overflow-hidden rounded-md border border-theme-border bg-theme-surface text-left shadow-theme-sm transition-all hover:border-theme-text-tertiary/40 hover:shadow-theme-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-theme-text-primary/20',
|
|
1527
1575
|
row.terminating && 'opacity-80',
|
|
@@ -1732,6 +1780,25 @@ export function summarizeGitOpsRows(rows: GitOpsRow[]) {
|
|
|
1732
1780
|
)
|
|
1733
1781
|
}
|
|
1734
1782
|
|
|
1783
|
+
// hasSyncPolicy answers whether the Automation (sync policy) facet means
|
|
1784
|
+
// anything for a row. An ApplicationSet never syncs - it generates
|
|
1785
|
+
// Applications, and the policy on its template belongs to those. Counting it
|
|
1786
|
+
// as Manual would answer "what is not auto-syncing" with objects that were
|
|
1787
|
+
// never going to sync in the first place.
|
|
1788
|
+
// destinationNamespaceLabel resolves what to show under a row's destination.
|
|
1789
|
+
// Falling back to the row's own namespace suits a resource that lives beside
|
|
1790
|
+
// what it deploys. An ApplicationSet sits in the controller namespace and
|
|
1791
|
+
// generates Applications that land somewhere else, so that fallback would name
|
|
1792
|
+
// a namespace it never deploys to.
|
|
1793
|
+
export function destinationNamespaceLabel(row: GitOpsRow): string {
|
|
1794
|
+
if (row.destinationNamespace) return row.destinationNamespace
|
|
1795
|
+
return row.kind === 'ApplicationSet' ? '' : row.namespace
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
export function hasSyncPolicy(row: GitOpsRow): boolean {
|
|
1799
|
+
return row.kind !== 'ApplicationSet'
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1735
1802
|
// Natural direction per column, used the first time a column is clicked: the
|
|
1736
1803
|
// urgency-ordered facets default ascending (most-urgent first); recency defaults
|
|
1737
1804
|
// to newest-first.
|
|
@@ -1930,6 +1997,96 @@ export function normalizeArgoApplication(resource: any): GitOpsRow {
|
|
|
1930
1997
|
}
|
|
1931
1998
|
}
|
|
1932
1999
|
|
|
2000
|
+
// An ApplicationSet is a generator, not a deployed app — it has no sync/health
|
|
2001
|
+
// of its own in the sense the fleet table shows for Applications. Its own
|
|
2002
|
+
// status.conditions use a different vocabulary (ErrorOccurred,
|
|
2003
|
+
// ParametersGenerated, ResourcesUpToDate — never Ready), so the generic
|
|
2004
|
+
// getGitOpsResourceStatus fallback below correctly bottoms out at
|
|
2005
|
+
// Unknown/Unknown rather than us inventing a fleet-health rollup from
|
|
2006
|
+
// generator-run conditions that don't mean what a Synced/Healthy chip implies.
|
|
2007
|
+
// An ApplicationSet's template fields may carry generator placeholders
|
|
2008
|
+
// ({{path}}, {{cluster.name}}, …) that only resolve per generated Application.
|
|
2009
|
+
// Rendering the raw placeholder is worse than rendering nothing, so a value is
|
|
2010
|
+
// only surfaced when it is literal.
|
|
2011
|
+
function resolvedTemplateValue(value: unknown): string {
|
|
2012
|
+
if (typeof value !== 'string' || value.includes('{{')) return ''
|
|
2013
|
+
return value
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
export function normalizeArgoApplicationSet(resource: any): GitOpsRow {
|
|
2017
|
+
const status = getGitOpsResourceStatus('applicationsets', resource)
|
|
2018
|
+
const templateSpec = resource.spec?.template?.spec ?? {}
|
|
2019
|
+
// Destination is templated as often as the source is ({{server}},
|
|
2020
|
+
// {{cluster.name}}), and a cluster generator templates it by definition.
|
|
2021
|
+
const dest = resolvedTemplateValue(templateSpec.destination?.server)
|
|
2022
|
+
|| resolvedTemplateValue(templateSpec.destination?.name)
|
|
2023
|
+
// The source the generated Applications deploy from lives on the template.
|
|
2024
|
+
// A git generator's repo is a different thing - it is scanned to produce
|
|
2025
|
+
// parameters and is often not a deployment source at all - so it is only a
|
|
2026
|
+
// fallback for templates that carry no literal source of their own.
|
|
2027
|
+
const templateSource = templateSpec.source ?? {}
|
|
2028
|
+
const gitGenerator = (resource.spec?.generators ?? []).find((g: any) => g?.git)?.git
|
|
2029
|
+
// Repo, revision and path are taken from one place or the other, never
|
|
2030
|
+
// mixed. A template repo paired with a generator's path would name a tree
|
|
2031
|
+
// that exists in neither, which reads as a real location and is not one.
|
|
2032
|
+
//
|
|
2033
|
+
// The branch turns on whether the template declares a source at all, not on
|
|
2034
|
+
// whether that source resolved. A template whose repoURL is itself a
|
|
2035
|
+
// placeholder still means "the generated Applications deploy from here", so
|
|
2036
|
+
// falling back would present the generator's own tree as the deployed one.
|
|
2037
|
+
// Multi-source templates take the same branch and render blank, matching
|
|
2038
|
+
// what the rest of the view does with spec.sources.
|
|
2039
|
+
const fromTemplate = templateSpec.source != null || Array.isArray(templateSpec.sources)
|
|
2040
|
+
const repository = fromTemplate ? resolvedTemplateValue(templateSource.repoURL) : (gitGenerator?.repoURL ?? '')
|
|
2041
|
+
const targetRevision = fromTemplate
|
|
2042
|
+
? resolvedTemplateValue(templateSource.targetRevision)
|
|
2043
|
+
: (gitGenerator?.revision ?? '')
|
|
2044
|
+
const path = fromTemplate
|
|
2045
|
+
? resolvedTemplateValue(templateSource.path)
|
|
2046
|
+
: (resolvedTemplateValue(gitGenerator?.directories?.[0]?.path)
|
|
2047
|
+
|| resolvedTemplateValue(gitGenerator?.files?.[0]?.path))
|
|
2048
|
+
return {
|
|
2049
|
+
id: `argo/applicationsets/${resource.metadata?.namespace ?? ''}/${resource.metadata?.name ?? ''}`,
|
|
2050
|
+
mode: 'applications',
|
|
2051
|
+
tool: 'argo',
|
|
2052
|
+
kindName: 'applicationsets',
|
|
2053
|
+
kind: 'ApplicationSet',
|
|
2054
|
+
group: 'argoproj.io',
|
|
2055
|
+
name: resource.metadata?.name ?? '',
|
|
2056
|
+
namespace: resource.metadata?.namespace ?? '',
|
|
2057
|
+
// The project every generated Application lands in, when the template
|
|
2058
|
+
// names one literally. A templated project varies per generated app, so
|
|
2059
|
+
// it resolves to blank rather than to a placeholder.
|
|
2060
|
+
project: resolvedTemplateValue(templateSpec.project),
|
|
2061
|
+
labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
|
|
2062
|
+
sync: status?.sync ?? 'Unknown',
|
|
2063
|
+
health: status?.health ?? 'Unknown',
|
|
2064
|
+
reconciling: status?.reconciling,
|
|
2065
|
+
reconcilingSince: status?.reconcilingSince,
|
|
2066
|
+
suspended: false,
|
|
2067
|
+
repository,
|
|
2068
|
+
targetRevision,
|
|
2069
|
+
path,
|
|
2070
|
+
chart: fromTemplate ? resolvedTemplateValue(templateSource.chart) : '',
|
|
2071
|
+
destination: dest,
|
|
2072
|
+
destinationNamespace: resolvedTemplateValue(templateSpec.destination?.namespace),
|
|
2073
|
+
createdAt: resource.metadata?.creationTimestamp ?? '',
|
|
2074
|
+
// When the generator last ran, taken from the condition that reported it.
|
|
2075
|
+
// Left empty the Last Sync column falls back to the creation timestamp,
|
|
2076
|
+
// which dates the object rather than any activity on it.
|
|
2077
|
+
lastSync: status?.lastSyncTime ?? '',
|
|
2078
|
+
// An ApplicationSet has no sync policy of its own; the template's policy
|
|
2079
|
+
// belongs to the Applications it generates. Automation counts and the
|
|
2080
|
+
// sync-policy filter skip this kind rather than read this field.
|
|
2081
|
+
autoSync: false,
|
|
2082
|
+
terminating: isTerminating(resource),
|
|
2083
|
+
terminationStartedAt: terminationStartedAt(resource),
|
|
2084
|
+
raw: resource,
|
|
2085
|
+
_cluster: resource._cluster,
|
|
2086
|
+
_destination: resource._destination,
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
1933
2090
|
export function normalizeFluxKustomization(resource: any, fluxSourceUrls?: Map<string, string>): GitOpsRow {
|
|
1934
2091
|
const status = getGitOpsResourceStatus('kustomizations', resource)
|
|
1935
2092
|
const sourceName = resource.spec?.sourceRef?.name ?? ''
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { describe, test, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { destinationNamespaceLabel, hasSyncPolicy, normalizeArgoApplicationSet, normalizeArgoApplication } from './GitOpsTableView'
|
|
4
|
+
|
|
5
|
+
// Mirrors scripts/gitops-demo/05-argo-applicationset.yaml: a list generator,
|
|
6
|
+
// so nothing about the deployed source is discoverable from the generator.
|
|
7
|
+
const listGeneratorSet = {
|
|
8
|
+
metadata: { name: 'radar-demo-set', namespace: 'argocd', creationTimestamp: '2026-01-01T00:00:00Z' },
|
|
9
|
+
spec: {
|
|
10
|
+
generators: [{ list: { elements: [{ name: 'vanilla', path: 'guestbook' }] } }],
|
|
11
|
+
template: {
|
|
12
|
+
spec: {
|
|
13
|
+
project: 'radar-demo',
|
|
14
|
+
source: { repoURL: 'https://github.com/argoproj/argocd-example-apps', targetRevision: 'HEAD', path: '{{path}}' },
|
|
15
|
+
destination: { server: 'https://kubernetes.default.svc', namespace: 'demo-set' },
|
|
16
|
+
syncPolicy: { automated: { prune: true, selfHeal: true } },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
status: { conditions: [{ type: 'ResourcesUpToDate', status: 'True' }] },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('normalizeArgoApplicationSet source and project', () => {
|
|
24
|
+
// The source the generated Applications deploy from lives on the template.
|
|
25
|
+
// List, cluster and SCM generators carry no repo at all, so the template is
|
|
26
|
+
// the only place it can come from for those.
|
|
27
|
+
test('reads the repo from the template, not the generator', () => {
|
|
28
|
+
const row = normalizeArgoApplicationSet(listGeneratorSet)
|
|
29
|
+
expect(row.repository).toBe('https://github.com/argoproj/argocd-example-apps')
|
|
30
|
+
expect(row.targetRevision).toBe('HEAD')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// A templated value only resolves per generated Application, so the literal
|
|
34
|
+
// "{{path}}" would be noise in the Source column.
|
|
35
|
+
test('blanks a templated path rather than printing the placeholder', () => {
|
|
36
|
+
expect(normalizeArgoApplicationSet(listGeneratorSet).path).toBe('')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('reads a literal project from the template', () => {
|
|
40
|
+
expect(normalizeArgoApplicationSet(listGeneratorSet).project).toBe('radar-demo')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('blanks a templated project', () => {
|
|
44
|
+
const templated = structuredClone(listGeneratorSet)
|
|
45
|
+
templated.spec.template.spec.project = '{{cluster.name}}'
|
|
46
|
+
expect(normalizeArgoApplicationSet(templated).project).toBe('')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// A git generator's repo is scanned for parameters, not necessarily deployed
|
|
50
|
+
// from. It stays available for templates that carry no literal source.
|
|
51
|
+
test('falls back to the git generator when the template has no source', () => {
|
|
52
|
+
const gitGen: any = structuredClone(listGeneratorSet)
|
|
53
|
+
delete gitGen.spec.template.spec.source
|
|
54
|
+
gitGen.spec.generators = [{ git: { repoURL: 'https://github.com/org/infra', revision: 'main', directories: [{ path: 'apps/prod' }] } }]
|
|
55
|
+
const row = normalizeArgoApplicationSet(gitGen)
|
|
56
|
+
expect(row.repository).toBe('https://github.com/org/infra')
|
|
57
|
+
expect(row.targetRevision).toBe('main')
|
|
58
|
+
expect(row.path).toBe('apps/prod')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// A glob is how git-generator directories are normally written, and it is a
|
|
62
|
+
// real literal - not a per-app placeholder - so it survives.
|
|
63
|
+
test('keeps a directory glob from the git generator', () => {
|
|
64
|
+
const gitGen: any = structuredClone(listGeneratorSet)
|
|
65
|
+
delete gitGen.spec.template.spec.source
|
|
66
|
+
gitGen.spec.generators = [{ git: { repoURL: 'https://github.com/org/infra', directories: [{ path: 'apps/*' }] } }]
|
|
67
|
+
expect(normalizeArgoApplicationSet(gitGen).path).toBe('apps/*')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('a set with neither template source nor git generator stays blank', () => {
|
|
71
|
+
const bare: any = structuredClone(listGeneratorSet)
|
|
72
|
+
delete bare.spec.template.spec.source
|
|
73
|
+
const row = normalizeArgoApplicationSet(bare)
|
|
74
|
+
expect(row.repository).toBe('')
|
|
75
|
+
expect(row.path).toBe('')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('hasSyncPolicy', () => {
|
|
80
|
+
// The Automation facet answers "what is not auto-syncing". An ApplicationSet
|
|
81
|
+
// never syncs, so counting it as Manual puts objects in that answer that
|
|
82
|
+
// were never going to sync.
|
|
83
|
+
test('an ApplicationSet has no sync policy of its own', () => {
|
|
84
|
+
expect(hasSyncPolicy(normalizeArgoApplicationSet(listGeneratorSet))).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('an Application does', () => {
|
|
88
|
+
const app = normalizeArgoApplication({
|
|
89
|
+
metadata: { name: 'guestbook', namespace: 'argocd' },
|
|
90
|
+
spec: { project: 'default', source: {}, destination: {}, syncPolicy: { automated: {} } },
|
|
91
|
+
status: {},
|
|
92
|
+
})
|
|
93
|
+
expect(hasSyncPolicy(app)).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('normalizeArgoApplicationSet destination', () => {
|
|
98
|
+
// A cluster generator templates the destination by definition. Rendering
|
|
99
|
+
// "{{server}}" in the Destination column is the same class of noise as a
|
|
100
|
+
// templated path, and the table shows a dash for an empty destination.
|
|
101
|
+
test('blanks a templated destination server', () => {
|
|
102
|
+
const clusterGen = structuredClone(listGeneratorSet)
|
|
103
|
+
clusterGen.spec.template.spec.destination = { server: '{{server}}', namespace: 'demo-set' } as any
|
|
104
|
+
const row = normalizeArgoApplicationSet(clusterGen)
|
|
105
|
+
expect(row.destination).toBe('')
|
|
106
|
+
expect(row.destinationNamespace).toBe('demo-set')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('keeps a literal destination server', () => {
|
|
110
|
+
expect(normalizeArgoApplicationSet(listGeneratorSet).destination).toBe('https://kubernetes.default.svc')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('blanks a templated destination namespace', () => {
|
|
114
|
+
const templatedNs = structuredClone(listGeneratorSet)
|
|
115
|
+
templatedNs.spec.template.spec.destination = { server: 'https://kubernetes.default.svc', namespace: '{{ns}}' } as any
|
|
116
|
+
expect(normalizeArgoApplicationSet(templatedNs).destinationNamespace).toBe('')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('normalizeArgoApplicationSet last activity', () => {
|
|
121
|
+
// The Last Sync column falls back to the creation timestamp when this is
|
|
122
|
+
// empty, which would date the object rather than report activity on it.
|
|
123
|
+
test('carries the condition transition time, not the creation time', () => {
|
|
124
|
+
const withTime = structuredClone(listGeneratorSet)
|
|
125
|
+
withTime.metadata.creationTimestamp = '2026-01-01T00:00:00Z'
|
|
126
|
+
withTime.status.conditions = [
|
|
127
|
+
{ type: 'ResourcesUpToDate', status: 'True', lastTransitionTime: '2026-06-15T09:30:00Z' },
|
|
128
|
+
] as any
|
|
129
|
+
expect(normalizeArgoApplicationSet(withTime).lastSync).toBe('2026-06-15T09:30:00Z')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a failing generator reports when it failed', () => {
|
|
133
|
+
const failed = structuredClone(listGeneratorSet)
|
|
134
|
+
failed.status.conditions = [
|
|
135
|
+
{ type: 'ErrorOccurred', status: 'True', message: 'boom', lastTransitionTime: '2026-06-15T10:00:00Z' },
|
|
136
|
+
] as any
|
|
137
|
+
expect(normalizeArgoApplicationSet(failed).lastSync).toBe('2026-06-15T10:00:00Z')
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('source is taken from one place, never mixed', () => {
|
|
142
|
+
// A template repo paired with a git generator's path would name a tree that
|
|
143
|
+
// exists in neither the generated Application nor the generator.
|
|
144
|
+
test('a literal template repo does not borrow the generator path', () => {
|
|
145
|
+
const mixed: any = structuredClone(listGeneratorSet)
|
|
146
|
+
mixed.spec.template.spec.source = { repoURL: 'https://github.com/org/deploy', path: '{{path}}' }
|
|
147
|
+
mixed.spec.generators = [{ git: { repoURL: 'https://github.com/org/config', revision: 'main', directories: [{ path: 'apps/*' }] } }]
|
|
148
|
+
const row = normalizeArgoApplicationSet(mixed)
|
|
149
|
+
expect(row.repository).toBe('https://github.com/org/deploy')
|
|
150
|
+
expect(row.path).toBe('')
|
|
151
|
+
expect(row.targetRevision).toBe('')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('with no literal template repo the generator supplies all three', () => {
|
|
155
|
+
const gen: any = structuredClone(listGeneratorSet)
|
|
156
|
+
delete gen.spec.template.spec.source
|
|
157
|
+
gen.spec.generators = [{ git: { repoURL: 'https://github.com/org/config', revision: 'main', directories: [{ path: 'apps/*' }] } }]
|
|
158
|
+
const row = normalizeArgoApplicationSet(gen)
|
|
159
|
+
expect(row.repository).toBe('https://github.com/org/config')
|
|
160
|
+
expect(row.targetRevision).toBe('main')
|
|
161
|
+
expect(row.path).toBe('apps/*')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('destinationNamespaceLabel', () => {
|
|
166
|
+
// An ApplicationSet lives in the controller namespace and deploys elsewhere,
|
|
167
|
+
// so naming its own namespace as the destination would be a real-looking
|
|
168
|
+
// value that is simply wrong.
|
|
169
|
+
test('an ApplicationSet with a templated namespace resolves to nothing', () => {
|
|
170
|
+
const templatedNs: any = structuredClone(listGeneratorSet)
|
|
171
|
+
templatedNs.spec.template.spec.destination = { server: 'https://kubernetes.default.svc', namespace: '{{ns}}' }
|
|
172
|
+
expect(destinationNamespaceLabel(normalizeArgoApplicationSet(templatedNs))).toBe('')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('a literal namespace is used as-is', () => {
|
|
176
|
+
expect(destinationNamespaceLabel(normalizeArgoApplicationSet(listGeneratorSet))).toBe('demo-set')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('an Application still falls back to its own namespace', () => {
|
|
180
|
+
const app = normalizeArgoApplication({
|
|
181
|
+
metadata: { name: 'guestbook', namespace: 'argocd' },
|
|
182
|
+
spec: { project: 'default', source: {}, destination: {} },
|
|
183
|
+
status: {},
|
|
184
|
+
})
|
|
185
|
+
expect(destinationNamespaceLabel(app)).toBe('argocd')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('a declared template source claims the row even when unresolved', () => {
|
|
190
|
+
// A templated repoURL still means "the generated Applications deploy from
|
|
191
|
+
// here". Falling back would present the generator's own tree as the
|
|
192
|
+
// deployed one.
|
|
193
|
+
test('a fully templated template source does not fall back to the generator', () => {
|
|
194
|
+
const templatedRepo: any = structuredClone(listGeneratorSet)
|
|
195
|
+
templatedRepo.spec.template.spec.source = { repoURL: '{{url}}', targetRevision: '{{rev}}', path: '{{path}}' }
|
|
196
|
+
templatedRepo.spec.generators = [{ git: { repoURL: 'https://github.com/org/config', revision: 'main', directories: [{ path: 'apps/*' }] } }]
|
|
197
|
+
const row = normalizeArgoApplicationSet(templatedRepo)
|
|
198
|
+
expect(row.repository).toBe('')
|
|
199
|
+
expect(row.targetRevision).toBe('')
|
|
200
|
+
expect(row.path).toBe('')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// Multi-source is unrendered across this view, so a sources-only template
|
|
204
|
+
// reads blank rather than borrowing the generator.
|
|
205
|
+
test('a multi-source template does not fall back to the generator', () => {
|
|
206
|
+
const multi: any = structuredClone(listGeneratorSet)
|
|
207
|
+
delete multi.spec.template.spec.source
|
|
208
|
+
multi.spec.template.spec.sources = [{ repoURL: 'https://github.com/org/a', path: 'x' }]
|
|
209
|
+
multi.spec.generators = [{ git: { repoURL: 'https://github.com/org/config', directories: [{ path: 'apps/*' }] } }]
|
|
210
|
+
const row = normalizeArgoApplicationSet(multi)
|
|
211
|
+
expect(row.repository).toBe('')
|
|
212
|
+
expect(row.path).toBe('')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -3,6 +3,7 @@ import { describe, test, expect } from 'vitest'
|
|
|
3
3
|
import {
|
|
4
4
|
formatGitOpsDestination,
|
|
5
5
|
formatGitOpsSourceUrl,
|
|
6
|
+
getGitOpsResourceStatus,
|
|
6
7
|
getGitOpsTool,
|
|
7
8
|
parseArgoRollbackID,
|
|
8
9
|
} from './detail-helpers'
|
|
@@ -95,3 +96,56 @@ describe('getGitOpsTool', () => {
|
|
|
95
96
|
expect(getGitOpsTool('somecustomresource', 'unknown.io')).toBe('flux')
|
|
96
97
|
})
|
|
97
98
|
})
|
|
99
|
+
|
|
100
|
+
describe('getGitOpsResourceStatus - ApplicationSet', () => {
|
|
101
|
+
// An ApplicationSet reports through ErrorOccurred / ParametersGenerated /
|
|
102
|
+
// ResourcesUpToDate and never sets Ready. Routed to the Flux reader - which
|
|
103
|
+
// looks for Ready - every one of these cases collapses to Unknown, and a
|
|
104
|
+
// generator that is failing outright reads exactly like a healthy one.
|
|
105
|
+
const appSet = (conditions: Array<Record<string, string>>) => ({
|
|
106
|
+
status: { conditions },
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a failed generator is Degraded, not Unknown', () => {
|
|
110
|
+
const status = getGitOpsResourceStatus('applicationsets', appSet([
|
|
111
|
+
{ type: 'ErrorOccurred', status: 'True', message: 'error generating params from git' },
|
|
112
|
+
{ type: 'ResourcesUpToDate', status: 'False', reason: 'ErrorOccurred' },
|
|
113
|
+
]))
|
|
114
|
+
expect(status?.health).toBe('Degraded')
|
|
115
|
+
expect(status?.message).toBe('error generating params from git')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('a generator that produced its Applications is Healthy', () => {
|
|
119
|
+
const status = getGitOpsResourceStatus('applicationsets', appSet([
|
|
120
|
+
{ type: 'ResourcesUpToDate', status: 'True', reason: 'ApplicationSetUpToDate' },
|
|
121
|
+
{ type: 'ParametersGenerated', status: 'True', reason: 'ParametersGenerated' },
|
|
122
|
+
]))
|
|
123
|
+
expect(status?.health).toBe('Healthy')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('no conditions yet is Unknown', () => {
|
|
127
|
+
expect(getGitOpsResourceStatus('applicationsets', appSet([]))?.health).toBe('Unknown')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Sync answers "is the declared state applied". An ApplicationSet applies
|
|
131
|
+
// nothing - it generates Applications, each of which carries its own sync.
|
|
132
|
+
// Unknown is the honest answer in every case, including the healthy one.
|
|
133
|
+
test('sync stays Unknown regardless of health', () => {
|
|
134
|
+
expect(getGitOpsResourceStatus('applicationsets', appSet([
|
|
135
|
+
{ type: 'ResourcesUpToDate', status: 'True' },
|
|
136
|
+
]))?.sync).toBe('Unknown')
|
|
137
|
+
expect(getGitOpsResourceStatus('applicationsets', appSet([
|
|
138
|
+
{ type: 'ErrorOccurred', status: 'True', message: 'boom' },
|
|
139
|
+
]))?.sync).toBe('Unknown')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// ErrorOccurred=False is the steady state of a working generator; only
|
|
143
|
+
// status 'True' means a fault.
|
|
144
|
+
test('ErrorOccurred=False does not count as a fault', () => {
|
|
145
|
+
const status = getGitOpsResourceStatus('applicationsets', appSet([
|
|
146
|
+
{ type: 'ErrorOccurred', status: 'False' },
|
|
147
|
+
{ type: 'ResourcesUpToDate', status: 'True' },
|
|
148
|
+
]))
|
|
149
|
+
expect(status?.health).toBe('Healthy')
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { argoStatusToGitOpsStatus, fluxConditionsToGitOpsStatus, type FluxCondition, type GitOpsStatus } from '../../types/gitops'
|
|
1
|
+
import { argoApplicationSetConditionsToGitOpsStatus, argoStatusToGitOpsStatus, fluxConditionsToGitOpsStatus, type FluxCondition, type GitOpsStatus } from '../../types/gitops'
|
|
2
2
|
import { formatCompactAge } from '../../utils/format'
|
|
3
3
|
|
|
4
4
|
// =============================================================================
|
|
@@ -91,14 +91,24 @@ export function describeGitOpsTerminating(summary?: {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// getGitOpsResourceStatus dispatches sync/health extraction by
|
|
95
|
-
//
|
|
94
|
+
// getGitOpsResourceStatus dispatches sync/health extraction by kind. Argo
|
|
95
|
+
// Applications read from .status (rich operationState + sync state); Argo
|
|
96
|
+
// ApplicationSets read their own generator conditions; Flux kinds read
|
|
96
97
|
// .status.conditions + .spec.suspend. Returns null if the resource has
|
|
97
98
|
// no recognizable status (e.g. just-created, status not yet populated).
|
|
98
99
|
export function getGitOpsResourceStatus(kind: string, resource: any): GitOpsStatus | null {
|
|
99
100
|
if (kind === 'applications') {
|
|
100
101
|
return argoStatusToGitOpsStatus(resource?.status ?? {})
|
|
101
102
|
}
|
|
103
|
+
// ApplicationSets carry Argo's generator conditions, not Flux's. Without
|
|
104
|
+
// this they fall through to the Flux reader below, which looks for Ready /
|
|
105
|
+
// Reconciling / Stalled, finds none, and reports a failing generator as
|
|
106
|
+
// Unknown.
|
|
107
|
+
if (kind === 'applicationsets') {
|
|
108
|
+
return argoApplicationSetConditionsToGitOpsStatus(
|
|
109
|
+
(resource?.status?.conditions ?? []) as FluxCondition[]
|
|
110
|
+
)
|
|
111
|
+
}
|
|
102
112
|
const conditions = (resource?.status?.conditions ?? []) as FluxCondition[]
|
|
103
113
|
return fluxConditionsToGitOpsStatus(conditions, resource?.spec?.suspend === true, {
|
|
104
114
|
generation: resource?.metadata?.generation,
|
|
@@ -582,6 +582,13 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
582
582
|
// border lift telegraphs "this responds to clicks" without
|
|
583
583
|
// looking like a primary action.
|
|
584
584
|
node.role === 'group' && 'cursor-pointer hover:border-theme-text-tertiary/50 hover:bg-theme-hover',
|
|
585
|
+
// The root node is this page's own subject, so it deliberately
|
|
586
|
+
// skips the portal ring/tool-badge above (clicking it can't
|
|
587
|
+
// "dive into" anything — it's already the page you're on). It's
|
|
588
|
+
// still clickable — opens the standard resource drawer — so it
|
|
589
|
+
// gets its own quieter affordance: a hover ring, no ring at rest,
|
|
590
|
+
// so it doesn't visually compete with real portal children.
|
|
591
|
+
node.role === 'root' && !data.highlighted && 'cursor-pointer hover:ring-1 hover:ring-theme-text-tertiary/40',
|
|
585
592
|
)}
|
|
586
593
|
style={{ width: dim.width, minHeight: dim.height, borderLeftWidth: 4 }}
|
|
587
594
|
>
|