@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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
- auto: allRows.filter((row) => row.autoSync).length,
418
- manual: allRows.filter((row) => !row.autoSync).length,
419
- suspended: allRows.filter((row) => row.suspended).length,
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.destinationNamespace || row.namespace || '-'}</div>
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
- if (row.tool === 'argo') {
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.destinationNamespace || row.namespace
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 tool. Argo
95
- // reads from .status (rich operationState + sync state); Flux reads from
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,
@@ -15,6 +15,7 @@ export {
15
15
  shortClusterName,
16
16
  summarizeGitOpsRows,
17
17
  normalizeArgoApplication,
18
+ normalizeArgoApplicationSet,
18
19
  normalizeFluxKustomization,
19
20
  normalizeFluxHelmRelease,
20
21
  buildFluxSourceUrlMap,
@@ -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
  >