diff --git a/ui/src/app/app.tsx b/ui/src/app/app.tsx index 54434bc15e498..40306ee366383 100644 --- a/ui/src/app/app.tsx +++ b/ui/src/app/app.tsx @@ -32,6 +32,7 @@ type Routes = {[path: string]: {component: React.ComponentType ({ ...app, - filterResult: { - repos: pref.reposFilter.length === 0 || pref.reposFilter.includes(getAppDefaultSource(app).repoURL), - sync: pref.syncFilter.length === 0 || pref.syncFilter.includes(app.status.sync.status), - autosync: pref.autoSyncFilter.length === 0 || pref.autoSyncFilter.includes(getAutoSyncStatus(app.spec.syncPolicy)), - health: pref.healthFilter.length === 0 || pref.healthFilter.includes(app.status.health.status), - namespaces: pref.namespacesFilter.length === 0 || pref.namespacesFilter.some(ns => app.spec.destination.namespace && minimatch(app.spec.destination.namespace, ns)), - favourite: !pref.showFavorites || (pref.favoritesAppList && pref.favoritesAppList.includes(app.metadata.name)), - clusters: - pref.clustersFilter.length === 0 || - pref.clustersFilter.some(filterString => { - const match = filterString.match('^(.*) [(](http.*)[)]$'); - if (match?.length === 3) { - const [, name, url] = match; - return url === app.spec.destination.server || name === app.spec.destination.name; - } else { - const inputMatch = filterString.match('^http.*$'); - return (inputMatch && inputMatch[0] === app.spec.destination.server) || (app.spec.destination.name && minimatch(app.spec.destination.name, filterString)); - } - }), - labels: pref.labelsFilter.length === 0 || pref.labelsFilter.every(selector => LabelSelector.match(selector, app.metadata.labels)), - operation: pref.operationFilter.length === 0 || pref.operationFilter.includes(getOperationStateTitle(app)) - } + filterResult: isApp(app) + ? { + repos: (pref as AppsListPreferences).reposFilter.length === 0 || (pref as AppsListPreferences).reposFilter.includes(getAppDefaultSource(app as Application).repoURL), + sync: (pref as AppsListPreferences).syncFilter.length === 0 || (pref as AppsListPreferences).syncFilter.includes((app as Application).status.sync.status), + autosync: + (pref as AppsListPreferences).autoSyncFilter.length === 0 || + (pref as AppsListPreferences).autoSyncFilter.includes(getAutoSyncStatus((app as Application).spec.syncPolicy)), + health: pref.healthFilter.length === 0 || pref.healthFilter.includes((app as Application).status.health.status), + namespaces: + (pref as AppsListPreferences).namespacesFilter.length === 0 || + (pref as AppsListPreferences).namespacesFilter.some(ns => (app as Application).spec.destination.namespace && minimatch((app as Application).spec.destination.namespace, ns)), + favourite: !pref.showFavorites || (pref.favoritesAppList && pref.favoritesAppList.includes(app.metadata.name)), + clusters: + (pref as AppsListPreferences).clustersFilter.length === 0 || + (pref as AppsListPreferences).clustersFilter.some(filterString => { + const match = filterString.match('^(.*) [(](http.*)[)]$'); + if (match?.length === 3) { + const [, name, url] = match; + return url === (app as Application).spec.destination.server || name === (app as Application).spec.destination.name; + } else { + const inputMatch = filterString.match('^http.*$'); + return ( + (inputMatch && inputMatch[0] === (app as Application).spec.destination.server) || + ((app as Application).spec.destination.name && minimatch((app as Application).spec.destination.name, filterString)) + ); + } + }), + labels: pref.labelsFilter.length === 0 || pref.labelsFilter.every(selector => LabelSelector.match(selector, app.metadata.labels)), + operation: (pref as AppsListPreferences).operationFilter.length === 0 || (pref as AppsListPreferences).operationFilter.includes(getOperationStateTitle(app as Application)) + } + : { + health: pref.healthFilter.length === 0 || pref.healthFilter.includes(getAppSetHealthStatus(app as ApplicationSet)), + favourite: !pref.showFavorites || (pref.favoritesAppList && pref.favoritesAppList.includes(app.metadata.name)), + labels: pref.labelsFilter.length === 0 || pref.labelsFilter.every(selector => LabelSelector.match(selector, app.metadata.labels)) + } })); } @@ -80,12 +121,35 @@ const optionsFrom = (options: string[], filter: string[]) => { }); }; -interface AppFilterProps { +interface AbstractAppFilterProps { + apps: AbstractFilteredApp[]; + pref: AbstractAppsListPreferences; + onChange: (newPrefs: AbstractAppsListPreferences) => void; + children?: React.ReactNode; + collapsed?: boolean; +} + +interface AppFilterProps extends AbstractAppFilterProps { apps: FilteredApp[]; pref: AppsListPreferences; onChange: (newPrefs: AppsListPreferences) => void; - children?: React.ReactNode; - collapsed?: boolean; +} + +// ApplicationSetFilterProps is used for documentation purposes. Currently we use AbstractAppFilterProps directly. +// When more AppSet-specific filters are needed, this can be uncommented and used. + +export function isAppFilterProps( + abstractAppFilterProps: AbstractAppFilterProps, + ctx: ContextApis & { + history: History; + } +): abstractAppFilterProps is AppFilterProps { + // Check if we have apps and if the first one is an Application + if (abstractAppFilterProps.apps.length > 0) { + return isApp(abstractAppFilterProps.apps[0]); + } + // Fall back to checking the URL path + return ctx.history.location.pathname.includes('/applications') && !ctx.history.location.pathname.includes('/applicationsets'); } const getCounts = (apps: FilteredApp[], filterType: keyof FilterResult, filter: (app: Application) => string, init?: string[]) => { @@ -100,6 +164,18 @@ const getCounts = (apps: FilteredApp[], filterType: keyof FilterResult, filter: return map; }; +const getAppSetCounts = (apps: ApplicationSetFilteredApp[], filterType: keyof ApplicationSetFilterResult, filter: (app: ApplicationSet) => string, init?: string[]) => { + const map = new Map(); + if (init) { + init.forEach(key => map.set(key, 0)); + } + // filter out all apps that does not match other filters and ignore this filter result + apps.filter(app => filter(app) && Object.keys(app.filterResult).every((key: keyof ApplicationSetFilterResult) => key === filterType || app.filterResult[key])).forEach(app => + map.set(filter(app), (map.get(filter(app)) || 0) + 1) + ); + return map; +}; + const getOptions = (apps: FilteredApp[], filterType: keyof FilterResult, filter: (app: Application) => string, keys: string[], getIcon?: (k: string) => React.ReactNode) => { const counts = getCounts(apps, filterType, filter, keys); return keys.map(k => { @@ -111,6 +187,23 @@ const getOptions = (apps: FilteredApp[], filterType: keyof FilterResult, filter: }); }; +const getAppSetOptions = ( + apps: ApplicationSetFilteredApp[], + filterType: keyof ApplicationSetFilterResult, + filter: (app: ApplicationSet) => string, + keys: string[], + getIcon?: (k: string) => React.ReactNode +) => { + const counts = getAppSetCounts(apps, filterType, filter, keys); + return keys.map(k => { + return { + label: k, + icon: getIcon && getIcon(k), + count: counts.get(k) + }; + }); +}; + const SyncFilter = (props: AppFilterProps) => ( ( /> ); -const HealthFilter = (props: AppFilterProps) => ( - props.onChange({...props.pref, healthFilter: s})} - options={getOptions( - props.apps, - 'health', - app => app.status.health.status, - Object.keys(HealthStatuses), - s => ( - - ) - )} - /> -); +const HealthFilter = (props: AbstractAppFilterProps) => { + const ctx = React.useContext(Context); + const isApps = isAppFilterProps(props, ctx); -const LabelsFilter = (props: AppFilterProps) => { + return ( + props.onChange({...props.pref, healthFilter: s})} + options={ + isApps + ? getOptions( + props.apps as FilteredApp[], + 'health', + app => app.status.health.status, + Object.keys(HealthStatuses), + s => + ) + : getAppSetOptions( + props.apps as ApplicationSetFilteredApp[], + 'health', + app => getAppSetHealthStatus(app), + Object.keys(HealthStatuses), + s => + ) + } + /> + ); +}; + +const LabelsFilter = (props: AbstractAppFilterProps) => { const labels = new Map>(); - props.apps + (props.apps as AbstractFilteredApp[]) .filter(app => app.metadata && app.metadata.labels) .forEach(app => Object.keys(app.metadata.labels).forEach(label => { @@ -236,11 +342,11 @@ const NamespaceFilter = (props: AppFilterProps) => { ); }; -const FavoriteFilter = (props: AppFilterProps) => { +const FavoriteFilter = (props: AbstractAppFilterProps) => { const ctx = React.useContext(Context); const onChange = (val: boolean) => { ctx.navigation.goto('.', {showFavorites: val}, {replace: true}); - services.viewPreferences.updatePreferences({appList: {...props.pref, showFavorites: val}}); + services.viewPreferences.updatePreferences({appList: {...props.pref, showFavorites: val} as AppsListPreferences}); }; return (
( /> ); -export const ApplicationsFilter = (props: AppFilterProps) => { +export const ApplicationsFilter = (props: AbstractAppFilterProps) => { + const ctx = React.useContext(Context); + const isApps = isAppFilterProps(props, ctx); + return ( - + - + {isApps && } - + {isApps && } - - - - + {isApps && } + {isApps && } + {isApps && } + {isApps && } ); }; diff --git a/ui/src/app/applications/components/applications-list/applications-list.tsx b/ui/src/app/applications/components/applications-list/applications-list.tsx index 17a13119ce9df..eb530349f947f 100644 --- a/ui/src/app/applications/components/applications-list/applications-list.tsx +++ b/ui/src/app/applications/components/applications-list/applications-list.tsx @@ -9,12 +9,12 @@ import {bufferTime, delay, filter, map, mergeMap, repeat, retryWhen} from 'rxjs/ import {AddAuthToToolbar, ClusterCtx, DataLoader, EmptyState, Page, Paginate, Spinner} from '../../../shared/components'; import {AuthSettingsCtx, Consumer, Context, ContextApis} from '../../../shared/context'; import * as models from '../../../shared/models'; -import {AppsListViewKey, AppsListPreferences, AppsListViewType, HealthStatusBarPreferences, services} from '../../../shared/services'; +import {AppsListViewKey, AppsListPreferences, AbstractAppsListPreferences, AppsListViewType, HealthStatusBarPreferences, services} from '../../../shared/services'; import {ApplicationCreatePanel} from '../application-create-panel/application-create-panel'; import {ApplicationSyncPanel} from '../application-sync-panel/application-sync-panel'; import {ApplicationsSyncPanel} from '../applications-sync-panel/applications-sync-panel'; import * as AppUtils from '../utils'; -import {ApplicationsFilter, FilteredApp, getFilterResults} from './applications-filter'; +import {ApplicationsFilter, AbstractFilteredApp, getFilterResults} from './applications-filter'; import {ApplicationsStatusBar} from './applications-status-bar'; import {ApplicationsSummary} from './applications-summary'; import {ApplicationsTable} from './applications-table'; @@ -50,13 +50,38 @@ const APP_FIELDS = [ 'status.summary', 'status.resources' ]; -const APP_LIST_FIELDS = ['metadata.resourceVersion', ...APP_FIELDS.map(field => `items.${field}`)]; + +// ApplicationSet fields - simpler structure than Application +const APPSET_FIELDS = [ + 'metadata.name', + 'metadata.namespace', + 'metadata.annotations', + 'metadata.labels', + 'metadata.creationTimestamp', + 'metadata.deletionTimestamp', + 'spec', + 'status' +]; + +function getAppListFields(isApplication: boolean): string[] { + const fields = isApplication ? APP_FIELDS : APPSET_FIELDS; + return ['metadata.resourceVersion', ...fields.map(field => `items.${field}`)]; +} + const APP_WATCH_FIELDS = ['result.type', ...APP_FIELDS.map(field => `result.application.${field}`)]; function loadApplications(projects: string[], appNamespace: string, objectListKind: string): Observable { - return from(services.applications.list(projects, objectListKind, {appNamespace, fields: APP_LIST_FIELDS})).pipe( + const isApplication = objectListKind === 'application'; + + return from(services.applications.list(projects, objectListKind, {appNamespace, fields: getAppListFields(isApplication)})).pipe( mergeMap(applicationsList => { const applications = applicationsList.items; + + // Watch is only available for Applications, not ApplicationSets + if (!isApplication) { + return from([applications]); + } + return merge( from([applications]), services.applications @@ -168,18 +193,31 @@ const ViewPref = ({children}: {children: (pref: AppsListPreferences & {page: num ); }; -function filterApps(applications: models.Application[], pref: AppsListPreferences, search: string): {filteredApps: models.Application[]; filterResults: FilteredApp[]} { - applications = applications.map(app => { +function filterApps( + applications: models.AbstractApplication[], + pref: AppsListPreferences, + search: string, + isListOfApplications: boolean +): {filteredApps: models.AbstractApplication[]; filterResults: AbstractFilteredApp[]} { + const processedApps = applications.map(app => { let isAppOfAppsPattern = false; - for (const resource of app.status.resources) { - if (resource.kind === 'Application') { - isAppOfAppsPattern = true; - break; + if (isListOfApplications) { + const typedApp = app as models.Application; + if (typedApp.status?.resources) { + for (const resource of typedApp.status.resources) { + if (resource.kind === 'Application') { + isAppOfAppsPattern = true; + break; + } + } } + } else { + // ApplicationSets are always "app of apps" pattern since they manage applications + isAppOfAppsPattern = true; } return {...app, isAppOfAppsPattern}; }); - const filterResults = getFilterResults(applications, pref); + const filterResults = getFilterResults(processedApps, pref); return { filterResults, filteredApps: filterResults.filter( @@ -196,8 +234,8 @@ function tryJsonParse(input: string) { } } -const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Application[]}) => { - const {content, ctx, apps} = {...props}; +const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.AbstractApplication[]; isListOfApplications: boolean}) => { + const {content, ctx, apps, isListOfApplications} = {...props}; const searchBar = React.useRef(null); @@ -256,7 +294,7 @@ const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Appli }} style={{fontSize: '14px'}} className='argo-field' - placeholder='Search applications...' + placeholder={isListOfApplications ? 'Search applications...' : 'Search ApplicationSets...'} />
/
{content && ( @@ -287,19 +325,20 @@ const SearchBar = (props: {content: string; ctx: ContextApis; apps: models.Appli }; interface ApplicationsToolbarProps { - applications: models.Application[]; + applications: models.AbstractApplication[]; pref: AppsListPreferences & {page: number; search: string}; ctx: ContextApis; healthBarPrefs: HealthStatusBarPreferences; + isListOfApplications: boolean; } -const ApplicationsToolbar: React.FC = ({applications, pref, ctx, healthBarPrefs}) => { +const ApplicationsToolbar: React.FC = ({applications, pref, ctx, healthBarPrefs, isListOfApplications}) => { const {List, Summary, Tiles} = AppsListViewKey; const query = useQuery(); return ( - +
); @@ -401,8 +442,7 @@ export const ApplicationsList = (props: RouteComponentProps & {objectListKi const {List, Summary, Tiles} = AppsListViewKey; const objectListKind = props.objectListKind; - // isListOfApplications will be used when ApplicationSet routes are added - // const isListOfApplications = objectListKind === 'application'; + const isListOfApplications = objectListKind === 'application'; function refreshApp(appName: string, appNamespace: string) { // app refreshing might be done too quickly so that UI might miss it due to event batching @@ -418,32 +458,45 @@ export const ApplicationsList = (props: RouteComponentProps & {objectListKi services.applications.get(appName, appNamespace, objectListKind, 'normal'); } - function onFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences) { - services.viewPreferences.updatePreferences({appList: newPref}); - ctx.navigation.goto( - '.', - { - proj: newPref.projectsFilter.join(','), - sync: newPref.syncFilter.join(','), - autoSync: newPref.autoSyncFilter.join(','), - health: newPref.healthFilter.join(','), - namespace: newPref.namespacesFilter.join(','), - cluster: newPref.clustersFilter.join(','), - labels: newPref.labelsFilter.map(encodeURIComponent).join(','), - operation: newPref.operationFilter.join(',') - }, - {replace: true} - ); + function onFilterPrefChanged(ctx: ContextApis, newPref: AppsListPreferences | AbstractAppsListPreferences) { + services.viewPreferences.updatePreferences({appList: newPref as AppsListPreferences}); + if (isListOfApplications) { + const appPref = newPref as AppsListPreferences; + ctx.navigation.goto( + '.', + { + proj: appPref.projectsFilter.join(','), + sync: appPref.syncFilter.join(','), + autoSync: appPref.autoSyncFilter.join(','), + health: appPref.healthFilter.join(','), + namespace: appPref.namespacesFilter.join(','), + cluster: appPref.clustersFilter.join(','), + labels: appPref.labelsFilter.map(encodeURIComponent).join(','), + operation: appPref.operationFilter.join(',') + }, + {replace: true} + ); + } else { + ctx.navigation.goto( + '.', + { + health: newPref.healthFilter.join(','), + labels: newPref.labelsFilter.map(encodeURIComponent).join(',') + }, + {replace: true} + ); + } } function getPageTitle(view: string) { + const prefix = isListOfApplications ? 'Applications' : 'ApplicationSets'; switch (view) { case List: - return 'Applications List'; + return `${prefix} List`; case Tiles: - return 'Applications Tiles'; + return `${prefix} Tiles`; case Summary: - return 'Applications Summary'; + return `${prefix} Summary`; } return ''; } @@ -461,7 +514,14 @@ export const ApplicationsList = (props: RouteComponentProps & {objectListKi key={pref.view} title={getPageTitle(pref.view)} useTitleOnly={true} - toolbar={{breadcrumbs: [{title: 'Applications', path: '/applications'}]}} + toolbar={{ + breadcrumbs: [ + { + title: isListOfApplications ? 'Applications' : 'ApplicationSets', + path: isListOfApplications ? '/applications' : '/applicationsets' + } + ] + }} hideAuth={true}> & {objectListKi )}> - {(applications: models.Application[]) => { + {(applications: models.AbstractApplication[]) => { const healthBarPrefs = pref.statusBarView || ({} as HealthStatusBarPreferences); - const {filteredApps, filterResults} = filterApps(applications, pref, pref.search); + const {filteredApps, filterResults} = filterApps(applications, pref, pref.search, isListOfApplications); const handleCreatePanelClose = async () => { const outsideDiv = document.querySelector('.sliding-panel__outside'); const closeButton = document.querySelector('.sliding-panel__close'); @@ -493,41 +553,50 @@ export const ApplicationsList = (props: RouteComponentProps & {objectListKi , + tools: , actionMenu: { - items: [ - { - title: 'New App', - iconClassName: 'fa fa-plus', - qeId: 'applications-list-button-new-app', - action: () => ctx.navigation.goto('.', {new: '{}'}, {replace: true}) - }, - { - title: 'Sync Apps', - iconClassName: 'fa fa-sync', - action: () => ctx.navigation.goto('.', {syncApps: true}, {replace: true}) - }, - { - title: 'Refresh Apps', - iconClassName: 'fa fa-redo', - action: () => ctx.navigation.goto('.', {refreshApps: true}, {replace: true}) - } - ] + items: isListOfApplications + ? [ + { + title: 'New App', + iconClassName: 'fa fa-plus', + qeId: 'applications-list-button-new-app', + action: () => ctx.navigation.goto('.', {new: '{}'}, {replace: true}) + }, + { + title: 'Sync Apps', + iconClassName: 'fa fa-sync', + action: () => ctx.navigation.goto('.', {syncApps: true}, {replace: true}) + }, + { + title: 'Refresh Apps', + iconClassName: 'fa fa-redo', + action: () => ctx.navigation.goto('.', {refreshApps: true}, {replace: true}) + } + ] + : [] // No action menu for ApplicationSets yet } }} />
{applications.length === 0 && pref.projectsFilter?.length === 0 && (pref.labelsFilter || []).length === 0 ? ( - -

No applications available to you just yet

-
Create new application to start managing resources in your cluster
- -
+ isListOfApplications ? ( + +

No applications available to you just yet

+
Create new application to start managing resources in your cluster
+ +
+ ) : ( + +

No ApplicationSets available to you just yet

+
Create an ApplicationSet using kubectl or the CLI to manage multiple applications
+
+ ) ) : ( <> {ReactDOM.createPortal( @@ -544,7 +613,7 @@ export const ApplicationsList = (props: RouteComponentProps & {objectListKi sidebarTarget?.current )} - {(pref.view === 'summary' && ) || ( + {(pref.view === 'summary' && isListOfApplications && ) || ( 1 && } showHeader={healthBarPrefs.showHealthStatusBar} @@ -552,7 +621,7 @@ export const ApplicationsList = (props: RouteComponentProps & {objectListKi page={pref.page} emptyState={() => ( -

No matching applications found

+

No matching {isListOfApplications ? 'applications' : 'ApplicationSets'} found

Change filter criteria or  & {objectListKi
)} - sortOptions={[ - { - title: 'Name', - compare: (a, b) => a.metadata.name.localeCompare(b.metadata.name, undefined, {numeric: true}) - }, - { - title: 'Created At', - compare: (b, a) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp) - }, - { - title: 'Synchronized', - compare: (b, a) => - a.status.operationState?.finishedAt?.localeCompare(b.status.operationState?.finishedAt) - } - ]} + sortOptions={ + isListOfApplications + ? [ + { + title: 'Name', + compare: (a, b) => a.metadata.name.localeCompare(b.metadata.name, undefined, {numeric: true}) + }, + { + title: 'Created At', + compare: (b, a) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp) + }, + { + title: 'Synchronized', + compare: (b, a) => + (a as models.Application).status.operationState?.finishedAt?.localeCompare( + (b as models.Application).status.operationState?.finishedAt + ) + } + ] + : [ + { + title: 'Name', + compare: (a, b) => a.metadata.name.localeCompare(b.metadata.name, undefined, {numeric: true}) + }, + { + title: 'Created At', + compare: (b, a) => a.metadata.creationTimestamp.localeCompare(b.metadata.creationTimestamp) + } + ] + } data={filteredApps} onPageChange={page => ctx.navigation.goto('.', {page})}> {data => @@ -611,18 +695,22 @@ export const ApplicationsList = (props: RouteComponentProps & {objectListKi )} )} - ctx.navigation.goto('.', {syncApps: null}, {replace: true})} - apps={filteredApps} - /> - ctx.navigation.goto('.', {refreshApps: null}, {replace: true})} - apps={filteredApps} - /> + {isListOfApplications && ( + ctx.navigation.goto('.', {syncApps: null}, {replace: true})} + apps={filteredApps as models.Application[]} + /> + )} + {isListOfApplications && ( + ctx.navigation.goto('.', {refreshApps: null}, {replace: true})} + apps={filteredApps as models.Application[]} + /> + )}
diff --git a/ui/src/app/applications/components/applications-list/applications-status-bar.tsx b/ui/src/app/applications/components/applications-list/applications-status-bar.tsx index d1b4f8a460a14..11621fabaa709 100644 --- a/ui/src/app/applications/components/applications-list/applications-status-bar.tsx +++ b/ui/src/app/applications/components/applications-list/applications-status-bar.tsx @@ -3,46 +3,77 @@ import * as React from 'react'; import {COLORS} from '../../../shared/components'; import {Consumer} from '../../../shared/context'; import * as models from '../../../shared/models'; +import {getAppSetHealthStatus, isApp} from '../utils'; import './applications-status-bar.scss'; export interface ApplicationsStatusBarProps { - applications: models.Application[]; + applications: models.AbstractApplication[]; } export const ApplicationsStatusBar = ({applications}: ApplicationsStatusBarProps) => { - const readings = [ - { - name: 'Healthy', - value: applications.filter(app => app.status.health.status === 'Healthy').length, - color: COLORS.health.healthy - }, - { - name: 'Progressing', - value: applications.filter(app => app.status.health.status === 'Progressing').length, - color: COLORS.health.progressing - }, - { - name: 'Degraded', - value: applications.filter(app => app.status.health.status === 'Degraded').length, - color: COLORS.health.degraded - }, - { - name: 'Suspended', - value: applications.filter(app => app.status.health.status === 'Suspended').length, - color: COLORS.health.suspended - }, - { - name: 'Missing', - value: applications.filter(app => app.status.health.status === 'Missing').length, - color: COLORS.health.missing - }, - { - name: 'Unknown', - value: applications.filter(app => app.status.health.status === 'Unknown').length, - color: COLORS.health.unknown - } - ]; + if (!applications || applications.length === 0) { + return null; + } + + const isApplicationList = isApp(applications[0]); + + const readings = isApplicationList + ? [ + { + name: 'Healthy', + value: applications.filter(app => (app as models.Application).status.health.status === 'Healthy').length, + color: COLORS.health.healthy + }, + { + name: 'Progressing', + value: applications.filter(app => (app as models.Application).status.health.status === 'Progressing').length, + color: COLORS.health.progressing + }, + { + name: 'Degraded', + value: applications.filter(app => (app as models.Application).status.health.status === 'Degraded').length, + color: COLORS.health.degraded + }, + { + name: 'Suspended', + value: applications.filter(app => (app as models.Application).status.health.status === 'Suspended').length, + color: COLORS.health.suspended + }, + { + name: 'Missing', + value: applications.filter(app => (app as models.Application).status.health.status === 'Missing').length, + color: COLORS.health.missing + }, + { + name: 'Unknown', + value: applications.filter(app => (app as models.Application).status.health.status === 'Unknown').length, + color: COLORS.health.unknown + } + ] + : [ + // ApplicationSet health derived from conditions + { + name: 'Healthy', + value: applications.filter(app => getAppSetHealthStatus(app as models.ApplicationSet) === 'Healthy').length, + color: COLORS.health.healthy + }, + { + name: 'Progressing', + value: applications.filter(app => getAppSetHealthStatus(app as models.ApplicationSet) === 'Progressing').length, + color: COLORS.health.progressing + }, + { + name: 'Degraded', + value: applications.filter(app => getAppSetHealthStatus(app as models.ApplicationSet) === 'Degraded').length, + color: COLORS.health.degraded + }, + { + name: 'Unknown', + value: applications.filter(app => getAppSetHealthStatus(app as models.ApplicationSet) === 'Unknown').length, + color: COLORS.health.unknown + } + ]; // will sort readings by value greatest to lowest, then by name readings.sort((a, b) => (a.value < b.value ? 1 : a.value === b.value ? (a.name > b.name ? 1 : -1) : -1)); diff --git a/ui/src/app/applications/components/applications-list/applications-table.tsx b/ui/src/app/applications/components/applications-list/applications-table.tsx index 1d1b0ac420ced..f674066cb6891 100644 --- a/ui/src/app/applications/components/applications-list/applications-table.tsx +++ b/ui/src/app/applications/components/applications-list/applications-table.tsx @@ -7,14 +7,14 @@ import {Consumer, Context} from '../../../shared/context'; import * as models from '../../../shared/models'; import {ApplicationURLs} from '../application-urls'; import * as AppUtils from '../utils'; -import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils'; +import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL, isApp, getAppSetHealthStatus} from '../utils'; import {ApplicationsLabels} from './applications-labels'; import {ApplicationsSource} from './applications-source'; import {services} from '../../../shared/services'; import './applications-table.scss'; export const ApplicationsTable = (props: { - applications: models.Application[]; + applications: models.AbstractApplication[]; syncApplication: (appName: string, appNamespace: string) => any; refreshApplication: (appName: string, appNamespace: string) => any; deleteApplication: (appName: string, appNamespace: string) => any; @@ -53,13 +53,17 @@ export const ApplicationsTable = (props: { return (
{props.applications.map((app, i) => { + const isApplication = isApp(app); + const typedApp = isApplication ? (app as models.Application) : null; + const typedAppSet = !isApplication ? (app as models.ApplicationSet) : null; + const healthStatus = isApplication ? typedApp.status.health.status : getAppSetHealthStatus(typedAppSet); return (
+ applications-list__entry applications-list__entry--health-${healthStatus} ${selectedApp === i ? 'applications-tiles__selected' : ''}`}>
ctx.navigation.goto(`/${AppUtils.getAppUrl(app)}`, {}, {event: e})}>
@@ -84,11 +88,11 @@ export const ApplicationsTable = (props: { /> - + {isApplication && }
-
Project:
-
{app.spec.project}
+
{isApplication ? 'Project:' : 'Kind:'}
+
{isApplication ? typedApp.spec.project : 'ApplicationSet'}
@@ -129,60 +133,73 @@ export const ApplicationsTable = (props: {
-
-
-
Source:
-
-
- -
-
- + {isApplication && typedApp && ( +
+
+
Source:
+
+
+ +
+
+ +
-
-
-
Destination:
-
- /{app.spec.destination.namespace} +
+
Destination:
+
+ /{typedApp.spec.destination.namespace} +
-
+ )} -
- {app.status.health.status}
- {app.status.sourceHydrator?.currentOperation && ( +
+ {isApplication && typedApp && ( <> - {' '} - {app.status.sourceHydrator.currentOperation.phase}
+ {typedApp.status.health.status}
+ {typedApp.status.sourceHydrator?.currentOperation && ( + <> + {' '} + {typedApp.status.sourceHydrator.currentOperation.phase}
+ + )} + + {typedApp.status.sync.status} )} - - {app.status.sync.status} - ( - - )} - items={[ - { - title: 'Sync', - iconClassName: 'fa fa-fw fa-sync', - action: () => props.syncApplication(app.metadata.name, app.metadata.namespace) - }, - { - title: 'Refresh', - iconClassName: 'fa fa-fw fa-redo', - action: () => props.refreshApplication(app.metadata.name, app.metadata.namespace) - }, - { - title: 'Delete', - iconClassName: 'fa fa-fw fa-times-circle', - action: () => props.deleteApplication(app.metadata.name, app.metadata.namespace) - } - ]} - /> + {!isApplication && typedAppSet && ( + <> + {getAppSetHealthStatus(typedAppSet)} + + )} + {isApplication && ( + ( + + )} + items={[ + { + title: 'Sync', + iconClassName: 'fa fa-fw fa-sync', + action: () => props.syncApplication(app.metadata.name, app.metadata.namespace) + }, + { + title: 'Refresh', + iconClassName: 'fa fa-fw fa-redo', + action: () => props.refreshApplication(app.metadata.name, app.metadata.namespace) + }, + { + title: 'Delete', + iconClassName: 'fa fa-fw fa-times-circle', + action: () => props.deleteApplication(app.metadata.name, app.metadata.namespace) + } + ]} + /> + )}
diff --git a/ui/src/app/applications/components/applications-list/applications-tiles.tsx b/ui/src/app/applications/components/applications-list/applications-tiles.tsx index f0a45e6537b34..7d61e698ed155 100644 --- a/ui/src/app/applications/components/applications-list/applications-tiles.tsx +++ b/ui/src/app/applications/components/applications-list/applications-tiles.tsx @@ -7,13 +7,13 @@ import {Consumer, Context, AuthSettingsCtx} from '../../../shared/context'; import * as models from '../../../shared/models'; import {ApplicationURLs} from '../application-urls'; import * as AppUtils from '../utils'; -import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL} from '../utils'; +import {getAppDefaultSource, OperationState, getApplicationLinkURL, getManagedByURL, isApp, getAppSetHealthStatus} from '../utils'; import {services} from '../../../shared/services'; import './applications-tiles.scss'; export interface ApplicationTilesProps { - applications: models.Application[]; + applications: models.AbstractApplication[]; syncApplication: (appName: string, appNamespace: string) => any; refreshApplication: (appName: string, appNamespace: string) => any; deleteApplication: (appName: string, appNamespace: string) => any; @@ -98,6 +98,7 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat return navApp(NumKeyToNumber(n)); } }); + return ( {ctx => ( @@ -107,15 +108,19 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat return (
{applications.map((app, i) => { - const source = getAppDefaultSource(app); + const isApplication = isApp(app); + const typedApp = isApplication ? (app as models.Application) : null; + const typedAppSet = !isApplication ? (app as models.ApplicationSet) : null; + const source = isApplication ? getAppDefaultSource(typedApp) : null; const isOci = source?.repoURL?.startsWith('oci://'); const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown'; const linkInfo = getApplicationLinkURL(app, ctx.baseHref); + const healthStatus = isApplication ? typedApp.status.health.status : getAppSetHealthStatus(typedAppSet); return (
-
0 ? 'columns small-10' : 'columns small-11'}> - - - - {AppUtils.appQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)} - - -
-
0 ? 'columns small-2' : 'columns small-1'}> + {isApplication && ( +
0 ? 'columns small-10' : 'columns small-11'}> + + + + {AppUtils.appQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)} + + +
+ )} + {!isApplication && ( +
+ + + + {AppUtils.appQualifiedName(app, useAuthSettingsCtx?.appsInAnyNamespaceEnabled)} + + +
+ )} +
0 ? 'columns small-2' : 'columns small-1'}>
- + {isApplication && }
-
-
- Project: + {isApplication && ( +
+
+ Project: +
+
{typedApp.spec.project}
-
{app.spec.project}
-
+ )}
Labels: @@ -212,121 +231,143 @@ export const ApplicationTiles = ({applications, syncApplication, refreshApplicat Status:
- {app.status.health.status} -   - {app.status.sourceHydrator?.currentOperation && ( + {isApplication ? ( <> - {' '} - {app.status.sourceHydrator.currentOperation.phase} + {typedApp.status.health.status} +   + {typedApp.status.sourceHydrator?.currentOperation && ( + <> + {' '} + {typedApp.status.sourceHydrator.currentOperation.phase} +   + + )} + {typedApp.status.sync.status}   + + + ) : ( + <> + {healthStatus} )} - {app.status.sync.status} -   - -
-
-
-
- Repository: -
-
- - {source?.repoURL} -
-
-
- Target Revision: -
-
{targetRevision}
-
- {source?.path && ( -
-
- Path: + {isApplication && ( + <> +
+
+ Repository: +
+
+ + {source?.repoURL} + +
-
{source?.path}
-
+
+
+ Target Revision: +
+
{targetRevision}
+
+ {source?.path && ( +
+
+ Path: +
+
{source?.path}
+
+ )} + {source?.chart && ( +
+
+ Chart: +
+
{source?.chart}
+
+ )} +
+
+ Destination: +
+
+ +
+
+
+
+ Namespace: +
+
{typedApp.spec.destination.namespace}
+
+ )} - {source?.chart && ( + {!isApplication && typedAppSet && (
-
- Chart: +
+ Applications:
-
{source?.chart}
+
{typedAppSet.status?.applicationStatus?.length || 0}
)} -
-
- Destination: -
-
- -
-
-
-
- Namespace: -
-
{app.spec.destination.namespace}
-
Created At:
{AppUtils.formatCreationTimestamp(app.metadata.creationTimestamp)}
- {app.status.operationState && ( + {isApplication && typedApp.status.operationState && (
Last Sync:
- {AppUtils.formatCreationTimestamp(app.status.operationState.finishedAt || app.status.operationState.startedAt)} + {AppUtils.formatCreationTimestamp(typedApp.status.operationState.finishedAt || typedApp.status.operationState.startedAt)}
)} -
diff --git a/ui/src/app/applications/components/utils.tsx b/ui/src/app/applications/components/utils.tsx index 719aab35409c0..008e93bd1f391 100644 --- a/ui/src/app/applications/components/utils.tsx +++ b/ui/src/app/applications/components/utils.tsx @@ -1722,6 +1722,35 @@ export function getRootPathByApp(abstractApp: appModels.AbstractApplication) { return isApp(abstractApp) ? '/applications' : '/applicationsets'; } +// Get ApplicationSet health status from its conditions +// Priority: ErrorOccurred=True → Degraded, RolloutProgressing=True → Progressing, ResourcesUpToDate=True → Healthy, else Unknown +export function getAppSetHealthStatus(appSet: appModels.ApplicationSet): appModels.HealthStatusCode { + const conditions = appSet.status?.conditions; + if (!conditions || conditions.length === 0) { + return 'Unknown'; + } + + // Check for errors first (indicates degraded state) + const errorCondition = conditions.find(c => c.type === 'ErrorOccurred' && c.status === 'True'); + if (errorCondition) { + return 'Degraded'; + } + + // Check if rollout is progressing + const progressingCondition = conditions.find(c => c.type === 'RolloutProgressing' && c.status === 'True'); + if (progressingCondition) { + return 'Progressing'; + } + + // Check if resources are up to date (healthy state) + const upToDateCondition = conditions.find(c => c.type === 'ResourcesUpToDate' && c.status === 'True'); + if (upToDateCondition) { + return 'Healthy'; + } + + return 'Unknown'; +} + export function appQualifiedName(app: appModels.AbstractApplication, nsEnabled: boolean): string { return (nsEnabled ? app.metadata.namespace + '/' : '') + app.metadata.name; } diff --git a/ui/src/app/shared/services/applications-service.ts b/ui/src/app/shared/services/applications-service.ts index 7cb3ebceb3e21..8cf89d48de3a3 100644 --- a/ui/src/app/shared/services/applications-service.ts +++ b/ui/src/app/shared/services/applications-service.ts @@ -14,11 +14,18 @@ interface QueryOptions { appNamespace?: string; } -function optionsToSearch(options?: QueryOptions) { +function optionsToSearch(options?: QueryOptions): {fields?: string; selector: string; appNamespace: string} { if (options) { - return {fields: (options.exclude ? '-' : '') + options.fields.join(','), selector: options.selector || '', appNamespace: options.appNamespace || ''}; + const result: {fields?: string; selector: string; appNamespace: string} = { + selector: options.selector || '', + appNamespace: options.appNamespace || '' + }; + if (options.fields) { + result.fields = (options.exclude ? '-' : '') + options.fields.join(','); + } + return result; } - return {}; + return {selector: '', appNamespace: ''}; } function getQuery(projects: string[], isListOfApplications: boolean, options?: QueryOptions): any {